9 コミット da1848f810 ... 64db9860a8

作者 SHA1 メッセージ 日付
  RomanL 64db9860a8 Neo lint typecheck (#5944) 5 時間 前
  RomanL f9d403ec3f Neo: let React Compiler compile composers, feed, browse-query and more (#5945) 6 時間 前
  Andrey Yasko a28de01fe4 RC3 update packs: Glossary pack carries the module's db_prefix (#5942) 6 時間 前
  dependabot[bot] f7c04e3492 Bump @ag-ui/core from 1.0.2 to 1.0.3 (#5939) 7 時間 前
  dependabot[bot] 07e6eb972b Bump lucide from 1.57.0 to 1.58.0 (#5938) 7 時間 前
  Andrey Yasko 5ac43ff822 Neo: manifest icons load from /static/, Vercel scripts only on Vercel (#5934) 7 時間 前
  Aleksandr Trofimov 9259d5b596 Upgrade/15.0.0 rc2 rc3 (#5941) 7 時間 前
  Andrey Yasko 8f84074c12 CI: PR previews on Railway's own domains, no certificates per PR (#5936) 8 時間 前
  Andrey Yasko 9d92027ee8 Neo: footer theme and sounds switchers show only their icon (#5935) 9 時間 前
100 ファイル変更、37752 行追加、172 行削除
  1. 97 34
      .github/workflows/ci.yml
  2. 2 2
      AGENTS.md
  3. 1 1
      modules/boonex/glossary/updates/15.0.2_15.0.3/install/config.php
  4. 1 1
      neo/AGENTS.md
  5. 1 1
      neo/apps/next/app/layout.js
  6. 4 3
      neo/apps/next/app/providers.js
  7. 22 0
      neo/apps/next/next.config.js
  8. 1 1
      neo/apps/next/public/manifest.json
  9. 0 6
      neo/apps/next/tsconfig.json
  10. 12 13
      neo/packages/app/components/elements/feed-item-parts.js
  11. 25 17
      neo/packages/app/components/forms/comments.js
  12. 20 11
      neo/packages/app/components/forms/messenger.js
  13. 8 6
      neo/packages/app/components/nav/menu-footer.js
  14. 0 9
      neo/packages/app/components/nav/tabs/expoui-tabs.js
  15. 0 4
      neo/packages/app/context/emitter.ts
  16. 3 0
      neo/packages/app/default/translations/de.json
  17. 3 0
      neo/packages/app/default/translations/en.json
  18. 3 0
      neo/packages/app/default/translations/es.json
  19. 3 0
      neo/packages/app/default/translations/fr.json
  20. 3 0
      neo/packages/app/default/translations/ru.json
  21. 22 10
      neo/packages/app/lib/browse-query/index.ts
  22. 16 11
      neo/packages/app/lib/hooks/use-pulse-once.ts
  23. 2 2
      neo/packages/app/ui/atoms/dropdown-menu.tsx
  24. 37 34
      neo/packages/app/ui/molecules/content/gallery.js
  25. 97 0
      neo/scripts/check-new-errors.js
  26. 0 2
      neo/scripts/typecheck.js
  27. 2 2
      package.json
  28. 1 1
      scripts/railway/seed.php
  29. 6 1
      tests/units/install/ModuleUpdatePacksTest.php
  30. 19 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/app.sql
  31. 9 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/check.php
  32. 50 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/.htaccess
  33. 52 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/image_transcoder.php
  34. 287 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIModelFactory.php
  35. 56 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIProviderUnaProxy.php
  36. 1111 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolAgentCreate.php
  37. 67 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolChatButtons.php
  38. 81 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolChatTitle.php
  39. 83 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolFactory.php
  40. 280 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolMockup.php
  41. 199 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolMysqlUndo.php
  42. 464 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolMysqlWrite.php
  43. 409 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolPromptFeedback.php
  44. 67 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIUnaProxyHttpClient.php
  45. 138 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIVectorStoreFactory.php
  46. 152 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIVectorStoreMariaDb.php
  47. 97 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIVectorStoreMariaDbStore.php
  48. 220 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAi.php
  49. 489 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiActivity.php
  50. 40 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiActivityObserver.php
  51. 178 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiAgent.php
  52. 138 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiAgentTools.php
  53. 752 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiChat.php
  54. 353 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiChatImages.php
  55. 556 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiChatUi.php
  56. 238 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiJudge.php
  57. 132 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiJudgeTypeSafe.php
  58. 762 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiQuery.php
  59. 20 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiRetrievalNone.php
  60. 110 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiRetrievalUnaProxy.php
  61. 863 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiSearchExtParser.php
  62. 465 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiSearchParser.php
  63. 283 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiSearchRerank.php
  64. 213 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiTrigger.php
  65. 764 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiTriggerChat.php
  66. 303 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiTriggerMessage.php
  67. 161 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolCacheFile.php
  68. 2694 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolCmts.php
  69. 1299 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolConnection.php
  70. 1505 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolDb.php
  71. 373 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolFavorite.php
  72. 263 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolFeature.php
  73. 317 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolImageResize.php
  74. 176 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolLogs.php
  75. 1288 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolMetatags.php
  76. 467 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolObject.php
  77. 274 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolPaginate.php
  78. 1010 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolPrivacy.php
  79. 856 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolProfile.php
  80. 500 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolScore.php
  81. 1543 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolSearch.php
  82. 284 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolSearchExtended.php
  83. 96 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolSounds.php
  84. 1656 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolStorage.php
  85. 179 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolStorageLocal.php
  86. 4001 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolTemplate.php
  87. 1016 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolTranscoder.php
  88. 435 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVote.php
  89. 83 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVoteLikes.php
  90. 642 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVoteReactions.php
  91. 226 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVoteReactionsQuery.php
  92. 1009 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolCmts.js
  93. 153 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolConnection.js
  94. 212 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolFavorite.js
  95. 605 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolGrid.js
  96. 578 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolMenuMoreAuto.js
  97. 147 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolVote.js
  98. 327 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolVoteReactions.js
  99. 123 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolVoteStars.js
  100. 2362 0
      upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/functions.js

+ 97 - 34
.github/workflows/ci.yml

@@ -141,6 +141,85 @@ jobs:
         path: packages/*.zip
         if-no-files-found: error
 
+  neo-checks:
+    # The jobs above don't look at neo/. `yarn lint` and `yarn typecheck` aren't clean on master
+    # yet, so this fails only on errors the PR adds: both run on the PR and on its base, and
+    # neo/scripts/check-new-errors.js compares them per file and rule (line numbers ignored).
+    name: NEO lint and typecheck
+    if: github.event_name == 'pull_request' && github.event.action != 'closed'
+    runs-on: ubuntu-latest
+    env:
+      BASE_REF: ${{ github.event.pull_request.base.ref }}
+    steps:
+    - uses: actions/checkout@v4
+      with:
+        fetch-depth: 0
+
+    - name: Check for NEO changes
+      id: changes
+      run: |
+        if git diff --quiet "origin/$BASE_REF...HEAD" -- neo/; then
+          echo "neo=false" >> "$GITHUB_OUTPUT"
+          echo "No changes under neo/, nothing to check."
+        else
+          echo "neo=true" >> "$GITHUB_OUTPUT"
+        fi
+
+    - name: Setup Node
+      if: steps.changes.outputs.neo == 'true'
+      uses: actions/setup-node@v4
+      with:
+        node-version: '24'
+
+    - name: Install NEO dependencies
+      if: steps.changes.outputs.neo == 'true'
+      working-directory: neo
+      # yarn.lock isn't committed (see neo/AGENTS.md), so this resolves the package.json ranges.
+      # The npm registry now and then answers 502 for one tarball; retry instead of failing the PR.
+      run: |
+        # --ignore-scripts keeps dependencies' install scripts from running in CI. NEO's own
+        # postinstall is only patch-package, so run that one explicitly: the result is the
+        # same lint and typecheck output as a plain install.
+        for attempt in 1 2 3; do
+          if yarn install --ignore-scripts --non-interactive --network-timeout 600000; then
+            yarn patch-package
+            exit 0
+          fi
+          echo "yarn install failed (attempt $attempt of 3)"
+          sleep 15
+        done
+        exit 1
+
+    - name: Lint and typecheck the PR
+      if: steps.changes.outputs.neo == 'true'
+      working-directory: neo
+      run: |
+        yarn --silent lint -f json -o "$RUNNER_TEMP/lint-head.json" || true
+        node scripts/typecheck.js > "$RUNNER_TEMP/typecheck-head.txt" 2>&1 || true
+
+    - name: Lint and typecheck the base branch
+      if: steps.changes.outputs.neo == 'true'
+      run: |
+        git worktree add "$RUNNER_TEMP/base" "origin/$BASE_REF"
+        cd "$RUNNER_TEMP/base/neo"
+        # Reuse the PR's install instead of a second `yarn install`.
+        ln -s "$GITHUB_WORKSPACE/neo/node_modules" node_modules
+        for app in apps/next apps/expo; do
+          if [ -d "$GITHUB_WORKSPACE/neo/$app/node_modules" ]; then
+            ln -s "$GITHUB_WORKSPACE/neo/$app/node_modules" "$app/node_modules"
+          fi
+        done
+        yarn --silent lint -f json -o "$RUNNER_TEMP/lint-base.json" || true
+        node scripts/typecheck.js > "$RUNNER_TEMP/typecheck-base.txt" 2>&1 || true
+
+    - name: Fail on new errors
+      if: steps.changes.outputs.neo == 'true'
+      working-directory: neo
+      run: >-
+        node scripts/check-new-errors.js
+        --lint "$RUNNER_TEMP/lint-head.json" "$RUNNER_TEMP/lint-base.json"
+        --typecheck "$RUNNER_TEMP/typecheck-head.txt" "$RUNNER_TEMP/typecheck-base.txt"
+
   preview-env:
     # Same-repo PRs only: forks and Dependabot get no secrets, and Dependabot PRs need no preview.
     name: Prepare PR environment
@@ -155,14 +234,7 @@ jobs:
       RAILWAY_BASE_ENVIRONMENT: ${{ secrets.RAILWAY_ENVIRONMENT }}
       RAILWAY_TEMPLATE_ENVIRONMENT: pr-template
       RAILWAY_PR_ENVIRONMENT: pr-${{ github.event.pull_request.number }}
-      # unacms.app hosts: UNA (service una) and the NEO web client (service neo), both on Railway
-      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
-      VERCEL_TEAM: yasko
-      PREVIEW_API_HOST: api-pr-${{ github.event.pull_request.number }}.unacms.app
-      PREVIEW_CLIENT_HOST: pr-${{ github.event.pull_request.number }}.unacms.app
       UNA_PREVIEW_API_KEY: ${{ secrets.UNA_PREVIEW_API_KEY }}
-    outputs:
-      domains: ${{ steps.domains.outputs.ok }}
     steps:
     - uses: actions/checkout@v4
       with:
@@ -208,30 +280,27 @@ jobs:
             MYSQLUSER=una \
             MYSQLPASSWORD=una \
             UNA_DEMO_SEED=1 \
-            UNA_CLIENT_URL="https://$PREVIEW_CLIENT_HOST" \
             ${UNA_PREVIEW_API_KEY:+UNA_API_KEY="$UNA_PREVIEW_API_KEY"} \
             && break
           sleep 5
         done
+        # Previews live on their Railway domains (<service>-pr-<n>.up.railway.app), which share
+        # Railway's wildcard certificate. Custom pr-<n> / api-pr-<n> hosts on unacms.app needed two
+        # Let's Encrypt certificates per PR, and a busy week ran into Let's Encrypt's rate limit
+        # (PR #5934 got none). UNA_AUTO_HOSTNAME makes UNA build its URLs from its host.
         railway domain --service una --environment "$RAILWAY_PR_ENVIRONMENT" || true
         railway domain --service neo --environment "$RAILWAY_PR_ENVIRONMENT" || true
-
-        # NEO talks to this PR's UNA through its Railway domain, which has a certificate from
-        # the start (the api-pr host gets one minutes later). UNA_AUTO_HOSTNAME makes UNA build
-        # its URLs from that host.
-        una_host=$(railway domain list --service una --environment "$RAILWAY_PR_ENVIRONMENT" --json | jq -r '.domains[] | select(.type == "service") | .domain' | head -n1)
+        service_host() {
+          railway domain list --service "$1" --environment "$RAILWAY_PR_ENVIRONMENT" --json | jq -r '.domains[] | select(.type == "service") | .domain' | head -n1
+        }
+        una_host=$(service_host una)
+        neo_host=$(service_host neo)
         [ -n "$una_host" ] || { echo "No Railway domain for una" >&2; exit 1; }
-        scripts/railway/neo.sh vars "$RAILWAY_PR_ENVIRONMENT" "https://${una_host#https://}" "https://$PREVIEW_CLIENT_HOST"
-
-    - name: Preview domains on unacms.app
-      id: domains
-      if: env.VERCEL_TOKEN != ''
-      run: |
-        scripts/railway/domains.sh railway-domain "$PREVIEW_API_HOST" "$RAILWAY_PR_ENVIRONMENT" una
-        # Until 2026-10 the client host was a Vercel alias of the neo-ci build; drop it if present.
-        scripts/railway/domains.sh client-unalias "$PREVIEW_CLIENT_HOST"
-        scripts/railway/domains.sh railway-domain "$PREVIEW_CLIENT_HOST" "$RAILWAY_PR_ENVIRONMENT" neo
-        echo "ok=1" >> "$GITHUB_OUTPUT"
+        [ -n "$neo_host" ] || { echo "No Railway domain for neo" >&2; exit 1; }
+        una_url="https://${una_host#https://}"
+        neo_url="https://${neo_host#https://}"
+        railway variable set --service una --environment "$RAILWAY_PR_ENVIRONMENT" --skip-deploys UNA_CLIENT_URL="$neo_url"
+        scripts/railway/neo.sh vars "$RAILWAY_PR_ENVIRONMENT" "$una_url" "$neo_url"
 
   deploy:
     name: Deploy PR to Railway (una)
@@ -292,9 +361,6 @@ jobs:
       RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }}
       RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
       RAILWAY_PR_ENVIRONMENT: pr-${{ github.event.pull_request.number }}
-      PREVIEW_API_HOST: api-pr-${{ github.event.pull_request.number }}.unacms.app
-      PREVIEW_CLIENT_HOST: pr-${{ github.event.pull_request.number }}.unacms.app
-      DOMAINS_OK: ${{ needs.preview-env.outputs.domains }}
       UNA_RESULT: ${{ needs.deploy.result }}
       NEO_RESULT: ${{ needs.deploy-neo.result }}
       GH_TOKEN: ${{ github.token }}
@@ -319,13 +385,8 @@ jobs:
         state() { [ "$1" = success ] && echo "" || echo " (deploy: $1)"; }
 
         marker="<!-- railway-preview -->"
-        if [ "$DOMAINS_OK" = "1" ]; then
-          body=$(printf '%s\n**Preview**: https://%s (NEO web client%s, also %s)\n**API**: https://%s (UNA%s, also %s)\n\nRailway environment: `%s`. A new custom domain needs a few minutes for its certificate, and a fresh UNA install about 10.' \
-            "$marker" "$PREVIEW_CLIENT_HOST" "$(state "$NEO_RESULT")" "$neo_url" "$PREVIEW_API_HOST" "$(state "$UNA_RESULT")" "$una_url" "$RAILWAY_PR_ENVIRONMENT")
-        else
-          body=$(printf '%s\n**Preview**: %s (NEO web client%s)\n**API**: %s (UNA%s)\n\nRailway environment: `%s`' \
-            "$marker" "$neo_url" "$(state "$NEO_RESULT")" "$una_url" "$(state "$UNA_RESULT")" "$RAILWAY_PR_ENVIRONMENT")
-        fi
+        body=$(printf '%s\n**Preview**: %s (NEO web client%s)\n**API**: %s (UNA%s)\n\nRailway environment: `%s`. A fresh UNA install takes about 10 minutes.' \
+          "$marker" "$neo_url" "$(state "$NEO_RESULT")" "$una_url" "$(state "$UNA_RESULT")" "$RAILWAY_PR_ENVIRONMENT")
 
         comment_id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
           --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
@@ -493,6 +554,8 @@ jobs:
           .github/actions
           scripts/railway
 
+    # Previews created before they moved to Railway domains (2026-10-20) still have DNS records
+    # on unacms.app; this step can go once those PRs are closed.
     - name: Remove preview domains
       if: env.VERCEL_TOKEN != ''
       continue-on-error: true

+ 2 - 2
AGENTS.md

@@ -89,7 +89,7 @@ The pull request description must include the database migration SQL when the ch
 - Run NEO's yarn scripts from `neo/`, not from the UNA root.
 - Branch from `master` and open the PR here. Start the title of a NEO-only PR with `Neo:`.
 - Never commit to unacms/neo. It is a mirror that `.github/workflows/sync-neo.yml` pushes on every push to `master`, and client projects fork it.
-- UNA CI doesn't check `neo/`. Run `yarn typecheck` and `yarn lint` in `neo/` before you push.
-- CI deploys both halves to the `una-ci` Railway project: service `una` (UNA) and service `neo` (the NEO web client, built from `neo/` with `scripts/railway/neo/`). A PR gets `pr-<n>.unacms.app` (NEO) and `api-pr-<n>.unacms.app` (UNA); master deploys production. See `.github/workflows/ci.yml` and `scripts/railway/`.
+- Run `yarn typecheck` and `yarn lint` in `neo/` before you push. On a PR that touches `neo/`, the CI job `NEO lint and typecheck` runs both on the PR and on its base and fails on any new error.
+- CI deploys both halves to the `una-ci` Railway project: service `una` (UNA) and service `neo` (the NEO web client, built from `neo/` with `scripts/railway/neo/`). A PR gets `neo-pr-<n>.up.railway.app` (NEO) and `una-pr-<n>.up.railway.app` (UNA), Railway's own domains, so previews need no certificates of their own; master deploys production (`api.unacms.app`). See `.github/workflows/ci.yml` and `scripts/railway/`.
 - A UNA service that NEO calls must return JSON for guests too; see [`neo/.agents/skills/una-api/SKILL.md`](neo/.agents/skills/una-api/SKILL.md).
 

+ 1 - 1
modules/boonex/glossary/updates/15.0.2_15.0.3/install/config.php

@@ -26,7 +26,7 @@ $aConfig = array(
     'module_dir' => 'boonex/glossary/',
     'module_uri' => 'glossary',
 
-    'db_prefix' => 'bx_glsr_',
+    'db_prefix' => 'bx_glossary_',
     'class_prefix' => 'BxGlsr',
 
     /**

+ 1 - 1
neo/AGENTS.md

@@ -20,7 +20,7 @@ If a fork needs a change outside `customization/`, make it upstream as a PR to u
 - NEO is a git subtree of the UNA repo since October 2026. The rest of that repo is the UNA backend (PHP), so one PR can change an API and the NEO code that calls it. UNA's own agent notes are in the root `AGENTS.md`; the [`una-api` skill](.agents/skills/una-api/SKILL.md) covers what NEO needs from UNA services.
 - Branch from UNA `master` and open the PR against unacms/UNA. Start the title of a NEO-only PR with `Neo:`, for example "Neo: allow only http(s) URLs in inline post embeds" ([#5787](https://github.com/unacms/UNA/pull/5787)).
 - unacms/neo is a mirror: on every push to UNA `master`, [`.github/workflows/sync-neo.yml`](https://github.com/unacms/UNA/blob/master/.github/workflows/sync-neo.yml) runs `git subtree push --prefix neo` to unacms/neo `main`. Never commit, merge or open PRs there; one commit on the mirror makes the next push non-fast-forward and stops the sync. There is nothing to `git subtree pull` from it either: it only holds what UNA already has, and pulling an outdated copy reverts newer UNA changes.
-- Run `yarn typecheck` and `yarn lint` from `neo/` before you push. UNA CI has no NEO job and the Next build ignores type errors. Neither command is clean on `master` yet, so compare with `master` and add no new errors.
+- Run `yarn typecheck` and `yarn lint` from `neo/` before you push. The Next build ignores type errors. Neither command is clean on `master` yet, so the rule is: add no new errors. On a PR that touches `neo/`, the CI job `NEO lint and typecheck` enforces it: it runs both commands on the PR and on its base and fails when a file gains errors for a rule (`scripts/check-new-errors.js`; run it yourself the same way).
 
 Upstream changes to `packages/app/customization/` reach every fork, so treat that folder as an interface:
 

+ 1 - 1
neo/apps/next/app/layout.js

@@ -26,7 +26,7 @@ export default async function RootLayout({ children }) {
             suppressHydrationWarning
         >
             <body className={appSetting('layout', 'body')}>
-                <Providers initialLang={langCode} initialResources={initialResources}>{children}</Providers>
+                <Providers initialLang={langCode} initialResources={initialResources} onVercel={!!process.env.VERCEL}>{children}</Providers>
             </body>
         </html>
     )

+ 4 - 3
neo/apps/next/app/providers.js

@@ -59,7 +59,7 @@ function ensureI18n(lang, initialResources) {
     }
 }
 
-export function Providers({ children, initialLang = 'en', initialResources }) {
+export function Providers({ children, initialLang = 'en', initialResources, onVercel = false }) {
     // Must match server-rendered copy before the first child paints (menu aria-labels, etc.).
     ensureI18n(initialLang, initialResources)
 
@@ -97,8 +97,9 @@ export function Providers({ children, initialLang = 'en', initialResources }) {
         <I18nextProvider i18n={i18nInstance}>
             <JotaiProvider>
                 <QueryClientProvider client={queryClient}>
-                    <Analytics />
-                    <SpeedInsights />
+                    {/* Their scripts are served by Vercel only; elsewhere both 404 on every page load. */}
+                    {onVercel ? <Analytics /> : null}
+                    {onVercel ? <SpeedInsights /> : null}
                     {children}
                     <Subscriber />
                     <AnalyticsTracker />

+ 22 - 0
neo/apps/next/next.config.js

@@ -47,6 +47,28 @@ function copyCustomizationResources() {
             fs.copyFileSync(src, destPath);
         }
     });
+
+    // The manifest's icon paths are relative, so they resolve under /static/ too. Fill in the
+    // repo's default for each icon it names that customization did not supply; a missing one
+    // 404s on every page load (Chrome fetches it to check installability).
+    const staticManifest = path.join(staticDest, 'manifest.json');
+    if (fs.existsSync(staticManifest)) {
+        let icons = [];
+        try {
+            icons = JSON.parse(fs.readFileSync(staticManifest, 'utf8')).icons || [];
+        } catch (e) {
+            console.warn('public/static/manifest.json is not valid JSON:', e.message);
+        }
+        icons.forEach(({ src }) => {
+            if (!src || src.startsWith('/') || /^[a-z]+:/i.test(src)) return;
+            const destPath = path.join(staticDest, src);
+            const defaultPath = path.resolve(__dirname, 'public', src);
+            if (!fs.existsSync(destPath) && fs.existsSync(defaultPath)) {
+                fs.mkdirSync(path.dirname(destPath), { recursive: true });
+                fs.copyFileSync(defaultPath, destPath);
+            }
+        });
+    }
 }
 
 // Run copy BEFORE loading config

+ 1 - 1
neo/apps/next/public/manifest.json

@@ -30,7 +30,7 @@
       "src": "maskable_icon_x192.png",
       "sizes": "192x192",
       "type": "image/png",
-      "purpose": "any "
+      "purpose": "any"
     },
     {
       "src": "maskable_icon_x192.png",

+ 0 - 6
neo/apps/next/tsconfig.json

@@ -2,7 +2,6 @@
   "compilerOptions": {
     "allowJs": true,
     "allowImportingTsExtensions": true,
-    "baseUrl": ".",
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
     "incremental": true,
@@ -16,11 +15,6 @@
     "module": "ESNext",
     "moduleResolution": "bundler",
     "noEmit": true,
-    "paths": {
-      "react-native": [
-        "react-native-web"
-      ]
-    },
     "plugins": [
       {
         "name": "next"

+ 12 - 13
neo/packages/app/components/elements/feed-item-parts.js

@@ -5,7 +5,6 @@ import Profile from 'app/ui/molecules/profile/profile'
 import ProfileHoverCard from 'app/ui/molecules/profile/profile-hover-card'
 import {
     memo,
-    useMemo,
     useState,
     useEffect,
     useRef,
@@ -471,16 +470,17 @@ const MenuManage_ = memo(({ id, menu, setViewState, defaultOpen }) => {
 export const ActionMenu = memo(({ data }) => {
     const settings = appSetting('feed', 'actions_menu');
 
-    if (data?.items?.length > 2) {
-        for (let i = 2; i < data.items.length; i++)
-            if (data.items[i].data)
-                data.items[i].data.params = { 'button_show_title_from_size': 'sm' }
-            else
-                data.items[i].params = { 'button_show_title_from_size': 'sm' }
-    }
+    // From the third item on, titles show from `sm` up. Items are copied, never written to.
+    const items = data?.items?.length > 2
+        ? data.items.map((item, i) => {
+            if (i < 2) return item
+            const params = { 'button_show_title_from_size': 'sm' }
+            return item.data ? { ...item, data: { ...item.data, params } } : { ...item, params }
+        })
+        : data?.items
 
-    const data_other = {...data, items: data?.items.filter(item => item.class != " bx-mi-primary" && item.primary != 1 )};
-    const data_primary = {...data, items: data?.items.filter(item => item.class == " bx-mi-primary" || item.primary == 1 )};
+    const data_other = {...data, items: items?.filter(item => item.class != " bx-mi-primary" && item.primary != 1 )};
+    const data_primary = {...data, items: items?.filter(item => item.class == " bx-mi-primary" || item.primary == 1 )};
 
     return settings && <Row className="flex-auto justify-between gap-1">
         <Menu {...data_primary} displayType="button" params={settings} />
@@ -684,9 +684,8 @@ function enrichFeedItem(data) {
 }
 
 export const UnitFeed = ({ data, mode, DefaultUnit, SmallUnit, feed_type }) => {
-    const enrichedData = useMemo(() => enrichFeedItem(data), [data?.id]);
-
-    const [datas, setDatas] = useState(enrichedData)
+    // Only the first item seeds the state; later edits arrive through setDatas.
+    const [datas, setDatas] = useState(() => enrichFeedItem(data))
     const itemId = datas?.id
 
     // Comments preview shared through the object store: a reload after a socket

+ 25 - 17
neo/packages/app/components/forms/comments.js

@@ -35,6 +35,9 @@ export default function FormComments(props) {
    
   
 
+    // Jobs reviews send `cmt_mood` (star rating).
+    const hasMood = !!props.data.inputs['cmt_mood']
+
     const rawEditorText = formContext.watch('cmt_text')
     const imagesValue = formContext.watch('cmt_image')
     const moodValue = formContext.watch('cmt_mood')
@@ -57,7 +60,7 @@ export default function FormComments(props) {
         // The success cue plays when UNA returns the new comment (comments-browse).
         if (formContext.formState.isSubmitted) {
             formContext.setValue('cmt_text', '');
-            if (props.data.inputs['cmt_mood']) {
+            if (hasMood) {
                 formContext.setValue('cmt_mood', 0);
             }
             emitter.emit(EVENTS.comment, { action: 'send'})
@@ -68,7 +71,7 @@ export default function FormComments(props) {
                 Keyboard.dismiss()
             }
         }
-    }, [formContext.formState.isSubmitted, formContext, isWeb]);
+    }, [formContext.formState.isSubmitted, formContext, isWeb, hasMood]);
 
     const handleSubmitWithSanitization = () => {
         let sanitizedHtml = removeEmptyTags(formContext.getValues('cmt_text') || '');
@@ -83,22 +86,27 @@ export default function FormComments(props) {
         handleSubmitWithSanitization();
     };
 
-    props.data.inputs['cmt_submit'].hide_errors = true
-    props.data.inputs['cmt_submit'].icon = 'ArrowUp'
-    props.data.inputs['cmt_submit'].style = 'bordered'
-    props.data.inputs['cmt_submit'].rounded = 'true'
-    props.data.inputs['cmt_submit'].icon_only = true
-    props.data.inputs['cmt_image'].rounded = 'true'
-    props.data.inputs['cmt_image'].variant = 'default'
+    // Copies with the composer's look; `props` itself is never written to.
+    const submitInput = {
+        ...props.data.inputs['cmt_submit'],
+        hide_errors: true,
+        icon: 'ArrowUp',
+        style: 'bordered',
+        rounded: 'true',
+        icon_only: true,
+    }
+    const imageInput = {
+        ...props.data.inputs['cmt_image'],
+        rounded: 'true',
+        variant: 'default',
+    }
     // Comment storage decides: images-only modules keep the photo icon, others get a generic attachment.
-    const attachIcon = getStoragePickerKind(props.data.inputs['cmt_image'].ext_allow) === 'any' ? 'Paperclip' : 'Image'
+    const attachIcon = getStoragePickerKind(imageInput.ext_allow) === 'any' ? 'Paperclip' : 'Image'
 
     const hasContent = !!hasText || !!imagesValue || props.data.inputs['cmt_text'].html == 2 || props.data.inputs['cmt_text'].html == 1
-    // Jobs reviews send `cmt_mood` (star rating). Keep it on the composer
-    // toolbar so the empty pill stays a single line — same row as the
-    // placeholder and attach button — then it drops to the bottom with
-    // attach/send once the composer expands.
-    const hasMood = !!props.data.inputs['cmt_mood']
+    // The mood stars sit on the composer toolbar so the empty pill stays a single
+    // line — same row as the placeholder and attach button — then drop to the
+    // bottom with attach/send once the composer expands.
     const moodInline = hasMood && !hasContent
 
     useEffect(() => {
@@ -184,7 +192,7 @@ export default function FormComments(props) {
                                     {hasContent ? (
                                         <View>
                                             {getFormFieldByData(
-                                                props.data.inputs['cmt_submit'],
+                                                submitInput,
                                                 handleSubmitWithSanitization,
                                                 'custom',
                                                 {
@@ -224,7 +232,7 @@ export default function FormComments(props) {
             <ScrollView horizontal={true}>
                 <Row className={imagesValue ? 'flex-wrap mb-1 mx-1' : 'flex-wrap'}>
                     {getFormFieldByData(
-                        props.data.inputs['cmt_image'],
+                        imageInput,
                         props.handleSubmit,
                         'notitle',
                         { hide_button: true, list_only: true, asDefaultStorage: true, form_name: props.name}

+ 20 - 11
neo/packages/app/components/forms/messenger.js

@@ -12,6 +12,9 @@ import emitter, { EVENTS } from 'app/context/emitter'
 import { useTranslation } from 'react-i18next'
 import { MessageInput } from 'app/components/elements/chat/parts/message-input'
 
+/** Send time in seconds. Module level on purpose: it is called from the submit handler, never while rendering. */
+const nowInSeconds = () => Math.floor(Date.now() / 1000)
+
 export default function FormMessenger(props) {
     const { t } = useTranslation()
     const isWeb = Platform.OS == 'web'
@@ -54,6 +57,7 @@ export default function FormMessenger(props) {
         if (!editorHtmlIsSubmittable(sanitizedHtml) && !hasFiles(images)) return
 
         formContext.setValue('message', sanitizedHtml, { shouldValidate: true, shouldDirty: true })
+        formContext.setValue('payload', nowInSeconds())
         props.handleSubmit()
     }
 
@@ -61,15 +65,20 @@ export default function FormMessenger(props) {
         handleSubmitWithSanitization()
     }
 
-    props.data.inputs['payload'].value = parseInt((new Date()).getTime() / 1000)
-
-    props.data.inputs['submit'].hide_errors = true
-    props.data.inputs['submit'].icon = 'ArrowUp'
-    props.data.inputs['submit'].style = 'bordered'
-    props.data.inputs['submit'].rounded = 'true'
-    props.data.inputs['submit'].icon_only = true
-    props.data.inputs['files'].rounded = 'true'
-    props.data.inputs['files'].variant = 'default'
+    // Copies with the composer's look; `props` itself is never written to.
+    const submitInput = {
+        ...props.data.inputs['submit'],
+        hide_errors: true,
+        icon: 'ArrowUp',
+        style: 'bordered',
+        rounded: 'true',
+        icon_only: true,
+    }
+    const filesInput = {
+        ...props.data.inputs['files'],
+        rounded: 'true',
+        variant: 'default',
+    }
 
     const hasContent = !!hasText || !!imagesValue
 
@@ -100,7 +109,7 @@ export default function FormMessenger(props) {
                 {hasContent ? (
                     <View className="">
                         {getFormFieldByData(
-                            props.data.inputs['submit'],
+                            submitInput,
                             handleSubmitWithSanitization,
                             'custom',
                             {
@@ -126,7 +135,7 @@ export default function FormMessenger(props) {
                 <ScrollView horizontal={true}>
                     <Row className="flex-wrap  mx-1">
                         {getFormFieldByData(
-                            props.data.inputs['files'],
+                            filesInput,
                             props.handleSubmit,
                             'notitle',
                             { hide_button: true, list_only: true, asDefaultStorage: true, form_name: props.name }

+ 8 - 6
neo/packages/app/components/nav/menu-footer.js

@@ -13,9 +13,9 @@ export const MENU_FOOTER_CLASSES = 'flex w-full items-center border-t border-bor
 // Settings use the historical misspelling; keep this key aligned with settings/layout.js.
 const AVAILABLE_LANGS_SETTING_KEY = 'avaliable_langs';
 const FOOTER_THEME_ITEMS = [
-    { id: 'auto', titleKey: 'theme_footer_auto', icon: 'Eclipse' },
-    { id: 'light', titleKey: 'theme_footer_light', icon: 'Sun' },
-    { id: 'dark', titleKey: 'theme_footer_dark', icon: 'Moon' },
+    { id: 'auto', titleKey: 'theme_footer_auto', labelKey: 'theme_level_auto', icon: 'Eclipse' },
+    { id: 'light', titleKey: 'theme_footer_light', labelKey: 'theme_level_light', icon: 'Sun' },
+    { id: 'dark', titleKey: 'theme_footer_dark', labelKey: 'theme_level_dark', icon: 'Moon' },
 ];
 
 const linkVariantToNeoStyle = {
@@ -87,16 +87,17 @@ function FooterThemeSwitcher({ visualProps }) {
         }))
     ), [currentThemeName, t]);
     const currentThemeItem = FOOTER_THEME_ITEMS.find((theme) => theme.id === currentThemeName) ?? FOOTER_THEME_ITEMS[0];
-    const themeLabel = t(currentThemeItem.titleKey);
+    const themeLabel = t(currentThemeItem.labelKey);
 
+    // Icon only: the icon shows the current theme, and the menu names each one.
     return (
         <DropdownMenu
             items={items}
             onSelect={(item) => setThemeName(item.id)}
             buttonProps={{
-                label: themeLabel,
                 image: currentThemeItem.icon,
                 accessibilityLabel: themeLabel,
+                tooltip: themeLabel,
                 style: visualProps.style,
                 controlSize: visualProps.controlSize,
                 className: visualProps.className,
@@ -123,14 +124,15 @@ function FooterSoundsLevel({ visualProps }) {
     const current = SOUND_LEVEL_ITEMS.find((option) => option.id === level) ?? SOUND_LEVEL_ITEMS[0];
     const label = t(current.labelKey);
 
+    // Icon only, like the theme switcher: the icon shows the level, and the menu names each one.
     return (
         <DropdownMenu
             items={items}
             onSelect={(item) => setLevel(item.id)}
             buttonProps={{
-                label,
                 image: current.icon,
                 accessibilityLabel: label,
+                tooltip: label,
                 style: visualProps.style,
                 controlSize: visualProps.controlSize,
                 className: visualProps.className,

+ 0 - 9
neo/packages/app/components/nav/tabs/expoui-tabs.js

@@ -38,7 +38,6 @@ import { useTabBarColors } from './tab-colors';
 import { selectOverflowTab } from './tab-press';
 import { getWindowSafeAreaInsets, useGlobalSearchParams, useRouter, usePathname, useSafeAreaInsets } from 'app/lib/hooks/router';
 import { rememberSelectedTab } from 'app/lib/navigation/tab-history';
-import emitter, { EVENTS } from 'app/context/emitter';
 import { useBottomSheetData } from 'app/context/bottomsheet';
 import { useCurrentUser } from 'app/context/user';
 
@@ -358,14 +357,6 @@ export default function ExpoUITabNavigator({
         ? tabBarHeight
         : IOS_TAB_BAR.height + (getWindowSafeAreaInsets().bottom || 0));
 
-    useEffect(() => {
-        const subscription = emitter.addListener(EVENTS.tabsMore, (data) => {
-            if (data?.action === 'open') setMoreMenuOpen(true);
-            if (data?.action === 'close') setMoreMenuOpen(false);
-        });
-        return () => subscription.remove();
-    }, []);
-
     const overflowMenuItems = useMemo(
         () => buildTabBarMoreMenuItems({
             visible,

+ 0 - 4
neo/packages/app/context/emitter.ts

@@ -81,10 +81,6 @@ export const EVENTS = {
     wiki: 'wiki',
         /* reload */
 
-    tabsMore: 'tabs-more',
-        /* open | close — native "More" menu. Core no longer emits it (the tab's own
-         * tabPress opens it); kept for forks still on the old native-tab-press-sync. */
-
     // ************************ content ***************************************
 
     feed: 'feed',

+ 3 - 0
neo/packages/app/default/translations/de.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Hell",
     "theme_footer_dark": "Dunkel",
+    "theme_level_auto": "Design: automatisch",
+    "theme_level_light": "Design: hell",
+    "theme_level_dark": "Design: dunkel",
     "Sounds": "Töne",
     "sounds_full": "Laut",
     "sounds_quiet": "Leise",

+ 3 - 0
neo/packages/app/default/translations/en.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Light",
     "theme_footer_dark": "Dark",
+    "theme_level_auto": "Theme: Auto",
+    "theme_level_light": "Theme: Light",
+    "theme_level_dark": "Theme: Dark",
     "Sounds": "Sounds",
     "sounds_full": "Full",
     "sounds_quiet": "Quiet",

+ 3 - 0
neo/packages/app/default/translations/es.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Claro",
     "theme_footer_dark": "Oscuro",
+    "theme_level_auto": "Tema: automático",
+    "theme_level_light": "Tema: claro",
+    "theme_level_dark": "Tema: oscuro",
     "Sounds": "Sonidos",
     "sounds_full": "Alto",
     "sounds_quiet": "Bajo",

+ 3 - 0
neo/packages/app/default/translations/fr.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Clair",
     "theme_footer_dark": "Sombre",
+    "theme_level_auto": "Thème : automatique",
+    "theme_level_light": "Thème : clair",
+    "theme_level_dark": "Thème : sombre",
     "Sounds": "Sons",
     "sounds_full": "Fort",
     "sounds_quiet": "Doux",

+ 3 - 0
neo/packages/app/default/translations/ru.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Авто",
     "theme_footer_light": "Светлая",
     "theme_footer_dark": "Тёмная",
+    "theme_level_auto": "Тема: авто",
+    "theme_level_light": "Тема: светлая",
+    "theme_level_dark": "Тема: тёмная",
     "Sounds": "Звуки",
     "sounds_full": "Громко",
     "sounds_quiet": "Тихо",

+ 22 - 10
neo/packages/app/lib/browse-query/index.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
 import { appSetting } from 'app/lib/util'
 import emitter, { EVENTS } from 'app/context/emitter'
@@ -57,6 +57,13 @@ function parseTimelinePayload(payload: unknown): TimelineSocketEvent | null {
     return payload && typeof payload === 'object' ? (payload as TimelineSocketEvent) : null
 }
 
+/** A page without the rows the socket reported deleted since it was requested. */
+function withoutDeleted(page: any, deletedIds: Set<string>) {
+    return deletedIds.size
+        ? { ...page, data: (page?.data ?? []).filter((it: any) => !deletedIds.has(String(it.id))) }
+        : page
+}
+
 /**
  * Shared UniList infinite query: TanStack is the store.
  * First page is always fetched (no page-JSON seed). Skeletons until that returns.
@@ -97,11 +104,16 @@ export function useUniListQuery({
     const matchesNewContentRef = useRef(matchesNewContent)
     const canFetchNextRef = useRef(canFetchNext)
 
-    requestUrlRef.current = requestUrl
-    paramsRef.current = defaultParams
-    qKeyRef.current = queryKey
-    matchesNewContentRef.current = matchesNewContent
-    canFetchNextRef.current = canFetchNext
+    // "Latest value" refs: written in a layout effect, not during render (React Compiler
+    // rule). Layout effects run before every passive effect, including TanStack's fetch,
+    // so `queryFn` and the socket handlers below always see the current values.
+    useLayoutEffect(() => {
+        requestUrlRef.current = requestUrl
+        paramsRef.current = defaultParams
+        qKeyRef.current = queryKey
+        matchesNewContentRef.current = matchesNewContent
+        canFetchNextRef.current = canFetchNext
+    })
 
     const qKeyId = JSON.stringify(queryKey)
     const queryEnabled = Boolean(enabled && requestUrl)
@@ -202,16 +214,16 @@ export function useUniListQuery({
         const loadNewItems = async () => {
             timer = null
             const cacheKey = qKeyRef.current
+            // The effect was torn down, or the list moved on to another query, while fetching.
+            const isStale = () => cancelled || cacheKey !== qKeyRef.current
             try {
                 const freshPage = await fetchUniListData({
                     pageParam: undefined,
                     requestUrl: requestUrlRef.current,
                     defaultParams: paramsRef.current,
                 })
-                if (cancelled || cacheKey !== qKeyRef.current) return
-                const page = deletedIds.size
-                    ? { ...freshPage, data: (freshPage?.data ?? []).filter((it: any) => !deletedIds.has(String(it.id))) }
-                    : freshPage
+                if (isStale()) return
+                const page = withoutDeleted(freshPage, deletedIds)
                 if (mergeFirstPageIntoUniListQueryCache(queryClient, cacheKey, page)) {
                     refetchRef.current.skipToast = true
                     setRequested(true)

+ 16 - 11
neo/packages/app/lib/hooks/use-pulse-once.ts

@@ -4,6 +4,18 @@ import { AccessibilityInfo, Animated } from 'react-native';
 import { nativeDriver } from 'app/lib/platform/animation';
 import type { PulseOnceOptions } from './use-pulse-once.types';
 
+/**
+ * The system "reduce motion" setting; a failed or missing lookup counts as off.
+ * Kept out of the hook: React Compiler can't compile a conditional inside try/catch.
+ */
+async function prefersReducedMotion(): Promise<boolean> {
+    try {
+        return AccessibilityInfo.isReduceMotionEnabled ? await AccessibilityInfo.isReduceMotionEnabled() : false;
+    } catch {
+        return false;
+    }
+}
+
 /**
  * Parameters:
  * - pulseDurationMs: duration of ONE pulse (down-up), default 3000ms
@@ -48,17 +60,10 @@ export function usePulseOne({
             return;
         }
 
-        try {
-            if (respectReducedMotion && AccessibilityInfo.isReduceMotionEnabled) {
-                const reduced = await AccessibilityInfo.isReduceMotionEnabled();
-                if (reduced) {
-                    opacity.setValue(1);
-                    onEnd && onEnd();
-                    return;
-                }
-            }
-        } catch {
-            // Silently ignore AccessibilityInfo errors
+        if (respectReducedMotion && (await prefersReducedMotion())) {
+            opacity.setValue(1);
+            onEnd && onEnd();
+            return;
         }
 
         const anim = buildAnimation();

+ 2 - 2
neo/packages/app/ui/atoms/dropdown-menu.tsx

@@ -251,7 +251,7 @@ function DropdownMenuPopup({
                     content: resolveContent ? resolveContent(item.content, item) : item.content as ReactNode,
                 }) : openModalByUrl(item.link)) : redirectdRef.current?.redirect('' + item.link);
         },
-        [onSelect, setIsOpen, resolveContent]
+        [onSelect, setIsOpen, resolveContent, openModalWithContent, openModalByUrl]
     );
 
     useEffect(() => {
@@ -421,7 +421,7 @@ const MenuBottomSheet = memo(({ items, onSelect, setBottomSheetData, resolveCont
             }
             redirectdRef.current?.redirect('' + item.link);
         },
-        [onSelect, setBottomSheetData, openModalByUrl, openModalWithContent]
+        [onSelect, setBottomSheetData, openModalByUrl, openModalWithContent, resolveContent]
     );
 
     const classes = isGridLayout ? variantClassMap.grid : variantClassMap.vertical;

+ 37 - 34
neo/packages/app/ui/molecules/content/gallery.js

@@ -1,4 +1,4 @@
-import { useRef, useState, useEffect } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useAnimatedValue } from 'app/lib/hooks/use-animated-value';
 import { Animated, PanResponder, Dimensions } from 'react-native';
 import { View } from 'app/design/view';
@@ -17,7 +17,7 @@ export default function Gallery({ items, autoscroll }) {
     const [isPaused, setIsPaused] = useState(false);
 
 
-    const animateToIndex = (newIndex, direction) => {
+    const animateToIndex = useCallback((newIndex, direction) => {
         Animated.parallel([
             Animated.timing(position, {
                 toValue: direction * -screenWidth,
@@ -45,47 +45,50 @@ export default function Gallery({ items, autoscroll }) {
                 }),
             ]).start();
         });
-    };
+    }, [position, opacity]);
 
-    const goLeft = () => {
+    const goLeft = useCallback(() => {
         const prevIndex = (currentIndex - 1 + items.length) % items.length;
         animateToIndex(prevIndex, -1);
-    };
+    }, [currentIndex, items.length, animateToIndex]);
 
-    const goRight = () => {
+    const goRight = useCallback(() => {
         const nextIndex = (currentIndex + 1) % items.length;
         animateToIndex(nextIndex, 1);
-    };
+    }, [currentIndex, items.length, animateToIndex]);
 
-// Auto-scroll
-useEffect(() => {
-    if (!autoscroll || isPaused || items.length <= 1) return;
-    
-    const interval = typeof autoscroll === 'number' ? autoscroll : 3000;
-    const timer = setInterval(() => {
-        goRight();
-    }, interval);
+    // Auto-scroll; restarts after every slide change (manual or automatic).
+    useEffect(() => {
+        if (!autoscroll || isPaused || items.length <= 1) return;
 
-    return () => clearInterval(timer);
-}, [autoscroll, isPaused, currentIndex, items.length]);
+        const interval = typeof autoscroll === 'number' ? autoscroll : 3000;
+        const timer = setInterval(goRight, interval);
 
-    const panResponder = useRef(
-        PanResponder.create({
-            onMoveShouldSetPanResponder: (_, gesture) =>
-                Math.abs(gesture.dx) > 20,
-            onPanResponderGrant: () => {
-                setIsPaused(true); 
-            },
-            onPanResponderRelease: (_, gesture) => {
-                setIsPaused(false); 
-                if (gesture.dx > 50) {
-                    goLeft();
-                } else if (gesture.dx < -50) {
-                    goRight();
-                }
-            },
-        })
-    ).current;
+        return () => clearInterval(timer);
+    }, [autoscroll, isPaused, items.length, goRight]);
+
+    // Rebuilt only when the slide changes (goLeft/goRight depend on it), so a swipe in
+    // progress keeps its responder. Created once, it kept the first render's currentIndex
+    // and every swipe stepped from slide 0.
+    const panResponder = useMemo(
+        () =>
+            PanResponder.create({
+                onMoveShouldSetPanResponder: (_, gesture) =>
+                    Math.abs(gesture.dx) > 20,
+                onPanResponderGrant: () => {
+                    setIsPaused(true);
+                },
+                onPanResponderRelease: (_, gesture) => {
+                    setIsPaused(false);
+                    if (gesture.dx > 50) {
+                        goLeft();
+                    } else if (gesture.dx < -50) {
+                        goRight();
+                    }
+                },
+            }),
+        [goLeft, goRight]
+    );
 
     return (
         <View className=' w-full overflow-hidden py-2 px-0.5 -my-2 ' {...panResponder.panHandlers}>

+ 97 - 0
neo/scripts/check-new-errors.js

@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// Fails when a branch adds lint or typecheck errors compared with its base.
+// `yarn lint` and `yarn typecheck` aren't clean on master yet, so CI can't ask
+// for zero errors; it asks for no new ones (see AGENTS.md "Before you push").
+//
+//   node scripts/check-new-errors.js \
+//     --lint <head-eslint.json> <base-eslint.json> \
+//     --typecheck <head-typecheck.txt> <base-typecheck.txt>
+//
+// ESLint input is `eslint -f json` output; typecheck input is the text printed by
+// scripts/typecheck.js. Errors are compared per file + rule (lint) or file + TS code
+// (typecheck), ignoring line numbers, so unrelated edits that shift lines don't count.
+// Warnings are ignored.
+
+const fs = require('node:fs');
+
+function args() {
+    const out = {};
+    const argv = process.argv.slice(2);
+    for (let i = 0; i < argv.length; i++) {
+        if (argv[i] === '--lint' || argv[i] === '--typecheck') {
+            out[argv[i].slice(2)] = [argv[i + 1], argv[i + 2]];
+            i += 2;
+        }
+    }
+    return out;
+}
+
+/** Path relative to the NEO root, whatever checkout the report came from. */
+function rel(file) {
+    const p = file.replaceAll('\\', '/');
+    const m = p.match(/(?:^|\/)((?:apps|packages|scripts|crypto-shim)\/.*)$/);
+    return m ? m[1] : p;
+}
+
+function lintCounts(file) {
+    const counts = new Map();
+    const where = new Map();
+    for (const f of JSON.parse(fs.readFileSync(file, 'utf8'))) {
+        for (const m of f.messages) {
+            if (m.severity !== 2) continue;
+            const key = `${rel(f.filePath)}\t${m.ruleId || 'parse-error'}`;
+            counts.set(key, (counts.get(key) || 0) + 1);
+            if (!where.has(key)) where.set(key, { line: m.line, message: m.message.split('\n')[0] });
+        }
+    }
+    return { counts, where };
+}
+
+// tsc prints `file(12,5): error TS2532: ...` without a TTY and `file:12:5 - error TS2532: ...` with one.
+const TSC_LINE = /^(.+?)(?:\((\d+),\d+\)|:(\d+):\d+)(?::| -) error (TS\d+): (.*)$/;
+
+function typecheckCounts(file) {
+    const counts = new Map();
+    const where = new Map();
+    const text = fs.readFileSync(file, 'utf8').replace(/\x1b\[[0-9;]*m/g, '');
+    for (const raw of text.split(/\r?\n/)) {
+        const m = raw.trim().match(TSC_LINE);
+        if (!m) continue;
+        const key = `${rel(m[1])}\t${m[4]}`;
+        counts.set(key, (counts.get(key) || 0) + 1);
+        if (!where.has(key)) where.set(key, { line: Number(m[2] || m[3]), message: m[5] });
+    }
+    return { counts, where };
+}
+
+function compare(label, head, base) {
+    const added = [];
+    for (const [key, n] of head.counts) {
+        const before = base.counts.get(key) || 0;
+        if (n > before) added.push({ key, n, before, ...head.where.get(key) });
+    }
+    const total = (c) => [...c.counts.values()].reduce((s, n) => s + n, 0);
+    console.log(`${label}: ${total(base)} errors on base, ${total(head)} on this branch`);
+    for (const a of added) {
+        const [file, rule] = a.key.split('\t');
+        const msg = `${rule}: ${a.n - a.before} new (${a.before} → ${a.n}). First: ${a.message}`;
+        // GitHub annotation; paths are relative to the repo root, where NEO lives in neo/.
+        console.log(`::error file=neo/${file},line=${a.line || 1},title=${label}::${msg}`);
+        console.log(`  ${file}  ${msg}`);
+    }
+    return added.length;
+}
+
+const opts = args();
+let failures = 0;
+if (opts.lint) failures += compare('lint', lintCounts(opts.lint[0]), lintCounts(opts.lint[1]));
+if (opts.typecheck) failures += compare('typecheck', typecheckCounts(opts.typecheck[0]), typecheckCounts(opts.typecheck[1]));
+if (!opts.lint && !opts.typecheck) {
+    console.error('usage: check-new-errors.js --lint <head.json> <base.json> --typecheck <head.txt> <base.txt>');
+    process.exit(2);
+}
+if (failures) {
+    console.log(`\n${failures} file/rule pair(s) gained errors. Fix them, or compare with master locally: yarn lint, yarn typecheck.`);
+    process.exit(1);
+}
+console.log('\nno new errors');

+ 0 - 2
neo/scripts/typecheck.js

@@ -90,8 +90,6 @@ function run(name) {
         compilerOptions: {
             noEmit: true,
             moduleSuffixes: [platform.suffix, ''],
-            // apps/next still sets `baseUrl`, deprecated since TS 6.
-            ...(parseInt(ts.version, 10) >= 6 ? { ignoreDeprecations: '6.0' } : {}),
             ...platform.compilerOptions,
         },
         include: [],

+ 2 - 2
package.json

@@ -150,7 +150,7 @@
     "postinstall": "npm run build"
   },
   "devDependencies": {
-    "@ag-ui/core": "1.0.2",
+    "@ag-ui/core": "1.0.3",
     "@rollup/plugin-commonjs": "^29.0.3",
     "@rollup/plugin-node-resolve": "^16.0.3",
     "@rollup/plugin-replace": "^6.0.3",
@@ -167,7 +167,7 @@
     "@types/react": "^19.2.18",
     "@types/react-dom": "^19.2.7",
     "bower": "^1.8.14",
-    "lucide": "^1.55.0",
+    "lucide": "^1.58.0",
     "lucide-static": "^1.55.0",
     "npm-run-all": "^4.1.5",
     "partial-json": "^0.1.7",

+ 1 - 1
scripts/railway/seed.php

@@ -153,7 +153,7 @@ else
 $aPosts = [
     ['Hello from the demo site', "This site is a fresh UNA install deployed from the master branch. Everything here is sample content."],
     ['What NEO is', "NEO is the universal UNA client: one React Native codebase for web, iOS and Android, talking to UNA through its API."],
-    ['Preview environments', "Every pull request to UNA gets its own backend and client on unacms.app, created by CI and removed when the PR closes."],
+    ['Preview environments', "Every pull request to UNA gets its own backend and client on Railway, created by CI and removed when the PR closes."],
     ['Weekend reading', "A few articles about building communities that stay useful: keep threads short, answer quickly, and welcome new members by name."],
     ['Photo walk this Saturday', "Meeting at the old harbour at 10:00. Bring any camera; phones are fine. Coffee afterwards."],
     ['Tips for a good profile', "Add a picture, write two sentences about what you do, and follow a few groups. That's enough to get a useful feed."],

+ 6 - 1
tests/units/install/ModuleUpdatePacksTest.php

@@ -4,6 +4,7 @@
  * Module update packs (modules/<vendor>/<module>/updates/<from>_<to>/) must name the module they update the way the
  * installed module is recorded. BxDolStudioUpdater finds the module by `module_dir` and `module_uri`, and builds the
  * updater class name from `class_prefix`, so a pack that differs from the module's own config fails on every site.
+ * `db_prefix` names the module's tables, so a pack must carry the module's own.
  */
 class ModuleUpdatePacksTest extends \PHPUnit\Framework\TestCase
 {
@@ -50,6 +51,10 @@ class ModuleUpdatePacksTest extends \PHPUnit\Framework\TestCase
             if (($aPack['class_prefix'] ?? '') !== $aModule['class_prefix'])
                 $aErrors[] = "$sPack: class_prefix '" . ($aPack['class_prefix'] ?? '') . "' is not the module's '{$aModule['class_prefix']}'";
 
+            $sDbPrefix = $aModule['db_prefix'] ?? '';
+            if (($aPack['db_prefix'] ?? '') !== $sDbPrefix)
+                $aErrors[] = "$sPack: db_prefix '" . ($aPack['db_prefix'] ?? '') . "' is not the module's '$sDbPrefix'";
+
             $sVersions = ($aPack['version_from'] ?? '') . '_' . ($aPack['version_to'] ?? '');
             if (basename($sPack) !== $sVersions)
                 $aErrors[] = "$sPack: the folder is not named after version_from and version_to ($sVersions)";
@@ -58,6 +63,6 @@ class ModuleUpdatePacksTest extends \PHPUnit\Framework\TestCase
                 $aErrors[] = "$sPack: home_dir '" . ($aPack['home_dir'] ?? '') . "' is not '{$sModuleDir}updates/update_{$sVersions}/'";
         }
 
-        $this->assertSame([], $aErrors, "Update packs that would not apply:\n" . implode("\n", $aErrors));
+        $this->assertSame([], $aErrors, "Update packs that don't match their module:\n" . implode("\n", $aErrors));
     }
 }

+ 19 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/app.sql

@@ -0,0 +1,19 @@
+--
+-- NEO app flags added since 15.0.0-RC2.
+-- Difference between the RC2 enable-app snapshots and
+-- modules/system/install/sql/enable-app-menus.sql
+-- modules/system/install/sql/enable-app-pages.sql
+-- Rows these updates target are inserted by sql.sql, which runs first.
+--
+
+-- menus
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='sys_toolbar_member' AND `module`='system' AND `name`='search';
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='sys_toolbar_member' AND `module`='system' AND `name`='main-menu';
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='sys_account_dashboard' AND `module`='system' AND `name`='dashboard-admin';
+
+-- pages
+UPDATE `sys_objects_page` SET `config_api`='{"layout":"chat"}' WHERE `object`='sys_cooperator';
+UPDATE `sys_pages_blocks` SET `config_api`='{"designbox": {"bg": true, "padding": true, "title": false, "solid": ["mobile"]}}' WHERE `object`='sys_home' AND `module`='system' AND `title_system`='_sys_page_block_title_sys_create_post' AND `config_api` IN ('', '{"designbox": {"bg": true, "padding": true}}');
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='sys_cooperator' AND `module`='system' AND `title_system`='_sys_page_block_title_system_cooperator' AND `title`='_sys_page_block_title_cooperator';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='sys_dashboard_admin' AND `module`='system' AND `title_system`='_sys_page_block_title_system_dashboard_admin' AND `title`='_sys_page_block_title_dashboard_admin';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `module`='system' AND `title_system`='_sys_page_block_title_sys_search_ai';

+ 9 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/check.php

@@ -0,0 +1,9 @@
+<?php
+
+$mixCheckResult = 'Update can not be applied';
+
+$sVer = $this->oDb->getOne("SELECT `version` FROM `sys_modules` WHERE `name` = 'system'");
+if ('15.0.0.RC2' == $sVer || '15.0.0-RC2' == $sVer)
+    $mixCheckResult = true;
+
+return $mixCheckResult;

+ 50 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/.htaccess

@@ -0,0 +1,50 @@
+
+Options -MultiViews -Indexes
+
+<IfModule mod_php5.c>
+    php_flag allow_url_include Off
+    php_flag register_globals Off
+</IfModule>
+
+<IfModule mod_rewrite.c>
+    RewriteEngine on
+    RewriteRule ^m/(.*)$  modules/index.php?r=$1 [QSA,L]
+    RewriteRule ^page/(.*)$  page.php?i=$1 [QSA,L]
+    RewriteRule ^s/([a-zA-Z0-9_]+)/([a-zA-Z0-9\.]+)  storage.php?o=$1&f=$2   [QSA,L]
+
+    RewriteCond %{REQUEST_FILENAME} -f [OR]
+    RewriteCond %{REQUEST_FILENAME} -d
+    RewriteRule .+ - [L]
+    RewriteRule ^(.+)$ r.php?_q=$1 [QSA,L]
+</IfModule>
+
+AddType image/svg+xml svg
+
+# Compress text responses (guarded: no effect unless mod_deflate is enabled).
+<IfModule mod_deflate.c>
+    AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml application/javascript text/javascript application/json application/xml image/svg+xml application/manifest+json
+</IfModule>
+
+# Long-lived caching for static assets (guarded: no effect unless mod_expires is enabled).
+# text/html is intentionally omitted so dynamic pages are never cached.
+<IfModule mod_expires.c>
+    ExpiresActive On
+    ExpiresByType text/css                  "access plus 30 days"
+    ExpiresByType application/javascript    "access plus 30 days"
+    ExpiresByType text/javascript           "access plus 30 days"
+    ExpiresByType image/svg+xml             "access plus 30 days"
+    ExpiresByType image/png                 "access plus 30 days"
+    ExpiresByType image/jpeg                "access plus 30 days"
+    ExpiresByType image/gif                 "access plus 30 days"
+    ExpiresByType image/webp                "access plus 30 days"
+    ExpiresByType image/x-icon              "access plus 30 days"
+    ExpiresByType font/woff2                "access plus 365 days"
+    ExpiresByType font/woff                 "access plus 365 days"
+    ExpiresByType font/ttf                  "access plus 365 days"
+    ExpiresByType application/vnd.ms-fontobject "access plus 365 days"
+
+    # The service worker is generated by PHP as text/javascript and must stay fresh.
+    <Files "sw.js.php">
+        ExpiresActive Off
+    </Files>
+</IfModule>

+ 52 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/image_transcoder.php

@@ -0,0 +1,52 @@
+<?php
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+ob_start();
+
+require_once('./inc/header.inc.php');
+
+$sTranscoderObject = bx_process_input(bx_get('o'));
+$sHandler = bx_process_input(bx_get('h'));
+
+$oTranscoder = BxDolTranscoderImage::getObjectInstance($sTranscoderObject);
+
+if (!$oTranscoder) {
+    ob_end_clean();
+    bx_transcoder_error_occured();
+    exit;
+}
+
+ob_end_clean();
+
+if (isset($_GET['dpx']))
+    $oTranscoder->forceDevicePixelRatio((int)$_GET['dpx']);
+
+// Video goes through the transcoding queue (getFileUrl queues it), never inline ffmpeg here.
+if (!($oTranscoder instanceof BxDolTranscoderVideo) && !$oTranscoder->isFileReady($sHandler) && !$oTranscoder->transcode ($sHandler)) {
+    bx_transcoder_error_occured();
+    exit;
+}
+
+$sImageUrl = $oTranscoder->getFileUrl($sHandler);
+if (!$sImageUrl) {
+    bx_transcoder_error_occured();
+    exit;
+}
+
+header('Location: ' . $sImageUrl);
+exit;
+
+function bx_transcoder_error_occured($sMethod = 'displayPageNotFound')
+{
+    require_once(BX_DIRECTORY_PATH_INC . "design.inc.php");
+    $oTemplate = BxDolTemplate::getInstance();
+    $oTemplate->$sMethod ();
+}
+
+/** @} */

+ 287 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIModelFactory.php

@@ -0,0 +1,287 @@
+<?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
+ * @{
+ */
+
+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);
+        $sCacheKey = __CLASS__ . '_Model_' . $iId . ($iMaxTokensOverride > 0 ? '_' . $iMaxTokensOverride : '');
+        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 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");
+        }
+
+        if ($iMaxTokensOverride > 0 && ($a['type'] ?? '') !== 'una-proxy') {
+            $aParameters['max_tokens'] = $iMaxTokensOverride;
+            if (!isset($aParameters['parameters']) || !is_array($aParameters['parameters']))
+                $aParameters['parameters'] = [];
+
+            switch ($a['type']) {
+                case 'openai-responses':
+                    $aParameters['parameters']['max_output_tokens'] = $iMaxTokensOverride;
+                    break;
+                case 'azure-openai':
+                    $aParameters['parameters']['max_completion_tokens'] = $iMaxTokensOverride;
+                    break;
+                case 'gemini':
+                    if (!isset($aParameters['parameters']['generationConfig']) || !is_array($aParameters['parameters']['generationConfig']))
+                        $aParameters['parameters']['generationConfig'] = [];
+                    $aParameters['parameters']['generationConfig']['maxOutputTokens'] = $iMaxTokensOverride;
+                    break;
+                case 'ollama':
+                    if (!isset($aParameters['parameters']['options']) || !is_array($aParameters['parameters']['options']))
+                        $aParameters['parameters']['options'] = [];
+                    $aParameters['parameters']['options']['num_predict'] = $iMaxTokensOverride;
+                    break;
+                case 'aws-bedrock':
+                    if (!isset($aParameters['inferenceConfig']) || !is_array($aParameters['inferenceConfig']))
+                        $aParameters['inferenceConfig'] = [];
+                    $aParameters['inferenceConfig']['maxTokens'] = $iMaxTokensOverride;
+                    break;
+                default:
+                    $aParameters['parameters']['max_tokens'] = $iMaxTokensOverride;
+                    break;
+            }
+        }
+
+        switch($a['type']) {
+            // regular AI providers ------------------------
+            case 'anthropic':
+                $o = new NeuronAI\Providers\Anthropic\Anthropic(
+                    key: $a['key'],
+                    model: $a['model'],
+                    version: $aParameters['version'] ?? '2023-06-01',
+                    max_tokens: $aParameters['max_tokens'] ?? 8192,
+                    parameters: $aParameters['parameters'] ?? [],
+                );
+                break;
+            case 'openai-responses':
+                $o = new NeuronAI\Providers\OpenAI\Responses\OpenAIResponses(
+                    key: $a['key'],
+                    model: $a['model'],
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'azure-openai':
+                $o = new NeuronAI\Providers\OpenAI\AzureOpenAI(
+                    key: $a['key'],
+                    model: $a['model'],
+                    endpoint: $aParameters['endpoint'],
+                    version: $aParameters['version'],
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'openai-like':
+                $o = new NeuronAI\Providers\OpenAILike(
+                    baseUri: $aParameters['baseUri'],
+                    key: $a['key'],
+                    model: $a['model'],                    
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'ollama':
+                $o = new NeuronAI\Providers\Ollama\Ollama(
+                    url: $aParameters['url'] ?? 'http://localhost:11434/api',
+                    model: $a['model'],                    
+                    parameters: $aParameters['parameters'] ?? [],
+                );
+                break;
+            case 'gemini':
+                $o = new NeuronAI\Providers\Gemini\Gemini(
+                    key: $a['key'],
+                    model: $a['model'],
+                    parameters: $aParameters['parameters'] ?? [],
+                );
+                break;
+            case 'mistral':
+                $o = new NeuronAI\Providers\Mistral\Mistral(
+                    key: $a['key'],
+                    model: $a['model'],
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'huggingface':
+                $o = new NeuronAI\Providers\HuggingFace\HuggingFace(
+                    key: $a['key'],
+                    model: $a['model'],
+                    inferenceProvider: $aParameters['inferenceProvider'] ?? 'hf-inference/models', // cohere, groq, etc - https://github.com/neuron-core/neuron-ai/blob/3.x/src/Providers/HuggingFace/InferenceProvider.php
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'deepseek':
+                $o = new NeuronAI\Providers\Deepseek\Deepseek(
+                    key: $a['key'],
+                    model: $a['model'],
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'grok':
+                $o = new NeuronAI\Providers\XAI\Grok(
+                    key: $a['key'],
+                    model: $a['model'],
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'aws-bedrock':
+                $oClient = new Aws\BedrockRuntime\BedrockRuntimeClient($aParameters['client_params']);
+
+                $o = new NeuronAI\Providers\AWS\BedrockRuntime(
+                    client: $oClient,
+                    model: $a['model'],
+                    inferenceConfig: $aParameters['inferenceConfig'] ?? [],
+                );
+                break;
+            case 'cohere':
+                $o = new NeuronAI\Providers\Cohere\Cohere(
+                    key: $a['key'],
+                    model: $a['model'],
+                    baseUri: $aParameters['baseUri'] ?? 'https://api.cohere.ai/v2',
+                    parameters: $aParameters['parameters'] ?? [],
+                    strict_response: $aParameters['strict_response'] ?? false,
+                );
+                break;
+            case 'una-proxy':
+                $o = new BxDolAIProviderUnaProxy(
+                    sKey: (string)getParam('sys_oauth_key'),
+                    sSecret: (string)getParam('sys_oauth_secret'),
+                    sModel: (string)$a['model'],
+                );
+                break;
+
+            // embeddings models ------------------------
+            case 'ollama-embeddings':
+                $o = new NeuronAI\RAG\Embeddings\OllamaEmbeddingsProvider(
+                    model: $a['model'],
+                    url: $aParameters['url'] ?? 'http://localhost:11434/api',
+                    parameters: $aParameters['parameters'] ?? [],
+                );
+                break;
+            case 'voyageai-embeddings':
+                $o = new NeuronAI\RAG\Embeddings\VoyageEmbeddingsProvider(
+                    key: $a['key'],
+                    model: $a['model'],
+                    dimensions: !empty($aParameters['dimensions']) ? (int)$aParameters['dimensions'] : null
+                );
+                break;
+            case 'openai-embeddings':
+                $o = new NeuronAI\RAG\Embeddings\OpenAIEmbeddingsProvider(
+                    key: $a['key'],
+                    model: $a['model'],
+                    dimensions: !empty($aParameters['dimensions']) ? (int)$aParameters['dimensions'] : 1024
+                );
+                break;
+            case 'openai-like-embeddings':
+                $o = new NeuronAI\RAG\Embeddings\OpenAILikeEmbeddings(
+                    baseUri: $aParameters['baseUri'],
+                    key: $a['key'],
+                    model: $a['model'],                    
+                    dimensions: !empty($aParameters['dimensions']) ? (int)$aParameters['dimensions'] : 1024
+                );
+                break;
+            case 'aws-bedrock-embeddings':
+                $oClient = new Aws\BedrockRuntime\BedrockRuntimeClient($aParameters['client_params']);
+                $o = new NeuronAI\RAG\Embeddings\AwsBedrockEmbeddingsProvider(
+                    client: $oClient,
+                    model: $a['model']
+                );
+                break;
+            default:
+                bx_log('sys_agents', "Model type {$a['type']} is not supported", BX_LOG_ERR);
+                throw new Exception("Model type {$a['type']} is not supported");
+        }
+
+        $GLOBALS['bxDolClasses'][$sCacheKey] = $o;
+
+        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
+    {
+        // una-proxy signs with the site UNA key. An empty provider key is expected.
+        $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];
+    }
+}

+ 56 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIProviderUnaProxy.php

@@ -0,0 +1,56 @@
+<?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
+ * @{
+ */
+
+class BxDolAIProviderUnaProxy extends NeuronAI\Providers\OpenAI\OpenAI
+{
+    protected string $baseUri = 'https://api.unacms.com/m/ai_proxy/v1';
+
+    /**
+     * null reads the AI Proxy setting. A string, including empty, is used as given.
+     */
+    public function __construct(
+        string $sKey,
+        string $sSecret,
+        string $sModel,
+        ?NeuronAI\HttpClient\HttpClientInterface $httpClient = null,
+        protected ?string $sPrompt = null,
+    ) {
+        if ($sKey === '' || $sSecret === '')
+            throw new Exception(_t('_sys_agents_una_proxy_unconfigured'));
+
+        parent::__construct(
+            key: '',
+            model: $sModel,
+            parameters: [],
+            strict_response: false,
+            httpClient: $httpClient ?? new BxDolAIUnaProxyHttpClient($sKey, $sSecret),
+        );
+    }
+
+    public function systemPrompt(?string $prompt): NeuronAI\Providers\AIProviderInterface
+    {
+        $sOperator = trim($this->operatorPrompt());
+        if ($sOperator !== '') {
+            $sClient = trim((string)$prompt);
+            $prompt = $sClient === '' ? $sOperator : $sOperator . "\n\n" . $sClient;
+        }
+
+        return parent::systemPrompt($prompt);
+    }
+
+    protected function operatorPrompt(): string
+    {
+        if ($this->sPrompt !== null)
+            return $this->sPrompt;
+
+        return (string)getParam('bx_ai_proxy_prompt');
+    }
+}
+
+/** @} */

+ 1111 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolAgentCreate.php

@@ -0,0 +1,1111 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Operator tool: create / update / catalog Studio agents (sys_agents_agents).
+ * mysql_write_safe cannot touch that table — use this instead.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolAgentCreate extends BxDolAITool
+{
+    protected const TRIGGERS = ['alert', 'scheduler', 'webhook', 'manual', 'agent', 'message', 'form-input'];
+    protected const PRESETS = ['comment_reply', 'manual', 'message', 'custom'];
+    protected const COMMENT_ACTIONS = ['commentPost', 'comment_added', 'commentAdded', 'cmt_post'];
+    protected const FORBIDDEN_TOOL_TYPES = ['mysql_write'];
+    protected const CLIP = 20000;
+    protected const PROFILE_NAME_MAX = 40;
+    protected const VERSION = 4;
+
+    /** @var int[]|null see _allowedAccounts() */
+    protected $_aAllowedAccounts = null;
+
+    /** @var array<string,string>|null lowercase label => sys_modules.name */
+    protected $_aModuleNames = null;
+
+    protected const COMMENT_TOOLS = ['comments_get', 'comments_add', 'comment_get', 'content_get'];
+
+    protected const COMMENT_PROMPT_TOOLS = "You run on a UNA commentPost alert. The user message is JSON: object_id, sender_profile_id, extra (comment id/text/system).\nIf sender_profile_id or extra.author / extra.author_id equals this agent's profile_id, do nothing — never reply to yourself (prevents loops).\nRead context with comments_get / content_get. Post at most one reply with comments_add on the same object. Then stop.\nMatch the comment language. Do not mention that you are an agent unless asked.";
+
+    public function __construct()
+    {
+        parent::__construct(
+            'agent_create',
+            'Create or update a Studio agent (sys_agents_agents). Do NOT use mysql_write on that table. '
+            . 'Never ask the user for a numeric profile_id. action=catalog returns profiles and profile_buttons — call chat_buttons with exactly those labels. '
+            . 'Existing: profile=display name or id. New bot: profile_new=Name (creates a Persons profile under the preset Robot account, sys_profile_bot). create without profile/profile_new is refused. '
+            . 'preset=comment_reply for “reply to every comment”: trigger=alert, async=1, comments_* tools. '
+            . 'Pass module as one name from installed content modules: ' . $this->getContentModules() . '. Or alert as unit:action (for example bx_forum:commentPost). '
+            . 'Show the plan in the site language. Confirm with chat_buttons whose reply labels are exactly "' . _t('_Yes') . '" and "' . _t('_No') . '". Wait for ' . _t('_Yes') . ', then create. Prompt goes in prompt_system.'
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty('action', PropertyType::STRING, 'catalog | create | update. catalog = list alerts/tools/models/agents. create = insert. update = change prompt/tools/alert/active of agent_id.', true),
+            new ToolProperty('preset', PropertyType::STRING, 'comment_reply | manual | message | custom. comment_reply = alert agent that replies to comments.', false),
+            new ToolProperty('title', PropertyType::STRING, 'Human title. Required for create.', false),
+            new ToolProperty('name', PropertyType::STRING, 'Unique machine name. Auto from title if empty.', false),
+            new ToolProperty('description', PropertyType::STRING, 'Operator notes.', false),
+            new ToolProperty('prompt_system', PropertyType::STRING, 'System / role prompt. Required for create. This is what the new agent will follow.', false),
+            new ToolProperty('prompt_steps', PropertyType::STRING, 'Optional steps prompt.', false),
+            new ToolProperty('prompt_output', PropertyType::STRING, 'Optional output prompt.', false),
+            new ToolProperty('prompt_tools', PropertyType::STRING, 'Optional extra tool rules. comment_reply appends loop-prevention rules.', false),
+            new ToolProperty('trigger', PropertyType::STRING, 'alert | scheduler | webhook | manual | agent | message | form-input. Default from preset (comment_reply → alert).', false),
+            new ToolProperty('alert', PropertyType::STRING, 'unit:action from sys_alerts_log, e.g. bx_forum:commentPost. Required for trigger=alert unless module is set.', false),
+            new ToolProperty('module', PropertyType::STRING, 'Installed module: module from installed modules list.', false),
+            new ToolProperty('tools', PropertyType::STRING, 'CSV of tool types or ids (comments_add,comments_get). comment_reply fills comments_* if empty.', false),
+            new ToolProperty('model_id', PropertyType::INTEGER, 'Chat model id. Default = operator agent / sys_agents_model.', false),
+            new ToolProperty('profile_id', PropertyType::INTEGER, 'Numeric profile id. Prefer profile (name) or profile_new. Do not ask the user for this number.', false),
+            new ToolProperty('profile', PropertyType::STRING, 'Existing profile the agent acts as: display name, @uri, or id. From catalog.profiles. Do not ask the user for a number.', false),
+            new ToolProperty('profile_new', PropertyType::STRING, 'Short display name for a NEW Persons profile, like a person name: 1–3 words, max 40 chars. Examples: Posts Bot. NEVER a prompt, sentence, or agent instructions.', false),
+            new ToolProperty('vector_store_id', PropertyType::INTEGER, 'Knowledge store id. Default = operator / first active store / 0.', false),
+            new ToolProperty('async', PropertyType::INTEGER, '1 = delayed (use for alert comment replies so the post is not blocked). Default 1 for alert.', false),
+            new ToolProperty('active', PropertyType::INTEGER, '1 = run immediately. Default 1.', false),
+            new ToolProperty('scheduler_cron', PropertyType::STRING, 'CRON string for trigger=scheduler.', false),
+            new ToolProperty('webhook_key', PropertyType::STRING, 'Bearer key for trigger=webhook.', false),
+            new ToolProperty('message_profile_id', PropertyType::INTEGER, 'Accepted sender for trigger=message. 0 = any.', false),
+            new ToolProperty('form_object', PropertyType::STRING, 'Form object for trigger=form-input.', false),
+            new ToolProperty('form_input', PropertyType::STRING, 'Form input name for trigger=form-input.', false),
+            new ToolProperty('agent_id', PropertyType::INTEGER, 'Existing sys_agents_agents.id for update.', false),
+            new ToolProperty('query', PropertyType::STRING, 'catalog filter: alert unit/action, tool type, agent name.', false),
+            new ToolProperty('dry_run', PropertyType::BOOLEAN, 'If true, validate and return the row without writing.', false),
+        ];
+    }
+
+    public function __invoke(
+        $action = 'catalog',
+        $preset = '',
+        $title = '',
+        $name = '',
+        $description = '',
+        $prompt_system = '',
+        $prompt_steps = '',
+        $prompt_output = '',
+        $prompt_tools = '',
+        $trigger = '',
+        $alert = '',
+        $module = '',
+        $tools = '',
+        $model_id = 0,
+        $profile_id = 0,
+        $profile = '',
+        $profile_new = '',
+        $vector_store_id = 0,
+        $async = null,
+        $active = 1,
+        $scheduler_cron = '',
+        $webhook_key = '',
+        $message_profile_id = 0,
+        $form_object = '',
+        $form_input = '',
+        $agent_id = 0,
+        $query = '',
+        $dry_run = false
+    ): array {
+        $sAction = strtolower(trim((string)$action));
+        if ($sAction === 'list')
+            $sAction = 'catalog';
+        if (!in_array($sAction, ['catalog', 'create', 'update'], true))
+            return ['ok' => 0, 'error' => 'action must be catalog, create, or update'];
+
+        try {
+            if ($sAction === 'catalog')
+                return $this->_catalog((string)$query, (string)$preset, (string)$module);
+            if ($sAction === 'update')
+                return $this->_update((int)$agent_id, [
+                    'title' => $title,
+                    'description' => $description,
+                    'prompt_system' => $prompt_system,
+                    'prompt_steps' => $prompt_steps,
+                    'prompt_output' => $prompt_output,
+                    'prompt_tools' => $prompt_tools,
+                    'tools' => $tools,
+                    'alert' => $alert,
+                    'module' => $module,
+                    'async' => $async,
+                    'active' => $active,
+                    'preset' => $preset,
+                    'profile' => $profile,
+                    'profile_id' => $profile_id,
+                    'profile_new' => $profile_new,
+                ], $this->_isDry($dry_run));
+
+            return $this->_create([
+                'preset' => $preset,
+                'title' => $title,
+                'name' => $name,
+                'description' => $description,
+                'prompt_system' => $prompt_system,
+                'prompt_steps' => $prompt_steps,
+                'prompt_output' => $prompt_output,
+                'prompt_tools' => $prompt_tools,
+                'trigger' => $trigger,
+                'alert' => $alert,
+                'module' => $module,
+                'tools' => $tools,
+                'model_id' => $model_id,
+                'profile_id' => $profile_id,
+                'vector_store_id' => $vector_store_id,
+                'async' => $async,
+                'active' => $active,
+                'scheduler_cron' => $scheduler_cron,
+                'webhook_key' => $webhook_key,
+                'message_profile_id' => $message_profile_id,
+                'form_object' => $form_object,
+                'form_input' => $form_input,
+                'profile' => $profile,
+                'profile_new' => $profile_new,
+            ], $this->_isDry($dry_run));
+        } catch (Throwable $o) {
+            return ['ok' => 0, 'error' => $o->getMessage()];
+        }
+    }
+
+    protected function _isDry($dry_run): bool
+    {
+        return $dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true';
+    }
+
+    protected function _catalog(string $sQuery, string $sPreset, string $sModule): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $sQ = mb_strtolower(trim($sQuery));
+        $sMod = $this->_resolveModule($sModule !== '' ? $sModule : $sQuery);
+
+        $aAlerts = [];
+        if ($oDb->isTableExists('sys_alerts_log')) {
+            $aRows = $oDb->getAll("SELECT `unit`, `action`, `counter_24h` FROM `sys_alerts_log` ORDER BY `counter_24h` DESC, `unit`, `action` LIMIT 400");
+            foreach ($aRows as $aRow) {
+                $sKey = $aRow['unit'] . ':' . $aRow['action'];
+                $sHay = mb_strtolower($sKey . ' ' . ($aRow['unit'] ?? '') . ' ' . ($aRow['action'] ?? ''));
+                $bComment = in_array($aRow['action'], self::COMMENT_ACTIONS, true);
+                if ($sQ !== '' && strpos($sHay, $sQ) === false && !($sMod && $aRow['unit'] === $sMod) && !($sPreset === 'comment_reply' && $bComment))
+                    continue;
+                if ($sPreset === 'comment_reply' && $sQ === '' && $sModule === '' && !$bComment)
+                    continue;
+                if ($sMod && $aRow['unit'] !== $sMod && strpos($sHay, $sQ) === false)
+                    continue;
+                $aAlerts[] = [
+                    'alert' => $sKey,
+                    'fired_24h' => (int)$aRow['counter_24h'],
+                    'desc' => method_exists('BxDolAiQuery', 'getAlertDesc') ? (string)BxDolAiQuery::getAlertDesc($sKey) : '',
+                ];
+                if (count($aAlerts) >= 40)
+                    break;
+            }
+        }
+
+        $aTools = [];
+        $aToolRows = $oDb->getAll("SELECT `id`, `type`, `title`, `active` FROM `sys_agents_tools` ORDER BY `type` ASC");
+        foreach ($aToolRows as $aRow) {
+            if (in_array($aRow['type'], BxDolAiAgentTools::hiddenTypes(), true))
+                continue;
+            $sHay = mb_strtolower($aRow['id'] . ' ' . $aRow['type'] . ' ' . $aRow['title']);
+            if ($sQ !== '' && strpos($sHay, $sQ) === false && $sPreset !== 'comment_reply')
+                continue;
+            if ($sPreset === 'comment_reply' && $sQ === '' && !in_array($aRow['type'], self::COMMENT_TOOLS, true))
+                continue;
+            $aTools[] = [
+                'id' => (int)$aRow['id'],
+                'type' => $aRow['type'],
+                'title' => $aRow['title'],
+                'active' => (int)$aRow['active'],
+            ];
+        }
+
+        $aModels = $oDb->getAll("SELECT `id`, `type`, `model`, `title`, `capabilities`, `active` FROM `sys_agents_models` WHERE `active` = 1 AND (`capabilities` LIKE '%chatllm%' OR `capabilities` LIKE '%chatvlm%') ORDER BY `title` ASC LIMIT 30");
+        $aStores = $oDb->isTableExists('sys_agents_vector_store')
+            ? $oDb->getAll("SELECT `id`, `title`, `type`, `active` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `title` ASC LIMIT 20")
+            : [];
+        $aAgents = $oDb->getAll("SELECT `id`, `name`, `title`, `trigger`, `alert`, `active`, `profile_id`, `model_id` FROM `sys_agents_agents` ORDER BY `id` DESC LIMIT 30");
+        if ($sQ !== '') {
+            $aAgents = array_values(array_filter($aAgents, function ($aRow) use ($sQ) {
+                $sHay = mb_strtolower(($aRow['name'] ?? '') . ' ' . ($aRow['title'] ?? '') . ' ' . ($aRow['alert'] ?? '') . ' ' . ($aRow['trigger'] ?? ''));
+                return strpos($sHay, $sQ) !== false;
+            }));
+        }
+
+        $aDefaults = $this->_defaults();
+        $aProfiles = $this->_listProfiles('');
+        $sSuggest = $this->_suggestProfileName($sPreset);
+
+        return [
+            'ok' => 1,
+            'version' => self::VERSION,
+            'action' => 'catalog',
+            'profiles' => $aProfiles,
+            'profiles_count' => count($aProfiles),
+            'suggested_new_name' => $sSuggest,
+            'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
+            'profile_hint' => 'Call chat_buttons now with exactly profile_buttons as reply labels (one per line, no other text). Tap on a name → profile=<name>; tap on «' . _t('_sys_agents_profile_btn_new', $sSuggest) . '» → profile_new=' . $sSuggest . '. create without profile/profile_new is refused.',
+            'defaults' => $aDefaults,
+            'presets' => self::PRESETS,
+            'comment_reply_hint' => 'Pick an alert, example - bx_forum:commentPost (module=bx_forum), module bx_forum must be installed. Tools: comments_get,comments_add,comment_get,content_get. async=1.',
+            'alerts' => $aAlerts,
+            'tools' => $aTools,
+            'models' => $aModels,
+            'vector_stores' => $aStores,
+            'agents' => $aAgents,
+        ];
+    }
+
+    protected function _create(array $aIn, bool $bDry): array
+    {
+        $sPreset = $this->_preset($aIn['preset'] ?? '');
+        $sTitle = trim((string)($aIn['title'] ?? ''));
+        if ($sTitle === '')
+            return ['ok' => 0, 'error' => 'title is required'];
+
+        $sPrompt = $this->_clip((string)($aIn['prompt_system'] ?? ''));
+        if (trim($sPrompt) === '')
+            return ['ok' => 0, 'error' => 'prompt_system is required — this is what the new agent will do'];
+
+        $sTrigger = strtolower(trim((string)($aIn['trigger'] ?? '')));
+        if ($sTrigger === '')
+            $sTrigger = $this->_triggerForPreset($sPreset);
+        if (!in_array($sTrigger, self::TRIGGERS, true))
+            return ['ok' => 0, 'error' => 'unknown trigger. Use: ' . implode(', ', self::TRIGGERS)];
+
+        $aDefaults = $this->_defaults();
+        $iModel = (int)($aIn['model_id'] ?? 0) ?: (int)$aDefaults['model_id'];
+        $sProfileIn = trim((string)($aIn['profile'] ?? ''));
+        $sProfileNewIn = trim((string)($aIn['profile_new'] ?? ''));
+        if ($sProfileIn === '' && $sProfileNewIn === '' && (int)($aIn['profile_id'] ?? 0) <= 0) {
+            // never fall back to the operator's own profile silently — the user must pick a name or a new one
+            $aProfiles = $this->_listProfiles('');
+            $sSuggest = $this->_suggestProfileName($sPreset);
+            return [
+                'ok' => 0,
+                'error' => _t('_sys_agents_profile_required_err', _t('_sys_agents_profile_btn_new', $sSuggest)),
+                'profiles' => $aProfiles,
+                'suggested_new_name' => $sSuggest,
+                'profile_buttons' => $this->_profileButtons($aProfiles, $sSuggest),
+            ];
+        }
+
+        $aProf = $this->_resolveProfile($sProfileIn, $aIn['profile_id'] ?? 0, $sProfileNewIn, $bDry);
+        if (!empty($aProf['error']))
+            return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
+        $iProfile = (int)($aProf['profile_id'] ?? 0);
+        $iStore = (int)($aIn['vector_store_id'] ?? 0) ?: (int)$aDefaults['vector_store_id'];
+
+        if ($iModel <= 0)
+            return ['ok' => 0, 'error' => 'no chat model. Pass model_id or activate a chatllm model.'];
+        if ($iProfile <= 0 && empty($aProf['dry_create']))
+            return ['ok' => 0, 'error' => 'profile could not be resolved. Pass profile=<name from catalog.profiles> or profile_new=<name>.'];
+
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->getRow("SELECT `id` FROM `sys_agents_models` WHERE `id` = :id AND `active` = 1 LIMIT 1", ['id' => $iModel]))
+            return ['ok' => 0, 'error' => "model_id {$iModel} is missing or inactive"];
+
+        $sAlert = '';
+        $aAlertPick = null;
+        if ($sTrigger === 'alert') {
+            $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $sPreset);
+            if (!empty($aAlertPick['error']))
+                return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
+            $sAlert = $aAlertPick['alert'];
+        }
+
+        $sToolsCsv = $this->_resolveToolsCsv((string)($aIn['tools'] ?? ''), $sPreset);
+        if (is_array($sToolsCsv) && isset($sToolsCsv['error']))
+            return $sToolsCsv;
+
+        if ($sTrigger === 'message') {
+            $aDup = $oDb->getRow("SELECT `id`, `name` FROM `sys_agents_agents` WHERE `trigger` = 'message' AND `profile_id` = :p LIMIT 1", ['p' => $iProfile]);
+            if ($aDup)
+                return ['ok' => 0, 'error' => "profile_id {$iProfile} already has message-agent {$aDup['id']} ({$aDup['name']}). One message-agent per profile."];
+        }
+
+        if ($sTrigger === 'scheduler' && trim((string)($aIn['scheduler_cron'] ?? '')) === '')
+            return ['ok' => 0, 'error' => 'scheduler_cron is required for trigger=scheduler'];
+        if ($sTrigger === 'webhook' && trim((string)($aIn['webhook_key'] ?? '')) === '')
+            return ['ok' => 0, 'error' => 'webhook_key is required for trigger=webhook'];
+        if ($sTrigger === 'form-input' && (trim((string)($aIn['form_object'] ?? '')) === '' || trim((string)($aIn['form_input'] ?? '')) === ''))
+            return ['ok' => 0, 'error' => 'form_object and form_input are required for trigger=form-input'];
+
+        $iAsync = $aIn['async'];
+        if ($iAsync === null || $iAsync === '')
+            $iAsync = in_array($sTrigger, ['alert', 'webhook', 'message'], true) ? 1 : 0;
+        $iAsync = (int)$iAsync ? 1 : 0;
+        if (in_array($sTrigger, ['manual', 'scheduler'], true))
+            $iAsync = 0;
+
+        $iActive = isset($aIn['active']) && $aIn['active'] !== '' && $aIn['active'] !== null
+            ? ((int)$aIn['active'] ? 1 : 0)
+            : 1;
+
+        $sName = $this->_uniqueName((string)($aIn['name'] ?? ''), $sTitle);
+        $sPromptTools = $this->_clip((string)($aIn['prompt_tools'] ?? ''));
+        if ($sPreset === 'comment_reply') {
+            $sPromptTools = trim($sPromptTools . "\n\n" . self::COMMENT_PROMPT_TOOLS);
+        }
+
+        $sPromptSteps = $this->_clip((string)($aIn['prompt_steps'] ?? ''));
+        if ($sPreset === 'comment_reply' && $sPromptSteps === '') {
+            $sPromptSteps = "1. Parse the alert JSON.\n2. Skip if the comment author is this agent's profile_id.\n3. Read the parent object and recent comments.\n4. Write one reply with comments_add.\n5. Stop. Do not loop.";
+        }
+
+        $aRow = [
+            'title' => $this->_clip($sTitle, 255),
+            'name' => $sName,
+            'description' => $this->_clip((string)($aIn['description'] ?? ''), 2000),
+            'icon' => '',
+            'model_id' => $iModel,
+            'profile_id' => $iProfile,
+            'acl_levels' => 0,
+            'trigger' => $sTrigger,
+            'async' => $iAsync,
+            'alert' => $sAlert,
+            'scheduler_cron' => trim((string)($aIn['scheduler_cron'] ?? '')),
+            'webhook_key' => trim((string)($aIn['webhook_key'] ?? '')),
+            'message_profile_id' => (int)($aIn['message_profile_id'] ?? 0),
+            'form_object' => trim((string)($aIn['form_object'] ?? '')),
+            'form_input' => trim((string)($aIn['form_input'] ?? '')),
+            'max_turns' => 40,
+            'chat_ttl_min' => 0,
+            'max_input_chars' => 2000,
+            'max_tokens' => 1024,
+            'max_sessions_per_hour' => 3,
+            'max_sessions_per_day' => 10,
+            'vector_store_id' => $iStore,
+            'chat_history_context' => $sTrigger === 'alert' ? 0 : 50000,
+            'tools' => $sToolsCsv,
+            'tools_max_run' => $sPreset === 'comment_reply' ? 8 : 10,
+            'prompt_system' => $sPrompt,
+            'prompt_steps' => $sPromptSteps,
+            'prompt_output' => $this->_clip((string)($aIn['prompt_output'] ?? '')),
+            'prompt_tools' => $sPromptTools,
+            'added' => time(),
+            'active' => $iActive,
+        ];
+
+        $aRow = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aRow);
+
+        $aOut = [
+            'ok' => 1,
+            'action' => $bDry ? 'dry_run' : 'create',
+            'preset' => $sPreset,
+            'studio' => '/studio/agents.php?page=agents',
+            'row' => [
+                'name' => $aRow['name'],
+                'title' => $aRow['title'],
+                'trigger' => $aRow['trigger'],
+                'alert' => $aRow['alert'] ?? '',
+                'async' => (int)($aRow['async'] ?? 0),
+                'active' => (int)$aRow['active'],
+                'model_id' => (int)$aRow['model_id'],
+                'profile_id' => (int)$aRow['profile_id'],
+                'profile_name' => $aProf['name'] ?? '',
+                'profile_created' => (int)($aProf['created'] ?? 0),
+                'vector_store_id' => (int)($aRow['vector_store_id'] ?? 0),
+                'tools' => $aRow['tools'],
+                'prompt_system_chars' => strlen($aRow['prompt_system']),
+            ],
+        ];
+        if (!empty($aAlertPick['warning']))
+            $aOut['warning'] = $aAlertPick['warning'];
+
+        if ($bDry)
+            return $aOut;
+
+        $mixed = $oDb->query("INSERT INTO `sys_agents_agents` SET " . $oDb->arrayToSQL($aRow));
+        if ($mixed === false)
+            return ['ok' => 0, 'error' => 'INSERT failed'];
+
+        $iId = (int)$oDb->lastId();
+        $oDb->cleanCache('sys_agents_with_alert');
+        $this->_logSql('insert', 'sys_agents_agents', 'id', (string)$iId, 'INSERT agent ' . $aRow['name'], null, $aRow);
+
+        $aOut['id'] = $iId;
+        $aOut['row']['id'] = $iId;
+        $aOut['next'] = $iActive
+            ? 'Agent is active. Test the event (post a comment) or open Studio Agents grid.'
+            : 'Created inactive. action=update agent_id=' . $iId . ' active=1 to turn on.';
+        return $aOut;
+    }
+
+    protected function _update(int $iId, array $aIn, bool $bDry): array
+    {
+        if ($iId <= 0)
+            return ['ok' => 0, 'error' => 'agent_id is required for update'];
+
+        $oDb = BxDolDb::getInstance();
+        $aBefore = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
+        if (!$aBefore)
+            return ['ok' => 0, 'error' => "agent {$iId} not found"];
+
+        $aSet = [];
+        foreach (['title', 'description', 'prompt_system', 'prompt_steps', 'prompt_output', 'prompt_tools'] as $sF) {
+            if (isset($aIn[$sF]) && $aIn[$sF] !== '' && $aIn[$sF] !== null)
+                $aSet[$sF] = $this->_clip((string)$aIn[$sF], $sF === 'title' ? 255 : self::CLIP);
+        }
+
+        if (isset($aIn['tools']) && $aIn['tools'] !== '' && $aIn['tools'] !== null) {
+            $sCsv = $this->_resolveToolsCsv((string)$aIn['tools'], $this->_preset($aIn['preset'] ?? ''));
+            if (is_array($sCsv) && isset($sCsv['error']))
+                return $sCsv;
+            $aSet['tools'] = $sCsv;
+        }
+
+        if ((isset($aIn['alert']) && $aIn['alert'] !== '') || (isset($aIn['module']) && $aIn['module'] !== '')) {
+            $aAlertPick = $this->_resolveAlert((string)($aIn['alert'] ?? ''), (string)($aIn['module'] ?? ''), $this->_preset($aIn['preset'] ?? 'comment_reply'));
+            if (!empty($aAlertPick['error']))
+                return ['ok' => 0, 'error' => $aAlertPick['error'], 'candidates' => $aAlertPick['candidates'] ?? []];
+            $aSet['alert'] = $aAlertPick['alert'];
+            $aSet['trigger'] = 'alert';
+        }
+
+        if ($aIn['async'] !== null && $aIn['async'] !== '')
+            $aSet['async'] = (int)$aIn['async'] ? 1 : 0;
+        if ($aIn['active'] !== null && $aIn['active'] !== '')
+            $aSet['active'] = (int)$aIn['active'] ? 1 : 0;
+
+        $bWantProfile = (isset($aIn['profile']) && $aIn['profile'] !== '' && $aIn['profile'] !== null)
+            || (isset($aIn['profile_new']) && $aIn['profile_new'] !== '' && $aIn['profile_new'] !== null)
+            || !empty($aIn['profile_id']);
+        $aProf = ['name' => '', 'created' => 0];
+        if ($bWantProfile) {
+            $aProf = $this->_resolveProfile($aIn['profile'] ?? '', $aIn['profile_id'] ?? 0, $aIn['profile_new'] ?? '', $bDry);
+            if (!empty($aProf['error']))
+                return ['ok' => 0, 'error' => $aProf['error'], 'candidates' => $aProf['candidates'] ?? []];
+            if (!empty($aProf['profile_id']))
+                $aSet['profile_id'] = (int)$aProf['profile_id'];
+        }
+
+        if (!$aSet)
+            return ['ok' => 0, 'error' => 'nothing to update'];
+
+        $aSet = $this->_onlyExistingColumns($oDb, 'sys_agents_agents', $aSet);
+
+        if ($bDry) {
+            return ['ok' => 1, 'action' => 'dry_run', 'id' => $iId, 'set' => array_keys($aSet)];
+        }
+
+        $b = $oDb->query("UPDATE `sys_agents_agents` SET " . $oDb->arrayToSQL($aSet) . " WHERE `id` = :id", ['id' => $iId]);
+        if ($b === false)
+            return ['ok' => 0, 'error' => 'UPDATE failed'];
+
+        $oDb->cleanCache('sys_agents_with_alert');
+        $aAfter = $oDb->getRow("SELECT * FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iId]);
+        $this->_logSql('update', 'sys_agents_agents', 'id', (string)$iId, 'UPDATE agent ' . $iId, $aBefore, $aAfter ?: null);
+
+        return [
+            'ok' => 1,
+            'action' => 'update',
+            'id' => $iId,
+            'name' => $aAfter['name'] ?? $aBefore['name'],
+            'changed' => array_keys($aSet),
+            'active' => (int)($aAfter['active'] ?? 0),
+            'profile_id' => (int)($aAfter['profile_id'] ?? $aBefore['profile_id'] ?? 0),
+            'profile_name' => $aProf['name'] ?? '',
+            'profile_created' => (int)($aProf['created'] ?? 0),
+            'studio' => '/studio/agents.php?page=agents',
+        ];
+    }
+
+    protected function _preset($s): string
+    {
+        $s = strtolower(trim((string)$s));
+        if ($s === '' || $s === 'custom')
+            return 'custom';
+        if (in_array($s, self::PRESETS, true))
+            return $s;
+        return 'custom';
+    }
+
+    protected function _triggerForPreset(string $sPreset): string
+    {
+        if ($sPreset === 'comment_reply')
+            return 'alert';
+        if ($sPreset === 'message')
+            return 'message';
+        if ($sPreset === 'manual')
+            return 'manual';
+        return 'alert';
+    }
+
+    protected function _resolveModule(string $s): string
+    {
+        $s = trim($s);
+        if ($s === '')
+            return '';
+        $sLow = mb_strtolower($s);
+        $aNames = $this->_moduleNames();
+        if (isset($aNames[$sLow]))
+            return $aNames[$sLow];
+        if (preg_match('/^[a-z0-9_]+$/i', $s))
+            return $s;
+        return '';
+    }
+
+    /**
+     * Labels that mean a module: machine name, uri, install title, and the current-language title from _t().
+     * Enabled modules claim a label before disabled ones. A name or uri is never overwritten by a title.
+     *
+     * @return array<string,string>
+     */
+    protected function _moduleNames(): array
+    {
+        if ($this->_aModuleNames !== null)
+            return $this->_aModuleNames;
+
+        $this->_aModuleNames = [];
+        $oDb = BxDolDb::getInstance();
+        $aRows = $oDb->getAll("SELECT `name`, `uri`, `title` FROM `sys_modules` WHERE `name` <> '' ORDER BY `enabled` DESC, `name` ASC");
+        foreach ($aRows as $aRow)
+            $this->_rememberModule((string)$aRow['name'], (string)$aRow['name']);
+        foreach ($aRows as $aRow)
+            $this->_rememberModule((string)$aRow['uri'], (string)$aRow['name']);
+        foreach ($aRows as $aRow) {
+            $sName = (string)$aRow['name'];
+            $this->_rememberModule((string)$aRow['title'], $sName);
+            $sKey = BxDolModule::getTitleKey((string)$aRow['uri']);
+            $sTitle = _t($sKey);
+            if ($sTitle !== $sKey)
+                $this->_rememberModule($sTitle, $sName);
+        }
+
+        return $this->_aModuleNames;
+    }
+
+    protected function _rememberModule(string $sLabel, string $sName): void
+    {
+        $sLabel = mb_strtolower(trim($sLabel));
+        if ($sLabel === '' || isset($this->_aModuleNames[$sLabel]))
+            return;
+        $this->_aModuleNames[$sLabel] = $sName;
+    }
+
+    protected function _resolveAlert(string $sAlert, string $sModule, string $sPreset): array
+    {
+        $sAlert = trim($sAlert);
+        $sAlert = str_replace([' ', '/', '|'], ':', $sAlert);
+        $sAlert = preg_replace('/:+/', ':', $sAlert);
+
+        if ($sAlert !== '' && preg_match('/^[a-zA-Z0-9_]+:[a-zA-Z0-9_]+$/', $sAlert)) {
+            $aWarn = $this->_alertInLog($sAlert) ? '' : 'Alert is not in sys_alerts_log yet (never fired). It will run once that unit:action happens.';
+            return ['alert' => $sAlert, 'warning' => $aWarn];
+        }
+
+        $sMod = $this->_resolveModule($sModule !== '' ? $sModule : $sAlert);
+        $aCandidates = $this->_commentAlerts($sMod);
+
+        if ($sPreset === 'comment_reply' || $sMod !== '') {
+            if (count($aCandidates) === 1)
+                return ['alert' => $aCandidates[0]['alert']];
+            if (count($aCandidates) > 1)
+                return ['error' => 'Several comment alerts match. Pass alert=unit:action.', 'candidates' => $aCandidates];
+            if ($sMod !== '')
+                return ['error' => "No comment alert for module {$sMod} in sys_alerts_log. Someone must post a comment once, then retry, or pass alert explicitly (e.g. {$sMod}:commentPost).", 'candidates' => []];
+        }
+
+        return ['error' => 'Pass alert as unit:action (bx_forum:commentPost) or module=discussions / bx_forum. action=catalog preset=comment_reply to list.', 'candidates' => $aCandidates];
+    }
+
+    protected function _commentAlerts(string $sMod): array
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('sys_alerts_log'))
+            return [];
+
+        $aParams = [];
+        $sWhere = "`action` IN (" . $oDb->implode_escape(self::COMMENT_ACTIONS) . ")";
+        if ($sMod !== '') {
+            $sWhere .= " AND `unit` = :u";
+            $aParams['u'] = $sMod;
+        }
+        $aRows = $oDb->getAll("SELECT `unit`, `action`, `counter_24h` FROM `sys_alerts_log` WHERE {$sWhere} ORDER BY `counter_24h` DESC LIMIT 20", $aParams);
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            $aOut[] = [
+                'alert' => $aRow['unit'] . ':' . $aRow['action'],
+                'fired_24h' => (int)$aRow['counter_24h'],
+            ];
+        }
+        return $aOut;
+    }
+
+    protected function _alertInLog(string $sAlert): bool
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('sys_alerts_log'))
+            return false;
+        $a = explode(':', $sAlert, 2);
+        $s = $oDb->getOne("SELECT CONCAT(`unit`, ':', `action`) FROM `sys_alerts_log` WHERE `unit` = :u AND `action` = :a LIMIT 1", [
+            'u' => $a[0] ?? '',
+            'a' => $a[1] ?? '',
+        ]);
+        return (bool)$s;
+    }
+
+    protected function _listProfiles(string $sQuery = ''): array
+    {
+        $aOut = [];
+        $aSeen = [];
+        $oDb = BxDolDb::getInstance();
+
+        // each source is isolated: one failing query must not empty the whole list
+        $aDefaults = $this->_defaults();
+        $this->_pushProfile($aOut, $aSeen, (int)$aDefaults['profile_id'], '', 'default');
+
+        try {
+            // profiles of the preset Robot account (sys_profile_bot) go first — new profiles are created there
+            $iBotAccount = $this->_botAccount();
+            if ($iBotAccount > 0) {
+                $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iBotAccount]);
+                foreach ($aRows as $aRow)
+                    $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'robot');
+            }
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (robot account): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            $iOpAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => (int)$aDefaults['profile_id']]);
+            if ($iOpAccount > 0) {
+                $aRows = $oDb->getAll("SELECT `id` FROM `sys_profiles` WHERE `account_id` = :a AND `status` = 'active' ORDER BY `id` ASC LIMIT 50", ['a' => $iOpAccount]);
+                foreach ($aRows as $aRow)
+                    $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
+            }
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (account): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            if (function_exists('bx_srv')) {
+                $aOpts = bx_srv('system', 'get_options_agents_profile', [false], 'TemplServices');
+                if (is_array($aOpts)) {
+                    foreach ($aOpts as $aOpt)
+                        $this->_pushProfile($aOut, $aSeen, (int)($aOpt['key'] ?? 0), (string)($aOpt['value'] ?? ''), 'operator');
+                }
+            }
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (service): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            $iRoleAdmin = defined('BX_DOL_ROLE_ADMIN') ? (int)BX_DOL_ROLE_ADMIN : 2;
+            $aRows = $oDb->getAll("SELECT p.`id` FROM `sys_profiles` p INNER JOIN `sys_accounts` a ON a.`id` = p.`account_id` WHERE (a.`role` & :r) AND p.`status` = 'active' ORDER BY p.`id` ASC LIMIT 50", [
+                'r' => $iRoleAdmin,
+            ]);
+            foreach ($aRows as $aRow)
+                $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], '', 'operator');
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (admins): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        try {
+            $aUsed = $oDb->getColumn("SELECT DISTINCT `profile_id` FROM `sys_agents_agents` WHERE `profile_id` > 0 ORDER BY `profile_id` DESC LIMIT 30");
+            foreach ($aUsed as $iId)
+                $this->_pushProfile($aOut, $aSeen, (int)$iId, '', 'agent');
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'agent_create profiles (agents): ' . $o->getMessage(), BX_LOG_WARN);
+        }
+
+        $sQ = trim($sQuery);
+        if ($sQ !== '' && mb_strlen($sQ) <= 80 && $oDb->isTableExists('bx_persons_data')) {
+            $aRows = $oDb->getAll("SELECT p.`id`, d.`fullname` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE d.`fullname` LIKE :q AND p.`status` = 'active'" . $this->_allowedAccountsSql() . " ORDER BY d.`fullname` ASC LIMIT 20", [
+                'q' => '%' . $sQ . '%',
+            ]);
+            foreach ($aRows as $aRow)
+                $this->_pushProfile($aOut, $aSeen, (int)$aRow['id'], (string)$aRow['fullname'], 'search');
+        }
+
+        return $aOut;
+    }
+
+    protected function _profileButtons(array $aProfiles, string $sSuggest): array
+    {
+        $aOut = [];
+        foreach ($aProfiles as $aP) {
+            $s = (string)($aP['name'] ?? '');
+            if ($s !== '' && !in_array($s, $aOut, true))
+                $aOut[] = $s;
+        }
+        $aOut[] = _t('_sys_agents_profile_btn_new', $sSuggest);
+        return $aOut;
+    }
+
+    protected function _suggestProfileName(string $sPreset): string
+    {
+        return _t($this->_preset($sPreset) === 'comment_reply' ? '_sys_agents_profile_name_commenter' : '_sys_agents_profile_name_bot');
+    }
+
+    /**
+     * Accounts whose profiles an agent may act as: the operators' (the profiles
+     * Studio offers, get_options_agents_profile) and the Robot account's, where
+     * profile_new creates them. Never a member's: an agent on a member's profile
+     * would post, and as a message agent answer their messages, in their name.
+     *
+     * @return int[]
+     */
+    protected function _allowedAccounts(): array
+    {
+        if ($this->_aAllowedAccounts === null) {
+            $aIds = array_map('intval', (array)BxDolAccountQuery::getInstance()->getOperators());
+            $aIds[] = $this->_botAccount();
+            $this->_aAllowedAccounts = array_values(array_unique(array_filter($aIds)));
+        }
+        return $this->_aAllowedAccounts;
+    }
+
+    protected function _isAllowedAgentProfile(int $iProfileId): bool
+    {
+        if ($iProfileId <= 0)
+            return false;
+        $iAccount = (int)BxDolDb::getInstance()->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfileId]);
+        return $iAccount > 0 && in_array($iAccount, $this->_allowedAccounts(), true);
+    }
+
+    /** `AND p.account_id IN (...)` for a query over `sys_profiles` aliased `p`. */
+    protected function _allowedAccountsSql(): string
+    {
+        $aIds = $this->_allowedAccounts();
+        return ' AND p.`account_id` IN (' . ($aIds ? implode(',', $aIds) : '0') . ')';
+    }
+
+    protected function _pushProfile(array &$aOut, array &$aSeen, int $iId, string $sFallback, string $sSource): void
+    {
+        if ($iId <= 0 || isset($aSeen[$iId]) || !$this->_isAllowedAgentProfile($iId))
+            return;
+        try {
+            $sName = $this->_profileDisplayName($iId, $sFallback);
+        } catch (Throwable $o) {
+            $sName = $sFallback;
+        }
+        if ($sName === '')
+            $sName = '#' . $iId;
+        $aOut[] = ['id' => $iId, 'name' => $sName, 'source' => $sSource];
+        $aSeen[$iId] = 1;
+    }
+
+    protected function _shortDisplayName(string $s): array
+    {
+        $s = trim(preg_replace('/\s+/u', ' ', $s));
+        $sPrefix = trim(_t('_sys_agents_profile_btn_new', ''));
+        if ($sPrefix !== '' && $sPrefix !== '_sys_agents_profile_btn_new')
+            $s = trim((string)preg_replace('/^' . preg_quote($sPrefix, '/') . '\s*/iu', '', $s));
+        if ($s === '')
+            return ['error' => 'profile name is empty'];
+
+        $iWords = count(preg_split('/\s+/u', $s, -1, PREG_SPLIT_NO_EMPTY));
+        if (mb_strlen($s) > self::PROFILE_NAME_MAX || $iWords > 3 || preg_match('/[\r\n.!?—–;:]/u', $s)) {
+            return ['error' => _t('_sys_agents_profile_new_err', _t('_sys_agents_profile_name_commenter'), _t('_sys_agents_profile_name_bot'))];
+        }
+
+        return ['name' => $s];
+    }
+
+    protected function _profileDisplayName(int $iId, string $sFallback = ''): string
+    {
+        $o = BxDolProfile::getInstance($iId);
+        if ($o)
+            return $o->getDisplayName();
+        if ($sFallback !== '')
+            return $sFallback;
+        return '#' . $iId;
+    }
+
+    protected function _resolveProfile($sProfile, $mixedId, $sNew, bool $bDry): array
+    {
+        $sNew = trim((string)$sNew);
+        if ($sNew !== '')
+            return $this->_createPersonProfile($sNew, $bDry);
+
+        $sProfile = trim((string)$sProfile);
+        $iId = (int)$mixedId;
+        if ($sProfile !== '' && preg_match('/^\d+$/', $sProfile))
+            $iId = (int)$sProfile;
+
+        if ($iId > 0) {
+            $o = BxDolProfile::getInstance($iId);
+            if (!$o)
+                return ['error' => "profile {$iId} not found"];
+            if (!$this->_isAllowedAgentProfile($iId))
+                return ['error' => "profile {$iId} belongs to a member, and an agent may not act as a member. Pick one from catalog.profiles or pass profile_new=<name>."];
+            return ['profile_id' => $iId, 'name' => $o->getDisplayName(), 'created' => 0];
+        }
+
+        if ($sProfile === '')
+            return ['profile_id' => 0, 'name' => '', 'created' => 0];
+
+        $sProfile = ltrim($sProfile, '@');
+        $aHits = [];
+        foreach ($this->_listProfiles($sProfile) as $aP) {
+            if (mb_strtolower((string)$aP['name']) === mb_strtolower($sProfile) || (string)$aP['id'] === $sProfile)
+                $aHits[(int)$aP['id']] = $aP;
+        }
+
+        $oDb = BxDolDb::getInstance();
+        if ($oDb->isTableExists('bx_persons_data')) {
+            $aRows = $oDb->getAll("SELECT p.`id`, d.`fullname` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE d.`fullname` = :n AND p.`status` = 'active'" . $this->_allowedAccountsSql() . " LIMIT 10", [
+                'n' => $sProfile,
+            ]);
+            foreach ($aRows as $aRow)
+                $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
+
+            if (!$aHits) {
+                $aRows = $oDb->getAll("SELECT p.`id`, d.`fullname` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE d.`fullname` LIKE :n AND p.`status` = 'active'" . $this->_allowedAccountsSql() . " ORDER BY d.`fullname` ASC LIMIT 10", [
+                    'n' => '%' . $sProfile . '%',
+                ]);
+                foreach ($aRows as $aRow)
+                    $aHits[(int)$aRow['id']] = ['id' => (int)$aRow['id'], 'name' => $aRow['fullname']];
+            }
+        }
+
+        $aList = array_values($aHits);
+        if (count($aList) === 1)
+            return ['profile_id' => (int)$aList[0]['id'], 'name' => $aList[0]['name'], 'created' => 0];
+        if (count($aList) > 1)
+            return ['error' => 'Several profiles match. Pass profile=exact name from catalog.profiles.', 'candidates' => $aList];
+
+        return ['error' => 'No profile named «' . $sProfile . '». Pick from catalog.profiles or pass profile_new=' . $sProfile . '.'];
+    }
+
+    protected function _createPersonProfile(string $sName, bool $bDry): array
+    {
+        $aName = $this->_shortDisplayName($sName);
+        if (!empty($aName['error']))
+            return $aName;
+        $sName = $aName['name'];
+
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('bx_persons_data'))
+            return ['error' => 'bx_persons_data is missing — cannot create a Persons profile'];
+
+        // new bot profiles always live under the preset Robot account (sys_profile_bot), not under the operator.
+        // author is that Robot profile's id; sys_profiles.account_id is the account that owns it.
+        $aBot = $this->_botIdentity();
+        $iAuthor = (int)$aBot['profile_id'];
+        $iAccount = (int)$aBot['account_id'];
+        if ($iAuthor <= 0 || $iAccount <= 0)
+            return ['error' => 'Robot account not found: set sys_profile_bot (Studio → Settings → Bot profile), then retry.'];
+
+        $aExist = $oDb->getRow("SELECT p.`id` FROM `sys_profiles` p INNER JOIN `bx_persons_data` d ON d.`id` = p.`content_id` AND p.`type` = 'bx_persons' WHERE p.`account_id` = :a AND d.`fullname` = :n LIMIT 1", [
+            'a' => $iAccount,
+            'n' => $sName,
+        ]);
+        if ($aExist)
+            return ['profile_id' => (int)$aExist['id'], 'name' => $sName, 'created' => 0];
+
+        if ($bDry)
+            return ['profile_id' => 0, 'name' => $sName, 'created' => 1, 'dry_create' => 1];
+
+        $iNow = time();
+        $iPublic = defined('BX_DOL_PG_ALL') ? BX_DOL_PG_ALL : 3;
+        $aPerson = $this->_onlyExistingColumns($oDb, 'bx_persons_data', [
+            'author' => $iAuthor,
+            'added' => $iNow,
+            'changed' => $iNow,
+            'fullname' => $sName,
+            'last_name' => '',
+            'allow_view_to' => $iPublic,
+            'allow_post_to' => $iPublic,
+            'allow_contact_to' => $iPublic,
+        ]);
+        $mixed = $oDb->query("INSERT INTO `bx_persons_data` SET " . $oDb->arrayToSQL($aPerson));
+        if ($mixed === false)
+            return ['error' => 'failed to insert bx_persons_data'];
+
+        $iContent = (int)$oDb->lastId();
+        $this->_logSql('insert', 'bx_persons_data', 'id', (string)$iContent, 'INSERT person ' . $sName, null, array_merge($aPerson, ['id' => $iContent]));
+
+        $iAction = defined('BX_PROFILE_ACTION_AUTO') ? BX_PROFILE_ACTION_AUTO : 0;
+        $iProfile = (int)BxDolProfile::add($iAction, $iAccount, $iContent, 'active', 'bx_persons');
+        if ($iProfile <= 0)
+            return ['error' => 'person row created but sys_profiles insert failed', 'content_id' => $iContent];
+
+        $this->_logSql('insert', 'sys_profiles', 'id', (string)$iProfile, 'INSERT profile ' . $sName, null, [
+            'id' => $iProfile,
+            'account_id' => $iAccount,
+            'content_id' => $iContent,
+            'type' => 'bx_persons',
+            'status' => 'active',
+        ]);
+
+        return ['profile_id' => $iProfile, 'name' => $sName, 'created' => 1];
+    }
+
+    protected function _resolveToolsCsv(string $sTools, string $sPreset)
+    {
+        $oDb = BxDolDb::getInstance();
+        $aWanted = [];
+        $sTools = trim($sTools);
+        if ($sTools !== '') {
+            foreach (preg_split('/[,\s]+/', $sTools) as $sTok) {
+                $sTok = trim($sTok);
+                if ($sTok === '')
+                    continue;
+                $aWanted[] = $sTok;
+            }
+        } else if ($sPreset === 'comment_reply') {
+            $aWanted = self::COMMENT_TOOLS;
+        }
+
+        if (!$aWanted)
+            return '';
+
+        $aIds = [];
+        $aMissing = [];
+        foreach ($aWanted as $sTok) {
+            if (preg_match('/^\d+$/', $sTok)) {
+                $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `id` = :id LIMIT 1", ['id' => (int)$sTok]);
+            } else {
+                $aRow = $oDb->getRow("SELECT `id`, `type`, `active` FROM `sys_agents_tools` WHERE `type` = :t ORDER BY `active` DESC, `id` ASC LIMIT 1", ['t' => $sTok]);
+            }
+            if (!$aRow) {
+                $aMissing[] = $sTok;
+                continue;
+            }
+            if (in_array($aRow['type'], self::FORBIDDEN_TOOL_TYPES, true))
+                return ['ok' => 0, 'error' => 'Refusing stock mysql_write on a created agent.'];
+            if (in_array($aRow['type'], BxDolAiAgentTools::hiddenTypes(), true))
+                continue;
+            $aIds[(int)$aRow['id']] = (int)$aRow['id'];
+        }
+        if ($aMissing)
+            return ['ok' => 0, 'error' => 'Unknown tools: ' . implode(', ', $aMissing) . '. action=catalog to list types.'];
+
+        return implode(',', array_values($aIds));
+    }
+
+    protected function _uniqueName(string $sName, string $sTitle): string
+    {
+        $s = $sName !== '' ? $sName : $sTitle;
+        $s = strtolower($s);
+        $s = preg_replace('/[^a-z0-9_]+/', '_', $s);
+        $s = trim($s, '_');
+        if ($s === '')
+            $s = 'agent';
+        if (strlen($s) > 80)
+            $s = substr($s, 0, 80);
+
+        $oDb = BxDolDb::getInstance();
+        $sBase = $s;
+        $i = 2;
+        while ($oDb->getOne("SELECT `id` FROM `sys_agents_agents` WHERE `name` = :n LIMIT 1", ['n' => $s])) {
+            $s = $sBase . '_' . $i;
+            $i++;
+            if ($i > 99)
+                $s = $sBase . '_' . bin2hex(random_bytes(3));
+        }
+        return $s;
+    }
+
+    protected function _defaults(): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $aOp = null;
+        $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
+        $iFromChat = (int)($aCtx['agent_id'] ?? 0);
+        if ($iFromChat > 0)
+            $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iFromChat]);
+        if (!$aOp) {
+            $iOpt = (int)getParam('sys_agents_operator_agent');
+            if ($iOpt > 0)
+                $aOp = $oDb->getRow("SELECT `id`, `model_id`, `profile_id`, `vector_store_id` FROM `sys_agents_agents` WHERE `id` = :id LIMIT 1", ['id' => $iOpt]);
+        }
+
+        $iModel = (int)($aOp['model_id'] ?? 0) ?: (int)(getParam('sys_agents_model'));
+        $iProfile = (int)($aOp['profile_id'] ?? 0);
+        if ($iProfile <= 0)
+            $iProfile = (int)getParam('sys_profile_bot');
+        $iStore = (int)($aOp['vector_store_id'] ?? 0);
+        if ($iStore <= 0 && $oDb->isTableExists('sys_agents_vector_store'))
+            $iStore = (int)$oDb->getOne("SELECT `id` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `id` ASC LIMIT 1");
+
+        return [
+            'model_id' => $iModel,
+            'profile_id' => $iProfile,
+            'vector_store_id' => $iStore,
+            'from_agent_id' => (int)($aOp['id'] ?? 0),
+        ];
+    }
+
+    /**
+     * Preset Robot profile and the account that owns it: sys_profile_bot, then the operator agent's profile.
+     *
+     * @return array{profile_id:int,account_id:int}
+     */
+    protected function _botIdentity(): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $aCandidates = [
+            (int)getParam('sys_profile_bot'),
+            (int)($this->_defaults()['profile_id'] ?? 0),
+        ];
+        foreach ($aCandidates as $iProfile) {
+            if ($iProfile <= 0)
+                continue;
+            $iAccount = (int)$oDb->getOne("SELECT `account_id` FROM `sys_profiles` WHERE `id` = :id LIMIT 1", ['id' => $iProfile]);
+            if ($iAccount > 0)
+                return ['profile_id' => $iProfile, 'account_id' => $iAccount];
+        }
+        return ['profile_id' => 0, 'account_id' => 0];
+    }
+
+    /**
+     * Account of the preset Robot. See _botIdentity().
+     */
+    protected function _botAccount(): int
+    {
+        return (int)$this->_botIdentity()['account_id'];
+    }
+
+    protected function _onlyExistingColumns(BxDolDb $oDb, string $sTable, array $aRow): array
+    {
+        $aOut = [];
+        foreach ($aRow as $sK => $mixedV) {
+            if ($oDb->isFieldExists($sTable, $sK))
+                $aOut[$sK] = $mixedV;
+        }
+        return $aOut;
+    }
+
+    protected function _clip(string $s, int $iMax = 0): string
+    {
+        $iMax = $iMax ?: self::CLIP;
+        $s = trim($s);
+        if (strlen($s) <= $iMax)
+            return $s;
+        // at most $iMax bytes, never half a character: substr() split UTF-8 text mid-letter
+        return mb_strcut($s, 0, $iMax, 'UTF-8');
+    }
+
+    protected function _logSql(string $sOp, string $sTable, string $sPk, string $sPkValue, string $sSql, $mixedBefore, $mixedAfter): void
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb->isTableExists('sys_agents_sql_log'))
+            return;
+
+        $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
+        $aSet = [
+            'agent_id' => (int)($aCtx['agent_id'] ?? 0),
+            'profile_id' => (int)($aCtx['profile_id'] ?? 0),
+            'thread_id' => (string)($aCtx['thread_id'] ?? ''),
+            'op' => $sOp,
+            'table_name' => $sTable,
+            'pk_name' => $sPk,
+            'pk_value' => $sPkValue,
+            'sql_text' => $sSql,
+            'before_json' => $mixedBefore ? json_encode($mixedBefore, JSON_UNESCAPED_UNICODE) : null,
+            'after_json' => $mixedAfter ? json_encode($mixedAfter, JSON_UNESCAPED_UNICODE) : null,
+            'added' => time(),
+        ];
+        if ($oDb->isFieldExists('sys_agents_sql_log', 'undone'))
+            $aSet['undone'] = 0;
+
+        $oDb->query("INSERT INTO `sys_agents_sql_log` SET " . $oDb->arrayToSQL($aSet));
+    }
+}
+
+/** @} */

+ 67 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolChatButtons.php

@@ -0,0 +1,67 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+use NeuronAI\Tools\ArrayProperty;
+use NeuronAI\Tools\ObjectProperty;
+
+class BxDolAIToolChatButtons extends BxDolAITool
+{
+    public function __construct()
+    {
+        $sYes = _t('_Yes');
+        $sNo = _t('_No');
+
+        parent::__construct(
+            'chat_buttons',
+            'Show clickable buttons under THIS assistant reply: ' . $sYes . '/' . $sNo . ', suggested names, register/support links. Call once per turn, only AFTER you wrote the visible sentence the user should answer (never with an empty reply). Write every label in the site language. A yes/no choice uses exactly these labels: "' . $sYes . '" and "' . $sNo . '". Do not write another assistant message after this tool. Do not say that buttons are ready.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        $sYes = _t('_Yes');
+        $sNo = _t('_No');
+
+        return [
+            new ArrayProperty(
+                name: 'buttons',
+                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration), with check_url and message from a tool result the chat waits for it and sends message by itself; form = opens a site form in a popup over the chat, form_url comes from a tool result only (never made up), after the form is saved the "message" text is sent as the visitor next message. Yes/no labels are exactly "' . $sYes . '" and "' . $sNo . '" (site language). Example: [{"type":"reply","label":"' . $sYes . '"},{"type":"reply","label":"' . $sNo . '"}]',
+                required: true,
+                items: new ObjectProperty(
+                    name: 'button',
+                    properties: [
+                        new ToolProperty('type', PropertyType::STRING, 'reply, link or form', true),
+                        new ToolProperty('label', PropertyType::STRING, 'Button text shown to the visitor', true),
+                        new ToolProperty('url', PropertyType::STRING, 'Required for link. https URL.', false),
+                        new ToolProperty('form_url', PropertyType::STRING, 'Required for form. Exactly the form_url from a tool result, like "module/method/&params[]=1".', false),
+                        new ToolProperty('check_url', PropertyType::STRING, 'Optional for link, together with message. Exactly the check_url from a tool result, like "module/method/&params[]=1": the chat polls it after the link is opened and sends message once it is done.', false),
+                        new ToolProperty('message', PropertyType::STRING, 'For form, and for link with check_url. Sent as the visitor message after the form is saved or the check_url reports done, in the site language, e.g. "DigitalOcean is connected".', false),
+                    ]
+                ),
+                minItems: 1,
+                maxItems: 8
+            ),
+        ];
+    }
+
+    public function __invoke($buttons): string
+    {
+        $oChat = BxDolAiChat::getInstance();
+        if (!$oChat->hasTurnText())
+            return 'Not shown: buttons need a visible sentence above them. First write the reply text (question or offer) as a normal assistant message, then call chat_buttons again.';
+
+        $i = $oChat->setPendingChatActions(is_array($buttons) ? $buttons : []);
+
+        return $i > 0
+            ? 'Buttons are already under your last sentence. Do not send another assistant message this turn. Wait for the user to tap a button.'
+            : 'ignored';
+    }
+}

+ 81 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolChatTitle.php

@@ -0,0 +1,81 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+/**
+ * `chat_title` tool: the agent names the conversation it is in, once, as part of
+ * its own reply — no second model request, no settings. Goes into
+ * `sys_agents_chat_history`.`title` for the current thread (BxDolAiChat::setCurrentChatThreadTitle)
+ * and shows up in the "Chats" history panel and the Studio chat popup.
+ *
+ * Without the tool (or when the agent never calls it) the lists fall back to the
+ * first thing the person typed.
+ */
+class BxDolAIToolChatTitle extends BxDolAITool
+{
+    const MAX_WORDS = 6;
+    const MAX_CHARS = 80;
+
+    public function __construct()
+    {
+        parent::__construct(
+            'chat_title',
+            'Name THIS conversation: 3 to 6 words saying what it is about, in the language the user writes in. Call it once, in your first reply after the user\'s first real message (not after "[page opened…"). Do not mention the title to the user.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty('title', PropertyType::STRING, 'Conversation title, 3-6 words, no quotes, no trailing period. Example: "Comment counter for post 291"', true),
+        ];
+    }
+
+    public function __invoke($title): string
+    {
+        $sTitle = self::clean($title);
+        if ($sTitle === '')
+            return 'ignored: empty title';
+
+        $iSet = BxDolAiChat::getInstance()->setCurrentChatThreadTitle($sTitle);
+        if ($iSet === 0)
+            return 'This conversation already has a title. Continue your reply.';
+
+        return $iSet > 0
+            ? 'Title saved. Continue your reply without mentioning it.'
+            : 'ignored';
+    }
+
+    /**
+     * One line, quotes and trailing punctuation gone, at most MAX_WORDS words and
+     * MAX_CHARS characters.
+     */
+    public static function clean($mixed)
+    {
+        $s = strip_tags(is_string($mixed) ? $mixed : '');
+        $s = preg_split('/\r\n|\r|\n/', trim($s))[0] ?? '';
+        $s = trim($s, " \t\"'«»“”‘’`");
+        $s = preg_replace('/\s+/u', ' ', $s);
+        $s = rtrim($s, " .!。");
+        if ($s === '')
+            return '';
+
+        $aWords = explode(' ', $s);
+        if (count($aWords) > self::MAX_WORDS)
+            $s = implode(' ', array_slice($aWords, 0, self::MAX_WORDS));
+        if (get_mb_len($s) > self::MAX_CHARS)
+            $s = rtrim(get_mb_substr($s, 0, self::MAX_CHARS - 1)) . '…';
+
+        return $s;
+    }
+}
+
+/** @} */

+ 83 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolFactory.php

@@ -0,0 +1,83 @@
+<?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
+ * @{
+ */
+
+class BxDolAIToolFactory extends BxDolFactory
+{
+    public static function getToolInstance(int $iId):  NeuronAI\Tools\ToolInterface
+    {
+        if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_AiAgentTool_' . $iId]))
+            return $GLOBALS['bxDolClasses'][__CLASS__ . '_AiAgentTool_' . $iId];
+
+        $a = BxDolAiQuery::getToolObject($iId);
+        if (!$a) {
+            bx_log('sys_agents', "Tool with id {$iId} not found", BX_LOG_ERR);
+            throw new Exception("Tool with id {$iId} not found");
+        }
+
+        $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);
+        
+        switch($a['type']) {
+            case 'mysql_schema':
+                $o = NeuronAI\Tools\Toolkits\MySQL\MySQLSchemaTool::make(
+                    pdo: BxDolDb::getInstance()->getLink(),
+                    tables: $aParameters['tables'] ?? null
+                );
+                break;
+            case 'mysql_select':
+                $o = NeuronAI\Tools\Toolkits\MySQL\MySQLSelectTool::make(
+                    pdo: BxDolDb::getInstance()->getLink(),
+                );
+                break;
+            case 'mysql_write':
+                $o = NeuronAI\Tools\Toolkits\MySQL\MySQLWriteTool::make(
+                    pdo: BxDolDb::getInstance()->getLink(),
+                );
+                break;
+
+            case 'tavily_web_search':
+                $o = NeuronAI\Tools\Toolkits\Tavily\TavilySearchTool::make(
+                    key: $aParameters['key'] ?? ''
+                );
+                break;
+            case 'tavily_extract':
+                $o = NeuronAI\Tools\Toolkits\Tavily\TavilyExtractTool::make(
+                    key: $aParameters['key'] ?? ''
+                );
+                break;
+
+            case 'jina_web_search':
+                $o = NeuronAI\Tools\Toolkits\Jina\JinaWebSearch::make(
+                    key: $aParameters['key'] ?? ''
+                );
+                break;
+            case 'jina_url_reader':
+                $o = NeuronAI\Tools\Toolkits\Jina\JinaUrlReader::make(
+                    key: $aParameters['key'] ?? ''
+                );
+                break;
+
+            default:
+                if (!empty($a['class_name'])) {
+                    if (!empty($a['class_file']))
+                        require_once(BX_DIRECTORY_PATH_ROOT . $a['class_file']);
+                    $o = $a['class_name']::make();
+                }
+                else {
+                    bx_log('sys_agents', "Tool type {$a['type']} is not supported", BX_LOG_ERR);
+                    throw new Exception("Tool type {$a['type']} is not supported");
+                }
+        }
+
+        $GLOBALS['bxDolClasses'][__CLASS__ . '_AiAgentTool_' . $iId] = $o;
+
+        return $o;
+    }
+}

+ 280 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolMockup.php

@@ -0,0 +1,280 @@
+<?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
+ * @{
+ */
+
+/**
+ * `mockup_upsert` tool: the agent saves a NEO mockup JSON tree onto an existing
+ * `get_mockup_block` service page block. The tree lives in `sys_pages_blocks_data`
+ * (no new table) and is served by system/get_mockup_block/TemplServices as a
+ * `mockup` API block, which NEO renders natively (view/row/text/image/icon/button).
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolMockup extends BxDolAITool
+{
+    const MAX_DEPTH = 24;
+    const MAX_JSON = 200000;
+    /** sys_pages_blocks_data.data is MEDIUMTEXT: a longer tree is cut off and no longer parses. */
+    const MAX_STORED = 16777215;
+
+    public function __construct()
+    {
+        parent::__construct(
+            'mockup_upsert',
+            'Save a NEO mockup JSON tree onto a get_mockup_block page block (sys_pages_blocks_data). Do not use mysql_write, HTML, or React Native JSX for mockups. Do not touch get_mockup_test (hardcoded demo). '
+            . 'Root: {"type":"view","className":"...","children":[...]}. Types: view (column), row (horizontal; already flex-row), text, image, icon, button. Empty view/row/text are dropped. '
+            . 'Columns: parent type=row className="gap-4 w-full"; children view with flex-1 or w-1/2 / w-1/3 / w-1/4. NEVER w-full on row children — that stacks cards full-width. 2 cols → w-1/2 or flex-1; 3 → w-1/3 or flex-1; 4 → w-1/4. w-full only on vertical view sections. '
+            . 'className: Tailwind already in NEO (gap-2 gap-4 gap-8 w-full w-1/2 w-1/3 w-1/4 flex-1 min-w-0 p-4 rounded-xl border border-border bg-card text-foreground text-muted-foreground text-sm text-lg text-4xl font-semibold font-bold items-center justify-between h-48). No arbitrary-[]. Text color/size/font-* only on text nodes (RN does not cascade). '
+            . 'text: field text; one feature line = one text node, not \\n. icon: Lucide name, size default 24. image: display src only (src/medium/small/thumb), never src_orig. button: label required, optional href, style primary|secondary|outline|plain|link|glass. '
+            . 'Match screenshot hierarchy/spacing/CTAs; skip video/map/forms as text+button placeholders.'
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty('block_id', PropertyType::INTEGER, 'sys_pages_blocks.id of the get_mockup_block service block', true),
+            new ToolProperty('tree_json', PropertyType::STRING, 'JSON object, root type view. Row children: flex-1 or w-1/3, never w-full. Example: {"type":"view","className":"gap-8 w-full","children":[{"type":"row","className":"gap-4 w-full","children":[{"type":"view","className":"w-1/3 p-4 rounded-xl border border-border bg-card gap-4","children":[{"type":"text","className":"text-4xl font-bold text-foreground","text":"Pro"}]}]}]}', true),
+            new ToolProperty('dry_run', PropertyType::BOOLEAN, 'If true, validate only and do not save', false),
+        ];
+    }
+
+    public function __invoke($block_id, $tree_json, $dry_run = false): string
+    {
+        $iBlockId = (int)$block_id;
+        if ($iBlockId <= 0)
+            return 'error: block_id is required (sys_pages_blocks.id of the get_mockup_block block)';
+
+        $sJson = is_string($tree_json) ? trim($tree_json) : '';
+        if ($sJson === '')
+            return 'error: tree_json is required';
+        if (strlen($sJson) > self::MAX_JSON)
+            return 'error: tree_json is too large';
+
+        $aRaw = json_decode($sJson, true);
+        if (!is_array($aRaw))
+            return 'error: tree_json is not valid JSON object';
+
+        $aTree = self::normalizeNode($aRaw, 0);
+        if (!$aTree)
+            return 'error: empty or invalid tree. Root type view with children. Types: view,row,text,image,icon,button.';
+
+        if (!self::isMockupBlock($iBlockId))
+            return 'error: block_id ' . $iBlockId . ' is not a get_mockup_block service block';
+
+        $bDry = ($dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true');
+        if ($bDry)
+            return 'dry_run ok. block_id=' . $iBlockId . ' nodes=' . self::countNodes($aTree);
+
+        $sErr = self::saveTree($iBlockId, $aTree);
+        if ($sErr !== '')
+            return $sErr;
+
+        return 'saved. block_id=' . $iBlockId . ' nodes=' . self::countNodes($aTree);
+    }
+
+    public static function currentBlockId()
+    {
+        $aProc = BxDolPage::getBlockProcessing();
+        return (!empty($aProc['id'])) ? (int)$aProc['id'] : 0;
+    }
+
+    public static function isMockupBlock($iBlockId)
+    {
+        $iBlockId = (int)$iBlockId;
+        if ($iBlockId <= 0)
+            return false;
+
+        $sContent = BxDolDb::getInstance()->getOne("SELECT `content` FROM `sys_pages_blocks` WHERE `id` = :id AND `type` = 'service' LIMIT 1", [
+            'id' => $iBlockId,
+        ]);
+        $aCall = BxDolService::decodeServiceCall($sContent);
+        return is_array($aCall) && ($aCall['module'] ?? '') === 'system' && ($aCall['method'] ?? '') === 'get_mockup_block';
+    }
+
+    public static function getTree($iBlockId = 0)
+    {
+        $iBlockId = (int)$iBlockId;
+        if ($iBlockId <= 0)
+            $iBlockId = self::currentBlockId();
+        if ($iBlockId <= 0)
+            return null;
+
+        $oDb = new BxDolPageQuery([]);
+        $sData = $oDb->getPageBlockData($iBlockId);
+        if (!$sData)
+            return null;
+
+        $aRaw = json_decode($sData, true);
+        if (!is_array($aRaw))
+            return null;
+
+        return self::normalizeNode($aRaw, 0);
+    }
+
+    public static function saveTree($iBlockId, array $aTree)
+    {
+        $iBlockId = (int)$iBlockId;
+        if ($iBlockId <= 0)
+            return 'error: block_id missing';
+
+        // Unescaped: a non-Latin character costs 2-4 bytes here instead of 6 as \uXXXX.
+        $sData = json_encode($aTree, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if (!is_string($sData))
+            return 'error: the tree cannot be encoded as JSON';
+        if (strlen($sData) > self::MAX_STORED)
+            return 'error: the tree is too large to save (' . strlen($sData) . ' bytes, the limit is ' . self::MAX_STORED . '). Simplify it: fewer nodes, shorter texts and class names.';
+
+        $oDb = new BxDolPageQuery([]);
+        $b = $oDb->setPageBlockData($iBlockId, 0, '', $sData);
+        if ($b === false)
+            return 'error: failed to write sys_pages_blocks_data';
+
+        return '';
+    }
+
+    public static function normalizeNode($raw, $iDepth)
+    {
+        $aTypes = ['view' => 1, 'row' => 1, 'text' => 1, 'image' => 1, 'icon' => 1, 'button' => 1];
+        $aBtn = [
+            'primary' => 'borderedProminent',
+            'secondary' => 'bordered',
+            'outline' => 'bordered',
+            'bordered' => 'bordered',
+            'borderedProminent' => 'borderedProminent',
+            'plain' => 'plain',
+            'link' => 'link',
+            'glass' => 'glass',
+            'glassProminent' => 'glassProminent',
+            'borderless' => 'borderless',
+        ];
+
+        if ($iDepth > self::MAX_DEPTH || $raw === null)
+            return null;
+
+        if (is_string($raw)) {
+            $s = trim($raw);
+            if ($s !== '' && ($s[0] === '{' || $s[0] === '[')) {
+                $decoded = json_decode($s, true);
+                if (is_array($decoded))
+                    return self::normalizeNode($decoded, $iDepth);
+            }
+            return $s !== '' ? ['type' => 'text', 'className' => '', 'text' => $s] : null;
+        }
+        if (!is_array($raw))
+            return null;
+
+        if (isset($raw[0]) && !isset($raw['type'])) {
+            $aChildren = [];
+            foreach ($raw as $child) {
+                $n = self::normalizeNode($child, $iDepth + 1);
+                if ($n)
+                    $aChildren[] = $n;
+            }
+            return $aChildren ? ['type' => 'view', 'className' => '', 'children' => $aChildren] : null;
+        }
+
+        $sType = isset($raw['type']) ? (string)$raw['type'] : '';
+        if ($sType === 'mockup')
+            return self::normalizeNode($raw['data'] ?? $raw['content'] ?? $raw['tree'] ?? $raw['node'] ?? null, $iDepth);
+
+        if ($sType === '')
+            $sType = (isset($raw['text']) && !isset($raw['children'])) ? 'text' : 'view';
+        if (empty($aTypes[$sType]))
+            return null;
+
+        $sClass = (isset($raw['className']) && is_string($raw['className'])) ? $raw['className'] : '';
+        $aChildrenIn = [];
+        if (isset($raw['children']))
+            $aChildrenIn = is_array($raw['children']) ? $raw['children'] : [$raw['children']];
+        $aChildren = [];
+        foreach ($aChildrenIn as $child) {
+            $n = self::normalizeNode($child, $iDepth + 1);
+            if ($n)
+                $aChildren[] = $n;
+        }
+
+        if ($sType === 'text') {
+            $sText = isset($raw['text']) ? (string)$raw['text'] : '';
+            return $sText === '' ? null : ['type' => 'text', 'className' => $sClass, 'text' => $sText];
+        }
+        if ($sType === 'icon') {
+            $sIcon = isset($raw['icon']) ? (string)$raw['icon'] : '';
+            if ($sIcon === '')
+                return null;
+            $iSize = isset($raw['size']) ? (int)$raw['size'] : 24;
+            return ['type' => 'icon', 'className' => $sClass, 'icon' => $sIcon, 'size' => $iSize > 0 ? $iSize : 24];
+        }
+        if ($sType === 'image') {
+            $sSrc = self::pickImageSrc($raw);
+            if ($sSrc === '')
+                return null;
+            return ['type' => 'image', 'className' => $sClass, 'src' => $sSrc, 'alt' => isset($raw['alt']) ? (string)$raw['alt'] : ''];
+        }
+        if ($sType === 'button') {
+            $sLabel = (string)($raw['label'] ?? $raw['text'] ?? '');
+            if ($sLabel === '')
+                return null;
+            $sStyleKey = (string)($raw['style'] ?? $raw['variant'] ?? 'primary');
+            return [
+                'type' => 'button',
+                'className' => $sClass,
+                'label' => $sLabel,
+                'href' => self::safeHref($raw['href'] ?? ''),
+                'style' => $aBtn[$sStyleKey] ?? 'borderedProminent',
+            ];
+        }
+        if (!$aChildren)
+            return null;
+
+        return ['type' => $sType, 'className' => $sClass, 'children' => $aChildren];
+    }
+
+    /**
+     * Only relative paths, anchors, http(s) and mailto links; javascript:/data: and the like are dropped.
+     */
+    protected static function safeHref($mixed)
+    {
+        $s = is_string($mixed) ? trim($mixed) : '';
+        if ($s === '')
+            return '';
+        if ($s[0] === '/' || $s[0] === '#' || $s[0] === '?')
+            return $s;
+        if (preg_match('#^(https?://|mailto:)#i', $s))
+            return $s;
+        return '';
+    }
+
+    /**
+     * Display src for image node: string src, or UNA image object (src/medium/small/thumb) either
+     * directly in the node or nested under `src`. Never src_orig.
+     */
+    protected static function pickImageSrc(array $raw)
+    {
+        $aSrc = (isset($raw['src']) && is_array($raw['src'])) ? $raw['src'] : $raw;
+        foreach (['src', 'medium', 'small', 'thumb'] as $k) {
+            if (!empty($aSrc[$k]) && is_string($aSrc[$k]))
+                return $aSrc[$k];
+        }
+        return '';
+    }
+
+    protected static function countNodes(array $aNode)
+    {
+        $i = 1;
+        if (!empty($aNode['children']) && is_array($aNode['children'])) {
+            foreach ($aNode['children'] as $child) {
+                if (is_array($child))
+                    $i += self::countNodes($child);
+            }
+        }
+        return $i;
+    }
+}

+ 199 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolMysqlUndo.php

@@ -0,0 +1,199 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolMysqlUndo extends BxDolAITool
+{
+    public function __construct()
+    {
+        parent::__construct(
+            'mysql_undo',
+            'Roll back mysql_write changes from sys_agents_sql_log. scope=last undoes the latest snapshot in this chat; scope=session undoes all remaining snapshots in reverse order. INSERT is undone by deleting that PK internally. UPDATE restores the before snapshot. Do not use mysql_write DELETE.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'scope',
+                type: PropertyType::STRING,
+                description: 'last = one newest change in this chat. session = all not-yet-undone changes in this chat, newest first.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'log_id',
+                type: PropertyType::NUMBER,
+                description: 'Optional sys_agents_sql_log.id. If set, undo that snapshot only (must belong to this chat).',
+                required: false
+            ),
+        ];
+    }
+
+    public function __invoke(string $scope = 'last', $log_id = 0): array
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $sScope = strtolower(trim($scope));
+        if (!in_array($sScope, ['last', 'session'], true))
+            throw new Exception('scope must be last or session.');
+
+        $iLogId = (int)$log_id;
+        $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
+        $sThread = $aCtx['thread_id'];
+        $iProfile = (int)$aCtx['profile_id'];
+
+        $aRows = $this->_pickRows($oDb, $sScope, $iLogId, $sThread, $iProfile);
+        if (!$aRows)
+            throw new Exception('Nothing to undo in this chat.');
+
+        $aDone = [];
+        foreach ($aRows as $aRow) {
+            $aDone[] = $this->_undoRow($oDb, $aRow);
+        }
+
+        return [
+            'ok' => 1,
+            'scope' => $iLogId > 0 ? 'log_id' : $sScope,
+            'undone' => count($aDone),
+            'items' => $aDone,
+        ];
+    }
+
+    protected function _pickRows(BxDolDb $oDb, string $sScope, int $iLogId, string $sThread, int $iProfile): array
+    {
+        if ($iLogId > 0) {
+            $aRow = $oDb->getRow("SELECT * FROM `sys_agents_sql_log` WHERE `id` = :id LIMIT 1", ['id' => $iLogId]);
+            if (!$aRow)
+                throw new Exception("log_id {$iLogId} not found.");
+            if ((int)$aRow['undone'] > 0)
+                throw new Exception("log_id {$iLogId} is already undone.");
+            if ($sThread !== '') {
+                if ((string)$aRow['thread_id'] !== $sThread)
+                    throw new Exception("log_id {$iLogId} belongs to another chat.");
+            }
+            else if ($iProfile <= 0 || (int)$aRow['profile_id'] !== $iProfile) {
+                throw new Exception("log_id {$iLogId} belongs to another chat.");
+            }
+            return [$aRow];
+        }
+
+        if ($sThread !== '') {
+            $sSql = "SELECT * FROM `sys_agents_sql_log` WHERE `thread_id` = :t AND `undone` = 0 ORDER BY `id` DESC";
+            $aBind = ['t' => $sThread];
+        }
+        else if ($iProfile > 0) {
+            $sSql = "SELECT * FROM `sys_agents_sql_log` WHERE `profile_id` = :p AND `undone` = 0 AND `added` > :since ORDER BY `id` DESC";
+            $aBind = ['p' => $iProfile, 'since' => time() - 7200];
+        }
+        else {
+            return [];
+        }
+
+        if ($sScope === 'last')
+            $sSql .= " LIMIT 1";
+        else
+            $sSql .= " LIMIT 40";
+
+        $aRows = $oDb->getAll($sSql, $aBind);
+        return is_array($aRows) ? $aRows : [];
+    }
+
+    protected function _undoRow(BxDolDb $oDb, array $aRow): array
+    {
+        $sOp = strtolower((string)$aRow['op']);
+        $sTable = str_replace('`', '', (string)$aRow['table_name']);
+        $sPk = str_replace('`', '', (string)$aRow['pk_name']);
+        $sPkValue = (string)$aRow['pk_value'];
+
+        if ($sTable === '' || $sPk === '' || $sPkValue === '')
+            throw new Exception("log_id {$aRow['id']} has incomplete snapshot.");
+        if (!$oDb->isValidFieldName($sTable) || !$oDb->isValidFieldName($sPk))
+            throw new Exception("log_id {$aRow['id']} has invalid table/pk.");
+
+        if ($sOp === 'insert')
+            $this->_undoInsert($oDb, $sTable, $sPk, $sPkValue);
+        else if ($sOp === 'update')
+            $this->_undoUpdate($oDb, $aRow, $sTable, $sPk, $sPkValue);
+        else
+            throw new Exception("Cannot undo op {$sOp}.");
+
+        $oDb->query("UPDATE `sys_agents_sql_log` SET `undone` = :ts WHERE `id` = :id", [
+            'ts' => time(),
+            'id' => (int)$aRow['id'],
+        ]);
+
+        return [
+            'log_id' => (int)$aRow['id'],
+            'op' => $sOp,
+            'table' => $sTable,
+            'pk' => $sPk,
+            'pk_value' => $sPkValue,
+        ];
+    }
+
+    protected function _undoInsert(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): void
+    {
+        $aBind = ['v' => preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $sPkValue];
+        $oDb->query("DELETE FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", $aBind);
+    }
+
+    protected function _undoUpdate(BxDolDb $oDb, array $aRow, string $sTable, string $sPk, string $sPkValue): void
+    {
+        $aBefore = json_decode((string)$aRow['before_json'], true);
+        if (!is_array($aBefore) || !$aBefore)
+            throw new Exception("log_id {$aRow['id']} has no before snapshot.");
+
+        $aCurrent = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
+        if (!$aCurrent)
+            throw new Exception("Row {$sTable}.{$sPk}={$sPkValue} is gone; cannot restore.");
+
+        $aAfter = json_decode((string)($aRow['after_json'] ?? ''), true);
+        if (is_array($aAfter) && $aAfter && !$this->_rowsClose($aCurrent, $aAfter) && !$this->_rowsClose($aCurrent, $aBefore))
+            throw new Exception("Row {$sTable}.{$sPk}={$sPkValue} changed after the write; refuse undo.");
+
+        $aSet = [];
+        foreach ($aBefore as $sField => $mixedVal) {
+            if (!$oDb->isValidFieldName($sField))
+                throw new Exception("Invalid field {$sField} in snapshot.");
+            if ($sField === $sPk)
+                continue;
+            $aSet[$sField] = $mixedVal;
+        }
+        if (!$aSet)
+            throw new Exception("log_id {$aRow['id']} snapshot has nothing to restore.");
+
+        $sSet = '';
+        foreach ($aSet as $sField => $mixedVal) {
+            $sSet .= ($sSet === '' ? '' : ', ') . '`' . $sField . '` = ' . ($mixedVal === null ? 'NULL' : $oDb->escape($mixedVal));
+        }
+
+        $sPkSql = preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $oDb->escape($sPkValue);
+        $oDb->query("UPDATE `{$sTable}` SET {$sSet} WHERE `{$sPk}` = {$sPkSql} LIMIT 1");
+    }
+
+    protected function _fetchRow(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): array|false
+    {
+        $aBind = ['v' => preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $sPkValue];
+        return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", $aBind);
+    }
+
+    protected function _rowsClose(array $aA, array $aB): bool
+    {
+        foreach ($aB as $sKey => $mixedB) {
+            if (!array_key_exists($sKey, $aA))
+                return false;
+            if ((string)($aA[$sKey] ?? '') !== (string)($mixedB ?? ''))
+                return false;
+        }
+        return true;
+    }
+}

+ 464 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolMysqlWrite.php

@@ -0,0 +1,464 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolMysqlWrite extends BxDolAITool
+{
+    protected $_aDenyTables = [
+        'sys_accounts',
+        'sys_profiles',
+        'sys_sessions',
+        'sys_cron_jobs',
+        'sys_modules',
+        'sys_agents_sql_log',
+        'sys_agents_chat_history',
+        'sys_agents_tools',
+        'sys_agents_agents',
+        'sys_agents_prompt_feedback',
+    ];
+
+    public function __construct()
+    {
+        parent::__construct(
+            'mysql_write',
+            'INSERT or UPDATE one row. DELETE/DROP/TRUNCATE/ALTER are forbidden. UPDATE WHERE must identify exactly one row (prefer primary key: WHERE id = N). Snapshots are stored automatically. Prefer mysql_schema and mysql_select first. '
+            . 'Call with dry_run=true first: it returns a plain-language summary (table, row, field: old → new) — show that to the person, never the SQL.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'query',
+                type: PropertyType::STRING,
+                description: 'Single INSERT or UPDATE. UPDATE needs WHERE that matches one row, e.g. WHERE `id` = 12. Table.column and extra AND conditions are ok. No DELETE. No multiple statements.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'dry_run',
+                type: PropertyType::BOOLEAN,
+                description: 'true = validate and return summary / changes (field: old → new) without writing. Use it before asking the person to confirm.',
+                required: false
+            ),
+        ];
+    }
+
+    public function __invoke(string $query, $dry_run = false): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $sSql = $this->_normalize($query);
+        $sBare = $this->_stripLiterals($sSql);
+        $bDry = $dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true';
+
+        $this->_assertSafe($sBare);
+
+        if (preg_match('/^insert\b/i', $sSql))
+            $sOp = 'insert';
+        else if (preg_match('/^update\b/i', $sSql))
+            $sOp = 'update';
+        else
+            throw new Exception('Only INSERT or UPDATE is allowed.');
+
+        $sTable = $this->_tableName($sSql, $sOp);
+        $this->_assertTable($sTable);
+
+        $sPk = $this->_primaryKey($oDb, $sTable);
+
+        $sBefore = null;
+        $sPkValue = '';
+        if ($sOp === 'update') {
+            $sPkValue = $this->_wherePkValue($sSql, $sPk, $oDb, $sTable);
+            $aBefore = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
+            if (!$aBefore)
+                throw new Exception("Row not found: {$sTable}.{$sPk} = {$sPkValue}");
+            $sBefore = json_encode($aBefore, JSON_UNESCAPED_UNICODE);
+        }
+
+        if ($bDry) {
+            $aAssign = $this->_parseAssignments($sSql, $sOp);
+            $aChanges = $this->_changes($sOp === 'update' ? ($aBefore ?? []) : null, $aAssign, $sOp === 'update');
+            return [
+                'ok' => 1,
+                'dry_run' => 1,
+                'op' => $sOp,
+                'table' => $sTable,
+                'pk' => $sPk,
+                'pk_value' => $sPkValue,
+                'changes' => $aChanges,
+                'summary' => $this->_summary($sOp, $sTable, $sPkValue, $aChanges),
+            ];
+        }
+
+        $mixedRes = $oDb->query($sSql);
+        if ($mixedRes === false)
+            throw new Exception('Query failed.');
+
+        if ($sOp === 'insert') {
+            $sPkValue = (string)$oDb->lastId();
+            if ($sPkValue === '' || $sPkValue === '0')
+                $sPkValue = $this->_insertedPkFromSql($sSql, $sPk);
+            if ($sPkValue === '')
+                throw new Exception('INSERT succeeded but primary key is unknown.');
+        }
+
+        $aAfter = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
+        $iLogId = $this->_log($sOp, $sTable, $sPk, $sPkValue, $sSql, $sBefore, $aAfter ? json_encode($aAfter, JSON_UNESCAPED_UNICODE) : null);
+
+        // exact diff from the snapshots (the dry run only had the parsed SET clause)
+        $aChanges = $sOp === 'update'
+            ? $this->_changes($aBefore ?? [], $aAfter ?: [], true)
+            : $this->_changes(null, $this->_parseAssignments($sSql, $sOp), false);
+
+        return [
+            'ok' => 1,
+            'op' => $sOp,
+            'table' => $sTable,
+            'pk' => $sPk,
+            'pk_value' => $sPkValue,
+            'log_id' => $iLogId,
+            'affected' => is_numeric($mixedRes) ? (int)$mixedRes : 1,
+            'changes' => $aChanges,
+            'summary' => $this->_summary($sOp, $sTable, $sPkValue, $aChanges),
+        ];
+    }
+
+    /**
+     * column => literal from the SET clause (UPDATE / INSERT ... SET) or the
+     * (columns) VALUES (...) pair. Expressions that are not plain literals
+     * (NOW(), col + 1) are kept as their SQL text.
+     *
+     * SET and WHERE are located on a copy with quoted literals blanked
+     * ($sBare, same length as $sSql) so a value cannot end the clause.
+     */
+    protected function _parseAssignments(string $sSql, string $sOp): array
+    {
+        $aOut = [];
+        $sBare = $this->_maskLiterals($sSql, true);
+        if (preg_match('/\bset\b/i', $sBare, $aSet, PREG_OFFSET_CAPTURE)) {
+            $iFrom = $aSet[0][1] + strlen($aSet[0][0]);
+            $sClause = substr($sSql, $iFrom);
+            if (preg_match('/\bwhere\b/i', substr($sBare, $iFrom), $aWhere, PREG_OFFSET_CAPTURE))
+                $sClause = substr($sSql, $iFrom, $aWhere[0][1]);
+            foreach ($this->_splitTopLevel($sClause) as $sPair) {
+                if (preg_match('/^\s*(?:`?[a-zA-Z0-9_]+`?\s*\.\s*)?`?([a-zA-Z0-9_]+)`?\s*=\s*(.+)$/s', trim($sPair), $aP))
+                    $aOut[$aP[1]] = $this->_literal(trim($aP[2]));
+            }
+            return $aOut;
+        }
+        if ($sOp === 'insert' && preg_match('/^insert\s+into\s+`?[a-zA-Z0-9_]+`?\s*\((.+?)\)\s*values\s*\((.+)\)\s*$/is', $sSql, $aM)) {
+            $aCols = array_map(fn($c) => trim($c, " `\t\n\r"), $this->_splitTopLevel($aM[1]));
+            $aVals = $this->_splitTopLevel($aM[2]);
+            foreach ($aCols as $i => $sCol) {
+                if ($sCol !== '' && array_key_exists($i, $aVals))
+                    $aOut[$sCol] = $this->_literal(trim($aVals[$i]));
+            }
+        }
+        return $aOut;
+    }
+
+    /**
+     * Copy of $s with the inside of quoted literals replaced by spaces.
+     * Length and quote marks stay, so keyword offsets still point into $s.
+     * Quote rules match _splitTopLevel() (backslash and doubled quotes).
+     * $bIdents also blanks `identifiers`, so a column named where/set is not a clause boundary.
+     */
+    protected function _maskLiterals(string $s, bool $bIdents = false): string
+    {
+        $iLen = strlen($s);
+        $sQuote = '';
+        for ($i = 0; $i < $iLen; $i++) {
+            $c = $s[$i];
+            if ($sQuote !== '') {
+                if ($sQuote !== '`' && $c === '\\' && $i + 1 < $iLen) {
+                    $s[$i] = ' ';
+                    $s[++$i] = ' ';
+                } elseif ($c === $sQuote) {
+                    if ($i + 1 < $iLen && $s[$i + 1] === $sQuote) {
+                        $s[$i] = ' ';
+                        $s[++$i] = ' ';
+                    } else {
+                        $sQuote = '';
+                    }
+                } else {
+                    $s[$i] = ' ';
+                }
+                continue;
+            }
+            if ($c === "'" || $c === '"' || ($bIdents && $c === '`'))
+                $sQuote = $c;
+        }
+        return $s;
+    }
+
+    /** Split on commas that are outside quotes and parentheses. */
+    protected function _splitTopLevel(string $s): array
+    {
+        $aOut = [];
+        $sCur = '';
+        $iDepth = 0;
+        $sQuote = '';
+        $iLen = strlen($s);
+        for ($i = 0; $i < $iLen; $i++) {
+            $c = $s[$i];
+            if ($sQuote !== '') {
+                $sCur .= $c;
+                if ($c === '\\' && $i + 1 < $iLen) {
+                    $sCur .= $s[++$i];
+                } elseif ($c === $sQuote) {
+                    if ($i + 1 < $iLen && $s[$i + 1] === $sQuote)
+                        $sCur .= $s[++$i];
+                    else
+                        $sQuote = '';
+                }
+                continue;
+            }
+            if ($c === "'" || $c === '"') {
+                $sQuote = $c;
+                $sCur .= $c;
+            } elseif ($c === '(') {
+                $iDepth++;
+                $sCur .= $c;
+            } elseif ($c === ')') {
+                $iDepth--;
+                $sCur .= $c;
+            } elseif ($c === ',' && $iDepth === 0) {
+                $aOut[] = $sCur;
+                $sCur = '';
+            } else {
+                $sCur .= $c;
+            }
+        }
+        if (trim($sCur) !== '')
+            $aOut[] = $sCur;
+        return $aOut;
+    }
+
+    /** SQL literal → PHP value; anything else stays as the expression text. */
+    protected function _literal(string $s)
+    {
+        if (preg_match('/^null$/i', $s))
+            return null;
+        if (preg_match('/^-?\d+(\.\d+)?$/', $s))
+            return $s + 0;
+        $iLen = strlen($s);
+        if ($iLen >= 2 && ($s[0] === "'" || $s[0] === '"') && $s[$iLen - 1] === $s[0]) {
+            $q = $s[0];
+            $sInner = substr($s, 1, -1);
+            $sInner = str_replace($q . $q, $q, $sInner);
+            return stripcslashes($sInner);
+        }
+        return $s;
+    }
+
+    /**
+     * [{field, before, after}] — for an update only the fields whose value
+     * actually changes; for an insert every given field (before = null).
+     */
+    protected function _changes(?array $aBefore, array $aAfter, bool $bDiff): array
+    {
+        $aOut = [];
+        foreach ($aAfter as $sField => $mixedAfter) {
+            $mixedBefore = $aBefore[$sField] ?? null;
+            if ($bDiff && (string)$mixedBefore === (string)$mixedAfter)
+                continue;
+            $aOut[] = [
+                'field' => $sField,
+                'before' => $bDiff ? $this->_short($mixedBefore) : null,
+                'after' => $this->_short($mixedAfter),
+            ];
+        }
+        return $aOut;
+    }
+
+    protected function _short($mixed): ?string
+    {
+        if ($mixed === null)
+            return null;
+        $s = trim(strip_tags((string)$mixed));
+        $s = preg_replace('/\s+/u', ' ', $s);
+        return mb_strlen($s) > 120 ? mb_substr($s, 0, 119) . '…' : $s;
+    }
+
+    /** "sys_menu_items #12: active: 0 → 1; title: «Old» → «New»" */
+    protected function _summary(string $sOp, string $sTable, string $sPkValue, array $aChanges): string
+    {
+        $fQ = fn($v) => $v === null || $v === '' ? '—' : (is_numeric($v) ? (string)$v : '«' . $v . '»');
+        $aParts = [];
+        foreach ($aChanges as $aC)
+            $aParts[] = $aC['field'] . ': ' . ($sOp === 'update' ? $fQ($aC['before']) . ' → ' : '') . $fQ($aC['after']);
+        $sHead = $sTable . ($sPkValue !== '' ? ' #' . $sPkValue : '');
+        if (!$aParts)
+            return $sHead . ($sOp === 'update' ? ': no changes' : ': new row');
+        return ($sOp === 'insert' ? 'new row in ' : '') . $sHead . ': ' . implode('; ', $aParts);
+    }
+
+    protected function _normalize(string $s): string
+    {
+        $s = preg_replace('~/\*.*?\*/~s', ' ', $s);
+        $s = preg_replace('/--[^\n]*/', ' ', $s);
+        $s = preg_replace('/#[^\n]*/', ' ', $s);
+        $s = trim($s);
+        $s = rtrim($s, "; \t\n\r");
+        if ($s === '')
+            throw new Exception('Empty query.');
+        if (strpos($s, ';') !== false)
+            throw new Exception('Multiple statements are not allowed.');
+        return $s;
+    }
+
+    protected function _stripLiterals(string $s): string
+    {
+        $s = preg_replace("/'([^'\\\\]|\\\\.)*'/s", "''", $s);
+        $s = preg_replace('/"([^"\\\\]|\\\\.)*"/s', '""', $s);
+        return $s;
+    }
+
+    protected function _assertSafe(string $sBare): void
+    {
+        if (preg_match('/\b(delete|drop|truncate|alter|replace|create|grant|revoke|call|handler|load|outfile|dumpfile|into\s+outfile|lock\s+tables|unlock\s+tables)\b/i', $sBare))
+            throw new Exception('Forbidden SQL keyword.');
+        if (preg_match('/\bon\s+duplicate\s+key\b/i', $sBare))
+            throw new Exception('ON DUPLICATE KEY is not allowed.');
+        if (preg_match('/^insert\b/i', $sBare) && preg_match('/\bselect\b/i', $sBare))
+            throw new Exception('INSERT ... SELECT is not allowed.');
+    }
+
+    protected function _tableName(string $sSql, string $sOp): string
+    {
+        if ($sOp === 'insert' && preg_match('/^insert\s+into\s+`?([a-zA-Z0-9_]+)`?/i', $sSql, $aM))
+            return $aM[1];
+        if ($sOp === 'update' && preg_match('/^update\s+(?:low_priority\s+|ignore\s+)*`?([a-zA-Z0-9_]+)`?/i', $sSql, $aM))
+            return $aM[1];
+        throw new Exception('Cannot parse table name.');
+    }
+
+    protected function _assertTable(string $sTable): void
+    {
+        $sTable = strtolower($sTable);
+        if (in_array($sTable, $this->_aDenyTables, true))
+            throw new Exception("Table {$sTable} is not writable by this tool.");
+        if (preg_match('/^(mysql|information_schema|performance_schema|sys)\./', $sTable))
+            throw new Exception("Table {$sTable} is not writable by this tool.");
+    }
+
+    protected function _primaryKey(BxDolDb $oDb, string $sTable): string
+    {
+        $aKeys = $oDb->getAll("SHOW KEYS FROM `" . str_replace('`', '', $sTable) . "` WHERE `Key_name` = 'PRIMARY'");
+        if (!$aKeys)
+            throw new Exception("Table {$sTable} has no PRIMARY KEY.");
+        if (count($aKeys) > 1)
+            throw new Exception("Composite primary keys are not supported.");
+        return $aKeys[0]['Column_name'];
+    }
+
+    protected function _wherePkValue(string $sSql, string $sPk, BxDolDb $oDb, string $sTable): string
+    {
+        $sBare = $this->_maskLiterals($sSql, true);
+        if (!preg_match('/\bwhere\b/i', $sBare, $aWhere, PREG_OFFSET_CAPTURE))
+            throw new Exception('UPDATE requires WHERE primary_key = value.');
+
+        $sWhere = substr($sSql, $aWhere[0][1] + strlen($aWhere[0][0]));
+        $sWhereBare = $this->_maskLiterals($sWhere, true);
+        $iCut = strlen($sWhere);
+        if (preg_match('/\s+(?:limit|offset)\b/i', $sWhereBare, $aTail, PREG_OFFSET_CAPTURE) && $aTail[0][1] < $iCut)
+            $iCut = $aTail[0][1];
+        if (preg_match('/\s+order\s+by\b/i', $sWhereBare, $aOrder, PREG_OFFSET_CAPTURE) && $aOrder[0][1] < $iCut)
+            $iCut = $aOrder[0][1];
+        $sWhere = trim(substr($sWhere, 0, $iCut), " \t\n\r;");
+        if ($sWhere === '')
+            throw new Exception('UPDATE requires WHERE primary_key = value.');
+
+        $sWhereBare = $this->_stripLiterals($sWhere);
+        if (preg_match('/\b(or|in\s*\(|between|like|exists|select)\b/i', $sWhereBare))
+            throw new Exception('UPDATE WHERE must be a simple equality (no OR / IN / LIKE).');
+
+        $sPkRe = preg_quote($sPk, '/');
+        $sTableRe = preg_quote($sTable, '/');
+        $sIdent = '(?:`?' . $sTableRe . '`?\s*\.\s*)?`?' . $sPkRe . '`?';
+        $sWhereMask = $this->_maskLiterals($sWhere);
+        if (preg_match('/(?:^|\band\s+)\s*\(?\s*' . $sIdent . '\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sWhereMask, $aV, PREG_OFFSET_CAPTURE))
+            return trim(substr($sWhere, $aV[1][1], strlen($aV[1][0])), "\"'");
+
+        $sTableSafe = str_replace('`', '', $sTable);
+        $sPkSafe = str_replace('`', '', $sPk);
+        $aRows = $oDb->getAll("SELECT `{$sPkSafe}` FROM `{$sTableSafe}` WHERE {$sWhere} LIMIT 2");
+        if (!$aRows)
+            throw new Exception("No rows match UPDATE WHERE. Use `{$sPk}` = value.");
+        if (count($aRows) > 1)
+            throw new Exception("UPDATE WHERE matched more than one row. Use `{$sPk}` = value.");
+
+        return (string)$aRows[0][$sPkSafe];
+    }
+
+    protected function _insertedPkFromSql(string $sSql, string $sPk): string
+    {
+        $sPkRe = preg_quote($sPk, '/');
+        if (preg_match('/`' . $sPkRe . '`\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sSql, $aM))
+            return trim($aM[1], "\"'");
+        if (preg_match('/\b' . $sPkRe . '\b\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sSql, $aM))
+            return trim($aM[1], "\"'");
+        return '';
+    }
+
+    protected function _fetchRow(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): array|false
+    {
+        $sTable = str_replace('`', '', $sTable);
+        $sPk = str_replace('`', '', $sPk);
+        if (preg_match('/^\d+$/', $sPkValue))
+            return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", ['v' => (int)$sPkValue]);
+        return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", ['v' => $sPkValue]);
+    }
+
+    public static function currentLogContext(): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $oAi = BxDolAi::getInstance();
+        $iAgentId = 0;
+        $sThread = '';
+        $iProfile = is_object($oAi) ? (int)$oAi->getProfileId() : 0;
+        $iHist = (int)BxDolAiChat::getInstance()->getCurrentChatHistoryId();
+        if ($iHist > 0) {
+            $aRow = $oDb->getRow("SELECT `thread_id` FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => $iHist]);
+            $sThread = $aRow['thread_id'] ?? '';
+            $aParts = explode(':', $sThread);
+            $iAgentId = (int)($aParts[1] ?? 0);
+        }
+
+        return [
+            'agent_id' => $iAgentId,
+            'profile_id' => $iProfile,
+            'thread_id' => $sThread,
+        ];
+    }
+
+    protected function _log(string $sOp, string $sTable, string $sPk, string $sPkValue, string $sSql, ?string $sBefore, ?string $sAfter): int
+    {
+        $oDb = BxDolDb::getInstance();
+        $aCtx = self::currentLogContext();
+
+        $oDb->query("INSERT INTO `sys_agents_sql_log` SET " . $oDb->arrayToSQL([
+            'agent_id' => $aCtx['agent_id'],
+            'profile_id' => $aCtx['profile_id'],
+            'thread_id' => $aCtx['thread_id'],
+            'op' => $sOp,
+            'table_name' => $sTable,
+            'pk_name' => $sPk,
+            'pk_value' => $sPkValue,
+            'sql_text' => $sSql,
+            'before_json' => $sBefore,
+            'after_json' => $sAfter,
+            'added' => time(),
+        ]));
+
+        return (int)$oDb->lastId();
+    }
+}

+ 409 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIToolPromptFeedback.php

@@ -0,0 +1,409 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolPromptFeedback extends BxDolAITool
+{
+    public const TABLE = 'sys_agents_prompt_feedback';
+
+    protected const CLIP_FIELD = 20000;
+    protected const CLIP_TURN = 1500;
+    protected const EXCERPT_TURNS = 8;
+
+    public function __construct()
+    {
+        parent::__construct(
+            'prompt_feedback',
+            'Log a wrong answer. Call when the user says you were wrong, corrects you, or you notice you broke a rule. Fill error_reason, expected_behavior, suggested_prompt_rule as reusable prompt text — not an apology. Question and wrong reply are taken from this chat if omitted.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'error_reason',
+                type: PropertyType::STRING,
+                description: 'Why the previous answer was wrong. Be specific: what fact, policy, tone, or missing step failed.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'expected_behavior',
+                type: PropertyType::STRING,
+                description: 'What the agent should have said or done instead. Include the correct answer when it is known.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'suggested_prompt_rule',
+                type: PropertyType::STRING,
+                description: 'One concise instruction to add or change in the system prompt so this failure does not repeat. Write it as a rule the agent can follow, not a description of this chat.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'category',
+                type: PropertyType::STRING,
+                description: 'hallucination | wrong_policy | wrong_tone | missing_info | tool_misuse | language | other',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'severity',
+                type: PropertyType::STRING,
+                description: 'low | medium | high. high = wrong fact, leaked private data, or a broken business rule.',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'user_question',
+                type: PropertyType::STRING,
+                description: 'Optional override of the user question that got the wrong answer. Leave empty to take it from chat history.',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'wrong_answer',
+                type: PropertyType::STRING,
+                description: 'Optional override of the wrong assistant reply. Leave empty to take it from chat history.',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'user_correction',
+                type: PropertyType::STRING,
+                description: 'Optional override of the user message that flagged the error. Leave empty to take the latest user line.',
+                required: false
+            ),
+        ];
+    }
+
+    public function __invoke(
+        string $error_reason,
+        string $expected_behavior,
+        string $suggested_prompt_rule,
+        ?string $category = null,
+        ?string $severity = null,
+        ?string $user_question = null,
+        ?string $wrong_answer = null,
+        ?string $user_correction = null
+    ): array {
+        $oDb = BxDolDb::getInstance();
+
+        $sReason = self::clip(trim((string)$error_reason), self::CLIP_FIELD);
+        $sExpected = self::clip(trim((string)$expected_behavior), self::CLIP_FIELD);
+        $sRule = self::clip(trim((string)$suggested_prompt_rule), self::CLIP_FIELD);
+        if ($sReason === '' || $sExpected === '' || $sRule === '')
+            throw new Exception('error_reason, expected_behavior and suggested_prompt_rule are required.');
+
+        $aCtx = self::currentContext();
+        $aTurns = self::turnsFromHistory($oDb, $aCtx['thread_id']);
+        $aCase = self::extractCase($aTurns, [
+            'user_question' => $user_question,
+            'wrong_answer' => $wrong_answer,
+            'user_correction' => $user_correction,
+        ]);
+
+        $iAdded = time();
+        $sCategory = self::normalizeCategory($category);
+        $sSeverity = self::normalizeSeverity($severity);
+        $sExcerpt = self::excerpt($aTurns);
+        $sMarkdown = self::caseMarkdown([
+            'added' => $iAdded,
+            'agent_id' => $aCtx['agent_id'],
+            'agent_name' => $aCtx['agent_name'],
+            'category' => $sCategory,
+            'severity' => $sSeverity,
+            'prompt_hash' => $aCtx['prompt_hash'],
+            'prompt_system' => $aCtx['prompt_system'],
+            'prompt_steps' => $aCtx['prompt_steps'],
+            'prompt_output' => $aCtx['prompt_output'],
+            'user_question' => $aCase['user_question'],
+            'wrong_answer' => $aCase['wrong_answer'],
+            'user_correction' => $aCase['user_correction'],
+            'error_reason' => $sReason,
+            'expected_behavior' => $sExpected,
+            'suggested_prompt_rule' => $sRule,
+            'conversation_excerpt' => $sExcerpt,
+        ]);
+
+        $oDb->query("INSERT INTO `" . self::TABLE . "` SET " . $oDb->arrayToSQL([
+            'agent_id' => $aCtx['agent_id'],
+            'agent_name' => $aCtx['agent_name'],
+            'profile_id' => $aCtx['profile_id'],
+            'thread_id' => $aCtx['thread_id'],
+            'history_id' => $aCtx['history_id'],
+            'category' => $sCategory,
+            'severity' => $sSeverity,
+            'user_question' => $aCase['user_question'],
+            'wrong_answer' => $aCase['wrong_answer'],
+            'user_correction' => $aCase['user_correction'],
+            'error_reason' => $sReason,
+            'expected_behavior' => $sExpected,
+            'suggested_prompt_rule' => $sRule,
+            'conversation_excerpt' => $sExcerpt,
+            'prompt_system' => $aCtx['prompt_system'],
+            'prompt_hash' => $aCtx['prompt_hash'],
+            'markdown' => $sMarkdown,
+            'added' => $iAdded,
+        ]));
+
+        return [
+            'ok' => 1,
+            'id' => (int)$oDb->lastId(),
+            'agent_id' => $aCtx['agent_id'],
+            'category' => $sCategory,
+            'severity' => $sSeverity,
+            'msg' => 'Logged. Do not mention the log to the user. Acknowledge the correction and continue with the expected behavior.',
+        ];
+    }
+
+    /**
+     * Paste-ready case: dump `markdown` into an AI to improve the prompt.
+     */
+    public static function caseMarkdown(array $a): string
+    {
+        $sWhen = !empty($a['added']) ? gmdate('Y-m-d H:i', (int)$a['added']) . ' UTC' : '';
+        $sName = (string)($a['agent_name'] ?? '');
+        $iId = (int)($a['agent_id'] ?? 0);
+        $sAgent = $sName !== '' ? '`' . $sName . '` (id ' . $iId . ')' : 'id ' . $iId;
+
+        $aOut = [];
+        $aOut[] = 'Improve this agent prompt from a real failure. Keep correct behavior. Add a concise testable rule. Do not invent product facts.';
+        $aOut[] = '';
+        $aOut[] = 'Agent: ' . $sAgent;
+        $aOut[] = 'When: ' . $sWhen;
+        $aOut[] = 'Category: ' . ($a['category'] ?? 'other') . ' / ' . ($a['severity'] ?? 'medium');
+        $aOut[] = 'Prompt hash: ' . ($a['prompt_hash'] ?? '');
+        $aOut[] = '';
+        $aOut[] = '## Failure';
+        $aOut[] = '';
+        $aOut[] = 'User asked:';
+        $aOut[] = self::mdBlock($a['user_question'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Agent answered (wrong):';
+        $aOut[] = self::mdBlock($a['wrong_answer'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'User correction:';
+        $aOut[] = self::mdBlock($a['user_correction'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Why it was wrong:';
+        $aOut[] = self::mdBlock($a['error_reason'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Expected behavior / correct answer:';
+        $aOut[] = self::mdBlock($a['expected_behavior'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Suggested prompt rule:';
+        $aOut[] = self::mdBlock($a['suggested_prompt_rule'] ?? '');
+        $aOut[] = '';
+        $aOut[] = '## Conversation excerpt';
+        $aOut[] = '';
+        $aOut[] = self::mdBlock($a['conversation_excerpt'] ?? '');
+        $aOut[] = '';
+        $aOut[] = '## Prompt at the time of failure';
+        $aOut[] = '';
+        $aOut[] = 'prompt_system:';
+        $aOut[] = self::mdBlock($a['prompt_system'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'prompt_steps:';
+        $aOut[] = self::mdBlock($a['prompt_steps'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'prompt_output:';
+        $aOut[] = self::mdBlock($a['prompt_output'] ?? '');
+
+        return implode("\n", $aOut);
+    }
+
+    /**
+     * @return array{agent_id:int,agent_name:string,profile_id:int,thread_id:string,history_id:int,prompt_system:string,prompt_hash:string,prompt_steps:string,prompt_output:string,prompt_tools:string}
+     */
+    public static function currentContext(): array
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $iAgentId = 0;
+        $sThread = '';
+        $iProfile = (int)bx_get_logged_profile_id();
+        $aAgent = [];
+
+        $iHistory = (int)BxDolAiChat::getInstance()->getCurrentChatHistoryId();
+
+        if ($iHistory > 0) {
+            $aRow = $oDb->getRow("SELECT `thread_id` FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => $iHistory]);
+            $sThread = (string)($aRow['thread_id'] ?? '');
+            $aParts = explode(':', $sThread);
+            $iAgentId = (int)($aParts[1] ?? 0);
+        }
+
+        if ($iAgentId > 0)
+            $aAgent = BxDolAiQuery::getAgentObject($iAgentId) ?: [];
+
+        $sSystem = (string)($aAgent['prompt_system'] ?? '');
+        $sSteps = (string)($aAgent['prompt_steps'] ?? '');
+        $sOutput = (string)($aAgent['prompt_output'] ?? '');
+        $sTools = (string)($aAgent['prompt_tools'] ?? '');
+
+        return [
+            'agent_id' => $iAgentId,
+            'agent_name' => (string)($aAgent['name'] ?? ''),
+            'profile_id' => $iProfile,
+            'thread_id' => $sThread,
+            'history_id' => $iHistory,
+            'prompt_system' => self::clip($sSystem, self::CLIP_FIELD),
+            'prompt_hash' => hash('sha256', $sSystem . "\n" . $sSteps . "\n" . $sOutput . "\n" . $sTools),
+            'prompt_steps' => self::clip($sSteps, self::CLIP_FIELD),
+            'prompt_output' => self::clip($sOutput, self::CLIP_FIELD),
+            'prompt_tools' => self::clip($sTools, self::CLIP_FIELD),
+        ];
+    }
+
+    /**
+     * @return array<int, array{role:string,text:string}>
+     */
+    public static function turnsFromHistory(BxDolDb $oDb, string $sThread): array
+    {
+        if ($sThread === '')
+            return [];
+
+        $sJson = (string)$oDb->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThread,
+        ]);
+        $aStored = json_decode($sJson, true);
+        if (!is_array($aStored))
+            return [];
+
+        $aTurns = [];
+        foreach ($aStored as $aMessage) {
+            if (!is_array($aMessage))
+                continue;
+            $sType = (string)($aMessage['type'] ?? '');
+            if ($sType === 'tool_call' || $sType === 'tool_call_result')
+                continue;
+            $sRole = (string)($aMessage['role'] ?? '');
+            if ($sRole === 'model')
+                $sRole = 'assistant';
+            if ($sRole !== 'user' && $sRole !== 'assistant')
+                continue;
+            $sText = self::textFromContent($aMessage['content'] ?? '');
+            if ($sText === '')
+                continue;
+            $aTurns[] = ['role' => $sRole, 'text' => $sText];
+        }
+        return $aTurns;
+    }
+
+    /**
+     * @param array<int, array{role:string,text:string}> $aTurns
+     * @param array{user_question?:?string,wrong_answer?:?string,user_correction?:?string} $aOverride
+     * @return array{user_question:string,wrong_answer:string,user_correction:string}
+     */
+    public static function extractCase(array $aTurns, array $aOverride = []): array
+    {
+        $sQuestion = self::clip(trim((string)($aOverride['user_question'] ?? '')), self::CLIP_FIELD);
+        $sWrong = self::clip(trim((string)($aOverride['wrong_answer'] ?? '')), self::CLIP_FIELD);
+        $sCorrection = self::clip(trim((string)($aOverride['user_correction'] ?? '')), self::CLIP_FIELD);
+
+        $sLastUser = '';
+        $sLastAssistant = '';
+        $sPrevUser = '';
+        for ($i = count($aTurns) - 1; $i >= 0; $i--) {
+            $sRole = $aTurns[$i]['role'];
+            $sText = $aTurns[$i]['text'];
+            if ($sRole === 'user' && $sLastUser === '') {
+                $sLastUser = $sText;
+                continue;
+            }
+            if ($sRole === 'assistant' && $sLastAssistant === '' && $sLastUser !== '') {
+                $sLastAssistant = $sText;
+                continue;
+            }
+            if ($sRole === 'user' && $sLastAssistant !== '' && $sPrevUser === '') {
+                $sPrevUser = $sText;
+                break;
+            }
+        }
+
+        if ($sCorrection === '')
+            $sCorrection = self::clip($sLastUser, self::CLIP_FIELD);
+        if ($sWrong === '')
+            $sWrong = self::clip($sLastAssistant, self::CLIP_FIELD);
+        if ($sQuestion === '')
+            $sQuestion = self::clip($sPrevUser !== '' ? $sPrevUser : $sLastUser, self::CLIP_FIELD);
+
+        return [
+            'user_question' => $sQuestion,
+            'wrong_answer' => $sWrong,
+            'user_correction' => $sCorrection,
+        ];
+    }
+
+    /**
+     * @param array<int, array{role:string,text:string}> $aTurns
+     */
+    public static function excerpt(array $aTurns): string
+    {
+        $aSlice = array_slice($aTurns, -self::EXCERPT_TURNS);
+        $aLines = [];
+        foreach ($aSlice as $aTurn) {
+            $sLabel = $aTurn['role'] === 'assistant' ? 'Assistant' : 'User';
+            $aLines[] = $sLabel . ': ' . self::clip($aTurn['text'], self::CLIP_TURN);
+        }
+        return implode("\n\n", $aLines);
+    }
+
+    public static function clip(string $s, int $iMax): string
+    {
+        if ($iMax <= 0 || mb_strlen($s) <= $iMax)
+            return $s;
+        return mb_substr($s, 0, $iMax - 1) . '…';
+    }
+
+    public static function textFromContent($mixed): string
+    {
+        if (is_string($mixed))
+            return trim($mixed);
+        if (!is_array($mixed))
+            return '';
+
+        $aParts = [];
+        foreach ($mixed as $aBlock) {
+            if (is_string($aBlock)) {
+                $s = trim($aBlock);
+                if ($s !== '')
+                    $aParts[] = $s;
+                continue;
+            }
+            if (!is_array($aBlock))
+                continue;
+            $sType = (string)($aBlock['type'] ?? '');
+            $sText = (string)($aBlock['content'] ?? ($aBlock['text'] ?? ''));
+            if ($sText === '')
+                continue;
+            if ($sType === '' || $sType === 'text')
+                $aParts[] = trim($sText);
+        }
+        return trim(implode("\n", $aParts));
+    }
+
+    protected static function mdBlock($mixed): string
+    {
+        $s = trim((string)$mixed);
+        return $s !== '' ? $s : '(empty)';
+    }
+
+    protected static function normalizeCategory(?string $s): string
+    {
+        $s = strtolower(trim((string)$s));
+        $aOk = ['hallucination', 'wrong_policy', 'wrong_tone', 'missing_info', 'tool_misuse', 'language', 'other'];
+        return in_array($s, $aOk, true) ? $s : 'other';
+    }
+
+    protected static function normalizeSeverity(?string $s): string
+    {
+        $s = strtolower(trim((string)$s));
+        $aOk = ['low', 'medium', 'high'];
+        return in_array($s, $aOk, true) ? $s : 'medium';
+    }
+}

+ 67 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIUnaProxyHttpClient.php

@@ -0,0 +1,67 @@
+<?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
+ * @{
+ */
+
+class BxDolAIUnaProxyHttpClient extends NeuronAI\HttpClient\GuzzleHttpClient
+{
+    public function __construct(
+        protected string $sUnaKey,
+        protected string $sUnaSecret,
+    ) {
+        parent::__construct();
+    }
+
+    public static function encodeBody(array $aBody): string
+    {
+        return json_encode($aBody, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+    }
+
+    public static function sign(string $sRawBody, string $sSecret): string
+    {
+        return hash_hmac('sha256', $sRawBody, $sSecret);
+    }
+
+    /**
+     * Headers for the exact bytes that will be posted. The secret is not a header.
+     *
+     * @return array<string, string>
+     */
+    public function headersForBody(string $sRawBody): array
+    {
+        if ($this->sUnaKey === '' || $this->sUnaSecret === '')
+            throw new Exception(_t('_sys_agents_una_proxy_unconfigured'));
+
+        return [
+            'X-Una-Key' => $this->sUnaKey,
+            'X-Una-Sign' => self::sign($sRawBody, $this->sUnaSecret),
+        ];
+    }
+
+    public function runRequest(NeuronAI\HttpClient\HttpRequest $request, array $options, GuzzleHttp\Client $client): Psr\Http\Message\ResponseInterface
+    {
+        $sBody = '';
+        if (is_array($request->body)) {
+            $sBody = self::encodeBody($request->body);
+            $request = new NeuronAI\HttpClient\HttpRequest($request->method, $request->uri, $request->headers, $sBody);
+        }
+        elseif (is_string($request->body))
+            $sBody = $request->body;
+
+        $aHeaders = $options[\GuzzleHttp\RequestOptions::HEADERS] ?? [];
+        foreach (array_keys($aHeaders) as $sName) {
+            if (strcasecmp((string)$sName, 'Authorization') === 0)
+                unset($aHeaders[$sName]);
+        }
+        $options[\GuzzleHttp\RequestOptions::HEADERS] = array_merge($aHeaders, $this->headersForBody($sBody));
+        unset($options[\GuzzleHttp\RequestOptions::JSON]);
+
+        return parent::runRequest($request, $options, $client);
+    }
+}
+
+/** @} */

+ 138 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIVectorStoreFactory.php

@@ -0,0 +1,138 @@
+<?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
+ * @{
+ */
+
+
+class BxDolAIVectorStoreFactory extends BxDolFactory 
+{
+    public static function getVectorStoreInstance(int $iId): NeuronAI\RAG\VectorStore\VectorStoreInterface 
+    {
+        if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_VectorStore_' . $iId]))
+            return $GLOBALS['bxDolClasses'][__CLASS__ . '_VectorStore_' . $iId];
+
+        $a = BxDolAiQuery::getVectorStoreObject($iId);
+        if (!$a) {
+            bx_log('sys_agents', "Vector store with id {$iId} not found", BX_LOG_ERR);
+            throw new Exception("Vector store with id {$iId} not found");
+        }
+
+        $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 marker {topk} in $aParameters recoursively
+        $aParameters = bx_replace_markers($aParameters, [
+            'topk' => $a['topk'],
+        ]);
+        
+        switch($a['type']) {
+            case 'file':
+                $sDirectoryStores = BX_DIRECTORY_STORAGE . 'vector_stores/';
+                $sDirectory = $sDirectoryStores . $iId;
+                $sFile = '/data.store';
+                if (!file_exists($sDirectory . $sFile)) {
+                    @mkdir($sDirectoryStores, BX_DOL_DIR_RIGHTS);
+                    @chmod($sDirectoryStores, BX_DOL_DIR_RIGHTS);
+                    @mkdir($sDirectory, BX_DOL_DIR_RIGHTS);
+                    @chmod($sDirectory, BX_DOL_DIR_RIGHTS);
+                    @touch($sDirectory . $sFile);
+                    @chmod($sDirectory . $sFile, BX_DOL_FILE_RIGHTS);
+                }
+                $o = new NeuronAI\RAG\VectorStore\FileVectorStore(
+                    directory: $sDirectory,
+                    topK: $a['topk'] ?? 4,
+                    name: 'data',
+                    ext: '.store'
+                );
+                break;
+            case 'pinecon':
+                $o = new NeuronAI\RAG\VectorStore\PineconeVectorStore(
+                    key: $a['key'],
+                    indexUrl: $aParameters['indexUrl'],
+                    topK: $a['topk'] ?? 4,
+                    version: $aParameters['version'] ?? '2025-04',
+                    namespace: $aParameters['namespace'] ?? '__default__'
+                );
+                break;
+            case 'elasticsearch':
+                $oClient = Elastic\Elasticsearch\ClientBuilder::create()
+                    ->setHosts([$aParameters['client_params']['endpoint']])
+                    ->setApiKey($aParameters['client_params']['key'])
+                    ->build();
+                
+                $o = new NeuronAI\RAG\VectorStore\ElasticsearchVectorStore(
+                    client: $oClient,
+                    index: $aParameters['index'],
+                    topK: $a['topk'] ?? 4
+                );
+                break;
+            case 'opensearch':
+                $oClient = (new OpenSearch\GuzzleClientFactory())->create([
+                    'base_uri' => $aParameters['client_params']['endpoint'] ?? 'http://localhost:9200',
+                ]);
+        
+                $o = NeuronAI\RAG\VectorStore\OpenSearchVectorStore(
+                    client: $oClient,
+                    index: $aParameters['index'],
+                    topK: $a['topk'] ?? 4,
+                );
+                break;
+            case 'typesense':
+                $oClient = new \Typesense\Client($aParameters['client_params']);
+
+                $o = new NeuronAI\RAG\VectorStore\TypesenseVectorStore(
+                    client: $oClient,
+                    collection: $aParameters['collection'],
+                    vectorDimension: $aParameters['vectorDimension'] ?? 1024,
+                    topK: $a['topk'] ?? 4,
+                );
+                break;
+            case 'qdrant':
+                $o = new NeuronAI\RAG\VectorStore\QdrantVectorStore(
+                    collectionUrl: $aParameters['collectionUrl'],
+                    key: $a['key'],
+                    topK: $a['topk'] ?? 4,
+                    dimension: $aParameters['dimension'] ?? 1024
+                );
+                break;
+            case 'chromadb':
+                $o = new NeuronAI\RAG\VectorStore\ChromaVectorStore(
+                    collection: $aParameters['collection'],
+                    host: $aParameters['host'] ?? 'http://localhost:8000',
+                    tenant: $aParameters['tenant'] ?? 'default_tenant',
+                    database: $aParameters['database'] ?? 'default_database',
+                    key: $a['key'] ?? null,
+                    topK: $a['topk'] ?? 4
+                );
+                break;
+            case 'meilisearch':
+                $o = new NeuronAI\RAG\VectorStore\MeilisearchVectorStore(
+                    indexUid: $aParameters['indexUid'],
+                    host: $aParameters['host'] ?? 'http://localhost:8000',
+                    key: $a['key'] ?? null,
+                    embedder: $aParameters['embedder'] ?? 'default',
+                    topK: $a['topk'] ?? 4,
+                    dimension: $aParameters['dimension'] ?? 1024
+                );
+                break;
+            case 'mariadb':
+                $o = BxDolAIVectorStoreMariaDb::open($iId, (int)($a['topk'] ?? 4));
+                break;
+            case 'una-proxy':
+                bx_log('sys_agents', 'Remote knowledge is stored on the master', BX_LOG_ERR);
+                throw new Exception('Remote knowledge is stored on the master');
+            default:
+                bx_log('sys_agents', "Vector store type {$a['type']} is not supported", BX_LOG_ERR);
+                throw new Exception("Vector store type {$a['type']} is not supported");
+        }
+
+        $GLOBALS['bxDolClasses'][__CLASS__ . '_VectorStore_' . $iId] = $o;
+
+        return $o;
+    }
+}

+ 152 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIVectorStoreMariaDb.php

@@ -0,0 +1,152 @@
+<?php defined('BX_DOL') or defined('BX_DOL_INSTALL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * MariaDB vector store on the site database connection.
+ * Embeddings of width 1024 and 384 live in separate tables, created from install/sql/mariadb.sql.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIVectorStoreMariaDb
+{
+    public const MARIADB_MIN_VERSION = '11.7.0';
+    public const DIMENSIONS = [1024, 384];
+
+    /**
+     * @return array{engine:string, version:string, supported:bool}
+     */
+    public static function describe(string $sVersion): array
+    {
+        $sVersion = trim($sVersion);
+        if ($sVersion === '')
+            return ['engine' => '', 'version' => '', 'supported' => false];
+
+        $bMaria = stripos($sVersion, 'mariadb') !== false;
+        $sEngine = $bMaria ? 'mariadb' : 'mysql';
+        $sNumber = self::versionNumber($sVersion, $bMaria);
+        $bSupported = $bMaria && $sNumber !== '' && self::atLeast($sNumber, self::MARIADB_MIN_VERSION);
+
+        return [
+            'engine' => $sEngine,
+            'version' => $sNumber,
+            'supported' => $bSupported,
+        ];
+    }
+
+    public static function compatibilityMessage(array $aServer): string
+    {
+        if (($aServer['engine'] ?? '') === 'mariadb')
+            $sEngine = 'MariaDB';
+        elseif (($aServer['engine'] ?? '') === 'mysql')
+            $sEngine = 'MySQL';
+        else
+            $sEngine = 'unknown';
+
+        $sVersion = (string)($aServer['version'] ?? '');
+        if ($sVersion === '')
+            $sVersion = 'unknown';
+
+        return 'MariaDB vector store needs MariaDB ' . self::MARIADB_MIN_VERSION . ' or newer (connected: ' . $sEngine . ' ' . $sVersion . ')';
+    }
+
+    public static function tableName(string $sTable): string
+    {
+        if (!preg_match('/^[A-Za-z0-9_]+$/', $sTable))
+            throw new Exception('MariaDB vector store table name is invalid');
+
+        return $sTable;
+    }
+
+    public static function tableForDimension(int $iDimension): string
+    {
+        if (!in_array($iDimension, self::DIMENSIONS, true))
+            throw new Exception('MariaDB vector store expects embedding width 1024 or 384, got ' . $iDimension);
+
+        return self::tableName('sys_agents_vector_mariadb_' . $iDimension);
+    }
+
+    public static function pdo(): PDO
+    {
+        BxDolDb::getInstance();
+        $oPdo = BxDolDb::getLink();
+        if (!$oPdo instanceof PDO)
+            throw new Exception('Database is not connected');
+
+        return $oPdo;
+    }
+
+    public static function open(int $iStoreId, int $iTopK): BxDolAIVectorStoreMariaDbStore
+    {
+        if ($iStoreId < 1)
+            throw new Exception('MariaDB vector store id is missing');
+        if ($iTopK < 1)
+            $iTopK = 4;
+
+        $oDb = BxDolDb::getInstance();
+        $sVersion = $oDb ? (string)$oDb->getOne('SELECT VERSION()') : '';
+        $aServer = self::describe($sVersion);
+        if (empty($aServer['supported'])) {
+            $sMessage = self::compatibilityMessage($aServer);
+            bx_log('sys_agents', $sMessage, BX_LOG_ERR);
+            throw new Exception($sMessage);
+        }
+
+        foreach (self::DIMENSIONS as $iDimension)
+            self::assertTable($iDimension);
+
+        return new BxDolAIVectorStoreMariaDbStore(self::pdo(), $iTopK, (string)$iStoreId);
+    }
+
+    protected static function versionNumber(string $sVersion, bool $bMaria): string
+    {
+        if ($bMaria && preg_match('/(\d+\.\d+(?:\.\d+)?)-MariaDB/i', $sVersion, $aMatch))
+            return $aMatch[1];
+        if (preg_match('/(\d+\.\d+(?:\.\d+)?)/', $sVersion, $aMatch))
+            return $aMatch[1];
+
+        return '';
+    }
+
+    protected static function atLeast(string $sVersion, string $sMinimum): bool
+    {
+        return version_compare(self::normalizeVersion($sVersion), self::normalizeVersion($sMinimum), '>=');
+    }
+
+    protected static function normalizeVersion(string $sVersion): string
+    {
+        $aParts = explode('.', $sVersion);
+        $aParts = array_slice(array_pad($aParts, 3, '0'), 0, 3);
+        return implode('.', $aParts);
+    }
+
+    protected static function assertTable(int $iDimension): void
+    {
+        $sTable = self::tableForDimension($iDimension);
+        $iExisting = self::embeddingDimensions($sTable);
+        if ($iExisting === null)
+            throw new Exception('MariaDB vector store table ' . $sTable . ' is missing');
+        if ($iExisting !== $iDimension)
+            throw new Exception('MariaDB vector store ' . $sTable . ' must use dimension ' . $iDimension);
+    }
+
+    protected static function embeddingDimensions(string $sTable): ?int
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb)
+            return null;
+
+        $sType = $oDb->getOne(
+            'SELECT `COLUMN_TYPE` FROM `information_schema`.`COLUMNS` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = ? AND `COLUMN_NAME` = ?',
+            [$sTable, 'embedding']
+        );
+        if (!is_string($sType) || !preg_match('/vector\((\d+)\)/i', $sType, $aMatch))
+            return null;
+
+        return (int)$aMatch[1];
+    }
+}
+
+/** @} */

+ 97 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAIVectorStoreMariaDbStore.php

@@ -0,0 +1,97 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * MariaDB vector search limited to one Studio store.
+ * Rows from every store share one table per embedding width and are marked with sourceType.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIVectorStoreMariaDbStore extends NeuronAI\RAG\VectorStore\MariaDBVectorStore
+{
+    public function __construct(
+        PDO $oPdo,
+        int $iTopK,
+        protected string $sStoreId,
+    ) {
+        parent::__construct($oPdo, BxDolAIVectorStoreMariaDb::tableForDimension(1024), $iTopK);
+    }
+
+    public function addDocuments(array $documents): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        $aGrouped = [];
+        foreach ($documents as $oDocument) {
+            if (!$oDocument instanceof NeuronAI\RAG\Document)
+                continue;
+
+            $sTable = BxDolAIVectorStoreMariaDb::tableForDimension(count($oDocument->getEmbedding()));
+            $aGrouped[$sTable][] = $oDocument;
+        }
+
+        foreach ($aGrouped as $sTable => $aDocuments) {
+            $this->tableName = $sTable;
+            parent::addDocuments($aDocuments);
+        }
+
+        return $this;
+    }
+
+    public function deleteBy(string $sourceType, ?string $sourceName = null): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        foreach (BxDolAIVectorStoreMariaDb::DIMENSIONS as $iDimension) {
+            $this->tableName = BxDolAIVectorStoreMariaDb::tableForDimension($iDimension);
+            parent::deleteBy($sourceType, $sourceName);
+        }
+
+        return $this;
+    }
+
+    public function similaritySearch(array $embedding): iterable
+    {
+        $this->tableName = BxDolAIVectorStoreMariaDb::tableForDimension(count($embedding));
+
+        $oStatement = $this->pdo->prepare(sprintf(
+            <<<'SQL'
+                SELECT id, content, sourceType, sourceName, metadata,
+                       VEC_DISTANCE_COSINE(embedding, VEC_FromText(:embedding)) AS distance
+                FROM %s
+                WHERE sourceType = :storeId
+                ORDER BY distance ASC
+                LIMIT %d
+                SQL,
+            $this->tableName,
+            $this->topK,
+        ));
+
+        $oStatement->execute([
+            ':embedding' => json_encode($embedding),
+            ':storeId' => $this->sStoreId,
+        ]);
+
+        $aDocuments = [];
+        while ($aRow = $oStatement->fetch(PDO::FETCH_ASSOC)) {
+            $oDocument = new NeuronAI\RAG\Document($aRow['content']);
+            $oDocument->id = $aRow['id'];
+            $oDocument->sourceType = $aRow['sourceType'];
+            $oDocument->sourceName = $aRow['sourceName'];
+            $oDocument->score = NeuronAI\RAG\VectorSimilarity::similarityFromDistance((float)$aRow['distance']);
+
+            $aMetadata = json_decode($aRow['metadata'] ?? '{}', true);
+            if (is_array($aMetadata)) {
+                foreach ($aMetadata as $sKey => $mixedValue) {
+                    if (!in_array($sKey, ['content', 'sourceType', 'sourceName', 'score', 'embedding', 'id'], true))
+                        $oDocument->addMetadata($sKey, $mixedValue);
+                }
+            }
+
+            $aDocuments[] = $oDocument;
+        }
+
+        return $aDocuments;
+    }
+}
+
+/** @} */

+ 220 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAi.php

@@ -0,0 +1,220 @@
+<?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
+ * @{
+ */
+
+class BxDolAi extends BxDolFactory implements iBxDolSingleton
+{
+    protected $_oDb;
+    protected $_iProfileId;
+    protected $_aAgentProfiles = [];
+
+    protected function __construct()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+        $this->_oDb = new BxDolAiQuery();
+
+        $this->_iProfileId = (int)getParam('sys_profile_bot');
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__])) {
+            $GLOBALS['bxDolClasses'][__CLASS__] = BxDolDb::getInstance()->isTableExists('sys_agents_agents') ? new BxDolAi() : null;
+        }
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public static function getAgentInstance(int $iId, array $aParams = []): NeuronAI\Agent\Agent
+    {
+        if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId]))
+            return $GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId];
+
+        $a = BxDolAiQuery::getAgentObject($iId);
+        if (!$a) {
+            $s = "Agent with id {$iId} not found";
+            bx_log('sys_agents', $s, BX_LOG_ERR);
+            throw new Exception($s);
+        }
+        if (!$a['active']) {
+            $s = "Agent with id {$iId} isn't active";
+            bx_log('sys_agents', $s, BX_LOG_ERR);
+            throw new Exception($s);
+        }
+        if (!$a['prompt_system'] || !$a['model_id']) {
+            $s = "Agent with id {$iId} can't be used because it haven't prompt or AI model";
+            bx_log('sys_agents', $s, BX_LOG_ERR);
+            throw new Exception($s);
+        }
+
+        $aVectorStore = false;
+        if ($a['vector_store_id']) {
+            $aVectorStore = BxDolAiQuery::getVectorStoreObject($a['vector_store_id']);
+            if (is_array($aVectorStore) && (string)($aVectorStore['type'] ?? '') === 'una-proxy')
+                $a['vector_store_type'] = 'una-proxy';
+        }
+
+        $o = BxDolAiAgent::make($a, $aParams);
+
+        if ((int)$a['tools_max_run'] > 0)
+            $o->toolMaxRuns($a['tools_max_run']);
+
+        if (is_array($aVectorStore) && ($a['vector_store_type'] ?? '') !== 'una-proxy' && !empty($aVectorStore['embedding_provider_id'])) {
+            $oEmbedder = self::getAiEmbeddingsProviderInstance($aVectorStore['embedding_provider_id']);
+            $o->setEmbeddingsProvider($oEmbedder);
+        }
+
+        $GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId] = $o;
+
+        return $o;
+    }
+
+    public static function getAiProviderInstance(int $iId):NeuronAI\Providers\AIProviderInterface
+    {
+        return BxDolAIModelFactory::getModelInstance($iId);
+    }
+
+    public static function getAiEmbeddingsProviderInstance(int $iId):NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
+    {
+        return BxDolAIModelFactory::getModelInstance($iId);
+    }
+
+    public function getProfileId()
+    {
+        return $this->_iProfileId;
+    }
+
+    public function getModels($aParams = [])
+    {
+        $aParamsDb = ['sample' => 'all_pairs'];
+        if(isset($aParams['active']))
+            $aParamsDb['active'] = $aParams['active'] === true ? 1 : 0;
+        if(isset($aParams['capabilities']))
+            $aParamsDb['capabilities'] = $aParams['capabilities'];
+
+        return $this->_oDb->getModelsBy($aParamsDb);
+    }
+
+    public function callAgent($sType, $aAgent, $mixedParams = [])
+    {
+        return BxDolAiTrigger::getInstance($sType)->call($aAgent, $mixedParams);
+    }
+
+    /**
+     * Whether the current (or given) member may interact with the agent
+     * via chat, messenger, or form-input. Empty acl_levels means Nobody.
+     * Site admins / Studio operators always may, so they can debug.
+     */
+    public function canInteract($aAgent, $iProfileId = false, $bAllowOperators = true)
+    {
+        if (!is_array($aAgent) || empty($aAgent['id']))
+            return false;
+
+        if ($bAllowOperators && isAdmin())
+            return true;
+
+        $iLevels = (int)($aAgent['acl_levels'] ?? 0);
+        if (!$iLevels)
+            return false;
+
+        return (bool)BxDolAcl::getInstance()->isMemberLevelInSet($iLevels, $iProfileId);
+    }
+
+    /**
+     * Direct HTTP chat (`sys-ai-chat`). Operators may talk to any trigger;
+     * everyone else only `manual` and `message` agents they can interact with.
+     */
+    public function canChatDirectly($aAgent, $iProfileId = false)
+    {
+        if (!$this->canInteract($aAgent, $iProfileId))
+            return false;
+
+        if (isAdmin())
+            return true;
+
+        return in_array($aAgent['trigger'] ?? '', ['manual', 'message'], true);
+    }
+
+    public function extractChatPromptFromRequest($aData)
+    {
+        return BxDolAiTrigger::getInstance('chat')->extractPromptFromRequest($aData);
+    }
+
+    public function streamAgentChat($iAgentId, $sPrompt, $aParams = [], $sThreadId = null)
+    {
+        return BxDolAiTrigger::getInstance('chat')->stream($iAgentId, $sPrompt, $aParams, $sThreadId);
+    }
+
+    public function sendMessengerMessage($iSender, $iRecipient, $sMsg)
+    {
+        return BxDolAiTrigger::getInstance('message')->sendMessengerMessage($iSender, $iRecipient, $sMsg);
+    }
+
+    public function getAgentsByAlertUnitAndAction($sUnit, $sAction)
+    {
+        return BxDolAiTrigger::getInstance('alert')->getAgentsByUnitAndAction($sUnit, $sAction);
+    }
+
+    public function getAgentsBy($aParams)
+    {
+        return $this->_oDb->getAgentsBy($aParams);
+    }
+
+    public function getAgentsByProfileId($iProfileId)
+    {
+        return BxDolAiTrigger::getInstance('message')->getAgentsByProfileId($iProfileId);
+    }
+
+    public function getAgentsByFormObject($sFormObject)
+    {
+        return $this->_oDb->getAgentsByFormObject($sFormObject);
+    }
+
+    public function getAgentsByTriggerType($sTrigger)
+    {
+        return $this->_oDb->getAgentsByTriggerType($sTrigger);
+    }
+
+    public function getAgentByTriggerWebhookKey($sKey)
+    {
+        return BxDolAiTrigger::getInstance('webhook')->getAgentByKey($sKey);
+    }
+
+    /**
+     * Profile is used by an active agent (any trigger). Cached per request.
+     */
+    public function isAgentProfile($iProfileId)
+    {
+        $iProfileId = (int)$iProfileId;
+        if ($iProfileId <= 0)
+            return false;
+
+        if (!array_key_exists($iProfileId, $this->_aAgentProfiles))
+            $this->_aAgentProfiles[$iProfileId] = $this->_oDb->isAgentProfile($iProfileId);
+
+        return $this->_aAgentProfiles[$iProfileId];
+    }
+}
+
+/** @} */

+ 489 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiActivity.php

@@ -0,0 +1,489 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Human-readable activity of agents: what an alert / scheduler / webhook agent
+ * actually did (posted a comment, changed content, ran SQL...). One row per
+ * write tool call, stored in `sys_agents_activity`, written by
+ * BxDolAiActivityObserver and shown in Studio and in the App.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiActivity
+{
+    const TABLE = 'sys_agents_activity';
+    const SUMMARY_MAX = 300;
+    const DETAILS_MAX = 20000;
+
+    /**
+     * tool name → action key. Tools not listed here are read-only and are not recorded.
+     */
+    const TOOL_ACTIONS = [
+        'comments_add' => 'comment_add',
+        'comments_update' => 'comment_update',
+        'comments_delete' => 'comment_delete',
+        'content_add' => 'content_add',
+        'content_update' => 'content_update',
+        'content_delete' => 'content_delete',
+        'mysql_write' => 'db_write',
+        'mysql_write_safe' => 'db_write',
+        'mysql_undo' => 'db_undo',
+        'email_send' => 'email',
+        'polyglot' => 'lang',
+        'agent_create' => 'agent',
+        'mockup_upsert' => 'mockup',
+    ];
+
+    public static function isTableReady(): bool
+    {
+        static $bReady = null;
+        if ($bReady === null)
+            $bReady = BxDolDb::getInstance()->isTableExists(self::TABLE);
+        return $bReady;
+    }
+
+    /**
+     * Record one tool call of an agent. $mixedResult is whatever the tool returned
+     * (array or string); a "Tool ... failed:" string means the call threw.
+     */
+    public static function record(array $aAgent, array $aParams, string $sTool, array $aInputs, $mixedResult): int
+    {
+        if (!self::isTableReady())
+            return 0;
+
+        $sAction = self::TOOL_ACTIONS[$sTool] ?? '';
+        if ($sAction === '')
+            return 0;
+        if ($sTool === 'agent_create' && !in_array(strtolower((string)($aInputs['action'] ?? '')), ['create', 'update'], true))
+            return 0;
+        if ($sTool === 'mockup_upsert' && !empty($aInputs['dry_run']))
+            return 0;
+
+        $aResult = is_array($mixedResult) ? $mixedResult : [];
+        $sResultText = is_string($mixedResult) ? $mixedResult : '';
+        if ($sResultText !== '' && ($aDecoded = json_decode($sResultText, true)) && is_array($aDecoded))
+            $aResult = $aDecoded;
+
+        $bOk = true;
+        if (preg_match('/^Tool "[^"]*" failed:/', $sResultText))
+            $bOk = false;
+        elseif (isset($aResult['ok']) && !(int)$aResult['ok'])
+            $bOk = false;
+        elseif (isset($aResult['code']) && (int)$aResult['code'] >= 400)
+            $bOk = false;
+        elseif ($sTool === 'email_send' && $mixedResult === false)
+            $bOk = false;
+
+        $aRow = self::describeCall($sAction, $sTool, $aInputs, $aResult);
+
+        $sThreadId = '';
+        try {
+            $sThreadId = (string)BxDolAiChat::threadId($aAgent, $aParams);
+        } catch (Throwable $o) {
+        }
+
+        $aDetails = [
+            'inputs' => self::clipArray($aInputs),
+            'result' => $aResult ?: ($sResultText !== '' ? mb_substr($sResultText, 0, 2000) : null),
+        ];
+        $sDetails = json_encode($aDetails, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if ($sDetails === false)
+            $sDetails = '';
+        if (strlen($sDetails) > self::DETAILS_MAX)
+            $sDetails = substr($sDetails, 0, self::DETAILS_MAX);
+
+        $oDb = BxDolDb::getInstance();
+        $aSet = [
+            'agent_id' => (int)($aAgent['id'] ?? 0),
+            'profile_id' => (int)($aAgent['profile_id'] ?? 0),
+            'trigger' => (string)($aAgent['trigger'] ?? ''),
+            'thread_id' => $sThreadId,
+            'tool' => $sTool,
+            'action' => $sAction,
+            'unit' => (string)$aRow['unit'],
+            'object_id' => (int)$aRow['object_id'],
+            'object_url' => (string)$aRow['object_url'],
+            'object_title' => mb_substr((string)$aRow['object_title'], 0, 255),
+            'summary' => (string)$aRow['summary'],
+            'details' => $sDetails,
+            'ok' => $bOk ? 1 : 0,
+            'added' => time(),
+        ];
+        // the table may predate object_title
+        foreach (array_keys($aSet) as $sField) {
+            if (!$oDb->isFieldExists(self::TABLE, $sField))
+                unset($aSet[$sField]);
+        }
+        $oDb->query("INSERT INTO `" . self::TABLE . "` SET " . $oDb->arrayToSQL($aSet));
+        return (int)$oDb->lastId();
+    }
+
+    /**
+     * unit / object_id / object_url / summary for one call — what the row is about.
+     */
+    protected static function describeCall(string $sAction, string $sTool, array $aInputs, array $aResult): array
+    {
+        $sUnit = '';
+        $iObject = 0;
+        $sUrl = '';
+        $sTitle = '';
+        $sSummary = '';
+
+        $fText = function ($m, $iMax = self::SUMMARY_MAX) {
+            $s = trim(strip_tags(html_entity_decode((string)$m, ENT_QUOTES, 'UTF-8')));
+            $s = preg_replace('/\s+/u', ' ', $s);
+            return mb_strlen($s) > $iMax ? mb_substr($s, 0, $iMax - 1) . '…' : $s;
+        };
+
+        switch ($sAction) {
+            case 'comment_add':
+            case 'comment_update':
+            case 'comment_delete':
+                $sUnit = (string)($aInputs['module'] ?? '');
+                $iObject = (int)($aInputs['content_id'] ?? 0);
+                $iCmt = (int)($aResult['id'] ?? $aInputs['comment_id'] ?? 0);
+                $sSummary = $fText($aInputs['comment_text'] ?? '');
+                $aTarget = self::commentTarget($sUnit, $iObject, $iCmt);
+                $sTitle = $fText($aTarget['title'], 255);
+                $sUrl = $aTarget['url'];
+                break;
+
+            case 'content_add':
+            case 'content_update':
+            case 'content_delete':
+                $sUnit = (string)($aInputs['module'] ?? '');
+                $iObject = (int)($aInputs['content_id'] ?? $aResult['id'] ?? $aResult['content_id'] ?? 0);
+                $aData = is_array($aInputs['data'] ?? null) ? $aInputs['data'] : [];
+                // deleted content has no title to fetch any more — keep what the tool was given
+                $sTitle = $fText(self::contentTitle($sUnit, $iObject), 255);
+                if ($sTitle === '')
+                    $sTitle = $fText($aData['title'] ?? $aData['name'] ?? '', 255);
+                $sSummary = $fText($aData['text'] ?? $aData['description'] ?? '');
+                if ($sAction !== 'content_delete')
+                    $sUrl = self::contentUrl($sUnit, $iObject);
+                break;
+
+            case 'db_write':
+                $sQuery = trim((string)($aInputs['query'] ?? ''));
+                if (preg_match('/^\s*(insert\s+into|update)\s+`?([a-z0-9_]+)`?/i', $sQuery, $m))
+                    $sUnit = $m[2];
+                $iObject = (int)($aResult['id'] ?? $aResult['pk_value'] ?? $aResult['insert_id'] ?? 0);
+                $sSummary = $fText($sQuery);
+                // a row of a content module's data table is content: link it like content_update
+                $aTarget = self::contentByTable($sUnit, $iObject);
+                if ($aTarget) {
+                    $sTitle = $fText($aTarget['title'], 255);
+                    $sUrl = $aTarget['url'];
+                }
+                break;
+
+            case 'db_undo':
+                $sSummary = 'scope=' . (string)($aInputs['scope'] ?? 'last');
+                if (!empty($aResult['undone']) && is_array($aResult['undone']))
+                    $sSummary .= ', ' . count($aResult['undone']) . ' rows';
+                break;
+
+            case 'email':
+                $sUnit = (string)($aInputs['email'] ?? '');
+                $sSummary = $fText($aInputs['subject'] ?? '');
+                break;
+
+            case 'lang':
+                $sUnit = (string)($aInputs['key'] ?? '');
+                $sSummary = $fText($aInputs['string'] ?? '');
+                $sUrl = bx_absolute_url('studio/polyglot.php?page=keys');
+                break;
+
+            case 'agent':
+                $sUnit = strtolower((string)($aInputs['action'] ?? 'create'));
+                $iObject = (int)($aResult['id'] ?? $aInputs['agent_id'] ?? 0);
+                $sSummary = $fText($aInputs['title'] ?? ($aResult['name'] ?? ''));
+                $sUrl = !empty($aResult['studio']) ? bx_absolute_url((string)$aResult['studio']) : '';
+                break;
+
+            case 'mockup':
+                $sUnit = 'block';
+                $iObject = (int)($aInputs['block_id'] ?? 0);
+                $sSummary = $fText($aResult['summary'] ?? '');
+                $aBlock = self::pageBlock($iObject);
+                $sTitle = $fText($aBlock['title'] ?? '', 255);
+                $sUrl = (string)($aBlock['url'] ?? '');
+                break;
+        }
+
+        return ['unit' => $sUnit, 'object_id' => $iObject, 'object_url' => $sUrl, 'object_title' => $sTitle, 'summary' => $sSummary];
+    }
+
+    /**
+     * Rows for one agent, newest first, with the text a person reads.
+     */
+    public static function listForAgent(int $iAgentId, int $iStart = 0, int $iLimit = 50): array
+    {
+        if (!self::isTableReady() || $iAgentId <= 0)
+            return [];
+        $iLimit = max(1, min(200, $iLimit));
+        $iStart = max(0, $iStart);
+        $aRows = BxDolDb::getInstance()->getAll("SELECT * FROM `" . self::TABLE . "` WHERE `agent_id` = :a ORDER BY `id` DESC LIMIT {$iStart}, {$iLimit}", ['a' => $iAgentId]);
+        $aOut = [];
+        foreach ($aRows as $aRow)
+            $aOut[] = self::formatRow($aRow);
+        return $aOut;
+    }
+
+    /**
+     * agent_id → ['count' => n, 'last' => ts] for the agents grid.
+     */
+    public static function statsByAgent(): array
+    {
+        if (!self::isTableReady())
+            return [];
+        $aRows = BxDolDb::getInstance()->getAll("SELECT `agent_id`, COUNT(*) AS `count`, MAX(`added`) AS `last` FROM `" . self::TABLE . "` GROUP BY `agent_id`");
+        $aOut = [];
+        foreach ($aRows as $aRow)
+            $aOut[(int)$aRow['agent_id']] = ['count' => (int)$aRow['count'], 'last' => (int)$aRow['last']];
+        return $aOut;
+    }
+
+    public static function deleteForAgent(int $iAgentId): void
+    {
+        if (!self::isTableReady() || $iAgentId <= 0)
+            return;
+        BxDolDb::getInstance()->query("DELETE FROM `" . self::TABLE . "` WHERE `agent_id` = :a", ['a' => $iAgentId]);
+    }
+
+    /**
+     * @return array id, action, tool, ok, text (human sentence), summary, unit, object_id, url, added, added_formatted, thread_id
+     */
+    public static function formatRow(array $aRow): array
+    {
+        $iAdded = (int)($aRow['added'] ?? 0);
+        $aRow = self::resolveTarget($aRow);
+        return [
+            'id' => (int)($aRow['id'] ?? 0),
+            'action' => (string)($aRow['action'] ?? ''),
+            'tool' => (string)($aRow['tool'] ?? ''),
+            'ok' => (int)($aRow['ok'] ?? 1),
+            'text' => self::describe($aRow),
+            'summary' => (string)($aRow['summary'] ?? ''),
+            'unit' => (string)($aRow['unit'] ?? ''),
+            'object_id' => (int)($aRow['object_id'] ?? 0),
+            'url' => (string)($aRow['object_url'] ?? ''),
+            'title' => (string)($aRow['object_title'] ?? ''),
+            'thread_id' => (string)($aRow['thread_id'] ?? ''),
+            'added' => $iAdded,
+            'added_formatted' => $iAdded ? date('d.m.Y H:i', $iAdded) : '',
+        ];
+    }
+
+    /**
+     * "Posted a comment on Post #45", "Updated bx_events #3", ... Language keys
+     * `_sys_agents_activity_<action>` take {unit}, {object}; the English text below is
+     * the fallback while the keys are not compiled.
+     */
+    public static function describe(array $aRow): string
+    {
+        $sAction = (string)($aRow['action'] ?? '');
+        $sUnit = (string)($aRow['unit'] ?? '');
+        $iObject = (int)($aRow['object_id'] ?? 0);
+        $bOk = (int)($aRow['ok'] ?? 1) === 1;
+
+        $aFallback = [
+            'comment_add' => 'Posted a comment on {target}',
+            'comment_update' => 'Edited a comment on {target}',
+            'comment_delete' => 'Deleted a comment on {target}',
+            'content_add' => 'Created {target}',
+            'content_update' => 'Updated {target}',
+            'content_delete' => 'Deleted {target}',
+            'db_write' => 'Changed {target}',
+            'db_undo' => 'Rolled back changes',
+            'email' => 'Sent an email to {unit}',
+            'lang' => 'Changed language key {unit}',
+            'agent' => 'Agent {unit}: #{object}',
+            'mockup' => 'Updated page block #{object}',
+        ];
+        $sKey = '_sys_agents_activity_' . $sAction;
+        $sTpl = _t($sKey);
+        if ($sTpl === $sKey)
+            $sTpl = $aFallback[$sAction] ?? ucfirst(str_replace('_', ' ', $sAction));
+
+        $sUnitName = self::unitTitle($sUnit);
+        $sObjectTitle = trim((string)($aRow['object_title'] ?? ''));
+        // «Title» when it is known, otherwise "Unit #id"
+        $sTarget = $sObjectTitle !== ''
+            ? ($sUnitName !== '' ? $sUnitName . ': ' : '') . '«' . $sObjectTitle . '»'
+            : trim($sUnitName . ($iObject ? ' #' . $iObject : ''));
+        if ($sAction === 'db_write' && $sObjectTitle === '')
+            $sTarget = 'table ' . $sUnit;
+        // a language string compiled from the first version knows only {unit} #{object}: still show the title
+        if ($sObjectTitle !== '' && strpos($sTpl, '{target}') === false && strpos($sTpl, '{title}') === false)
+            $sTpl .= ': «{title}»';
+        $sText = str_replace(['{target}', '{unit}', '{object}', '{title}'], [$sTarget, $sUnitName, (string)$iObject, $sObjectTitle], $sTpl);
+        $sText = trim(str_replace(' #0', '', $sText));
+        if (!$bOk) {
+            $sFailed = _t('_sys_agents_activity_failed');
+            if ($sFailed === '_sys_agents_activity_failed')
+                $sFailed = 'failed';
+            $sText .= ' (' . $sFailed . ')';
+        }
+        return $sText;
+    }
+
+    protected static function unitTitle(string $sUnit): string
+    {
+        if ($sUnit === '')
+            return '';
+        if (preg_match('/^bx_[a-z0-9_]+$/', $sUnit)) {
+            $s = _t('_' . $sUnit);
+            if ($s !== '_' . $sUnit)
+                return $s;
+            $aModule = BxDolModuleQuery::getInstance()->getModuleByName($sUnit);
+            if ($aModule && !empty($aModule['title']))
+                return $aModule['title'];
+        }
+        return $sUnit;
+    }
+
+    /**
+     * Fill url / title for rows recorded before they were stored. Cheap when nothing is missing.
+     */
+    protected static function resolveTarget(array $aRow): array
+    {
+        $sAction = (string)($aRow['action'] ?? '');
+        $sUnit = (string)($aRow['unit'] ?? '');
+        $iObject = (int)($aRow['object_id'] ?? 0);
+        $bContent = in_array($sAction, ['comment_add', 'comment_update', 'comment_delete', 'content_add', 'content_update'], true);
+        if (!$bContent || $sUnit === '' || $iObject <= 0)
+            return $aRow;
+        $bComment = strpos($sAction, 'comment_') === 0;
+        if (empty($aRow['object_title'])) {
+            $sTitle = $bComment ? self::commentTarget($sUnit, $iObject, 0)['title'] : self::contentTitle($sUnit, $iObject);
+            $aRow['object_title'] = mb_substr(trim(strip_tags($sTitle)), 0, 255);
+        }
+        if (empty($aRow['object_url']))
+            $aRow['object_url'] = $bComment ? self::commentTarget($sUnit, $iObject, 0)['url'] : self::contentUrl($sUnit, $iObject);
+        return $aRow;
+    }
+
+    /**
+     * A row in a content module's entries table (bx_posts_data, bx_forum_discussions...)
+     * is that module's content → [title, url], else null.
+     */
+    protected static function contentByTable(string $sTable, int $iId): ?array
+    {
+        if ($sTable === '' || $iId <= 0 || !preg_match('/^(bx_[a-z0-9]+)_/', $sTable, $m))
+            return null;
+        $sModule = $m[1];
+        try {
+            $oModule = BxDolModule::getInstance($sModule);
+            if (!$oModule || empty($oModule->_oConfig->CNF['TABLE_ENTRIES']) || $oModule->_oConfig->CNF['TABLE_ENTRIES'] !== $sTable)
+                return null;
+        } catch (Throwable $o) {
+            return null;
+        }
+        $sUrl = self::contentUrl($sModule, $iId);
+        if ($sUrl === '')
+            return null;
+        return ['title' => self::contentTitle($sModule, $iId), 'url' => $sUrl];
+    }
+
+    /** Page block → [title, url of its page] for mockup rows. */
+    protected static function pageBlock(int $iBlockId): array
+    {
+        if ($iBlockId <= 0)
+            return [];
+        try {
+            $oDb = BxDolDb::getInstance();
+            $aBlock = $oDb->getRow("SELECT b.`title`, b.`object`, p.`uri` FROM `sys_pages_blocks` b LEFT JOIN `sys_pages` p ON p.`object` = b.`object` WHERE b.`id` = :id LIMIT 1", ['id' => $iBlockId]);
+            if (!$aBlock)
+                return [];
+            $sTitle = (string)$aBlock['title'];
+            if ($sTitle !== '' && $sTitle[0] === '_')
+                $sTitle = _t($sTitle);
+            $sUrl = !empty($aBlock['uri']) ? bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $aBlock['uri'])) : '';
+            return ['title' => $sTitle, 'url' => $sUrl];
+        } catch (Throwable $o) {
+            return [];
+        }
+    }
+
+    protected static function contentUrl(string $sModule, int $iId): string
+    {
+        if ($sModule === '' || $iId <= 0)
+            return '';
+        try {
+            if (!BxDolRequest::serviceExists($sModule, 'get_link'))
+                return '';
+            $s = BxDolService::call($sModule, 'get_link', [$iId]);
+            return is_string($s) ? $s : '';
+        } catch (Throwable $o) {
+            return '';
+        }
+    }
+
+    protected static function contentTitle(string $sModule, int $iId): string
+    {
+        if ($sModule === '' || $iId <= 0)
+            return '';
+        try {
+            if (!BxDolRequest::serviceExists($sModule, 'get_title'))
+                return '';
+            $s = BxDolService::call($sModule, 'get_title', [$iId]);
+            return is_string($s) ? $s : '';
+        } catch (Throwable $o) {
+            return '';
+        }
+    }
+
+    /**
+     * Title and link of the content a comment belongs to: the comments object knows
+     * both (trigger table, base_url) whatever the module, and the comment itself when
+     * the system supports comment permalinks. Falls back to the module's get_title / get_link.
+     */
+    protected static function commentTarget(string $sSystem, int $iContentId, int $iCmtId): array
+    {
+        $aOut = ['title' => '', 'url' => ''];
+        if ($sSystem === '' || $iContentId <= 0)
+            return $aOut;
+
+        $sModule = $sSystem;
+        try {
+            $o = BxDolCmts::getObjectInstance($sSystem, $iContentId);
+            if ($o) {
+                $sModule = (string)($o->getModule() ?: $sSystem);
+                $aOut['title'] = (string)$o->getObjectTitle($iContentId);
+                if ($iCmtId > 0)
+                    $aOut['url'] = (string)$o->getViewUrl($iCmtId);
+                if ($aOut['url'] === '') {
+                    $sBase = (string)$o->getBaseUrl();
+                    if ($sBase !== '' && strpos($sBase, '{') === false)
+                        $aOut['url'] = $sBase;
+                }
+            }
+        } catch (Throwable $e) {
+        }
+
+        if ($aOut['title'] === '')
+            $aOut['title'] = self::contentTitle($sModule, $iContentId);
+        if ($aOut['url'] === '')
+            $aOut['url'] = self::contentUrl($sModule, $iContentId);
+        return $aOut;
+    }
+
+    protected static function clipArray(array $a, int $iMax = 2000): array
+    {
+        $aOut = [];
+        foreach ($a as $k => $v) {
+            if (is_string($v) && mb_strlen($v) > $iMax)
+                $v = mb_substr($v, 0, $iMax) . '…';
+            elseif (is_array($v))
+                $v = self::clipArray($v, $iMax);
+            $aOut[$k] = $v;
+        }
+        return $aOut;
+    }
+}
+
+/** @} */

+ 40 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiActivityObserver.php

@@ -0,0 +1,40 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * NeuronAI observer that turns every write tool call of an agent into a
+ * BxDolAiActivity row. Attached in BxDolAiAgent alongside the debug LogObserver.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+use NeuronAI\Observability\ObserverInterface;
+
+class BxDolAiActivityObserver implements ObserverInterface
+{
+    public function __construct(protected array $aAgent, protected array $aParams = [])
+    {
+    }
+
+    public function onEvent(string $event, object $source, mixed $data = null, ?string $branchId = null): void
+    {
+        if ($event !== 'tool-called' || !is_object($data))
+            return;
+
+        $oTool = $data->tool ?? null;
+        if (!is_object($oTool) || !method_exists($oTool, 'getName'))
+            return;
+
+        try {
+            $aInputs = method_exists($oTool, 'getInputs') ? (array)$oTool->getInputs() : [];
+            $mixedResult = method_exists($oTool, 'getResult') ? $oTool->getResult() : null;
+            BxDolAiActivity::record($this->aAgent, $this->aParams, (string)$oTool->getName(), $aInputs, $mixedResult);
+        } catch (Throwable $o) {
+            bx_log('sys_agents', 'activity observer: ' . $o->getMessage(), BX_LOG_WARN);
+        }
+    }
+}
+
+/** @} */

+ 178 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiAgent.php

@@ -0,0 +1,178 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\RAG\RAG;
+use NeuronAI\Observability\InspectorObserver;
+use NeuronAI\Observability\LogObserver;
+
+class BxDolAiAgent extends RAG
+{
+    public function __construct(protected array $aAgent, protected array $aParams = [])
+    {
+        parent::__construct();
+
+        $sKey = getParam('sys_agents_inspector_key');
+        if ($sKey) {
+            $logger = InspectorObserver::instance(
+                key: $sKey,
+                autoFlush: true,
+            );
+            $this->observe($logger);
+        }
+        else {
+            $logger = new BxDolLoggerDb('sys_agents_' . $this->aAgent['id']);
+            $this->observe(new LogObserver($logger));
+        }
+
+        // what the agent did (comments, content, SQL) — shown as activity history
+        $this->observe(new BxDolAiActivityObserver($this->aAgent, $this->aParams));
+    }
+
+    protected function provider(): NeuronAI\Providers\AIProviderInterface
+    {
+        $aOverrides = [];
+        if ((int)($this->aAgent['max_tokens'] ?? 0) > 0)
+            $aOverrides['max_tokens'] = (int)$this->aAgent['max_tokens'];
+
+        return BxDolAIModelFactory::getModelInstance((int)$this->aAgent['model_id'], $aOverrides);
+    }
+
+    protected function instructions(): string
+    {
+        $aPromptSystem = !empty($this->aAgent['prompt_system']) ? [$this->aAgent['prompt_system']] : [];
+        $aPromptSteps = !empty($this->aAgent['prompt_steps']) ? [$this->aAgent['prompt_steps']] : [];
+        $aPromptTools = !empty($this->aAgent['prompt_tools']) ? [$this->aAgent['prompt_tools']] : [];
+        $aPromptOutput = !empty($this->aAgent['prompt_output']) ? [$this->aAgent['prompt_output']] : [];
+
+        $aPromptTools[] = "Always use agent profile id = {$this->aAgent['profile_id']} as author ('profile_id' or 'author_profile_id' tool parameter), unless explicitly specified in the user instructions, no other variants strictly.";
+
+        if ('alert' == $this->aAgent['trigger']) {
+            if (!$this->aAgent['async']) {
+                $aPromptTools[] = "Return array only, modified version of 'extra' array. Modifyable keys: " . $this->getAlertTriggerModifyableKeys() . ".";
+            }
+
+            $sDesc = trim(BxDolAiQuery::getAlertDesc($this->aAgent['alert']));
+            if ('.' != mb_substr($sDesc, -1))
+                $sDesc .= '.';
+            $aPromptSystem[] = $sDesc;
+        }
+
+        $oPrompt = new NeuronAI\Agent\SystemPrompt(
+            background: $aPromptSystem,
+            steps: $aPromptSteps,
+            output: $aPromptOutput,
+            toolsUsage: $aPromptTools
+        );
+        return (string) $oPrompt;
+    }
+
+    /**
+     * @return \NeuronAI\Tools\ToolInterface[]
+     */
+    protected function tools(): array
+    {
+        $aToolInstances = [];
+        foreach (BxDolAiAgentTools::resolveIds($this->aAgent) as $iToolId) {
+            if (!BxDolAiQuery::getToolObject($iToolId))
+                continue;
+            $aToolInstances[] = BxDolAIToolFactory::getToolInstance($iToolId);
+        }
+
+        return $aToolInstances;
+    }
+    
+    protected function vectorStore(): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        if ($this->aAgent['vector_store_id'])
+            return BxDolAIVectorStoreFactory::getVectorStoreInstance($this->aAgent['vector_store_id']);
+        else
+            return new NeuronAI\RAG\VectorStore\MemoryVectorStore();
+    }
+
+    /**
+     * Retrieval is built for every chat. A una-proxy store is searched on the
+     * master. Any other store needs an embedding model on this site.
+     */
+    protected function retrieval(): NeuronAI\RAG\Retrieval\RetrievalInterface
+    {
+        if (($this->aAgent['vector_store_type'] ?? '') === 'una-proxy') {
+            return new BxDolAiRetrievalUnaProxy(
+                (string)getParam('sys_oauth_key'),
+                (string)getParam('sys_oauth_secret')
+            );
+        }
+
+        if (!isset($this->embeddingsProvider)) {
+            if ((int)($this->aAgent['vector_store_id'] ?? 0) > 0)
+                throw new Exception("Vector store {$this->aAgent['vector_store_id']} has no embedding model");
+
+            return new BxDolAiRetrievalNone();
+        }
+
+        return new NeuronAI\RAG\Retrieval\SimilarityRetrieval(
+            $this->resolveVectorStore(),
+            $this->resolveEmbeddingsProvider()
+        );
+    }
+
+    protected function embeddings(): NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
+    {
+        throw new Exception('Embedding model is not configured');
+    }
+
+    protected function chatHistory(): NeuronAI\Chat\History\ChatHistoryInterface
+    {
+        if ($this->aAgent['chat_history_context']) {
+            return new BxDolAiChatHistory(
+                thread_id: $this->getChatHistoryThreadId(),
+                pdo: BxDolDb::getInstance()->getLink(),
+                table: 'sys_agents_chat_history',
+                contextWindow: $this->aAgent['chat_history_context']
+            );
+        }
+        else {
+            return new NeuronAI\Chat\History\InMemoryChatHistory(
+                contextWindow: 50000
+            );
+        }
+    }
+
+    /**
+     * Turn tool exceptions into a tool result so chat history stays a valid sequence.
+     *
+     * @return callable|null fn(\Throwable $e, \NeuronAI\Tools\ToolInterface $tool): string
+     */
+    protected function resolveToolErrorHandler(): ?callable
+    {
+        return function (\Throwable $e, NeuronAI\Tools\ToolInterface $tool): string {
+            return 'Tool "' . $tool->getName() . '" failed: ' . $e->getMessage();
+        };
+    }
+
+    protected function getChatHistoryThreadId(): string
+    {
+        return BxDolAiChat::threadId($this->aAgent, $this->aParams);
+    }
+
+    protected function getAlertTriggerModifyableKeys(): string
+    {
+        if ('alert' != $this->aAgent['trigger'])
+            return 'none';
+
+        $aAlert = BxDolAiQuery::getAlert($this->aAgent['alert']);
+        if (!$aAlert)
+            return 'none';
+
+        $aKeys = json_decode($aAlert['extra_refs']);
+        if (!$aKeys)
+            return 'none';
+
+        return implode(',', $aKeys);
+    }
+}

+ 138 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiAgentTools.php

@@ -0,0 +1,138 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Tools attached in code. They are not stored on the agent and are not offered
+ * in the Studio tool picker, the tools grid, or agent_create.
+ * mysql_schema and mysql_select are mandatory on una_operator and stay visible.
+ * mysql_write_safe and mysql_undo do not.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiAgentTools
+{
+    /** Every agent. */
+    public const ALWAYS = ['chat_buttons', 'chat_title'];
+
+    /** una_operator, in addition to ALWAYS. Hidden from the picker. mysql_write is not included. */
+    public const OPERATOR = [
+        'agent_create',
+        'mockup_upsert',
+        'mysql_write_safe',
+        'mysql_undo',
+    ];
+
+    /** una_operator, in addition to ALWAYS and OPERATOR. Visible, so other agents can keep them. */
+    public const OPERATOR_READ = ['mysql_schema', 'mysql_select'];
+
+    public static function typesFor(array $aAgent): array
+    {
+        $a = self::ALWAYS;
+        if (($aAgent['name'] ?? '') === 'una_operator')
+            $a = array_merge($a, self::OPERATOR, self::OPERATOR_READ);
+
+        return $a;
+    }
+
+    public static function hiddenTypes(): array
+    {
+        return array_values(array_unique(array_merge(self::ALWAYS, self::OPERATOR)));
+    }
+
+    /**
+     * Studio tools grid source, with locked types removed.
+     */
+    public static function excludeHiddenFromSource(string $sSource): string
+    {
+        if (stripos($sSource, ' WHERE ') === false)
+            $sSource .= ' WHERE 1 ';
+
+        return $sSource . ' AND `type` NOT IN (' . BxDolDb::getInstance()->implode_escape(self::hiddenTypes()) . ')';
+    }
+
+    /**
+     * One active row per type that this agent must have, lowest id.
+     *
+     * @return int[]
+     */
+    public static function idsFor(array $aAgent): array
+    {
+        $aTypes = self::typesFor($aAgent);
+        if (!$aTypes)
+            return [];
+
+        $aIds = [];
+        $aSeen = [];
+        foreach ((new BxDolAiQuery())->getActiveToolsByTypes($aTypes) as $aRow) {
+            $sType = (string)$aRow['type'];
+            if (isset($aSeen[$sType]))
+                continue;
+            $aSeen[$sType] = true;
+            $aIds[] = (int)$aRow['id'];
+        }
+
+        return $aIds;
+    }
+
+    /**
+     * Every row of a locked type, including inactive copies, so a stored id cannot grant one.
+     *
+     * @return int[]
+     */
+    public static function hiddenIds(): array
+    {
+        $aTypes = self::hiddenTypes();
+        if (!$aTypes)
+            return [];
+
+        return (new BxDolAiQuery())->getToolIdsByTypes($aTypes);
+    }
+
+    /**
+     * Stored ids minus locked ones, plus the ids this agent always has.
+     *
+     * @return int[]
+     */
+    public static function resolveIds(array $aAgent): array
+    {
+        $aIds = [];
+        if (!empty($aAgent['tools'])) {
+            foreach (explode(',', (string)$aAgent['tools']) as $sToolId) {
+                $iToolId = (int)trim($sToolId);
+                if ($iToolId > 0)
+                    $aIds[$iToolId] = $iToolId;
+            }
+        }
+
+        foreach (self::hiddenIds() as $iHidden)
+            unset($aIds[$iHidden]);
+
+        foreach (self::idsFor($aAgent) as $iId)
+            $aIds[$iId] = $iId;
+
+        return array_values($aIds);
+    }
+
+    public static function stripCsv(string $sTools): string
+    {
+        $sTools = trim($sTools);
+        if ($sTools === '')
+            return '';
+
+        $aHidden = array_flip(self::hiddenIds());
+        $aKeep = [];
+        foreach (explode(',', $sTools) as $sId) {
+            $iId = (int)trim($sId);
+            if ($iId <= 0 || isset($aHidden[$iId]))
+                continue;
+            $aKeep[$iId] = $iId;
+        }
+
+        return implode(',', $aKeep);
+    }
+}
+
+/** @} */

+ 752 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiChat.php

@@ -0,0 +1,752 @@
+<?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
+ * @{
+ */
+
+class BxDolAiChat
+{
+    protected $_oDb;
+    protected $_oUi;
+    protected $_aChatContext;
+
+    /**
+     * Buttons queued by the `chat_buttons` tool during the current turn. The chat
+     * trigger emits them after the assistant text and pins them to the stored message.
+     */
+    protected $_aPendingChatActions = [];
+
+    /**
+     * The assistant already wrote visible text this turn: `chat_buttons` refuses
+     * to hang buttons on an empty reply.
+     */
+    protected $_bTurnHasText = false;
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new self();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function __construct()
+    {
+        $this->_oDb = new BxDolAiQuery();
+        $this->_oUi = BxDolAiChatUi::getInstance();
+        $this->_aChatContext = null;
+        $this->_aPendingChatActions = [];
+    }
+
+    public function setChatContext($aAgent, $aParams)
+    {
+        $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
+    }
+
+    /**
+     * Optional `?chat=` partition. Not identity — owner stays profile id / session id.
+     * `{owner}#{chat}` so parseThreadId does not treat the nonce as a context pid.
+     */
+    public static function sanitizeChatQueryId($s)
+    {
+        $s = is_string($s) ? $s : '';
+        $s = preg_replace('/[^A-Za-z0-9_-]/', '', $s);
+        $s = substr($s, 0, 64);
+        return strlen($s) >= 8 ? $s : '';
+    }
+
+    /**
+     * Owner (profile id or guest session id) of a `{owner}` / `{owner}#{chat}` subindex.
+     */
+    public static function chatHistoryOwnerFromSubindex($sSub)
+    {
+        $sSub = (string)$sSub;
+        if ($sSub === '')
+            return '';
+        $i = strpos($sSub, '#');
+        return $i === false ? $sSub : substr($sSub, 0, $i);
+    }
+
+    /**
+     * Guests: UNA session id. Members: profile id.
+     * Guest chats remember agent ids in session so login can adopt those threads.
+     * `?chat=` appends a conversation id without changing the owner.
+     */
+    public function resolveChatHistoryParams($iAgentId = 0)
+    {
+        $iAgentId = (int)$iAgentId;
+        $iProfileId = (int)bx_get_logged_profile_id();
+        $oSession = BxDolSession::getInstance();
+        $sSessionKey = 'ai_chat_agent_ids';
+        $sChat = self::sanitizeChatQueryId(bx_get('chat'));
+
+        if ($iProfileId) {
+            $aIds = $oSession->getValue($sSessionKey);
+            if (is_array($aIds) && $aIds) {
+                $sSessionId = (string)$oSession->getId();
+                if ($iAgentId)
+                    $aIds[] = $iAgentId;
+                foreach (array_unique(array_map('intval', $aIds)) as $iStoredAgentId) {
+                    if (!$iStoredAgentId || $sSessionId === '')
+                        continue;
+                    $aAgent = BxDolAiQuery::getAgentObject($iStoredAgentId);
+                    if ($aAgent)
+                        $this->_oDb->adoptGuestChatHistory($aAgent, $sSessionId, $iProfileId);
+                }
+                $oSession->unsetValue($sSessionKey);
+            }
+
+            $sOwner = (string)$iProfileId;
+            return [
+                'sender_profile_id' => $iProfileId,
+                'chat_history_subindex' => $sChat !== '' ? ($sOwner . '#' . $sChat) : $sOwner,
+            ];
+        }
+
+        if ($iAgentId) {
+            $aIds = $oSession->getValue($sSessionKey);
+            $aIds = array_map('intval', is_array($aIds) ? $aIds : []);
+            if (!in_array($iAgentId, $aIds, true)) {
+                $aIds[] = $iAgentId;
+                $oSession->setValue($sSessionKey, $aIds);
+            }
+        }
+
+        $sOwner = (string)$oSession->getId();
+        return [
+            'sender_profile_id' => 0,
+            'chat_history_subindex' => $sChat !== '' ? ($sOwner . '#' . $sChat) : $sOwner,
+        ];
+    }
+
+    /**
+     * `{trigger}:{agentId}:{contextPid}:{userSubindex}`.
+     * Context is omitted when 0 so existing site-wide threads still load.
+     * User suffix (session id or profile id, optionally `#{chat}`) stays last so adopt can replace it.
+     */
+    public static function threadId($aAgent, $aParams = [])
+    {
+        $s = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $iContextPid = (int)($aParams['chat_history_context_pid'] ?? 0);
+        if ($iContextPid > 0)
+            $s .= ':' . $iContextPid;
+        if (isset($aParams['chat_history_subindex']) && $aParams['chat_history_subindex'] !== '')
+            $s .= ':' . (string)$aParams['chat_history_subindex'];
+        return $s;
+    }
+
+    /**
+     * @return array{thread_id:string,chat_history_context_pid:int,chat_history_subindex:string}|false
+     */
+    public static function parseThreadId($aAgent, $sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        $sBase = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        if ($sBase === ':' || $sThreadId === '')
+            return false;
+        if ($sThreadId !== $sBase && strpos($sThreadId, $sBase . ':') !== 0)
+            return false;
+
+        $sRest = $sThreadId === $sBase ? '' : substr($sThreadId, strlen($sBase) + 1);
+        $aParts = $sRest === '' ? [] : explode(':', $sRest);
+        $iContext = 0;
+        $sSub = '';
+        if (count($aParts) >= 2) {
+            $iContext = (int)$aParts[0];
+            $sSub = implode(':', array_slice($aParts, 1));
+        } elseif (count($aParts) === 1) {
+            $sSub = $aParts[0];
+        }
+
+        return [
+            'thread_id' => $sThreadId,
+            'chat_history_context_pid' => $iContext,
+            'chat_history_subindex' => $sSub,
+        ];
+    }
+
+    public function getChatHistoryRowId($aAgent, $aParams = [])
+    {
+        return $this->_oDb->getChatHistoryIdByThreadId(self::threadId($aAgent, $aParams));
+    }
+
+    public function getCurrentChatHistoryId()
+    {
+        if (!is_array($this->_aChatContext))
+            return 0;
+
+        $aAgent = $this->_aChatContext['agent'] ?? null;
+        $aParams = $this->_aChatContext['params'] ?? [];
+        if (!is_array($aAgent))
+            return 0;
+
+        return $this->getChatHistoryRowId($aAgent, is_array($aParams) ? $aParams : []);
+    }
+
+    /**
+     * No `?chat=` in the request means "the conversation I am in", not "the base
+     * thread": the newest open thread for this owner+context (a `?chat=` partition
+     * or the base thread). Otherwise a block without `allow_new` would keep talking
+     * to a base thread the agent closed days ago, while Studio shows a newer chat.
+     *
+     * Explicit `?chat=` (subindex with a #) is left alone. Falls back to the base
+     * thread when nothing is open.
+     */
+    public function resolveCurrentChatThread($aAgent, $aParams)
+    {
+        $sSub = (string)($aParams['chat_history_subindex'] ?? '');
+        if ($sSub === '' || strpos($sSub, '#') !== false)
+            return $aParams;
+
+        $sCurrent = $this->_oDb->getCurrentOpenChatThreadId($aAgent, $sSub, (int)($aParams['chat_history_context_pid'] ?? 0));
+        if ($sCurrent === '')
+            return $aParams;
+
+        $aParsed = self::parseThreadId($aAgent, $sCurrent);
+        if (!$aParsed || (string)$aParsed['chat_history_subindex'] === '')
+            return $aParams;
+
+        $aParams['chat_history_subindex'] = (string)$aParsed['chat_history_subindex'];
+        return $aParams;
+    }
+
+    /**
+     * Create the `?chat=` history row immediately, so the thread exists (and lists)
+     * before its first message. The owner's other threads are left as they are: a
+     * "New chat" is one more conversation, not the end of the previous one — people
+     * switch back to an earlier open thread from the history panel and go on.
+     * Threads close only when the agent or the turn limit closes them.
+     *
+     * @return int history row id, 0 when this is not a `?chat=` thread
+     */
+    public function openPartitionedChatThread($aAgent, $aParams)
+    {
+        $sSub = (string)($aParams['chat_history_subindex'] ?? '');
+        if (strpos($sSub, '#') === false)
+            return 0;
+
+        $sThreadId = self::threadId($aAgent, $aParams);
+        if ($sThreadId === '')
+            return 0;
+
+        return (int)$this->_oDb->ensureChatHistoryRow($sThreadId);
+    }
+
+    /**
+     * `chat_buttons` tool: queue buttons for the reply being generated.
+     *
+     * @return int number of accepted buttons
+     */
+    public function setPendingChatActions($mixed)
+    {
+        $a = $this->_oUi->sanitizeChatActions($this->normalizeChatButtonsInput($mixed));
+        if (!$a)
+            return 0;
+
+        $this->_aPendingChatActions = $this->_oUi->mergeChatActions($this->_aPendingChatActions, $a);
+        return count($a);
+    }
+
+    public function getPendingChatActions()
+    {
+        return $this->_oUi->sanitizeChatActions($this->_aPendingChatActions);
+    }
+
+    public function takePendingChatActions()
+    {
+        $a = $this->getPendingChatActions();
+        $this->_aPendingChatActions = [];
+        return $a;
+    }
+
+    public function resetPendingChatActions()
+    {
+        $this->_aPendingChatActions = [];
+        $this->_bTurnHasText = false;
+    }
+
+    public function markTurnText($sDelta)
+    {
+        if (trim((string)$sDelta) !== '')
+            $this->_bTurnHasText = true;
+    }
+
+    public function hasTurnText()
+    {
+        return $this->_bTurnHasText;
+    }
+
+    protected function normalizeChatButtonsInput($mixed)
+    {
+        if (!is_array($mixed))
+            return [];
+
+        if (isset($mixed['buttons']) && is_array($mixed['buttons']))
+            $mixed = $mixed['buttons'];
+
+        if (isset($mixed['type']) && (isset($mixed['label']) || isset($mixed['url'])))
+            return [$mixed];
+
+        return $mixed;
+    }
+
+    public function emitConversationClosed($sReason, $sSummary = '', $aAgent = null, $aParams = null)
+    {
+        if (!is_array($aAgent) && is_array($this->_aChatContext))
+            $aAgent = $this->_aChatContext['agent'] ?? null;
+        if (!is_array($aParams) && is_array($this->_aChatContext))
+            $aParams = $this->_aChatContext['params'] ?? [];
+        if (!is_array($aAgent) || empty($aAgent['id']))
+            return false;
+
+        $iHistoryId = $this->getChatHistoryRowId($aAgent, is_array($aParams) ? $aParams : []);
+        if ($iHistoryId <= 0)
+            return false;
+
+        $sClosed = $this->_oDb->getChatHistoryClosedReasonById($iHistoryId);
+        if ($sClosed !== '')
+            return false;
+
+        bx_alert('system', 'agent_conversation_closed', $iHistoryId, 0, [
+            'agent_id' => (int)$aAgent['id'],
+            'summary' => (string)$sSummary,
+            'reason' => (string)$sReason,
+        ]);
+        return true;
+    }
+
+    /**
+     * Thread belongs to the current profile / guest session (any `?chat=` partition of theirs).
+     */
+    public function isOwnAgentChatThread($aAgent, $sThreadId)
+    {
+        $aParsed = self::parseThreadId($aAgent, $sThreadId);
+        if (!$aParsed)
+            return false;
+
+        $aMine = $this->resolveChatHistoryParams((int)$aAgent['id']);
+        $sMineOwner = self::chatHistoryOwnerFromSubindex($aMine['chat_history_subindex'] ?? '');
+        $sRowOwner = self::chatHistoryOwnerFromSubindex($aParsed['chat_history_subindex']);
+        return $sMineOwner !== '' && $sMineOwner === $sRowOwner;
+    }
+
+    public function getChatHistoryUiMessagesByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseThreadId($aAgent, $sThreadId))
+            return false;
+
+        $sJson = $this->_oDb->getChatHistoryMessagesByThreadId((string)$sThreadId);
+
+        return $this->_oUi->storedChatJsonToUiMessages($sJson);
+    }
+
+    public function getChatHistoryArtifactsByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseThreadId($aAgent, $sThreadId))
+            return [];
+
+        return $this->_oDb->getChatArtifactsByThreadId((string)$sThreadId);
+    }
+
+    public function getChatHistoryClosedReasonByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseThreadId($aAgent, $sThreadId))
+            return '';
+
+        return $this->_oDb->getChatHistoryClosedReasonById($this->_oDb->getChatHistoryIdByThreadId((string)$sThreadId));
+    }
+
+    /**
+     * Every thread of this agent (Studio chat popup): all owners, all contexts.
+     */
+    public function listAgentChatThreads($aAgent)
+    {
+        $aMineParams = $this->resolveChatHistoryParams((int)$aAgent['id']);
+        $sMySub = (string)($aMineParams['chat_history_subindex'] ?? '');
+        $sMyOwner = self::chatHistoryOwnerFromSubindex($sMySub);
+        $sMyDefault = self::threadId($aAgent, $aMineParams);
+
+        $aOut = [];
+        $bHaveMineDefault = false;
+        $aRows = $this->_oDb->getAgentChatHistoryRows($aAgent);
+        if (!is_array($aRows))
+            $aRows = [];
+
+        $aHistoryIds = [];
+        foreach ($aRows as $aRow)
+            $aHistoryIds[] = (int)($aRow['id'] ?? 0);
+        $aArtifactsMap = $this->_oDb->getChatArtifactsForAgent($aAgent);
+        if (!$aArtifactsMap && $aHistoryIds)
+            $aArtifactsMap = $this->_oDb->getChatArtifactsByHistoryIds($aHistoryIds);
+
+        foreach ($aRows as $aRow) {
+            $sThreadId = (string)($aRow['thread_id'] ?? '');
+            $aParsed = self::parseThreadId($aAgent, $sThreadId);
+            if (!$aParsed)
+                continue;
+
+            $aUi = $this->_oUi->storedChatJsonToUiMessages($aRow['messages'] ?? '');
+            // A `?chat=` thread is listed even before its first message (it was opened on purpose).
+            $bPartition = strpos((string)$aParsed['chat_history_subindex'], '#') !== false;
+            if (!$aUi && $sThreadId !== $sMyDefault && !$bPartition)
+                continue;
+
+            $bMine = ($sMyOwner !== '' && $sMyOwner === self::chatHistoryOwnerFromSubindex($aParsed['chat_history_subindex']));
+            if ($sThreadId === $sMyDefault)
+                $bHaveMineDefault = true;
+
+            $iHistoryId = (int)($aRow['id'] ?? 0);
+            $aArtifacts = ($iHistoryId && !empty($aArtifactsMap[$iHistoryId])) ? $aArtifactsMap[$iHistoryId] : [];
+            $aOut[] = $this->formatAgentChatThread($aParsed, $aRow, $aUi, $bMine, $aArtifacts);
+        }
+
+        if (!$bHaveMineDefault && $sMyDefault !== '') {
+            $aParsedMine = self::parseThreadId($aAgent, $sMyDefault);
+            if ($aParsedMine)
+                $aOut[] = $this->formatAgentChatThread($aParsedMine, [], [], true);
+        }
+
+        usort($aOut, function ($a, $b) {
+            return strcmp((string)$b['updated_sort'], (string)$a['updated_sort']);
+        });
+
+        return $aOut;
+    }
+
+    /**
+     * The caller's own conversations with this agent, for the chat widget's history
+     * panel (`system/get_ai_chat_threads`). Unlike listAgentChatThreads (Studio, every
+     * owner) this only reads the current profile's / guest session's rows: any
+     * context, base thread and `?chat=` partitions, newest first. Closed threads
+     * with nothing in them are skipped; the open one is listed even when empty so
+     * the panel can mark it as current.
+     *
+     * @return array<int, array<string, mixed>> see formatOwnAgentChatThread
+     */
+    public function listOwnAgentChatThreads($aAgent)
+    {
+        $aMine = $this->resolveChatHistoryParams((int)$aAgent['id']);
+        $sOwner = self::chatHistoryOwnerFromSubindex($aMine['chat_history_subindex'] ?? '');
+        if ($sOwner === '')
+            return [];
+
+        $aRows = $this->_oDb->getOwnerAgentChatHistoryRows($aAgent, $sOwner);
+        if (!is_array($aRows))
+            return [];
+
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            $aParsed = self::parseThreadId($aAgent, (string)($aRow['thread_id'] ?? ''));
+            // LIKE matched the owner loosely; the parsed id is the authority.
+            if (!$aParsed || self::chatHistoryOwnerFromSubindex($aParsed['chat_history_subindex']) !== $sOwner)
+                continue;
+
+            $aUi = $this->_oUi->storedChatJsonToUiMessages($aRow['messages'] ?? '');
+            $sReason = trim((string)($aRow['closed_reason'] ?? ''));
+            if (!$aUi && $sReason !== '')
+                continue;
+
+            $aOut[] = $this->formatOwnAgentChatThread($aParsed, $aRow, $aUi);
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * `chat_title` tool: store the title the agent chose for the conversation it is
+     * in right now (`_aChatContext`, set by the chat trigger). Set once —
+     * a thread that already has a title keeps it.
+     *
+     * @return int 1 saved, 0 already titled, -1 no current thread or no `title` column yet
+     */
+    public function setCurrentChatThreadTitle($sTitle)
+    {
+        $sTitle = trim((string)$sTitle);
+        if ($sTitle === '' || !is_array($this->_aChatContext))
+            return -1;
+        if (!$this->_oDb->isFieldExists('sys_agents_chat_history', 'title'))
+            return -1;
+
+        $aAgent = $this->_aChatContext['agent'] ?? null;
+        $aParams = $this->_aChatContext['params'] ?? [];
+        if (!is_array($aAgent))
+            return -1;
+
+        $sThreadId = self::threadId($aAgent, is_array($aParams) ? $aParams : []);
+        if ($sThreadId === '' || $sThreadId === ':')
+            return -1;
+
+        // The row may not be there yet on the very first turn: the history is
+        // persisted after the tools ran. Create it, so the title has somewhere to go.
+        $this->_oDb->ensureChatHistoryRow($sThreadId);
+        if ($this->_oDb->getChatHistoryTitle($sThreadId) !== '')
+            return 0;
+
+        return $this->_oDb->setChatHistoryTitle($sThreadId, $sTitle) ? 1 : -1;
+    }
+
+    /**
+     * Context from chat HTTP (`?context=`). Missing/0 = site-wide. Invalid/denied = false (403).
+     * Posting in a group is not the same as viewing it; chat requires post by default.
+     * @return int|false
+     */
+    public function resolveChatHistoryContextPid($bRequirePost = true)
+    {
+        $mixed = bx_get('context');
+        if ($mixed === false)
+            return 0;
+
+        $iPid = (int)$mixed;
+        if ($iPid <= 0)
+            return 0;
+
+        $oProfile = BxDolProfile::getInstance($iPid);
+        if (!$oProfile || !bx_srv('system', 'is_module_context', [$oProfile->getModule()]))
+            return false;
+
+        if (!$this->isAllowedContextChat($oProfile, $bRequirePost))
+            return false;
+
+        return $iPid;
+    }
+
+    /**
+     * Context of the current page (AI agent block).
+     * `bx_get_page_info()` reads the URL; App page JSON merges `i`/`id` into $_GET instead.
+     * @param bool $bRequirePost also require posting into the context (membership / post ACL)
+     */
+    public function resolveChatHistoryContextPidFromPage($bRequirePost = false)
+    {
+        $aInfo = bx_get_page_info();
+        if ($aInfo && !empty($aInfo['context_profile_id']))
+            return $this->filterContextPid((int)$aInfo['context_profile_id'], $bRequirePost);
+
+        $sUri = bx_get('i');
+        $iContentId = (int)bx_process_input(bx_get('id'), BX_DATA_INT);
+        if ($sUri && $iContentId > 0) {
+            $oPage = BxDolPage::getObjectInstanceByURI($sUri);
+            $sModule = $oPage ? (string)$oPage->getModule() : '';
+            if ($sModule !== '' && bx_srv('system', 'is_module_context', [$sModule])) {
+                $oProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $sModule);
+                if ($oProfile && ($iPid = $this->filterContextPid((int)$oProfile->id(), $bRequirePost)))
+                    return $iPid;
+            }
+        }
+
+        $iPid = (int)bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if ($iPid > 0 && ($iPid = $this->filterContextPid($iPid, $bRequirePost)))
+            return $iPid;
+
+        return 0;
+    }
+
+    public function filterContextPid($iPid, $bRequirePost = false)
+    {
+        $iPid = (int)$iPid;
+        if ($iPid <= 0)
+            return 0;
+
+        $oProfile = BxDolProfile::getInstance($iPid);
+        if (!$oProfile || !bx_srv('system', 'is_module_context', [$oProfile->getModule()]))
+            return 0;
+
+        if (!$this->isAllowedContextChat($oProfile, $bRequirePost))
+            return 0;
+
+        return $iPid;
+    }
+
+    /**
+     * Transcript for TanStack `useChat` hydrate / `initialMessages`.
+     * Loads the same NeuronAI chat history the agent uses when streaming.
+     *
+     * @param bool $bAppendLimitMessage when the session is at max_turns, append limit_message for the UI
+     */
+    public function getChatHistoryUiMessages($iAgentId, $aParams = [], $bAppendLimitMessage = true)
+    {
+        if (!isset($aParams['chat_history_subindex']))
+            $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
+
+        $aAgent = BxDolAiQuery::getAgentObject((int)$iAgentId);
+        if (!$aAgent || empty($aAgent['chat_history_context']))
+            return [];
+
+        $sThreadId = self::threadId($aAgent, $aParams);
+        $sJson = $this->_oDb->getChatHistoryMessagesByThreadId($sThreadId);
+        $aStored = json_decode((string)$sJson, true);
+        if (!is_array($aStored) || $aStored === [])
+            return [];
+
+        try {
+            $o = BxDolAi::getAgentInstance((int)$iAgentId, $aParams);
+        } catch (Exception $oException) {
+            bx_log('sys_agents', "Hydrate exception for agent {$iAgentId}: " . $oException->getMessage(), BX_LOG_ERR);
+            return [];
+        }
+
+        $aResult = $this->_oUi->neuronMessagesToUiMessages($o->getChatHistory()->getMessages());
+        $oLimits = BxDolAiChatLimits::getInstance();
+        if ($bAppendLimitMessage && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->countChatUserTurns($aResult))) {
+            $aResult[] = [
+                'id' => 'agent_limit_message',
+                'role' => 'assistant',
+                'parts' => [['type' => 'text', 'content' => $oLimits->getChatLimitMessage($aAgent)]],
+            ];
+        }
+
+        return $aResult;
+    }
+
+    protected function formatAgentChatThread($aParsed, $aRow, $aUi, $bMine, $aArtifacts = [])
+    {
+        $iContext = (int)$aParsed['chat_history_context_pid'];
+        $sSub = (string)$aParsed['chat_history_subindex'];
+        $sContextName = '';
+        if ($iContext > 0) {
+            $oContext = BxDolProfile::getInstance($iContext);
+            $sContextName = $oContext ? $oContext->getDisplayName() : ('#' . $iContext);
+        }
+
+        $sTitle = _t('_sys_agents_agents_txt_guest_chat');
+        $sOwner = self::chatHistoryOwnerFromSubindex($sSub);
+        $oProfile = (ctype_digit($sOwner) && (int)$sOwner > 0) ? BxDolProfile::getInstance((int)$sOwner) : false;
+        if ($oProfile)
+            $sTitle = $oProfile->getDisplayName();
+        if ($sContextName !== '')
+            $sTitle .= ' · ' . $sContextName;
+        $sThreadTitle = trim((string)($aRow['title'] ?? ''));
+        if ($sThreadTitle !== '')
+            $sTitle .= ' · ' . $sThreadTitle;
+
+        $sReason = trim((string)($aRow['closed_reason'] ?? ''));
+        $sStatus = $sReason !== '' ? 'closed' : 'opened';
+        $sStatusLabel = $sStatus;
+        if ($sStatus === 'closed' && $sReason !== '')
+            $sStatusLabel .= ' (' . $sReason . ')';
+
+        $aArtifacts = is_array($aArtifacts) ? $aArtifacts : [];
+        $sCreated = $this->formatAgentChatThreadTime($aRow['created_at'] ?? '');
+        $sUpdated = $this->formatAgentChatThreadTime($aRow['updated_at'] ?? '');
+        $sUpdatedRaw = (string)($aRow['updated_at'] ?? '');
+
+        $sMeta = 'status: ' . $sStatusLabel;
+        $sDates = '';
+        if ($sCreated !== '' && $sUpdated !== '')
+            $sDates = $sCreated . ' - ' . $sUpdated;
+        else if ($sCreated !== '')
+            $sDates = $sCreated;
+        else if ($sUpdated !== '')
+            $sDates = $sUpdated;
+        if ($sDates !== '')
+            $sMeta .= "\n" . $sDates;
+
+        return [
+            'thread_id' => $aParsed['thread_id'],
+            'writable' => $bMine ? 1 : 0,
+            'context_pid' => $iContext,
+            'title' => $sTitle,
+            'meta' => $sMeta,
+            'status' => $sStatus,
+            'closed_reason' => $sReason,
+            'created_at' => $sCreated,
+            'updated_at' => $sUpdated,
+            'artifacts' => is_array($aArtifacts) ? $aArtifacts : [],
+            'messages' => is_array($aUi) ? $aUi : [],
+            'updated_sort' => $sUpdatedRaw,
+            'class_mine' => $bMine ? ' bx-agents-popup-chat-thread-mine' : '',
+        ];
+    }
+
+    /**
+     * One history-panel item. `chat` is the `?chat=` nonce ('' for the base thread),
+     * so the client can tell which row its live widget is talking to. `title` is the
+     * one the agent gave (`chat_title` tool) or the first thing the person typed (the
+     * hidden "[page opened…" bootstrap is skipped), `preview` the last line said; both plain text.
+     */
+    protected function formatOwnAgentChatThread($aParsed, $aRow, $aUi)
+    {
+        $iContext = (int)$aParsed['chat_history_context_pid'];
+        $sContextName = '';
+        if ($iContext > 0) {
+            $oContext = BxDolProfile::getInstance($iContext);
+            $sContextName = $oContext ? $oContext->getDisplayName() : ('#' . $iContext);
+        }
+
+        $sSub = (string)$aParsed['chat_history_subindex'];
+        $iDot = strpos($sSub, '#');
+        $sChat = $iDot === false ? '' : substr($sSub, $iDot + 1);
+
+        $sTitle = trim((string)($aRow['title'] ?? ''));
+        $sPreview = '';
+        foreach ($aUi as $aMessage) {
+            $sText = $this->_oUi->uiChatMessagePlainText($aMessage);
+            if ($sText === '')
+                continue;
+            if ($sTitle === '' && ($aMessage['role'] ?? '') === 'user' && strpos($sText, '[page opened') !== 0)
+                $sTitle = $this->clipChatThreadText($sText, 80);
+            $sPreview = $sText;
+        }
+        if ($sPreview !== '' && strpos($sPreview, '[page opened') === 0)
+            $sPreview = '';
+
+        $sReason = trim((string)($aRow['closed_reason'] ?? ''));
+        return [
+            'thread_id' => (string)$aParsed['thread_id'],
+            'chat' => $sChat,
+            'context_pid' => $iContext,
+            'context_name' => $sContextName,
+            'status' => $sReason !== '' ? 'closed' : 'opened',
+            'closed_reason' => $sReason,
+            'title' => $sTitle,
+            'preview' => $this->clipChatThreadText($sPreview, 120),
+            'messages_count' => count($aUi),
+            'created_ts' => $this->chatThreadTimestamp($aRow['created_at'] ?? ''),
+            'updated_ts' => $this->chatThreadTimestamp($aRow['updated_at'] ?? ''),
+        ];
+    }
+
+    protected function clipChatThreadText($s, $iMax)
+    {
+        $s = (string)$s;
+        if (get_mb_len($s) <= $iMax)
+            return $s;
+        return rtrim(get_mb_substr($s, 0, $iMax - 1)) . '…';
+    }
+
+    /** Unix timestamp for a `created_at` / `updated_at` cell, 0 when unset. */
+    protected function chatThreadTimestamp($mixed)
+    {
+        $s = trim((string)$mixed);
+        if ($s === '' || $s === '0')
+            return 0;
+        $i = ctype_digit($s) ? (int)$s : (int)strtotime($s);
+        return $i > 86400 ? $i : 0;
+    }
+
+    protected function formatAgentChatThreadTime($mixed): string
+    {
+        $s = trim((string)$mixed);
+        if ($s === '' || $s === '0')
+            return '';
+        $i = ctype_digit($s) ? (int)$s : (int)strtotime($s);
+        if ($i <= 86400)
+            return '';
+        return date('d.m.Y H:i', $i);
+    }
+
+    protected function isAllowedContextChat($oProfile, $bRequirePost = false)
+    {
+        if ($oProfile->checkAllowedProfileView() !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        if ($bRequirePost && $oProfile->checkAllowedPostInProfile() !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        return true;
+    }
+}
+
+/** @} */

+ 353 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiChatImages.php

@@ -0,0 +1,353 @@
+<?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
+ * @{
+ */
+
+/**
+ * Images in agent chats: composer uploads (`sys_agents_chat_images` storage),
+ * image parts of an AG-UI request, and NeuronAI content blocks built from them.
+ */
+class BxDolAiChatImages
+{
+    const STORAGE = 'sys_agents_chat_images';
+    const MAX_PER_TURN = 4;
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new self();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    /**
+     * Store one composer upload (`$_FILES` entry) into the chat images storage.
+     *
+     * @return array{file_id:int,url:string,mime:string}|array{error:string}
+     */
+    public function storeUpload($aFile, $iProfileId = 0)
+    {
+        $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
+        if (!$oStorage)
+            return ['error' => 'Storage is not available'];
+
+        $sName = (string)($aFile['name'] ?? '');
+        $sTmp = (string)($aFile['tmp_name'] ?? '');
+        $iSize = (int)($aFile['size'] ?? 0);
+        if ($sTmp === '' || !is_uploaded_file($sTmp))
+            return ['error' => 'No file'];
+        $iMax = $this->maxBytes();
+        if ($iSize <= 0 || ($iMax > 0 && $iSize > $iMax))
+            return ['error' => 'File is too large'];
+
+        $sMime = strtolower((string)($aFile['type'] ?? ''));
+        $sExt = strtolower(pathinfo($sName, PATHINFO_EXTENSION));
+        $aMime = [
+            'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
+            'gif' => 'image/gif', 'webp' => 'image/webp',
+        ];
+        if (!isset($aMime[$sExt]))
+            return ['error' => 'Only JPEG, PNG, GIF, WebP'];
+        if ($sMime && strncmp($sMime, 'image/', 6) !== 0)
+            return ['error' => 'Only images'];
+
+        $iId = (int)$oStorage->storeFileFromForm($aFile, false, (int)$iProfileId);
+        if (!$iId)
+            return ['error' => $oStorage->getErrorString() ?: 'Upload failed'];
+
+        if (method_exists($oStorage, 'afterUploadCleanup'))
+            $oStorage->afterUploadCleanup($iId, (int)$iProfileId);
+
+        $sUrl = $oStorage->getFileUrlById($iId);
+        if (!$sUrl)
+            return ['error' => 'Upload failed'];
+
+        $aInfo = $oStorage->getFile($iId) ?: [];
+        return [
+            'file_id' => $iId,
+            'url' => $sUrl,
+            'mime' => (string)($aInfo['mime_type'] ?? $aMime[$sExt]),
+        ];
+    }
+
+    /**
+     * Image part of an AG-UI / stored message: `{type:image, source:{type:url|data,...}}`,
+     * `{type:image_url, image_url:{url}}`, `{url}`, or a `file_id` from storeUpload.
+     *
+     * @return array{url:string,mime:string,file_id:int}|null
+     */
+    public function parsePart($aPart)
+    {
+        if (!is_array($aPart))
+            return null;
+
+        $sType = strtolower((string)($aPart['type'] ?? ''));
+        $sUrl = '';
+        $sMime = 'image/jpeg';
+        $aSource = is_array($aPart['source'] ?? null) ? $aPart['source'] : [];
+        $iFileId = (int)($aPart['file_id'] ?? $aSource['file_id'] ?? 0);
+
+        if ($iFileId > 0) {
+            $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
+            $sStored = $oStorage ? (string)$oStorage->getFileUrlById($iFileId) : '';
+            if ($sStored !== '')
+                $sUrl = $sStored;
+        }
+
+        if ($sType === 'image' || $sType === 'image_url' || $sUrl !== '') {
+            if ($sUrl === '' && ($aSource['type'] ?? '') === 'url')
+                $sUrl = (string)($aSource['value'] ?? '');
+            elseif ($sUrl === '' && !empty($aPart['url']))
+                $sUrl = (string)$aPart['url'];
+            elseif ($sUrl === '' && !empty($aPart['image_url']['url']))
+                $sUrl = (string)$aPart['image_url']['url'];
+            elseif ($sUrl === '' && ($sType === 'image' || $sType === 'image_url')) {
+                $sContent = (string)($aPart['content'] ?? '');
+                if ($sContent !== '' && (strpos($sContent, 'http://') === 0 || strpos($sContent, 'https://') === 0 || (strpos($sContent, '/') === 0 && strpos($sContent, '//') !== 0)))
+                    $sUrl = $sContent;
+            }
+            $sMime = (string)($aSource['mimeType'] ?? $aPart['media_type'] ?? $aPart['mediaType'] ?? $aPart['mime'] ?? $aPart['mimeType'] ?? $sMime);
+        }
+
+        $sUrl = $this->sanitizeUrl($sUrl);
+        if ($sUrl === '')
+            return null;
+
+        return ['url' => $sUrl, 'mime' => $this->sanitizeMime($sMime), 'file_id' => $iFileId];
+    }
+
+    public function sanitizeMime($sMime)
+    {
+        $sMime = strtolower(trim((string)$sMime));
+        return preg_match('#^image/(jpeg|png|gif|webp)$#', $sMime) ? $sMime : 'image/jpeg';
+    }
+
+    /**
+     * http(s) URLs on this site or on the storage host. Site-relative paths are made absolute.
+     */
+    public function sanitizeUrl($sUrl)
+    {
+        $sUrl = trim((string)$sUrl);
+        if ($sUrl === '' || preg_match('#^(javascript|data|file):#i', $sUrl))
+            return '';
+
+        if (strpos($sUrl, '/') === 0 && strpos($sUrl, '//') !== 0)
+            $sUrl = rtrim(BX_DOL_URL_ROOT, '/') . $sUrl;
+
+        $aUrl = parse_url($sUrl);
+        if (empty($aUrl['scheme']) || empty($aUrl['host']) || !empty($aUrl['user']) || !empty($aUrl['pass']))
+            return '';
+        if (!in_array(strtolower($aUrl['scheme']), ['http', 'https'], true))
+            return '';
+
+        $sHost = strtolower(preg_replace('/^www\./', '', $aUrl['host']));
+        return in_array($sHost, $this->allowedHosts(), true) ? $sUrl : '';
+    }
+
+    /**
+     * This site, the storage domain, the storage endpoint, and the bucket host.
+     */
+    protected function allowedHosts()
+    {
+        $aHosts = [];
+        $aRoot = parse_url(BX_DOL_URL_ROOT);
+        if (!empty($aRoot['host']))
+            $aHosts[] = strtolower(preg_replace('/^www\./', '', $aRoot['host']));
+
+        $sDomain = trim((string)getParam('sys_storage_s3_domain'));
+        $sEndpoint = trim((string)getParam('sys_storage_s3_endpoint'));
+        $sBucket = strtolower(trim((string)getParam('sys_storage_s3_bucket')));
+        $sEndpointHost = '';
+
+        foreach ([$sDomain, $sEndpoint] as $sValue) {
+            if ($sValue === '')
+                continue;
+            $aValue = parse_url(preg_match('#://#', $sValue) ? $sValue : 'https://' . $sValue);
+            if (empty($aValue['host']))
+                continue;
+            $aHosts[] = strtolower($aValue['host']);
+            if ($sValue === $sEndpoint)
+                $sEndpointHost = strtolower($aValue['host']);
+        }
+
+        if (preg_match('/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/', $sBucket)) {
+            if ($sEndpointHost !== '')
+                $aHosts[] = $sBucket . '.' . $sEndpointHost;
+            elseif ($sDomain === '')
+                $aHosts[] = $sBucket . '.s3.amazonaws.com';
+        }
+
+        return $aHosts;
+    }
+
+    /**
+     * NeuronAI user message: text block plus one image block per image.
+     * Images that cannot be turned into a block are passed as their URL in text.
+     *
+     * @param array<int, array{url:string,mime?:string}> $aImages
+     * @return NeuronAI\Chat\Messages\UserMessage|null null when there is nothing to send
+     */
+    public function makeUserMessage($sText, $aImages = [])
+    {
+        $aBlocks = [];
+        $sText = (string)$sText;
+        if ($sText !== '')
+            $aBlocks[] = new NeuronAI\Chat\Messages\ContentBlocks\TextContent($sText);
+
+        foreach ((array)$aImages as $aImage) {
+            $sUrl = trim((string)($aImage['url'] ?? ''));
+            $sMime = trim((string)($aImage['mime'] ?? 'image/jpeg'));
+            $oImage = $this->makeNeuronImageContent($sUrl, $sMime);
+            if ($oImage)
+                $aBlocks[] = $oImage;
+            elseif ($sUrl !== '')
+                $aBlocks[] = new NeuronAI\Chat\Messages\ContentBlocks\TextContent($sUrl);
+        }
+
+        if (!$aBlocks)
+            return null;
+
+        try {
+            return new NeuronAI\Chat\Messages\UserMessage($aBlocks);
+        } catch (Throwable $oException) {
+            $oMessage = new NeuronAI\Chat\Messages\UserMessage($sText !== '' ? $sText : ' ');
+            foreach ($aBlocks as $oBlock) {
+                if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\TextContent)
+                    continue;
+                if (method_exists($oMessage, 'addContent'))
+                    $oMessage->addContent($oBlock);
+            }
+            return $oMessage;
+        }
+    }
+
+    /**
+     * Prefer base64 (the model does not have to fetch our URL); fall back to a URL block.
+     */
+    public function makeNeuronImageContent($sUrl, $sMime = 'image/jpeg')
+    {
+        $sUrl = $this->sanitizeUrl($sUrl);
+        if ($sUrl === '')
+            return null;
+
+        $sClass = 'NeuronAI\\Chat\\Messages\\ContentBlocks\\ImageContent';
+        if (!class_exists($sClass))
+            return null;
+
+        $sData = $this->urlToBase64($sUrl);
+        if ($sData !== '') {
+            if (method_exists($sClass, 'fromBase64'))
+                return $sClass::fromBase64($sData, $sMime);
+            if (class_exists('NeuronAI\\Chat\\Enums\\SourceType')) {
+                try {
+                    return new $sClass($sData, NeuronAI\Chat\Enums\SourceType::BASE64, $sMime);
+                } catch (Throwable $oException) {
+                }
+            }
+        }
+
+        if (method_exists($sClass, 'fromUrl'))
+            return $sClass::fromUrl($sUrl);
+
+        try {
+            if (class_exists('NeuronAI\\Chat\\Enums\\SourceType'))
+                return new $sClass($sUrl, NeuronAI\Chat\Enums\SourceType::URL, $sMime);
+            return new $sClass($sUrl);
+        } catch (Throwable $oException) {
+            return null;
+        }
+    }
+
+    /**
+     * NeuronAI ImageContent block → UI part (`{type:image, source:{type:url|data,...}}`).
+     */
+    public function neuronBlockToUiPart($oBlock)
+    {
+        if (!($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\ImageContent))
+            return null;
+
+        $sContent = (string)($oBlock->content ?? '');
+        if ($sContent === '')
+            return null;
+
+        $sMime = (string)($oBlock->mediaType ?? 'image/jpeg');
+        $sSource = '';
+        if (isset($oBlock->sourceType)) {
+            $sSource = $oBlock->sourceType instanceof \BackedEnum
+                ? (string)$oBlock->sourceType->value
+                : (string)$oBlock->sourceType;
+        }
+
+        if ($sSource === 'base64')
+            return ['type' => 'image', 'source' => ['type' => 'data', 'value' => $sContent, 'mimeType' => $sMime]];
+
+        $sUrl = $this->sanitizeUrl($sContent);
+        if ($sUrl === '')
+            return null;
+        return ['type' => 'image', 'source' => ['type' => 'url', 'value' => $sUrl, 'mimeType' => $sMime]];
+    }
+
+    protected function urlToBase64($sUrl)
+    {
+        $sUrl = $this->sanitizeUrl($sUrl);
+        if ($sUrl === '')
+            return '';
+
+        $sLocal = $this->urlToLocalPath($sUrl);
+        $sBin = ($sLocal !== '' && is_readable($sLocal)) ? @file_get_contents($sLocal) : false;
+        if (!is_string($sBin) || $sBin === '') {
+            $sCode = null;
+            $sBin = bx_file_get_contents($sUrl, [], 'get', [], $sCode, [], 10, [CURLOPT_FOLLOWLOCATION => false]);
+        }
+        $iMax = $this->maxBytes();
+        if (!is_string($sBin) || $sBin === '' || ($iMax > 0 && strlen($sBin) > $iMax))
+            return '';
+
+        return base64_encode($sBin);
+    }
+
+    /**
+     * `max_file_size` of the chat images storage, in bytes. 0 means that object sets no limit.
+     */
+    public function maxBytes()
+    {
+        $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
+        if (!$oStorage)
+            return 0;
+
+        return (int)($oStorage->getObjectData()['max_file_size'] ?? 0);
+    }
+
+    /**
+     * Local path of a file in the chat images storage, '' when the URL is not ours.
+     */
+    protected function urlToLocalPath($sUrl)
+    {
+        if (!defined('BX_DIRECTORY_STORAGE'))
+            return '';
+
+        $aUrl = parse_url((string)$sUrl);
+        $sPath = urldecode((string)($aUrl['path'] ?? ''));
+        $aQuery = [];
+        if (!empty($aUrl['query']))
+            parse_str((string)$aUrl['query'], $aQuery);
+
+        $sFile = '';
+        if (!empty($aQuery['f']))
+            $sFile = basename((string)$aQuery['f']);
+        elseif (preg_match('#/' . preg_quote(self::STORAGE, '#') . '/([^/?]+)$#', $sPath, $aMatch))
+            $sFile = basename((string)$aMatch[1]);
+
+        if ($sFile === '' || strpos($sFile, '..') !== false)
+            return '';
+
+        $sLocal = BX_DIRECTORY_STORAGE . self::STORAGE . '/' . $sFile;
+        return is_file($sLocal) ? $sLocal : '';
+    }
+}
+
+/** @} */

+ 556 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiChatUi.php

@@ -0,0 +1,556 @@
+<?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
+ * @{
+ */
+
+class BxDolAiChatUi
+{
+    protected $_oImages;
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new self();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function __construct()
+    {
+        $this->_oImages = BxDolAiChatImages::getInstance();
+    }
+
+    public function storedChatJsonToUiMessages($sJson)
+    {
+        $aStored = json_decode((string)$sJson, true);
+        if (is_string($aStored))
+            $aStored = json_decode($aStored, true);
+        if (!is_array($aStored) || $aStored === [])
+            return [];
+        if (isset($aStored['messages']) && is_array($aStored['messages']))
+            $aStored = $aStored['messages'];
+        if (!isset($aStored[0]) && isset($aStored['role']))
+            $aStored = [$aStored];
+
+        $aResult = [];
+        $i = 0;
+        foreach ($aStored as $aMessage) {
+            if (!is_array($aMessage))
+                continue;
+
+            // A tool call turn keeps the assistant text said before the call
+            // (then chat_buttons, etc.): show it, only the tool result is internal.
+            $sType = $this->storedEnumString($aMessage['type'] ?? '');
+            if ($sType === 'tool_call_result')
+                continue;
+
+            $sRole = $this->storedEnumString($aMessage['role'] ?? $aMessage['author'] ?? '');
+            if ($sRole === 'model' || $sType === 'tool_call')
+                $sRole = 'assistant';
+            if ($sRole === 'human')
+                $sRole = 'user';
+            if ($sRole !== 'user' && $sRole !== 'assistant')
+                continue;
+
+            $aParts = [];
+            if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
+                foreach ($aMessage['parts'] as $aBlock) {
+                    $aPart = $this->storedContentBlockToUiPart($aBlock);
+                    if ($aPart)
+                        $aParts[] = $aPart;
+                }
+            }
+            $mixedContent = $aMessage['content'] ?? $aMessage['contents'] ?? '';
+            if (is_string($mixedContent) && $mixedContent !== '') {
+                $aParts[] = ['type' => 'text', 'content' => $mixedContent];
+            } elseif (is_array($mixedContent)) {
+                if (isset($mixedContent['type']) && !isset($mixedContent[0]))
+                    $mixedContent = [$mixedContent];
+                foreach ($mixedContent as $aBlock) {
+                    $aPart = $this->storedContentBlockToUiPart($aBlock);
+                    if ($aPart)
+                        $aParts[] = $aPart;
+                }
+            }
+            // trigger payloads (message / alert / form-input / webhook) are stored as raw JSON
+            // in the user turn — show the human text instead of the escaped blob
+            if ($sRole === 'user') {
+                foreach ($aParts as &$aPart) {
+                    if (($aPart['type'] ?? '') === 'text')
+                        $aPart['content'] = $this->humanizeTriggerPayload((string)$aPart['content']);
+                }
+                unset($aPart);
+            }
+
+            $sId = '';
+            if (!empty($aMessage['__id']) && is_string($aMessage['__id']))
+                $sId = $aMessage['__id'];
+            elseif (!empty($aMessage['metadata']['__id']) && is_string($aMessage['metadata']['__id']))
+                $sId = $aMessage['metadata']['__id'];
+
+            $mixedActions = $aMessage['actions'] ?? ($aMessage['metadata']['actions'] ?? null);
+            $aUi = $this->finalizeUiChatMessage($sId !== '' ? $sId : ('msg_' . $i), $sRole, $aParts, $mixedActions);
+            if (!$aUi)
+                continue;
+
+            $aResult[] = $aUi;
+            $i++;
+        }
+
+        return $this->foldUiChatMessages($aResult);
+    }
+
+    /**
+     * @param NeuronAI\Chat\Messages\Message[] $aMessages
+     * @return array<int, array<string, mixed>>
+     */
+    public function neuronMessagesToUiMessages($aMessages)
+    {
+        $aResult = [];
+        $i = 0;
+
+        foreach ($aMessages as $oMessage) {
+            // ToolCallMessage is an assistant message holding the text said before
+            // the call; only the tool result is skipped (see storedChatJsonToUiMessages).
+            if ($oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage)
+                continue;
+
+            $sRole = $oMessage->getRole();
+            if ($sRole === 'model' || $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage)
+                $sRole = 'assistant';
+            if ($sRole !== 'user' && $sRole !== 'assistant')
+                continue;
+
+            $aParts = [];
+            foreach ($oMessage->getContentBlocks() as $oBlock) {
+                if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\ReasoningContent) {
+                    if ($oBlock->content !== '')
+                        $aParts[] = ['type' => 'thinking', 'content' => $oBlock->content];
+                    continue;
+                }
+
+                if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\TextContent && $oBlock->content !== '')
+                    $aParts[] = ['type' => 'text', 'content' => $oBlock->content];
+
+                $aImage = $this->_oImages->neuronBlockToUiPart($oBlock);
+                if ($aImage)
+                    $aParts[] = $aImage;
+            }
+
+            $sId = $oMessage->getMetadata('__id');
+            $aUi = $this->finalizeUiChatMessage(
+                is_string($sId) && $sId !== '' ? $sId : ('msg_' . $i),
+                $sRole,
+                $aParts,
+                $sRole === 'assistant' ? $oMessage->getMetadata('actions') : null
+            );
+            if (!$aUi)
+                continue;
+
+            $aResult[] = $aUi;
+            $i++;
+        }
+
+        return $this->foldUiChatMessages($aResult);
+    }
+
+    /**
+     * Split assistant JSON {content, actions} into UI text + actions.
+     * User messages pass through unchanged.
+     *
+     * @param array<int, array<string, mixed>> $aParts
+     * @return array<string, mixed>|null
+     */
+    public function finalizeUiChatMessage($sId, $sRole, $aParts, $mixedActions = null)
+    {
+        $aActions = $sRole === 'assistant' ? $this->sanitizeChatActions($mixedActions) : [];
+        if ($sRole === 'assistant') {
+            $sText = '';
+            foreach ($aParts as $aPart) {
+                if (($aPart['type'] ?? '') === 'text')
+                    $sText .= (string)($aPart['content'] ?? '');
+            }
+            $aParsed = $this->parseAssistantChatPayload($sText);
+            if ($aParsed) {
+                $aKept = [];
+                foreach ($aParts as $aPart) {
+                    if (($aPart['type'] ?? '') === 'text')
+                        continue;
+                    $aKept[] = $aPart;
+                }
+                if ($aParsed['content'] !== '')
+                    $aKept[] = ['type' => 'text', 'content' => $aParsed['content']];
+                $aParts = $aKept;
+                $aActions = $this->mergeChatActions($aActions, $aParsed['actions']);
+            }
+        }
+
+        if (!$aParts && !$aActions)
+            return null;
+
+        $aOut = [
+            'id' => $sId,
+            'role' => $sRole,
+            'parts' => $aParts,
+        ];
+        if ($sRole === 'assistant') {
+            $aOut['actions'] = $aActions;
+            if ($aActions)
+                $aOut['metadata'] = ['actions' => $aActions];
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * One model turn can be stored as several assistant messages: the text before a
+     * tool call (ToolCallMessage), then the reply after the tool result — often empty
+     * and carrying only `actions`. Show them as one bubble, like the live stream does.
+     * The first message's id is kept; text is joined, actions are merged.
+     *
+     * @param array<int, array<string, mixed>> $aMessages
+     * @return array<int, array<string, mixed>>
+     */
+    public function foldUiChatMessages($aMessages)
+    {
+        $aOut = [];
+        foreach ($aMessages as $aMessage) {
+            $iLast = count($aOut) - 1;
+            $aPrev = $iLast >= 0 ? $aOut[$iLast] : null;
+            if (($aMessage['role'] ?? '') !== 'assistant' || ($aPrev['role'] ?? '') !== 'assistant') {
+                $aOut[] = $aMessage;
+                continue;
+            }
+
+            $sText = '';
+            $aMedia = [];
+            foreach (array_merge($aPrev['parts'] ?? [], $aMessage['parts'] ?? []) as $aPart) {
+                if (($aPart['type'] ?? '') === 'text') {
+                    $sChunk = (string)($aPart['content'] ?? '');
+                    if ($sChunk !== '')
+                        $sText .= ($sText !== '' ? "\n\n" : '') . $sChunk;
+                    continue;
+                }
+                $aMedia[] = $aPart;
+            }
+
+            $aParts = $sText !== '' ? [['type' => 'text', 'content' => $sText]] : [];
+            $aActions = $this->mergeChatActions($aPrev['actions'] ?? [], $aMessage['actions'] ?? []);
+            $aMerged = array_merge($aPrev, [
+                'parts' => array_merge($aParts, $aMedia),
+                'actions' => $aActions,
+            ]);
+            if ($aActions)
+                $aMerged['metadata'] = ['actions' => $aActions];
+            else
+                unset($aMerged['metadata']);
+            $aOut[$iLast] = $aMerged;
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * @return array{content: string, actions: array}|null
+     */
+    public function parseAssistantChatPayload($sText)
+    {
+        $s = trim((string)$sText);
+        if ($s === '')
+            return null;
+
+        if (preg_match('/^```(?:json)?\s*(\{.*\})\s*```$/s', $s, $aM))
+            $s = trim($aM[1]);
+
+        if ($s === '' || $s[0] !== '{') {
+            $iStart = strpos($s, '{');
+            $iEnd = strrpos($s, '}');
+            if ($iStart === false || $iEnd === false || $iEnd <= $iStart)
+                return null;
+            $s = substr($s, $iStart, $iEnd - $iStart + 1);
+        }
+
+        $a = json_decode($s, true);
+        if (!is_array($a) || !array_key_exists('content', $a) || !is_string($a['content']))
+            return null;
+        if (!array_key_exists('actions', $a))
+            return null;
+
+        return [
+            'content' => $a['content'],
+            'actions' => $this->sanitizeChatActions($a['actions']),
+        ];
+    }
+
+    /**
+     * Union of two action lists, duplicates (same type + label + url) dropped, at most 8.
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    public function mergeChatActions($a, $b)
+    {
+        $aAll = array_merge($this->sanitizeChatActions($a), $this->sanitizeChatActions($b));
+        $aOut = [];
+        $aSeen = [];
+        foreach ($aAll as $aItem) {
+            $sKey = ($aItem['type'] ?? '') . '|' . ($aItem['label'] ?? '') . '|' . ($aItem['url'] ?? ($aItem['form_url'] ?? ''));
+            if (isset($aSeen[$sKey]))
+                continue;
+            $aSeen[$sKey] = true;
+            $aOut[] = $aItem;
+            if (count($aOut) >= 8)
+                break;
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    public function sanitizeChatActions($mixed)
+    {
+        if (!is_array($mixed))
+            return [];
+
+        $aOut = [];
+        foreach ($mixed as $aItem) {
+            if (!is_array($aItem))
+                continue;
+
+            $sType = strtolower(trim((string)($aItem['type'] ?? '')));
+            $sLabel = trim((string)($aItem['label'] ?? ''));
+            if ($sLabel === '')
+                continue;
+            if (function_exists('mb_substr'))
+                $sLabel = mb_substr($sLabel, 0, 80);
+            else
+                $sLabel = substr($sLabel, 0, 80);
+
+            if ($sType === 'reply') {
+                $aOut[] = ['type' => 'reply', 'label' => $sLabel];
+            } elseif ($sType === 'link') {
+                $sUrl = trim((string)($aItem['url'] ?? ''));
+                if (!$this->isAllowedChatActionUrl($sUrl))
+                    continue;
+                $aLink = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
+                // a link the chat waits for (e.g. OAuth): it polls check_url and sends message when done
+                $sCheckUrl = trim((string)($aItem['check_url'] ?? ''));
+                $sMessage = $this->sanitizeChatActionMessage($aItem['message'] ?? '');
+                if ($sMessage !== '' && $this->isAllowedChatActionServiceUrl($sCheckUrl)) {
+                    $aLink['check_url'] = $sCheckUrl;
+                    $aLink['message'] = $sMessage;
+                }
+                $aOut[] = $aLink;
+            } elseif ($sType === 'form') {
+                // form opened in a popup over the chat
+                $sFormUrl = trim((string)($aItem['form_url'] ?? ''));
+                if (!$this->isAllowedChatActionServiceUrl($sFormUrl))
+                    continue;
+                $aForm = ['type' => 'form', 'label' => $sLabel, 'form_url' => $sFormUrl];
+                $sMessage = $this->sanitizeChatActionMessage($aItem['message'] ?? '');
+                if ($sMessage !== '')
+                    $aForm['message'] = $sMessage;
+                $aOut[] = $aForm;
+            }
+
+            if (count($aOut) >= 8)
+                break;
+        }
+
+        return $aOut;
+    }
+
+    /** Text parts of a UI message joined, tags stripped, whitespace collapsed. */
+    public function uiChatMessagePlainText($aMessage)
+    {
+        $aTexts = [];
+        foreach ((array)($aMessage['parts'] ?? []) as $aPart) {
+            if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && is_string($aPart['content'] ?? null))
+                $aTexts[] = $aPart['content'];
+        }
+        $s = strip_tags(html_entity_decode(implode(' ', $aTexts), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+        return trim(preg_replace('/\s+/u', ' ', $s));
+    }
+
+    /**
+     * A user turn produced by a trigger is the JSON that callAgent() built
+     * (BxDolAlerts, BxDolAiAlertResponse, webhook, form-input). Return the text
+     * a person would want to read; anything that is not such a payload is returned as is.
+     */
+    public function humanizeTriggerPayload($sText)
+    {
+        $s = trim((string)$sText);
+        if ($s === '' || $s[0] !== '{')
+            return $sText;
+
+        $a = json_decode($s, true);
+        if (!is_array($a))
+            return $sText;
+        $sTrigger = is_string($a['trigger'] ?? null) ? $a['trigger'] : '';
+        if ($sTrigger === '' && (isset($a['form_values']) || isset($a['user_prompt'])))
+            $sTrigger = 'form-input';
+        if ($sTrigger === '')
+            return $sText;
+
+        $fText = function ($m) {
+            $s = trim((string)$m);
+            return $s !== '' ? trim(strip_tags(html_entity_decode($s, ENT_QUOTES, 'UTF-8'))) : '';
+        };
+
+        switch ($sTrigger) {
+            case 'message':
+                $sMsg = $fText($a['message_text'] ?? ($a['message_info']['message'] ?? ''));
+                return $sMsg !== '' ? $sMsg : $sText;
+
+            case 'alert':
+                $sUnit = (string)($a['unit'] ?? '');
+                $sAction = (string)($a['action'] ?? '');
+                $sHead = trim($sUnit . ':' . $sAction, ':');
+                if (!empty($a['object_id']))
+                    $sHead .= ' #' . (int)$a['object_id'];
+                $sBody = '';
+                $aExtra = is_array($a['extra'] ?? null) ? $a['extra'] : [];
+                foreach (['comment_text', 'cmt_text', 'text', 'message', 'title', 'content'] as $sKey) {
+                    if (!empty($aExtra[$sKey]) && is_string($aExtra[$sKey])) {
+                        $sBody = $fText($aExtra[$sKey]);
+                        break;
+                    }
+                }
+                if ($sBody === '' && !empty($aExtra['comment_id']))
+                    $sBody = 'comment #' . (int)$aExtra['comment_id'];
+                return $sHead . ($sBody !== '' ? "\n" . $sBody : '');
+
+            case 'form-input':
+                $aOut = [];
+                $sPrompt = $fText($a['user_prompt'] ?? '');
+                if ($sPrompt !== '')
+                    $aOut[] = $sPrompt;
+                $aValues = is_array($a['form_values'] ?? null) ? $a['form_values'] : [];
+                foreach ($aValues as $sKey => $mixedVal) {
+                    if (is_scalar($mixedVal) && (string)$mixedVal !== '')
+                        $aOut[] = $sKey . ': ' . $fText($mixedVal);
+                }
+                $sHead = trim((string)($a['form_field_name'] ?? ''));
+                return trim($sHead . ($aOut ? "\n" . implode("\n", $aOut) : ''));
+
+            case 'webhook':
+                $aOut = [];
+                foreach ($a as $sKey => $mixedVal) {
+                    if ($sKey === 'trigger')
+                        continue;
+                    if (is_scalar($mixedVal) && (string)$mixedVal !== '')
+                        $aOut[] = $sKey . ': ' . $fText($mixedVal);
+                    elseif (is_array($mixedVal))
+                        $aOut[] = $sKey . ': ' . json_encode($mixedVal, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+                }
+                return 'webhook' . ($aOut ? "\n" . implode("\n", $aOut) : '');
+        }
+
+        return $sText;
+    }
+
+    /**
+     * Neuron / UI content block from stored chat JSON → UI part.
+     *
+     * @param mixed $mixedBlock
+     * @return array<string, mixed>|null
+     */
+    protected function storedContentBlockToUiPart($mixedBlock)
+    {
+        if (is_string($mixedBlock) && $mixedBlock !== '')
+            return ['type' => 'text', 'content' => $mixedBlock];
+        if (!is_array($mixedBlock))
+            return null;
+
+        $sType = $this->storedEnumString($mixedBlock['type'] ?? '');
+        if (strpos($sType, 'textcontent') !== false)
+            $sType = 'text';
+        if (strpos($sType, 'imagecontent') !== false)
+            $sType = 'image';
+        $sText = (string)($mixedBlock['content'] ?? $mixedBlock['text'] ?? '');
+        if ($sType === 'text' || $sType === '' || $sType === 'array')
+            return $sText !== '' ? ['type' => 'text', 'content' => $sText] : null;
+        if ($sType === 'reasoning' || $sType === 'thinking')
+            return $sText !== '' ? ['type' => 'thinking', 'content' => $sText] : null;
+
+        $aImage = $this->storedImageBlockToUiPart($mixedBlock);
+        return $aImage ?: null;
+    }
+
+    /**
+     * @param array<string, mixed> $aBlock
+     * @return array<string, mixed>|null
+     */
+    protected function storedImageBlockToUiPart($aBlock)
+    {
+        $aSource = is_array($aBlock['source'] ?? null) ? $aBlock['source'] : [];
+        $sSourceKind = strtolower((string)($aSource['type'] ?? ''));
+        $sNeuronSource = $this->storedEnumString($aBlock['source_type'] ?? $aBlock['sourceType'] ?? '');
+        $sMime = $this->_oImages->sanitizeMime($aSource['mimeType'] ?? $aBlock['media_type'] ?? $aBlock['mediaType'] ?? $aBlock['mime'] ?? $aBlock['mimeType'] ?? 'image/jpeg');
+
+        if ($sSourceKind === 'data' && (string)($aSource['value'] ?? '') !== '')
+            return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aSource['value'], 'mimeType' => $sMime]];
+
+        if (($sNeuronSource === 'base64' || $sNeuronSource === 'data') && (string)($aBlock['content'] ?? '') !== '')
+            return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aBlock['content'], 'mimeType' => $sMime]];
+
+        $aParsed = $this->_oImages->parsePart($aBlock);
+        if ($aParsed)
+            return ['type' => 'image', 'source' => ['type' => 'url', 'value' => $aParsed['url'], 'mimeType' => $aParsed['mime']]];
+
+        return null;
+    }
+
+    /**
+     * Enum (object, `{value}` array or string) from stored JSON → lower-case string.
+     *
+     * @param mixed $mixed
+     */
+    protected function storedEnumString($mixed)
+    {
+        if (is_object($mixed) && $mixed instanceof \BackedEnum)
+            return strtolower((string)$mixed->value);
+        if (is_array($mixed))
+            return strtolower((string)($mixed['value'] ?? $mixed['name'] ?? ''));
+        return strtolower((string)$mixed);
+    }
+
+    /**
+     * API service call of a chat action: 'module/method/&params[]=..', nothing else.
+     */
+    protected function isAllowedChatActionServiceUrl($sUrl)
+    {
+        return (bool)preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-\.]*)*$/', (string)$sUrl);
+    }
+
+    /**
+     * Text a chat action sends as the visitor answer: plain, one line, max 200 chars.
+     */
+    protected function sanitizeChatActionMessage($mixed)
+    {
+        $s = trim(strip_tags((string)$mixed));
+        return get_mb_substr($s, 0, 200);
+    }
+
+    protected function isAllowedChatActionUrl($sUrl)
+    {
+        $a = parse_url((string)$sUrl);
+        if (($a['scheme'] ?? '') !== 'https' || empty($a['host']))
+            return false;
+
+        $sHost = strtolower((string)$a['host']);
+        if ($sHost === 'localhost' || str_ends_with($sHost, '.localhost'))
+            return false;
+        if (filter_var($sHost, FILTER_VALIDATE_IP))
+            return (bool)filter_var($sHost, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
+
+        return true;
+    }
+}
+
+/** @} */

+ 238 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/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
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/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);
+    }
+}
+
+/** @} */

+ 762 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiQuery.php

@@ -0,0 +1,762 @@
+<?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
+ * @{
+ */
+
+class BxDolAiQuery extends BxDolDb
+{
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    static protected function _getObject(int $iId, string $sTable): array|bool
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $a = $oDb->getRow("SELECT * FROM `$sTable` WHERE `id` = :id", ['id' => $iId]);
+        if(!$a || !is_array($a))
+            return false;
+
+        return $a;
+    }
+
+    static public function getModelObject(int $iId): array|bool
+    {
+        return self::_getObject($iId, 'sys_agents_models');
+    }
+
+    static public function getVectorStoreObject(int $iId): array|bool
+    {
+        return self::_getObject($iId, 'sys_agents_vector_store');
+    }
+
+    static public function getAgentObject(int $iId): array|bool
+    {
+        return self::_getObject($iId, 'sys_agents_agents');
+    }
+
+    static public function getToolObject(int $iId): array|bool
+    {
+        return self::_getObject($iId, 'sys_agents_tools');
+    }
+
+    public function insertModel($aModel)
+    {
+        if(empty($aModel))
+            return false;
+
+        return (int)$this->query("INSERT INTO `sys_agents_models` SET " . $this->arrayToSQL($aModel));
+    }
+
+    public function getModelsBy($aParams = [])
+    {
+        $aMethod = ['name' => 'getAll', 'params' => [0 => 'query']];
+    	$sWhereClause = "";
+
+        switch($aParams['sample']) {
+            case 'id':
+            	$aMethod['name'] = 'getRow';
+            	$aMethod['params'][1] = [
+                    'id' => $aParams['id']
+                ];
+
+                $sWhereClause .= " AND `id`=:id";
+                break;
+
+            case 'all_pairs':
+                $aMethod['name'] = 'getPairs';
+                $aMethod['params'][1] = 'id';
+                $aMethod['params'][2] = 'title';
+                $aMethod['params'][3] = [];
+
+                if(isset($aParams['active'])) {
+                    $aMethod['params'][3]['active'] = $aParams['active'];
+
+                    $sWhereClause .= " AND `active`=:active";
+                }
+
+                if(isset($aParams['capabilities'])) {
+                    if (is_array($aParams['capabilities']))
+                    {                        
+                        $sWhereClause .= " AND `capabilities` IN (" . $this->implode_escape($aParams['capabilities']) . ")"; 
+                    }
+                    else {
+                        $aMethod['params'][3]['capabilities'] = $aParams['capabilities'];
+                        $sWhereClause .= " AND `capabilities` = :capabilities";
+                    }
+                }
+
+                if (!empty($aParams['type_not'])) {
+                    $aMethod['params'][3]['type_not'] = $aParams['type_not'];
+                    $sWhereClause .= " AND `type` <> :type_not";
+                }
+                break;
+        }
+
+        $aMethod['params'][0] = "SELECT * 
+            FROM `sys_agents_models`
+            WHERE 1" . $sWhereClause;
+
+        return call_user_func_array([$this, $aMethod['name']], $aMethod['params']);
+    }
+
+    public function getVectorStores (): mixed
+    {
+        $sQuery = "SELECT `id`, `title` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `title` ASC";
+        return $this->getPairs($sQuery, 'id', 'title'); 
+    }
+
+    public function getVectorStoreById (int $iId): mixed
+    {
+        $sQuery = "SELECT * FROM `sys_agents_vector_store` WHERE `id` = :id";
+        return $this->getRow($sQuery, ['id' => $iId]); 
+    }
+
+    public function insertVectorStore($aVectorStore)
+    {
+        if(empty($aVectorStore) || !is_array($aVectorStore))
+            return false;
+
+        return (int)$this->query("INSERT INTO `sys_agents_vector_store` SET " . $this->arrayToSQL($aVectorStore));
+    }
+
+    public function getVectorStoreDataNum (int $iVectorStoreId): int
+    {
+        $sQuery = "SELECT COUNT(*) FROM `sys_agents_vector_store_data` WHERE `vector_store_id` = :vector_store_id";
+        return (int)$this->getOne($sQuery, ['vector_store_id' => $iVectorStoreId]);
+    }
+
+    public function addVectorStoreData (int $iVectorStoreId, string $sType, string $sName, int $iFileSize, string $sMetadata, string $sSettings, string $sContent): bool
+    {
+        $sQuery = "INSERT INTO `sys_agents_vector_store_data` SET  
+            `vector_store_id` = :vector_store_id,
+            `type` = :type, 
+            `name` = :name, 
+            `size` = :size,
+            `metadata` = :metadata, 
+            `settings` = :settings, 
+            `content` = :content,
+            `added` = :ts
+        ";
+        return $this->query($sQuery, [
+            'vector_store_id' => $iVectorStoreId,
+            'type' => $sType, 
+            'name' => $sName, 
+            'size' => $iFileSize,
+            'metadata' => $sMetadata, 
+            'settings' => $sSettings, 
+            'content' => $sContent,
+            'ts' => time()]) > 0;
+    }
+
+    public function getVectorStoreDataById (int $iId): mixed
+    {
+        $sQuery = "SELECT * FROM `sys_agents_vector_store_data` WHERE `id` = :id";
+        return $this->getRow($sQuery, ['id' => $iId]);
+    }
+
+    static public function getVectorStorePendingData (int $iLimit = 1): mixed
+    {
+        $oDb = BxDolDb::getInstance();
+        $sQuery = "SELECT * FROM `sys_agents_vector_store_data` WHERE `status` = 'pending' ORDER BY `added` ASC LIMIT :limit";
+        return $oDb->getAll($sQuery, ['limit' => $iLimit]);
+    }
+
+    static public function updateVectorStoreDataStatus (int $iId, string $sStatus): mixed
+    {
+        $oDb = BxDolDb::getInstance();
+        $sQuery = "UPDATE `sys_agents_vector_store_data` SET `status` = :status WHERE `id` = :id";
+        return $oDb->query($sQuery, ['status' => $sStatus, 'id' => $iId]);
+    }
+
+    public function getAgentsWithAlert($bActiveOnly = true)
+    {
+        return $this->fromCache('sys_agents_with_alert', 'getAll', "SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'alert' AND `active` = :active", ['active' => $bActiveOnly ? 1 : 0]);
+    }
+
+    public function getAgentsByProfileId($iProfileId, $bActiveOnly = true)
+    {
+        return $this->getAll("SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'message' AND `profile_id` = :profile AND `active` = :active", ['profile' => $iProfileId, 'active' => $bActiveOnly ? 1 : 0]);
+    }
+
+    public function getAgentsByFormObject($sFormObject, $bActiveOnly = true)
+    {
+        return $this->fromMemory('sys_agents_with_form_' . $sFormObject, 'getAll', "SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'form-input' AND `form_object` = :form_object AND `active` = :active", ['form_object' => $sFormObject, 'active' => $bActiveOnly ? 1 : 0]);
+    }
+
+    public function getAgentsBy($aParams = [])
+    {
+        $aMethod = ['name' => 'getAll', 'params' => [0 => 'query']];
+    	$sWhereClause = "";
+
+        switch($aParams['sample']) {
+            case 'id':
+            	$aMethod['name'] = 'getRow';
+            	$aMethod['params'][1] = [
+                    'id' => $aParams['id']
+                ];
+
+                $sWhereClause .= " AND `id`=:id";
+                break;
+
+            case 'all':
+                if(isset($aParams['active'])) {
+                    $aMethod['params'][3]['active'] = $aParams['active'];
+
+                    $sWhereClause .= " AND `active`=:active";
+                }
+                break;
+        }
+
+        $aMethod['params'][0] = "SELECT * 
+            FROM `sys_agents_agents`
+            WHERE 1" . $sWhereClause;
+
+        return call_user_func_array([$this, $aMethod['name']], $aMethod['params']);
+    }
+
+    public function getAgentById(int $iId): mixed
+    {
+        return $this->getAgentsBy(['sample' => 'id', 'id' => $iId]);
+    }
+
+    public function getAgentByTriggerWebhookKey($sKey, $bActiveOnly = true)
+    {
+        return $this->getRow("SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'webhook' AND `webhook_key` = :key AND `active` = :active", ['key' => $sKey, 'active' => $bActiveOnly ? 1 : 0]);
+    }
+    
+    public function getAgentsByTriggerType($sTrigger, $bActiveOnly = true)
+    {
+        return $this->getAll("SELECT * FROM `sys_agents_agents` WHERE `trigger` = :trigger AND `active` = :active", ['trigger' => $sTrigger, 'active' => $bActiveOnly ? 1 : 0]);
+    }
+
+    public function isAgentProfile($iProfileId)
+    {
+        return (int)$this->getOne("SELECT `id` FROM `sys_agents_agents` WHERE `profile_id` = :profile_id AND `active` = 1 LIMIT 1", ['profile_id' => (int)$iProfileId]) > 0;
+    }
+
+    public function updateAgentField($iId, $sField, $sValue)
+    {
+        return $this->query("UPDATE `sys_agents_agents` SET `$sField` = :value WHERE `id` = :id", ['value' => $sValue, 'id' => $iId]);
+    }
+
+    public function getToolById (int $iId): mixed
+    {
+        $sQuery = "SELECT * FROM `sys_agents_tools` WHERE `id` = :id";
+        return $this->getRow($sQuery, ['id' => $iId]); 
+    }
+
+    public function insertTool(array $aFields): int
+    {
+        if(empty($aFields) || !is_array($aFields))
+            return false;
+
+        return (int)$this->query("INSERT INTO `sys_agents_tools` SET " . $this->arrayToSQL($aFields));
+    }
+
+    public function getTools()
+    {
+        $sHidden = $this->implode_escape(BxDolAiAgentTools::hiddenTypes());
+        $sQuery = "SELECT `id`, `title` FROM `sys_agents_tools` WHERE `active` = 1 AND `type` NOT IN (" . $sHidden . ") ORDER BY `title` ASC";
+        return $this->getPairs($sQuery, 'id', 'title');
+    }
+
+    /**
+     * Active tool rows for these types, lowest id first.
+     *
+     * @return array<int, array{id:int, type:string}>
+     */
+    public function getActiveToolsByTypes(array $aTypes): array
+    {
+        if (!$aTypes)
+            return [];
+
+        $aRows = $this->getAll("SELECT `id`, `type` FROM `sys_agents_tools` WHERE `active` = 1 AND `type` IN (" . $this->implode_escape($aTypes) . ") ORDER BY `id` ASC");
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            $aOut[] = [
+                'id' => (int)$aRow['id'],
+                'type' => (string)$aRow['type'],
+            ];
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * Every tool id of these types, including inactive copies.
+     *
+     * @return int[]
+     */
+    public function getToolIdsByTypes(array $aTypes): array
+    {
+        if (!$aTypes)
+            return [];
+
+        $aRows = $this->getAll("SELECT `id` FROM `sys_agents_tools` WHERE `type` IN (" . $this->implode_escape($aTypes) . ")");
+        $aIds = [];
+        foreach ($aRows as $aRow)
+            $aIds[] = (int)$aRow['id'];
+
+        return $aIds;
+    }
+
+    public function wipeAgentChatHistory($aAgent) 
+    {
+        $sQuery = "DELETE FROM `sys_agents_chat_history` WHERE `thread_id` LIKE :val";
+        $iAffected = 0;
+        $iAffected += $this->query($sQuery, ['val' => $aAgent['trigger'] . ':' . $aAgent['id'] . ':%']);
+        $iAffected += $this->query($sQuery, ['val' => $aAgent['trigger'] . ':' . $aAgent['id']]);
+        return $iAffected;
+    }
+
+    public function getAgentChatHistoryRows($aAgent)
+    {
+        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        return $this->getAll("
+            SELECT " . $this->getChatHistoryRowFields() . "
+            FROM `sys_agents_chat_history`
+            WHERE `thread_id` = :exact OR `thread_id` LIKE :prefix
+            ORDER BY `updated_at` DESC
+            LIMIT 200
+        ", [
+            'exact' => $sExact,
+            'prefix' => $sExact . ':%',
+        ]);
+    }
+
+    /**
+     * Every thread one owner (profile id, or guest session id) has with this agent:
+     * site-wide and in any context, base and `?chat=` partitions. Newest first.
+     * Owner is matched by LIKE on the thread id, so callers still re-check it via
+     * BxDolAiChat::parseThreadId.
+     */
+    public function getOwnerAgentChatHistoryRows($aAgent, $sOwner, $iLimit = 100)
+    {
+        $sPrefix = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $sOwner = (string)$sOwner;
+        if ($sPrefix === ':' || $sOwner === '')
+            return [];
+
+        $sOwnerLike = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $sOwner);
+        return $this->getAll("
+            SELECT " . $this->getChatHistoryRowFields() . "
+            FROM `sys_agents_chat_history`
+            WHERE `thread_id` = :site
+               OR `thread_id` LIKE :site_chat
+               OR `thread_id` LIKE :ctx
+               OR `thread_id` LIKE :ctx_chat
+            ORDER BY `updated_at` DESC, `id` DESC
+            LIMIT " . (int)$iLimit, [
+            'site' => $sPrefix . ':' . $sOwner,
+            'site_chat' => $sPrefix . ':' . $sOwnerLike . '#%',
+            'ctx' => $sPrefix . ':%:' . $sOwnerLike,
+            'ctx_chat' => $sPrefix . ':%:' . $sOwnerLike . '#%',
+        ]);
+    }
+
+    /**
+     * The owner's current thread for this agent+context: the newest one that is not
+     * closed — a `?chat=` partition or the base thread. '' when there is none.
+     */
+    public function getCurrentOpenChatThreadId($aAgent, $sOwner, $iContext)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            return '';
+
+        $sPrefix = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $sOwner = (string)$sOwner;
+        if ($sPrefix === ':' || $sOwner === '')
+            return '';
+
+        $sBase = ((int)$iContext > 0)
+            ? ($sPrefix . ':' . (int)$iContext . ':' . $sOwner)
+            : ($sPrefix . ':' . $sOwner);
+
+        return (string)$this->getOne("
+            SELECT `thread_id`
+            FROM `sys_agents_chat_history`
+            WHERE (`thread_id` = :exact OR `thread_id` LIKE :hash)
+              AND (`closed_reason` = '' OR `closed_reason` IS NULL)
+            ORDER BY `updated_at` DESC, `id` DESC
+            LIMIT 1
+        ", [
+            'exact' => $sBase,
+            'hash' => $sBase . '#%',
+        ]);
+    }
+
+    /**
+     * Stored thread title; '' when unset, or when the column is not there yet —
+     * callers fall back to deriving one from the messages.
+     */
+    public function getChatHistoryTitle($sThreadId)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'title'))
+            return '';
+
+        return (string)$this->getOne("SELECT `title` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => (string)$sThreadId,
+        ]);
+    }
+
+    public function setChatHistoryTitle($sThreadId, $sTitle)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'title'))
+            return false;
+
+        return (bool)$this->query("UPDATE `sys_agents_chat_history` SET `title` = :s WHERE `thread_id` = :t", [
+            's' => get_mb_substr((string)$sTitle, 0, 255),
+            't' => (string)$sThreadId,
+        ]);
+    }
+
+    /**
+     * History row for a thread, created empty when missing.
+     *
+     * @return int row id, 0 for an invalid thread id
+     */
+    public function ensureChatHistoryRow($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '' || $sThreadId === ':')
+            return 0;
+
+        $iId = $this->getChatHistoryIdByThreadId($sThreadId);
+        if ($iId)
+            return $iId;
+
+        $this->query("INSERT IGNORE INTO `sys_agents_chat_history` (`thread_id`, `messages`) VALUES (:t, :m)", [
+            't' => $sThreadId,
+            'm' => '[]',
+        ]);
+
+        return $this->getChatHistoryIdByThreadId($sThreadId);
+    }
+
+    protected function getChatHistoryRowFields()
+    {
+        $sFields = "`id`, `thread_id`, `messages`, `created_at`, `updated_at`";
+        if ($this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            $sFields .= ", `closed_reason`";
+        if ($this->isFieldExists('sys_agents_chat_history', 'title'))
+            $sFields .= ", `title`";
+        return $sFields;
+    }
+
+    public function getChatHistoryIdByThreadId($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '' || $sThreadId === ':')
+            return 0;
+        return (int)$this->getOne("SELECT `id` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThreadId,
+        ]);
+    }
+
+    public function getChatHistoryMessagesByThreadId($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '')
+            return '';
+        return $this->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThreadId,
+        ]);
+    }
+
+    public function getChatHistoryClosedReasonById($iHistoryId)
+    {
+        $iHistoryId = (int)$iHistoryId;
+        if ($iHistoryId <= 0 || !$this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            return '';
+        return (string)$this->getOne(
+            "SELECT `closed_reason` FROM `sys_agents_chat_history` WHERE `id` = :id",
+            ['id' => $iHistoryId]
+        );
+    }
+
+    public function isNewChatSession($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '')
+            return true;
+
+        $aRow = $this->getRow("SELECT `ip`, `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThreadId,
+        ]);
+        if (!$aRow)
+            return true;
+
+        $iIp = (int)($aRow['ip'] ?? 0);
+        $sMessages = trim((string)($aRow['messages'] ?? ''));
+        return $iIp <= 0 || $sMessages === '' || $sMessages === '[]';
+    }
+
+    public function countChatSessionsByIp($sIp, $sPrefix, $iWindowSec)
+    {
+        $sSince = date('Y-m-d H:i:s', time() - (int)$iWindowSec);
+        return (int)$this->getOne("
+            SELECT COUNT(*) FROM `sys_agents_chat_history`
+            WHERE `ip` = :ip
+              AND (`thread_id` = :prefix OR `thread_id` LIKE :prefix_like)
+              AND `created_at` >= :since
+        ", [
+            'ip' => $sIp,
+            'prefix' => $sPrefix,
+            'prefix_like' => $sPrefix . ':%',
+            'since' => $sSince,
+        ]);
+    }
+
+    public function getChatArtifactsByHistoryIds($aIds)
+    {
+        $aIds = array_values(array_unique(array_filter(array_map('intval', (array)$aIds))));
+        if (!$aIds)
+            return [];
+
+        $sIn = implode(',', $aIds);
+        $aRows = $this->getAll("
+            SELECT `history_id`, `field_name`, `field_value`
+            FROM `sys_agents_chat_artifacts`
+            WHERE `history_id` IN ($sIn)
+            ORDER BY `id` ASC
+        ");
+        return $this->mapChatArtifactRows($aRows);
+    }
+
+    public function getChatArtifactsByThreadId($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '')
+            return [];
+
+        $aRows = $this->getAll("
+            SELECT `a`.`history_id`, `a`.`field_name`, `a`.`field_value`
+            FROM `sys_agents_chat_artifacts` AS `a`
+            INNER JOIN `sys_agents_chat_history` AS `h` ON `h`.`id` = `a`.`history_id`
+            WHERE `h`.`thread_id` = :t
+            ORDER BY `a`.`id` ASC
+        ", [
+            't' => $sThreadId,
+        ]);
+        $aMap = $this->mapChatArtifactRows($aRows);
+        if (!$aMap)
+            return [];
+        return (array)reset($aMap);
+    }
+
+    public function getChatArtifactsForAgent($aAgent)
+    {
+        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        if ($sExact === ':')
+            return [];
+
+        $aRows = $this->getAll("
+            SELECT `a`.`history_id`, `a`.`field_name`, `a`.`field_value`
+            FROM `sys_agents_chat_artifacts` AS `a`
+            INNER JOIN `sys_agents_chat_history` AS `h` ON `h`.`id` = `a`.`history_id`
+            WHERE `h`.`thread_id` = :exact OR `h`.`thread_id` LIKE :prefix
+            ORDER BY `a`.`id` ASC
+        ", [
+            'exact' => $sExact,
+            'prefix' => $sExact . ':%',
+        ]);
+        return $this->mapChatArtifactRows($aRows);
+    }
+
+    protected function mapChatArtifactRows($aRows)
+    {
+        if (!is_array($aRows) || !$aRows)
+            return [];
+
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            if (!is_array($aRow))
+                continue;
+            $iHistoryId = (int)($aRow['history_id'] ?? 0);
+            $sName = trim((string)($aRow['field_name'] ?? ''));
+            if ($iHistoryId <= 0 || $sName === '')
+                continue;
+            $aOut[$iHistoryId][] = [
+                'field_name' => $sName,
+                'field_value' => (string)($aRow['field_value'] ?? ''),
+            ];
+        }
+        return $aOut;
+    }
+
+    public function getExpiredAgentChatHistoryIds($aAgent, $iTtlMin)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            return [];
+
+        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $iTtlMin = (int)$iTtlMin;
+        if ($sExact === ':' || $iTtlMin <= 0)
+            return [];
+
+        return $this->getColumn("
+            SELECT `id` FROM `sys_agents_chat_history`
+            WHERE (`thread_id` = :exact OR `thread_id` LIKE :prefix)
+              AND `messages` IS NOT NULL AND `messages` != '' AND `messages` != '[]'
+              AND (`closed_reason` = '' OR `closed_reason` IS NULL)
+              AND `updated_at` <= DATE_SUB(NOW(), INTERVAL :ttl MINUTE)
+        ", [
+            'exact' => $sExact,
+            'prefix' => $sExact . ':%',
+            'ttl' => $iTtlMin,
+        ]);
+    }
+
+    /**
+     * Rename this agent's guest threads (`…:{sessionId}` or `…:{sessionId}#{chat}`) onto a profile id.
+     * If the member thread already has messages, drop the guest copy.
+     */
+    public function adoptGuestChatHistory($aAgent, $sSessionId, $iProfileId)
+    {
+        $sSessionId = (string)$sSessionId;
+        $iProfileId = (int)$iProfileId;
+        if ($sSessionId === '' || !$iProfileId || empty($aAgent['id']))
+            return 0;
+
+        $sPrefix = $aAgent['trigger'] . ':' . $aAgent['id'] . ':';
+        $aRows = $this->getAll("SELECT `id`, `thread_id`, `messages` FROM `sys_agents_chat_history` WHERE `thread_id` LIKE :p", [
+            'p' => $sPrefix . '%',
+        ]);
+        if (!is_array($aRows) || !$aRows)
+            return 0;
+
+        $iAffected = 0;
+        foreach ($aRows as $aRow) {
+            $sThreadId = (string)($aRow['thread_id'] ?? '');
+            $aParsed = BxDolAiChat::parseThreadId($aAgent, $sThreadId);
+            if (!$aParsed)
+                continue;
+
+            $sSub = (string)$aParsed['chat_history_subindex'];
+            $sOwner = BxDolAiChat::chatHistoryOwnerFromSubindex($sSub);
+            if ($sOwner !== $sSessionId)
+                continue;
+
+            $sConv = strlen($sSub) > strlen($sOwner) ? substr($sSub, strlen($sOwner)) : '';
+            $sNewThread = BxDolAiChat::threadId($aAgent, [
+                'chat_history_context_pid' => (int)$aParsed['chat_history_context_pid'],
+                'chat_history_subindex' => $iProfileId . $sConv,
+            ]);
+            if ($sNewThread === '' || $sNewThread === $sThreadId)
+                continue;
+
+            $aMember = $this->getRow("SELECT `id`, `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+                't' => $sNewThread,
+            ]);
+            $sGuestMsg = trim((string)($aRow['messages'] ?? ''));
+            $bGuestHas = $sGuestMsg !== '' && $sGuestMsg !== '[]';
+            $sMemberMsg = is_array($aMember) ? trim((string)($aMember['messages'] ?? '')) : '';
+            $bMemberHas = $sMemberMsg !== '' && $sMemberMsg !== '[]';
+
+            if ($bMemberHas) {
+                $this->query("DELETE FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => (int)$aRow['id']]);
+                continue;
+            }
+            if (is_array($aMember) && (int)($aMember['id'] ?? 0) > 0)
+                $this->query("DELETE FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => (int)$aMember['id']]);
+            if (!$bGuestHas && is_array($aMember))
+                continue;
+
+            $iAffected += (int)$this->query("UPDATE `sys_agents_chat_history` SET `thread_id` = :n WHERE `id` = :id", [
+                'n' => $sNewThread,
+                'id' => (int)$aRow['id'],
+            ]);
+        }
+
+        return $iAffected;
+    }
+
+    static public function getAlert($s) {
+        if (!$s)
+            return false;        
+        $a = explode(':', $s);
+        $oDb = BxDolDb::getInstance();
+        return $oDb->getRow("SELECT * FROM `sys_alerts_log` WHERE `unit` = :unit AND `action` = :action", [
+            'unit' => $a[0] ?? '',
+            'action' => $a[1] ?? '',
+        ]);
+    }
+
+    public function getAlerts()
+    {
+        $aValues = [];
+        $aAlerts = $this->getAll("SELECT `unit`, `action`, `counter_24h`, `counter_per_request` FROM `sys_alerts_log` ORDER BY `unit`, `action`");
+        foreach ($aAlerts as $a) {
+            $sKey = $a['unit'] . ':' . $a['action'];
+            $aValues[$sKey] = [
+                'key' => $sKey,
+                'unit' => $a['unit'],
+                'action' => $a['action'],
+                'name' => $a['unit'] . ' - ' . $a['action'],
+                'desc' => $this->getAlertDesc($a['unit'] . ':' . $a['action']),
+                'counter_24h' => $a['counter_24h'],
+                'counter_per_request' => $a['counter_per_request'],
+            ];
+        }
+        return $aValues;
+    }
+
+    public function getFormDisplay($sObject)
+    {
+        $aDisplays = $this->getColumn("SELECT `display_name` FROM `sys_form_displays` WHERE `object` = :object", ['object' => $sObject]);
+        return $aDisplays ? current($aDisplays) : null;
+    }
+
+    public function getFormObjects()
+    {
+        $aValues = [];
+        $aForms = $this->getAll("SELECT `f`.`object`, `f`.`title`, `f`.`module`, `m`.`title` AS `module_title` FROM `sys_objects_form` AS f INNER JOIN `sys_modules` as `m` ON `m`.`name` = `f`.`module` ORDER BY `f`.`module`, `f`.`object` ASC");
+        foreach ($aForms as $r) {
+            if ('system' != $r['module']) {
+                $oModule = BxDolModule::getInstance($r['module']);
+                if (!$oModule || !$oModule->isEnabled())
+                    continue;
+            }
+            
+            $sDisplay = $this->getFormDisplay($r['object']);
+            if (!$sDisplay) 
+                continue;
+            $oForm = BxDolForm::getObjectInstance($r['object'], $sDisplay);
+            $sFormId = $oForm->getId();
+            $aValues[$r['object']] = [
+                'form_id' => $sFormId,
+                'form_object' => $r['object'],
+                'module' => $r['module'],
+                'module_title' => $r['module_title'],
+                'form_title' => _t($r['title']),
+            ];
+        }
+        return $aValues;
+    }
+
+    static public function getAlertDesc($sAlert) 
+    {
+        if (!$sAlert)
+            return '';
+        $oDb = BxDolDb::getInstance();
+        [$sUnit, $sAction] = explode(':', $sAlert);
+        $sDesc = $oDb->getOne("SELECT `description` FROM `sys_alerts_desc` WHERE `unit` = :unit AND `action` = :action LIMIT 1", [
+            'unit' => $sUnit,
+            'action' => $sAction,
+        ]);
+        if (!$sDesc) {
+            $sDesc = $oDb->getOne("SELECT `description` FROM `sys_alerts_desc` WHERE `unit` LIKE '{%}' AND `action` = :action LIMIT 1", [
+                'action' => $sAction,
+            ]);
+        }
+        return $sDesc;
+    }
+}
+
+/** @} */

+ 20 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiRetrievalNone.php

@@ -0,0 +1,20 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Retrieval for an agent that has no knowledge store.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiRetrievalNone implements NeuronAI\RAG\Retrieval\RetrievalInterface
+{
+    public function retrieve(NeuronAI\Chat\Messages\Message $query): array
+    {
+        return [];
+    }
+}
+
+/** @} */

+ 110 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiRetrievalUnaProxy.php

@@ -0,0 +1,110 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * Retrieval that asks the UNA master to embed and search its knowledge store.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiRetrievalUnaProxy implements NeuronAI\RAG\Retrieval\RetrievalInterface
+{
+    public const URL = 'https://api.unacms.com/m/ai_proxy/v1/knowledge/search';
+
+    /**
+     * @param callable(string, array): array{code:int, body:string}|null $fPost
+     */
+    public function __construct(
+        protected string $sKey,
+        protected string $sSecret,
+        protected $fPost = null,
+    ) {
+    }
+
+    public function retrieve(NeuronAI\Chat\Messages\Message $query): array
+    {
+        $sQuery = trim((string)$query->getContent());
+        if ($sQuery === '' || $this->sKey === '' || $this->sSecret === '')
+            return [];
+
+        $sBody = BxDolAIUnaProxyHttpClient::encodeBody(['query' => $sQuery]);
+        $aSigned = (new BxDolAIUnaProxyHttpClient($this->sKey, $this->sSecret))->headersForBody($sBody);
+        $aHeaders = [
+            'Content-Type: application/json',
+            'X-Una-Key: ' . $aSigned['X-Una-Key'],
+            'X-Una-Sign: ' . $aSigned['X-Una-Sign'],
+        ];
+
+        try {
+            $aResponse = $this->post($sBody, $aHeaders);
+        }
+        catch (Throwable $oException) {
+            bx_log('sys_agents', 'Remote knowledge search failed: ' . $oException->getMessage(), BX_LOG_ERR);
+            return [];
+        }
+
+        if (!is_array($aResponse) || (int)($aResponse['code'] ?? -1) !== 200) {
+            bx_log('sys_agents', 'Remote knowledge search failed, exit code: ' . ($aResponse['code'] ?? -1), BX_LOG_ERR);
+            return [];
+        }
+
+        return $this->documentsFrom((string)($aResponse['body'] ?? ''));
+    }
+
+    /**
+     * @param array<int, string> $aHeaders
+     * @return array{code:int, body:string}
+     */
+    protected function post(string $sBody, array $aHeaders): array
+    {
+        if ($this->fPost !== null)
+            return ($this->fPost)($sBody, $aHeaders);
+
+        $sHttpCode = 0;
+        $sResult = bx_file_get_contents(self::URL, $sBody, 'post-raw', $aHeaders, $sHttpCode, [], 15);
+        if ($sResult === false)
+            $sResult = '';
+
+        return [
+            'code' => (int)$sHttpCode,
+            'body' => (string)$sResult,
+        ];
+    }
+
+    /**
+     * @return NeuronAI\RAG\Document[]
+     */
+    protected function documentsFrom(string $sBody): array
+    {
+        $aBody = json_decode($sBody, true);
+        if (!is_array($aBody) || !isset($aBody['documents']) || !is_array($aBody['documents']))
+            return [];
+
+        $aDocuments = [];
+        foreach ($aBody['documents'] as $aDocument) {
+            if (!is_array($aDocument))
+                continue;
+
+            $sContent = trim((string)($aDocument['content'] ?? ''));
+            if ($sContent === '')
+                continue;
+
+            $oDocument = new NeuronAI\RAG\Document($sContent);
+            $sSourceType = trim((string)($aDocument['sourceType'] ?? ''));
+            $sSourceName = trim((string)($aDocument['sourceName'] ?? ''));
+            if ($sSourceType !== '')
+                $oDocument->sourceType = $sSourceType;
+            if ($sSourceName !== '')
+                $oDocument->sourceName = $sSourceName;
+            if (isset($aDocument['score']))
+                $oDocument->setScore((float)$aDocument['score']);
+            $aDocuments[] = $oDocument;
+        }
+
+        return $aDocuments;
+    }
+}
+
+/** @} */

+ 863 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/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
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/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
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/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';
+    }
+}
+
+/** @} */

+ 213 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiTrigger.php

@@ -0,0 +1,213 @@
+<?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
+ * @{
+ */
+
+interface iBxDolAiTrigger
+{
+    public function getType();
+
+    /**
+     * Host entry point (alert, cron, webhook, HTTP).
+     * @param mixed $mixed host-specific context
+     */
+    public function handle($mixed = null);
+
+    /**
+     * Run one agent. Used by handle() and background jobs.
+     */
+    public function call($aAgent, $mixedParams = []);
+}
+
+class BxDolAiTrigger extends BxDol implements iBxDolAiTrigger
+{
+    protected $_sType = '';
+
+    public static function getInstance($sType)
+    {
+        if ('chat' === $sType)
+            $sType = 'manual';
+
+        $aMap = [
+            'alert' => 'BxDolAiTriggerAlert',
+            'message' => 'BxDolAiTriggerMessage',
+            'webhook' => 'BxDolAiTriggerWebhook',
+            'scheduler' => 'BxDolAiTriggerScheduler',
+            'form-input' => 'BxDolAiTriggerFormInput',
+            'manual' => 'BxDolAiTriggerChat',
+        ];
+
+        $sClass = $aMap[$sType] ?? 'BxDolAiTrigger';
+        $sKey = __CLASS__ . '_' . $sType;
+        if (!isset($GLOBALS['bxDolClasses'][$sKey])) {
+            $o = new $sClass();
+            if ('BxDolAiTrigger' === $sClass)
+                $o->_sType = $sType;
+            $GLOBALS['bxDolClasses'][$sKey] = $o;
+        }
+
+        return $GLOBALS['bxDolClasses'][$sKey];
+    }
+
+    public function getType()
+    {
+        return $this->_sType;
+    }
+
+    public function handle($mixed = null)
+    {
+        return false;
+    }
+
+    public function call($aAgent, $mixedParams = [])
+    {
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return false;
+
+        if ($mixedParams)
+            $sParams = is_string($mixedParams) ? $mixedParams : json_encode($mixedParams);
+        else
+            $sParams = 'START';
+
+        $sSampleField = $this->getSampleField();
+        if ($sSampleField && empty($aAgent[$sSampleField])) {
+            $oDb = new BxDolAiQuery();
+            $oDb->updateAgentField($aAgent['id'], $sSampleField, $sParams);
+        }
+
+        $aParams = $this->getCallChatHistoryParams($aAgent, $mixedParams);
+        $sParams = $this->limitCallInput($aAgent, $mixedParams, $sParams);
+
+        $oLimits = BxDolAiChatLimits::getInstance();
+        $oChat = BxDolAiChat::getInstance();
+        if ($this->appliesChatLimits()) {
+            if ($oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount($aAgent['id'], $aParams))) {
+                $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
+                return $this->getChatLimitReply($aAgent);
+            }
+
+            if ($oLimits->isChatSessionRateLimited($aAgent, $aParams))
+                throw new Exception($oLimits->getChatSessionRateLimitError());
+        }
+
+        $oChat->setChatContext($aAgent, $aParams);
+
+        $mixed = '';
+        try {
+            $o = BxDolAi::getAgentInstance($aAgent['id'], $aParams);
+            if (!$o)
+                return false;
+
+            $oMessage = $o->chat(new NeuronAI\Chat\Messages\UserMessage($sParams))->getMessage();
+            $mixed = $oMessage->getContent();
+        } catch (Exception $exception) {
+            bx_log('sys_agents', "Exception in '{$aAgent['name']}' agent: " . $exception->getMessage() . " INPUT:" . $sParams, BX_LOG_ERR);
+            $mixed = isAdmin() ? self::visibleError($exception) : _t('_sys_agents_exception');
+        }
+
+        return $mixed;
+    }
+
+    /**
+     * Provider failures keep their full text in the log. Operators see the
+     * provider's own message, such as an authentication error.
+     */
+    public static function visibleError(Throwable $oException): string
+    {
+        $s = trim($oException->getMessage());
+        if ($s === '')
+            return _t('_sys_agents_exception');
+
+        $iJson = bx_mb_strpos($s, '{');
+        if ($iJson !== false) {
+            $sJson = get_mb_substr($s, $iJson, get_mb_len($s) - $iJson);
+            $a = json_decode($sJson, true);
+            if (is_array($a)) {
+                $sMessage = trim((string)($a['error']['message'] ?? ''));
+                if ($sMessage !== '')
+                    return $sMessage;
+            }
+        }
+
+        return $s;
+    }
+
+    protected function getAi()
+    {
+        return BxDolAi::getInstance();
+    }
+
+    protected function getSampleField()
+    {
+        return '';
+    }
+
+    protected function usesSenderChatHistory()
+    {
+        return false;
+    }
+
+    protected function appliesChatLimits()
+    {
+        return false;
+    }
+
+    /**
+     * What call() returns when the thread is at max_turns; null = reply with nothing.
+     */
+    protected function getChatLimitReply($aAgent)
+    {
+        return BxDolAiChatLimits::getInstance()->getChatLimitMessage($aAgent);
+    }
+
+    protected function getCallChatHistoryParams($aAgent, $mixedParams)
+    {
+        if (!$this->usesSenderChatHistory() || !is_array($mixedParams))
+            return [];
+
+        return ['chat_history_subindex' => (int)($mixedParams['sender_profile_id'] ?? 0)];
+    }
+
+    protected function limitCallInput($aAgent, $mixedParams, $sParams)
+    {
+        if (!$this->appliesChatLimits())
+            return $sParams;
+
+        $oLimits = BxDolAiChatLimits::getInstance();
+        if (is_array($mixedParams) && isset($mixedParams['message_text'])) {
+            $mixedParams['message_text'] = $oLimits->applyChatInputLimit((string)$mixedParams['message_text'], $aAgent);
+            return json_encode($mixedParams);
+        }
+
+        if (is_string($mixedParams))
+            return $oLimits->applyChatInputLimit($sParams, $aAgent);
+
+        return $sParams;
+    }
+
+    protected function enqueue($aAgent, $mixedParams)
+    {
+        BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
+            'system', 'call_agent',
+            [$this->getType(), $aAgent, $mixedParams],
+            'TemplServices'
+        ]);
+    }
+
+    protected function runOrEnqueue($aAgent, $mixedParams)
+    {
+        if (!empty($aAgent['async'])) {
+            $this->enqueue($aAgent, $mixedParams);
+            return null;
+        }
+
+        return $this->call($aAgent, $mixedParams);
+    }
+}
+
+/** @} */

+ 764 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiTriggerChat.php

@@ -0,0 +1,764 @@
+<?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
+ * @{
+ */
+
+class BxDolAiTriggerChat extends BxDolAiTrigger
+{
+    protected $_sType = 'manual';
+
+    protected function appliesChatLimits()
+    {
+        return true;
+    }
+
+    /**
+     * HTTP/SSE only. GET hydrates transcript; POST streams (or falls back to call()).
+     */
+    public function handle($mixed = null)
+    {
+        $iAgentId = (int)$mixed;
+        $sMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '';
+        if ('GET' === $sMethod)
+            return $this->hydrate($iAgentId);
+
+        if ('POST' !== $sMethod)
+            return echoJson(['code' => 405, 'msg' => _t('_error occured')]);
+
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return echoJson(['code' => $iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found')]);
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception')]);
+
+        if (!$oAi->canChatDirectly($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $sJson = file_get_contents('php://input');
+        $aData = json_decode($sJson, true);
+        if (json_last_error() !== JSON_ERROR_NONE)
+            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $aTurn = $this->extractUserTurnFromRequest($aData);
+        $sPrompt = BxDolAiChatLimits::getInstance()->applyChatInputLimit($aTurn['text'], $aAgent);
+        $oMessage = BxDolAiChatImages::getInstance()->makeUserMessage($sPrompt, $aTurn['images']);
+        if (!$oMessage)
+            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $aParams['request_user_turns'] = BxDolAiChatLimits::getInstance()->countChatUserTurns($aData['messages'] ?? []);
+
+        $sThreadId = !empty($aData['threadId']) ? $aData['threadId'] : null;
+
+        if (class_exists('NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter')) {
+            $this->stream($aAgent['id'], $oMessage, $aParams, $sThreadId);
+            return;
+        }
+
+        try {
+            $sReply = $this->call($aAgent, $sPrompt !== '' ? $sPrompt : ' ');
+        } catch (Throwable $o) {
+            return echoJson(['code' => 500, 'msg' => $o->getMessage(), 'messages' => []]);
+        }
+        return echoJson(['code' => 200, 'msg' => $sReply, 'text' => $sReply]);
+    }
+
+    /**
+     * Composer image upload (`system/ai_chat_upload`): one `$_FILES['file']` into the chat images storage.
+     *
+     * @return array{file_id:int,url:string,mime:string}|array{error:string,code?:int}
+     */
+    public function upload($iAgentId)
+    {
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => (int)$iAgentId ? 404 : 400];
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canChatDirectly($aAgent))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $aFile = $_FILES['file'] ?? null;
+        if (!is_array($aFile))
+            return ['error' => 'No file'];
+
+        $aStored = BxDolAiChatImages::getInstance()->storeUpload($aFile, (int)bx_get_logged_profile_id());
+        if (!empty($aStored['error']))
+            return ['error' => $aStored['error']];
+
+        return $aStored;
+    }
+
+    /**
+     * The caller's own conversations with an agent (`system/get_ai_chat_threads`).
+     *
+     * @return array{threads:array}|array{error:string,code:int}
+     */
+    public function listThreads($iAgentId)
+    {
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => (int)$iAgentId ? 404 : 400];
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canChatDirectly($aAgent))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        return ['threads' => BxDolAiChat::getInstance()->listOwnAgentChatThreads($aAgent)];
+    }
+
+    /**
+     * One of the caller's earlier conversations, read-only (`system/get_ai_chat_thread`):
+     * the transcript in the same UI message shape hydrate returns. Does not open or
+     * close anything — which thread is current is left as it was.
+     *
+     * @return array{messages:array,status:string,closed_reason:string,artifacts:array}|array{error:string,code:int}
+     */
+    public function getThread($iAgentId, $sThreadId = '')
+    {
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => (int)$iAgentId ? 404 : 400];
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canChatDirectly($aAgent))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $oChat = BxDolAiChat::getInstance();
+        $sThreadId = trim((string)$sThreadId);
+        if ($sThreadId === '' || !$oChat->isOwnAgentChatThread($aAgent, $sThreadId))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $aMessages = $oChat->getChatHistoryUiMessagesByThread($aAgent, $sThreadId);
+        if ($aMessages === false)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $sReason = trim((string)$oChat->getChatHistoryClosedReasonByThread($aAgent, $sThreadId));
+        return [
+            'messages' => is_array($aMessages) ? $aMessages : [],
+            'status' => $sReason !== '' ? 'closed' : 'opened',
+            'closed_reason' => $sReason,
+            'artifacts' => $oChat->getChatHistoryArtifactsByThread($aAgent, $sThreadId),
+        ];
+    }
+
+    public function extractPromptFromRequest($aData)
+    {
+        $aTurn = $this->extractUserTurnFromRequest($aData);
+        return $aTurn['text'];
+    }
+
+    /**
+     * Last user turn: text + image URLs from AG-UI `content` / `parts`.
+     *
+     * @return array{text:string,images:array<int,array{url:string,mime:string}>}
+     */
+    public function extractUserTurnFromRequest($aData)
+    {
+        $aEmpty = ['text' => '', 'images' => []];
+        if (!is_array($aData))
+            return $aEmpty;
+
+        $sPrompt = (!empty($aData['prompt']) && is_string($aData['prompt'])) ? trim($aData['prompt']) : '';
+
+        if (empty($aData['messages']) || !is_array($aData['messages']))
+            return $sPrompt !== '' ? ['text' => $sPrompt, 'images' => []] : $aEmpty;
+
+        $oImages = BxDolAiChatImages::getInstance();
+        for ($i = count($aData['messages']) - 1; $i >= 0; $i--) {
+            $aMessage = $aData['messages'][$i];
+            if (!is_array($aMessage) || ($aMessage['role'] ?? '') !== 'user')
+                continue;
+
+            $aParts = [];
+            if (!empty($aMessage['parts']) && is_array($aMessage['parts']))
+                $aParts = array_merge($aParts, $aMessage['parts']);
+            if (isset($aMessage['content']) && is_array($aMessage['content']))
+                $aParts = array_merge($aParts, $aMessage['content']);
+
+            $aText = [];
+            $aImages = [];
+            foreach ($aParts as $aPart) {
+                if (!is_array($aPart))
+                    continue;
+                $sType = strtolower((string)($aPart['type'] ?? ''));
+                if ($sType === 'text') {
+                    $s = trim((string)($aPart['content'] ?? $aPart['text'] ?? ''));
+                    if ($s !== '')
+                        $aText[] = $s;
+                    continue;
+                }
+                $aImage = $oImages->parsePart($aPart);
+                if ($aImage)
+                    $aImages[] = $aImage;
+            }
+
+            if (!$aParts && isset($aMessage['content']) && is_string($aMessage['content']))
+                $aText[] = trim($aMessage['content']);
+
+            $sText = trim(implode("\n", $aText));
+            if ($sText === '' && $sPrompt !== '')
+                $sText = $sPrompt;
+
+            return [
+                'text' => $sText,
+                'images' => array_slice($aImages, 0, BxDolAiChatImages::MAX_PER_TURN),
+            ];
+        }
+
+        return $aEmpty;
+    }
+
+    /**
+     * @param string|NeuronAI\Chat\Messages\UserMessage $mixedPrompt plain text, or a ready user message (text + images)
+     */
+    public function stream($iAgentId, $mixedPrompt, $aParams = [], $sThreadId = null)
+    {
+        $oChat = BxDolAiChat::getInstance();
+        $oLimits = BxDolAiChatLimits::getInstance();
+        if (!isset($aParams['chat_history_subindex']))
+            $aParams = array_merge($oChat->resolveChatHistoryParams($iAgentId), $aParams);
+
+        // Staging header.inc.php dumps HTML on fatals and warns on null error_get_last().
+        // Any HTML after SSE is parsed by TanStack as "unterminated trailing data".
+        @ini_set('display_errors', '0');
+        @ini_set('zlib.output_compression', '0');
+        @ini_set('implicit_flush', '1');
+        if (function_exists('apache_setenv'))
+            @apache_setenv('no-gzip', '1');
+        while (ob_get_level())
+            ob_end_clean();
+
+        // UNA BxDolDb::pdoExceptionHandler dumps HTML 503 on uncaught PDOException.
+        restore_exception_handler();
+
+        $oAdapter = new NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter($sThreadId);
+
+        http_response_code(200);
+        foreach ($oAdapter->getHeaders() as $sName => $sValue)
+            header($sName . ': ' . $sValue);
+
+        $fEmit = function ($sEvent) {
+            echo $sEvent;
+            if (ob_get_level())
+                ob_flush();
+            flush();
+        };
+
+        $aAgent = BxDolAiQuery::getAgentObject((int)$iAgentId);
+        if ($aAgent)
+            $oChat->setChatContext($aAgent, $aParams);
+
+        if ($aAgent && $oLimits->isChatSessionRateLimited($aAgent, $aParams)) {
+            $this->emitErrorSse($oAdapter, $fEmit, $oLimits->getChatSessionRateLimitError());
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        if ($aAgent)
+            $oChat->openPartitionedChatThread($aAgent, $aParams);
+
+        $iRequestTurns = (int)($aParams['request_user_turns'] ?? 0);
+        if ($aAgent && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount((int)$iAgentId, $aParams), $iRequestTurns)) {
+            $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
+            $this->emitLimitSse($oAdapter, $fEmit, $oLimits->getChatLimitMessage($aAgent));
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        $oUserMessage = $mixedPrompt instanceof NeuronAI\Chat\Messages\UserMessage
+            ? $mixedPrompt
+            : BxDolAiChatImages::getInstance()->makeUserMessage($oLimits->applyChatInputLimit((string)$mixedPrompt, $aAgent ?: []), []);
+        $sPrompt = $oUserMessage ? (string)$oUserMessage->getContent() : '';
+
+        $oChat->resetPendingChatActions();
+        $bStarted = false;
+        $aSseState = $this->resetActionsSseState();
+        $o = null;
+        try {
+            if (!$oUserMessage)
+                throw new Exception('Empty chat message');
+            $o = BxDolAi::getAgentInstance((int)$iAgentId, $aParams);
+            $oHandler = $o->stream($oUserMessage);
+
+            foreach ($oHandler->events($oAdapter) as $sChunk) {
+                $bStarted = true;
+                foreach ($this->splitSseEvents($sChunk) as $sEvent) {
+                    $aPayload = $this->parseSseEvent($sEvent);
+                    $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
+                    // Buttons queued by tools must reach the client before RUN_FINISHED.
+                    if ($sType === 'RUN_FINISHED')
+                        $this->finishActionsSse($o, $aSseState, $fEmit);
+                    $this->processActionsSseEvent($sEvent, $aSseState, $fEmit);
+                }
+            }
+
+            $this->flushActionsSseState($aSseState, $fEmit);
+            if (empty($aSseState['customEmitted']))
+                $this->finishActionsSse($o, $aSseState, $fEmit);
+
+            if ($aAgent && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount((int)$iAgentId, $aParams)))
+                $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
+        } catch (Throwable $oException) {
+            bx_log('sys_agents', "Stream exception for agent {$iAgentId}: " . $oException->getMessage() . " INPUT:" . $sPrompt);
+            $this->flushActionsSseState($aSseState, $fEmit);
+            if (!$bStarted) {
+                foreach ($oAdapter->start() as $sEvent)
+                    $fEmit($sEvent);
+            }
+            $sMsg = isAdmin() ? self::visibleError($oException) : $oException->getMessage();
+            $fEmit('data: ' . json_encode(['type' => 'RUN_ERROR', 'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception')]) . "\n\n");
+            foreach ($oAdapter->end() as $sEvent)
+                $fEmit($sEvent);
+        }
+
+        error_clear_last();
+        @ini_set('display_errors', '0');
+        exit;
+    }
+
+    protected function hydrate($iAgentId)
+    {
+        if (false !== bx_get('offset'))
+            return echoJson(['code' => 405, 'msg' => _t('_error occured'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        if (!$oAi->canChatDirectly($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $oChat = BxDolAiChat::getInstance();
+        $oChat->openPartitionedChatThread($aAgent, $aParams);
+
+        try {
+            $aMessages = $oChat->getChatHistoryUiMessages($aAgent['id'], $aParams);
+        } catch (Throwable $o) {
+            $aMessages = [];
+        }
+
+        if (!is_array($aMessages))
+            $aMessages = [];
+
+        return echoJson([
+            'messages' => $aMessages,
+            'activeRun' => null,
+            'interrupts' => null,
+        ]);
+    }
+
+    /**
+     * Member/guest subindex + optional `?context=` (group/org profile id).
+     * Invalid context is 403 — never fall back to the site-wide thread.
+     * Without `?chat=`, follow the owner's current open thread (see BxDolAiChat::resolveCurrentChatThread).
+     */
+    protected function historyParams($aAgent)
+    {
+        $oChat = BxDolAiChat::getInstance();
+        $mixedContext = $oChat->resolveChatHistoryContextPid();
+        if ($mixedContext === false)
+            return false;
+
+        $aParams = $oChat->resolveChatHistoryParams((int)$aAgent['id']);
+        $aParams['chat_history_context_pid'] = (int)$mixedContext;
+        return $oChat->resolveCurrentChatThread($aAgent, $aParams);
+    }
+
+    protected function loadAgent($iAgentId)
+    {
+        $iAgentId = (int)$iAgentId;
+        if (!$iAgentId)
+            return false;
+
+        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
+        if (!$aAgent || empty($aAgent['active']))
+            return false;
+
+        return $aAgent;
+    }
+
+    protected function sseEvent($aPayload)
+    {
+        return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
+    }
+
+    /**
+     * One adapter chunk may carry several SSE frames (`data: …\n\n` ×N). Split so each is processed on its own.
+     *
+     * @return string[]
+     */
+    protected function splitSseEvents($sChunk)
+    {
+        $s = trim(str_replace(["\r\n", "\r"], "\n", (string)$sChunk));
+        if ($s === '')
+            return [$sChunk];
+
+        $aOut = [];
+        foreach (preg_split('/\n\s*\n/', $s) as $sFrame) {
+            $sFrame = trim($sFrame);
+            if ($sFrame !== '')
+                $aOut[] = $sFrame . "\n\n";
+        }
+        return $aOut ?: [$sChunk];
+    }
+
+    protected function parseSseEvent($sEvent)
+    {
+        $s = str_replace(["\r\n", "\r"], "\n", (string)$sEvent);
+        $aLast = null;
+        foreach (preg_split('/\n\s*\n/', trim($s)) as $sFrame) {
+            $aData = [];
+            foreach (explode("\n", $sFrame) as $sLine) {
+                if (stripos($sLine, 'data:') === 0)
+                    $aData[] = ltrim(substr($sLine, 5));
+            }
+            if (!$aData)
+                continue;
+            $a = json_decode(implode("\n", $aData), true);
+            if (is_array($a))
+                $aLast = $a;
+        }
+        return $aLast;
+    }
+
+    /**
+     * `lastMessageId` / `lastText` remember the assistant message just finished (so
+     * buttons queued by a tool can be attached to it); `customEmitted` — the
+     * `chat_actions` event went out for this turn already.
+     */
+    protected function resetActionsSseState($aKeep = [])
+    {
+        return array_merge([
+            'mode' => 'undecided',
+            'buf' => '',
+            'messageId' => '',
+            'held' => [],
+            'lastMessageId' => '',
+            'lastText' => '',
+            'customEmitted' => false,
+        ], $aKeep);
+    }
+
+    protected function rememberActionsSseTurn(&$aState, $sMessageId = '', $sText = '', $bCustom = null)
+    {
+        $aState = $this->resetActionsSseState([
+            'lastMessageId' => $sMessageId !== '' ? $sMessageId : (string)($aState['lastMessageId'] ?? ''),
+            'lastText' => $sText !== '' ? $sText : (string)($aState['lastText'] ?? ''),
+            'customEmitted' => $bCustom === null ? !empty($aState['customEmitted']) : (bool)$bCustom,
+        ]);
+    }
+
+    protected function emitHeldActionsSse(&$aState, $fEmit)
+    {
+        foreach ($aState['held'] as $sHeld)
+            $fEmit($sHeld);
+        $aState['held'] = [];
+    }
+
+    protected function emitParsedActionsSse(&$aState, $fEmit, $aParsed)
+    {
+        $sId = $aState['messageId'] !== '' ? $aState['messageId'] : ('msg_' . uniqid());
+        $fEmit($this->sseEvent([
+            'type' => 'TEXT_MESSAGE_START',
+            'messageId' => $sId,
+            'role' => 'assistant',
+        ]));
+        if ($aParsed['content'] !== '') {
+            $fEmit($this->sseEvent([
+                'type' => 'TEXT_MESSAGE_CONTENT',
+                'messageId' => $sId,
+                'delta' => $aParsed['content'],
+            ]));
+        }
+        $fEmit($this->sseEvent([
+            'type' => 'TEXT_MESSAGE_END',
+            'messageId' => $sId,
+        ]));
+        $bCustom = false;
+        if (!empty($aParsed['actions']))
+            $bCustom = $this->emitActionsCustomSse($sId, $aParsed['actions'], $fEmit);
+
+        $aState = $this->resetActionsSseState([
+            'lastMessageId' => $sId,
+            'lastText' => (string)($aParsed['content'] ?? ''),
+            'customEmitted' => !empty($aState['customEmitted']) || $bCustom,
+        ]);
+        if (empty($aState['customEmitted']))
+            $this->emitPendingActionsSse($aState, $fEmit, $aParsed['actions'] ?? []);
+    }
+
+    /**
+     * `chat_actions` custom event: `messageId` + `value` (the buttons) — what the App chat widget reads.
+     */
+    protected function emitActionsCustomSse($sMessageId, $aActions, $fEmit)
+    {
+        $aActions = BxDolAiChatUi::getInstance()->sanitizeChatActions($aActions);
+        if (!$aActions)
+            return false;
+
+        $fEmit($this->sseEvent([
+            'type' => 'CUSTOM',
+            'name' => 'chat_actions',
+            'messageId' => (string)$sMessageId,
+            'value' => $aActions,
+        ]));
+        return true;
+    }
+
+    /**
+     * Buttons queued by the `chat_buttons` tool (plus `$aExtra`), once per turn,
+     * pinned to the last assistant message.
+     */
+    protected function emitPendingActionsSse(&$aState, $fEmit, $aExtra = [])
+    {
+        if (!empty($aState['customEmitted']))
+            return false;
+
+        $oChat = BxDolAiChat::getInstance();
+        $aActions = BxDolAiChatUi::getInstance()->mergeChatActions($oChat->getPendingChatActions(), $aExtra);
+        if (!$aActions)
+            return false;
+
+        $sMid = (string)($aState['lastMessageId'] ?? '');
+        if ($sMid === '')
+            $sMid = (string)($aState['messageId'] ?? '');
+
+        if (!$this->emitActionsCustomSse($sMid, $aActions, $fEmit))
+            return false;
+
+        $aState['customEmitted'] = true;
+        return true;
+    }
+
+    /**
+     * End of the model turn: flush held text, pin actions to the stored assistant
+     * message, emit whatever buttons are still pending.
+     */
+    protected function finishActionsSse($oAgent, &$aState, $fEmit)
+    {
+        $this->flushActionsSseState($aState, $fEmit);
+        $aDone = $this->persistAssistantActions($oAgent, (string)($aState['lastText'] ?? ''));
+        if (($aState['lastMessageId'] ?? '') === '' && !empty($aDone['messageId']))
+            $aState['lastMessageId'] = (string)$aDone['messageId'];
+        $this->emitPendingActionsSse($aState, $fEmit, $aDone['actions'] ?? []);
+    }
+
+    protected function processActionsSseEvent($sEvent, &$aState, $fEmit)
+    {
+        $oUi = BxDolAiChatUi::getInstance();
+        $aPayload = $this->parseSseEvent($sEvent);
+        $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
+
+        if ($sType === 'TEXT_MESSAGE_START') {
+            if ($aState['held'] || $aState['buf'] !== '')
+                $this->flushActionsSseState($aState, $fEmit);
+            $aState['messageId'] = (string)($aPayload['messageId'] ?? '');
+            $aState['held'][] = $sEvent;
+            return;
+        }
+
+        if ($sType === 'TEXT_MESSAGE_CONTENT') {
+            $sDelta = (string)($aPayload['delta'] ?? '');
+            $aState['buf'] .= $sDelta;
+            BxDolAiChat::getInstance()->markTurnText($sDelta);
+            if ($aState['mode'] === 'text') {
+                $fEmit($sEvent);
+                return;
+            }
+            if ($aState['mode'] === 'undecided') {
+                $mixed = $this->actionsStreamLooksLikeJson($aState['buf']);
+                if ($mixed === false) {
+                    $aState['mode'] = 'text';
+                    $this->emitHeldActionsSse($aState, $fEmit);
+                    $fEmit($sEvent);
+                    return;
+                }
+                if ($mixed === true)
+                    $aState['mode'] = 'json';
+            }
+            $aState['held'][] = $sEvent;
+            return;
+        }
+
+        if ($sType === 'TEXT_MESSAGE_END') {
+            $aParsed = $oUi->parseAssistantChatPayload($aState['buf']);
+            if ($aParsed && ($aState['mode'] === 'json' || $aState['mode'] === 'undecided')) {
+                $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
+                return;
+            }
+            $sId = (string)($aState['messageId'] ?? '');
+            $sText = (string)($aState['buf'] ?? '');
+            $this->emitHeldActionsSse($aState, $fEmit);
+            $fEmit($sEvent);
+            $this->rememberActionsSseTurn($aState, $sId, $sText);
+            $this->emitPendingActionsSse($aState, $fEmit);
+            return;
+        }
+
+        if ($sType === 'RUN_ERROR' || $sType === 'RUN_FINISHED') {
+            $this->flushActionsSseState($aState, $fEmit);
+            $fEmit($sEvent);
+            return;
+        }
+
+        // Tool call frames are internal: not forwarded, but they end the text so far.
+        if ($sType === 'TOOL_CALL_START' || $sType === 'TOOL_CALL_ARGS' || $sType === 'TOOL_CALL_END' || $sType === 'TOOL_CALL_RESULT') {
+            if ($aState['mode'] === 'json' || $aState['held'] || $aState['buf'] !== '')
+                $this->flushActionsSseState($aState, $fEmit);
+            return;
+        }
+
+        if ($aState['mode'] === 'json' || $aState['held'])
+            $this->flushActionsSseState($aState, $fEmit);
+
+        $fEmit($sEvent);
+    }
+
+    protected function actionsStreamLooksLikeJson($sBuf)
+    {
+        $s = ltrim((string)$sBuf);
+        if ($s === '')
+            return null;
+        $sFirst = $s[0];
+        if ($sFirst === '{' || $sFirst === '`')
+            return true;
+        return false;
+    }
+
+    protected function flushActionsSseState(&$aState, $fEmit)
+    {
+        if ($aState['buf'] === '' && !$aState['held'])
+            return;
+
+        $aParsed = BxDolAiChatUi::getInstance()->parseAssistantChatPayload($aState['buf']);
+        if ($aParsed && $aState['mode'] !== 'text') {
+            $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
+            return;
+        }
+
+        $sId = (string)($aState['messageId'] ?? '');
+        $sText = (string)($aState['buf'] ?? '');
+        $this->emitHeldActionsSse($aState, $fEmit);
+        $this->rememberActionsSseTurn($aState, $sId, $sText);
+    }
+
+    /**
+     * Pin this turn's actions (JSON payload and/or `chat_buttons` tool) to the last
+     * assistant message in history, so hydrate shows them again.
+     *
+     * @return array{messageId: string, actions: array}
+     */
+    protected function persistAssistantActions($oAgent, $sThisTurnText = '')
+    {
+        $oUi = BxDolAiChatUi::getInstance();
+        $aEmpty = ['messageId' => '', 'actions' => []];
+        $aPending = BxDolAiChat::getInstance()->takePendingChatActions();
+
+        if (!is_object($oAgent) || !method_exists($oAgent, 'getChatHistory'))
+            return ['messageId' => '', 'actions' => $aPending];
+
+        $oHistory = $oAgent->getChatHistory();
+        if (!is_object($oHistory) || !method_exists($oHistory, 'getMessages'))
+            return ['messageId' => '', 'actions' => $aPending];
+
+        $aMessages = $oHistory->getMessages();
+        for ($i = count($aMessages) - 1; $i >= 0; $i--) {
+            $oMessage = $aMessages[$i];
+            if ($oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage)
+                continue;
+
+            // When the model obeys "do not write another message after chat_buttons"
+            // the turn ends on the ToolCallMessage: pin the buttons to it, or they
+            // would be lost from history.
+            $sRole = $oMessage->getRole();
+            if ($sRole === 'model' || $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage)
+                $sRole = 'assistant';
+            if ($sRole !== 'assistant')
+                return ['messageId' => '', 'actions' => $aPending];
+
+            $sStored = (string)$oMessage->getContent();
+            $aParsed = $oUi->parseAssistantChatPayload($sStored);
+            if (!$aParsed && $sThisTurnText !== '')
+                $aParsed = $oUi->parseAssistantChatPayload($sThisTurnText);
+
+            $aActions = $oUi->mergeChatActions($aPending, is_array($aParsed) ? ($aParsed['actions'] ?? []) : []);
+            if (!$aActions)
+                return $aEmpty;
+
+            if ($aParsed && method_exists($oMessage, 'setContents'))
+                $oMessage->setContents($aParsed['content']);
+            if (method_exists($oMessage, 'addMetadata'))
+                $oMessage->addMetadata('actions', $aActions);
+            if (method_exists($oHistory, 'persistMessages'))
+                $oHistory->persistMessages();
+
+            $sId = method_exists($oMessage, 'getMetadata') ? $oMessage->getMetadata('__id') : '';
+            return [
+                'messageId' => is_string($sId) ? $sId : '',
+                'actions' => $aActions,
+            ];
+        }
+
+        return ['messageId' => '', 'actions' => $aPending];
+    }
+
+    /**
+     * SSE assistant reply without calling the model. Used when max_turns is hit.
+     */
+    protected function emitLimitSse($oAdapter, $fEmit, $sMessage)
+    {
+        foreach ($oAdapter->start() as $sEvent)
+            $fEmit($sEvent);
+
+        $oChunk = new NeuronAI\Chat\Messages\Stream\Chunks\TextChunk('msg_limit', (string)$sMessage);
+        foreach ($oAdapter->transform($oChunk) as $sEvent)
+            $fEmit($sEvent);
+
+        foreach ($oAdapter->end() as $sEvent)
+            $fEmit($sEvent);
+    }
+
+    /**
+     * SSE error without an assistant bubble. Used when a new session is rate-limited.
+     */
+    protected function emitErrorSse($oAdapter, $fEmit, $sMessage)
+    {
+        foreach ($oAdapter->start() as $sEvent)
+            $fEmit($sEvent);
+
+        $sMsg = trim((string)$sMessage);
+        $fEmit('data: ' . json_encode([
+            'type' => 'RUN_ERROR',
+            'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception'),
+        ]) . "\n\n");
+
+        foreach ($oAdapter->end() as $sEvent)
+            $fEmit($sEvent);
+    }
+}
+
+/** @} */

+ 303 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolAiTriggerMessage.php

@@ -0,0 +1,303 @@
+<?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
+ * @{
+ */
+
+class BxDolAiTriggerMessage extends BxDolAiTrigger
+{
+    protected $_sType = 'message';
+
+    /**
+     * The agent's out-of-domain answer: not posted into the talk.
+     */
+    const NO_REPLY = 'NO_REPLY';
+
+    protected function usesSenderChatHistory()
+    {
+        return true;
+    }
+
+    protected function appliesChatLimits()
+    {
+        return true;
+    }
+
+    /**
+     * Messenger history is per talk (lot), not mixed with the sender's Studio chat.
+     */
+    protected function getCallChatHistoryParams($aAgent, $mixedParams)
+    {
+        $aParams = parent::getCallChatHistoryParams($aAgent, $mixedParams);
+
+        $iLot = is_array($mixedParams) ? (int)($mixedParams['message_lot_id'] ?? 0) : 0;
+        if ($iLot > 0)
+            $aParams['chat_history_subindex'] = 'lot' . $iLot;
+
+        return $aParams;
+    }
+
+    /**
+     * limit_message is for chat widgets; in a talk the agent just goes quiet.
+     */
+    protected function getChatLimitReply($aAgent)
+    {
+        return null;
+    }
+
+    public function response($oAlert)
+    {
+        BxDolAiTrigger::getInstance($this->getType())->handle($oAlert);
+    }
+
+    public function handle($mixed = null)
+    {
+        $oAlert = $mixed;
+        if (!$oAlert || 'bx_messenger' != $oAlert->sUnit || 'got_jot' != $oAlert->sAction)
+            return false;
+
+        $aJotInfo = isset($oAlert->aExtras['subobject_info']) && is_array($oAlert->aExtras['subobject_info'])
+            ? $oAlert->aExtras['subobject_info']
+            : [];
+
+        return $this->processMessage(
+            $oAlert->iSender,
+            (int)($oAlert->aExtras['recipient_id'] ?? 0),
+            (int)$oAlert->iObject,
+            (int)($oAlert->aExtras['subobject_id'] ?? 0),
+            $aJotInfo
+        );
+    }
+
+    public function getAgentsByProfileId($iProfileId)
+    {
+        $oDb = new BxDolAiQuery();
+        return $oDb->getAgentsByProfileId($iProfileId);
+    }
+
+    /**
+     * Drains `$GLOBALS['glAgentsCallQueue']` once; jobs queued while a reply is
+     * being posted (got_jot fires again) are picked up by the same loop, never re-run.
+     */
+    public function processCallQueue($bFinishRequest = true, $bExit = true)
+    {
+        if ($bFinishRequest) {
+            if (function_exists('fastcgi_finish_request')) {
+                fastcgi_finish_request();
+            } else {
+                @ob_end_flush();
+                @flush();
+            }
+        }
+
+        if (empty($GLOBALS['glAgentsCallQueueLock'])) {
+            $GLOBALS['glAgentsCallQueueLock'] = true;
+            try {
+                while (!empty($GLOBALS['glAgentsCallQueue'])) {
+                    $aBatch = $GLOBALS['glAgentsCallQueue'];
+                    $GLOBALS['glAgentsCallQueue'] = [];
+                    foreach ($aBatch as $r) {
+                        $sType = $r['type'] ?? $this->getType();
+                        $mixedReply = BxDolAiTrigger::getInstance($sType)->call($r['agent'], $r['params']);
+                        if (!is_string($mixedReply) || $mixedReply === '')
+                            continue;
+
+                        $this->replyToMessage($r['agent'], $r['params'], $mixedReply);
+                    }
+                }
+            } finally {
+                $GLOBALS['glAgentsCallQueueLock'] = false;
+            }
+        }
+
+        if ($bExit)
+            exit(0);
+    }
+
+    /**
+     * Post the agent's reply (markdown) into the talk the message came from.
+     *
+     * @param array $aParams the call params (`sender_profile_id`, `message_lot_id`, `message_id`)
+     */
+    public function replyToMessage($aAgent, $aParams, $sReply)
+    {
+        if (is_string($aParams))
+            $aParams = json_decode($aParams, true) ?: [];
+        if (!is_array($aParams))
+            $aParams = [];
+
+        $oParsedown = new Parsedown();
+        $oParsedown->setSafeMode(true);
+        $sHtml = $oParsedown->text((string)$sReply);
+
+        return $this->sendMessengerMessage(
+            (int)($aAgent['profile_id'] ?? 0),
+            (int)($aParams['sender_profile_id'] ?? 0),
+            str_replace("\n", '', $sHtml),
+            (int)($aParams['message_lot_id'] ?? 0),
+            (int)($aParams['message_id'] ?? 0)
+        );
+    }
+
+    /**
+     * @param int $iLotId talk to post into; 0 = a private talk with $iRecipient (resolved from $iJotId when given)
+     * @param int $iJotId the message being answered — used to find the talk and to stop agent-on-agent loops
+     */
+    public function sendMessengerMessage($iSender, $iRecipient, $sMsg, $iLotId = 0, $iJotId = 0)
+    {
+        $oMessengerModule = BxDolModule::getInstance('bx_messenger');
+        if (!$oMessengerModule)
+            return false;
+
+        $iSender = (int)$iSender;
+        $iRecipient = (int)$iRecipient;
+        $iLotId = (int)$iLotId;
+        $iJotId = (int)$iJotId;
+
+        $sPlain = trim(html_entity_decode(strip_tags((string)$sMsg), ENT_QUOTES, 'UTF-8'));
+        if ($sPlain === '' || strcasecmp($sPlain, self::NO_REPLY) === 0)
+            return false;
+
+        if (!$iLotId && $iJotId) {
+            $aJot = $oMessengerModule->_oDb->getJotById($iJotId);
+            $iLotId = (int)($aJot['lot_id'] ?? 0);
+        }
+
+        // No talk and no recipient: sendMessage would open a stray talk with participant 0.
+        if (!$iLotId && !$iRecipient)
+            return false;
+
+        $aAutoReplyData = [
+            'message' => $sMsg,
+        ];
+        if ($iLotId)
+            $aAutoReplyData['lot'] = $iLotId;
+        else
+            $aAutoReplyData['participants'] = [$iSender, $iRecipient];
+
+        // Never pile agent jots on agent jots.
+        if ($this->isAgentFlood($oMessengerModule, $iLotId, $iJotId))
+            return false;
+
+        $iSaveProfileId = $oMessengerModule->setProfileId($iSender);
+        $a = $oMessengerModule->sendMessage($aAutoReplyData, $iLotId ? 0 : $iRecipient, $iSender);
+        $oMessengerModule->setProfileId($iSaveProfileId);
+
+        return $a;
+    }
+
+    protected function processMessage($iSender, $iRecipient, $iLotId, $iJotId, $aJotInfo)
+    {
+        $iSender = (int)$iSender;
+        $iRecipient = (int)$iRecipient;
+        $iLotId = (int)$iLotId;
+        $iJotId = (int)$iJotId;
+
+        if (!$iSender || !$iRecipient || $iSender == $iRecipient)
+            return false;
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return false;
+
+        // The alert may carry no jot info (or an empty one): read the jot.
+        $oMessenger = BxDolModule::getInstance('bx_messenger');
+        if ((empty($aJotInfo) || empty($aJotInfo['message'])) && $iJotId && $oMessenger)
+            $aJotInfo = $oMessenger->_oDb->getJotById($iJotId) ?: $aJotInfo;
+        if (!$iLotId && !empty($aJotInfo['lot_id']))
+            $iLotId = (int)$aJotInfo['lot_id'];
+
+        // Never answer an agent's own jot (the alert sender can be the human it replied to).
+        $iJotAuthor = (int)($aJotInfo['user_id'] ?? 0);
+        if ($oAi->isAgentProfile($iSender) || ($iJotAuthor && $oAi->isAgentProfile($iJotAuthor)))
+            return false;
+
+        $aAgents = $this->getAgentsByProfileId($iRecipient);
+        if (!$aAgents)
+            return false;
+
+        $sText = trim((string)($aJotInfo['message'] ?? ''));
+        if ($sText === '')
+            return false;
+
+        // got_jot fires once per participant and can nest: handle each jot+recipient once.
+        $sQueueKey = $iLotId . ':' . $iJotId . ':' . $iRecipient;
+        if (!isset($GLOBALS['glAgentsCallQueueKeys']))
+            $GLOBALS['glAgentsCallQueueKeys'] = [];
+        if (isset($GLOBALS['glAgentsCallQueueKeys'][$sQueueKey]))
+            return false;
+        $GLOBALS['glAgentsCallQueueKeys'][$sQueueKey] = true;
+
+        if (!isset($GLOBALS['glAgentsCallQueue']) || !is_array($GLOBALS['glAgentsCallQueue']))
+            $GLOBALS['glAgentsCallQueue'] = [];
+        foreach ($aAgents as $a) {
+            if (!$oAi->canInteract($a, $iSender))
+                continue;
+            if (!$a['message_profile_id'] || $iSender == $a['message_profile_id']) {
+                $aParams = [
+                    'trigger' => $this->getType(),
+                    'sender_profile_id' => $iSender,
+                    'recipient_profile_id' => $iRecipient,
+                    'message_lot_id' => $iLotId,
+                    'message_id' => $iJotId,
+                    'message_text' => $sText,
+                    'message_info' => $aJotInfo,
+                ];
+                if ($a['async']) {
+                    $this->enqueue($a, $aParams);
+                } else {
+                    $GLOBALS['glAgentsCallQueue'][] = [
+                        'type' => $this->getType(),
+                        'agent' => $a,
+                        'params' => $aParams,
+                    ];
+                }
+            }
+        }
+
+        if (!empty($GLOBALS['glAgentsCallQueue'])) {
+            ignore_user_abort(true);
+            set_time_limit(0);
+
+            // TODO: need a way to force messenger to show new message from the server side
+            // register_shutdown_function(function () {
+            //     bx_ai_process_agents_call_queue();
+            // });
+            $this->processCallQueue(false, false); // TODO: for a while do synchronically
+        }
+
+        return true;
+    }
+
+    /**
+     * The jot being answered, or the last jot in the talk, was written by an agent.
+     */
+    protected function isAgentFlood($oMessengerModule, $iLotId, $iSourceJotId)
+    {
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return false;
+
+        $iLotId = (int)$iLotId;
+        $iSourceJotId = (int)$iSourceJotId;
+        if ($iSourceJotId) {
+            $aSrc = $oMessengerModule->_oDb->getJotById($iSourceJotId);
+            $iSrcAuthor = (int)($aSrc['user_id'] ?? 0);
+            if ($iSrcAuthor && $oAi->isAgentProfile($iSrcAuthor))
+                return true;
+        }
+
+        if (!$iLotId)
+            return false;
+
+        $CNF = &$oMessengerModule->_oConfig->CNF;
+        $aLast = $oMessengerModule->_oDb->getRow("SELECT `{$CNF['FIELD_MESSAGE_AUTHOR']}` AS `user_id` FROM `{$CNF['TABLE_MESSAGES']}` WHERE `{$CNF['FIELD_MESSAGE_FK']}` = :lot AND `{$CNF['FIELD_MESSAGE_TRASH']}` = 0 ORDER BY `{$CNF['FIELD_MESSAGE_ID']}` DESC LIMIT 1", ['lot' => $iLotId]);
+        $iLastAuthor = (int)($aLast['user_id'] ?? 0);
+        return $iLastAuthor > 0 && $oAi->isAgentProfile($iLastAuthor);
+    }
+}
+
+/** @} */

+ 161 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolCacheFile.php

@@ -0,0 +1,161 @@
+<?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
+ * @{
+ */
+
+class BxDolCacheFile extends BxDolCache
+{
+    protected $sPath;
+
+    /**
+     * constructor
+     */
+    function __construct()
+    {
+        parent::__construct();
+        $this->sPath = BX_DIRECTORY_PATH_CACHE;
+    }
+
+    /**
+     * Get all data from the cache file.
+     *
+     * @param  string $sKey - file name
+     * @param  int    $iTTL - time to live
+     * @return the    data is got from cache.
+     */
+    function getData($sKey, $iTTL = false)
+    {
+        if (!file_exists($this->sPath . $sKey))
+            return null;
+
+        if ($iTTL > 0 && $this->_removeFileIfTtlExpired ($this->sPath . $sKey, $iTTL))
+            return null;
+
+        include($this->sPath . $sKey);
+        if (!isset($mixedData))
+            return null;
+
+        return $mixedData;
+    }
+
+    /**
+     * Save all data in cache file.
+     *
+     * @param  string  $sKey      - file name
+     * @param  mixed   $mixedData - the data to be cached in the file
+     * @param  int     $iTTL      - time to live
+     * @return boolean result of operation.
+     */
+    function setData($sKey, $mixedData, $iTTL = false)
+    {
+        $sFileName = $this->sPath . $sKey;
+        $sFileNameTmp = $this->sPath . '.' . uniqid('', true) . '.tmp';
+
+        if(file_exists($sFileName) && !is_writable($sFileName))
+           return false;
+
+        $content = '<?php $mixedData=' . var_export($mixedData, true) . '; ?>';
+        if (false === file_put_contents($sFileNameTmp, $content))
+            return false;
+        rename($sFileNameTmp, $sFileName);
+        @chmod($sFileName, 0666);
+
+        if (function_exists('opcache_invalidate')) opcache_invalidate($sFileName, true);
+
+        return true;
+    }
+
+    /**
+     * Delete cache file.
+     *
+     * @param  string $sKey - file name
+     * @return result of the operation
+     */
+    function delData($sKey)
+    {
+        $sFile = $this->sPath . $sKey;
+        return !file_exists($sFile) || $this->_unlinkFile($sFile);
+    }
+
+    /**
+     * remove all data from cache by key prefix
+     * @return true on success
+     */
+    function removeAllByPrefix ($s)
+    {
+        if (!($rHandler = opendir($this->sPath)))
+            return false;
+
+        $l = strlen($s);
+        while (($sFile = readdir($rHandler)) !== false)
+            if (0 === strncmp($sFile, $s, $l))
+                $this->_unlinkFile($this->sPath . $sFile);
+
+        closedir($rHandler);
+
+        return true;
+    }
+
+    /**
+     * get size of cached data by name prefix
+     */
+    function getSizeByPrefix ($s)
+    {
+        if (!($rHandler = opendir($this->sPath)))
+            return false;
+
+        $iSize = 0;
+        $l = strlen($s);
+        while (($sFile = readdir($rHandler)) !== false)
+            if (0 === strncmp($sFile, $s, $l))
+                $iSize += @filesize ($this->sPath . $sFile);
+
+        closedir($rHandler);
+
+        return $iSize;
+    }
+
+    /**
+     * remove file from dist if TTL expored
+     * @param  string $sFile - full path to filename
+     * @param  int    $iTTL  - time to live in seconds
+     * @return true   if TTL is expired and file is deleted or false otherwise
+     */
+    function _removeFileIfTtlExpired ($sFile, $iTTL)
+    {
+        $iTimeDiff = time() - filectime($sFile);
+        if ($iTimeDiff > $iTTL) {
+            $this->_unlinkFile($sFile);
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Remove a cache file together with its opcache entry.
+     *
+     * Cache files are PHP scripts read with include(), so opcache keeps a compiled copy of each of them.
+     * It never evicts entries on its own, and once a file is gone opcache_invalidate() can no longer resolve
+     * its path, so a file removed with a plain unlink() leaves a dead entry behind for good. With hash-named
+     * cache files being regenerated all the time such entries pile up until the cache is full and every
+     * script that no longer fits is recompiled on every request. Invalidating before the unlink turns the
+     * entry into wasted memory instead, which opcache reclaims with its next restart.
+     *
+     * @param  string $sFile - full path to the file
+     * @return true if the file is deleted, false otherwise
+     */
+    protected function _unlinkFile ($sFile)
+    {
+        if (function_exists('opcache_invalidate'))
+            opcache_invalidate($sFile, true);
+
+        return @unlink($sFile);
+    }
+}
+
+/** @} */

+ 2694 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolCmts.php

@@ -0,0 +1,2694 @@
+<?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
+ * @{
+ */
+
+bx_import('BxDolAcl');
+bx_import('BxDolPrivacy');
+
+define('BX_DOL_CMT_TYPE_COMMENTS', 'comments');
+define('BX_DOL_CMT_TYPE_REVIEWS', 'reviews');
+
+define('BX_CMT_OLD_VOTES', 365*86400); ///< comment votes older than this number of seconds will be deleted automatically
+
+define('BX_CMT_ACTION_POST', 'post');
+define('BX_CMT_ACTION_EDIT', 'edit');
+
+define('BX_CMT_DISPLAY_FLAT', 'flat');
+define('BX_CMT_DISPLAY_THREADED', 'threaded');
+
+define('BX_CMT_BROWSE_HEAD', 'head');
+define('BX_CMT_BROWSE_TAIL', 'tail');
+define('BX_CMT_BROWSE_POPULAR', 'popular');
+define('BX_CMT_BROWSE_CONNECTION', 'connection');
+
+define('BX_CMT_ORDER_BY_DATE', 'date');
+define('BX_CMT_ORDER_BY_POPULAR', 'popular');
+
+define('BX_CMT_FILTER_ALL', 'all');
+define('BX_CMT_FILTER_OTHERS', 'others');
+define('BX_CMT_FILTER_FRIENDS', 'friends');
+define('BX_CMT_FILTER_SUBSCRIPTIONS', 'subscriptions');
+define('BX_CMT_FILTER_PINNED', 'pinned');
+
+define('BX_CMT_ORDER_WAY_ASC', 'asc');
+define('BX_CMT_ORDER_WAY_DESC', 'desc');
+
+define('BX_CMT_PFP_TOP', 'top');
+define('BX_CMT_PFP_BOTTOM', 'bottom');
+
+define('BX_CMT_RATE_VALUE_PLUS', 1);
+define('BX_CMT_RATE_VALUE_MINUS', -1);
+
+define('BX_CMT_USAGE_BLOCK', 'block');
+define('BX_CMT_USAGE_INLINE', 'inline');
+define('BX_CMT_USAGE_DEFAULT', BX_CMT_USAGE_BLOCK);
+
+define('BX_CMT_STATUS_ACTIVE', 'active');
+define('BX_CMT_STATUS_HIDDEN', 'hidden');
+define('BX_CMT_STATUS_PENDING', 'pending');
+
+
+/**
+ * Comments for any content
+ *
+ * Related classes:
+ * - BxDolCmtsQuery - comments database queries
+ * - BxBaseCmts - comments base representation
+ * - BxTemplCmts - custom template representation
+ *
+ * AJAX comments for any content.
+ * Self moderated - users rate all comments, and if comment is
+ * below viewing treshold it is hidden by default.
+ *
+ * To add comments section to your module you need to add a record to 'sys_objects_cmts' table:
+ *
+ * - ID - autoincremented id for internal usage
+ * - ObjectName - your unique module name, with vendor prefix, lowercase and spaces are underscored
+ * - TableCmts - table name where comments are stored
+ * - Html - 0 - convert new lines to <br /> on saving, 1 - standard(default) visual editor, 2 - full visual editor, 3 - mini visual editor.
+ * - PerView - number of comments on a page
+ * - IsRatable - 0 or 1 allow to rate comments or not
+ * - ViewingThreshold - comment viewing treshost, if comment is below this number it is hidden by default
+ * - IsOn - is this comment object enabled
+ * - RootStylePrefix - toot comments style prefix, if you need root comments look different\
+ * - ObjectVote - Vote object name to process comments' votes. May be empty if Comment Vote is not needed.
+ * - TriggerTable - table to be updated upon each comment
+ * - TriggerFieldId - TriggerTable table field with unique record id of
+ * - TriggerFieldComments - TriggerTable table field with comments count, it will be updated automatically upon eaech comment
+ * - ClassName - your custom class name if you need to override default class, this class must have the same constructor arguments
+ * - ClassFile - file where your ClassName is stored.
+ *
+ * You can refer to BoonEx modules for sample record in this table.
+ *
+ *
+ *
+ * @section example Example of usage:
+ * After filling in the table you can show comments section in any place, using the following code:
+ *
+ * @code
+ * $o = new BxTemplCmts('value of ObjectName field', $iYourEntryId);
+ * if ($o->isEnabled())
+ *     echo $o->getCommentsBlock ();
+ * @endcode
+ *
+ * Please note that you never need to use BxDolCmts class directly, use BxTemplCmts instead.
+ * Also if you override comments class with your own then make it child of BxTemplCmts class.
+ *
+ *
+ *
+ * @section acl Memberships/ACL:
+ * - comments post
+ * - comments edit own
+ * - comments remove own
+ * - comments edit all
+ *
+ *
+ *
+ * @section alerts Alerts:
+ * Alerts type/unit - every module has own type/unit, it equals to ObjectName.
+ *
+ * The following alerts are rised
+ *
+ * - commentPost - comment was posted
+ *      - $iObjectId - entry id
+ *      - $iSenderId - author of comment
+ *      - $aExtra['comment_id'] - just added comment id
+ *
+ * - commentRemoved - comments was removed
+ *      - $iObjectId - entry id
+ *      - $iSenderId - comment deleter id
+ *      - $aExtra['comment_id'] - removed comment id
+ *
+ * - commentUpdated - comments was updated
+ *      - $iObjectId - entry id
+ *      - $iSenderId - comment deleter id
+ *      - $aExtra['comment_id'] - updated comment id
+ *
+ * - commentRated - comments was rated
+ *      - $iObjectId - entry id
+ *      - $iSenderId - comment rater id
+ *      - $aExtra['comment_id'] - rated comment id
+ *      - $aExtra['rate'] - comment rate 1 or -1
+ *
+ */
+class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContentInfoService
+{
+    /**
+     * System tables which are UNITED for all 
+     * comment systems and cannot be overwritten.
+     */
+    public static $sTableSystems = 'sys_objects_cmts';
+    public static $sTableIds = 'sys_cmts_ids';
+
+    /**
+     * System tables which are used by default and
+     * can be overwritten in comment systems.
+     * @see BxDolCmts::getTableNameImages and BxDolCmts::getTableNameImages2Entries
+     */
+    protected $_sTableImages = 'sys_cmts_images';
+    protected $_sTableImages2Entries = 'sys_cmts_images2entries';
+
+    protected $_aElementDefaults;
+    protected $_aElementDefaultsApi;
+    protected $_aElementParamsApi; //--- Params from DefaultsApi array to be passed to Api
+
+    protected $_bIsApi;
+
+    protected $_sType;
+    protected $_oQuery = null;
+    protected $_oTemplate = null;
+
+    protected $_bPostQuote;
+    protected $_bMinPostForm;
+    protected $_sFormObject;
+    protected $_sFormDisplayPost;
+    protected $_sFormDisplayEdit;
+
+    protected $_sConnObjFriends;
+    protected $_sConnObjSubscriptions;
+
+    protected $_sMenuObjManage;
+    protected $_sMenuObjActions;
+    protected $_sMenuObjCounters;
+    protected $_sMenuObjMeta;
+
+    protected $_sMetatagsObj;
+
+    protected $_sViewUrl = '';
+    protected $_sBaseUrl = '';
+    protected $_sListAnchor = '';
+    protected $_sItemAnchor = '';
+    protected $_sItemAnchorApi = '';
+
+    protected $_aSystems = [];
+
+    protected $_sSystem = 'profile'; ///< current comment system name
+    protected $_aSystem = []; ///< current comments system array
+    protected $_iId = 0; ///< obect id to be commented
+    protected $_iAuthorId = 0; ///< currently logged in user who browse, post, etc.
+
+    protected $_aParams = [];
+    protected $_aT = []; ///< an array of lang keys
+    protected $_aMarkers = [];
+
+    protected $_sDisplayType = '';
+    protected $_sDpSessionKey = '';
+    protected $_iDpMaxLevel = 0;
+
+    protected $_sBrowseType = '';
+    protected $_bBrowseFilter = false;
+    protected $_sBrowseFilter = '';
+    protected $_sBpSessionKeyType = '';
+    protected $_sBpSessionKeyFilter = '';
+    protected $_aOrder = array();
+
+    protected $_sSnippetLenthLiveSearch = 50;
+
+    protected $_iRememberTime = 2592000;
+
+    protected $_bLiveUpdates = true;
+
+    /**
+     * Constructor
+     * $sSystem - comments system name
+     * $iId - obect id to be commented
+     */
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct();
+
+        $this->_bIsApi = bx_is_api();
+
+        $this->_sType = BX_DOL_CMT_TYPE_COMMENTS;
+
+        $this->_aSystems = $this->getSystems();
+        if(!isset($this->_aSystems[$sSystem]))
+            return;
+
+        $this->_sSystem = $sSystem;
+        $this->_aSystem = $this->_aSystems[$sSystem];
+        $this->_aSystem['is_browse_filter'] = (int)$this->_bBrowseFilter;
+
+        $this->_iAuthorId = $this->_getAuthorId();
+
+        $this->_iDpMaxLevel = (int)$this->_aSystem['number_of_levels'];
+        $this->_sDisplayType = $this->_iDpMaxLevel == 0 ? BX_CMT_DISPLAY_FLAT : BX_CMT_DISPLAY_THREADED;
+        $this->_sDpSessionKey = 'bx_' . $this->_sSystem . '_dp_';
+
+        $this->_sBrowseType = $this->_aSystem['browse_type'];
+        $this->_sBrowseFilter = BX_CMT_FILTER_ALL;
+        $this->_sBpSessionKeyType = 'bx_' . $this->_sSystem . '_bpt_';
+        $this->_sBpSessionKeyFilter = 'bx_' . $this->_sSystem . '_bpf_';
+        $this->_aOrder = array(
+            'by' => BX_CMT_ORDER_BY_DATE,
+            'way' => BX_CMT_ORDER_WAY_ASC
+        );
+
+        list($mixedUserDp, $mixedUserBpType, $mixedUserBpFilter) = $this->_getUserChoice();
+        if(!empty($mixedUserDp))
+            $this->_sDisplayType = $mixedUserDp;
+        if(!empty($mixedUserBpType))
+            $this->_sBrowseType = $mixedUserBpType;
+        if(!empty($mixedUserBpFilter))
+            $this->_sBrowseFilter = $mixedUserBpFilter;
+
+        $this->_sViewUrl = 'page.php?i=cmts-view';
+        $this->_sBaseUrl = $this->_aSystem['base_url'];
+
+        $this->_sListAnchor = "cmts-anchor-%s-%d";
+        $this->_sItemAnchor = "cmt-anchor-%s-%d-%d";
+        $this->_sItemAnchorApi = "cid=%d";
+
+        $this->_oQuery = new BxDolCmtsQuery($this);
+
+        $this->_bPostQuote = false;
+        $this->_bMinPostForm = true;
+        $this->_sFormObject = 'sys_comment';
+        $this->_sFormDisplayPost = 'sys_comment_post';
+        $this->_sFormDisplayEdit = 'sys_comment_edit';
+
+        $this->_sConnObjFriends = 'sys_profiles_friends';
+        $this->_sConnObjSubscriptions = 'sys_profiles_subscriptions';
+
+        $this->_sMenuObjManage = 'sys_cmts_item_manage';
+        $this->_sMenuObjActions = 'sys_cmts_item_actions';
+        $this->_sMenuObjCounters = 'sys_cmts_item_counters';
+        $this->_sMenuObjMeta = 'sys_cmts_item_meta';
+
+        $this->_sMetatagsObj = 'sys_cmts';
+
+        if(($aParams = bx_get('params')) !== false) {
+            if(is_string($aParams))
+                $aParams = json_decode($aParams, true);
+            if(!empty($aParams) && is_array($aParams))
+                $this->_aParams = array_merge($this->_aParams, $aParams);
+        }
+
+        $this->_aT = [
+            'block_comments_title' => '_cmt_block_comments_title',
+            'txt_sample_single' => '_cmt_txt_sample_comment_single',
+            'txt_sample_vote_single' => '_cmt_txt_sample_vote_single',
+            'txt_sample_reaction_single' => '_cmt_txt_sample_reaction_single',
+            'txt_sample_score_up_single' => '_cmt_txt_sample_score_up_single',
+            'txt_sample_score_down_single' => '_cmt_txt_sample_score_down_single',
+            'txt_min_form_placeholder' => '_cmt_txt_min_form_placeholder'
+        ];
+
+        if ($iInit)
+            $this->init($iId);
+
+        if ($oTemplate)
+            $this->_oTemplate = $oTemplate;
+        else
+            $this->_oTemplate = BxDolTemplate::getInstance();
+    }
+
+    /**
+     * get comments object instanse
+     * @param $sSys comments object name
+     * @param $iId associated content id, where comments are postred in
+     * @param $iInit perform initialization
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstance($sSys, $iId, $iInit = true, $oTemplate = false)
+    {
+        if(isset($GLOBALS['bxDolClasses']['BxDolCmts!' . $sSys . $iId]))
+            return $GLOBALS['bxDolClasses']['BxDolCmts!' . $sSys . $iId];
+
+        $aSystems = self::getSystems();
+        if (!isset($aSystems[$sSys]))
+            return null;
+
+        $sClassName = 'BxTemplCmts';
+        if(!empty($aSystems[$sSys]['class_name'])) {
+            $sClassName = $aSystems[$sSys]['class_name'];
+            if(!empty($aSystems[$sSys]['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSystems[$sSys]['class_file']);
+        }
+
+        $o = new $sClassName($sSys, $iId, $iInit, $oTemplate);
+        return ($GLOBALS['bxDolClasses']['BxDolCmts!' . $sSys . $iId] = $o);
+    }
+    
+    /**
+     * get comments object instanse
+     * @param $iUniqId unique comment id
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstanceByUniqId($iUniqId, $iInit = true, $oTemplate = false)
+    {
+        $aData = BxDolCmtsQuery::getInfoByUniqId($iUniqId);
+        if(empty($aData) || !is_array($aData))
+            return null;
+
+        return self::getObjectInstance($aData['system_name'], $aData['cmt_object_id']);
+    }
+
+    public static function &getSystems ()
+    {
+        $sKey = 'bx_dol_cache_memory_cmts_systems';
+
+        if (!isset($GLOBALS[$sKey])) {
+            $GLOBALS[$sKey] = BxDolDb::getInstance()->fromCache('sys_objects_cmts', 'getAllWithKey', '
+                SELECT
+                    `ID` as `system_id`,
+                    `Name` AS `name`,
+                    `Module` AS `module`,
+                    `Table` AS `table`,
+                    `CharsPostMin` AS `chars_post_min`,
+                    `CharsPostMax` AS `chars_post_max`,
+                    `CharsDisplayMax` AS `chars_display_max`,
+                    `Html` AS `html`,
+                    `PerView` AS `per_view`,
+                    `PerViewReplies` AS `per_view_replies`,
+                    `BrowseType` AS `browse_type`,
+                    `IsBrowseSwitch` AS `is_browse_switch`,
+                    `PostFormPosition` AS `post_form_position`,
+                    `NumberOfLevels` AS `number_of_levels`,
+                    `IsDisplaySwitch` AS `is_display_switch`,
+                    `IsRatable` AS `is_ratable`,
+                    `ViewingThreshold` AS `viewing_threshold`,
+                    `IsOn` AS `is_on`,
+                    `RootStylePrefix` AS `root_style_prefix`,
+                    `BaseUrl` AS `base_url`,
+                    `ObjectVote` AS `object_vote`,
+                    `ObjectReaction` AS `object_reaction`,
+                    `ObjectScore` AS `object_score`,
+                    `ObjectReport` AS `object_report`,
+                    `TriggerTable` AS `trigger_table`,
+                    `TriggerFieldId` AS `trigger_field_id`,
+                    `TriggerFieldAuthor` AS `trigger_field_author`,
+                    `TriggerFieldTitle` AS `trigger_field_title`,
+                    `TriggerFieldComments` AS `trigger_field_comments`,
+                    `ClassName` AS `class_name`,
+                    `ClassFile` AS `class_file`
+                FROM `' . self::$sTableSystems . '`', 'name');
+        }
+        return $GLOBALS[$sKey];
+    }
+
+    public static function getGlobalInfo ($iUniqueId)
+    {
+        return BxDolDb::getInstance()->getRow("SELECT `ti`.*, `ts`.`Name` AS `system_name` FROM `" . self::$sTableIds . "` AS `ti` LEFT JOIN `" . self::$sTableSystems . "` AS `ts` ON `ti`.`system_id`=`ts`.`ID` WHERE `ti`.`id`=:id LIMIT 1", array(
+            'id' => (int)$iUniqueId
+        ));
+    }
+
+    public static function getGlobalNumByParams($aParams = [])
+    {
+        $sQuery = "SELECT COUNT(*) FROM `" . self::$sTableIds . "` WHERE 1";
+        foreach($aParams as $aValue)
+            $sQuery .= " AND `" . $aValue['key'] ."` " . $aValue['operator'] . " '" . $aValue['value'] . "'";
+
+        return BxDolDb::getInstance()->getOne($sQuery);
+    }
+
+    public function init ($iId)
+    {
+        if (empty($this->iId) && $iId)
+            $this->setId($iId);
+
+        $this->addMarkers(array(
+            'object_id' => $this->getId(),
+            'user_id' => $this->_getAuthorId()
+        ));
+    }
+
+    public function setParam($sName, $sValue)
+    {
+        $this->_aParams[$sName] = $sValue;
+    }
+
+    public function isParam($sName)
+    {
+        return isset($this->_aParams[$sName]);
+    }
+
+    public function getParam($sName)
+    {
+        return $this->_aParams[$sName];
+    }
+
+    public function getId ()
+    {
+        return $this->_iId;
+    }
+
+    public function isEnabled ()
+    {
+        return isset($this->_aSystem['is_on']) && $this->_aSystem['is_on'];
+    }
+
+    public function getSystemId()
+    {
+        return $this->_aSystem['system_id'];
+    }
+
+    public function getSystemName()
+    {
+        return $this->_sSystem;
+    }
+
+    public function getSystemInfo()
+    {
+        return $this->_aSystem;
+    }
+
+    public function getModule()
+    {
+        return $this->_aSystem['module'];
+    }
+
+    public function getStorageObjectName()
+    {
+    	return $this->_getFormObject()->getStorageObjectName();
+    }
+
+    public function getTranscoderPreviewName()
+    {
+    	return $this->_getFormObject()->getTranscoderPreviewName();
+    }
+    
+    public function getFormObject()
+    {
+    	return $this->_getFormObject();
+    }
+
+    public function getTableNameImages()
+    {
+        return $this->_sTableImages;
+    }
+
+    public function getTableNameImages2Entries()
+    {
+        return $this->_sTableImages2Entries;
+    }
+
+    public function getLanguageKey($sIndex)
+    {
+        return isset($this->_aT[$sIndex]) ? $this->_aT[$sIndex] : '';
+    }
+
+    public function getMaxLevel()
+    {
+        return $this->_iDpMaxLevel;
+    }
+
+    public function getOrder ()
+    {
+        return $this->_sOrder;
+    }
+
+    public function getPerView ($iCmtParentId = 0)
+    {
+        return $iCmtParentId == 0 ? $this->_aSystem['per_view'] : $this->_aSystem['per_view_replies'];
+    }
+
+    public function getStatusAdmin()
+    {
+        return $this->isEditAllowedAll() || $this->isRemoveAllowedAll() || $this->isAutoApprove() ? BX_CMT_STATUS_ACTIVE : BX_CMT_STATUS_PENDING;
+    }
+
+    public function getViewUrl($iCmtId, $bAbsolute = true)
+    {
+    	if(empty($this->_aSystem['trigger_field_title']))
+            return '';
+
+        $s = BxDolPermalinks::getInstance()->permalink($this->_sViewUrl, [
+            'sys' => $this->_sSystem,
+            'id' => $this->_iId,
+            'cmt_id' => $iCmtId
+        ]);
+
+    	return $bAbsolute ? bx_absolute_url($s) : $s;
+    }
+
+    public function getViewText($mixedItem)
+    {
+        if(!is_array($mixedItem))
+            $mixedItem = $this->getCommentSimple((int)$mixedItem);
+
+        if(empty($mixedItem) || !is_array($mixedItem))
+            return '';
+
+        return $this->_prepareTextForOutput($mixedItem['cmt_text'], (int)$mixedItem['cmt_id']);
+    }
+
+    public function getViewSnippet($mixedItem, $sMaxLength = 20)
+    {
+        if(!is_array($mixedItem))
+            $mixedItem = $this->getCommentSimple((int)$mixedItem);
+
+        if(empty($mixedItem) || !is_array($mixedItem))
+            return '';
+
+        $sResult = bx_process_output(strip_tags($mixedItem['cmt_text']), BX_DATA_TEXT);
+        if(mb_strlen($sResult) > $sMaxLength)
+            $sResult = strmaxtextlen($sResult, $sMaxLength, '...');
+
+        return $sResult;
+    }
+
+    public function getBaseUrl($sPrefix = BX_DOL_URL_ROOT)
+    {
+        $sUrl = $this->_replaceMarkers($this->_sBaseUrl);
+        $sUrl = BxDolPermalinks::getInstance()->permalink($sUrl);
+        return bx_absolute_url($sUrl, $sPrefix);
+    }
+
+    public function getListUrl($sPrefix = BX_DOL_URL_ROOT)
+    {
+        $sBaseUrl = $this->getBaseUrl($sPrefix);
+        if(empty($sBaseUrl))
+            return '';
+
+        return $sBaseUrl . $this->getListAnchor(true);
+    }
+
+    /**
+     * Get URL for view commented content page with anchor to the coment. 
+     * @param integer $iItemId - comment ID.
+     * @param string $sPrefix - URL prefix.
+     * @return string with URL.
+     */
+    public function getItemUrl($iItemId, $sPrefix = BX_DOL_URL_ROOT)
+    {
+        $sBaseUrl = $this->getBaseUrl($sPrefix);
+        if(empty($sBaseUrl))
+            return '';
+
+        return $sBaseUrl . $this->getItemAnchor($iItemId, true);
+    }
+
+    public function getItemUrlApi($iItemId, $sPrefix = BX_DOL_URL_ROOT)
+    {
+        $sBaseUrl = $this->getBaseUrl($sPrefix);
+        if(empty($sBaseUrl))
+            return '';
+
+        return $sBaseUrl . $this->getItemAnchorApi($iItemId, true);
+    }
+
+    public function getListAnchor($bWithHash = false)
+    {
+        return ($bWithHash ? '#' : '') . sprintf($this->_sListAnchor, str_replace('_', '-', $this->getSystemName()), $this->getId());
+    }
+
+    public function getItemAnchor($iItemId, $bWithHash = false)
+    {
+        return ($bWithHash ? '#' : '') . sprintf($this->_sItemAnchor, str_replace('_', '-', $this->getSystemName()), $this->getId(), $iItemId);
+    }
+
+    public function getItemAnchorApi($iItemId, $bWithHash = false)
+    {
+        return ($bWithHash ? '#' : '') . sprintf($this->_sItemAnchorApi, $iItemId);
+    }
+
+    public function geModalViewApi()
+    {
+        return 'default';
+    }
+
+    public function getAttachments($iCmtId)
+    {
+        $aResult = array();
+
+        if(!$this->isAttachImageEnabled())
+            return $aResult; 
+
+        $aFiles = $this->_oQuery->getFiles($this->_aSystem['system_id'], $iCmtId);
+        if(empty($aFiles) || !is_array($aFiles)) 
+            return $aResult;
+
+        $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+        $oTranscoder = BxDolTranscoderImage::getObjectInstance($this->getTranscoderPreviewName());
+
+        foreach($aFiles as $aFile) {
+            $bImage = $oTranscoder && $oTranscoder->isMimeTypeSupported($aFile['mime_type']);
+
+            $sPreview = '';
+            if($bImage)
+                $sPreview = $oTranscoder->getFileUrl($aFile['image_id']);
+
+            if(!$sPreview)
+                $sPreview = $this->_oTemplate->getIconUrl($oStorage->getIconNameByFileName($aFile['file_name']));
+
+            $sUrl = $oStorage->getFileUrlById($aFile['image_id']);
+
+            $aResult[] = array(
+                'id' => $aFile['image_id'],
+                'src' => $sPreview,
+                'src_orig' => $bImage ? $sUrl : '',
+                'url' => !$bImage ? $sUrl : ''
+            );
+        }
+
+        return $aResult;
+    }
+
+    public function getConnectionObject($sType)
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_CMT_FILTER_FRIENDS:
+                $sResult = $this->_sConnObjFriends;
+                break;
+            case BX_CMT_FILTER_SUBSCRIPTIONS:
+                $sResult = $this->_sConnObjSubscriptions;
+                break;
+        }
+
+        return $sResult;
+    }
+
+    public function getVoteObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_vote']))
+            $this->_aSystem['object_vote'] = 'sys_cmts';
+
+        $oVote = BxDolVote::getObjectInstance($this->_aSystem['object_vote'], $iEniqId, true, $this->_oTemplate);
+        if(!$oVote || !$oVote->isEnabled())
+            return false;
+
+        return $oVote;
+    }
+
+    public function getReactionObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_reaction']))
+            $this->_aSystem['object_reaction'] = 'sys_cmts_reactions';
+
+        $oReaction = BxDolVote::getObjectInstance($this->_aSystem['object_reaction'], $iEniqId, true, $this->_oTemplate);
+        if(!$oReaction || !$oReaction->isEnabled())
+            return false;
+
+        return $oReaction;
+    }
+
+    public function getScoreObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_score']))
+            $this->_aSystem['object_score'] = 'sys_cmts';
+
+        $oScore = BxDolScore::getObjectInstance($this->_aSystem['object_score'], $iEniqId, true, $this->_oTemplate);
+        if(!$oScore || !$oScore->isEnabled())
+            return false;
+
+        return $oScore;
+    }
+
+    public function getReportObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_report']))
+            $this->_aSystem['object_report'] = 'sys_cmts';
+
+        $oReport = BxDolReport::getObjectInstance($this->_aSystem['object_report'], $iEniqId, true, $this->_oTemplate);
+        if(!$oReport || !$oReport->isEnabled())
+            return false;
+
+        return $oReport;
+    }
+
+    /**
+     * Is used as: 
+     * 1. Live Update's session key.
+     * 2. HTML ID for Live Update's 'New Content' button.
+     * @return string with ID.
+     */
+    public function getNotificationId()
+    {
+        return 'cmts-notification-' . $this->_sSystem . '-' . $this->_iId;
+    }
+
+    public function getSocketName()
+    {
+        return 'cmts_' . $this->_sSystem;
+    }
+
+    /**
+     * @deprecated since version 10.0.0-B3 and can be removed in later versions.
+     */
+    public function setTableNameFiles($sTable)
+    {
+        $this->_sTableImages = $sTable;
+    	$this->_oQuery->setTableNameFiles($sTable);
+    }
+
+    /**
+     * @deprecated since version 10.0.0-B3 and can be removed in later versions.
+     */
+    public function setTableNameFiles2Entries($sTable)
+    {
+    	$this->_sTableImages2Entries = $sTable;
+    	$this->_oQuery->setTableNameFiles2Entries($sTable);
+    }
+
+    public function isHtml ()
+    {
+        return $this->_aSystem['html'] > 0;
+    }
+
+    public function isRatable ()
+    {
+        return $this->_aSystem['is_ratable'];
+    }
+
+    public function isAttachImageEnabled()
+    {
+        return true;
+    }
+
+    public function isAutoApprove()
+    {
+        return getParam('sys_cmts_enable_auto_approve') == 'on';
+    }
+
+    /**
+     * set id to operate with votes
+     */
+    public function setId ($iId)
+    {
+        if ($iId == $this->getId()) return;
+        $this->_iId = $iId;
+    }
+
+    /**
+     * Add replace markers.
+     * @param $a array of markers as key => value
+     * @return true on success or false on error
+     */
+    public function addMarkers ($a)
+    {
+        if (empty($a) || !is_array($a))
+            return false;
+        $this->_aMarkers = array_merge ($this->_aMarkers, $a);
+        return true;
+    }
+
+    /**
+     * Database functions
+     */
+    public function getQueryObject ()
+    {
+        return $this->_oQuery;
+    }
+
+    public function getCommentsTableName ()
+    {
+        return $this->_oQuery->getTableName ();
+    }
+
+    public function getObjectAuthorId ($iObjectId = 0)
+    {
+    	if(empty($this->_aSystem['trigger_field_author']))
+    		return 0;
+
+        return $this->_oQuery->getObjectAuthorId ($iObjectId ? $iObjectId : $this->getId());
+    }
+
+    public function getObjectTitle ($iObjectId = 0)
+    {
+        if(empty($this->_aSystem['trigger_field_title']))
+            return '';
+
+        $sTitle = $this->_oQuery->getObjectTitle ($iObjectId ? $iObjectId : $this->getId());
+        if(get_mb_substr($sTitle, 0, 1) == '_')
+            $sTitle = _t($sTitle);
+
+        return $sTitle;
+    }
+    
+    public function getObjectPrivacyView ($iObjectId = 0)
+    {
+        if(empty($iObjectId))
+            $iObjectId = $this->getId();
+
+        $sFieldPrivacyView = '';
+        if(!empty($this->_aSystem['trigger_field_privacy_view']))
+            $sFieldPrivacyView = $this->_aSystem['trigger_field_privacy_view'];
+
+        if(($iPrivacyView = $this->_oQuery->getObjectPrivacyView($iObjectId, $sFieldPrivacyView)) !== false)
+            return $iPrivacyView;
+
+        return BX_DOL_PG_ALL;
+    }
+
+    /*
+     * Gets Content Filter object.
+     */
+    public function getObjectContentFilter ()
+    {
+        $oCf = BxDolContentFilter::getInstance();
+        if(!$oCf->isEnabledForComments())
+            return false;
+
+        return $oCf;
+    }
+
+    public function getCommentsCountAll ($iObjectId = 0, $bForceCalculate = false)
+    {
+        return $this->_oQuery->getCommentsCountAll ($iObjectId ? $iObjectId : $this->getId(), $this->_getAuthorId(), $bForceCalculate);
+    }
+
+    public function getCommentsCount ($iObjectId = 0, $iCmtVParentId = -1, $sFilter = '')
+    {
+        return $this->_oQuery->getCommentsCount ($iObjectId ? $iObjectId : $this->getId(), $iCmtVParentId, $this->_getAuthorId(), $sFilter);
+    }
+
+    public function getCommentsArray ($iVParentId, $sFilter, $aOrder, $iStart = 0, $iCount = -1)
+    {
+        return $this->_oQuery->getComments ($this->getId(), $iVParentId, $this->_getAuthorId(), $sFilter, $aOrder, $iStart, $iCount);
+    }
+
+    public function getCommentsBy ($aParams = [])
+    {
+        return $this->_oQuery->getCommentsBy($aParams); 
+    }
+
+    /**
+     * Get comment's unique id.
+     */
+    public function getCommentUniqId($iCmtId, $iAuthorId = 0)
+    {
+        return $this->_oQuery->getUniqId($this->getSystemId(), $iCmtId, ['author_id' => $iAuthorId]);
+    }
+
+    /**
+     * Get comment's short info.
+     */
+    public function getCommentSimple ($iCmtId)
+    {
+        return $this->_oQuery->getCommentSimple ($this->getId(), $iCmtId);
+    }
+
+    /**
+     * Get comment's full info.
+     */
+    public function getCommentRow ($iCmtId)
+    {
+        return $this->_oQuery->getComment ($this->getId(), $iCmtId);
+    }
+
+    public function getCommentParents ($mixedCmt, $iDepthMax = 0)
+    {
+        if(!is_array($mixedCmt))
+            $mixedCmt = $this->_oQuery->getCommentsBy(['type' => 'id', 'id' => (int)$mixedCmt]);
+
+        $iDepth = 0;
+        $aParents = [];
+        $this->_getParents($mixedCmt, $iDepthMax, $iDepth, $aParents);
+
+        return $aParents;
+    }
+
+    public function onObjectDelete ($iObjectId = 0)
+    {
+        // delete comments
+        $aFiles = $aCmtIds = [];
+        $this->_oQuery->deleteObjectComments ($iObjectId ? $iObjectId : $this->getId(), $aFiles, $aCmtIds);
+
+        // delete votes
+        $this->deleteVotes($aCmtIds);
+
+        // delete reactions
+        $this->deleteReactions($aCmtIds);
+
+        // delete scores
+        $this->deleteScores($aCmtIds);
+
+        // delete reports
+        $this->deleteReports($aCmtIds);
+
+        // delete meta info
+        $this->deleteMetaInfo($aCmtIds);
+
+        // delete files
+        if ($aFiles) {
+            $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+            if ($oStorage)
+                $oStorage->queueFilesForDeletion($aFiles);
+        }
+
+        // delete unique IDs
+        $this->deleteUniqueIds($aCmtIds);
+    }
+
+    public static function onAuthorDelete ($iAuthorId)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            $o = self::getObjectInstance($sSystem, 0);
+            $oQuery = $o->getQueryObject();
+
+            // delete comments
+            $aFiles = $aCmtIds = array ();
+            $oQuery->deleteAuthorComments($iAuthorId, $aFiles, $aCmtIds);
+
+            // delete votes
+            $o->deleteVotes($aCmtIds);
+
+            // delete reactions
+            $o->deleteReactions($aCmtIds);
+
+            // delete scores
+            $o->deleteScores($aCmtIds);
+
+            // delete reports
+            $o->deleteReports($aCmtIds);
+
+            // delete meta info
+            $o->deleteMetaInfo($aCmtIds);
+    
+            // delete files
+            $oStorage = BxDolStorage::getObjectInstance($o->getStorageObjectName());
+            if ($oStorage)
+                $oStorage->queueFilesForDeletion($aFiles);
+            
+            // delete unique IDs
+            $o->deleteUniqueIds($aCmtIds);
+        }
+        return true;
+    }
+
+    public static function onModuleEnable ($sModuleName)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            if ($sModuleName !== $aSystem['module'])
+                continue;
+
+            $o = self::getObjectInstance($sSystem, 0);
+            $o->registerTranscoders();
+        }
+
+        return true;
+    }
+
+    public static function onModuleDisable ($sModuleName)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            if ($sModuleName !== $aSystem['module'])
+                continue;
+
+            $o = self::getObjectInstance($sSystem, 0);
+            $o->unregisterTranscoders();
+        }
+
+        return true;
+    }
+
+    public static function onModuleUninstall ($sModuleName, &$iFiles = null)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            if ($sModuleName !== $aSystem['module'])
+                continue;
+
+            $o = self::getObjectInstance($sSystem, 0);
+            $oQuery = $o->getQueryObject();
+
+            // delete comments
+            $aFiles = $aCmtIds = array ();
+            $oQuery->deleteAll($aSystem['system_id'], $aFiles, $aCmtIds);
+
+            // delete votes
+            $o->deleteVotes($aCmtIds);
+
+            // delete reactions
+            $o->deleteReactions($aCmtIds);
+
+            // delete scores
+            $o->deleteScores($aCmtIds);
+
+            // delete reports
+            $o->deleteReports($aCmtIds);
+
+            // delete meta info
+            $o->deleteMetaInfo($aCmtIds);
+
+            // delete files
+            $oStorage = BxDolStorage::getObjectInstance($o->getStorageObjectName());
+            if ($oStorage && $aFiles)
+                $oStorage->queueFilesForDeletion($aFiles);
+
+            if (null !== $iFiles)
+                $iFiles += count($aFiles);
+
+            // delete unique IDs
+            $o->deleteUniqueIds($aCmtIds);
+        }
+
+        return true;
+    }
+
+    public function deleteVotes ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getVoteObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteReactions ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getReactionObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteScores ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getScoreObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteReports ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getReportObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteMetaInfo ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = array($mixedCmtId);
+
+        $oMetatags = false;
+        if(!empty($this->_sMetatagsObj))
+            $oMetatags = BxDolMetatags::getObjectInstance($this->_sMetatagsObj);
+
+        if($oMetatags)
+            foreach($mixedCmtId as $iCmtId)
+                $oMetatags->onDeleteContent($this->getCommentUniqId($iCmtId));
+    }
+
+    public function deleteUniqueIds ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = array($mixedCmtId);
+
+        foreach($mixedCmtId as $iCmtId)
+            $this->_oQuery->deleteCmtIds($this->_aSystem['system_id'], $iCmtId);
+    }
+
+    /**
+     * Permissions functions
+     */
+    public function isAdmin($iCmtAuthorId)
+    {
+        return BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR, MEMBERSHIP_ID_ADMINISTRATOR), $iCmtAuthorId);
+    }
+
+    public function checkAction ($sAction, $isPerformAction = false)
+    {
+        $iId = $this->_getAuthorId();
+        $a = checkActionModule($iId, $sAction, 'system', $isPerformAction);
+        return $a[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function checkActionErrorMsg ($sAction)
+    {
+        $iId = $this->_getAuthorId();
+        $a = checkActionModule($iId, $sAction, 'system');
+        return $a[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED ? $a[CHECK_ACTION_MESSAGE] : '';
+    }
+
+    public function isViewAllowed ($isPerformAction = false)
+    {
+        if (BxDolRequest::serviceExists($this->_aSystem['module'], 'check_allowed_comments_view')) {
+            $mixedResult = BxDolService::call($this->_aSystem['module'], 'check_allowed_comments_view', array($this->getId(), $this->getSystemName()));
+            if($mixedResult !== CHECK_ACTION_RESULT_ALLOWED)
+                return $mixedResult;
+        }
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function isVoteAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(!$this->isRatable())
+            return false;
+
+        $oVote = $this->getVoteObject($this->getCommentUniqId($aCmt['cmt_id']));
+        if($oVote === false)
+            return false;
+
+        $iUserId = (int)$this->_getAuthorId();
+        if($iUserId == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+
+        return $oVote->isAllowedVote($isPerformAction);
+    }
+
+    public function isScoreAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(!$this->isRatable())
+            return false;
+
+        $oScore = $this->getScoreObject($this->getCommentUniqId($aCmt['cmt_id']));
+        if($oScore === false)
+            return false;
+
+        $iUserId = (int)$this->_getAuthorId();
+        if($iUserId == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+
+        return $oScore->isAllowedVote($isPerformAction);
+    }
+
+    public function isReportAllowed ($aCmt, $isPerformAction = false)
+    {
+        $oReport = $this->getReportObject($aCmt['cmt_id']);
+        if($oReport === false)
+            return false;
+
+        $iUserId = (int)$this->_getAuthorId();
+        if($iUserId == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+
+        return $oReport->isAllowedReport($isPerformAction);
+    }
+
+    public function isPostAllowed ($isPerformAction = false)
+    {
+        if (BxDolRequest::serviceExists($this->_aSystem['module'], 'check_allowed_comments_post')) {
+            $mixedResult = BxDolService::call($this->_aSystem['module'], 'check_allowed_comments_post', array($this->getId(), $this->getSystemName()));
+            if($mixedResult !== CHECK_ACTION_RESULT_ALLOWED)
+                return false;
+        }
+
+        return $this->checkAction ('comments post', $isPerformAction);
+    }
+
+    public function msgErrPostAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments post');
+    }
+
+    /**
+     * Determines whether a 'reply' action allowed or not.
+     * @param integer or array $mixedCmt - ID or an array which describes the comment to be replied
+     * @param boolean $isPerformAction
+     * @return boolean
+     */
+    public function isReplyAllowed ($mixedCmt, $isPerformAction = false)
+    {
+        return $this->isPostAllowed($isPerformAction);
+    }
+
+    public function msgErrReplyAllowed ()
+    {
+        return $this->msgErrPostAllowed ();
+    }
+
+    public function isQuoteAllowed ($mixedCmt, $isPerformAction = false)
+    {
+        return $this->_bPostQuote && $this->isReplyAllowed ($mixedCmt, $isPerformAction);
+    }
+
+    public function msgErrQuoteAllowed ()
+    {
+        return $this->msgErrReplyAllowed ();
+    }
+
+    public function isPinAllowed ($aCmt, $isPerformAction = false)
+    {
+        if((int)$aCmt['cmt_parent_id'] != 0 || (int)$aCmt['cmt_pinned'] != 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+        
+        return $this->checkAction ('comments pin', $isPerformAction);
+    }
+
+    public function msgErrPinAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments pin');
+    }
+
+    public function isUnpinAllowed ($aCmt, $isPerformAction = false)
+    {
+        if((int)$aCmt['cmt_pinned'] == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+        
+        return $this->checkAction ('comments pin', $isPerformAction);
+    }
+
+    public function msgErrUnpinAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments pin');
+    }
+
+    public function isEditAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        if($this->isEditAllowedAll($isPerformAction))
+            return true;
+
+        $mixedResult = BxDolService::call($this->_aSystem['module'], 'check_allowed_comments_post', array($this->getId(), $this->getSystemName()));
+        if($mixedResult !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        return abs($aCmt['cmt_author_id']) == $this->_getAuthorId() && $this->checkAction ('comments edit own', $isPerformAction);
+    }
+
+    public function msgErrEditAllowed ()
+    {
+        return $this->checkActionErrorMsg ('comments edit own');
+    }
+
+    public function isEditAllowedAll ($isPerformAction = false)
+    {
+        return $this->checkAction('comments edit all', $isPerformAction);
+    }
+
+    public function isRemoveAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        if($this->isRemoveAllowedAll($isPerformAction))
+            return true;
+
+        if(abs($aCmt['cmt_author_id']) == $this->_getAuthorId() && $this->checkAction ('comments remove own', $isPerformAction))
+            return true;
+        
+        $aContentInfo = BxDolRequest::serviceExists($this->_aSystem['module'], 'get_all') ? BxDolService::call($this->_aSystem['module'], 'get_all', array(array('type' => 'id', 'id' => $this->getId()))) : array();
+        $oModule = BxDolModule::getInstance($this->_aSystem['module']);
+        $CNF = $oModule->_oConfig->CNF;
+        
+        if (isset($CNF['FIELD_AUTHOR']) && $aContentInfo[$CNF['FIELD_AUTHOR']] == $this->_getAuthorId() && $this->checkAction('comments remove in own content', $isPerformAction)){
+            return true;
+        }
+        
+        if (isset($CNF['FIELD_ALLOW_VIEW_TO']) &&  $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0){
+            $iGroupProfileId = -(int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']];
+            $oProfileContext = BxDolProfile::getInstance($iGroupProfileId);
+            $oModule = BxDolModule::getInstance($oProfileContext->getModule()); 
+            if ($oModule->_oConfig->isRoles() && $oModule->getRole($iGroupProfileId, $this->_getAuthorId()) === BX_BASE_MOD_GROUPS_ROLE_ADMINISTRATOR && $this->checkAction('comments remove in group context', $isPerformAction)){
+                return true;
+            }
+        }
+        
+        return false;
+    }
+
+    public function isRemoveAllowedAll ($isPerformAction = false)
+    {
+        return $this->checkAction ('comments remove all', $isPerformAction);
+    }
+
+    public function msgErrRemoveAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments remove own');
+    }
+
+    public function isMoreAllowed ($aCmt, $isPerformAction = false)
+    {
+        $oMenuManage = BxDolMenu::getObjectInstance($this->_sMenuObjManage);
+        $oMenuManage->setCmtsData($this, $aCmt['cmt_id']);
+    	return $oMenuManage->isVisible();
+    }
+
+    public function isModerator($isPerformAction = false)
+    {
+        return $this->isEditAllowedAll($isPerformAction) || $this->isRemoveAllowedAll($isPerformAction);
+    }
+
+    /**
+     * Actions functions
+     */
+    public function actionPin ()
+    {
+        if (!$this->isEnabled())
+            return echoJson(array());
+
+        $iCmtId = 0;
+        if(bx_get('Cmt') !== false)
+            $iCmtId = bx_process_input(bx_get('Cmt'), BX_DATA_INT);
+
+        $aCmt = $this->getCommentRow($iCmtId);
+        if(empty($aCmt) || !is_array($aCmt))
+            return echoJson(array());
+
+        $iWay = 0;
+        if(bx_get('way') !== false)
+            $iWay = bx_process_input(bx_get('way'), BX_DATA_INT);
+
+        if(!$this->_oQuery->updateComments(array('cmt_pinned' => $iWay ? time() : 0), array('cmt_id' => $iCmtId)))
+            return echoJson(array());
+
+        $aBp = $aDp = array();
+        $this->_getParams($aBp, $aDp);
+        $this->_prepareParams($aBp, $aDp);
+
+        echoJson(array(
+            'parent_id' => $aCmt['cmt_parent_id'],
+            'per_view' => $aBp['per_view']
+        ));
+    }
+    
+    public function actionGetFormPost ()
+    {
+        if (!$this->isEnabled())
+            return '';
+
+        $iCmtParentId = bx_get('CmtParent');
+        $iCmtParentId = $iCmtParentId !== false ? bx_process_input($iCmtParentId, BX_DATA_INT) : 0;
+        
+        $sCmtBrowse = bx_get('CmtBrowse');
+        $sCmtBrowse = $sCmtBrowse !== false ? bx_process_input($sCmtBrowse, BX_DATA_TEXT) : '';
+
+        $sCmtDisplay = bx_get('CmtDisplay');
+        $sCmtDisplay = $sCmtDisplay !== false ? bx_process_input($sCmtDisplay, BX_DATA_TEXT) : '';
+
+        $bQuote = bx_get('CmtQuote');
+        $bQuote = $bQuote !== false ? bx_process_input($bQuote, BX_DATA_INT) == 1 : false;
+
+        $bMinPostForm = bx_get('CmtMinPostForm');
+        $bMinPostForm = $bMinPostForm !== false ? bx_process_input($bMinPostForm, BX_DATA_INT) == 1 : $this->_bMinPostForm;
+
+        return $this->getFormBoxPost(array('parent_id' => $iCmtParentId, 'type' => $sCmtBrowse), array('type' => $sCmtDisplay, 'dynamic_mode' => true, 'quote' => $bQuote, 'min_post_form' => $bMinPostForm));
+    }
+
+    public function actionGetFormEdit ()
+    {
+        if (!$this->isEnabled())
+            return echoJson(array());
+
+        $iCmtId = bx_process_input(bx_get('Cmt'), BX_DATA_INT);
+        echoJson($this->getFormEdit($iCmtId, array('dynamic_mode' => true)));
+    }
+
+    public function actionGetCmt ()
+    {
+        if(!$this->isEnabled())
+            return '';
+
+        if($this->isViewAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return '';
+
+        $iCmtId = bx_process_input($_REQUEST['Cmt'], BX_DATA_INT);
+        $sCmtBrowse = isset($_REQUEST['CmtBrowse']) ? bx_process_input($_REQUEST['CmtBrowse'], BX_DATA_TEXT) : '';
+        $sCmtDisplay = isset($_REQUEST['CmtDisplay']) ? bx_process_input($_REQUEST['CmtDisplay'], BX_DATA_TEXT) : '';
+
+        $aCmt = $this->getCommentRow($iCmtId);
+        echoJson([
+            'parent_id' => $aCmt['cmt_parent_id'],
+            'vparent_id' => $aCmt['cmt_parent_id'],
+            'content' => $this->getComment($aCmt, ['type' => $sCmtBrowse], ['type' => $sCmtDisplay, 'dynamic_mode' => true])
+        ]);
+    }
+
+    public function actionGetCmts ()
+    {
+        if (!$this->isEnabled())
+            return '';
+
+        if($this->isViewAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return '';
+
+        $aBp = $aDp = array();
+        $this->_getParams($aBp, $aDp);
+
+        $aDp['dynamic_mode'] = true;
+
+        //--- Beg: Using pregenerated structure
+        if(isset($aDp['structure']) && $aDp['structure'] === true) {
+            $iCmtId = isset($aBp['parent_id']) ? (int)$aBp['parent_id'] : 0;
+
+            $mixedStructure = $this->getCommentStructure($iCmtId, $aBp, $aDp);
+            if($mixedStructure === false || empty($mixedStructure[$iCmtId]['items'])) 
+                return '';
+
+            $aDp['structure'] = $mixedStructure[$iCmtId]['items'];
+            return $this->getCommentsByStructure($aBp, $aDp);
+        }
+        //--- End: Using pregenerated structure
+        else
+            return $this->{'getComments' . ($aBp['pinned'] ? 'Pinned' : '')}($aBp, $aDp);
+    }
+
+    public function actionSubmitPostForm()
+    {
+        if(!$this->isEnabled())
+            return echoJson(array());
+
+        $iCmtParentId = 0;
+        if(bx_get('cmt_parent_id') !== false)
+            $iCmtParentId = bx_process_input(bx_get('cmt_parent_id'), BX_DATA_INT);
+
+        echoJson($this->getFormPost($iCmtParentId, array('dynamic_mode' => true)));
+    }
+
+    public function actionSubmitEditForm()
+    {
+        if (!$this->isEnabled()) {
+            echoJson(array());
+            return;
+        };
+
+        $iCmtId = 0;
+        if(bx_get('cmt_id') !== false)
+            $iCmtId = bx_process_input(bx_get('cmt_id'), BX_DATA_INT);
+
+        echoJson($this->getFormEdit($iCmtId, array('dynamic_mode' => true)));
+    }
+
+    public function actionRemove()
+    {
+        if (!$this->isEnabled())
+            return echoJson([]);
+
+        $iCmtId = 0;
+        if(bx_get('Cmt') !== false)
+            $iCmtId = bx_process_input(bx_get('Cmt'), BX_DATA_INT);
+
+        echoJson($this->remove($iCmtId));
+    }
+    
+    public function remove($iCmtId)
+    {
+        $aCmt = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iCmtId));
+        if(!$aCmt)
+            return array('msg' => _t('_No such comment'));
+        
+        $iObjId = $this->getId();
+        if(!$iObjId){
+            $this->setId($aCmt['cmt_object_id']);
+            $iObjId = $aCmt['cmt_object_id'];
+        }
+        $iObjAthrId = $this->getObjectAuthorId($iObjId);
+        $iObjAthrPrivacyView = $this->getObjectPrivacyView($iObjId);
+
+        if($aCmt['cmt_replies'] > 0) {
+            if(!$this->isModerator())
+                return array('msg' => _t('_Can not delete comments with replies'));
+
+            $aReplies = $this->_oQuery->getCommentsBy(array('type' => 'parent_id', 'parent_id' => $iCmtId));
+            foreach($aReplies as $aReply)
+                $this->remove($aReply['cmt_id']);
+        }
+
+        $iPerformerId = $this->_getAuthorId();
+        if(!$this->isRemoveAllowed($aCmt))
+            return array('msg' => $aCmt['cmt_author_id'] == $iPerformerId ? strip_tags($this->msgErrRemoveAllowed()) : _t('_Access denied'));
+
+        $iCmtPrntId = (int)$aCmt['cmt_parent_id'];
+        if(!$this->_oQuery->removeComment($iObjId, $iCmtId, $iCmtPrntId)) 
+            return array('msg' => _t('_cmt_err_cannot_perform_action'));
+
+        $this->_triggerComment();
+
+        $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+
+        $aImages = $this->_oQuery->getFiles($this->_aSystem['system_id'], $iCmtId);
+        foreach($aImages as $aImage)
+            $oStorage->deleteFile($aImage['image_id']);
+
+        $this->_oQuery->deleteImages($this->_aSystem['system_id'], $iCmtId);
+
+        $this->isRemoveAllowed($aCmt, true);
+
+        $this->deleteVotes($iCmtId);
+
+        $this->deleteReactions($iCmtId);
+
+        $this->deleteScores($iCmtId);
+
+        $this->deleteReports($iCmtId);
+
+        $this->deleteMetaInfo($iCmtId);
+
+        $aAuditParams = $this->_prepareAuditParams($iCmtId, array('comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']));
+        bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_delete_comment', $aAuditParams);
+
+        $aAlertParams = $this->_prepareAlertParams($aCmt);
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-commentRemoved '{object_name}', 'commentRemoved' - hook after a comment was removed
+         * - $unit_name - comment object name
+         * - $action - equals `commentRemoved`
+         * - $object_id - commented object id
+         * - $sender_id - profile id who performed the action
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `source` - [string] unique comment source string
+         *      - `object_system` - [string] comment object name
+         *      - `object_id` - [int] commented object id
+         *      - `object_author_id` - [int] commented object author profile id
+         *      - `comment_id` - [int] comment id unique in the comment object scope
+         *      - `comment_uniq_id` - [int] system wide unique comment id
+         *      - `comment_author_id` - [int] comment author profile id
+         *      - `comment_text` - [string] comment text
+         *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+         *      - `cf` - [int] comment's audience filter value
+         * @hook @ref hook-bx_dol_comment-commentRemoved
+         */
+        $this->_callAlert($this->_sSystem, 'commentRemoved', $iObjId, $iPerformerId, $aAlertParams);
+        
+        /**
+         * @hooks
+         * @hookdef hook-comment-deleted 'comment', 'deleted' - hook after a comment was removed
+         * It's equivalent to @ref hook-bx_dol_comment-commentRemoved
+         * except 'comment id' is provided in $object_id
+         * @hook @ref hook-comment-deleted
+         */
+        $this->_callAlert('comment', 'deleted', $iCmtId, $iPerformerId, $aAlertParams);
+
+        if(!empty($iCmtPrntId)) {
+            $aCmtPrnt = $this->_oQuery->getCommentSimple($iObjId, $iCmtPrntId);
+            if(!empty($aCmtPrnt) && is_array($aCmtPrnt)) {
+                $aAlertParamsReply = $this->_prepareAlertParamsReply($aCmt, $aCmtPrnt);
+
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_comment-replyRemoved '{object_name}', 'replyRemoved' - hook after a reply was removed
+                 * - $unit_name - comment object name
+                 * - $action - equals `replyRemoved`
+                 * - $object_id - commented object id
+                 * - $sender_id - profile id who performed the action
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `source` - [string] unique comment source string
+                 *      - `object_system` - [string] comment object name
+                 *      - `object_id` - [int] commented object id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `parent_id` - [int] parent comment id unique in the comment object scope
+                 *      - `parent_uniq_id` - [int] system wide unique parent comment id
+                 *      - `parent_author_id` - [int] parent comment author profile id
+                 *      - `comment_id` - [int] comment id unique in the comment object scope
+                 *      - `comment_uniq_id` - [int] system wide unique comment id
+                 *      - `comment_author_id` - [int] comment author profile id
+                 *      - `comment_text` - [string] comment text
+                 *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+                 * @hook @ref hook-bx_dol_comment-replyRemoved
+                 */
+                $this->_callAlert($this->_sSystem, 'replyRemoved', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-reply-deleted 'reply', 'deleted' - hook after a reply was removed
+                 * It's equivalent to @ref hook-bx_dol_comment-replyRemoved
+                 * except 'comment id' is provided in $object_id
+                 * @hook @ref hook-reply-deleted
+                 */
+                $this->_callAlert('reply', 'deleted', $iCmtId, $iPerformerId, $aAlertParamsReply);
+            }
+        }
+
+        $this->deleteUniqueIds($iCmtId);
+
+        $iCount = (int)$this->getCommentsCountAll(0, true);
+        $aResult = [
+            'id' => $iCmtId,
+            'count' => $iCount,
+            'countf' => $iCount > 0 ? $this->getCounter() : ''
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $oSockets->sendEvent($this->getSocketName(), $iObjId, 'comment_deleted', json_encode(array_merge($aResult, [
+                'author_id' => $iPerformerId
+            ])));
+
+            /**
+             * Send 'content edited' event.
+             * Note. 0 is used as $iContentId to allow to subscribe on all events at once.
+             */
+            $oSockets->sendEvent($this->_aSystem['module'], 0, 'edited', json_encode([
+                'id' => $iObjId,
+                'author_id' => $this->getObjectAuthorId(),
+                'cmt_author_id' => $aCmt['cmt_author_id'],
+                'peformer_id' => $iPerformerId
+            ]));
+        }
+
+        return $aResult;
+    }
+
+    public function add($aValues)
+    {
+        return $this->_getFormDirectAdd($aValues);
+    }
+
+    public function edit($aValues, $iCmtId)
+    {
+        return $this->_getFormDirectEdit($aValues, $iCmtId);
+    }
+
+    public function actionResumeLiveUpdate()
+    {
+    	$sKey = $this->getNotificationId();
+
+    	bx_import('BxDolSession');
+    	BxDolSession::getInstance()->unsetValue($sKey);
+    }
+
+    public function actionPauseLiveUpdate()
+    {
+    	$sKey = $this->getNotificationId();
+
+    	bx_import('BxDolSession');
+    	BxDolSession::getInstance()->setValue($sKey, 1);
+    }
+
+    public function actionGetSiblingFiles()
+    {
+        $iFileId = 0;
+        if(bx_get('file_id') !== false)
+            $iFileId = bx_process_input(bx_get('file_id'), BX_DATA_INT);
+
+        if(!($aFileInfo = $this->_oQuery->getFileInfoById((int)$iFileId))) 
+            return echoJson(['error' => _t('_sys_txt_error_occured')]);
+
+        $aSiblings = [];
+        $aFiles = $this->_oQuery->getFiles($aFileInfo['system_id'], $aFileInfo['cmt_id']);
+        foreach($aFiles as $iIndex => $aFile) {
+            if($aFile['id'] != $iFileId)
+                continue;
+
+            $aSiblings['prev'] = isset($aFiles[$iIndex - 1]) ? $aFiles[$iIndex - 1] : false;
+            $aSiblings['next'] = isset($aFiles[$iIndex + 1]) ? $aFiles[$iIndex + 1] : false;
+        }
+
+        $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+        $oTranscoder = BxDolTranscoderImage::getObjectInstance($this->getTranscoderPreviewName());
+
+        foreach($aSiblings as $sSibling => $aSibling) {
+            if(!$aSibling)
+                continue;
+
+            $sFile = '';
+            if($oTranscoder && $oTranscoder->isMimeTypeSupported($aSibling['mime_type']))
+                $sFile = $oStorage->getFileUrlById($aSibling['image_id']);
+            else
+                $sFile = $this->_oTemplate->getIconUrl($oStorage->getIconNameByFileName($aFile['file_name']));
+
+            $aImageInfo = BxDolImageResize::getInstance()->getImageSize($sFile);
+
+            $iWidth = $iHeight = 0;
+            if(isset($aImageInfo['w']))
+                $iWidth = (int)$aImageInfo['w'];
+            if(isset($aImageInfo['h']))
+                $iHeight = (int)$aImageInfo['h'];
+
+            $aSiblings[$sSibling] = array_merge($aSibling, [
+                'file' => $sFile,
+                'file_id' => $aSibling['id'],
+                'w' => $iWidth, 
+                'h' => $iHeight
+            ]);
+        }
+
+        return echoJson($aSiblings);
+    }
+
+    public function onPostAfter($iCmtId, $aDp = [])
+    {
+        $iObjId = (int)$this->getId();
+
+        $aCmt = $this->_oQuery->getCommentSimple($iObjId, $iCmtId);
+        if(empty($aCmt) || !is_array($aCmt))
+            return false;
+
+        $iCmtPrntId = (int)$aCmt['cmt_parent_id'];
+        $iPerformerId = (int)$aCmt['cmt_author_id'];
+
+        $aAlertParams = $this->_prepareAlertParams($aCmt);
+        
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-commentPost '{object_name}', 'commentPost' - hook after a comment was added
+         * - $unit_name - comment object name
+         * - $action - equals `commentPost`
+         * - $object_id - commented object id
+         * - $sender_id - profile id who performed the action
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `source` - [string] unique comment source string
+         *      - `object_system` - [string] comment object name
+         *      - `object_id` - [int] commented object id
+         *      - `object_author_id` - [int] commented object author profile id
+         *      - `comment_id` - [int] comment id unique in the comment object scope
+         *      - `comment_uniq_id` - [int] system wide unique comment id
+         *      - `comment_author_id` - [int] comment author profile id
+         *      - `comment_text` - [string] comment text
+         *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+         *      - `cf` - [int] comment's audience filter value
+         * @hook @ref hook-bx_dol_comment-commentPost
+         */
+        $this->_callAlert($this->_sSystem, 'commentPost', $iObjId, $iPerformerId, $aAlertParams);
+        
+        /**
+         * @hooks
+         * @hookdef hook-comment-added 'comment', 'added' - hook after a comment was added
+         * It's equivalent to @hook @ref hook-bx_dol_comment-commentPost
+         * except 'comment id' is provided in $object_id
+         * @hook @ref hook-comment-added
+         */
+        $this->_callAlert('comment', 'added', $iCmtId, $iPerformerId, $aAlertParams);
+
+        $aAuditParams = $this->_prepareAuditParams($iCmtId, array('comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']));
+        bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_add_comment', $aAuditParams);
+
+        if(!empty($iCmtPrntId)) {
+            $aCmtPrnt = $this->_oQuery->getCommentSimple($iObjId, $iCmtPrntId);
+            if(!empty($aCmtPrnt) && is_array($aCmtPrnt)) {
+                $aAlertParamsReply = $this->_prepareAlertParamsReply($aCmt, $aCmtPrnt);
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_comment-replyPost '{object_name}', 'replyPost' - hook after a reply was added
+                 * - $unit_name - comment object name
+                 * - $action - equals `replyPost`
+                 * - $object_id - parent comment id
+                 * - $sender_id - profile id who performed the action
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `source` - [string] unique comment source string
+                 *      - `object_system` - [string] comment object name
+                 *      - `object_id` - [int] commented object id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `parent_id` - [int] parent comment id unique in the comment object scope
+                 *      - `parent_uniq_id` - [int] system wide unique parent comment id
+                 *      - `parent_author_id` - [int] parent comment author profile id
+                 *      - `comment_id` - [int] comment id unique in the comment object scope
+                 *      - `comment_uniq_id` - [int] system wide unique comment id
+                 *      - `comment_author_id` - [int] comment author profile id
+                 *      - `comment_text` - [string] comment text
+                 *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+                 * @hook @ref hook-bx_dol_comment-replyPost
+                 */
+                $this->_callAlert($this->_sSystem, 'replyPost', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-comment-replied 'comment', 'replied' - hook after a reply was added
+                 * It's equivalent to @ref hook-bx_dol_comment-replyPost
+                 * except 'comment id' is provided in $object_id
+                 * @hook @ref hook-comment-replied
+                 */
+                $this->_callAlert('comment', 'replied', $iCmtId, $iPerformerId, $aAlertParamsReply);
+            }
+        }
+
+        $iCount = (int)$this->getCommentsCountAll(0, true);
+        $aResult = [
+            'id' => $iCmtId, 
+            'parent_id' => $iCmtPrntId,
+            'count' => $iCount,
+            'countf' => $iCount > 0 ? $this->getCounter(['show_script' => false]) : ''
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $oSockets->sendEvent($this->getSocketName(), $iObjId, 'comment_added', json_encode(array_merge($aResult, [
+                'author_id' => $iPerformerId, 
+                'anchor' => $this->getItemAnchor($iCmtId)
+            ])));
+
+            /**
+             * Send 'content edited' event.
+             * Note. 0 is used as $iContentId to allow to subscribe on all events at once.
+             */
+            $oSockets->sendEvent($this->_aSystem['module'], 0, 'edited', json_encode([
+                'id' => $iObjId,
+            ]));
+        }
+
+        return $aResult;
+    }
+
+    public function onEditAfter($iCmtId, $aDp = [])
+    {
+    	$aCmt = $this->getCommentRow($iCmtId);
+        if(empty($aCmt) || !is_array($aCmt))
+            return false;
+
+        $aBp = [];
+        $this->_getParams($aBp, $aDp);
+
+        $iObjId = (int)$this->getId();
+        $iPerformerId = $this->_getAuthorId();
+
+        $aAlertParams = $this->_prepareAlertParams($aCmt);
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-commentUpdated '{object_name}', 'commentUpdated' - hook after a comment was updated (edited)
+         * - $unit_name - comment object name
+         * - $action - equals `commentUpdated`
+         * - $object_id - commented object id
+         * - $sender_id - profile id who performed the action
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `source` - [string] unique comment source string
+         *      - `object_system` - [string] comment object name
+         *      - `object_id` - [int] commented object id
+         *      - `object_author_id` - [int] commented object author profile id
+         *      - `comment_id` - [int] comment id unique in the comment object scope
+         *      - `comment_uniq_id` - [int] system wide unique comment id
+         *      - `comment_author_id` - [int] comment author profile id
+         *      - `comment_text` - [string] comment text
+         *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+         *      - `cf` - [int] comment's audience filter value
+         * @hook @ref hook-bx_dol_comment-commentUpdated
+         */
+        $this->_callAlert($this->_sSystem, 'commentUpdated', $iObjId, $iPerformerId, $aAlertParams);
+        
+        /**
+         * @hooks
+         * @hookdef hook-comment-edited 'comment', 'edited' - hook after a comment was updated (edited)
+         * It's equivalent to @ref hook-bx_dol_comment-commentUpdated
+         * except 'comment id' is provided in $object_id
+         * @hook @ref hook-comment-edited
+         */
+        $this->_callAlert('comment', 'edited', $iCmtId, $iPerformerId, $aAlertParams);
+
+        $aAuditParams = $this->_prepareAuditParams($iCmtId, ['comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']]);
+        bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_edit_comment', $aAuditParams);
+
+        $aResult = [
+            'id' => $iCmtId, 
+            'content' => $this->_getContent($aCmt, $aBp, $aDp)
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $oSockets->sendEvent($this->getSocketName(), $iObjId, 'comment_edited', json_encode(array_merge($aResult, [
+                'author_id' => $iPerformerId, 
+                'anchor' => $this->getItemAnchor($iCmtId)
+            ])));
+
+            /**
+             * Send 'content edited' event.
+             * Note. 0 is used as $iContentId to allow to subscribe on all events at once.
+             */
+            $oSockets->sendEvent($this->_aSystem['module'], 0, 'edited', json_encode([
+                'id' => $iObjId,
+            ]));
+        }
+
+        return $aResult;
+    }
+
+    public function serviceGetAuthor ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->init($aComment['cmt_object_id']);
+
+        return $aComment['cmt_author_id'];
+    }
+
+    public function serviceGetDateAdded ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->init($aComment['cmt_object_id']);
+
+        return $aComment['cmt_time'];
+    }
+
+    public function serviceGetDateChanged ($iContentId)
+    {
+        return 0;
+    }
+    public function serviceGetLink ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->init($aComment['cmt_object_id']);
+
+        return $this->getViewUrl($iContentId);
+    }
+
+    public function serviceGetTitle ($iContentId)
+    {
+        return '';
+    }
+
+    public function serviceGetText ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->init($aComment['cmt_object_id']);
+
+        return $aComment['cmt_text'];
+    }
+
+    public function serviceGetThumb ($iContentId)
+    {
+        return '';
+    }
+
+    public function serviceGetInfo ($iContentId, $bSearchableFieldsOnly = true)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->init($aComment['cmt_object_id']);
+
+        return BxDolContentInfo::formatFields($aComment);
+    }
+
+    public function serviceGetInfoApi ($iContentId, $bExtendedUnits = false)
+    {
+        $aData = $this->serviceGetInfo($iContentId, false);
+        if($aData)
+            $aData = $this->getDataAPI($aData, ['extended' => $bExtendedUnits]);
+
+        return $aData;
+    }
+
+    public function serviceGetSearchResultUnit ($iContentId, $sUnitTemplate = '')
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        if(empty($aComment) || !is_array($aComment))
+            return '';
+
+        $this->init($aComment['cmt_object_id']);
+
+        return $this->getComment($aComment, array(), array('type' => BX_CMT_DISPLAY_FLAT, 'view_only' => true));
+    }
+
+    public function serviceGetAll ($aParams = array())
+    {
+        if(empty($aParams) || !is_array($aParams))
+            $aParams = array('type' => 'all');
+
+        return $this->_oQuery->getCommentsBy($aParams);
+    }
+
+    public function serviceGetSearchableFieldsExtended($aInputsAdd = array())
+    {
+        $oForm = BxDolForm::getObjectInstance('sys_comment', 'sys_comment_post', $this->_oTemplate);
+        if(!$oForm)
+            return array();
+
+        $aSrchNamesExcept = array();
+        $aSrchCaptionsSystem = array(
+            'cmt_author_id' => '_sys_form_comment_input_caption_system_cmt_author_id',
+            'cmt_text' => '_sys_form_comment_input_caption_system_cmt_text'
+        );
+        $aSrchCaptions = array(
+            'cmt_author_id' => '_sys_form_comment_input_caption_cmt_author_id',
+            'cmt_text' => '_sys_form_comment_input_caption_cmt_text'
+        );
+
+        $aResult = array(
+            'cmt_author_id' => array(
+                'type' => 'text_auto', 
+                'caption_system' => $aSrchCaptionsSystem['cmt_author_id'],
+                'caption' => $aSrchCaptions['cmt_author_id'],
+                'info' => '',
+                'value' => '',
+                'values' => '',
+                'pass' => ''
+            )
+        );
+
+        $aInputs = $oForm->aInputs;
+        if(!empty($aInputsAdd) && is_array($aInputsAdd))
+            $aInputs = array_merge($aInputs, $aInputsAdd);
+
+        foreach($aInputs as $aInput)
+            if(in_array($aInput['type'], BxDolSearchExtended::$SEARCHABLE_TYPES) && !in_array($aInput['name'], $aSrchNamesExcept))
+                $aResult[$aInput['name']] = array(
+                    'type' => $aInput['type'], 
+                    'caption_system' => !empty($aInput['caption_system_src']) ? $aInput['caption_system_src'] : '',
+                    'caption' => !empty($aInput['caption_src']) ? $aInput['caption_src'] : (!empty($aSrchCaptions[$aInput['name']]) ? $aSrchCaptions[$aInput['name']] : ''),
+                    'info' => !empty($aInput['info_src']) ? $aInput['info_src'] : '',
+                    'value' => !empty($aInput['value']) ? $aInput['value'] : '',
+                    'values' => !empty($aInput['values_src']) ? $aInput['values_src'] : '',
+                    'pass' => !empty($aInput['db']['pass']) ? $aInput['db']['pass'] : ''
+                );
+
+        return $aResult;
+    }
+
+    /**
+     * Overwrite this method and register transcoder(s) if comments object uses custom transcoder(s), 
+     * which differs from default one 'sys_cmts_images_preview'
+     */
+    public function registerTranscoders()
+    {}
+
+	/**
+     * Overwrite this method and unregister transcoder(s) if comments object uses custom transcoder(s), 
+     * which differs from default one 'sys_cmts_images_preview'
+     */
+    public function unregisterTranscoders()
+    {}
+
+    public function serviceGetSearchResultExtended($aParams, $iStart = 0, $iPerPage = 0, $bFilterMode = false)
+    {
+        if((empty($aParams) || !is_array($aParams)) && !$bFilterMode)
+            return array();
+
+        return $this->_oQuery->getCommentsBy(array('type' => 'search_ids', 'search_params' => $aParams, 'start' => $iStart, 'per_page' => $iPerPage));
+    }
+
+    public function getAuthorInfo($iAuthorId = 0)
+    {
+        return $this->_getAuthorInfo($iAuthorId);
+    }
+    
+    public function getParams(&$aBp, &$aDp)
+    {
+        return $this->_getParams($aBp, $aDp);
+    }
+    
+    public function prepareParams(&$aBp, &$aDp)
+    {
+         return $this->_prepareParams($aBp, $aDp);
+    }
+
+    /**
+     * Internal functions
+     */
+    protected function _getAuthorId ()
+    {
+        return isMember() ? bx_get_logged_profile_id() : 0;
+    }
+
+    protected function _getAuthorPassword ()
+    {
+        return getLoggedPassword();
+    }
+
+    protected function _getAuthorIp ()
+    {
+        return getVisitorIP();
+    }
+
+    protected function _getAuthorInfo($iAuthorId = 0)
+    {
+        $iUserId = $this->_getAuthorId();
+        $iAuthorId = (int)$iAuthorId;
+        $oProfile = $this->_getAuthorObject($iAuthorId);
+        if (!$oProfile->isActive() && !isAdmin() && !BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR, MEMBERSHIP_ID_ADMINISTRATOR)))
+            $oProfile = BxDolProfileUndefined::getInstance();
+
+        return array(
+            $oProfile->getDisplayName(),
+            $oProfile->getUrl(),
+            $oProfile->getThumb(),
+            $oProfile->getUnit(0, array('template' => 'unit_wo_info')),
+            $oProfile->getBadges()
+        );
+    }
+
+    protected function _getAuthorObject($iAuthorId = 0)
+    {
+        return BxDolProfile::getInstanceMagic((int)$iAuthorId);
+    }
+
+    protected function _getFormObject($sAction = BX_CMT_ACTION_POST)
+    {
+        $sDisplayName = '_sFormDisplay' . ucfirst($sAction);
+
+        return BxDolForm::getObjectInstance($this->_sFormObject, $this->$sDisplayName, $this->_oTemplate, $this->_sSystem);
+    }
+    
+    protected function _unsetFormObject($sAction = BX_CMT_ACTION_POST)
+    {
+        $sDisplayName = '_sFormDisplay' . ucfirst($sAction);
+
+        return BxDolForm::unsetObjectInstance($this->_sFormObject, $this->$sDisplayName, $this->_oTemplate, $this->_sSystem);
+    }
+
+    protected function _getParams(&$aBp, &$aDp)
+    {
+        //--- Process 'Browse' params. 
+        $aBp['parent_id'] = isset($aBp['parent_id']) ? (int)$aBp['parent_id'] : 0;
+
+        $aBp['vparent_id'] = isset($aBp['vparent_id']) ? (int)$aBp['vparent_id'] : 0;
+        if(bx_get('CmtParent') !== false)
+            $aBp['vparent_id'] = bx_process_input(bx_get('CmtParent'), BX_DATA_INT);
+
+    	$aBp['type'] = isset($aBp['type']) ? $aBp['type'] : '';
+    	if(bx_get('CmtBrowse') !== false) 
+        	$aBp['type'] = bx_process_input(bx_get('CmtBrowse'), BX_DATA_TEXT);
+
+    	$aBp['filter'] = isset($aBp['filter']) ? $aBp['filter'] : '';
+    	if(bx_get('CmtFilter') !== false) 
+    	    $aBp['filter'] = bx_process_input(bx_get('CmtFilter'), BX_DATA_TEXT);
+
+        $aBp['start'] = isset($aBp['start']) ? (int)$aBp['start'] : -1;
+        if(bx_get('CmtStart') !== false) 
+            $aBp['start'] = bx_process_input($_REQUEST['CmtStart'], BX_DATA_INT);
+
+        $aBp['per_view'] = isset($aBp['per_view']) ? (int)$aBp['per_view'] : -1;
+        if(bx_get('CmtPerView') !== false) 
+            $aBp['per_view'] = bx_process_input($_REQUEST['CmtPerView'], BX_DATA_INT);
+
+        $aBp['pinned'] = isset($aBp['pinned']) ? (int)$aBp['pinned'] : 0;
+        if(bx_get('CmtPinned') !== false) 
+            $aBp['pinned'] = bx_process_input(bx_get('CmtPinned'), BX_DATA_INT);
+
+        //--- Process 'Display' params.
+        $aDp['type'] = isset($aDp['type']) ? $aDp['type'] : '';
+        if(bx_get('CmtDisplay') !== false) 
+            $aDp['type'] = bx_process_input($_REQUEST['CmtDisplay'], BX_DATA_TEXT);
+
+        if(bx_get('CmtDisplayStructure') !== false) {
+            $aDp['structure'] = bx_process_input($_REQUEST['CmtDisplayStructure'], BX_DATA_INT) == 1;
+
+            if($aDp['structure'] && bx_get('CmtParent') !== false)
+                $aBp['parent_id'] = bx_process_input(bx_get('CmtParent'), BX_DATA_INT);
+        }
+
+        $aDp['blink'] = isset($aDp['blink']) ? $aDp['blink'] : '';
+        if(bx_get('CmtBlink') !== false) 
+            $aDp['blink'] = bx_process_input($_REQUEST['CmtBlink'], BX_DATA_TEXT);
+
+        $aDp['quote'] = isset($aDp['quote']) ? (int)$aDp['quote'] : 0;
+        if(bx_get('CmtQuote') !== false) 
+            $aDp['quote'] = bx_process_input($_REQUEST['CmtQuote'], BX_DATA_INT);
+
+        $aDp['min_post_form'] = isset($aDp['min_post_form']) ? (bool)$aDp['min_post_form'] : $this->_bMinPostForm;
+        if(bx_get('CmtMinPostForm') !== false)
+            $aDp['min_post_form'] = bx_process_input(bx_get('CmtMinPostForm'), BX_DATA_INT) == 1;
+
+        $aDp['in_designbox'] = isset($aDp['in_designbox']) ? (bool)$aDp['in_designbox'] : true;
+        $aDp['dynamic_mode'] = isset($aDp['dynamic_mode']) ? (bool)$aDp['dynamic_mode'] : false;
+        $aDp['show_empty'] = isset($aDp['show_empty']) ? (bool)$aDp['show_empty'] : false;
+    }
+
+    protected function _callAlert($sUnit, $sAction, $iObjectId, $iSender = false, $aExtras = [])
+    {
+        return bx_alert($sUnit, $sAction, $iObjectId, $iSender, $aExtras);
+    }
+
+    protected function _prepareAlertParams($aCmt)
+    {
+        $iObjId = (int)$this->getId();
+        $iObjAthrId = $this->getObjectAuthorId($iObjId);
+        $iObjAthrPrivacyView = $this->getObjectPrivacyView($iObjId);
+
+        $iCmtId = (int)$aCmt['cmt_id'];
+        $iCmtUniqId = $this->getCommentUniqId($iCmtId);
+        $iCmtAthrId = (int)$aCmt['cmt_author_id'];
+        $iCmtCf = isset($aCmt['cmt_cf']) ? (int)$aCmt['cmt_cf'] : BxDolContentFilter::getInstance()->getDefaultValue();
+
+        return [
+            'source' => 'sys_cmts_' . $iCmtUniqId,
+            'source_mac' => 'sys_cmts_' . $iCmtAthrId . '_' . $iCmtUniqId,
+
+            'object_system' => $this->_sSystem, 
+            'object_id' => $iObjId, 
+            'object_author_id' => $iObjAthrId,
+
+            'comment_id' => $iCmtId, 
+            'comment_uniq_id' => $iCmtUniqId,
+            'comment_author_id' => $iCmtAthrId, 
+            'comment_parent_id' => $aCmt['cmt_parent_id'], 
+            'comment_text' => $aCmt['cmt_text'],
+
+            'privacy_view' => $iObjAthrPrivacyView,
+            'cf' => $iCmtCf
+        ];
+    }
+
+    protected function _prepareAlertParamsReply($aCmt, $aCmtPrnt)
+    {
+        $iObjId = (int)$this->getId();
+        $iObjAthrId = $this->getObjectAuthorId($iObjId);
+        $iObjAthrPrivacyView = $this->getObjectPrivacyView($iObjId);
+
+        $iCmtPrntId = (int)$aCmt['cmt_parent_id'];
+        $iCmtPrntUniqId = $this->getCommentUniqId($iCmtPrntId);
+
+        $iCmtId = (int)$aCmt['cmt_id'];
+        $iCmtUniqId = $this->getCommentUniqId($iCmtId);
+        $iCmtAthrId = (int)$aCmt['cmt_author_id'];
+
+        return [
+            'source' => 'sys_cmts_' . $iCmtUniqId,
+            'source_mac' => 'sys_cmts_' . $iCmtAthrId . '_' . $iCmtUniqId,
+
+            'object_system' => $this->_sSystem,
+            'object_id' => $iObjId,
+            'object_author_id' => $iObjAthrId,
+
+            'parent_id' => $iCmtPrntId,
+            'parent_uniq_id' => $iCmtPrntUniqId,
+            'parent_author_id' => $aCmtPrnt['cmt_author_id'],
+
+            'comment_id' => $iCmtId,
+            'comment_uniq_id' => $iCmtUniqId,
+            'comment_author_id' => $iCmtAthrId,
+            'comment_text' => $aCmt['cmt_text'],
+
+            'privacy_view' => $iObjAthrPrivacyView,
+        ];
+    }
+
+    protected function _prepareAuditParams($iId, $aData)
+    {
+        $sModule = $this->_aSystem['module'];
+        $oModule = BxDolModule::getInstance($sModule);
+        $CNF = isset($oModule->_oConfig->CNF) ? $oModule->_oConfig->CNF : array();
+
+        $aContentInfo = BxDolRequest::serviceExists($sModule, 'get_all') ? BxDolService::call($sModule, 'get_all', array(array('type' => 'id', 'id' => $this->getId()))) : array();
+        $iContextId = 0;
+        if (!empty($aContentInfo)){
+            $iContextId = isset($CNF['FIELD_ALLOW_VIEW_TO']) && (!empty($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) && (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0) ? - $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] : 0; 
+        }
+        
+        $AuditParams = array(
+            'content_title' => $this->getObjectTitle() ,
+            'context_profile_id' => $iContextId,
+            'content_info_object' =>  isset($CNF['OBJECT_CMTS_CONTENT_INFO']) ? $CNF['OBJECT_CMTS_CONTENT_INFO'] : '',
+            'data' => array_merge(array('comment_id' => $iId), $aData)
+        );
+        if ($iContextId > 0)
+            $AuditParams['context_profile_title'] = BxDolProfile::getInstance($iContextId)->getDisplayName();
+        
+        return $AuditParams;
+    }
+
+    protected function _prepareTextForOutput ($s, $iCmtId = 0)
+    {
+    	$iDataAction = !$this->isHtml() ? BX_DATA_TEXT_MULTILINE : BX_DATA_HTML;
+    	$s = bx_process_output($s, $iDataAction);
+    	$s = bx_linkify_html($s, 'class="' . BX_DOL_LINK_CLASS . '"');
+
+        if($iCmtId && $this->_sMetatagsObj && ($oMetatags = BxDolMetatags::getObjectInstance($this->_sMetatagsObj)) !== false)
+            $s = $oMetatags->metaParse($this->_oQuery->getUniqId($this->_aSystem['system_id'], $iCmtId), $s);
+
+        return $s;
+    }
+
+    protected function _prepareStructureBp($sDType, &$aBp)
+    {
+        $aBp['type'] = !empty($aBp['type']) ? $aBp['type'] : $this->_sBrowseType;
+        $aBp['filter'] = !empty($aBp['filter']) ? $aBp['filter'] : $this->_sBrowseFilter;
+        $aBp['parent_id'] = isset($aBp['parent_id']) ? $aBp['parent_id'] : 0;
+        $aBp['start'] = isset($aBp['start']) ? $aBp['start'] : -1;
+        $aBp['init_view'] = isset($aBp['init_view']) ? $aBp['init_view'] : -1;
+        $aBp['per_view'] = isset($aBp['per_view']) ? $aBp['per_view'] : -1;
+        $aBp['order']['by'] = isset($aBp['order_by']) ? $aBp['order_by'] : $this->_aOrder['by'];
+        $aBp['order']['way'] = isset($aBp['order_way']) ? $aBp['order_way'] : $this->_aOrder['way'];
+
+        if($aBp['per_view'] == -1)
+            switch($sDType) {
+                case BX_CMT_DISPLAY_FLAT:
+                    $aBp['per_view'] = $this->getPerView(0);
+                    break;
+
+                case BX_CMT_DISPLAY_THREADED:
+                    $aBp['per_view'] = $this->getPerView($aBp['parent_id']);
+                    break;
+            }
+    }
+
+    protected function _prepareParams(&$aBp, &$aDp)
+    {
+        $aBp['type'] = !empty($aBp['type']) ? $aBp['type'] : $this->_sBrowseType;
+        $aBp['filter'] = !empty($aBp['filter']) ? $aBp['filter'] : $this->_sBrowseFilter;
+        $aBp['parent_id'] = isset($aBp['parent_id']) ? $aBp['parent_id'] : 0;
+        $aBp['start'] = isset($aBp['start']) ? $aBp['start'] : -1;
+        $aBp['init_view'] = isset($aBp['init_view']) ? $aBp['init_view'] : -1;
+        $aBp['per_view'] = isset($aBp['per_view']) ? $aBp['per_view'] : -1;
+        $aBp['pinned'] = isset($aBp['pinned']) ? (int)$aBp['pinned'] : 0;
+        $aBp['order']['by'] = isset($aBp['order_by']) ? $aBp['order_by'] : $this->_aOrder['by'];
+        $aBp['order']['way'] = isset($aBp['order_way']) ? $aBp['order_way'] : $this->_aOrder['way'];
+
+        $aDp['type'] = !empty($aDp['type']) ? $aDp['type'] : $this->_sDisplayType;
+        $aDp['blink'] = !empty($aDp['blink']) ? $aDp['blink'] : array();
+        if(!is_array($aDp['blink']))
+            $aDp['blink'] = explode(',', $aDp['blink']);
+        $aDp['quote'] = !empty($aDp['quote']) ? (int)$aDp['quote'] : 0;
+        if(!isset($aDp['min_post_form']))
+            $aDp['min_post_form'] = $this->_bMinPostForm;
+
+        switch($aDp['type']) {
+            case BX_CMT_DISPLAY_FLAT:
+                $aBp['vparent_id'] = -1;
+                $aBp['per_view'] = $aBp['per_view'] != -1 ? $aBp['per_view'] : $this->getPerView(0);
+                break;
+
+            case BX_CMT_DISPLAY_THREADED:
+                $iParent = 0;
+                if(isset($aBp['vparent_id']))
+                    $iParent = $aBp['vparent_id'];
+                else if(isset($aBp['parent_id']))
+                    $iParent = $aBp['parent_id'];
+
+                $aBp['per_view'] = $aBp['per_view'] != -1 ? $aBp['per_view'] : $this->getPerView($iParent);
+                break;
+        }
+
+        switch ($aBp['type']) {
+            case BX_CMT_BROWSE_POPULAR:
+                $aBp['order'] = array(
+                    'by' => BX_CMT_ORDER_BY_POPULAR,
+                    'way' => BX_CMT_ORDER_WAY_DESC
+                );
+                break;
+        }
+
+        if(!isset($aBp['count']))
+            $aBp['count'] = $this->getCommentsCount($this->_iId, $aBp['vparent_id'], $aBp['filter']);
+
+        if($aBp['start'] != -1)
+            return;
+
+        $aBp['start'] = 0;
+        if($aBp['type'] == BX_CMT_BROWSE_TAIL) {
+            $sPerView = ($aBp['init_view'] != -1 ? 'init' : 'per') . '_view';
+
+            $aBp['start'] = $aBp['count'] - $aBp[$sPerView];
+            if($aBp['start'] < 0) {
+                $aBp[$sPerView] += $aBp['start'];
+                $aBp['start'] = 0;
+            }
+        }
+
+        $this->_setUserChoice($aDp['type'], $aBp['type'], $aBp['filter']);
+    }
+
+    protected function _triggerComment()
+    {
+        if(!$this->_aSystem['trigger_table'] || !$this->_aSystem['trigger_field_id'] || !$this->_aSystem['trigger_field_comments'])
+            return false;
+
+        $iId = $this->getId();
+        if(!$iId)
+            return false;
+
+        $iCount = $this->getCommentsCount($iId);
+        return $this->_oQuery->updateTriggerTable($iId, $iCount);
+    }
+
+    /**
+     * Replace provided markers in a string
+     * @param $mixed string or array to replace markers in
+     * @return string where all occured markers are replaced
+     */
+    protected function _replaceMarkers ($mixed)
+    {
+        return bx_replace_markers($mixed, $this->_aMarkers);
+    }
+
+    protected function _getUserChoice()
+    {
+        $mixedDp = $mixedBpType = $mixedBpFilter = false;
+        if(!isLogged())
+            return array($mixedDp, $mixedBpType, $mixedBpFilter);
+
+        $iUserId = $this->_getAuthorId();
+
+        $oSession = BxDolSession::getInstance();
+
+        $mixedDp = $oSession->getValue($this->_sDpSessionKey . $iUserId);
+        $mixedBpType = $oSession->getValue($this->_sBpSessionKeyType . $iUserId);
+        $mixedBpFilter = $oSession->getValue($this->_sBpSessionKeyFilter . $iUserId);
+
+        return array($mixedDp, $mixedBpType, $mixedBpFilter);
+    }
+
+    protected function _setUserChoice($sDp, $sBpType, $sBpFilter)
+    {
+        if(!isLogged())
+            return;
+
+        $iUserId = $this->_getAuthorId();
+
+        $oSession = BxDolSession::getInstance();
+
+        if(!empty($sDp))
+            $oSession->setValue($this->_sDpSessionKey . $iUserId, $sDp);
+
+        if(!empty($sBpType))
+            $oSession->setValue($this->_sBpSessionKeyType . $iUserId, $sBpType);
+
+        if(!empty($sBpFilter))
+            $oSession->setValue($this->_sBpSessionKeyFilter . $iUserId, $sBpFilter);
+    }
+
+    protected function _sendNotificationEmail($iCmtId, $iCmtParentId)
+    {
+        $aCmt = $this->getCommentRow($iCmtId);
+        $aCmtParent = $this->getCommentRow($iCmtParentId);
+        if(empty($aCmt) || !is_array($aCmt) || empty($aCmtParent) || !is_array($aCmtParent) || (int)$aCmt['cmt_author_id'] == (int)$aCmtParent['cmt_author_id'])
+            return;
+
+        $oProfile = $this->_getAuthorObject($aCmtParent['cmt_author_id']);
+
+        if($oProfile instanceof BxDolProfileUndefined)
+        	return;
+
+        $iAccount = $oProfile->getAccountId();
+        $aAccount = BxDolAccount::getInstance($iAccount)->getInfo();
+
+        $aPlus = array();
+        $aPlus['sender_display_name'] = $oProfile->getDisplayName();
+        $aPlus['reply_text'] = $this->_prepareTextForOutput($aCmt['cmt_text'], $iCmtId);
+
+        $sPageUrl = $this->getBaseUrl();
+        if(empty($sPageUrl))
+            $sPageUrl = $this->getViewUrl($iCmtParentId);
+        else 
+            $sPageUrl .= $this->getItemAnchor($iCmtParentId, true);
+        $aPlus['page_url'] = $sPageUrl;
+
+        $sPageTitle = $this->getObjectTitle();
+        if(empty($sPageTitle))
+            $sPageTitle = _t('_Content');
+        $aPlus['page_title'] = $sPageTitle;
+
+        $aTemplate = BxDolEmailTemplates::getInstance()->parseTemplate('t_CommentReplied', $aPlus);
+        return $aTemplate && sendMail($aAccount['email'], $aTemplate['Subject'], $aTemplate['Body']);
+    }
+
+    protected function _isShowDoComment($aParams, $isAllowedComment, $bCount)
+    {
+        $bShowDoComment = !isset($aParams['show_do_comment']) || $aParams['show_do_comment'] == true;
+
+        return $bShowDoComment && ($isAllowedComment || $bCount);
+    }
+
+    protected function _isShowCounter($aParams, $isAllowedComment, $bCount)
+    {
+        $bShowCounter = isset($aParams['show_counter']) && $aParams['show_counter'] === true;
+
+        return $bShowCounter && ($isAllowedComment || $bCount);
+    }
+
+    /**
+     * Note. By default image based controls aren't used.
+     * Therefore it can be overwritten in custom template.
+     */
+    protected function _getImageDo()
+    {
+    	return '';
+    }
+
+    protected function _getIconDo()
+    {
+    	return 'far comment';
+    }
+
+    protected function _getTitleDo()
+    {
+    	return '_cmt_txt_do';
+    }
+
+    protected function _getParents($aCmt, $iDepthMax, &$iDepth, &$aParents)
+    {
+        $iParentId = (int)$aCmt['cmt_parent_id'];
+        if($iParentId == 0)
+            return;
+
+        $aParents[] = $iParentId;
+        $iDepth++;
+
+        if($iDepthMax != 0 && $iDepth == $iDepthMax)
+            return;
+
+        $aCmt = $this->_oQuery->getCommentsBy(['type' => 'id', 'id' => $iParentId]);
+        $this->_getParents($aCmt, $iDepthMax, $iDepth, $aParents);
+    }
+
+    public function _getStructure($mixedItem, $aBp, &$iLevel, &$aStructure)
+    {
+        $bItem = !empty($mixedItem) && is_array($mixedItem);
+
+        $iCmtId = $bItem ? (int)$mixedItem['cmt_id'] : 0;
+        $iCmtIndex = $iCmtId;
+
+        if($bItem)
+            $aStructure[$iCmtIndex] = [
+                'id' => $mixedItem['cmt_id'], 
+                'order' => isset($mixedItem['cmt_order']) ? $mixedItem['cmt_order'] : 0, 
+                'items' => [],
+            ];
+
+        if(!$bItem || (int)$mixedItem['cmt_replies'] > 0) {
+            $aItems = $this->_oQuery->getStructure($this->_iId, $this->_iAuthorId, $iCmtId, $aBp['filter'], $aBp['order']);
+            if(!empty($aItems)) {
+                if($bItem && $iLevel < (int)$this->_aSystem['number_of_levels']) {
+                    $iPassLevel = $iLevel + 1;
+                    $aPassStructure = &$aStructure[$iCmtIndex]['items'];
+                }
+                else {
+                    $iPassLevel = $iLevel;
+                    $aPassStructure = &$aStructure; //--- Is NEEDED here to correctly check sublevels.
+                }
+
+                foreach($aItems as $aItem)
+                    $this->_getStructure($aItem, $aBp, $iPassLevel, $aPassStructure);
+
+                //--- Sort subitems
+                $iWay = isset($aBp['order']['way']) && $aBp['order']['way'] == 'desc' ? -1 : 1;
+                uasort($aPassStructure, function($aItem1, $aItem2) use ($iWay) {
+                    if($aItem1['order'] == $aItem2['order'])
+                        return 0;
+
+                    return $iWay * ($aItem1['order'] < $aItem2['order'] ? -1 : 1);
+                });
+            }
+        }
+    }
+
+    public function _getStructureAPI($mixedItem, $aBp, &$iLevel, &$aStructure)
+    {
+        $bItem = !empty($mixedItem) && is_array($mixedItem);
+
+        $iCmtId = $bItem ? (int)$mixedItem['cmt_id'] : 0;
+        $sCmtIndex = 'i' . $iCmtId;
+
+        if($bItem) {
+            $aMenuActions = [];
+            if(($oMenuActions = BxDolMenu::getObjectInstance($this->_sMenuObjActions)) !== false) {
+                $oMenuActions->setCmtsData($this, $iCmtId, $aBp);
+                $aMenuActions = $oMenuActions->getCodeAPI();
+            }
+
+            $aMenuManage = [];
+            if(($oMenuManage = BxDolMenu::getObjectInstance($this->_sMenuObjManage)) !== false) {
+                $oMenuManage->setCmtsData($this, $iCmtId, $aBp);
+                if($oMenuManage->isVisible())
+                    $aMenuManage = $oMenuManage->getShortCodeAPI();
+            }
+
+            bx_import('BxDolEmbed');
+            $aCmt = $this->getCommentSimple($iCmtId);
+            $aData = $this->getDataAPI(array_merge($aCmt, [
+                'embed' => bx_linkify_embeded($aCmt['cmt_text'] ?? ''),
+                'menu_actions' => $aMenuActions,
+                'menu_manage' => $aMenuManage,
+            ]));
+
+            $aStructure[$sCmtIndex] = [
+                'id' => $iCmtId, 
+                'order' => isset($mixedItem['cmt_order']) ? $mixedItem['cmt_order'] : 0, 
+                'data' => $aData,
+                'files' => $this->_getAttachments($mixedItem),
+                'items' => [],
+            ];
+        }
+
+        if(!$bItem || (int)$mixedItem['cmt_replies'] > 0) {
+            $aItems = $this->_oQuery->getStructure($this->_iId, $this->_iAuthorId, $iCmtId, $aBp['filter'], $aBp['order']);
+            if(!empty($aItems)) {
+                if($bItem && $iLevel < (int)$this->_aSystem['number_of_levels']) {
+                    $iPassLevel = $iLevel + 1;
+                    $aPassStructure = &$aStructure[$sCmtIndex]['items'];
+                }
+                else {
+                    $iPassLevel = $iLevel;
+                    $aPassStructure = [];
+                }
+                
+                foreach($aItems as $aItem)
+                    $this->_getStructureAPI($aItem, $aBp, $iPassLevel, $aPassStructure);
+                
+                //--- Sort subitems
+                $iWay = isset($aBp['order']['way']) && $aBp['order']['way'] == 'desc' ? -1 : 1;
+
+                uasort($aPassStructure, function($aItem1, $aItem2) use ($iWay) {
+                    if($aItem1['order'] == $aItem2['order'])
+                        return 0;
+
+                    return $iWay * ($aItem1['order'] < $aItem2['order'] ? -1 : 1);
+                });
+
+                $aStructure[$sCmtIndex]['items'] = $aPassStructure;
+            }
+        }
+    }
+
+    public function getDataAPI($aData, $aParams = [])
+    {
+        $aDataApi = array_merge($aData, [
+            'cmt_url' => bx_api_get_relative_url($this->getItemUrlApi($aData['cmt_id'])),
+            'author_data' => BxDolProfile::getData($aData['cmt_author_id']),
+            'author_badges' => BxDolProfile::getInstance($aData['cmt_author_id'])->getBadges()
+        ]);
+
+        $sModule = $this->_aSystem['module'];
+        $aExtras = [
+            'module' => $sModule,
+            'data' => $aData,
+            'params' => $aParams,
+            'data_api' => &$aDataApi,
+            'data_api_ref' => &$aDataApi,
+        ];
+
+        /**
+         * @hooks
+         * @hookdef hook-system-decode_comment_data_api 'system', 'decode_comment_data_api' - hook to override comment data prepared for sending in API response
+         * - $unit_name - equals `system`
+         * - $action - equals `decode_comment_data_api`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `data` - [array] comment info array as key&value pairs
+         *      - `params` - [array] params array as key&value pairs
+         *      - `data_api` - [array] by ref, comment data prepared for sending in API response, can be overridden in hook processing
+         * @hook @ref hook-system-decode_comment_data_api
+         */
+        $this->_callAlert('system', 'decode_comment_data_api', 0, 0, $aExtras);
+        
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-decode_comment_data_api '{object_name}', 'decode_comment_data_api' - hook to override comment data prepared for sending in API response
+         * It's equivalent to @ref hook-system-decode_comment_data_api
+         * @hook @ref hook-bx_dol_comment-decode_comment_data_api
+         */
+        $this->_callAlert($sModule, 'decode_comment_data_api', 0, 0, $aExtras);
+
+        return $aDataApi;
+    }
+}
+
+/** @} */

+ 1299 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolConnection.php

@@ -0,0 +1,1299 @@
+<?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
+ * @{
+ */
+
+/**
+ * Default limit for connections list in Counter.
+ */
+define('BX_CONNECTIONS_LIST_COUNTER', 5);
+
+/**
+ * Default limit for connections lists
+ */
+define('BX_CONNECTIONS_LIST_LIMIT', 1000);
+
+/**
+ * No limit for connections lists. 
+ * Is needed for Total Number calculation.
+ */
+define('BX_CONNECTIONS_LIST_NO_LIMIT', -1);
+
+/**
+ * Connections order: no order
+ */
+define('BX_CONNECTIONS_ORDER_NONE', 0);
+
+/**
+ * Connections order: by addded time, asceding
+ */
+define('BX_CONNECTIONS_ORDER_ADDED_ASC', 1);
+
+/**
+ * Connections order: by addded time, desceding
+ */
+define('BX_CONNECTIONS_ORDER_ADDED_DESC', 2);
+
+/**
+ * Connection type: one-way
+ */
+define('BX_CONNECTIONS_TYPE_ONE_WAY', 'one-way');
+
+/**
+ * Connection type: mutual
+ */
+define('BX_CONNECTIONS_TYPE_MUTUAL', 'mutual');
+
+/**
+ * Connections content type: content
+ */
+define('BX_CONNECTIONS_CONTENT_TYPE_CONTENT', 'content');
+
+/**
+ * Connections content type: initiators
+ */
+define('BX_CONNECTIONS_CONTENT_TYPE_INITIATORS', 'initiators');
+
+/**
+ * Connections content type: common
+ */
+define('BX_CONNECTIONS_CONTENT_TYPE_COMMON', 'common');
+
+/**
+ * Connections trigger type: initiator. 
+ * It updates 'Initiator' data with a counter's value of connected 'Content'.
+ */
+define('BX_CONNECTIONS_TRIGGER_TYPE_INITIATOR', 'initiator');
+
+/**
+ * Connections trigger type: content. 
+ * It updates 'Content' data with a counter's value of connected 'Initiators'.
+ */
+define('BX_CONNECTIONS_TRIGGER_TYPE_CONTENT', 'content');
+
+/**
+ * Connection is usefull when you need to organize some sorts of connections between different content,
+ * for example: friends, contacts, favorites, block lists, subscriptions, etc.
+ *
+ * Two types of connections are supported one way connections (block list, favourites) and mutual (friends).
+ *
+ * For automatic handling of connections (like, add/remove connection in frontend) refer to JS function: @see bx_conn_action()
+ *
+ * @section connection_create Creating the Connection object:
+ *
+ * Step 1:
+ * Add record to 'sys_objects_connection' table:
+ * - object: name of the connection object, in the format: vendor prefix, underscore, module prefix, underscore, internal identifier or nothing;
+ *           for example: bx_blogs_favorites - favorite blogs for users in blogs module.
+ * - table: table name with connections, see step 2
+ * - type: 'one-way' or 'mutual'
+ * - override_class_name: user defined class name which is derived from one of base classes.
+ * - override_class_file: the location of the user defined class, leave it empty if class is located in system folders.
+ *
+ * Step 2:
+ * Create table for connections:
+ * @code
+ * CREATE TABLE `my_sample_connections` (
+ *   `id` int(11) NOT NULL AUTO_INCREMENT,
+ *   `initiator` int(11) NOT NULL,
+ *   `content` int(11) NOT NULL,
+ *   `mutual` tinyint(4) NOT NULL, -- can re removed for one-way connections
+ *   `added` int(10) unsigned NOT NULL,
+ *   PRIMARY KEY (`id`),
+ *   UNIQUE KEY `initiator` (`initiator`,`content`),
+ *   KEY `content` (`content`)
+ * )
+ * @endcode
+ *
+ *
+ * @section example Example of usage
+ *
+ * Check if two profiles are friends:
+ * @code
+ *   $oConnectionFriends = BxDolConnection::getObjectInstance('bx_profiles_friends'); // get friends connections object
+ *   if ($oConnectionFriends) // check if connections is available for using
+ *      echo $oConnectionFriends->isConnected (100, 200, true) ? "100 and 200 are friends" : "100 and 200 aren't friends"; // check if profiles with IDs 100 and 200 have mutual connections
+ * @endcode
+ *
+ * Get mutual content IDs (friends IDs)
+ * @code
+ *   $oConnectionFriends = BxDolConnection::getObjectInstance('bx_profiles_friends'); // get friends connections object
+ *   if ($oConnectionFriends) // check if connections is available for using
+ *       print_r($oConnection->getConnectedContent(100, 1)); // print array of friends IDs of 100's profile
+ * @endcode
+ *
+ */
+class BxDolConnection extends BxDolFactory implements iBxDolFactoryObject
+{
+    protected $_oQuery;
+
+    protected $_bIsApi;
+
+    protected $_sObject;
+    protected $_aObject;
+    protected $_iInitiator;
+    protected $_iContent;
+    protected $_sType;
+    protected $_bMutual;
+
+    protected $_aTriggerTypes;
+
+    /**
+     * Constructor
+     * @param $aObject array of connection options
+     */
+    protected function __construct($aObject)
+    {
+        parent::__construct();
+
+        $this->_sObject = $aObject['object'];
+        $this->_aObject = $aObject;
+        $this->_aObject['per_page_default'] = 20;
+
+        $this->_iInitiator = bx_get_logged_profile_id();
+        $this->_sType = $aObject['type'];
+        $this->_bMutual = $this->_sType == BX_CONNECTIONS_TYPE_MUTUAL;
+
+        $this->_aTriggerTypes = [BX_CONNECTIONS_TRIGGER_TYPE_INITIATOR, BX_CONNECTIONS_TRIGGER_TYPE_CONTENT];
+
+        $this->_oQuery = new BxDolConnectionQuery($aObject);
+    }
+
+    /**
+     * Get connection object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    static public function getObjectInstance($sObject)
+    {
+        if (!$sObject)
+            return false;
+
+        if (isset($GLOBALS['bxDolClasses']['BxTemplConnection!'.$sObject]))
+            return $GLOBALS['bxDolClasses']['BxTemplConnection!'.$sObject];
+
+        $aObject = BxDolConnectionQuery::getConnectionObject($sObject);
+        if (!$aObject || !is_array($aObject))
+            return false;
+
+        $sClass = empty($aObject['override_class_name']) ? 'BxTemplConnection' : $aObject['override_class_name'];
+        if (!empty($aObject['override_class_file']))
+            require_once(BX_DIRECTORY_PATH_ROOT . $aObject['override_class_file']);
+
+        $o = new $sClass($aObject);
+        $o->init();
+
+        return ($GLOBALS['bxDolClasses']['BxTemplConnection!'.$sObject] = $o);
+    }
+
+    /**
+     * Init something here if it's needed.
+     */
+    public function init()
+    {
+    }
+
+    /**
+     * Get connection type.
+     * return BX_CONNECTIONS_TYPE_ONE_WAY or BX_CONNECTIONS_TYPE_MUTUAL
+     */ 
+    public function getType()
+    {
+        return $this->_sType;
+    }
+
+    /**
+     * Get connection table.
+     * return string with table name.
+     */ 
+    public function getTable()
+    {
+        return $this->_aObject['table'];
+    }
+
+    /**
+     * Checks whether connection's Initiator is profile or not.
+     * return boolean.
+     */
+    public function isProfileInitiator()
+    {
+        return (int)$this->_aObject['profile_initiator'] != 0;
+    }
+
+    /**
+     * Checks whether connection's Content is profile or not.
+     * return boolean.
+     */
+    public function isProfileContent()
+    {
+        return (int)$this->_aObject['profile_content'] != 0;
+    }
+
+    /**
+     * Check whether connection between Initiator and Content can be established.
+     */
+    public function checkAllowedConnect ($iInitiator, $iContent, $isPerformAction = false, $isMutual = false, $isInvertResult = false, $isSwap = false, $isCheckExists = true)
+    {
+        $aResult = $this->_checkAllowedConnect($iInitiator, $iContent, $isPerformAction, $isMutual, $isInvertResult, $isSwap, $isCheckExists);
+
+        return $aResult['code'] == 0 ? CHECK_ACTION_RESULT_ALLOWED : $aResult['message'];
+    }
+    
+    public function checkAllowedConnectByAcl ($iInitiator, $iContent, $isPerformAction = false)
+    {
+        $aResult = $this->_checkAllowedConnectByAcl($iInitiator, $iContent, $isPerformAction);
+
+        return $aResult['code'] == 0 ? CHECK_ACTION_RESULT_ALLOWED : $aResult['message'];
+    }
+
+    public function checkAllowedAddConnection ($iInitiator, $iContent, $isPerformAction = false, $isMutual = false, $isInvertResult = false, $isSwap = false, $isCheckExists = true)
+    {
+        $aResult = $this->_checkAllowedConnect($iInitiator, $iContent, $isPerformAction, $isMutual, $isInvertResult, $isSwap, $isCheckExists);
+
+        return $aResult['code'] == 0 || ($aResult['code'] == 4  && $this->_sObject == 'sys_profiles_friends') ? CHECK_ACTION_RESULT_ALLOWED : $aResult['message'];
+    }
+
+    public function checkAllowedRemoveConnection ($iInitiator, $iContent, $isPerformAction = false, $isMutual = false, $isInvertResult = false, $isSwap = false, $isCheckExists = true)
+    {
+        $aResult = $this->_checkAllowedConnect($iInitiator, $iContent, $isPerformAction, $isMutual, $isInvertResult, $isSwap, $isCheckExists);
+
+        return $aResult['code'] == 0 || ($aResult['code'] == 4  && $this->_sObject == 'sys_profiles_friends') ? CHECK_ACTION_RESULT_ALLOWED : $aResult['message'];
+    }
+
+    /**
+     * Add new connection.
+     * @param $iContent content to make connection to, in most cases some content id, or other profile id in case of friends
+     * @return array
+     */
+    public function actionAdd ($iContent = 0, $iInitiator = false)
+    {
+        if(!$iContent && ($_iContent = bx_get('id')) !== false)
+            $iContent = bx_process_input($_iContent, BX_DATA_INT);
+
+        if($iInitiator)
+            $this->_iInitiator = $iInitiator;
+        $this->_iContent = $iContent;
+
+        return $this->_action ($this->_iInitiator, $this->_iContent, 'addConnection', '_sys_conn_err_connection_already_exists', true);
+    }
+
+    /**
+     * Remove connection. This method is wrapper for @see removeConnection to be called from @see conn.php upon AJAX request to this file.
+     * @param $iContent content to make connection to, in most cases some content id, or other profile id in case of friends
+     * @return array
+     */
+    public function actionRemove ($iContent = 0, $iInitiator = false)
+    {
+        if (!$iContent && ($_iContent = bx_get('id')) !== false)
+            $iContent = bx_process_input($_iContent, BX_DATA_INT);
+
+        if ($iContent != bx_get_logged_profile_id() && BX_CONNECTIONS_TYPE_MUTUAL == $this->_aObject['type']) {
+            $a = $this->actionReject($iContent, $iInitiator);
+            if (false == $a['err'])
+                return $a;
+        }
+
+        if($iInitiator)
+            $this->_iInitiator = $iInitiator;
+        $this->_iContent = $iContent;
+
+        return $this->_action ($this->_iInitiator, $this->_iContent, 'removeConnection', '_sys_conn_err_connection_does_not_exists', false, true);
+    }
+
+    /**
+     * Reject connection request. This method is wrapper for @see removeConnection to be called from @see conn.php upon AJAX request to this file.
+     * @param $iContent content to make connection to, in most cases some content id, or other profile id in case of friends
+     * @return array
+     */
+    public function actionReject ($iContent = 0, $iInitiator = false)
+    {
+        if (!$iContent && ($_iContent = bx_get('id')) !== false)
+            $iContent = bx_process_input($_iContent, BX_DATA_INT);
+
+        if($iInitiator)
+            $this->_iInitiator = $iInitiator;
+        $this->_iContent = $iContent;
+
+        return $this->_action($this->_iContent, $this->_iInitiator, 'removeConnection', '_sys_conn_err_connection_does_not_exists', false, true);
+    }
+
+    protected function _action ($iInitiator, $iContent, $sMethod, $sErrorKey, $isMutual = false, $isInvert = false)
+    {
+        bx_import('BxDolLanguages');
+
+        if(!$iContent || !$iInitiator)
+            return ['err' => true, 'msg' => _t('_sys_conn_err_input_data_is_not_defined')];
+
+        $sMethodCheck = 'checkAllowed' . bx_gen_method_name($sMethod);
+        if(($mixedResult = $this->{method_exists($this, $sMethodCheck) ? $sMethodCheck : 'checkAllowedConnect'}($iInitiator, $iContent, false, false, $isInvert)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return ['err' => true, 'msg' => $mixedResult];
+
+        if (!$this->$sMethod((int)$iInitiator, (int)$iContent)) {
+            if ($isMutual && BX_CONNECTIONS_TYPE_MUTUAL == $this->_sType && $this->isConnected((int)$iInitiator, (int)$iContent, false) && !$this->isConnected((int)$iInitiator, (int)$iContent, true))
+                return ['err' => true, 'msg' => _t('_sys_conn_err_connection_is_awaiting_confirmation')];
+
+            return ['err' => true, 'msg' => _t($sErrorKey)];
+        }
+
+        return ['err' => false, 'msg' => _t('_sys_conn_msg_success')];
+    }
+
+    public function outputActionResult ($mixed, $sFormat = 'json')
+    {
+        switch ($sFormat) {
+            case 'html':
+                echo $mixed;
+                break;
+                
+            case 'json':
+            default:
+                header('Content-Type: application/json; charset=utf-8');
+                echo json_encode($mixed);     
+        }
+        exit;
+    }
+
+    /**
+     * Add new connection.
+     * @param $iInitiator initiator of the connection, in most cases some profile id
+     * @param $iContent content to make connection to, in most cases some content id, or other profile id in case of friends
+     * @return true - if connection was added, false - if connection already exists or error occured
+     */
+    public function addConnection ($iInitiator, $iContent, $aParams = array())
+    {
+        $iMutual = 0;
+        $iInitiator = (int)$iInitiator;
+        $iContent = (int)$iContent;
+        $iOverrideResult = null;
+
+        $aAlertExtras = [
+            'initiator' => &$iInitiator,
+            'content' => &$iContent,
+            'mutual' => &$iMutual,
+
+            'initiator_ref' => &$iInitiator,
+            'content_ref' => &$iContent,
+            'mutual_ref' => &$iMutual,
+
+            'object' => $this,
+            'override_result' => &$iOverrideResult,
+        ];
+        if(!empty($aParams['alert_extras']) && is_array($aParams['alert_extras']))
+            $aAlertExtras = array_merge($aAlertExtras, $aParams['alert_extras']);
+        
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_connection-connection_before_add '{object_name}', 'connection_before_add' - hook before connection was added. Connection params can be overridden
+         * - $unit_name - connection object name
+         * - $action - equals `connection_before_add`
+         * - $object_id - not used
+         * - $sender_id - logged in profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `initiator` - [int] by ref, profile id who is creating the connection, can be overridden in hook processing
+         *      - `content` - [int] by ref, profile id with whom the connection is creating, can be overridden in hook processing
+         *      - `mutual` - [int] by ref, if the relation is mutual or not, can be overridden in hook processing
+         *      - `object` - [object] an instance of connection object, @see BxDolConnection
+         *      - `object_name` - [string] connection object name
+         *      - `override_result` - [boolean] by ref, stop adding and return specified result
+         * @hook @ref hook-bx_dol_connection-connection_before_add
+         */
+        bx_alert($this->_sObject, 'connection_before_add', 0, false, $aAlertExtras);
+        bx_alert('system', 'connection_before_add', 0, false, array_merge($aAlertExtras, ['object_name' => $this->_sObject]));
+        if (null !== $aAlertExtras['override_result'])
+            return $aAlertExtras['override_result'];
+
+        $iId = 0;
+        if(!($iId = $this->_oQuery->addConnection($iInitiator, $iContent, $iMutual)))
+            return false;
+
+        $iAlertSender = bx_get_logged_profile_id() ?: $iInitiator;
+        $aAlertExtras = array_merge($aAlertExtras, [
+            'source' => $this->_sObject . '_' . $iId,
+            'source_mac' => $this->_sObject . '_' . $iAlertSender . '_' . $iId
+        ]);
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_connection-connection_added '{object_name}', 'connection_added' - hook after connection was added. Connection params can be overridden
+         * It's equivalent to @ref hook-bx_dol_connection-connection_before_add
+         * @hook @ref hook-bx_dol_connection-connection_added
+         */
+        bx_alert($this->_sObject, 'connection_added', 0, $iAlertSender, $aAlertExtras);
+        bx_alert('system', 'connection_added', 0, $iAlertSender, array_merge($aAlertExtras, ['object_name' => $this->_sObject]));
+
+        $this->onAdded($iInitiator, $iContent, $iMutual);
+
+        return true;
+    }
+
+    public function onAdded($iInitiator, $iContent, $iMutual)
+    {
+        $this->checkAllowedConnect($iInitiator, $iContent, true, $iMutual, false);
+
+        if($this->_isTriggerable($iMutual))
+            $this->_updateTriggerValue($iInitiator, $iContent, 1);
+
+        /**
+         * Call socket.
+         */
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $aMessageInitiator = $aMessageContent = [
+                'object' => $this->_sObject, 
+                'action' => 'added',
+                'initiator' => $iInitiator,
+                'content' => $iContent,
+            ];
+
+            if(bx_is_api()) {
+                $aMessageInitiator = array_merge($aMessageInitiator, [
+                    'user' => BxDolProfile::getDataForPage($iInitiator)
+                ]);
+
+                $aMessageContent = array_merge($aMessageContent, [
+                    'user' => BxDolProfile::getDataForPage($iContent)
+                ]);
+            }
+
+            $oSockets->sendEvent('sys_connections', $iInitiator , 'changed', json_encode($aMessageInitiator));
+            $oSockets->sendEvent('sys_connections', $iContent , 'changed', json_encode($aMessageContent));
+        }
+
+        $bMutual = false;
+        if($this->_aObject['type'] == BX_CONNECTIONS_TYPE_ONE_WAY || ($bMutual = ($this->_aObject['type'] == BX_CONNECTIONS_TYPE_MUTUAL && $iMutual))) {
+            $oProfileQuery = BxDolProfileQuery::getInstance();
+
+            /**
+             * Update recommendations.
+             */
+            if($this->_aObject['profile_initiator']) {
+                $aInitiator = $oProfileQuery->getInfoById($iInitiator);
+                if(bx_srv($aInitiator['type'], 'act_as_profile'))
+                    BxDolRecommendation::updateData($iInitiator);
+            }
+
+            if($bMutual && $this->_aObject['profile_content']) {
+                $aContent = $oProfileQuery->getInfoById($iContent);
+                if(bx_srv($aContent['type'], 'act_as_profile'))
+                    BxDolRecommendation::updateData($iContent);
+            }
+        }
+    }
+
+    /**
+     * Remove connection.
+     * @param $iInitiator initiator of the connection
+     * @param $iContent connected content or other profile id in case of friends
+     * @return true - if connection was removed, false - if connection isn't exist or error occured
+     */
+    public function removeConnection ($iInitiator, $iContent)
+    {
+        $iInitiator = (int)$iInitiator;
+        $iContent = (int)$iContent;
+
+        if(!($aConnection = $this->_oQuery->getConnection($iInitiator, $iContent))) // connection doesn't exist
+            return false;
+
+        $iMutual = isset($aConnection['mutual']) ? $aConnection['mutual'] : 0;
+
+        $aAlertExtras = [
+            'initiator' => &$iInitiator,
+            'content' => &$iContent,
+            'mutual' => &$iMutual,
+
+            'initiator_ref' => &$iInitiator,
+            'content_ref' => &$iContent,
+            'mutual_ref' => &$iMutual,
+
+            'object' => $this,
+        ];
+        if(!empty($aParams['alert_extras']) && is_array($aParams['alert_extras']))
+            $aAlertExtras = array_merge($aAlertExtras, $aParams['alert_extras']);
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_connection-connection_before_remove '{object_name}', 'connection_before_remove' - hook before connection was removed. Connection params can be overridden
+         * - $unit_name - connection object name
+         * - $action - equals `connection_before_remove`
+         * - $object_id - not used
+         * - $sender_id - logged in profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `initiator` - [int] by ref, profile id who is removing the connection, can be overridden in hook processing
+         *      - `content` - [int] by ref, profile id with whom the connection is removing, can be overridden in hook processing
+         *      - `mutual` - [int] by ref, if the relation is mutual or not, can be overridden in hook processing
+         *      - `object` - [object] an instance of relation, @see BxDolConnection
+         * @hook @ref hook-bx_dol_connection-connection_before_remove
+         */
+        bx_alert($this->_sObject, 'connection_before_remove', 0, false, $aAlertExtras);
+        bx_alert('system', 'connection_before_remove', 0, false, array_merge($aAlertExtras, ['object_name' => $this->_sObject]));
+
+        if(!$this->_oQuery->removeConnection($iInitiator, $iContent))
+            return false;
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_connection-connection_removed '{object_name}', 'connection_removed' - hook after a connection was removed.
+         * It's equivalent to @ref hook-bx_dol_connection-connection_before_remove
+         * @hook @ref hook-bx_dol_connection-connection_removed
+         */
+        bx_alert($this->_sObject, 'connection_removed', 0, false, $aAlertExtras);
+        bx_alert('system', 'connection_removed', 0, false, array_merge($aAlertExtras, ['object_name' => $this->_sObject]));
+
+        $this->onRemoved($iInitiator, $iContent, $iMutual);
+
+        return true;
+    }
+
+    public function onRemoved($iInitiator, $iContent, $iMutual)
+    {
+        $this->_updateTriggerValue($iInitiator, $iContent, -1);
+
+        /**
+         * Call socket.
+         */
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $aMessageInitiator = $aMessageContent = [
+                'object' => $this->_sObject, 
+                'action' => 'deleted',
+            ];
+
+            if(bx_is_api()) {
+                $aMessageInitiator = array_merge($aMessageInitiator, [
+                    'user' => BxDolProfile::getDataForPage($iInitiator)
+                ]);
+
+                $aMessageContent = array_merge($aMessageContent, [
+                    'user' => BxDolProfile::getDataForPage($iContent)
+                ]);
+            }
+
+            $oSockets->sendEvent('sys_connections', $iInitiator , 'changed', json_encode($aMessageInitiator));
+            $oSockets->sendEvent('sys_connections', $iContent , 'changed', json_encode($aMessageContent));
+        }
+    }
+
+    /**
+     * Compound function, which calls getCommonContent, getConnectedContent or getConnectedInitiators depending on $sContentType
+     * @param $sContentType content type to get BX_CONNECTIONS_CONTENT_TYPE_CONTENT, BX_CONNECTIONS_CONTENT_TYPE_INITIATORS or BX_CONNECTIONS_CONTENT_TYPE_COMMON
+     * @param $iId1 one content or initiator
+     * @param $iId2 second content or initiator only in case of BX_CONNECTIONS_CONTENT_TYPE_COMMON content type
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getConnectionsAsArray ($sContentType, $iId1, $iId2, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        if (BX_CONNECTIONS_CONTENT_TYPE_COMMON == $sContentType)
+            return $this->getCommonContent($iId1, $iId2, $isMutual, $iStart, $iLimit, $iOrder);
+
+        if (BX_CONNECTIONS_CONTENT_TYPE_INITIATORS == $sContentType)
+            $sMethod = 'getConnectedInitiators';
+        else
+            $sMethod = 'getConnectedContent';
+
+        return $this->$sMethod($iId1, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+
+    /**
+     * Get common content IDs between two initiators
+     * @param $iInitiator1 one initiator
+     * @param $iInitiator2 second initiator
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getCommonContent ($iInitiator1, $iInitiator2, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        return $this->_oQuery->getCommonContent($iInitiator1, $iInitiator2, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+    
+    /**
+     * Get common content count between two initiators
+     * @param $iInitiator1 one initiator
+     * @param $iInitiator2 second initiator
+     * @param $isMutual get mutual connections only
+     * @return number of connections
+     */
+    public function getCommonContentCount ($iInitiator1, $iInitiator2, $isMutual = false)
+    {
+        return $this->_oQuery->getCommonContentCount($iInitiator1, $iInitiator2, $isMutual);
+    }
+
+    /**
+     * Get connected content count
+     * @param $iInitiator initiator of the connection
+     * @param $isMutual get mutual connections only
+     * @return number of connections
+     */
+    public function getConnectedContentCount ($iInitiator, $isMutual = false, $iFromDate = 0)
+    {
+        if($this->_isTriggerable($isMutual) && ($iValue = $this->_getTriggerValueByContentType(BX_CONNECTIONS_CONTENT_TYPE_CONTENT, $iInitiator)) !== false)
+            return $iValue;
+
+        return $this->_oQuery->getConnectedContentCount($iInitiator, $isMutual, $iFromDate);
+    }
+
+    /**
+     * Get connected content count
+     * @param $iInitiator initiator of the connection
+     * @param $isMutual get mutual connections only
+     * @param $aParams additional params
+     * @return number of connections
+     */
+    public function getConnectedContentCountExt ($iInitiator, $isMutual = false, $aParams = [])
+    {
+        return $this->_oQuery->getConnectedContentCountExt($iInitiator, $isMutual, $aParams);
+    }
+
+    /**
+     * Get connected initiators count
+     * @param $iContent content of the connection
+     * @param $isMutual get mutual connections only
+     * @return number of connections
+     */
+    public function getConnectedInitiatorsCount ($iContent, $isMutual = false)
+    {
+        if($this->_isTriggerable($isMutual) && ($iValue = $this->_getTriggerValueByContentType(BX_CONNECTIONS_CONTENT_TYPE_INITIATORS, $iContent)) !== false)
+            return $iValue;
+
+        return $this->_oQuery->getConnectedInitiatorsCount($iContent, $isMutual);
+    }
+
+    /**
+     * Get connected content IDs
+     * @param $iInitiator initiator of the connection
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getConnectedContent ($iInitiator, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        return $this->_oQuery->getConnectedContent($iInitiator, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+    
+    /**
+     * Get connected content IDs for specified type
+     * @param $iInitiator initiator of the connection
+     * @param $mixedType type of content or an array of types
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getConnectedContentByType ($iInitiator, $mixedType, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        return $this->_oQuery->getConnectedContentByType($iInitiator, $mixedType, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+
+    /**
+     * Get connected content IDs for specified type
+     * @param $iInitiator initiator of the connection
+     * @param $iDate get connections appeared since this date 
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getConnectedContentSince ($iInitiator, $iDate, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        return $this->_oQuery->getConnectedContentSince($iInitiator, $iDate, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+
+    /**
+     * Get connected initiators IDs
+     * @param $iContent content of the connection
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getConnectedInitiators ($iContent, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        return $this->_oQuery->getConnectedInitiators($iContent, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+
+    /**
+     * Get connected initiators IDs
+     * @param $iContent content of the connection
+     * @param $mixedType type of content or an array of types
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getConnectedInitiatorsByType ($iContent, $mixedType, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        return $this->_oQuery->getConnectedInitiatorsByType($iContent, $mixedType, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+
+    /**
+     * Get connected initiators IDs
+     * @param $iContent content of the connection
+     * @param $iDate get connections appeared since this date 
+     * @param $isMutual get mutual connections only
+     * @return array of available connections
+     */
+    public function getConnectedInitiatorsSince ($iContent, $iDate, $isMutual = false, $iStart = 0, $iLimit = BX_CONNECTIONS_LIST_LIMIT, $iOrder = BX_CONNECTIONS_ORDER_NONE)
+    {
+        return $this->_oQuery->getConnectedInitiatorsSince($iContent, $iDate, $isMutual, $iStart, $iLimit, $iOrder);
+    }
+
+    /**
+     * Similar to getConnectionsAsArray, but for getCommonContentAsSQLParts, getConnectedContentAsSQLParts or getConnectedInitiatorsAsSQLParts methods
+     * @see getConnectionsAsArray
+     */
+    public function getConnectionsAsSQLParts ($sContentType, $sContentTable, $sContentField, $iId1, $iId2, $isMutual = false)
+    {
+        if (BX_CONNECTIONS_CONTENT_TYPE_COMMON == $sContentType)
+            return $this->getCommonContentAsSQLParts($sContentTable, $sContentField, $iId1, $iId2, $isMutual);
+
+        if (BX_CONNECTIONS_CONTENT_TYPE_INITIATORS == $sContentType)
+            $sMethod = 'getConnectedInitiatorsAsSQLParts';
+        else
+            $sMethod = 'getConnectedContentAsSQLParts';
+
+        return $this->$sMethod($sContentTable, $sContentField, $iId1, $isMutual);
+    }
+
+    /**
+     * Get necessary parts of SQL query to use connections in other queries
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $iInitiator initiator of the connection
+     * @param $isMutual get mutual connections only
+     * @return array of SQL string parts, for now 'join' part only is returned
+     */
+    public function getCommonContentAsSQLParts ($sContentTable, $sContentField, $iInitiator1, $iInitiator2, $isMutual = false)
+    {
+        return $this->_oQuery->getCommonContentSQLParts($sContentTable, $sContentField, $iInitiator1, $iInitiator2, $isMutual);
+    }
+
+    /**
+     * Get necessary parts of SQL query to use connections in other queries
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $iInitiator initiator of the connection
+     * @param $isMutual get mutual connections only
+     * @return array of SQL string parts, for now 'join' part only is returned
+     */
+    public function getConnectedContentAsSQLParts ($sContentTable, $sContentField, $iInitiator, $isMutual = false)
+    {
+        return $this->_oQuery->getConnectedContentSQLParts($sContentTable, $sContentField, $iInitiator, $isMutual);
+    }
+    
+    public function getConnectedContentAsSQLPartsExt ($sContentTable, $sContentField, $iInitiator, $isMutual = false)
+    {
+        return $this->_oQuery->getConnectedContentSQLPartsExt($sContentTable, $sContentField, $iInitiator, $isMutual);
+    }
+
+	/**
+     * Get necessary parts of SQL query to use connections in other queries
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $sInitiatorTable initiator table or alias
+     * @param $sInitiatorField initiator table field or field alias
+     * @param $iInitiator initiator of the connection
+     * @param $isMutual get mutual connections only
+     * @return array of SQL string parts, for now 'join' part only is returned
+     */
+    public function getConnectedContentAsSQLPartsMultiple ($sContentTable, $sContentField, $sInitiatorTable, $sInitiatorField, $isMutual = false)
+    {
+        return $this->_oQuery->getConnectedContentSQLPartsMultiple($sContentTable, $sContentField, $sInitiatorTable, $sInitiatorField, $isMutual);
+    }
+
+    /**
+     * Get necessary parts of SQL query to use connections in other queries
+     * @param $sInitiatorTable initiator table or alias
+     * @param $sInitiatorField initiator table field or field alias
+     * @param $iContent content of the connection
+     * @param $isMutual get mutual connections only
+     * @return array of SQL string parts, for now 'join' part only is returned
+     */
+    public function getConnectedInitiatorsAsSQLParts ($sInitiatorTable, $sInitiatorField, $iContent, $isMutual = false)
+    {
+        return $this->_oQuery->getConnectedInitiatorsSQLParts($sInitiatorTable, $sInitiatorField, $iContent, $isMutual);
+    }
+
+	/**
+     * Get necessary parts of SQL query to use connections in other queries
+     * @param $sInitiatorTable initiator table or alias
+     * @param $sInitiatorField initiator table field or field alias
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $isMutual get mutual connections only
+     * @return array of SQL string parts, for now 'join' part only is returned
+     */
+    public function getConnectedInitiatorsAsSQLPartsMultiple ($sInitiatorTable, $sInitiatorField, $sContentTable, $sContentField, $isMutual = false)
+    {
+        return $this->_oQuery->getConnectedInitiatorsSQLPartsMultiple ($sInitiatorTable, $sInitiatorField, $sContentTable, $sContentField, $isMutual);
+    }
+
+    /**
+     * Similar to getConnectionsAsArray, but for getCommonContentAsCondition, getConnectedContentAsCondition or getConnectedInitiatorsAsCondition methods
+     * @see getConnectionsAsArray
+     */
+    public function getConnectionsAsCondition ($sContentType, $sContentField, $iId1, $iId2, $isMutual = false)
+    {
+        if (BX_CONNECTIONS_CONTENT_TYPE_COMMON == $sContentType)
+            return $this->getCommonContentAsCondition($sContentField, $iId1, $iId2, $isMutual);
+
+        if (BX_CONNECTIONS_CONTENT_TYPE_INITIATORS == $sContentType)
+            $sMethod = 'getConnectedInitiatorsAsCondition';
+        else
+            $sMethod = 'getConnectedContentAsCondition';
+
+        return $this->$sMethod($sContentField, $iId1, $isMutual);
+    }
+
+    /**
+     * Get necessary condition array to use connections in search classes
+     * @param $sContentField content table field name
+     * @param $iInitiator initiator of the connection
+     * @param $iMutual get mutual connections only
+     * @return array of conditions, for now with 'restriction' and 'join' parts
+     */
+    public function getCommonContentAsCondition ($sContentField, $iInitiator1, $iInitiator2, $iMutual = false)
+    {
+        return array(
+
+            'restriction' => array (
+                'connections_' . $this->_sObject => array(
+                    'value' => $iInitiator1,
+                    'field' => 'initiator',
+                    'operator' => '=',
+                    'table' => 'c',
+                ),
+                'connections_mutual_' . $this->_sObject => array(
+                    'value' => $iMutual,
+                    'field' => 'mutual',
+                    'operator' => '=',
+                    'table' => 'c',
+                ),
+                'connections2_' . $this->_sObject => array(
+                    'value' => $iInitiator2,
+                    'field' => 'initiator',
+                    'operator' => '=',
+                    'table' => 'c2',
+                ),
+                'connections2_mutual_' . $this->_sObject => array(
+                    'value' => $iMutual,
+                    'field' => 'mutual',
+                    'operator' => '=',
+                    'table' => 'c2',
+                ),
+            ),
+
+            'join' => array (
+                'connections_' . $this->_sObject => array(
+                    'type' => 'INNER',
+                    'table' => $this->_aObject['table'],
+                    'table_alias' => 'c',
+                    'mainField' => $sContentField,
+                    'onField' => 'content',
+                    'joinFields' => array(),
+                ),
+                'connections2_' . $this->_sObject => array(
+                    'type' => 'INNER',
+                    'table' => $this->_aObject['table'],
+                    'table_alias' => 'c2',
+                    'mainTable' => 'c',
+                    'mainField' => 'content',
+                    'onField' => 'content',
+                    'joinFields' => array(),
+                ),
+            ),
+
+        );
+    }
+
+    /**
+     * Get necessary condition array to use connections in search classes
+     * @param $sContentField content table field name
+     * @param $iInitiator initiator of the connection
+     * @param $iMutual get mutual connections only
+     * @return array of conditions, for now with 'restriction' and 'join' parts
+     */
+    public function getConnectedContentAsCondition ($sContentField, $iInitiator, $iMutual = false)
+    {
+        $sOperation = '=';
+        if(is_array($iInitiator))
+            $sOperation = 'in';
+
+        return array(
+
+            'restriction' => array (
+                'connections_' . $this->_sObject => array(
+                    'value' => $iInitiator,
+                    'field' => 'initiator',
+                    'operator' => $sOperation,
+                    'table' => $this->_aObject['table'],
+                ),
+                'connections_mutual_' . $this->_sObject => array(
+                    'value' => $iMutual,
+                    'field' => 'mutual',
+                    'operator' => '=',
+                    'table' => $this->_aObject['table'],
+                ),
+            ),
+
+            'join' => array (
+                'connections_' . $this->_sObject => array(
+                    'type' => 'INNER',
+                    'table' => $this->_aObject['table'],
+                    'mainField' => $sContentField,
+                    'onField' => 'content',
+                    'joinFields' => array(),//'initiator'),
+                ),
+            ),
+
+        );
+    }
+
+    /**
+     * Get necessary condition array to use connections in search classes
+     * @param $sContentField content table field name
+     * @param $iInitiator initiator of the connection
+     * @param $iMutual get mutual connections only
+     * @return array of conditions, for now with 'restriction' and 'join' parts
+     */
+    public function getConnectedInitiatorsAsCondition ($sContentField, $iContent, $iMutual = false)
+    {
+        $sOperation = '=';
+        if(is_array($iContent))
+            $sOperation = 'in';
+
+        return array(
+
+            'restriction' => array (
+                'connections_' . $this->_sObject => array(
+                    'value' => $iContent,
+                    'field' => 'content',
+                    'operator' => $sOperation,
+                    'table' => $this->_aObject['table'],
+                ),
+                'connections_mutual_' . $this->_sObject => array(
+                    'value' => $iMutual,
+                    'field' => 'mutual',
+                    'operator' => '=',
+                    'table' => $this->_aObject['table'],
+                ),
+            ),
+
+            'join' => array (
+                'connections_' . $this->_sObject => array(
+                    'type' => 'INNER',
+                    'table' => $this->_aObject['table'],
+                    'mainField' => $sContentField,
+                    'onField' => 'initiator',
+                    'joinFields' => array(),//'initiator'),
+                ),
+            ),
+
+        );
+    }
+
+    /**
+     * Check if initiator and content are connected.
+     * In case if friends this function in conjunction with isMutual parameter can be used to check pending friend requests.
+     * @param $iInitiator initiator of the connection
+     * @param $iContent connected content or other profile id in case of friends
+     * @return true - if content and initiator are connected or false - in all other cases
+     */
+    public function isConnected ($iInitiator, $iContent, $isMutual = false)
+    {
+        $oConnection = $this->_oQuery->getConnection ($iInitiator, $iContent);
+        if (!$oConnection)
+            return false;
+        return false === $isMutual ? true : (isset($oConnection['mutual']) ? $oConnection['mutual'] : false);
+    }
+
+    /**
+     * Check if initiator and content are connected but connetion is not mutual, for checking pending connection requests.
+     * This method makes sense only when type of connection is mutual.
+     * @param $iInitiator initiator of the connection
+     * @param $iContent connected content or other profile id in case of friends
+     * @return true - if content and initiator are connected but connection is not mutual or false in all other cases
+     */
+    public function isConnectedNotMutual ($iInitiator, $iContent)
+    {
+        $oConnection = $this->_oQuery->getConnection ($iInitiator, $iContent);
+        if (!$oConnection)
+            return false;
+        return $oConnection['mutual'] ? false : true;
+    }
+
+    public function getConnection ($iInitiator, $iContent)
+    {
+        return $this->_oQuery->getConnection($iInitiator, $iContent);
+    }
+
+    public function getConnectionById ($iId)
+    {
+        return $this->_oQuery->getConnectionById($iId);
+    }
+
+    /**
+     * Must be called when some content is deleted which can have connections as 'content' or as 'initiator', to delete any associated data
+     * @param $iId which can be as conetnt ot initiator
+     * @return true if some connections were deleted
+     */
+    public function onDeleteInitiatorAndContent ($iId)
+    {
+        $b = $this->onDeleteInitiator ($iId);
+        $b = $this->onDeleteContent ($iId) || $b;
+        return $b;
+    }
+
+    /**
+     * Must be called when some content is deleted which can have connections as 'initiator', to delete any associated data
+     * @param $iIdInitiator initiator id
+     * @return true if some connections were deleted
+     */
+    public function onDeleteInitiator ($iIdInitiator)
+    {
+        if(!$this->_oQuery->onDelete ($iIdInitiator, 'initiator'))
+            return false;
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_connection-connection_removed_all '{object_name}', 'connection_removed_all' - hook after all connections with deleted 'initiator' were removed.
+         * - $unit_name - connection object name
+         * - $action - equals `connection_removed_all`
+         * - $object_id - not used
+         * - $sender_id - logged in profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `initiator` - [int] profile id who created the connection
+         *      - `object` - [object] an instance of relation, @see BxDolConnection
+         * @hook @ref hook-bx_dol_connection-connection_removed_all
+         */
+        bx_alert($this->_sObject, 'connection_removed_all', 0, bx_get_logged_profile_id(), array(
+            'initiator' => (int)$iIdInitiator,
+            'object' => $this,
+        ));
+
+        return true;
+    }
+
+    /**
+     * Must be called when some content is deleted which can have connections as 'content', to delete any associated data
+     * @param $iIdInitiator initiator id
+     * @return true if some connections were deleted
+     */
+    public function onDeleteContent ($iIdContent)
+    {
+        if(!$this->_oQuery->onDelete ($iIdContent, 'content'))
+            return false;
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_connection-connection_removed_all '{object_name}', 'connection_removed_all' - hook after all connections with deleted 'content' were removed.
+         * - $unit_name - connection object name
+         * - $action - equals `connection_removed_all`
+         * - $object_id - not used
+         * - $sender_id - logged in profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `content` - [int] profile id with whom the connection was created
+         *      - `object` - [object] an instance of relation, @see BxDolConnection
+         * @hook @ref hook-bx_dol_connection-connection_removed_all
+         */
+        bx_alert($this->_sObject, 'connection_removed_all', 0, bx_get_logged_profile_id(), array(
+            'content' => (int)$iIdContent,
+            'object' => $this,
+        ));
+
+        return true;
+    }
+
+
+    /**
+     * Must be called when module (which can have connections as 'content' or as 'initiator') is deleted, to delete any associated data.
+     * This method call may be automated via @see BxBaseModGeneralInstaller::_aConnections property.
+     * @param $sTable table name with data which have assiciations with the connection
+     * @param $sFieldId id field name which is associated with the connection
+     * @return number of deleted connections
+     */
+    public function onModuleDeleteInitiatorAndContent ($sTable, $sFieldId)
+    {
+        $iAffected = $this->onModuleDeleteInitiator ($sTable, $sFieldId);
+        $iAffected += $this->onModuleDeleteContent ($sTable, $sFieldId);
+        return $iAffected;
+    }
+
+    /**
+     * Must be called when module (which can have connections as 'initiator') is deleted, to delete any associated data.
+     * This method call may be automated via @see BxBaseModGeneralInstaller::_aConnections property.
+     * @param $sTable table name with data which have assiciations with the connection
+     * @param $sFieldId id field name which is associated with the connection
+     * @return number of deleted connections
+     */
+    public function onModuleDeleteInitiator ($sTable, $sFieldId)
+    {
+        return $this->_oQuery->onModuleDelete ($sTable, $sFieldId, 'initiator');
+    }
+
+    /**
+     * Must be called when module (which can have connections as 'content') is deleted, to delete any associated data.
+     * This method call may be automated via @see BxBaseModGeneralInstaller::_aConnections property.
+     * @param $sTable table name with data which have assiciations with the connection
+     * @param $sFieldId id field name which is associated with the connection
+     * @return number of deleted connections
+     */
+    public function onModuleDeleteContent ($sTable, $sFieldId)
+    {
+        return $this->_oQuery->onModuleDelete ($sTable, $sFieldId, 'content');
+    }
+
+
+    /**
+     * Must be called when module (which can have connections as 'content' or as 'initiator' with 'sys_profiles' table) is deleted, to delete any associated data.
+     * This method call may be automated via @see BxBaseModGeneralInstaller::_aConnections property.
+     * @param $sModuleName module name to delete connections for
+     * @return number of deleted connections
+     */
+    public function onModuleProfileDeleteInitiatorAndContent ($sModuleName)
+    {
+        $iAffected = $this->onModuleProfileDeleteInitiator ($sModuleName);
+        $iAffected += $this->onModuleProfileDeleteContent ($sModuleName);
+        return $iAffected;
+    }
+
+    /**
+     * Must be called when module (which can have connections as 'initiator' with 'sys_profiles' table) is deleted, to delete any associated data.
+     * This method call may be automated via @see BxBaseModGeneralInstaller::_aConnections property.
+     * @param $sModuleName module name to delete connections for
+     * @return number of deleted connections
+     */
+    public function onModuleProfileDeleteInitiator ($sModuleName)
+    {
+        return $this->_oQuery->onModuleProfileDelete ($sModuleName, 'initiator');
+    }
+
+    /**
+     * Must be called when module (which can have connections as 'content' with 'sys_profiles' table) is deleted, to delete any associated data.
+     * This method call may be automated via @see BxBaseModGeneralInstaller::_aConnections property.
+     * @param $sModuleName module name to delete connections for
+     * @return number of deleted connections
+     */
+    public function onModuleProfileDeleteContent ($sModuleName)
+    {
+        return $this->_oQuery->onModuleProfileDelete ($sModuleName, 'content');
+    }
+
+    protected function _checkAllowedConnect ($iInitiator, $iContent, $isPerformAction = false, $isMutual = false, $isInvertResult = false, $isSwap = false, $isCheckExists = true)
+    {
+        $sErr = _t('_sys_txt_access_denied');
+
+        if(!$iInitiator || !$iContent || $iInitiator == $iContent)
+            return ['code' => 1, 'message' => $sErr];
+
+        $oInitiator = BxDolProfile::getInstance($iInitiator);
+        $oContent = BxDolProfile::getInstance($iContent);
+        if(!$oInitiator || !$oContent)
+            return ['code' => 2, 'message' => $sErr];
+
+        // check ACL
+        if(($mixedResult = $this->_checkAllowedConnectInitiator($oInitiator, $isPerformAction)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return ['code' => 3, 'message' => $mixedResult];
+
+        $iCode = 0;
+        $sMessage = '';
+
+        // check content's visibility
+        if(!$this->isConnected($iContent, $iInitiator) && ($mixedResult = $this->_checkAllowedConnectContent($oContent)) !== CHECK_ACTION_RESULT_ALLOWED)
+            list($iCode, $sMessage) = [4, $mixedResult];
+
+        if(!$isCheckExists)
+            return ['code' => $iCode, 'message' => $sMessage != '' ? $sMessage : null];
+
+        if($isSwap)
+            $isConnected = $this->isConnected($iContent, $iInitiator, $isMutual);
+        else
+            $isConnected = $this->isConnected($iInitiator, $iContent, $isMutual);
+
+        if($isInvertResult)
+            $isConnected = !$isConnected;
+
+        if($isConnected)
+            list($iCode, $sMessage) = [5, $sErr];
+
+        return ['code' => $iCode, 'message' => $sMessage != '' ? $sMessage : null];
+    }
+
+    protected function _checkAllowedConnectByAcl ($iInitiator, $iContent, $isPerformAction = false)
+    {
+        $sErr = _t('_sys_txt_access_denied');
+
+        if(!$iInitiator || !$iContent || $iInitiator == $iContent)
+            return ['code' => 1, 'message' => $sErr];
+
+        $oInitiator = BxDolProfile::getInstance($iInitiator);
+        if(!$oInitiator || !BxDolProfile::getInstance($iContent))
+            return ['code' => 2, 'message' => $sErr];
+
+        // check ACL
+        if(($mixedResult = $this->_checkAllowedConnectInitiator($oInitiator, $isPerformAction)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return ['code' => 3, 'message' => $mixedResult];
+
+        return ['code' => 0];
+    }
+
+    protected function _checkAllowedConnectInitiator ($oInitiator, $isPerformAction = false)
+    {
+        $aCheck = checkActionModule($oInitiator->id(), 'connect', 'system', $isPerformAction);
+        if($aCheck[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED)
+            return $aCheck[CHECK_ACTION_MESSAGE];
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    protected function _checkAllowedConnectContent ($oContent)
+    {
+        return $oContent->checkAllowedProfileView();
+    }
+
+    protected function _isTriggerable($mixedMutual)
+    {
+        return $this->_aObject['type'] == BX_CONNECTIONS_TYPE_ONE_WAY || ($this->_aObject['type'] == BX_CONNECTIONS_TYPE_MUTUAL && $mixedMutual);
+    }
+
+    protected function _updateTriggerValue($iInitiator, $iContent, $iValue)
+    {
+        foreach($this->_aTriggerTypes as $sType) {
+            if(empty($this->_aObject['tt_' . $sType]) || empty($this->_aObject['tf_id_' . $sType]) || empty($this->_aObject['tf_count_' . $sType]))
+                continue;
+
+            $iObjectId = $this->_getTriggerObject($sType, $iInitiator, $iContent);
+            if(!$iObjectId)
+                continue;
+
+            $this->_oQuery->updateTriggerValue($sType, $iObjectId, $iValue);
+        }
+    }
+
+    protected function _getTriggerValueByContentType($sContentType, $iParticipantId)
+    {
+        $aCt2Tt = [
+            BX_CONNECTIONS_CONTENT_TYPE_CONTENT => BX_CONNECTIONS_TRIGGER_TYPE_INITIATOR,
+            BX_CONNECTIONS_CONTENT_TYPE_INITIATORS => BX_CONNECTIONS_TRIGGER_TYPE_CONTENT
+        ];
+
+        if(!isset($aCt2Tt[$sContentType]))
+            return false;
+
+        $sTriggerType = $aCt2Tt[$sContentType];
+        if(empty($this->_aObject['tt_' . $sTriggerType]) || empty($this->_aObject['tf_id_' . $sTriggerType]) || empty($this->_aObject['tf_count_' . $sTriggerType]))
+            return false;
+
+        $iObjectId = 0;
+        if((int)$this->_aObject['profile_' . $sTriggerType]) {
+            if(($oParticipant = BxDolProfile::getInstance($iParticipantId)) !== false)
+                $iObjectId = $oParticipant->getContentId();
+        }
+        else
+            $iObjectId = $iParticipantId;
+
+        return $iObjectId ? $this->_oQuery->getTriggerValue($sTriggerType, $iObjectId) : false;
+    }
+
+    /**
+     * Should be overwritten in Connection class which uses triggerable fields.
+     */
+    protected function _getTriggerObject($sType, $iInitiator, $iContent)
+    {
+        return false;
+    }
+}
+
+/** @} */

+ 1505 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolDb.php

@@ -0,0 +1,1505 @@
+<?php defined('BX_DOL') or defined('BX_DOL_INSTALL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+define('BX_DB_MODE_SILENT', PDO::ERRMODE_SILENT);
+define('BX_DB_MODE_EXCEPTION', PDO::ERRMODE_EXCEPTION);
+
+define('BX_DB_ERR_CONNECT_FAILD', 1);
+define('BX_DB_ERR_QUERY_ERROR', 2);
+define('BX_DB_ERR_ESCAPE', 3);
+
+define('BX_PDO_STATE_NOT_EXECUTED', NULL);
+define('BX_PDO_STATE_SUCCESS', '00000');
+
+class BxDolDb extends BxDolFactory implements iBxDolSingleton
+{	
+    protected static $_rLink;
+    protected static $_iQueriesCounter = 0;
+    protected static $_aDbCacheData = [];
+
+    protected static $_aParams;
+    protected static $_sParamsCacheName = 'sys_options';
+    protected static $_sParamsCacheNameMixed = 'sys_options_mixed_';
+    protected static $_sParamsCacheNameMix = 'sys_options_mix_';
+
+    protected static $_sErrorKey = 'bx_db_error';
+    protected static $_aErrors = array(
+    	BX_DB_ERR_CONNECT_FAILD => 'Database connect failed',
+    	BX_DB_ERR_QUERY_ERROR => 'Database query error',
+    	BX_DB_ERR_ESCAPE => 'Escape string error'
+    );
+
+    protected static $_bReadOnlyMode = true;
+    protected static $_bMultuServersMode = false;
+
+	protected $_bPdoPersistent;
+	protected $_iPdoFetchType;
+	protected $_iPdoErrorMode;
+
+	protected $_bErrorChecking;
+    protected $_aError;
+
+	protected $_sHost, $_sPort, $_sSocket, $_sDbname, $_sUser, $_sPassword, $_sCharset, $_sStorageEngine;
+
+    protected $_oStatement = null;
+    protected $_oDbCacheObject = null;
+
+    /**
+     * set database parameters and connect to it
+     */
+    protected function __construct($aDbConf = false)
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+		$this->_bPdoPersistent = true;
+        if (defined('BX_DATABASE_PERSISTENT'))
+            $this->_bPdoPersistent = (bool)BX_DATABASE_PERSISTENT;
+
+        $this->_iPdoFetchType = PDO::FETCH_ASSOC;
+        $this->_iPdoErrorMode = BX_DB_MODE_EXCEPTION;
+
+        $this->_bErrorChecking = true;
+        $this->_aError = array();
+
+        $this->_sStorageEngine = 'MYISAM';
+        if (defined('BX_DATABASE_ENGINE'))
+            $this->_sStorageEngine = BX_DATABASE_ENGINE;
+
+        $this->_sCharset = 'utf8mb4';
+        if($aDbConf === false) {
+            $this->_sHost = BX_DATABASE_HOST;
+            $this->_sPort = BX_DATABASE_PORT;
+            $this->_sSocket = BX_DATABASE_SOCK;
+            $this->_sDbname = BX_DATABASE_NAME;
+            $this->_sUser = BX_DATABASE_USER;
+            $this->_sPassword = BX_DATABASE_PASS;
+        } 
+        else {
+            $this->_sHost = $aDbConf['host'];
+            $this->_sPort = $aDbConf['port'];
+            $this->_sSocket = $aDbConf['sock'];
+            $this->_sDbname = $aDbConf['name'];
+            $this->_sUser = $aDbConf['user'];
+            $this->_sPassword = $aDbConf['pwd'];
+            if(isset($aDbConf['charset']))
+            	$this->_sCharset = $aDbConf['charset'];
+            if(isset($aDbConf['error_checking']))
+                $this->_bErrorChecking = $aDbConf['error_checking'];
+            if(isset($aDbConf['storage_engine']))
+            	$this->_sStorageEngine = $aDbConf['storage_engine'];
+        }
+
+        @set_exception_handler(array($this, 'pdoExceptionHandler'));
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        $null = null;
+        return self::getInstanceWithConf(false, $null);
+    }
+    
+    public static function getInstanceWithConf($aDbConf, &$sError)
+    {
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__])) {
+            if($aDbConf === false && !defined('BX_DATABASE_HOST'))
+                return null;
+
+            $o = new BxDolDb($aDbConf);
+            $sErrorMessage = $o->connect();
+            if($sErrorMessage) {
+                if($sError !== null)
+                    $sError = $sErrorMessage;
+
+                return null;
+            }
+
+			$GLOBALS['bxDolClasses'][__CLASS__] = $o;
+        }
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public static function getLink()
+    {
+    	return self::$_rLink;
+    }
+
+    public static function getQueriesCounter()
+    {
+    	return self::$_iQueriesCounter;
+    }
+
+    public function setReadOnlyMode($b)
+    {
+        if ($b == self::$_bReadOnlyMode)
+            return;
+        self::$_bReadOnlyMode = $b;
+        if (self::$_bMultuServersMode) {
+            $this->disconnect();
+            $this->connect();
+        }
+    }
+
+    protected function balancer()
+    {
+        self::$_bMultuServersMode = false;
+
+        // regular mode - one DB server
+        if (!is_array($this->_sHost))
+            return array($this->_sHost, $this->_sPort, $this->_sSocket, $this->_sUser, $this->_sPassword, $this->_sDbname);
+
+        $iServersNum = count($this->_sHost);
+        if ($iServersNum > 1)
+            self::$_bMultuServersMode = true;
+
+        // in read/write mode always use first server, which should be always master (also check for regular mode but with array with 1 item)        
+        if (!self::$_bReadOnlyMode || 1 == $iServersNum)
+            $i = 0;
+        // in case of 2 servers - always use second server, in read only mode
+        elseif (2 == $iServersNum)
+            $i = 1;
+        // when there are more that 2 servers, randomly select read only server
+        else
+            $i = rand(1, $iServersNum-1);
+            
+        return array($this->_sHost[$i], $this->_sPort[$i], $this->_sSocket[$i], $this->_sUser[$i], $this->_sPassword[$i], $this->_sDbname[$i]);
+    }
+
+    /**
+     * connect to database with appointed parameters
+     */
+    public function connect()
+    {
+    	if(self::$_rLink)
+    		return;
+
+        list ($sHost, $sPort, $sSocket, $sUser, $sPassword, $sDBName) = $this->balancer();
+
+    	try {   
+	    	$sDsn = "mysql:host=" . $sHost . ";";
+	   		$sDsn .= $sPort ? "port=" . $sPort . ";" : "";
+	   		$sDsn .= $sSocket ? "unix_socket=" . $sSocket . ";" : "";
+	    	$sDsn .= "dbname=" . $sDBName . ";charset=" . $this->_sCharset;
+
+	        self::$_rLink = new PDO($sDsn, $sUser, $sPassword, array(
+				PDO::ATTR_ERRMODE => $this->_iPdoErrorMode,
+				PDO::ATTR_DEFAULT_FETCH_MODE => $this->_iPdoFetchType,
+				PDO::ATTR_PERSISTENT => $this->_bPdoPersistent
+	        ));
+
+            $this->pdoExec("SET NAMES 'utf8mb4' COLLATE '" . (defined('BX_DATABASE_COLLATE') ? BX_DATABASE_COLLATE : 'utf8mb4_unicode_ci') . "'");
+            $this->pdoExec("SET sql_mode = ''");
+            if (0 == strcasecmp($this->_sStorageEngine, 'INNODB'))
+                $this->pdoExec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
+
+            $sVer = $this->getVersion();
+            $sStorageEngine = !$sVer || version_compare($sVer, '5.7.5', '>=') ? 'default_storage_engine' : 'storage_engine';
+            $this->pdoExec("SET $sStorageEngine=" . $this->_sStorageEngine);
+
+			$this->cleanMemoryAll();
+    	}
+    	catch (PDOException $oException) {
+    		$oException->errorInfo[self::$_sErrorKey] = array(
+    			'code' => BX_DB_ERR_CONNECT_FAILD,
+    			'message' => $oException->getMessage(),
+    			'trace' => $oException->getTrace()
+    		);
+
+    		throw $oException;
+    	}
+    }
+
+    /**
+     * close mysql connection
+     */
+    public function disconnect()
+    {
+        self::$_rLink = null;
+    }
+
+    /**
+     * check mysql connection
+     */
+    public function ping()
+    {
+    	try {
+    		$this->pdoQuery("SELECT 1");
+    	}
+    	catch (PDOException $e) {
+    		return false;
+    	}
+
+    	return true;
+    }
+
+    /**
+     * Can be used to execute queries which shouldn't return data
+     */
+    public function pdoExec($sQuery)
+    {
+    	return self::$_rLink->exec($sQuery);
+    }
+
+    /**
+     * Executes query and returns PDOStatement object or false 
+     */
+	public function pdoQuery($sQuery)
+    {
+    	return self::$_rLink->query($sQuery);
+    }
+
+    /**
+     * database query exception handler for exceptions appeared out of the try/catch block
+     */
+    public function pdoExceptionHandler($oException)
+    {
+        if(!($oException instanceof PDOException)) {
+            throw $oException;
+            return;
+        }
+
+		if(!isset($oException->errorInfo[self::$_sErrorKey]))
+			$oException->errorInfo[self::$_sErrorKey] = array(
+				'code' => BX_DB_ERR_QUERY_ERROR,
+				'message' => !empty($oException->errorInfo[2]) ? $oException->errorInfo[2] : $oException->getMessage(),
+				'trace' => $oException->getTrace()
+			);
+
+    	$this->error($oException->errorInfo[self::$_sErrorKey]);
+
+        exit(255);
+    }
+
+    /**
+     * get mysql option
+     */
+    function getOption($sName)
+    {
+    	$oStatement = $this->pdoQuery("SELECT @@{$sName}");
+    	return $this->getOne($oStatement);
+    }
+
+    /**
+     * execute sql query and return one value result
+     */
+    public function getOne($oStatement, $aBindings = array(), $iIndex = 0)
+    {
+        if(!$oStatement)
+			return false;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+		$aResult = array();
+		if($this->res($oStatement, $aBindings))
+            $aResult = $oStatement->fetch(PDO::FETCH_NUM);
+
+        return is_array($aResult) && count($aResult) ? $aResult[$iIndex] : false;
+    }
+
+    /**
+     * execute sql query and return one row result
+     */
+    function getRow($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+        if(!$this->res($oStatement, $aBindings))
+        	return array();
+
+		$aResult = $oStatement->fetch($iFetchType);
+		if($aResult === false)
+        	return array();
+
+        return $aResult;
+    }
+
+    /**
+     * execute sql query and return a column as result
+     */
+    function getColumn($oStatement, $aBindings = array(), $iFetchColumnNumber = 0)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!$this->res($oStatement, $aBindings))
+        	return array();
+
+		$aResult = $oStatement->fetchAll(PDO::FETCH_COLUMN, $iFetchColumnNumber);
+		if($aResult === false)
+        	return array();
+
+        return $aResult;
+    }
+
+	/**
+     * execute sql query and return the first row of result
+     * and keep $array type and poiter to all data
+     */
+    public function getFirstRow($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+        if(!$oStatement)
+            return array();
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+        if(!$this->res($oStatement, $aBindings)) 
+        	return array();
+
+        $aResult = $oStatement->fetch($iFetchType);
+        if($aResult === false) {
+            $oStatement->closeCursor();
+        	return array();
+        }
+
+		$this->_oStatement = $oStatement;
+        return $aResult;
+    }
+
+    /**
+     * return next row of pointed last getFirstRow calling data
+     */
+    public function getNextRow($iFetchType = PDO::FETCH_ASSOC)
+    {
+    	if(!$this->_oStatement)
+            return array();
+
+		if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+		$aResult = $this->_oStatement->fetch($iFetchType);
+		if($aResult !== false)
+			return $aResult;
+
+        $this->_oStatement->closeCursor();
+		$this->_oStatement = null;
+
+    	return array();
+    }
+
+	/**
+     * execute sql query and return table of records as result
+     */
+    public function getAll($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+        if(!$this->res($oStatement, $aBindings))
+        	return array();
+
+		$aResult = $oStatement->fetchAll($iFetchType);
+		if($aResult === false)
+        	return array();
+
+        return $aResult;
+    }
+
+    /**
+     * Executes sql query and returns table of records as result.
+     * 
+     * @deprecated use getAll instead.
+     */
+    public function fillArray($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	return $this->getAll($oStatement, $aBindings, $iFetchType);
+    }
+
+	/**
+     * execute sql query and return table of records as result
+     */
+    public function getAllWithKey($oStatement, $sFieldKey, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        $aRow = $this->getFirstRow($oStatement, $aBindings, $iFetchType);
+        while(!empty($aRow)) {
+        	$aResult[$aRow[$sFieldKey]] = $aRow;
+
+        	$aRow = $this->getNextRow($iFetchType);
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * execute sql query and return table of records as result
+     */
+    public function getPairs($oStatement, $sFieldKey, $sFieldValue, $aBindings = [], $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = [];
+        if(!$oStatement)
+            return $aResult;
+        else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+            $oStatement = $this->prepare($oStatement);
+
+        $aRow = $this->getFirstRow($oStatement, $aBindings, $iFetchType);
+        while(!empty($aRow)) {
+            $aResult[$aRow[$sFieldKey] ?? ''] = $aRow[$sFieldValue];
+
+            $aRow = $this->getNextRow($iFetchType);
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * return number of affected rows in current mysql result
+     * 
+     * NOTE: PDOStatement::rowCount works for SELECT queries in MySQL.
+     * So, this method should be rewritten if the other DB engine will be used.
+     */
+    public function getNumRows($oStatement = null)
+    {
+    	if($oStatement && ($oStatement instanceof PDOStatement))
+    		return $oStatement->rowCount();
+
+    	if($this->_oStatement && ($this->_oStatement instanceof PDOStatement))
+    		return $this->_oStatement->rowCount();
+
+    	return 0;
+    }
+
+    /**
+     * returns the number of rows affected by the last DELETE, INSERT, or UPDATE statement. 
+     */
+    public function getAffectedRows($oStatement = null)
+    {
+        return $this->getNumRows($oStatement);
+    }
+
+    public function lastId()
+    {
+        return self::$_rLink->lastInsertId();
+    }
+
+    /**
+     * execute any query return number of rows affected/false
+     */
+    public function query($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+    	if(!$oStatement)
+            return false;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if($this->res($oStatement, $aBindings, $bVerbose))
+            return $oStatement->rowCount();
+
+        return false;
+    }
+
+    /**
+     * execute any query
+     */
+    public function res($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+		if(!$oStatement || !($oStatement instanceof PDOStatement))
+            return false;
+
+		if($oStatement->errorCode() == BX_PDO_STATE_SUCCESS)
+			return true;
+
+        if(isset($GLOBALS['bx_profiler']))
+        	$GLOBALS['bx_profiler']->beginQuery($oStatement->queryString);
+
+        for ($iAttempts = 3; $iAttempts > 0; --$iAttempts) {
+
+            $oException = null;
+            $iErrorCode = 0;
+            $bResult = false;
+            try {
+                $bResult = $this->executeStatement($oStatement, $aBindings, $bVerbose, $iErrorCode);
+            } catch (PDOException $oException) {
+                $iErrorCode = $oException->errorInfo[1];
+            }
+
+            // if deadlock timeout - try again, make sure that deadlock timeout is short
+            if (!$bResult && (1205 == $iErrorCode || 1213 == $iErrorCode)) {
+                continue;
+            }
+
+	    	// if mysql connection is lost - reconnect and try again
+            if(!$bResult && (2006 == $iErrorCode || !$this->ping())) {
+                $this->disconnect();
+                $this->connect();
+                continue;
+            }
+
+            // in all other cases exit from the loop
+            if ($oException)
+                throw $oException;
+            break;
+        }
+
+        if(isset($GLOBALS['bx_profiler']))
+        	$GLOBALS['bx_profiler']->endQuery($oStatement);
+
+        ++self::$_iQueriesCounter;
+
+		//is needed for SILENT mode
+		if(!$bResult && !empty($this->_aError))
+			$this->error($this->_aError);
+
+        return $bResult;
+    }
+
+    /**
+     * get mysql server info
+     */
+    public function getServerInfo()
+    {
+    	return self::$_rLink->getAttribute(PDO::ATTR_SERVER_VERSION);
+    }
+
+    /**
+     * get mysql version
+     */
+    public function getVersion()
+    {
+        $s = $this->getOne("SELECT VERSION()");
+        return preg_match("/([0-9\.]+)/", $s, $m) ? $m[1] : false;
+    }
+    
+    /**
+     * get list of tables in database
+     */
+    public function listTables()
+    {
+        $sDBName = is_array($this->_sDbname) ? $this->_sDbname[0] : $this->_sDbname;
+    	$oStatement = $this->pdoQuery("SHOW TABLES FROM `" . $sDBName . "`");
+
+        return $this->getColumn($oStatement);
+    }
+
+    public function getFields($sTable)
+    {
+    	$oStatement = $this->pdoQuery("SHOW COLUMNS FROM `" . $sTable . "`");
+    	$aFields = $this->getAll($oStatement);
+
+        $aResult = array('original' => array(), 'uppercase' => array());
+        foreach($aFields as $aField) {
+            $aResult['original'][] = $aField['Field'];
+            $aResult['uppercase'][] = strtoupper($aField['Field']);
+        }
+
+        return $aResult;
+    }
+
+    public function fetchField($oStatement, $iField, $aBindings = array())
+    {
+        $aResult = array();
+        if(!$oStatement)
+            return $aResult;
+
+        else if(!($oStatement instanceof PDOStatement) && is_string($oStatement)) {
+            $oStatement = $this->prepare($oStatement);
+            if(!$this->res($oStatement, $aBindings))
+                return $aResult;
+        }
+
+        return $oStatement->getColumnMeta($iField);
+    }
+
+    public function isTableExists($sTable)
+    {
+        $aTableNames = $this->listTables();
+        foreach($aTableNames as $iKey => $sTableName)
+            $aTableNames[$iKey] = strtoupper($sTableName);
+
+        return in_array(strtoupper($sTable), $aTableNames);
+    }
+
+    public function isFieldExists($sTable, $sFieldName)
+    {
+        $aFields = $this->getFields($sTable);
+        return in_array(strtoupper($sFieldName), $aFields['uppercase']);
+    }
+
+	public function isIndexExists($sTable, $sIndexName)
+	{
+		$bIndex = false;
+
+        $aIndexes = $this->getAll("SHOW INDEXES FROM `" . $sTable . "`");
+        foreach($aIndexes as $aIndex)
+			if($aIndex['Key_name'] == $sIndexName) {
+				$bIndex = true;
+				break;
+			}
+
+		return $bIndex;
+	}
+
+    public function error($aError)
+    {
+    	$sErrorType = self::$_aErrors[$aError['code']];
+
+    	$bVerbose = isset($aError['verbose']) ? (bool)$aError['verbose'] : $this->_bErrorChecking;
+        if(!$bVerbose) {
+            $this->log($sErrorType . ': ' . $aError['message']);
+            if (!defined('BX_DOL_INSTALL')) // this is needed to display error during installation
+    			return;
+        }
+
+        if((defined('BX_DB_FULL_VISUAL_PROCESSING') && BX_DB_FULL_VISUAL_PROCESSING) || defined('BX_DOL_INSTALL')) {
+            $sOutput = '<div style="border:2px solid red;padding:4px;width:600px;margin:0px auto;">'."\n";
+            $sOutput .= '<div style="text-align:center;background-color:red;color:white;font-weight:bold;">Error</div>'."\n";
+            $sOutput .= '<div style="text-align:center;">' . $sErrorType . '</div>'."\n";
+            if((defined('BX_DB_FULL_DEBUG_MODE') && BX_DB_FULL_DEBUG_MODE) || defined('BX_DOL_INSTALL'))
+				$sOutput .= $this->errorOutput($aError);
+            $sOutput .= '</div>';
+        } 
+
+        if (self::$_rLink) { // connection errors aren't logged since bx_log required DB connection
+            bx_log('sys_db', "$sErrorType\n" . 
+                (empty($aError['message']) ? '' : "  Error: {$aError['message']}\n") . 
+                (empty($aError['query']) ? '' : "  Query: {$aError['query']}\n") . 
+                (!function_exists('getLoggedId') || !getLoggedId() ? '' : "  Account ID: " . getLoggedId() . "\n"),
+                BX_LOG_ERR
+            );
+        }
+
+        if(defined('BX_DB_DO_EMAIL_ERROR_REPORT') && BX_DB_DO_EMAIL_ERROR_REPORT) {
+            $sSiteTitle = $this->getParam('site_title');
+
+            $sMailBody = "Database error in " . $sSiteTitle . "<br /><br /> \n";
+            $sMailBody .= $this->errorOutput($aError);
+            $sMailBody .= "<hr />Auto-report system";
+
+            sendMail($this->getParam('site_email'), "Database error in " . $sSiteTitle, $sMailBody, 0, array(), BX_EMAIL_SYSTEM, 'html', true);
+        }
+
+        bx_show_service_unavailable_error_and_exit($sOutput);
+    }
+
+    public function isParamInCache($sKey)
+    {
+        return is_array(self::$_aParams) && isset(self::$_aParams[$sKey]);
+    }
+
+    public function cacheParams($bForceCacheInvalidate = false, $bForceCacheInvalidateMixed = false)
+    {
+        if ($bForceCacheInvalidate)
+            $this->cacheParamsClear();
+
+        self::$_aParams = $this->getPairs("SELECT `name`, `value` FROM `sys_options`", "name", "value");
+
+        list($sTmplCode, $sTmplName) = BxDolTemplate::retrieveCode();
+        if(!empty($sTmplCode) && !empty($sTmplName)) {
+            $iTmplMix = 0;
+            if(is_array($sTmplCode))
+                list($sTmplCode, $iTmplMix) = $sTmplCode;
+
+            $bMixesDiabled = ($GLOBALS['glMixesDisabled'] ?? false);
+            if(empty($iTmplMix) && !$bMixesDiabled)
+                $iTmplMix = (int)$this->getParam($sTmplName . '_default_mix');
+
+            if(!empty($iTmplMix) && !$bMixesDiabled) {
+                $sCacheNameMixed = self::$_sParamsCacheNameMixed . $sTmplCode .  '_' . $iTmplMix;
+                if($bForceCacheInvalidateMixed)
+                    $this->cacheParamsClear($sCacheNameMixed);
+
+                $aMixed = $this->fromCache($sCacheNameMixed, 'getPairs', "SELECT `option`, `value` FROM `sys_options_mixes2options` WHERE `mix_id`=:mix_id", "option", "value", array(
+                    'mix_id' => $iTmplMix
+                ));
+
+                if(!empty($aMixed) && is_array($aMixed))
+                    self::$_aParams = array_merge(self::$_aParams, $aMixed);
+            }
+        }
+
+        if (empty(self::$_aParams)) {
+            self::$_aParams = array();
+
+            return false;
+        }
+
+        return true;
+    }
+
+    public function cacheParamsClear($sCacheName = '')
+    {
+        if(empty($sCacheName)) {
+            self::$_aParams = [];
+            $sCacheName = self::$_sParamsCacheName;
+        }
+
+        return $this->cleanCache($sCacheName);
+    }
+
+    public function isParam($sKey, $bFromCache = true)
+    {
+        if ($bFromCache && $this->isParamInCache($sKey))
+           return true;
+
+        $sQuery = $this->prepare("SELECT `name` FROM `sys_options` WHERE `name` = ? LIMIT 1", $sKey);
+        return $this->getOne($sQuery) == $sKey;
+    }
+
+    public function addParam($sName, $sValue, $iKateg, $sDesc, $sType)
+    {
+        $sQuery = $this->prepare("INSERT INTO `sys_options` SET `category_id` = ?, `name` = ?, `caption` = ?, `value` = ?, `type` = ?", $iKateg, $sName, $sDesc, $sValue, $sType);
+        $this->query($sQuery);
+
+        // renew params cache
+        $this->cacheParams(true);
+    }
+
+    public function getParam($sKey, $bFromCache = true)
+    {
+        if (!$sKey)
+            return false;
+
+        if ($bFromCache) {
+            return $this->isParamInCache($sKey) ? self::$_aParams[$sKey] : false;
+        } else {
+            $sQuery = $this->prepare("SELECT `tmo`.`value` AS `value` FROM `sys_options_mixes2options` AS `tmo` INNER JOIN `sys_options_mixes` AS `tm` ON `tmo`.`mix_id`=`tm`.`id` AND `tm`.`active`='1' WHERE `tmo`.`option`=? LIMIT 1", $sKey);
+            $s = $this->getOne($sQuery);
+            if($s === false) {
+                $sQuery = $this->prepare("SELECT `value` FROM `sys_options` WHERE `name` = ? LIMIT 1", $sKey);            
+                $s = $this->getOne($sQuery);
+            }
+            self::$_aParams[$sKey] = $s; // cache it
+            return $s;
+        }
+    }
+
+    public function setParam($sKey, $mixedValue, $iMixId = 0)
+    {
+    	if(empty($iMixId))
+            $sQuery = $this->prepare("UPDATE `sys_options` SET `value` = ? WHERE `name` = ? LIMIT 1", $mixedValue, $sKey);
+        else
+            $sQuery = $this->prepare("REPLACE INTO `sys_options_mixes2options` SET `option` = ?, `mix_id` = ?, `value` = ?", $sKey, $iMixId, $mixedValue);
+
+        $bResult = (int)$this->query($sQuery) > 0;
+
+        // renew params cache
+        self::$_aParams[$sKey] = $mixedValue;
+
+        return $bResult;
+    }
+
+    public function getParamsMix($iId)
+    {
+        return $this->fromCache(self::$_sParamsCacheNameMix . $iId, 'getRow', 'SELECT * FROM `sys_options_mixes` WHERE `id`=:id', array(
+            'id' => $iId
+        ));
+    }
+
+    public function getParamsMixActive($sType)
+    {
+        return $this->getRow("SELECT * FROM `sys_options_mixes` WHERE `type`=:type AND `active`='1'", array(
+            'type' => $sType
+        ));
+    }
+
+    public function getParamsMixes($sType, $mixedPublished = false)
+    {
+        $aBindings = array('type' => $sType);
+
+        $sQuery = "SELECT * FROM `sys_options_mixes` WHERE `type`=:type";
+        if($mixedPublished !== false) {
+            $aBindings['published'] = (int)$mixedPublished;
+
+            $sQuery .= " AND `published`=:published";
+        }
+
+        return $this->getAll($sQuery, $aBindings);
+    }
+
+    public function setTimezone($sTimezone)
+    {
+        $oDate = new DateTime('now', new DateTimeZone($sTimezone));
+        return $this->pdoExec('SET time_zone = "' . $oDate->format('P') . '"') !== false;
+    }
+
+    public function getEncoding()
+    {
+    	$oStatement = $this->pdoQuery('SELECT @@character_set_database');
+    	if($oStatement !== false)
+    		return $this->getOne($oStatement);
+
+    	return false;
+    }
+
+    public function setErrorChecking ($b)
+    {
+        $this->_bErrorChecking = $b;
+    }
+
+    /**
+     * Cache functions.
+     */
+    public function getDbCacheObject ()
+    {
+        if($this->_oDbCacheObject != null)
+			return $this->_oDbCacheObject;
+
+		$sEngine = $this->getParam('sys_db_cache_engine');
+		$this->_oDbCacheObject = bx_instance('BxDolCache'.$sEngine);
+		if(!$this->_oDbCacheObject->isAvailable())
+			$this->_oDbCacheObject = bx_instance('BxDolCacheFile');
+
+		return $this->_oDbCacheObject;
+    }
+
+    public function genDbCacheKey ($sName)
+    {
+        return 'db_' . $sName . '_' . bx_site_hash() . '.php';
+    }
+
+    /**
+     * @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, $iTTL);
+    }
+
+    /**
+     * @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, $iTTL);
+    }
+    
+    public function fromCache ($sName, $sFunc)
+    {
+        $aArgs = func_get_args();
+        array_shift ($aArgs); // shift $sName
+        array_shift ($aArgs); // shift $sFunc
+
+        if (!$this->getParam('sys_db_cache_enable'))
+            return call_user_func_array (array ($this, $sFunc), $aArgs); // pass other function parameters as database function parameters
+
+        $oCache = $this->getDbCacheObject ();
+
+        $sKey = $this->genDbCacheKey($sName);
+
+        $mixedRet = $oCache->getData($sKey);
+
+        if ($mixedRet !== null) {
+
+            return $mixedRet;
+
+        } else {
+
+            $mixedRet = call_user_func_array (array ($this, $sFunc), $aArgs); // pass other function parameters as database function parameters
+
+            $oCache->setData($sKey, $mixedRet);
+        }
+
+        return $mixedRet;
+    }
+
+    public function cleanCache ($sName)
+    {
+        $oCache = $this->getDbCacheObject();
+
+        $sKey = $this->genDbCacheKey($sName);
+
+        return $oCache->delData($sKey);
+    }
+
+    public function &fromMemory ($sName, $sFunc)
+    {
+        if(array_key_exists($sName, self::$_aDbCacheData) && !defined('BX_DOL_INSTALL') && !defined('BX_DOL_CRON_EXECUTE'))
+			return self::$_aDbCacheData[$sName];
+
+		$aArgs = func_get_args();
+		array_shift($aArgs); // shift $sName
+		array_shift($aArgs); // shift $sFunc
+		self::$_aDbCacheData[$sName] = call_user_func_array (array ($this, $sFunc), $aArgs); // pass other function parameters as database function parameters
+
+		return self::$_aDbCacheData[$sName];
+    }
+
+    public function cleanMemory ($sName)
+    {
+        if(!isset(self::$_aDbCacheData[$sName])) 
+        	return false;
+
+		unset(self::$_aDbCacheData[$sName]);
+		return true;
+    }
+
+    public function cleanMemoryAll ()
+    {
+        self::$_aDbCacheData = array();
+		return true;
+    }
+
+    /**
+     * It escapes string to pass to mysql query.
+     * Try to use "prepare" function always (@see BxDolDb::prepare), use "escape" only if "prepare" function is not possible at all.
+     * Also consider using "implode_escape" function (@see BxDolDb::implode_escape).
+     *
+     * @param  string  $s string to escape
+     * @return escaped string whcich is ready to pass to SQL query.
+     */
+    public function escape($s)
+    {
+    	try {
+    		$s = self::$_rLink->quote($s);
+    	}
+    	catch (PDOException $oException) {
+    		$oException->errorInfo[self::$_sErrorKey] = array(
+    			'code' => BX_DB_ERR_ESCAPE,
+    			'message' => $oException->getMessage(),
+    			'trace' => $oException->getTrace()
+    		);
+
+    		throw $oException;
+    	}
+
+        return $s;
+    }
+
+    /**
+     * @param bool $bStrict one identifier of letters, digits and underscores. The loose check also allows a qualified name and most characters inside backticks.
+     */
+    public function isValidFieldName($s, $bStrict = false)
+    {
+        if ($bStrict)
+            return is_string($s) && (bool)preg_match('/^[A-Za-z0-9_]+$/', $s);
+
+        if (!is_string($s))
+            return false;
+
+        if (false !== strpos($s, '`.`')) {
+            $a = explode('`.`', $s, 2);
+            foreach ($a as $sField) {
+                if (false === $this->isValidFieldName($sField, $bStrict))
+                    return false;
+            }
+            return true;
+        }
+
+        $inner = $s;
+
+        // Disallow trailing space
+        if ($inner === '' || preg_match('/\s$/u', $inner)) {
+            return false;
+        }
+
+        // Disallow all-numeric content (even if quoted)
+        if (preg_match('/^\d+$/u', $inner)) {
+            return false;
+        }
+
+        $i = 0;
+        $len = mb_strlen($inner, 'UTF-8');
+
+        while ($i < $len) {
+            $char = mb_substr($inner, $i, 1, 'UTF-8');
+
+            // Allow escaped backtick (``)
+            if ($char === '`') {
+                if ($i + 1 >= $len || mb_substr($inner, $i + 1, 1, 'UTF-8') !== '`') {
+                    return false; // Single backtick is invalid
+                }
+                $i += 2;
+                continue;
+            }
+
+            // Convert to UTF-8 bytes to check for supplementary chars
+            $utf8 = mb_convert_encoding($char, 'UTF-8');
+            $byteLen = strlen($utf8);
+
+            // 1-byte to 3-byte UTF-8 sequences = BMP (valid)
+            // 4-byte UTF-8 sequences = Supplementary chars (invalid)
+            if ($byteLen === 4) {
+                return false;
+            }
+
+            // Disallow ASCII NUL (0x00)
+            if ($utf8 === "\x00") {
+                return false;
+            }
+
+            $i++;
+        }
+
+        return true;
+/*
+        // alternative quick checking without checking BMP range
+        if (preg_match('/^[0-9]+$/', $s))
+            return false;
+
+        return preg_match('/^(?:(?!`)[\x01-\x7F\x{0080}-\x{FFFF}]|``)*[^[:space:]]$/u', $s);
+*/
+    }
+
+    /**
+     * This function is usefull when you need to form array of parameters to pass to IN(...) SQL construction.
+     * Example:
+     * @code
+     * $a = array(2, 4.5, 'apple', 'car');
+     * $s = "SELECT * FROM `t` WHERE `a` IN (" . $oDb->implode_escape($a) . ")";
+     * echo $s; // outputs: SELECT * FROM `t` WHERE `a` IN (2, 4.5, 'apple', 'car')
+     * @endcode
+     *
+     * @param $mixed array or parameters or just one paramter
+     * @return string which is ready to pass to IN(...) SQL construction
+     */
+    public function implode_escape ($mixed)
+    {
+        if (is_array($mixed)) {
+            $s = '';
+            foreach ($mixed as $v)
+                $s .= (is_numeric($v) ? $v : $this->escape($v)) . ',';
+            if ($s)
+                return substr($s, 0, -1);
+            else
+                return 'NULL';
+        }
+
+        return is_numeric($mixed) ? $mixed : ($mixed ? $this->escape($mixed) : 'NULL');
+    }
+
+    /**
+     * @deprecated
+     */
+    public function unescape ($mixed)
+    {
+        if(is_array($mixed)) {
+            foreach($mixed as $k => $v)
+				$mixed[$k] = $this->getOne("SELECT '$v'");
+
+            return $mixed;
+        } 
+        else
+            return $this->getOne("SELECT '$mixed'");
+    }
+
+    /**
+     * Prepare SQL query before execution if some arguments are need to be passed to it.
+     * All parameters marked with question (?) symbol in SQL query are replaced with parameters passed after SQL query parameter.
+     * Parameters are properly excaped and surrounded by qutes if needed.
+     * Example:
+     * @code
+     * $sSql = $oDb->prepare("SELECT `a`, `b` from `t` WHERE `c` = ? and `d` = ?", 12, 'aa');
+     * echo $sSql;// outputs: SELECT `a`, `b` from `t` WHERE `c` = 12 and `d` = 'aa'
+     * $a = $oDb->getAll($sSql);
+     * @endcode
+     *
+     * @param  string $sQuery SQL query, parameters for replacing are marked with ? symbol
+     * @param  mixed  $mixed  any number if parameters to replace, number of parameters whould match number of ? symbols in SQL query
+     * @return PDOStatement object with SQL query ready for execution
+     */
+    public function prepare($sQuery)
+    {
+    	if(!self::$_rLink)
+    		return false;
+
+        if (self::$_bMultuServersMode && (0 != strncasecmp(ltrim($sQuery, " \t\n\r(\0\x0B"), 'SELECT', 6)))
+            $this->setReadOnlyMode(false);
+
+        $aArgs = func_get_args();
+        $sQuery = array_shift($aArgs);
+
+        $oStatement = self::$_rLink->prepare($sQuery);
+
+        $iIndex = 1;
+        foreach($aArgs as $mixedArg) {
+        	if(is_null($mixedArg))
+				$iValueType = PDO::PARAM_NULL;
+            else if(is_numeric($mixedArg) && is_int($mixedArg))
+                $iValueType = PDO::PARAM_INT;
+            else
+                $iValueType = PDO::PARAM_STR;
+
+        	$oStatement->bindValue($iIndex++, $mixedArg, $iValueType);
+        }
+
+        return $oStatement;
+    }
+
+    /**
+     * Prepare SQL query before execution if some arguments are need to be passed to it.
+     * All parameters marked with question (?) symbol in SQL query are replaced with parameters passed after SQL query parameter.
+     * Parameters are properly excaped and surrounded by qutes if needed.
+     * Example:
+     * @code
+     * $sSql = $oDb->prepare("SELECT `a`, `b` from `t` WHERE `c` = ? and `d` = ?", 12, 'aa');
+     * echo $sSql;// outputs: SELECT `a`, `b` from `t` WHERE `c` = 12 and `d` = 'aa'
+     * $a = $oDb->getAll($sSql);
+     * @endcode
+     *
+     * @param  string $sQuery SQL query, parameters for replacing are marked with ? symbol
+     * @param  mixed  $mixed  any number if parameters to replace, number of parameters whould match number of ? symbols in SQL query
+     * @return string with SQL query. 
+     */
+    function prepareAsString($sQuery)
+    {
+        $aArgs = func_get_args();
+        $sQuery = array_shift($aArgs);
+
+        $iPos = 0;
+        foreach ($aArgs as $mixedArg) {
+            if (is_null($mixedArg))
+                $s = 'NULL';
+            elseif (is_numeric($mixedArg))
+                $s = $mixedArg;
+            else
+                $s = $this->escape($mixedArg);
+
+            $i = bx_mb_strpos($sQuery, '?', $iPos);
+            $sQuery = bx_mb_substr_replace($sQuery, $s, $i, 1);
+            $iPos = $i + get_mb_len($s);
+        }
+
+        return $sQuery;
+    }
+
+    /**
+     * Convert array of key => values to SQL query.
+     * Array keys are field names and array values are field values.
+     * @param $a array
+     * @param $sDiv fields separator, by default it is ',', another useful value is ' AND '
+     * @param $sOperator operator for comparision: '=' or 'LIKE'
+     * @param $bWildcardSpaceChars substiture any space and dash characters with single wildcard symbol
+     * @return part of SQL query string
+     */
+    public function arrayToSQL($a, $sDiv = ',', $sOperator = '=', $bWildcardSpaceChars = false)
+    {
+        $aOperators = ['=', 'LIKE', 'like'];
+        $sOperator = in_array($sOperator, $aOperators) ? $sOperator : '=';
+        $s = '';
+        foreach($a as $k => $v) {
+            if (!$this->isValidFieldName($k))
+                throw new Exception('Invalid field name in arrayToSQL method');
+            if ($bWildcardSpaceChars)
+                $v = preg_replace('/[\p{Zs}\p{Cc}\p{Pd}]/', '_', $v);
+            $s .= "`{$k}` {$sOperator} " . (is_null($v) ? 'NULL' : $this->escape($v)) . $sDiv;
+        }
+        return trim($s, $sDiv);
+    }
+
+    function isValidOperator(string $operator): bool
+    {
+        static $operators = [
+            // Comparison
+            '=', '!=', '<>', '<', '>', '<=', '>=',
+            '<=>',
+    
+            // Logical
+            'AND', 'OR', 'XOR', 'NOT',
+    
+            // Arithmetic
+            '+', '-', '*', '/', '%',
+            'DIV', 'MOD',
+    
+            // Bitwise
+            '&', '|', '^', '<<', '>>',
+    
+            // Other MySQL operators
+            'IS', 'IS NOT',
+            'LIKE', 'NOT LIKE',
+            'REGEXP', 'NOT REGEXP',
+            'IN', 'NOT IN',
+            'BETWEEN', 'NOT BETWEEN',
+    
+            // Custom functions/operators
+            'COUNT', 'LOCATE',
+            'EMPTY VALUE', 
+            'NOT EMPTY VALUE',
+            'NOTHING', 
+            'IN_SET',
+        ];
+    
+        return in_array(strtoupper(trim($operator)), $operators, true);
+    }
+
+    protected function log($s)
+    {
+        if (defined('BX_DIRECTORY_PATH_LOGS')) {
+            $sPath = BX_DIRECTORY_PATH_LOGS;
+        }
+        else {
+            $sDirName = pathinfo(__FILE__, PATHINFO_DIRNAME);
+            $sPath = $sDirName . '/../../logs/';
+        }
+        return file_put_contents($sPath . 'db.err.log', date('Y-m-d H:i:s') . "\t" . $s . "\n", FILE_APPEND);
+    }
+
+    public function executeSQL($sPath, $aReplace = array (), $isBreakOnError = true)
+    {
+        if(!file_exists($sPath) || !($rHandler = fopen($sPath, "r")))
+            return array(array ('query' => "fopen($sPath, 'r')", 'error' => 'file not found or permission denied'));
+
+		self::$_rLink->setAttribute(PDO::ATTR_ERRMODE, BX_DB_MODE_SILENT);
+
+        $sQuery = "";
+        $sDelimiter = ';';
+        $aResult = array();
+        while(!feof($rHandler)) {
+            $sStr = trim(fgets($rHandler));
+
+            if(empty($sStr) || $sStr[0] == "" || $sStr[0] == "#" || ($sStr[0] == "-" && $sStr[1] == "-"))
+                continue;
+
+            //--- Change delimiter ---//
+            if(strpos($sStr, "DELIMITER //") !== false || strpos($sStr, "DELIMITER ;") !== false) {
+                $sDelimiter = trim(str_replace('DELIMITER', '', $sStr));
+                continue;
+            }
+
+            $sQuery .= $sStr;
+
+            //--- Check for multiline query ---//
+            if(substr($sStr, -strlen($sDelimiter)) != $sDelimiter)
+                continue;
+
+            //--- Execute query ---//
+            if ($aReplace)
+                $sQuery = str_replace($aReplace['from'], $aReplace['to'], $sQuery);
+            if($sDelimiter != ';')
+                $sQuery = str_replace($sDelimiter, "", $sQuery);
+
+            if($this->query(trim($sQuery), array(), false) === false) {
+                $aResult[] = array('query' => $sQuery, 'error' => $this->_aError['message']);
+                if ($isBreakOnError)
+                    break;
+            }
+
+            $sQuery = "";
+        }
+        fclose($rHandler);
+
+        self::$_rLink->setAttribute(PDO::ATTR_ERRMODE, $this->_iPdoErrorMode);
+
+        return empty($aResult) ? true : $aResult;
+    }
+
+    protected function executeStatement($oStatement, $aBindings = array(), $bVerbose = null, &$iErrorCode = null)
+    {
+        $bResult = false;
+
+        if (!isset($aBindings[0])) {
+    		foreach($aBindings as $sKey => $mixedValue) {
+	    		if(is_null($mixedValue))
+		    		$oStatement->bindValue(":{$sKey}", $mixedValue, PDO::PARAM_NULL);
+                else if(is_numeric($mixedValue) && is_int($mixedValue))
+                    $oStatement->bindValue(":{$sKey}", $mixedValue, PDO::PARAM_INT);
+                else
+                    $oStatement->bindValue(":{$sKey}", $mixedValue, PDO::PARAM_STR);
+            }
+            $aBindings = array();
+        }
+
+    	switch (self::$_rLink->getAttribute(PDO::ATTR_ERRMODE)) {
+    		case PDO::ERRMODE_SILENT:
+    			$bResult = $this->executeStatementSilent($oStatement, $aBindings, $bVerbose);
+    			break;
+
+    		case PDO::ERRMODE_EXCEPTION:
+    			$bResult = $this->executeStatementException($oStatement, $aBindings, $bVerbose);
+    			break;
+    	}
+
+        if (!$bResult && !is_null($iErrorCode)) {
+            $aError = $oStatement->errorInfo();
+            $iErrorCode = $aError[1];
+        }
+
+    	return $bResult;
+    }
+
+    protected function executeStatementException($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+    	$bResult = false;
+
+    	try {
+			$bResult = $oStatement->execute(!empty($aBindings) && is_array($aBindings) ? $aBindings : null);
+		}
+		catch (PDOException $oException) {
+			$aError = $oStatement->errorInfo();
+
+			$oException->errorInfo[self::$_sErrorKey] = array(
+				'code' => BX_DB_ERR_QUERY_ERROR,
+				'message' => !empty($aError[2]) ? $aError[2] : $oException->getMessage(),
+				'query' => $oStatement->queryString,
+				'trace' => $oException->getTrace(),
+				'verbose' => $bVerbose
+			);
+
+			throw $oException;
+		}
+
+		return $bResult;
+    }
+
+    protected function executeStatementSilent($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+        $bResult = $oStatement->execute(!empty($aBindings) && is_array($aBindings) ? $aBindings : null);
+        if($bResult)
+            return true;
+
+        $aError = $oStatement->errorInfo();
+
+        $aTrace = debug_backtrace();
+        unset($aTrace[0]);
+
+        $this->_aError = array(
+            'code' => BX_DB_ERR_QUERY_ERROR,
+            'message' => !empty($aError[2]) ? $aError[2] : '',
+            'query' => $oStatement->queryString,
+            'trace' => $aTrace,
+            'verbose' => $bVerbose
+        );
+
+        return false;
+    }
+
+    protected function errorOutput($aError)
+    {
+        $aErrorLocation = [];
+
+        if(!empty($aError['query']) && !empty($aError['trace']))
+            foreach($aError['trace'] as $aCall )
+                if(isset($aCall['args']) && is_array($aCall['args']))
+                    foreach($aCall['args'] as $argNum => $argVal)
+                        if((is_string($argVal) && strcmp($argVal, $aError['query']) == 0) || ($argVal instanceof PDOStatement && strcmp($argVal->queryString, $aError['query']) == 0)) {
+                            $aErrorLocation['file'] = isset($aCall['file']) ? $aCall['file'] : (isset($aCall['class']) ? 'class: ' . $aCall['class'] : 'undefined');
+                            $aErrorLocation['line'] = isset($aCall['line']) ? $aCall['line'] : 'undefined';
+                            $aErrorLocation['function'] = $aCall['function'];
+                            $aErrorLocation['arg'] = $argNum;
+                        }
+
+        $sOutput = '';
+        if(!empty($aError['query']))
+            $sOutput .= '<p><b>Query:</b><br />' . bx_process_output($aError['query']) . '</p>'."\n";
+
+        if(!empty($aError['message']))
+            $sOutput .= '<p><b>Mysql error:</b><br />' . $aError['message'] . '</p>'."\n";
+
+        if(!empty($aErrorLocation))
+            $sOutput .= '<p><b>Location:</b><br />The error was found in <b>' . $aErrorLocation['function'] . '</b> function in the file <b>' . $aErrorLocation['file'] . '</b> at line <b>' . $aErrorLocation['line'] . '</b>.</p>'."\n";
+
+        $sOutput .= '<p><b>collation_connection:</b><br />' . $this->getOne("SELECT @@collation_connection") . '</p>'."\n";
+
+        if(!empty($aError['trace'])) {
+            $sBackTrace = print_r($aError['trace'], true);
+            if(defined ('BX_DATABASE_USER') && !is_array(BX_DATABASE_USER))
+                $sBackTrace = str_replace('[_sUser:protected] => ' . BX_DATABASE_USER, '[_sUser:protected] => *****', $sBackTrace);
+            if(defined ('BX_DATABASE_PASS') && !is_array(BX_DATABASE_PASS))
+                $sBackTrace = str_replace('[_sPassword:protected] => ' . BX_DATABASE_PASS, '[_sPassword:protected] => *****', $sBackTrace);
+
+            $sOutput .= '<div><b>Debug backtrace:</b></div><div style="overflow:scroll;height:300px;border:1px solid gray;"><pre>' . htmlspecialchars_adv($sBackTrace) . '</pre></div>';
+        }
+
+        if(!empty(self::$_aParams)) {
+            $aParams = self::$_aParams;
+            $aParamsSecret = $this->getColumn("SELECT `name` FROM `sys_options` WHERE `type`='secret'", "name");
+            if($aParamsSecret && is_array($aParamsSecret))
+                array_walk($aParams, function(&$sValue, $sKey) use ($aParamsSecret) {
+                    if(in_array($sKey, $aParamsSecret)) {
+                        $sValue = bx_gen_secret($sValue);
+                    } elseif (mb_strlen($sValue) > 100) {
+                        $sValue = mb_substr($sValue, 0, 100) . '...';
+                    }
+                });
+
+            $sOutput .= '<div><b>Settings:</b></div><div style="overflow:scroll;height:300px;border:1px solid gray;"><pre>' . htmlspecialchars_adv(var_export($aParams, true)) . '</pre></div>';
+        }
+
+        $sOutput .= '<p><b>Called script:</b><br />' . htmlspecialchars_adv($_SERVER['PHP_SELF']) . '</p>';
+
+        if(!empty($_REQUEST))
+            $sOutput .= '<p><b>Request parameters:</b><br /><pre>' . htmlspecialchars_adv(var_export($_REQUEST, true)) . '</pre></p>';
+
+        return $sOutput;
+    }
+}
+
+/**
+ * Create the very first instance and initiate connetion to database.
+ */
+BxDolDb::getInstance();
+
+function getParam($sParamName, $bUseCache = true)
+{
+    if (bx_is_api() && $sParamName == 'permalinks_seo_links')
+        return true;
+    
+    $oObj = BxDolDb::getInstance();
+    if ($oObj === null)
+        return false;
+       
+     return $oObj->getParam($sParamName, $bUseCache);
+}
+
+function setParam($sParamName, $sParamVal)
+{
+    $oObj = BxDolDb::getInstance();
+    if ($oObj === null)
+        return false;
+    
+    return $oObj->setParam($sParamName, $sParamVal);
+}
+
+/** @} */

+ 373 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolFavorite.php

@@ -0,0 +1,373 @@
+<?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
+ * @{
+ */
+
+define('BX_DOL_FAVORITE_USAGE_BLOCK', 'block');
+define('BX_DOL_FAVORITE_USAGE_INLINE', 'inline');
+define('BX_DOL_FAVORITE_USAGE_DEFAULT', BX_DOL_FAVORITE_USAGE_BLOCK);
+
+/**
+ * Track any object favorites automatically
+ *
+ * Add record to sys_object_favorite table to track object favorites,
+ * to record favorites just create this class instance with your object id,
+ * for example:
+ * @code
+ *  BxDolFavorite::getObjectInstance('my_system', 25); // 25 - is object id
+ * @endcode
+ *
+ * Description of sys_object_favorite table fields:
+ * @code
+ *  `name` - system name, it is better to use unique module prefix here, lowercase and all spaces are underscored
+ *  `table_track` - table to track favorites
+ *  `table_lists` - table with lists 
+ *  `is_on` - is the system activated
+ *  `trigger_table` - table where you need to update favorites field
+ *  `trigger_field_id` - table field id to unique determine object
+ *  `trigger_field_count` - table field where total favorites number is stored
+ *  `class_name` - your custom class name, if you overrride default class
+ *  `class_file` - your custom class path
+ * @endcode
+ *
+ * Structure of the track table is the following:
+ * @code
+ *  CREATE TABLE `my_favorites_track` (
+ *      `object_id` int(11) NOT NULL default '0', -- this field type must be exact as your object id type
+ *      `author_id` int(11) NOT NULL default '0', -- favoring user profile id
+ *      `date` int(11) NOT NULL default '0', -- timestamp of last recorded view
+ *      KEY `id` (`object_id`,`author_id`)
+ *  );
+ * @endcode
+ *
+ */
+class BxDolFavorite extends BxDolObject
+{
+    protected $_sType;
+    protected $_sBaseUrl;
+
+    protected $_aFavorite;
+
+    protected $_aElementDefaults;
+    protected $_aElementDefaultsApi;
+    protected $_aElementParamsApi; //--- Params from DefaultsApi array to be passed to Api
+
+    protected $_sFormObject;
+    protected $_sFormDisplayAdd;
+    protected $_sFormDisplayListEdit;
+
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct($sSystem, $iId, $iInit, $oTemplate);
+        if(empty($this->_sSystem))
+            return;
+
+        $this->_oQuery = new BxDolFavoriteQuery($this);
+
+        $this->_sType = 'favorites';
+        $this->_sBaseUrl = BxDolPermalinks::getInstance()->permalink($this->_aSystem['base_url']);
+        if(get_mb_substr($this->_sBaseUrl, 0, 4) != 'http')
+            $this->_sBaseUrl = BX_DOL_URL_ROOT . $this->_sBaseUrl;
+
+        $this->_aFavorite = [];
+
+        $this->_sFormObject = 'sys_favorite';
+        $this->_sFormDisplayAdd = 'sys_favorite_add';
+        $this->_sFormDisplayListEdit = 'sys_favorite_list_edit';
+    }
+
+   /**
+     * get favorites object instanse
+     * @param $sSys favorite object name
+     * @param $iId associated content id, where favorite is available
+     * @param $iInit perform initialization
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstance($sSys, $iId, $iInit = true)
+    {
+        $sKey = 'BxDolFavorite!' . $sSys . $iId;
+        if(isset($GLOBALS['bxDolClasses'][$sKey]))
+            return $GLOBALS['bxDolClasses'][$sKey];
+
+        $aSystems = self::getSystems();
+        if(!isset($aSystems[$sSys]))
+            return null;
+
+        $sClassName = 'BxTemplFavorite';
+        if(!empty($aSystems[$sSys]['class_name'])) {
+            $sClassName = $aSystems[$sSys]['class_name'];
+            if(!empty($aSystems[$sSys]['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSystems[$sSys]['class_file']);
+        }
+
+        $o = new $sClassName($sSys, $iId, $iInit);
+        return ($GLOBALS['bxDolClasses'][$sKey] = $o);
+    }
+
+    public function getConditionsTrack($sMainTable, $sMainField, $iAuthorId = 0, $iListId = 0)
+    {
+        $aConditions = parent::getConditionsTrack($sMainTable, $sMainField, $iAuthorId);
+        if (empty($aConditions) || !isset($this->_aSystem['table_lists']) || $this->_aSystem['table_lists'] == '')
+            return $aConditions;
+        
+        $sTableTrack = isset($this->_aSystem['table_track']) ? $this->_aSystem['table_track'] : '';
+        $aConditions['restriction']['objects_' . $this->_sSystem . '_list'] = array(
+            'value' => $iListId,
+            'field' => 'list_id',
+            'operator' => '=',
+            'table' => $sTableTrack,
+        );
+        return $aConditions;
+    }
+    
+    public static function &getSystems()
+    {
+        $sKey = 'bx_dol_cache_memory_favorite_systems';
+
+        if(!isset($GLOBALS[$sKey]))
+            $GLOBALS[$sKey] = BxDolDb::getInstance()->fromCache('sys_objects_favorite', 'getAllWithKey', '
+                SELECT
+                    `id` as `id`,
+                    `name` AS `name`,
+                    `table_track` AS `table_track`,
+                    `table_lists` AS `table_lists`,
+                    `pruning` AS `pruning`,
+                    `is_on` AS `is_on`,
+                    `is_undo` AS `is_undo`,
+                    `is_public` AS `is_public`,
+                    `base_url` AS `base_url`,
+                    `trigger_table` AS `trigger_table`,
+                    `trigger_field_id` AS `trigger_field_id`,
+                    `trigger_field_author` AS `trigger_field_author`,
+                    `trigger_field_count` AS `trigger_field_count`,
+                    `class_name` AS `class_name`,
+                    `class_file` AS `class_file`
+                FROM `sys_objects_favorite`', 'name');
+
+        return $GLOBALS[$sKey];
+    }
+
+    public static function onAuthorDelete ($iAuthorId)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem)
+            self::getObjectInstance($sSystem, 0)->getQueryObject()->deleteAuthorEntries($iAuthorId);
+
+        return true;
+    }
+
+	/**
+     * Actions functions
+     */
+    public function actionFavorite()
+    {
+        return echoJson($this->favorite());
+    }
+    
+    public function actionEditList()
+    {
+        $iListId = null;
+        if(!bx_get('list_id'))
+            return false;
+        
+        $iListId = (int) bx_get('list_id');
+        $aList = $this->_oQuery->getList(array('type' => 'id', 'list_id' => $iListId));
+         
+        if ($this->isAllowedEditList($aList['author_id'])){
+            return echoJson($this->_getEditList($aList));
+        }
+        
+        return false;
+    }
+    
+    public function actionDeleteList()
+    {
+        $iListId = null;
+        if(!bx_get('list_id'))
+            return false;
+        
+        $iListId = (int) bx_get('list_id');
+        $aList = $this->_oQuery->getList(array('type' => 'id', 'list_id' => $iListId));
+        
+        $oModule = BxDolModule::getInstance($this->_aSystem["name"]);
+        $CNF = $oModule->_oConfig->CNF;    
+        
+        if ($this->isAllowedEditList($aList['author_id'])){
+            $this->_oQuery->deleteList($iListId);
+            return echoJson(array('redirect' => BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_AUTHOR_ENTRIES'] . '&profile_id=' . $aList['author_id'])));
+        }
+        
+        return false;
+    }
+
+	public function actionGetFavoritedBy()
+    {
+        if (!$this->isEnabled())
+           return '';
+
+	    if(!$this->isAllowedFavoriteView(true))
+            return $this->msgErrAllowedFavoriteView();
+
+        return $this->_getFavoritedBy();
+    }
+
+    /**
+     * Permissions functions
+     */
+    public function isAllowedFavorite($isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        return $this->checkAction('favorite', $isPerformAction);
+    }
+    
+    public function isAllowedEditList($iAuthorId, $isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+        
+        if ($iAuthorId == bx_get_logged_profile_id())
+            return true;
+        
+        return false;
+    }
+
+	public function msgErrAllowedFavorite()
+    {
+        return $this->checkActionErrorMsg('favorite');
+    }
+
+    public function isAllowedFavoriteView($isPerformAction = false)
+    {
+        if(!$this->isPublic())
+            return false;
+
+        if(isAdmin())
+            return true;
+
+        return $this->checkAction('favorite_view', $isPerformAction);
+    }
+
+    public function msgErrAllowedFavoriteView()
+    {
+        return $this->checkActionErrorMsg('favorite_view');
+    }
+
+    /**
+     * Auxiliary functions
+     */
+    public function isUndo()
+    {
+        return (int)$this->_aSystem['is_undo'] == 1;
+    }
+
+    public function isPublic()
+    {
+        return (int)$this->_aSystem['is_public'] == 1;
+    }
+
+    /**
+     * Internal functions
+     */
+    public function doFavorite()
+    {
+        if(!$this->isEnabled())
+            return ['code' => 1, 'message' => _t('_feature_err_not_enabled')];
+
+        $iObjectId = $this->getId();
+        $iObjectAuthorId = $this->_oQuery->getObjectAuthorId($iObjectId);
+        $iAuthorId = $this->_getAuthorId();
+
+        $bUndo = $this->isUndo();
+        $bPerformed = $this->isPerformed($iObjectId, $iAuthorId);
+        $bPerformUndo = $bPerformed && $bUndo ? true : false;
+
+        if(!$bPerformUndo && !$this->isAllowedFavorite())
+            return ['code' => 2, 'message' => $this->msgErrAllowedFavorite()];
+
+        if($bPerformed && !$bUndo)
+            return ['code' => 3, 'message' => _t('_favorite_err_duplicate_favorite')];
+
+        if(!$this->_oQuery->{($bPerformUndo ? 'un' : '') . 'doFavorite'}($iObjectId, $iAuthorId))
+            return ['code' => 4, 'message' => _t('_favorite_err_cannot_perform_action')];
+
+        if(!$bPerformUndo)
+            $this->isAllowedFavorite(true);
+
+        $this->_triggerValue($bPerformUndo ? -1 : 1);
+
+        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'favorite', $iObjectId, $iAuthorId, ['favorite_author_id' => $iAuthorId, 'object_author_id' => $iObjectAuthorId]);
+        /**
+         * @hooks
+         * @hookdef hook-report-undo 'favorite', 'favorite' - hook on add new object to favorites lists or remove object from favorites lists 
+         * - $unit_name - equals `favorite`
+         * - $action - can be  do/undo 
+         * - $object_id - not used
+         * - $sender_id - profile_id for favorite's author
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_system` - [string] system name, ex: bx_posts
+         *      - `object_id` - [int] reported object id 
+         *      - `object_author_id` - [int] author's profile_id for reported object_id 
+         * @hook @ref hook-favorite-undo
+         */
+        bx_alert('favorite', ($bPerformUndo ? 'un' : '') . 'do', 0, $iAuthorId, ['object_system' => $this->_sSystem, 'object_id' => $iObjectId, 'object_author_id' => $iObjectAuthorId]);
+
+        $aFavorite = $this->_oQuery->getFavorite($iObjectId);
+
+        $aResult = [
+            'eval' => $this->getJsObjectName() . '.onFavorite(oData, oElement)',
+            'code' => 0, 
+            'count' => $aFavorite['count'],
+            'countf' => (int)$aFavorite['count'] > 0 ? $this->_getCounterLabel($aFavorite['count']) : '',
+            'label_icon' => $this->_getIconDoFavorite(!$bPerformed),
+            'label_title' => _t($this->_getTitleDoFavorite(!$bPerformed)),
+            'favorited' => !$bPerformed,
+            'disabled' => !$bPerformed && !$bUndo
+        ];
+
+        $aResult['api'] = [
+            'ts' => microtime(true),
+            'is_favorited' => $aResult['favorited'],
+            'is_disabled' => $aResult['disabled'],
+            'icon' => $aResult['label_icon'],
+            'title' => $aResult['label_title'],
+            'counter' => $aFavorite
+        ];
+
+        return $aResult;
+    }
+
+    protected function _getFavorite($iObjectId = 0, $bForceGet = false)
+    {
+        if(!empty($this->_aFavorite) && !$bForceGet)
+            return $this->_aFavorite;
+
+        if(empty($iObjectId))
+            $iObjectId = $this->getId();
+
+        $this->_aFavorite = $this->_oQuery->getFavorite($iObjectId);
+        return $this->_aFavorite;
+    }
+
+    protected function _getIconDoFavorite($bPerformed)
+    {
+    	return $bPerformed && $this->isUndo() ?  'far fa-bookmark' : 'fas fa-bookmark';
+    }
+
+    protected function _getTitleDoFavorite($bPerformed)
+    {
+    	return $bPerformed && $this->isUndo() ? '_favorite_do_unfavorite' : '_favorite_do_favorite';
+    }
+    
+    protected function _getFormObject($sDisplayName)
+    {
+        return BxDolForm::getObjectInstance($this->_sFormObject, $sDisplayName);
+    }
+}
+
+/** @} */

+ 263 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolFeature.php

@@ -0,0 +1,263 @@
+<?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
+ * @{
+ */
+
+define('BX_DOL_FEATURED_USAGE_BLOCK', 'block');
+define('BX_DOL_FEATURED_USAGE_INLINE', 'inline');
+define('BX_DOL_FEATURED_USAGE_DEFAULT', BX_DOL_FEATURED_USAGE_BLOCK);
+
+/**
+ * Track any object feature automatically
+ *
+ * Add record to sys_object_feature table to track object feature,
+ * to record feature just create this class instance with your object id,
+ * for example:
+ * @code
+ *  BxDolFeature::getObjectInstance('my_system', 25); // 25 - is object id
+ * @endcode
+ *
+ * Description of sys_object_feature table fields:
+ * @code
+ *  `name` - system name, it is better to use unique module prefix here, lowercase and all spaces are underscored
+ *  `table_track` - table to track feature
+ *  `is_on` - is the system activated
+ *  `trigger_table` - table where you need to update feature field
+ *  `trigger_field_id` - table field id to unique determine object
+ *  `trigger_field_count` - table field where the featuring date is stored
+ *  `class_name` - your custom class name, if you overrride default class
+ *  `class_file` - your custom class path
+ * @endcode
+ *
+ */
+class BxDolFeature extends BxDolObject
+{
+    protected $_sType;
+    protected $_sBaseUrl;
+
+    protected $_aElementDefaults;
+    protected $_aElementDefaultsApi;
+    protected $_aElementParamsApi; //--- Params from DefaultsApi array to be passed to Api
+
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct($sSystem, $iId, $iInit, $oTemplate);
+        if(empty($this->_sSystem))
+            return;
+
+        $this->_oQuery = new BxDolFeatureQuery($this);
+
+        $this->_sType = 'features';
+        $this->_sBaseUrl = BxDolPermalinks::getInstance()->permalink($this->_aSystem['base_url']);
+        if(get_mb_substr($this->_sBaseUrl, 0, 4) != 'http')
+            $this->_sBaseUrl = BX_DOL_URL_ROOT . $this->_sBaseUrl;
+    }
+
+   /**
+     * get feature object instanse
+     * @param $sSys feature object name
+     * @param $iId associated content id, where feature is available
+     * @param $iInit perform initialization
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstance($sSys, $iId, $iInit = true)
+    {
+        $sKey = 'BxDolFeature!' . $sSys . $iId;
+        if(isset($GLOBALS['bxDolClasses'][$sKey]))
+            return $GLOBALS['bxDolClasses'][$sKey];
+
+        $aSystems = self::getSystems();
+        if(!isset($aSystems[$sSys]))
+            return null;
+
+        $sClassName = 'BxTemplFeature';
+        if(!empty($aSystems[$sSys]['class_name'])) {
+            $sClassName = $aSystems[$sSys]['class_name'];
+            if(!empty($aSystems[$sSys]['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSystems[$sSys]['class_file']);
+        }
+
+        $o = new $sClassName($sSys, $iId, $iInit);
+        return ($GLOBALS['bxDolClasses'][$sKey] = $o);
+    }
+
+    public static function &getSystems()
+    {
+        $sKey = 'bx_dol_cache_memory_feature_systems';
+
+        if(!isset($GLOBALS[$sKey]))
+            $GLOBALS[$sKey] = BxDolDb::getInstance()->fromCache('sys_objects_feature', 'getAllWithKey', '
+                SELECT
+                    `id` as `id`,
+                    `name` AS `name`,
+                    `module` AS `module`,
+                    `is_on` AS `is_on`,
+                    `is_undo` AS `is_undo`,
+                    `base_url` AS `base_url`,
+                    `trigger_table` AS `trigger_table`,
+                    `trigger_field_id` AS `trigger_field_id`,
+                    `trigger_field_author` AS `trigger_field_author`,
+                    `trigger_field_flag` AS `trigger_field_flag`,
+                    `class_name` AS `class_name`,
+                    `class_file` AS `class_file`
+                FROM `sys_objects_feature`', 'name');
+
+        return $GLOBALS[$sKey];
+    }
+
+	/**
+     * Actions functions
+     */
+    public function actionFeature()
+    {
+        return echoJson($this->doFeature());
+    }
+
+    public function actionGetFeatureBy()
+    {
+        return '';
+    }
+
+    /**
+     * Permissions functions
+     */
+    public function isAllowedFeature($isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        return $this->checkAction('feature', $isPerformAction);
+    }
+
+	public function msgErrAllowedFeature()
+    {
+        return $this->checkActionErrorMsg('feature');
+    }
+
+    /**
+     * Auxiliary functions
+     */
+    public function isUndo()
+    {
+        return (int)$this->_aSystem['is_undo'] == 1;
+    }
+
+    /**
+     * Internal functions
+     */
+    public function doFeature()
+    {
+        if(!$this->isEnabled())
+            return array('code' => 1, 'message' => _t('_feature_err_not_enabled'));
+
+        $iObjectId = $this->getId();
+        $iObjectAuthorId = $this->_oQuery->getObjectAuthorId($iObjectId);
+        $iAuthorId = $this->_getAuthorId();
+
+        $bUndo = $this->isUndo();
+        $bPerformed = $this->isPerformed($iObjectId, $iAuthorId);
+        $bPerformUndo = $bPerformed && $bUndo ? true : false;
+
+        if(!$bPerformUndo && !$this->isAllowedFeature())
+            return array('code' => 2, 'message' => $this->msgErrAllowedFeature());
+
+        if($bPerformed && !$bUndo)
+            return array('code' => 3, 'message' => _t('_feature_err_duplicate_feature'));
+
+        if(!$this->_triggerValue($bPerformUndo ? 0 : time()))
+            return array('code' => 4, 'message' => _t('_feature_err_cannot_perform_action'));
+            
+        if(!$bPerformUndo)
+            $this->isAllowedFeature(true);
+        
+        bx_audit(
+            $this->getId(), 
+            $this->_aSystem['name'], 
+            '_sys_audit_action_' . ($bPerformUndo ? 'un' : '') . 'feature',  
+            $this->_prepareAuditParams()
+        );
+
+        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'feature', $iObjectId, $iAuthorId, array('object_author_id' => $iObjectAuthorId));
+        /**
+         * @hooks
+         * @hookdef hook-feature-undo 'feature', 'undo' - hook on cancel feature 
+         * - $unit_name - equals `feature`
+         * - $action - equals `undo` 
+         * - $object_id - report id 
+         * - $sender_id - profile_id for feature's author
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_system` - [string] system name, ex: bx_posts
+         *      - `object_id` - [int] feature object id 
+         *      - `object_author_id` - [int] author's profile_id for feature object_id 
+         * @hook @ref hook-feature-undo
+         */
+        /**
+         * @hooks
+         * @hookdef hook-feature-do 'feature', 'do' - hook on feature 
+         * - $unit_name - equals `feature`
+         * - $action - equals `do` 
+         * - $object_id - report id 
+         * - $sender_id - profile_id for feature's author
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_system` - [string] system name, ex: bx_posts
+         *      - `object_id` - [int] feature object id 
+         *      - `object_author_id` - [int] author's profile_id for feature object_id 
+         * @hook @ref hook-feature-do
+         */
+        bx_alert('feature', ($bPerformUndo ? 'un' : '') . 'do', 0, $iAuthorId, array('object_system' => $this->_sSystem, 'object_id' => $iObjectId, 'object_author_id' => $iObjectAuthorId));
+
+        $aResult = [
+            'eval' => $this->getJsObjectName() . '.onFeature(oData, oElement)',
+            'code' => 0, 
+            'label_icon' => $this->_getIconDoFeature(!$bPerformed),
+            'label_title' => _t($this->_getTitleDoFeature(!$bPerformed)),
+            'featured' => !$bPerformed,
+            'disabled' => !$bPerformed && !$bUndo
+        ];
+
+        $aResult['api'] = [
+            'ts' => microtime(true),
+            'is_featured' => $aResult['featured'],
+            'is_disabled' => $aResult['disabled'],
+            'icon' => $aResult['label_icon'],
+            'title' => $aResult['label_title'],
+        ];
+        
+        return $aResult;
+    }
+
+    protected function _getIconDoFeature($bPerformed)
+    {
+    	return $bPerformed && $this->isUndo() ? 'far star' : 'star';
+    }
+
+    protected function _getTitleDoFeature($bPerformed)
+    {
+    	return $bPerformed && $this->isUndo() ? '_feature_do_unfeature' : '_feature_do_feature';
+    }
+    
+    private function _prepareAuditParams()
+    {
+        $sModule = $this->_aSystem['module'];
+        $oModule = BxDolModule::getInstance($sModule);
+        if(!$oModule)
+            return;
+
+        $CNF = $oModule->_oConfig->CNF;
+
+        $aContentInfo = BxDolRequest::serviceExists($sModule, 'get_all') ? BxDolService::call($sModule, 'get_all', array(array('type' => 'id', 'id' => $this->getId()))) : array();
+        
+        $AuditParams = array(
+            'content_title' => isset($CNF['FIELD_TITLE']) && isset($aContentInfo[$CNF['FIELD_TITLE']])  ? $aContentInfo[$CNF['FIELD_TITLE']] : '',
+            'content_info_object' =>  isset($CNF['OBJECT_CONTENT_INFO']) ? $CNF['OBJECT_CONTENT_INFO'] : '',
+        );
+        
+        return $AuditParams;
+    }
+}
+
+/** @} */

+ 317 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolImageResize.php

@@ -0,0 +1,317 @@
+<?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
+ * @{
+ */
+
+define('IMAGE_ERROR_SUCCESS', 0); ///< operation was successfull
+define('IMAGE_ERROR_WRONG_TYPE', 2); ///< operation failed, most probably because incorrect image format(or not image file) was provided
+
+class BxDolImageResize extends BxDolFactory implements iBxDolSingleton
+{
+    protected $w = 64, $h = 64; ///< size of destination image
+    protected $_isAutoCrop = false;
+    protected $_iJpegQuality = 90; ///< jpeg quality
+    protected $_isSquareResize = false; ///< use smart resize, destination image will be exact Width x Height size
+    protected $_isUseGD; ///< use GD library or command line ImagMagic utilites
+    protected $_oManager; ///< Intervention Image Manager
+    protected $_sError; ///< Intervention Image Manager error string
+    protected $_bRemoveTransparency = false; ///< remove transparency from images during resizing
+    protected $_sRemoveTransparencyFillColor = '#ffffff'; ///< when removing transparency - fill with this color
+
+    protected function __construct()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+        $this->_isUseGD = getParam('enable_gd') == 'on' && extension_loaded('gd') ? true : false;
+
+        $this->_oManager = new Intervention\Image\ImageManager(array('driver' => $this->_isUseGD ? 'gd' : 'imagick'));
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolImageResize();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    function getManager ()
+    {
+        return $this->_oManager;
+    }
+
+    function getError ()
+    {
+        return $this->_sError;
+    }
+
+    function isAllowedImage ($sSrcImage)
+    {
+        try {
+            $this->_oManager->make($sSrcImage);
+        }
+        catch (Exception $e) {
+            return false;
+        }
+
+        return true;
+    }
+
+    function resize ($mixedImage, $sDstImage = '')
+    {
+        if (is_array($mixedImage)) {
+            $aRet = array();
+            foreach ($mixedImage as $s) {
+                $aRet[] = $this->_resize ($s, $s);
+            }
+            return $aRet;
+        } else {
+            return $this->_resize ($mixedImage, $sDstImage);
+        }
+    }
+
+    function applyWatermark ($mixedImage, $sDstImage, $sWtrImage, $iTransparency, $sPosition = 'bottom-right', $sPositionOffsetX = 0, $sPositionOffsetY = 0, $sScaleFactor = 0.2 )
+    {
+        if (is_array($mixedImage)) {
+            $aRet = array();
+            foreach ($mixedImage as $s)
+                $aRet[] = $this->_applyWatermark ($s, $s, $sWtrImage, $iTransparency, $sPosition, $sPositionOffsetX, $sPositionOffsetY, $sScaleFactor);
+            return $aRet;
+        } else {
+            return $this->_applyWatermark ($mixedImage, $sDstImage, $sWtrImage, $iTransparency, $sPosition, $sPositionOffsetX, $sPositionOffsetY, $sScaleFactor);
+        }
+    }
+
+    function grayscale ($mixedImage, $sDstImage = '')
+    {
+        if (is_array($mixedImage)) {
+            $aRet = array();
+            foreach ($mixedImage as $s) {
+                $aRet[] = $this->_grayscale ($s, $s);
+            }
+            return $aRet;
+        } else {
+            return $this->_grayscale ($mixedImage, $sDstImage);
+        }
+    }
+
+    function setSize ($w, $h)
+    {
+        $this->w = $w;
+        $this->h = $h;
+    }
+
+    function removeCropOptions ()
+    {
+        $this->_isAutoCrop = false;
+    }
+
+    /**
+     * Crop image to destination size with filling whole area of destination size
+     */
+    function setAutoCrop ($b)
+    {
+        $this->_isAutoCrop = $b;
+    }
+
+    function setJpegQuality ($i)
+    {
+        $this->_iJpegQuality = $i;
+    }
+
+    public function getJpegQuality ()
+    {
+        return $this->_iJpegQuality;
+    }
+
+    function setSquareResize ($b)
+    {
+        $this->_isSquareResize = ($b ? true : false);
+    }
+
+    function isUsedGD ()
+    {
+        return $this->_isUseGD;
+    }
+
+    static function getImageSize($sPath)
+    {
+        $o = self::getInstance();
+        return $o->_getImageSize($sPath);
+    }
+
+    function _getImageSize($sPath)
+    {
+        $this->_sError = '';
+        try {        
+            $o = $this->_oManager->make($sPath);
+        } 
+        catch (Exception $e) {
+            $this->_sError = $e->getMessage();
+            return false;
+        }
+        return array ('w' => $o->width(), 'h' => $o->height());
+    }
+
+    function getExifInfo($sSrcImage, $bCreateLocalFileIfUrl = true)
+    {
+        $this->_sError = '';
+        $sTmpFileName = false;
+        $mixedRet = false;
+
+        if ($bCreateLocalFileIfUrl && preg_match('/^https?:\/\//', $sSrcImage)) {
+            $sTmpFileName = tempnam(BX_DIRECTORY_PATH_TMP, '');
+            file_put_contents($sTmpFileName, file_get_contents($sSrcImage));
+        }
+
+        try {
+            $mixedRet = $this->_oManager
+                ->make($sTmpFileName ? $sTmpFileName : $sSrcImage)
+                ->exif();
+        }
+        catch (Exception $e) {
+            $this->_sError = $e->getMessage();
+        }
+
+        if ($sTmpFileName)
+            @unlink($sTmpFileName);
+
+        return $mixedRet;
+    }
+
+    function getAverageColor($sSrcImage)
+    {
+        $this->_sError = '';
+        try {
+            $a = $this->_oManager
+                ->make($sSrcImage)
+                ->resize(1, 1)
+                ->pickColor(0, 0, 'array');
+
+            return array('r' => $a[0], 'g' => $a[1], 'b' => $a[2]);
+        }
+        catch (Exception $e) {
+            $this->_sError = $e->getMessage();
+            return false;
+        }
+    }
+
+    // private functions are below -------------------------------
+
+    function _grayscale ($sSrcImage, $sDstImage = '')
+    {
+        $this->_sError = '';
+        try {
+            $this->_oManager
+                ->make($sSrcImage)
+                ->greyscale()
+                ->save($sDstImage ? $sDstImage : $sSrcImage, $this->_iJpegQuality);
+
+            chmod($sDstImage ? $sDstImage : $sSrcImage, BX_DOL_FILE_RIGHTS);
+        }
+        catch (Exception $e) {
+            $this->_sError = $e->getMessage();
+            return IMAGE_ERROR_WRONG_TYPE;
+        }
+
+        return IMAGE_ERROR_SUCCESS;
+    }
+
+    function _removeTransparency ($oImg)
+    {
+        $oImgBack = $this->_oManager->canvas($oImg->width(), $oImg->height(), $this->_sRemoveTransparencyFillColor);
+        return $oImgBack->insert($oImg);
+    }
+
+    function _resize ($sSrcImage, $sDstImage = '')
+    {       
+        $this->_sError = '';
+        try {
+            if ($this->_isAutoCrop || $this->_isSquareResize) {
+
+                $oImg = $this->_oManager
+                    ->make($sSrcImage)
+                    ->orientate();
+
+                if ($this->_bRemoveTransparency)
+                    $oImg = $this->_removeTransparency($oImg);
+
+                $oImg
+                    ->fit($this->w, $this->_isSquareResize ? $this->w : $this->h)
+                    ->save($sDstImage ? $sDstImage : $sSrcImage, $this->_iJpegQuality);
+
+            } 
+            else {
+
+                $oImg = $this->_oManager
+                    ->make($sSrcImage)
+                    ->orientate();
+
+                if ($this->_bRemoveTransparency)
+                    $oImg = $this->_removeTransparency($oImg);
+
+                $oImg
+                    ->resize($this->w, $this->h, function ($constraint) {
+                        $constraint->aspectRatio();
+                        $constraint->upsize();
+                    })
+                    ->save($sDstImage ? $sDstImage : $sSrcImage, $this->_iJpegQuality);
+            }
+            chmod($sDstImage ? $sDstImage : $sSrcImage, BX_DOL_FILE_RIGHTS);
+        }
+        catch (Exception $e) {
+            $this->_sError = $e->getMessage();
+            return IMAGE_ERROR_WRONG_TYPE;
+        }
+
+        return IMAGE_ERROR_SUCCESS;
+    }
+
+    function _applyWatermark( $sSrcImage, $sDstImage, $sWtrImage, $iTransparency, $sPosition = 'bottom-right', $sPositionOffsetX = 0, $sPositionOffsetY = 0, $sScaleFactor = 0.2)
+    {
+        $this->_sError = '';
+        try {
+            $oImageOrig = $this->_oManager->make($sSrcImage);
+
+            $oImageOrig
+                ->insert($this->_oManager
+                    ->make($sWtrImage)
+                    ->resize(round($oImageOrig->width() * $sScaleFactor), round($oImageOrig->height() * $sScaleFactor),  function ($constraint) {
+                        $constraint->aspectRatio();
+                        $constraint->upsize();
+                    })
+                    ->opacity($iTransparency), 
+                $sPosition, $sPositionOffsetX, $sPositionOffsetY)
+                ->save($sDstImage ? $sDstImage : $sSrcImage, $this->_iJpegQuality);
+
+            chmod($sDstImage ? $sDstImage : $sSrcImage, BX_DOL_FILE_RIGHTS);
+        }
+        catch (Exception $e) {
+            $this->_sError = $e->getMessage();
+            return IMAGE_ERROR_WRONG_TYPE;
+        }
+
+        return IMAGE_ERROR_SUCCESS;        
+    }
+}
+
+/** @} */

+ 176 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolLogs.php

@@ -0,0 +1,176 @@
+<?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
+ * @{
+ */
+
+/**
+ * This class unifies the usage of logs.
+ *
+ * Add record to sys_objects_logs table, like you are doing this for Comments or Voting objects:
+ * - object: your logs object name, usually it is in the following format - vendor prefix, underscore, module prefix;
+ * - module: module name
+ * - logs_storage: logs storage, supported storages: 
+ *      - `Auto`: log to the storage currently configured in settings
+ *      - `Folder`: files in `logs` folder
+ *      - `PHPLog`: php log
+ *      - `STDErr`: standard error output, usually written to web server error log
+ * - title: translatable title
+ * - active: 0 or 1
+ * - class_name: user defined class name which is derived from BxDolLogs.
+ * - class_file: the location of the user defined class, leave it empty if class is located in system folders.
+ * 
+ * It's also possible to not add record to sys_objects_logs table, 
+ * then it will use default settings for logging.
+ *
+ * @section example Example of usage
+ *
+ * Log data to the logs object
+ *
+ * @code
+ *  if ($o = BxDolLogs::getObjectInstance('my_module_logs_object'))
+ *      $o->add($s);
+ * @endcode
+ *
+ * or
+ *
+ * @code
+ *  bx_log('my_module_logs_object', $s);
+ * @endcode
+ *
+ */
+class BxDolLogs extends BxDolFactory implements iBxDolFactoryObject
+{
+	protected $_oDb;
+	protected $_sObject;
+    protected $_aObject;
+    protected $_oLogsStorage;
+
+    /**
+     * Constructor
+     */
+    protected function __construct($aObject, $oLogsStorage)
+    {
+        parent::__construct();
+
+        $this->_aObject = $aObject;
+        $this->_sObject = $aObject['object'];
+
+        $this->_oLogsStorage = $oLogsStorage;
+        $this->_oDb = new BxDolLogsQuery($this->_aObject);
+    }
+
+   /**
+     * Get logs object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    public static function getObjectInstance($sObject)
+    {
+        if(isset($GLOBALS['bxDolClasses']['BxDolLogs!' . $sObject]))
+            return $GLOBALS['bxDolClasses']['BxDolLogs!' . $sObject];
+
+        $aObject = BxDolLogsQuery::getLogsObject($sObject);
+        if($aObject && is_array($aObject) && !$aObject['active'])
+            return false;
+        if(!$aObject) {
+            $aObject = array(
+                'object' => $sObject,
+                'module' => 'system',
+                'logs_storage' => getParam('sys_logs_storage_default') ?: 'Folder',
+            );
+        }
+
+        $sClass = 'BxDolLogs';
+        if(!empty($aObject['class_name'])) {
+            $sClass = $aObject['class_name'];
+            if(!empty($aObject['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aObject['class_file']);
+        }        
+
+        $sLogsStorage = (empty($aObject['logs_storage']) ? 'Auto' : $aObject['logs_storage']);
+        if ('Auto' == $sLogsStorage)
+            $sLogsStorage = getParam('sys_logs_storage_default') ?: 'Folder';
+        $sClassLogsStorage = 'BxDolLogsStorage' . $sLogsStorage;
+        $oLogsStorage = $sClassLogsStorage::getInstance();
+
+        $o = new $sClass($aObject, $oLogsStorage);
+        return ($GLOBALS['bxDolClasses']['BxDolLogs!' . $sObject] = $o);
+    }
+
+    static public function getLevel2NameMap()
+    {
+        return [
+            BX_LOG_ERR => 'ERROR',
+            BX_LOG_WARN => 'WARNING',
+            BX_LOG_INFO => 'INFO',
+            BX_LOG_DEBUG => 'DEBUG',
+        ];
+    }
+
+    static public function getName2LevelsMap()
+    {
+        return [
+            'ERROR' => BX_LOG_ERR,
+            'WARNING' => BX_LOG_WARN | BX_LOG_ERR,
+            'INFO' => BX_LOG_INFO | BX_LOG_WARN | BX_LOG_ERR,
+            'DEBUG' => BX_LOG_DEBUG | BX_LOG_INFO | BX_LOG_WARN | BX_LOG_ERR,
+        ];
+    }
+
+    /**
+     * Get current logs object name
+     */
+    public function getObjectName()
+    {
+        return $this->_aObject['object'];
+    }
+
+    /**
+     * Log to the current logs storage object
+     * @param $mixed string or array to log
+     * @return true on success or false on error
+     */
+    public function add($mixed, $iLevel = BX_LOG_DEBUG)
+    {
+        $a = $this->getName2LevelsMap();
+        $sLogLevel = getParam('sys_logs_level');
+        if (isset($a[$sLogLevel]) && ($a[$sLogLevel] & $iLevel))
+            return $this->_oLogsStorage->add($this, $mixed, $iLevel);
+        return true;
+    }
+
+    /**
+     * Get logs from current logs storage
+     * @param $iLines number of lines from the tail to return
+     * @param $sFilter filter lines by keyword
+     * @return array of strings or false on error
+     */
+    public function get($iLines = 30, $sFilter = '')
+    {
+        if (!$this->_oLogsStorage->isGetAvail())
+            return false;
+        return $this->_oLogsStorage->get($this, $iLines, $sFilter);
+    }
+
+    /**
+     * Check if filtering supported in `get` method
+     */
+    public function isFilterAvail()
+    {
+        return $this->_oLogsStorage->isFilterAvail();
+    }
+
+    /**
+     * Check if `get` method available
+     */
+    public function isGetAvail()
+    {
+        return $this->_oLogsStorage->isGetAvail();
+    }
+}
+
+/** @} */

+ 1288 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolMetatags.php

@@ -0,0 +1,1288 @@
+<?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
+ * @{
+ */
+
+/**
+ * Meta-tags for different content. It can handle \#keywords, \@mentions, locations and meta image for the content.
+ *
+ * Keywords are parsed after content add and edit and added to the database, 
+ * later when content is displayed \#keywords are highlighted as links to the search page with all content with this keyword, 
+ * also keywords are displayed as meta info in page header.
+ * 
+ * Location info upon content adding/editing is displayed as custom field, it detects current user location and attach location as hidden form fields.
+ * When content is displayed location can be shown as clickable links to search page where results are other items from same location,
+ * also location is displayed as meta info in page header as latutude and longitude.
+ * 
+ * Content image can be added as meta info using this class as well.
+ *
+ *
+ *
+ * @section metatags_create Add new meta-tags object.
+ *
+ * To add new meta-tags object insert new record into `sys_objects_metatags` table:
+ * - object: name of the meta-tags object, in the format: vendor prefix, underscore, module prefix, underscore, internal identifier or nothing
+ * - table_keywords: table name to store keywords, leave empty to disable keywords
+ * - table_locations: table name to store locations, leave empty to disable locations
+ * - table_mentions: table name to store mentions, leave empty to disable mentions
+ * - override_class_name: class name to override  
+ * - override_class_file: class file path to override
+ *
+ * To simplify operations with metatags in modules which are derived from some 'base' modules, specify it in module config class in 'CNF' array: 
+ * - FIELDS_WITH_KEYWORDS: array or comma separated list of field names with keywords support, or 'auto' to automatically use all 'textarea' fields
+ * - OBJECT_METATAGS: metatags object name
+ *
+ *
+ *
+ * @section metatags_locations Location
+ *
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::locationsAddFromForm
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->locationsAddFromForm($iContentId);
+ * @endcode
+ *
+ * Upon 'edit' form display call BxDolMetatags::locationGet to fill-in location form input, for example:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $aSpecificValues = $oMetatags->locationGet($iContentId);
+ *   $oForm->initChecker($aContentInfo, $aSpecificValues);
+ * @endcode
+ *
+ * To display location call BxDolMetatags::locationsString:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   echo $oMetatags->locationsString($iContentId)
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add location (and all other, including content image) meta information to the page header:
+ * @code
+ *   $o = BxDolMetatags::getObjectInstance('object_name');
+ *   $o->addPageMetaInfo($iContentId, array('id' => $iFileId, 'object' => 'storage_object_name'));
+ * @endcode
+ * 
+ * Form object must have location field, usual name for this field is 'location' (this name is used as $sPrefix in some functions), type is 'custom', 
+ * also form object must be derived from BxBaseModGeneralFormEntry class, which has custom field declaration.
+ *
+ *
+ *
+ * @section metatags_keywords \#keyword
+ *
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::keywordsAdd
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->keywordsAdd($iContentId, $aContentInfo['text_field_with_hash_tags']);
+ * @endcode
+ *
+ * Before displaying the text with hashtags call BxDolMetatags::keywordsParse to highlight keywords:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $sText = $oMetatags->keywordsParse($iContentId, $sText);
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add keywords (and all other, including content image) meta information to the page header.
+ *
+ * To be able to search by tags metatgs object must be specified in *SearchResult class as 'object_metatags' in 'aCurrent' array.
+ *
+ *
+ *
+ * @section metatags_mention \@mention
+ * 
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::mentionsAdd
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->mentionsAdd($iContentId, $aContentInfo['text_field_with_mentions']);
+ * @endcode
+ *
+ * Before displaying the text with mentions call BxDolMetatags::mentionsParse:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $sText = $oMetatags->mentionsParse($iContentId, $sText);
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add all available meta info to the page meta into where possible, however it doesn't adding anyting for mentions for now.
+ *
+ * To be able to search by mentions object must be specified in *SearchResult class as 'object_metatags' in 'aCurrent' array.
+ *
+ *
+ *
+ * @section metatags_auto Add \@mention, \#keyword using one call
+ * 
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::metaAdd
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->metaAdd($iContentId, $aContentInfo['text_field']);
+ * @endcode
+ * or call BxDolMetatags::metaAddAuto, it autodetects fields with metainfo:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->metaAddAuto($iContentId, $aContentInfo, $CNF, $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD']);
+ * @endcode
+ *
+ * Before displaying the text with different metainfo call BxDolMetatags::metaParse:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $sText = $oMetatags->metaParse($iContentId, $sText);
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add all available meta info to the page meta into where possible.
+ *
+ *
+ * 
+ * @section metatags_delete_content Content deletion
+ * 
+ * When content is deleted associated meta data can be deleted by calling BxDolMetatags::onDeleteContent:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->onDeleteContent($iContentId);
+ * @endcode
+ *
+ */
+class BxDolMetatags extends BxDolFactory implements iBxDolFactoryObject
+{
+    protected static $_aLocationKeys = ['lat', 'lng', 'country', 'state', 'city', 'zip', 'street', 'street_number'];
+    protected static $_sKeywordPattern = '/[\pCc\pZ\p{Ps}\p{Pe}\p{Pi}\p{Pf}]\#(\pL[\pL\pN_]+)/u';
+            
+    protected $_sObject;
+    protected $_aObject;
+    protected $_oQuery;
+    protected $_aMetas = [];
+
+    /**
+     * Constructor
+     * @param $aObject array of metags object options
+     */
+    protected function __construct($aObject)
+    {
+        parent::__construct();
+
+        $this->_sObject = $aObject['object'];
+        $this->_aObject = $aObject;
+        
+        $a = array ('keywords', 'locations', 'mentions', 'pictures');
+        foreach ($a as $sMeta) {
+            if (empty($this->_aObject['table_' . $sMeta]))
+                continue;
+            $this->_aMetas[] = $sMeta;
+        }
+
+        $this->_oQuery = new BxDolMetatagsQuery($this->_aObject);
+    }
+
+    /**
+     * Get metatags object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    static public function getObjectInstance($sObject)
+    {
+        if (isset($GLOBALS['bxDolClasses']['BxDolMetatags!'.$sObject])) 
+            return $GLOBALS['bxDolClasses']['BxDolMetatags!'.$sObject];
+
+        $aObject = BxDolMetatagsQuery::getMetatagsObject($sObject);
+        if (!$aObject || !is_array($aObject))
+            return false;
+
+        $sClass = 'BxTemplMetatags';
+        if (!empty($aObject['override_class_name']))
+            $sClass = $aObject['override_class_name'];
+        if (!empty($aObject['override_class_file']))
+            require_once(BX_DIRECTORY_PATH_ROOT . $aObject['override_class_file']);
+
+        $o = new $sClass($aObject);
+
+        return ($GLOBALS['bxDolClasses']['BxDolMetatags!'.$sObject] = $o);
+    }
+
+    /**
+     * Get metatags by term
+     * @param string $sMeta - 'keywords' is only supported meta for now
+     * @param type $sMetaItem
+     * @param type $sTerm
+     * @return type
+     */
+    static public function getMetatagsDataByTerm($sMeta, $sMetaItem, $sTerm)
+    {
+        $bHashtagsOnly = getParam('sys_metatags_hashtags_only') == 'on';
+        $aLabels = []; 
+        if($bHashtagsOnly)
+            $aLabels = array_map(function($sValue) {return mb_strtolower($sValue);}, BxDolLabel::getInstance()->getLabels(['type' => 'values']));
+
+        $aValues = [];
+        $aObjects = BxDolMetatagsQuery::getMetatagsObjects();
+        foreach($aObjects as $aObject) {
+            $oObject = BxDolMetatags::getObjectInstance($aObject['object']);
+            if(!$oObject || !$oObject->{$sMeta . 'IsEnabled'}())
+                continue;
+
+            $sMethod = $sMeta . 'GetByTerm';
+            if(!method_exists($oObject->_oQuery, $sMethod))
+                continue;
+
+            $aData = $oObject->_oQuery->$sMethod($sTerm);
+            foreach($aData as $aMeta) {
+                if($bHashtagsOnly && in_array(strtolower($aMeta[$sMetaItem]), $aLabels))
+                    continue;
+
+                $aMatch = [];
+                if(!preg_match(self::$_sKeywordPattern, ' #' . $aMeta[$sMetaItem], $aMatch) || !isset($aMatch[1]) || strcasecmp($aMeta[$sMetaItem], $aMatch[1]) != 0) 
+                    continue;
+
+                $aValues[$aMeta[$sMetaItem]] = [
+                    'id' => $aMeta['object_id'], 
+                    'meta' => $aMeta[$sMetaItem], 
+                    'url' => $oObject->keywordsGetHashTagUrl($aMeta[$sMetaItem], $aMeta['object_id'])
+                ];
+            }
+        }
+
+        $aValues = array_slice($aValues, 0, (int)getParam('sys_profiles_search_limit'));
+        
+        return $aValues;
+    }
+
+    public function getModule()
+    {
+        return $this->_aObject['module'];
+    }
+
+    /**
+     * Add all available meta tags to the head section 
+     * @return number of successfully added metas
+     */
+    public function addPageMetaInfo($iId, $mixedImage = false)
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'AddMeta';
+            $i += $this->$sFunc($iId);
+        }
+
+        if ($mixedImage && is_array($mixedImage)) {
+            
+            if (!empty($mixedImage['object']))
+                $o = BxDolStorage::getObjectInstance($mixedImage['object']);
+            elseif (!empty($mixedImage['transcoder']))
+                $o = BxDolTranscoder::getObjectInstance($mixedImage['transcoder']);
+
+            $mixedImage = $o ? $o->getFileUrlById($mixedImage['id']) : false;
+        }
+
+        if ($mixedImage) 
+            BxDolTemplate::getInstance()->addPageMetaImage($mixedImage);
+
+        return $i;
+    }
+
+    /**
+     * Perform @see keywordsParse @see mentionsParse and maybe other
+     * @param $iId content id
+     * @param $s string
+     * @return modified string
+     */    
+    public function metaParse($iId, $s)
+    {
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'Parse';
+            if (method_exists($this, $sFunc))
+                $s = $this->$sFunc($iId, $s);
+        }
+        return $s;
+    }
+
+    /**
+     * Perform @see keywordsAddAuto @see mentionsAddAuto and maybe other
+     * @param $iId content id
+     * @param $aContentInfo content info array
+     * @param $CNF module config array
+     * @param $sFormDisplay form display object
+     * @return number of founded keywords, mentions, etc
+     */
+    public function metaAddAuto($iId, $aContentInfo, $CNF, $sFormDisplay) 
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'AddAuto';
+            if (method_exists($this, $sFunc))
+                $i += $this->$sFunc($iId, $aContentInfo, $CNF, $sFormDisplay);
+        }
+        return $i;
+    }
+
+    /**
+     * Perform @see keywordsAdd @see mentionsAdd and maybe other
+     * @param $iId content id
+     * @param $s string
+     * @return number of added keywords, mentions, etc
+     */
+    public function metaAdd($iId, $s)
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            if ('locations' == $sMeta)
+                continue;
+            $sFunc = $sMeta . 'Add';
+            if (method_exists($this, $sFunc))
+                $i += $this->$sFunc($iId, $s);
+        }
+        return $i;
+    }
+    
+    /**
+     * Checks if keywords enabled for current metatags object
+     */
+    public function keywordsIsEnabled() 
+    {
+        return empty($this->_aObject['table_keywords']) ? false : true;
+    }
+
+    /**
+     * This function is specifically for formatting "Photo Camera" string, when "Photo Camera" is used as image hashtag
+     */
+    static public function keywordsCameraModel($aExif) 
+    {
+        if (!isset($aExif['Make']))
+            return '';
+
+        $sMake = trim($aExif['Make']);
+        if ($sMake && isset($aExif['Model'])) {
+            $sModel = trim($aExif['Model']);
+            if (0 === mb_strpos($sModel, $sMake))
+                $sModel = mb_substr($sModel, mb_strlen($sMake));
+        }
+
+        return $sMake . (empty($sModel) ? '' : ' ' . trim($sModel));
+    }
+
+    /**
+     * Add \#keywords from the string
+     * @param $iId content id
+     * @param $s string with \#keywords
+     * @return number of found keywords
+     */
+    public function keywordsAdd($iId, $s) 
+    {
+        /**
+         * First of all remove <a> HTML tags which don't have 'bx-tag-link' class WITH their content.
+         * It's needed because hashtags cannot be used inside links, otherwise we'll get link inside link.
+         */
+        $s = preg_replace("/<a\b((?!bx-tag-link)[^>])*>(.*?)<\/a>/si", '', $s);
+
+        //--- Strip the other HTML tags.
+        $s = strip_tags(str_replace(array('<br>', '<br />', '<hr>', '<hr />', '</p>', '</h1>', '</h2>', '</h3>', '</h4>', '</h5>', '</h6>'), "\n", $s));
+
+        // process spaces
+        $s = str_ireplace('&nbsp;', ' ', $s);
+        
+        return $this->_metaAdd($iId, ' ' . $s, self::$_sKeywordPattern, 'keywordsDelete', 'keywordsAdd', 'keywordsGet', (int)getParam('sys_metatags_hashtags_max'), 'keyword');
+    }
+
+    /**
+     * Add keyword from the whole string
+     * @param $iId content id
+     * @param $s keyword 
+     * @return number of added keywords, should be 1
+     */
+    public function keywordsAddOne($iId, $s, $bDeletePreviousKeywords = true)
+    {
+        if ($iRet = $this->_oQuery->keywordsAdd($iId, array($s), $bDeletePreviousKeywords)) {
+            $sSource = $this->_sObject . '_' . $iId;
+
+            /**
+             * @hooks
+             * @hookdef hook-bx_dol_metatags-keyword_added '{object_name}', 'keyword_added' - hook after a keyword (hashtag) was recognized in provided text
+             * - $unit_name - metatags object name
+             * - $action - equals `keyword_added`
+             * - $object_id - content id
+             * - $sender_id - logged in profile id
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `meta` - [string] recognized keyword
+             *      - `content_id` - [int] content id 
+             *      - `source` - [string] unique source id
+             * @hook @ref hook-bx_dol_metatags-keyword_added
+             */
+            bx_alert($this->_aObject['module'], 'keyword_added', $iId, bx_get_logged_profile_id(), [
+                'meta' => $s, 
+                'content_id' => $iId, 
+                'source' => $sSource
+            ]);
+
+            /**
+             * @hooks
+             * @hookdef hook-meta_keyword-added 'meta_keyword', 'added' - hook after a keyword (hashtag) was recognized in provided text
+             * It's equivalent to @ref hook-bx_dol_metatags-keyword_added 
+             * except `object` parameter with metatags object name was added in $extra_params
+             * @hook @ref hook-meta_keyword-added
+             */
+            bx_alert('meta_keyword', 'added', $iId, bx_get_logged_profile_id(), [
+                'meta' => $s, 
+                'content_id' => $iId, 
+                'object' => $this->_sObject, 
+                'source' => $sSource
+            ]);
+        }
+
+        return $iRet;
+    }
+
+    /**
+     * Add \#keywords from the content fields
+     * @param $iId content id
+     * @param $aContentInfo content info array
+     * @param $CNF module config array
+     * @param $sFormDisplay form display object
+     * @return number of found keywords
+     */
+    public function keywordsAddAuto($iId, $aContentInfo, $CNF, $sFormDisplay) 
+    {
+        $aFields = $this->metaFields($aContentInfo, $CNF, $sFormDisplay); 
+        $sTextWithKeywords = '';
+        foreach ($aFields as $sField)
+            $sTextWithKeywords .= "\n" . $aContentInfo[$sField];
+
+        return $this->keywordsAdd($iId, $sTextWithKeywords);
+    }
+
+    /**
+     * Get field names which are subject to parse keywords
+     */
+    public function metaFields($aContentInfo, $CNF, $sFormDisplay, $bHtmlOnly = false)
+    {
+        $aFields = array();
+        if (empty($CNF['FIELDS_WITH_KEYWORDS'])) {
+            return array();
+        }
+        elseif (is_string($CNF['FIELDS_WITH_KEYWORDS']) && 'auto' == $CNF['FIELDS_WITH_KEYWORDS']) {
+            if (!($oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $sFormDisplay)))
+                return array();
+
+            foreach ($oForm->aInputs as $k => $a) {
+                if ('textarea' == $a['type'] && (!$bHtmlOnly || ($bHtmlOnly && $a['html'])))
+                    $aFields[] = $a['name'];
+            }
+        } 
+        elseif (is_array($CNF['FIELDS_WITH_KEYWORDS'])) {
+            $aFields = $CNF['FIELDS_WITH_KEYWORDS'];
+        } 
+        elseif (is_string($CNF['FIELDS_WITH_KEYWORDS'])) {
+            $aFields = explode(',', $CNF['FIELDS_WITH_KEYWORDS']);
+        }
+
+        return $aFields;
+    }
+
+    /**
+     * Add links to the \#keywords in the string
+     * @param $iId content id
+     * @param $s string with \#keywords
+     * @return modified string where all \#keywords are transformed to links with rel="tag" attribute
+     */
+    public function keywordsParse($iId, $s) 
+    {   
+        $a = $this->keywordsGet($iId);
+        if (empty($a))
+            return $s;
+
+        /**
+         * Replace <a> tags, which have 'bx-tag-link' class, with their content. 
+         * It's needed to avoid link inside link after hashtags parsing.
+         */
+        $s = preg_replace('/<a\b[^>]*\bbx-tag-link\b[^>]*>(.*?)<\/a>/si', '$1', $s);
+
+        foreach ($a as $sKeyword) {
+            $f = function ($a) use ($sKeyword, $iId) {
+                return $a[1] . '<a class="bx-tag-link" rel="tag" href="' . $this->keywordsGetHashTagUrl($sKeyword, $iId) . '">' . (bx_is_api() ? '#' . $sKeyword : '<s>#</s><b>' . $sKeyword . '</b>') . '</a>';
+            };
+
+            $s = preg_replace_callback('/([^\pN^\pL])\#(' . preg_quote($sKeyword, '/') . ')/u', $f, $s);
+            $s = preg_replace_callback('/^()\#(' . preg_quote($sKeyword, '/') . ')/u', $f, $s);
+        }
+
+        return $s;
+    }
+
+    /**
+     * Add link to the provided keyword
+     * @param $iId content id
+     * @param $s keyword 
+     * @return modified string where provided keyword is transformed to link with rel="tag" attribute
+     */
+    public function keywordsParseOne($iId, $s) 
+    {   
+        $a = $this->keywordsGet($iId);
+        if (empty($a))
+            return $s;
+    
+        foreach ($a as $sKeyword)
+            if (0 === strcasecmp(mb_strtolower($s), mb_strtolower($sKeyword)))
+                $s = '<a class="bx-tag-link" rel="tag" href="' . $this->keywordsGetHashTagUrl($sKeyword, $iId) . '">' . $sKeyword . '</a>';
+
+        return $s;
+    }
+	
+    public function keywordsGetHashTagUrl($sKeyword, $iId, $mixedSection = false) 
+    {
+        $aUrlParams = ['type' => 'keyword', 'keyword' => rawurlencode($sKeyword)];
+        if(!empty($mixedSection))
+            $aUrlParams['section'] = $mixedSection;
+
+        $sUrl = BX_DOL_URL_ROOT . BxDolPermalinks::getInstance()->permalink('page.php?i=search-keyword', $aUrlParams);
+
+        /**
+         * @hooks
+         * @hookdef hook-meta_keyword-url 'meta_keyword', 'url' - hook to override meta keyword URL
+         * - $unit_name - equals `meta_keyword`
+         * - $action - equals `url`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `url` - [string] by ref, keyword URL, can be overridden in hook processing
+         *      - `keyword` - [string] keyword
+         *      - `id` - [int] content id
+         *      - `object` - [string] metatags object name
+         *      - `section` - [string] a string with sections (modules) in which a search by keyword will be performed (for default hashtag URL)
+         * @hook @ref hook-meta_keyword-url
+         */
+        bx_alert('meta_keyword', 'url', 0, false, [
+            'url' => &$sUrl,
+            'url_ref' => &$sUrl,
+            'keyword' => $sKeyword,
+            'id' => $iId,
+            'object' => $this->_sObject,
+            'section' => $mixedSection,
+            'sObject' => $this->_sObject, //depricated, will be removed in future versions, approximately in UNA 15.
+        ]);
+    
+        return bx_is_api() ? bx_api_get_relative_url($sUrl) : $sUrl;
+    }
+
+    /**
+     * Add keywords meta info to the head section
+     * @param $iId content id
+     */
+    protected function keywordsAddMeta($iId)
+    {
+        BxDolTemplate::getInstance()->addPageKeywords($this->keywordsGet($iId));
+    }
+
+    /**
+     * Get list of keywords associated with the content
+     * @return array of keywords
+     */
+    public function keywordsGet($iId)
+    {
+        return $this->_oQuery->keywordsGet($iId);
+    }
+
+    /**
+     * Set condition for search results object for meta keyword
+     * @param $oSearchResult search results object
+     * @param $sKeyword keyword
+     */
+    public function keywordsSetSearchCondition($oSearchResult, $sKeyword, $iCmtsSystemId = 0)
+    {
+        if (!$this->keywordsIsEnabled())
+            return;
+
+        if ('sys_cmts' == $oSearchResult->aCurrent['object_metatags']) {
+            $this->keywordsSetSearchConditionCmts($oSearchResult, $sKeyword, $iCmtsSystemId);
+            return;
+        }
+
+        $oSearchResult->aCurrent['restriction']['meta_keyword'] = array(
+            'value' => $sKeyword,
+            'field' => 'keyword',
+            'operator' => '=',
+            'table' => $this->_aObject['table_keywords'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_keyword'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_keywords'],
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+
+    public function keywordsSetSearchConditionCmts($oSearchResult, $sKeyword, $iCmtsSystemId = 0)
+    {
+        if (!$this->keywordsIsEnabled())
+            return;
+
+        if ($iCmtsSystemId) {
+            $oSearchResult->aCurrent['restriction']['meta_keyword_cmts_system'] = array(
+                'value' => $iCmtsSystemId,
+                'field' => 'system_id',
+                'operator' => '=',
+                'table' => 'sys_cmts_ids',
+            );
+        }
+        
+        $oSearchResult->aCurrent['restriction']['meta_keyword'] = array(
+            'value' => $sKeyword,
+            'field' => 'keyword',
+            'operator' => '=',
+            'table' => $this->_aObject['table_keywords'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_keyword_cmts'] = array(
+            'type' => 'INNER',
+            'table' => 'sys_cmts_ids',
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'cmt_id',
+            'joinFields' => array(),
+        );
+        
+        $oSearchResult->aCurrent['join']['meta_keyword'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_keywords'],
+            'mainField' => 'id',
+            'mainTable' => 'sys_cmts_ids',
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+    
+	/**
+     * Get part of SQL query for meta keyword
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $sKeyword keyword
+     */
+    public function keywordsGetAsSQLPart($sContentTable, $sContentField, $sKeyword)
+    {
+        if (empty($this->_aObject['table_keywords']))
+            return array();
+
+        return call_user_func_array(array($this->_oQuery, 'keywordsGetSQLParts'), func_get_args());
+    }
+
+    public function keywordsPopularList($iLimit, $mContextId = false)
+    {
+        return $this->_oQuery->keywordsPopularList($iLimit, $mContextId);
+    }
+
+
+    /**
+     * Checks if locations enabled for current metatags object
+     */
+    public function locationsIsEnabled() 
+    {
+        return empty($this->_aObject['table_locations']) ? false : true;
+    }
+
+    /**
+     * Add location for the content
+     * @param $iId content id
+     * @param $sLatitude latitude
+     * @param $sLongitude longitude
+     * @param $sCountryCode optional 2 letters country code (ISO 3166-1)
+     * @param $sState optional state/province/territory name
+     * @param $sCity optional city name
+     * @param $sZip optional ZIP/postcode
+     * @param $sStreet optional street name
+     * @param $sStreetNumber optional street number
+     * @return true if location was added, or false otherwise
+     */
+    public function locationsAdd($iId, $sLatitude, $sLongitude, $sCountryCode, $sState, $sCity, $sZip = '', $sStreet = '', $sStreetNumber = '') 
+    {
+        // TODO: if lat & lng aren't defined then perform geocoding automatically, or maybe leverage this on client side ?
+
+        return $this->_oQuery->locationsAdd($iId, $sLatitude, $sLongitude, $sCountryCode, $sState, $sCity, $sZip, $sStreet, $sStreetNumber);
+    }
+
+	/**
+     * Retrieve location for the content from POST data
+     * @param $sPrefix field prefix for POST data, or empty -  if no prefix
+     * @param $oForm form to use to get POST data, or null - then new form instance will be created
+     */
+    public static function locationsRetrieveFromForm($sPrefix = '', $oForm = null)
+    {
+        if($sPrefix)
+            $sPrefix .= '_';
+
+        if(!$oForm)
+            $oForm = new BxDolForm(array(), false);
+
+        $aResults = [];
+        foreach(self::$_aLocationKeys as $sKey) {
+            $sValue = $oForm->getCleanValue($sPrefix . $sKey);
+            if(!$sValue || in_array($sValue, ['null', 'false']))
+                $sValue = '';
+
+            $aResults[] = $sValue;
+        }
+
+        return $aResults;
+    }
+
+    /**
+     * Parse address components into associative array with the following indexes:
+     * lat, lng, country, state, city, zip, street, street_number
+     */ 
+    public static function locationsParseComponents($aAdress, $sPrefix = '')
+    {
+        if($sPrefix)
+            $sPrefix .= '_';
+
+        $aRet = array();
+        $iAdress = count($aAdress);
+        for($i = 0; $i < $iAdress; $i++)
+            if(isset(self::$_aLocationKeys[$i]))
+                $aRet[$sPrefix . self::$_aLocationKeys[$i]] = $aAdress[$i];
+
+        return $aRet;
+    }
+
+    /**
+     * Parse google's formatted address components into array with the following indexes:
+     * lat, lng, country, state, city, zip, street, street_number
+     */ 
+    public static function locationsParseAddressComponents($aAdress, $sPrefix = '')
+    {
+        if (!isset($aAdress['address_components']))
+            return false;
+        if (!isset($aAdress['geometry']['location']))
+            return false;
+
+        $aRet = array(
+            $sPrefix . 'lat' => $aAdress['geometry']['location']['lat'],
+            $sPrefix . 'lng' => $aAdress['geometry']['location']['lng'],
+        );
+
+        $aMap = array(
+            $sPrefix . 'city' => 'locality',
+            $sPrefix . 'state' => 'administrative_area_level_1',
+            $sPrefix . 'country' => 'country',
+            $sPrefix . 'zip' => 'postal_code',
+            $sPrefix . 'street' => 'route',
+            $sPrefix . 'street_number' => 'street_number'        
+        );
+
+        $aAdressComponents = $aAdress['address_components'];
+        
+        foreach ($aAdressComponents as $r) {
+            if (!isset($r['types']))
+                continue;
+            foreach ($aMap as $sKey => $sName) {
+                
+                if ('locality' == $sName && !in_array($sName, $r['types']))
+                    $sName = 'postal_town';
+                
+                if ('administrative_area_level_1' == $sName && !in_array($sName, $r['types']))
+                    $sName = 'administrative_area_level_2';
+
+                if (in_array($sName, $r['types'])) {
+                    $sIndex = 'route' == $sName || 'locality' == $sName ? 'long_name' : 'short_name';
+                    $aRet[$sKey] = $r[$sIndex];
+                }
+
+                if (in_array('locality', $r['types']))
+                    $aRet[$sPrefix . 'city'] = $r['short_name'];
+                else if (in_array('country', $r['types']))
+                    $aRet[$sPrefix . 'country'] = $r['short_name'];
+            }
+        }
+        
+        return $aRet;
+    }
+    
+    /**
+     * Add location for the content from POST data
+     * @param $iId content id
+     * @param $sPrefix field prefix for POST data, or empty -  if no prefix
+     * @param $oForm form to use to get POST data, or null - then new form instance will be created
+     */
+    public function locationsAddFromForm($iId, $sPrefix = '', $oForm = null)
+    {
+        if (!$this->locationsIsEnabled())
+            return;
+
+        call_user_func_array(array($this, 'locationsAdd'), array_merge(array($iId), self::locationsRetrieveFromForm($sPrefix, $oForm)));
+    }
+
+    /**
+     * Get locations string with links
+     * @param $iId content id
+     * @return string with links to country and city
+     */
+    public function locationsString($iId, $bHTML = true, $aParams = array())
+    {
+        bx_import('BxDolForm');        
+        $aLocation = $this->locationGet($iId);
+        if(bx_is_api())
+            return bx_api_get_location_string($aLocation);
+
+        return $this->locationsStringFromArray($aLocation, $bHTML, $aParams);
+    }
+
+    /**
+     * Get locations string with links
+     * @param $aLocation location array
+     * @return string with links to country and city
+     */
+    public function locationsStringFromArray($aLocation, $bHTML = true, $aParams = array())
+    {
+        $s = '';
+        $aCountries = BxDolFormQuery::getDataItems('Country');
+
+        if(!$aLocation || !$aLocation['country'] || !isset($aCountries[$aLocation['country']])) {
+            if (!empty($aLocation['lat']) || !empty($aLocation['lng']))
+                $s = _t('_sys_location_country', $aLocation['lat'] . ', ' . $aLocation['lng']);
+            else
+                return '';
+        }
+        
+        if (!$s) {
+            $sCountryUrl = '<a href="' . BX_DOL_URL_ROOT . 'searchKeyword.php?type=location_country&keyword=' . rawurlencode($aLocation['country']) . '">' . $aCountries[$aLocation['country']] . '</a>';
+            
+            if(empty($aLocation['state']) || empty($aLocation['city']) || (isset($aParams['country_only']) && $aParams['country_only']))
+                $s = _t('_sys_location_country', $sCountryUrl);
+        }
+
+        if (!$s) {
+            $sCityUrl = '<a href="' . BX_DOL_URL_ROOT . 'searchKeyword.php?type=location_country_city&keyword=' . $aLocation['country'] . '&state=' . rawurlencode($aLocation['state']) . '&city=' . rawurlencode($aLocation['city']) . '">' . bx_process_output($aLocation['city']) . '</a>';
+            $sStateUrl = '<a href="' . BX_DOL_URL_ROOT . 'searchKeyword.php?type=location_country_state&keyword=' . $aLocation['country'] . '&state=' . rawurlencode($aLocation['state']) . '">' . bx_process_output($aLocation['state']) . '</a>';
+
+            if (empty($aLocation['street']) || empty($aLocation['street_number']) || (isset($aParams['country_city_only']) && $aParams['country_city_only']))
+        	    $s = _t('_sys_location_country_city', $sCountryUrl, $sStateUrl, $sCityUrl);
+            elseif (!isset($aParams['country_city_only']) || !$aParams['country_city_only'])
+                $s = _t('_sys_location_country_city_street', $sCountryUrl, $sStateUrl, $sCityUrl, bx_process_output($aLocation['street']), bx_process_output($aLocation['street_number']));
+        }
+
+        return $bHTML ? $s : trim(strip_tags($s));
+    }
+
+    public function locationsStringFromArrayAPI($mixedValue)
+    {
+        if(!is_array($mixedValue)) {
+            $aValue = @unserialize($mixedValue);
+            if(!$aValue)
+                $aValue = json_decode($mixedValue, true);
+        }
+        else
+            $aValue = $mixedValue;
+
+        if(empty($aValue) || !is_array($aValue) || empty($aValue['country'])) 
+            return '';
+
+        $aCountries = BxDolFormQuery::getDataItems('Country');
+
+        $sResult = '';
+        $sResult .= $aValue['street_number'] ? $aValue['street_number'] . ', ' : '';
+        $sResult .= $aValue['street'] ? $aValue['street'] . ', ' : '';
+        $sResult .= $aValue['city'] ? $aValue['city'] . ', ' : '';
+        $sResult .= $aValue['state'] ? $aValue['state'] . ', ' : '';
+        $sResult .= $aCountries[$aValue['country']];
+
+        return $sResult;
+    }
+
+    /**
+     * Add keywords meta info to the head section
+     * @param $iId content id
+     */
+    protected function locationsAddMeta($iId) 
+    {
+        $aLocation = $this->locationGet($iId);
+        if (!empty($aLocation['lat']) && !empty($aLocation['lng']) && !empty($aLocation['country']))
+            BxDolTemplate::getInstance()->addPageMetaLocation($aLocation['lat'], $aLocation['lng'], $aLocation['country']);
+    }
+
+    /**
+     * Set condition for search results object for meta locations
+     * @param $oSearchResult search results object
+     * @param $sCountry country and other location info
+     */
+    public function locationsSetSearchCondition($oSearchResult, $sCountry = false, $sState = false, $sCity = false, $sZip = false)
+    {
+        if (empty($this->_aObject['table_locations'])) {
+            $oSearchResult->aCurrent['restriction']['meta_location'] = array(
+                'operator' => 'nothing',
+                'field' => 'nofield',
+                'value' => 'novalue',
+            );
+            return;
+        }
+
+        $aIndexes = ['country' => 'sCountry', 'state' => 'sState', 'city' => 'sCity', 'zip' => 'sZip'];
+        $aOperators = ['city' => 'like'];
+
+        foreach ($aIndexes as $sIndex => $sVar) {
+            if (!$$sVar)
+                continue;
+
+            $oSearchResult->aCurrent['restriction']['meta_location_' . $sIndex] = [
+                'value' => $$sVar,
+                'field' => $sIndex,
+                'operator' => isset($aOperators[$sIndex]) ? $aOperators[$sIndex] : '=',
+                'table' => $this->_aObject['table_locations'],
+            ];
+        }
+
+        $oSearchResult->aCurrent['join']['meta_location'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_locations'],
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+
+    /**
+     * Get part of SQL query for meta locations
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $sCountry country and other location info
+     */
+    public function locationsGetAsSQLPart($sContentTable, $sContentField, $sCountry = false, $sState = false, $sCity = false, $sZip = false, $aBounds = array())
+    {
+        if (empty($this->_aObject['table_locations']))
+            return array();
+
+        return call_user_func_array(array($this->_oQuery, 'locationsGetSQLParts'), func_get_args());
+    }
+
+    /**
+     * Get location
+     * @param $iId content id
+     * @param $sPrefix field prefix for returning data array
+     * @return location array with the following keys (when no prefix specified): object_id, lat, lng, country, state, city, zip, street, street_number
+     */
+    public function locationGet($iId, $sPrefix = '')
+    {
+        if (!$this->locationsIsEnabled())
+            return;
+
+        $a = $this->_oQuery->locationGet($iId);
+        if (!$sPrefix)
+            return $a;
+
+        $aRet = array();
+        foreach ($a as $sKey => $sVal)
+            $aRet[$sPrefix . '_' . $sKey] = $sVal;
+        return $aRet;
+    }
+
+    /**
+     * Checks if mentions enabled for current metatags object
+     */
+    public function mentionsIsEnabled() 
+    {
+        return empty($this->_aObject['table_mentions']) ? false : true;
+    }
+
+    /**
+     * Add \@mentions from the string (most probably \@mentions will be some sort of links already, so parsing may have to look for smth like <a data-mention="bx_persons|123">mention name</a> instead of \@mention, since there is no usernames for profiles modules and name could contain spaces and othr characters)
+     * @param $iId content id
+     * @param $s string with \@mentions
+     * @return number of found mentions 
+     */
+    public function mentionsAdd($iId, $s) 
+    {
+        $sPreg = '/data\-profile\-id=(?:"([0-9A-Za-z]+)"|\'([0-9A-Za-z]+)\'|([0-9A-Za-z]+))/u';
+
+        return $this->_metaAdd($iId, $s, $sPreg, 'mentionsDelete', 'mentionsAdd', 'mentionsGet', (int)getParam('sys_metatags_mentions_max'), 'mention');
+    }
+
+    /**
+     * Add \@mentions from the content of form fields
+     * @param $iId content id
+     * @param $aContentInfo content info array
+     * @param $CNF module config array
+     * @param $sFormDisplay form display object
+     * @return number of found keywords
+     */
+    public function mentionsAddAuto($iId, $aContentInfo, $CNF, $sFormDisplay) 
+    {
+        $aFields = $this->metaFields($aContentInfo, $CNF, $sFormDisplay, true); 
+        $sTextWithKeywords = '';
+        foreach ($aFields as $sField)
+            $sTextWithKeywords .= "\n" . $aContentInfo[$sField];
+
+        return $this->mentionsAdd($iId, $sTextWithKeywords);
+    }
+    
+    /**
+     * Add links to the \@mentions in the string (actual tranformation may have to be performed with ready links like <a data-mention="bx_persons|123">mention name</a>)
+     * @param $iId content id
+     * @param $s string with \@mentions
+     * @return modified string where all \@mentions are transformed to links
+     */
+    public function mentionsParse($iId, $s) 
+    {
+        if (!bx_is_api())
+            return $s;
+        
+        if(($sRootUrl = getParam('sys_api_url_root_email')) !== '') {
+            if(substr(BX_DOL_URL_ROOT, -1) == '/' && substr($sRootUrl, -1) != '/')
+                $sRootUrl .= '/';
+
+            $s = preg_replace(
+                '/(\shref\s*=\s*["\'])' . preg_quote(BX_DOL_URL_ROOT, '/') . '/i',
+                '${1}' . $sRootUrl,
+                $s
+            );
+        }
+        
+        return $s;
+    }
+
+    /**
+     * No mentions meta info in the head section
+     */
+    protected function mentionsAddMeta($iId) 
+    {
+        return 0;
+    }
+
+    /**
+     * Get list of profile IDs associated with the content
+     * @return array of profile IDs
+     */
+    public function mentionsGet($iId)
+    {
+        return $this->_oQuery->mentionsGet($iId);
+    }
+    
+    /**
+     * Set condition for search results object for mentions
+     * @param $oSearchResult search results object
+     * @param $sMention smbd
+     */
+    public function mentionsSetSearchCondition($oSearchResult, $iProfileId, $iCmtsSystemId = 0)
+    {
+        if (!$this->mentionsIsEnabled())
+            return;
+
+        if ('sys_cmts' == $oSearchResult->aCurrent['object_metatags']) {
+            $this->mentionsSetSearchConditionCmts($oSearchResult, $iProfileId, $iCmtsSystemId);
+            return;
+        }
+
+        $oSearchResult->aCurrent['restriction']['meta_mentions'] = array(
+            'value' => $iProfileId,
+            'field' => 'profile_id',
+            'operator' => '=',
+            'table' => $this->_aObject['table_mentions'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_mentions'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_mentions'],
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+
+    public function mentionsSetSearchConditionCmts($oSearchResult, $iProfileId, $iCmtsSystemId = 0)
+    {
+        if (!$this->keywordsIsEnabled())
+            return;
+
+        if ($iCmtsSystemId) {
+            $oSearchResult->aCurrent['restriction']['meta_mentions_cmts_system'] = array(
+                'value' => $iCmtsSystemId,
+                'field' => 'system_id',
+                'operator' => '=',
+                'table' => 'sys_cmts_ids',
+            );
+        }
+        
+        $oSearchResult->aCurrent['restriction']['meta_mentions'] = array(
+            'value' => $iProfileId,
+            'field' => 'profile_id',
+            'operator' => '=',
+            'table' => $this->_aObject['table_mentions'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_mentions_cmts'] = array(
+            'type' => 'INNER',
+            'table' => 'sys_cmts_ids',
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'cmt_id',
+            'joinFields' => array(),
+        );
+        
+        $oSearchResult->aCurrent['join']['meta_mentions'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_mentions'],
+            'mainField' => 'id',
+            'mainTable' => 'sys_cmts_ids',
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+    
+    /**
+     * Delete all data associated with the content
+     * @param $iId content id
+     */
+    public function onDeleteContent($iId) 
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'Delete';
+            $i += $this->_oQuery->$sFunc($iId);
+        }
+        return $i;
+    }
+
+    protected function _metaAdd($iId, $s, $sPreg, $sFuncDelete, $sFuncAdd, $sFuncGet, $iMaxItems, $sAlertName) 
+    {
+        $a = array();
+        if (!preg_match_all($sPreg, $s, $a)) {
+            $this->_oQuery->$sFuncDelete($iId);
+            return 0;
+        }
+
+        $aMetas = array_unique($a[1]);
+        $aMetas = array_slice($aMetas, 0, $iMaxItems);
+
+        // check if keywords/mentions were changed
+        $aMetasOld = $this->$sFuncGet($iId);
+        if (is_array($aMetas) && is_array($aMetasOld) 
+            && count($aMetas) == count($aMetasOld) 
+            && empty(array_diff($aMetas, $aMetasOld))
+            && empty(array_diff($aMetasOld, $aMetas))
+        ) {
+            return 0;
+        }
+
+        /**
+         * @hooks
+         * @hookdef hook-meta_keyword-before_add_multiple 'meta_keyword', 'before_add_multiple' - hook to override meta keywords before it will be processed
+         * - $unit_name - equals `meta_keyword` or `meta_mention` 
+         * - $action - equals `before_add_multiple`
+         * - $object_id - content id 
+         * - $sender_id - currently logged in profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `metas` - [array] array of keyword/mentions, can be overridden in hook processing
+         *      - `text` - [string] original string with keywords
+         *      - `content_id` - [int] content id
+         *      - `object` - [string] metatags object name
+         * @hook @ref hook-meta_keyword-before_add_multiple
+         */
+        /**
+         * @hooks
+         * @hookdef hook-meta_keyword-before_add_multiple 'meta_keyword', 'before_add_multiple' - hook to override meta keyword/mention before it will be processed
+         * It's equivalent to @ref hook-meta_mention-before_added
+         * except mention value is used as $iObjectId
+         * @hook @ref hook-meta_keyword-before_add_multiple
+         */
+        bx_alert('meta_' . $sAlertName, 'before_add_multiple', 'mention' == $sAlertName ? 0 : $iId, bx_get_logged_profile_id(), [
+            'metas' => &$aMetas,
+            'metas_ref' => &$aMetas,
+            'text' => $s,
+            'content_id' => $iId, 
+            'object' => $this->_sObject
+        ]);
+
+        if ($iRet = $this->_oQuery->$sFuncAdd($iId, $aMetas)) {
+            $iAuthorId = bx_get_logged_profile_id();
+
+            foreach ($aMetas as $sMeta) {
+                $iObjectId = 'mention' == $sAlertName ? $sMeta : $iId;
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_keyword-before_added 'meta_keyword', 'before_added' - hook to override meta keyword before it will be processed
+                 * - $unit_name - equals `meta_keyword`
+                 * - $action - equals `before_added`
+                 * - $object_id - object id
+                 * - $sender_id - currently logged in profile id
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `meta` - [string] by ref, keyword, can be overridden in hook processing
+                 *      - `content_id` - [int] content id
+                 *      - `object` - [string] metatags object name
+                 * @hook @ref hook-meta_keyword-before_added
+                 */
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_keyword-before_added 'meta_mention', 'before_added' - hook to override meta keyword before it will be processed
+                 * It's equivalent to @ref hook-meta_mention-before_added
+                 * except mention value is used in object id param
+                 * @hook @ref hook-meta_keyword-before_added
+                 */
+                bx_alert('meta_' . $sAlertName, 'before_added', $iObjectId, $iAuthorId, [
+                    'meta' => &$sMeta, 
+                    'meta_ref' => &$sMeta, 
+                    'content_id' => $iId, 
+                    'object' => $this->_sObject
+                ]);
+
+                $sSource = $this->_sObject . '_' . $iId;
+                $sSourceMac = $this->_sObject . '_' . $iAuthorId . '_' . $iId;
+
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_metatags-keyword_added '{object_name}', 'keyword_added' - hook after meta keyword/mention was processed (added)
+                 * - $unit_name - metatags object name
+                 * - $action - equals `keyword_added`
+                 * - $object_id - object id
+                 * - $sender_id - currently logged in profile id
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `meta` - [string] keyword
+                 *      - `content_id` - [int] content id
+                 *      - `source` - [string] unique source id
+                 * @hook @ref hook-bx_dol_metatags-keyword_added
+                 */
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_metatags-keyword_added '{object_name}', 'keyword_added' - hook after meta keyword/mention was processed
+                 * It's equivalent to @ref hook-bx_dol_metatags-keyword_added
+                 * @hook @ref hook-bx_dol_metatags-keyword_added
+                 */
+                bx_alert($this->_sObject, $sAlertName . '_added', $iObjectId, $iAuthorId, [
+                    'meta' => $sMeta, 
+                    'content_id' => $iId, 
+                    'source' => $sSource,
+                    'source_mac' => $sSourceMac
+                ]);
+
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_keyword-added 'meta_keyword', 'added' - hook after meta keyword was processed (added)
+                 * - $unit_name - equals `meta_keyword`
+                 * - $action - equals `added`
+                 * - $object_id - object id
+                 * - $sender_id - currently logged in profile id
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `meta` - [string] keyword
+                 *      - `content_id` - [int] content id
+                 *      - `source` - [string] unique source id
+                 *      - `object` - [string] metatags object name
+                 * @hook @ref hook-meta_keyword-added
+                 */
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_keyword-added 'meta_keyword', 'added' - hook after meta mention/keyword was processed
+                 * It's equivalent to @ref hook-meta_keyword-added
+                 * @hook @ref hook-meta_keyword-added
+                 */
+                bx_alert('meta_' . $sAlertName, 'added', $iObjectId, $iAuthorId, [
+                    'meta' => $sMeta, 
+                    'content_id' => $iId, 
+                    'source' => $sSource,
+                    'source_mac' => $sSourceMac, 
+                    'object' => $this->_sObject
+                ]);
+            }
+        }
+
+        return $iRet;
+    }
+}
+
+/** @} */

+ 467 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolObject.php

@@ -0,0 +1,467 @@
+<?php defined('BX_DOL') or defined('BX_DOL_INSTALL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+
+define('BX_DOL_OBJECT_ERR_NOT_AVAILABLE', 1);
+define('BX_DOL_OBJECT_ERR_ACCESS_DENIED', 2);
+define('BX_DOL_OBJECT_ERR_DUPLICATE', 3);
+define('BX_DOL_OBJECT_ERR_WRONG_DATE', 4);
+define('BX_DOL_OBJECT_ERR_CANNOT_PERFORM', 5);
+
+/**
+ * Base class for all "Object" classes.
+ * Child classes usually represents high level programming constructions to generate ready 'objects' functionality, like Comments, Votings, Forms.
+ */
+class BxDolObject extends BxDolFactory implements iBxDolReplaceable
+{
+    protected $_oTemplate = null;
+    protected $_oQuery = null;
+
+    protected $_bIsApi = false;
+
+    protected $_iId = 0; ///< item id the action to be performed with
+    protected $_sSystem = ''; ///< current system name
+    protected $_aSystem = array(); ///< current system array
+
+    protected $_aMarkers = array ();
+
+    protected $_sTmplContentElementBlock = '';
+    protected $_sTmplContentElementInline = '';
+    protected $_sTmplContentDoAction;
+    protected $_sTmplContentDoActionLabel = '';
+    protected $_sTmplContentCounter = '';
+    protected $_sTmplContentCounterLabel = '';
+
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct();
+
+        $this->_bIsApi = bx_is_api();
+
+        $aSystems = $this->getSystems();
+        if(!isset($aSystems[$sSystem]))
+            return;
+
+        $this->_sSystem = $sSystem;
+        $this->_aSystem = $aSystems[$sSystem];
+
+        if ($oTemplate)
+            $this->_oTemplate = $oTemplate;
+        else
+            $this->_oTemplate = BxDolTemplate::getInstance();
+
+        if(!$this->isEnabled())
+            return;
+
+        if($iInit)
+            $this->init($iId);
+    }
+
+    public static function &getSystems()
+    {
+        $aResult = [];
+
+        return $aResult;
+    }
+
+    static public function pruning()
+    {
+        $iResults = 0;
+
+        $sClass = get_called_class();
+        $sMethod = 'getSystems';
+        if(!method_exists($sClass, $sMethod))
+            return $iResults;
+
+        $aSystems = $sClass::$sMethod();
+        foreach($aSystems as $aSystem) {
+            if(empty($aSystem['pruning']))
+                continue;
+
+            $oObject = $sClass::getObjectInstance($aSystem['name'], 0, false);
+            if(!$oObject || !$oObject->isEnabled())
+                continue;
+
+            $iResults += $oObject->_oQuery->pruningByDate($aSystem['pruning']);
+        }
+
+        return $iResults;
+    }
+
+    public function init($iId)
+    {
+        if(!$this->isEnabled())
+            return false;
+
+        if(empty($this->_iId) && $iId)
+            $this->setId($iId);
+
+		$this->addMarkers(array(
+            'object_id' => $this->getId(),
+            'user_id' => $this->_getAuthorId()
+        ));
+
+        return true;
+    }
+
+    public function getSystemId()
+    {
+        return $this->_aSystem['id'];
+    }
+
+    public function getSystemName()
+    {
+        return $this->_sSystem;
+    }
+
+    public function getSystemInfo()
+    {
+        return $this->_aSystem;
+    }
+
+    public function getId()
+    {
+        return $this->_iId;
+    }
+
+    public function setId($iId)
+    {
+        if($iId == $this->getId())
+            return;
+
+        $this->_iId = $iId;
+    }
+
+    public function isEnabled ()
+    {
+        return $this->_aSystem && (int)$this->_aSystem['is_on'] == 1;
+    }
+
+    public function isPerformed($iObjectId, $iAuthorId, $iAuthorIp = 0)
+    {
+        return $this->_oQuery->isPerformed($iObjectId, $iAuthorId);
+    }
+
+    public function getVote($iObjectId = 0, $bForceGet = false)
+    {
+        return $this->_getVote($iObjectId, $bForceGet);
+    }
+
+    public function getTrack($iObjectId, $iAuthorId)
+    {
+        return $this->_getTrack($iObjectId, $iAuthorId);
+    }
+
+    /**
+     * Interface functions for outer usage
+     */
+    public function getConditions($sMainTable, $sMainField)
+    {
+        if(!$this->isEnabled())
+            return array();
+
+        $sTable = isset($this->_aSystem['table_main']) ? $this->_aSystem['table_main'] : '';
+        if(empty($sTable) || empty($sMainTable) || empty($sMainField))
+            return array();
+
+        return array(
+            'join' => array (
+                'objects_' . $this->_sSystem => array(
+                    'type' => 'INNER',
+                    'table' => $sTable,
+                    'mainTable' => $sMainTable,
+                    'mainField' => $sMainField,
+                    'onField' => 'object_id',
+                    'joinFields' => array('count'),
+                ),
+            ),
+        );
+    }
+
+    public function getConditionsTrack($sMainTable, $sMainField, $iAuthorId = 0)
+    {
+        if(!$this->isEnabled())
+            return array();
+
+        $sTableTrack = isset($this->_aSystem['table_track']) ? $this->_aSystem['table_track'] : '';
+        if(empty($sTableTrack) || empty($sMainTable) || empty($sMainField))
+            return array();
+
+        return array(
+            'restriction' => array (
+                'objects_' . $this->_sSystem . '_author' => array(
+                    'value' => $iAuthorId,
+                    'field' => 'author_id',
+                    'operator' => '=',
+                    'table' => $sTableTrack,
+                ),
+            ),
+            'join' => array (
+                'objects_' . $this->_sSystem => array(
+                    'type' => 'INNER',
+                    'table' => $sTableTrack,
+                    'mainTable' => $sMainTable,
+                    'mainField' => $sMainField,
+                    'onField' => 'object_id',
+                    'joinFields' => array('author_id'),
+                ),
+            ),
+
+        );
+    }
+    
+    public function getSqlParts($sMainTable, $sMainField)
+    {
+        if(!$this->isEnabled())
+            return array();
+
+        return $this->_oQuery->getSqlParts($sMainTable, $sMainField);
+    }
+
+    public function getSqlPartsTrack($sMainTable, $sMainField, $iAuthorId = 0)
+    {
+        if(!$this->isEnabled())
+            return array();
+
+        return $this->_oQuery->getSqlPartsTrack($sMainTable, $sMainField, $iAuthorId);
+    }
+
+    public function getSqlPartsTrackAuthor($sMainTable, $sMainField, $iObjectId = 0)
+    {
+        if(!$this->isEnabled())
+            return array();
+
+        return $this->_oQuery->getSqlPartsTrackAuthor($sMainTable, $sMainField, $iObjectId);
+    }
+
+    public function addMarkers($aMarkers)
+    {
+        if(empty($aMarkers) || !is_array($aMarkers))
+			return false;
+
+        $this->_aMarkers = array_merge($this->_aMarkers, $aMarkers);
+        return true;
+    }
+
+    public function getElementAPI($aParams = [])
+    {
+        if(!$this->_bIsApi)
+            return;
+
+        //TODO: Implement for Objects like Views, Votes, etc.
+        $sClass = get_class($this);
+        return [
+            'display_type' => 'element',
+            'name' => md5($sClass),
+            'title' => get_class($this)
+        ];
+    }
+
+    public function getCounterAPI($aParams = [])
+    {
+        if(!$this->_bIsApi)
+            return;
+
+        //TODO: Implement for Objects like Views, Votes, etc.
+        $sClass = get_class($this);
+        return [
+            'display_type' => 'element',
+            'name' => md5($sClass),
+            'title' => get_class($this)
+        ];
+    }
+
+    /**
+     * Database functions
+     */
+    public function getQueryObject ()
+    {
+        return $this->_oQuery;
+    }
+
+	/**
+     * Permissions functions
+     */
+	public function checkAction ($sAction, $isPerformAction = false)
+    {
+        $iId = $this->_getAuthorId();
+        $a = checkActionModule($iId, $sAction, 'system', $isPerformAction);
+        return $a[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function checkActionErrorMsg ($sAction)
+    {
+        $iId = $this->_getAuthorId();
+        $a = checkActionModule($iId, $sAction, 'system');
+        return $a[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED ? $a[CHECK_ACTION_MESSAGE] : '';
+    }
+
+    /**
+     * Actions' response functions
+     */
+    public function onObjectDelete($iObjectId = 0)
+    {
+        $this->_oQuery->deleteObjectEntries($iObjectId ? $iObjectId : $this->getId());
+    }
+
+    /**
+     * Internal functions
+     */
+    protected function _getAuthorId ()
+    {
+        return isMember() ? bx_get_logged_profile_id() : 0;
+    }
+
+    protected function _getAuthorPassword ()
+    {
+        return getLoggedPassword();
+    }
+
+    protected function _getAuthorIp ()
+    {
+        return getVisitorIP();
+    }
+
+    protected function _getAuthorInfo($iAuthorId = 0)
+    {
+        $oProfile = $this->_getAuthorObject($iAuthorId);
+
+        return array(
+            $oProfile->getDisplayName(),
+            $oProfile->getUrl(),
+            $oProfile->getThumb(),
+            $oProfile->getUnit(),
+            $oProfile->getUnit(0, array('template' => 'unit_wo_info'))
+        );
+    }
+
+    protected function _getAuthorObject($iAuthorId = 0)
+    {
+    	if($iAuthorId == 0)
+    		return BxDolProfileUndefined::getInstance();
+
+        $oProfile = BxDolProfile::getInstance($iAuthorId);
+        if(!$oProfile)
+			$oProfile = BxDolProfileUndefined::getInstance();
+
+        return $oProfile;
+    }
+
+    /**
+     * Update Trigger table using data which is automatically gotten from object's internal table.
+     */
+    protected function _trigger()
+    {
+        if(!$this->_aSystem['trigger_table'])
+            return false;
+
+        $iId = $this->getId();
+        if(!$iId)
+            return false;
+
+        return $this->_oQuery->updateTriggerTable($iId);
+    }
+
+    /**
+     * Update (increment/decrement) Trigger table using provided value.
+     */
+    protected function _triggerValue($iValue)
+    {
+        if(!$this->_aSystem['trigger_table'])
+            return false;
+
+        $iId = $this->getId();
+        if(!$iId)
+            return false;
+
+        return $this->_oQuery->updateTriggerTableValue($iId, $iValue);
+    }
+
+    /**
+     * Replace provided markers in a string
+     * @param $mixed string or array to replace markers in
+     * @return string where all occured markers are replaced
+     */
+    protected function _replaceMarkers ($mixed)
+    {
+        return bx_replace_markers($mixed, $this->_aMarkers);
+    }
+
+    protected function _prepareParamsData($aParams)
+    {
+        $aParams = array_merge([
+            'sSystem' => $this->getSystemName(),
+            'iObjId' => $this->getId(),
+            'iAuthorId' => $this->_getAuthorId(),
+            'sRootUrl' => BX_DOL_URL_ROOT,
+        ], $aParams);
+
+        foreach($aParams as $sKey => $mixedValue)
+            if(is_bool($mixedValue))
+                $aParams[$sKey] = (int)$mixedValue;
+
+        return $aParams;
+    }
+
+    protected function _getRequestParamsData($aKeys = array())
+    {
+        $sParams = bx_get('params');
+        if($sParams === false)
+            return [];
+
+        $aParams = [];
+        parse_str(bx_process_input($sParams), $aParams);
+
+        return $aParams;
+    }
+
+    protected function _prepareRequestParamsData($aParams, $aParamsAdd = array())
+    {
+        $aRequestParams = array_intersect_key($aParams, $this->_aElementDefaults);
+        $aRequestParams = array_merge($aRequestParams, $aParamsAdd);
+
+        foreach($aRequestParams as $sKey => $mixedValue)
+            if(is_bool($mixedValue))
+                $aRequestParams[$sKey] = (int)$mixedValue;
+
+        return $aRequestParams;
+    }
+
+    protected function _getTmplContentElementBlock()
+    {
+        return $this->_sTmplContentElementBlock;
+    }
+
+    protected function _getTmplContentElementInline()
+    {
+        return $this->_sTmplContentElementInline;
+    }
+
+    protected function _getTmplContentDoAction()
+    {
+        return $this->_sTmplContentDoAction;
+    }
+
+    protected function _getTmplContentDoActionLabel()
+    {
+        return $this->_sTmplContentDoActionLabel;
+    }
+
+    protected function _getTmplContentCounter()
+    {
+        return $this->_sTmplContentCounter;
+    }
+
+    protected function _getTmplContentCounterLabel()
+    {
+        return $this->_sTmplContentCounterLabel;
+    }
+}
+
+/** @} */

+ 274 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolPaginate.php

@@ -0,0 +1,274 @@
+<?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
+ * @{
+ */
+
+define('BX_DOL_PAGINATE_PER_PAGE_DEFAULT', 10);
+
+/**
+ * Paginage for any content.
+ * It is used to create paginate, configuring it via input parameters.
+ * Paginate don't support total number of pages, moreover it is not recommended to count all records - it slows down the site.
+ * To correctly determine last page we need to pass number of available records on the current page plus one - so we always know if next page is available.
+ *
+ *
+ * Two types of paginate presentation is supported:
+ * - getPaginate() - to get default paginate, it is better to use it on the whole page.
+ * - getSimplePaginate() - to get limited paginate, it is better to use in some boxes, where availabel space is limited or for ajax paginate.
+ *
+ *
+ * The list of available input parameters:
+ *
+ *
+ * Parameters:
+ * - start - position of the first item.
+ * - num - number of available items on the page, it should be number of items per page + 1 (+1 is needed to correctly determine last page). It is possible to set this value automatically @see setNumFromDataArray.
+ * - per_page - number of items displayed on the page.
+ * - page_url - page URL to go through pages, special markers are automatically replaced.
+ * - on_change_page - JavaScript code to be called on change page.
+ * - info - display info.
+ * - view_all_url - URL for 'view all' page. This url is not showed by default. It is convinient to use with @see getSimplePaginate.
+ * - view_all_caption - optional caption for 'view all' link.
+ *
+ *
+ * Available markers to replace in 'page_url' and 'on_change_page' parameters:
+ * - {per_page} - current number of items to display per page.
+ * - {start} - the number to display items starting from.
+ *
+ *
+ * Example of usage:
+ * @code
+ * $oPaginate = new BxDolPaginate(array(
+ *      'start' => 0,
+ *      'num' => 11,
+ *      'per_page' => 10,
+ *      'on_change_page' => 'changePage({start}, {per_page})'
+ * ));
+ * $oPaginate->getPaginate();
+ * @endcode
+ *
+ *
+ * Memberships/ACL:
+ * Doesn't depend on user's membership.
+ *
+ *
+ * Alerts:
+ * no alerts available
+ *
+ */
+abstract class BxDolPaginate extends BxDol
+{
+    protected $_aParams; ///< an array with initially provided params
+
+    protected $_iStart; ///< start display items from this number
+    protected $_iPerPage; ///< results per page
+    protected $_iNum; ///< available results, you need to query per_page + 1 results, so paginate can determine last page
+
+    protected $_iTotal; ///< total number of results. (Optional)
+    protected $_bTotal; ///< show displayed items info with total value
+
+    protected $_sPageUrl; ///< page url of next/prev page, special markers will be replaced here automatically
+    protected $_sOnChangePage; ///< on click of next/prev page, special markers will be replaced here automatically
+    protected $_bInfo; ///< show displayed items info
+
+    protected $_sViewAllUrl; ///< view "all results" url, for "simple" paginate
+    protected $_sViewAllCaption; ///< 'view all' link caption
+
+    /**
+     * Constructor
+     */
+    public function __construct($aParams)
+    {
+        parent::__construct();
+
+        if (isset($aParams['count']))
+           trigger_error ('Paginate "count" is deprecated - use "num" instead: ' . get_class($this), E_USER_ERROR);
+
+        //--- Main settings ---//
+        $this->_aParams = $aParams;
+
+        $this->_iStart = isset($aParams['start']) && (int)$aParams['start'] > 0 ? (int)$aParams['start'] : 0;
+        $this->_iPerPage = isset($aParams['per_page']) && (int)$aParams['per_page'] > 0 ? (int)$aParams['per_page'] : BX_DOL_PAGINATE_PER_PAGE_DEFAULT;
+        $this->_iNum = isset($aParams['num']) ? (int)$aParams['num'] : 0;
+
+        $this->_iTotal = isset($aParams['total']) ? (int)$aParams['total'] : 0;
+        $this->_bTotal = $this->_iTotal > 0;
+
+        $this->_bInfo = isset($aParams['info']) ? (bool)$aParams['info'] : true;
+        
+        $this->_sViewAllUrl = isset($aParams['view_all_url']) ? $aParams['view_all_url'] : false;
+        $this->_sViewAllCaption = isset($aParams['view_all_caption']) ? $aParams['view_all_caption'] : _t('_sys_paginate_view_all');       
+
+        // page url
+        $this->_sPageUrl = isset($aParams['page_url']) ? $aParams['page_url'] : BX_DOL_URL_ROOT;
+
+        // on click (js mode)
+        $this->_sOnChangePage = isset($aParams['on_change_page']) ? $aParams['on_change_page'] : '';
+    }
+
+    /**
+     * Get number of available results per page. If it is not last page,
+     * then this number is number of result per page plus one.
+     * @return integer
+     */
+    public function getNum()
+    {
+        return $this->_iNum;
+    }
+
+    /**
+     * Set number of available items on the page directly from data array.
+     * Since data array should contain additional record - we will pop last item from array automatically.
+     * @return nothing.
+     */
+    public function setNumFromDataArray(&$a, $isAutoPopLastElement = true)
+    {
+        if ($a && is_array($a))
+            $this->_iNum = count($a);
+        else
+            $this->_iNum = 0;
+
+        if ($this->_iNum > $this->_iPerPage && $isAutoPopLastElement)
+            array_pop($a);
+    }
+
+    /**
+     * Set number of available items on the page,
+     * it should be number of items per page + 1 (+1 is needed to correctly determine last page).
+     * It is possible to set this value automatically @see setNumFromDataArray.
+     * @param $i positive integer.
+     * @return true on success or false if $i param isn't correct.
+     */
+    public function setNum($i)
+    {
+        if ($i >= 0) {
+            $this->_iNum = (int)$i;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Position, the data is showing from.
+     * @return integer.
+     */
+    public function getStart()
+    {
+        return $this->_iStart;
+    }
+
+    /**
+     * Set the starting position, the data is showing from.
+     * @param $i positive integer.
+     * @return true on siccess or false on error.
+     */
+    public function setStart($i)
+    {
+        if ($i >= 0) {
+            $this->_iStart = (int)$i;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Get number of records per page.
+     * @return integer.
+     */
+    public function getPerPage()
+    {
+        return $this->_iPerPage;
+    }
+
+    /**
+     * Set number of records per page.
+     * @param $i positive integer.
+     * @return integer.
+     */
+    public function setPerPage($i)
+    {
+        if ((int)$i > 0) {
+            $this->_iPerPage = $i;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Set string to pass to 'onclick' for change page button.
+     * The following markers are replaced automatically:
+     * - {per_page}
+     * - {start}
+     *
+     * @param $i positive integer.
+     * @return integer.
+     */
+    public function setOnChangePage($s)
+    {
+        $this->_sOnChangePage = $s;
+    }
+
+    /**
+     * @return true if previous page is available, or false if not
+     */
+    public function isPrevAvail ()
+    {
+        return $this->_iStart > 0 ? true : false;
+    }
+
+    /**
+     * @return true if next page is available, or false if not
+     */
+    public function isNextAvail ()
+    {
+        return $this->_iNum > $this->_iPerPage ? true : false;
+    }
+
+    public function isLoadMoreAvail ()
+    {
+        return $this->isNextAvail();
+    }
+
+    protected function _getReplacement()
+    {
+        return [
+            'start' => $this->_iStart,
+            'per_page' => $this->_iPerPage,
+        ];
+    }
+
+    protected function _getReplacementPrev()
+    {
+        return array_merge($this->_getReplacement(), [
+            'start' => ($iStart = $this->_iStart - $this->_iPerPage) > 0 ? $iStart : 0
+        ]);
+    }
+
+    protected function _getReplacementNext()
+    {
+        return array_merge($this->_getReplacement(), [
+            'start' => $this->_iStart + $this->_iPerPage
+        ]);
+    }
+
+    protected function _getReplacementLoadMore()
+    {
+        return $this->_getReplacementNext();
+    }
+
+    protected function _getPageChangeUrl($aMarkers)
+    {
+        return bx_replace_markers($this->_sPageUrl, $aMarkers);
+    }
+
+    protected function _getPageChangeOnClick($aMarkers)
+    {
+        return !empty($this->_sOnChangePage) ? 'onclick="javascript:' . bx_replace_markers($this->_sOnChangePage, $aMarkers) . '; return false;"' : '';
+    }
+}
+
+/** @} */

+ 1010 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolPrivacy.php

@@ -0,0 +1,1010 @@
+<?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
+ * @{
+ */
+
+define('BX_DOL_PG_HIDDEN', '1');
+define('BX_DOL_PG_MEONLY', '2');
+define('BX_DOL_PG_ALL', '3');
+define('BX_DOL_PG_MEMBERS', '4');
+define('BX_DOL_PG_FRIENDS', '5');
+define('BX_DOL_PG_FRIENDS_SELECTED', '6');
+define('BX_DOL_PG_RELATIONS', '7');
+define('BX_DOL_PG_RELATIONS_SELECTED', '8');
+define('BX_DOL_PG_MEMBERSHIPS_SELECTED', '9');
+define('BX_DOL_PG_CUSTOM', '99');
+
+define('BX_DOL_PG_DEFAULT', BX_DOL_PG_ALL);
+
+/**
+ * Privacy settings for any content.
+ *
+ * Integration of the content with privacy engine allows site member
+ * to organize the access to his content.
+ *
+ * In addition to regular privacy groups (Public, Friends), spaces are supported. 
+ * When some space (usually some another profile) is specified as privacy, 
+ * then another profile visibility is used to check the privacy.
+ *
+ * Related classes:
+ *  BxDolPrivacyQuery - database queries.
+ *
+ * Example of usage:
+ * 1. Register your privacy actions in `sys_privacy_actions` database table.
+ * 2. Add one privacy field(with INT type) in the table with your items for each action.
+ *    For example, for action 'comment', the field name should be 'allow_comment_to'.
+ * 3. Add group choosers for necessary actions in the form, which is used to add new items.
+ * @code
+ *    $oPrivacy = new BxDolPrivacy();
+ *    $oPrivacy->getGroupChooser($iItemOwnerId, $sModuleUri, $sModuleAction);
+ * @endcode
+ *
+ * 4. Check privacy when any user tries to view an item.
+ * @code
+ *    $oPrivacy = new BxDolPrivacy($sTable, $sFieldId, $sFieldOwnerId);
+ *    if($oPrivacy->check($sAction, $iObjectId, $iViewerId)) {
+ *     //show necessary content
+ *    }
+ * @endcode
+ *
+ *    @see an example of integration in BoonEx modules, for example: Posts
+ *
+ *
+ * Memberships/ACL:
+ * Doesn't depend on user's membership.
+ *
+ *
+ * Alerts:
+ * no alerts available
+ *
+ */
+class BxDolPrivacy extends BxDolFactory implements iBxDolFactoryObject
+{
+    protected $_oDb;
+    protected $_sObject;
+    protected $_aObject;
+
+    protected $_aGroupsSettings;
+    protected $_aGroupsExclude;
+    
+    protected $_sFormGroupCustom;
+    protected $_sFormDisplayGcMembers;
+    protected $_sFormDisplayGcMemberships;
+
+    /**
+     * Constructor
+     * @param $aObject array of grid options
+     */
+    protected function __construct($aObject)
+    {
+        parent::__construct();
+
+        $this->_aObject = $aObject;
+        $this->_sObject = $aObject['object'];
+
+        $this->_oDb = new BxDolPrivacyQuery();
+        $this->_oDb->init($this->_aObject);
+
+        $this->_aGroupsSettings = [
+            BX_DOL_PG_FRIENDS_SELECTED => [
+                'name' => 'friends_selected',
+                'is_allowed' => '',
+                'connection' => 'sys_profiles_friends', 
+                'js_method_create_group' => 'selectMembers',
+                'db_table_items' => 'sys_privacy_groups_custom_members',
+                'db_field_item' => 'member_id',
+                'uri_get_items' => 'users_list',
+            ],
+            BX_DOL_PG_RELATIONS => [
+                'name' => 'relations',
+                'is_allowed' => 'isAllowedRelations',
+                'connection' => 'sys_profiles_relations',
+            ],
+            BX_DOL_PG_RELATIONS_SELECTED => [
+                'name' => 'relations_selected',
+                'is_allowed' => 'isAllowedRelations',
+                'connection' => 'sys_profiles_relations', 
+                'js_method_create_group' => 'selectMembers',
+                'db_table_items' => 'sys_privacy_groups_custom_members',
+                'db_field_item' => 'member_id',
+                'uri_get_items' => 'users_list',
+            ],
+            BX_DOL_PG_MEMBERSHIPS_SELECTED => [
+                'name' => 'memberships_selected',
+                'is_allowed' => 'isAllowedMemberships',
+                'connection' => '',
+                'js_method_create_group' => 'selectMemberships',
+                'db_table_items' => 'sys_privacy_groups_custom_memberships',
+                'db_field_item' => 'membership_id',
+                'uri_get_items' => 'memberships_list',
+            ]
+        ];
+
+        $this->_aGroupsExclude = array();
+
+        $this->_sFormGroupCustom = 'sys_privacy_group_custom';
+        $this->_sFormDisplayGcMembers = 'sys_privacy_group_custom_members';
+        $this->_sFormDisplayGcMemberships = 'sys_privacy_group_custom_memberships';
+    }
+
+    /**
+     * Get privacy object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    public static function getObjectInstance($sObject)
+    {
+        if(isset($GLOBALS['bxDolClasses']['BxDolPrivacy!' . $sObject]))
+            return $GLOBALS['bxDolClasses']['BxDolPrivacy!' . $sObject];
+
+        $aObject = BxDolPrivacyQuery::getPrivacyObject($sObject);
+        if(!$aObject || !is_array($aObject))
+            return false;
+
+        $sClass = 'BxTemplPrivacy';
+        if(!empty($aObject['override_class_name'])) {
+            $sClass = $aObject['override_class_name'];
+            if(!empty($aObject['override_class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aObject['override_class_file']);
+        }
+
+        $o = new $sClass($aObject);
+        return ($GLOBALS['bxDolClasses']['BxDolPrivacy!' . $sObject] = $o);
+    }
+
+    /**
+     * Get Select element with available groups.
+     *
+     * @param  string  $sObject  privacy object name.
+     * @param  integer $iOwnerId object's owner ID.
+     * @param  array   $aParams  an array of custom selector's params (dynamic_groups - an array of arrays('key' => group_id, 'value' => group_title), title - the title to be used for generated field).
+     * @return an      array with Select element description.
+     */
+    public static function getGroupChooser($sObject, $iOwnerId = 0, $aParams = array())
+    {
+        $oPrivacy = BxDolPrivacy::getObjectInstance($sObject);
+        if(empty($oPrivacy))
+            return array();
+
+        $sModule = $oPrivacy->_aObject['module'];
+        $sAction = $oPrivacy->_aObject['action'];
+
+        if($iOwnerId == 0)
+            $iOwnerId = bx_get_logged_profile_id();
+
+        $sValue = $oPrivacy->_oDb->getDefaultGroupByUser($sModule, $sAction, $iOwnerId);
+        if(empty($sValue))
+            $sValue = $oPrivacy->_oDb->getDefaultGroup($sModule, $sAction);
+
+        $aValues = $oPrivacy->getGroups($iOwnerId, $aParams);
+
+        $aValues = $oPrivacy->addDynamicGroups($aValues, $iOwnerId, $aParams);
+
+        if(getParam('sys_enable_post_to_context_for_privacy') == 'on')
+            $aValues = $oPrivacy->addSpaces($aValues, $iOwnerId, $aParams);
+
+        $sName = $oPrivacy->convertActionToField($sAction);
+
+        $sTitle = isset($aParams['title']) && !empty($aParams['title']) ? $aParams['title'] : '';
+        if(empty($sTitle)) {
+            $sTitle = $oPrivacy->_oDb->getTitle($sModule, $sAction);
+            $sTitle = _t(!empty($sTitle) ? $sTitle : '_' . $sName);
+        }
+
+        $bDynamicMode = (isset($aParams['dynamic_mode']) && (bool)$aParams['dynamic_mode'] === true) || bx_is_dynamic_request();
+        return array(
+            'type' => 'select',
+            'name' => $sName,
+            'caption' => $sTitle,
+            'value' => $sValue,
+            'values' => $aValues,
+            'attrs' => array(
+                'class' => 'sys-privacy-group',
+                'onchange' => 'javascript: ' . $oPrivacy->getJsObjectName() . '.selectGroup(this);'
+            ),
+            'checker' => array(
+                'func' => 'avail',
+                'error' => _t('_sys_ps_ferr_incorrect_select')
+            ),
+            'db' => array(
+                'pass' => 'Int'
+            ),
+            'content' => $oPrivacy->addCssJs($bDynamicMode)
+        );
+    }
+
+    public static function initGroupChooser($sObject, $iOwnerId = 0, $aParams = array())
+    {
+        $sResult = '';
+
+        $oPrivacy = BxDolPrivacy::getObjectInstance($sObject);
+        if(empty($oPrivacy))
+            return $sResult;
+
+        $iOwnerId = !empty($iOwnerId) ? (int)$iOwnerId : bx_get_logged_profile_id();
+        $iContentId = !empty($aParams['content_id']) ? (int)$aParams['content_id'] : 0;
+        $iGroupId = !empty($aParams['group_id']) ? (int)$aParams['group_id'] : 0;
+        $bDynamicMode = (isset($aParams['dynamic_mode']) && (bool)$aParams['dynamic_mode'] === true) || bx_is_dynamic_request();
+
+        $sJsCodeAdd = '';
+        if($oPrivacy->isGroupsCustom())
+            $sJsCodeAdd = $oPrivacy->getLoadGroupCustom($iOwnerId, $iContentId, $iGroupId, isset($aParams['html_ids']) ? $aParams['html_ids'] : array());
+
+        return $oPrivacy->getJsScript($sJsCodeAdd, $bDynamicMode);
+    }
+    
+    public static function initGroupChooserAPI($sObject, $iOwnerId = 0, $aParams = [])
+    {
+        $sResult = '';
+
+        $oPrivacy = BxDolPrivacy::getObjectInstance($sObject);
+        if(empty($oPrivacy))
+            return $sResult;
+
+        $iOwnerId = !empty($iOwnerId) ? (int)$iOwnerId : bx_get_logged_profile_id();
+        $iContentId = !empty($aParams['content_id']) ? (int)$aParams['content_id'] : 0;
+        $iGroupId = !empty($aParams['group_id']) ? (int)$aParams['group_id'] : 0;
+
+        if($oPrivacy->isGroupsCustom() && ($aGroupsSettings = $oPrivacy->getGroupSettings($iGroupId)) !== false) {
+            $aGroupCustom = $oPrivacy->getGroupCustom([
+                'type' => 'pcog_ext', 
+                'profile_id' => $iOwnerId, 
+                'content_id' => $iContentId, 
+                'object' => $sObject, 
+                'group_id' => $iGroupId,
+                'group_items_table' => $aGroupsSettings['db_table_items'],
+                'group_items_field' => $aGroupsSettings['db_field_item']
+            ]);
+
+            if(is_array($aGroupCustom) && !empty($aGroupCustom['items']) && is_array($aGroupCustom['items']))
+                $sResult = implode(',', $aGroupCustom['items']);
+        }
+
+        return $sResult;
+    }
+    
+    public static function getIcon($iVisibility)
+    {
+        $aIcons =array(
+           BX_DOL_PG_MEONLY => 'lock',
+           BX_DOL_PG_ALL => 'globe',
+           BX_DOL_PG_FRIENDS => 'user-friends',
+           BX_DOL_PG_FRIENDS => 'user-friends'
+       );
+       return isset($aIcons[$iVisibility]) ? $aIcons[$iVisibility] : 'eye';
+    }
+    
+            
+    public function actionLoadGroupCustom()
+    {
+        $iProfileId = (int)bx_get('profile_id');
+        $iContentId = (int)bx_get('content_id');
+        $iGroupId = (int)bx_get('group_id');
+
+        $oForm = BxDolForm::getObjectInstance($this->_sFormGroupCustom, $this->_sFormDisplayGcMembers);
+        return echoJson(array('eval' => $this->getJsObjectName() . '.onSelectGroup(oData);', 'content' => $oForm->getElementGroupCustom(array(
+            'profile_id' => $iProfileId, 
+            'content_id' => $iContentId, 
+            'object' => $this->_sObject, 
+            'group_id' => $iGroupId
+        ))));
+    }
+
+    public function actionSelectMembers()
+    {
+        $aValues = array(
+            'profile_id' => (int)bx_get('profile_id'),
+            'content_id' => (int)bx_get('content_id'),
+            'object' => $this->_sObject,
+            'group_id' => (int)bx_get('group_id')
+        );
+
+        $aParams = array(
+            'popup_only' => (bool)bx_get('popup_only')
+        );
+
+        if(!isset($this->_aGroupsSettings[$aValues['group_id']]))
+            return echoJson(array());
+
+        if($aValues['profile_id'] != bx_get_logged_profile_id())
+            return echoJson(array('msg' => _t('_sys_ps_ferr_incorrect_gc_owner')));
+
+        if(($mixedResult = $this->isSelectGroupCustomUsers($aValues)) !== true)
+            return echoJson(array('msg' => $mixedResult));
+
+        return echoJson($this->getSelectGroup($aValues, $aParams));
+    }
+
+    public function actionSelectMemberships()
+    {
+        $aValues = array(
+            'profile_id' => (int)bx_get('profile_id'),
+            'content_id' => (int)bx_get('content_id'),
+            'object' => $this->_sObject,
+            'group_id' => (int)bx_get('group_id')
+        );
+
+        $aParams = array(
+            'popup_only' => (bool)bx_get('popup_only')
+        );
+
+        if(!isset($this->_aGroupsSettings[$aValues['group_id']]))
+            return echoJson(array());
+
+        if($aValues['profile_id'] != bx_get_logged_profile_id())
+            return echoJson(array('msg' => _t('_sys_ps_ferr_incorrect_gc_owner')));
+
+        if(($mixedResult = $this->isSelectGroupCustomMemberships($aValues)) !== true)
+            return echoJson(array('msg' => $mixedResult));
+
+        return echoJson($this->getSelectMemberships($aValues, $aParams));
+    }
+
+    public function actionUsersList()
+    {
+        $iGroup = (int)bx_get('group');
+        if(!isset($this->_aGroupsSettings[$iGroup]))
+            return echoJson(array());
+
+        $oConnection = BxDolConnection::getObjectInstance($this->_aGroupsSettings[$iGroup]['connection']);
+        if(!$oConnection)
+            return echoJson(array());
+
+        $iProfileId = bx_get_logged_profile_id();
+        $aConnectedIds = $oConnection->getConnectedContent($iProfileId, true);
+        if(empty($aConnectedIds) || !is_array($aConnectedIds))
+            return echoJson(array());
+
+        $sTerm = bx_get('term');
+        $aProfiles = BxDolService::call('system', 'profiles_search', array($sTerm), 'TemplServiceProfiles');
+        if(empty($aProfiles))
+            return echoJson(array());
+        
+        $aResult = array();
+        foreach($aProfiles as $aProfile)
+            if(in_array($aProfile['value'], $aConnectedIds))
+                $aResult[] = $aProfile;
+
+        echoJson($aResult);
+    }
+
+    public function getGroupSettings($iGroup)
+    {
+        if(empty($this->_aGroupsSettings[$iGroup]) || !is_array($this->_aGroupsSettings[$iGroup]))
+            return false;
+
+        return $this->_aGroupsSettings[$iGroup];
+    }
+
+    public function isGroupsCustom()
+    {
+        $aGroups = $this->_oDb->getGroupsBy(array('type' => 'active_list'));
+
+        return in_array(BX_DOL_PG_FRIENDS_SELECTED, $aGroups) || in_array(BX_DOL_PG_RELATIONS_SELECTED, $aGroups) || in_array(BX_DOL_PG_MEMBERSHIPS_SELECTED, $aGroups);
+    }
+        
+    public function getGroupsBy($aParams)
+    {
+        return $this->_oDb->getGroupsBy($aParams);          
+    }
+
+    public function getGroupCustom($aParams)
+    {
+        return $this->_oDb->getGroupCustom($aParams);
+    }
+
+    /**
+     * Checks if this privacy object allows to add content in specified context.
+     * 
+     * @param type $sContext - context module name
+     * @return boolean
+     */
+    public function isAllowedAddInContext($sContext)
+    {
+        $bSpaces = !empty($this->_aObject['spaces']);
+
+        if($bSpaces && $this->_aObject['spaces'] == 'all') 
+            return true;
+
+        if($bSpaces && in_array($sContext, explode(',', $this->_aObject['spaces'])))
+            return true;
+
+        return false;
+    }
+
+    /**
+     * Note. Is used when privacy group is processing after the main form submit.
+     */
+    public function addGroupCustom($aGroup, $aItems = [])
+    {
+        $this->deleteGroupCustom([
+            'profile_id' => $aGroup['profile_id'],
+            'content_id' => $aGroup['content_id'],
+            'object' => $aGroup['object']
+        ]);
+
+        if(empty($aItems) || !is_array($aItems))
+            return 0;
+
+        $iGroupId = (int)$aGroup['group_id'];
+        $iGroupCustomId = $this->_oDb->insertGroupCustom($aGroup);
+        if($iGroupCustomId !== false && !empty($aItems) && is_array($aItems)) {
+            $bMembers = $iGroupId == BX_DOL_PG_FRIENDS_SELECTED || $iGroupId == BX_DOL_PG_RELATIONS_SELECTED;
+            $bMemberships = $iGroupId == BX_DOL_PG_MEMBERSHIPS_SELECTED;
+            if(($bMembers || $bMemberships) && ($sType = $bMembers ? 'member' : ($bMemberships ? 'membership' : '')) !== '')
+                foreach($aItems as $iItem)
+                    $this->_oDb->{'insertGroupCustom' . ucfirst($sType)}(['group_id' => $iGroupCustomId, $sType . '_id' => $iItem]);
+        }
+
+        return $iGroupCustomId;
+    }
+
+    /**
+     * Note. Is used when privacy group is processing after the main form submit.
+     */
+    public function editGroupCustom($aGroup, $aItems = [])
+    {
+        $aGroupCustom = $this->getGroupCustom([
+            'type' => 'pco',
+            'profile_id' => $aGroup['profile_id'],
+            'content_id' => $aGroup['content_id'],
+            'object' => $this->_sObject
+        ]);
+        if(empty($aGroupCustom) || !is_array($aGroupCustom))
+            return $this->addGroupCustom($aGroup, $aItems);
+
+        $iGroupId = (int)$aGroupCustom['group_id'];
+        $iGroupCustomId = (int)$aGroupCustom['id'];
+
+        if($iGroupId != (int)$aGroup['group_id'])
+            return $this->addGroupCustom($aGroup, $aItems);
+
+        $bMembers = $iGroupId == BX_DOL_PG_FRIENDS_SELECTED || $iGroupId == BX_DOL_PG_RELATIONS_SELECTED;
+        $bMemberships = $iGroupId == BX_DOL_PG_MEMBERSHIPS_SELECTED;
+        if(($bMembers || $bMemberships) && ($sType = $bMembers ? 'member' : ($bMemberships ? 'membership' : '')) !== '') {
+            $sTypeUc = ucfirst($sType);
+
+            $this->_oDb->{'deleteGroupCustom' . $sTypeUc}(['group_id' => $iGroupCustomId]);
+
+            if(!empty($aItems) && is_array($aItems))
+                foreach($aItems as $iItemId)
+                    $this->_oDb->{'insertGroupCustom' . $sTypeUc}(['group_id' => $iGroupCustomId, $sType . '_id' => $iItemId]);
+        }
+
+        return $iGroupCustomId;
+    }
+
+    public function updateGroupCustom($aParamsSet, $aParamsWhere)
+    {
+        return $this->_oDb->updateGroupCustom($aParamsSet, $aParamsWhere);
+    }
+
+    public function deleteGroupCustom($aParamsWhere)
+    {
+        return $this->_oDb->deleteGroupCustom($aParamsWhere);
+    }
+
+    public function associateGroupCustomWithContent($iProfileId, $iContentId, $iGroupId)
+    {
+        return $this->updateGroupCustom(array('content_id' => $iContentId), array(
+            'profile_id' => $iProfileId,
+            'content_id' => 0,
+            'object' => $this->_sObject,
+            'group_id' => $iGroupId
+        ));
+    }
+
+    public function reassociateGroupCustomWithContent($iProfileId, $iContentId, $iGroupId)
+    {
+        $aGroupCustom = $this->getGroupCustom(array(
+            'type' => 'pco', 
+            'profile_id' => $iProfileId,
+            'content_id' => $iContentId,
+            'object' => $this->_sObject
+        ));
+
+        if(!empty($aGroupCustom) && is_array($aGroupCustom) && $aGroupCustom['group_id'] != $iGroupId) {
+            $this->deleteGroupCustom(array('id' => $aGroupCustom['id']));
+
+            $this->associateGroupCustomWithContent($iProfileId, $iContentId, $iGroupId);
+        }
+    }
+
+    public function deleteGroupCustomByContentId($iContentId)
+    {
+        return $this->_oDb->deleteGroupCustom(array('content_id' => $iContentId, 'object' => $this->_sObject));
+    }
+
+    public function deleteGroupCustomByProfileId($iProfileId, $mixedContentId = false)
+    {
+        $aParams = ['type' => 'profile_id', 'profile_id' => $iProfileId];
+        if($mixedContentId !== false)
+            $aParams['content_id'] = (int)$mixedContentId;
+
+        $aGroups = $this->_oDb->getGroupCustom($aParams);
+        if(empty($aGroups) || !is_array($aGroups))
+            return true;
+
+        foreach($aGroups as $aGroup)
+            $this->_oDb->deleteGroupCustom(array('id' => $aGroup['id']));
+
+        return true;
+    }
+
+    public function deleteGroupCustomByProfileIdUnused($iProfileId)
+    {
+        return $this->deleteGroupCustomByProfileId($iProfileId, 0);
+    }
+
+    public function addDynamicGroups($aValues, $iOwnerId, $aParams)
+    {
+        if (isset($aParams['dynamic_groups']) && is_array($aParams['dynamic_groups']))
+            $aValues = array_merge($aValues, $aParams['dynamic_groups']);
+
+        return $aValues;
+    }
+
+    public function addSpaces($aValues, $iOwnerId, $aParams)
+    {
+        if (!$this->_aObject['spaces'])
+            return $aValues;
+
+        if (!($oProfile = BxDolProfile::getInstance($iOwnerId)))
+            return $aValues;
+
+        if (!($aModules = BxDolModuleQuery::getInstance()->getModules()))
+            return $aValues;
+        
+        $aExcludeModules = explode(',', getParam('sys_hide_post_to_context_for_privacy'));
+        
+        foreach ($aModules as $aModule) {
+            if (!$aModule['enabled'])
+                continue;
+
+            if ('all' != $this->_aObject['spaces'] && false === stripos($this->_aObject['spaces'], $aModule['name']))
+                continue;
+
+            if (!BxDolRequest::serviceExists($aModule['name'], 'act_as_profile'))
+                continue;
+            
+            if (in_array($aModule['name'], $aExcludeModules))
+                continue;
+
+            $oModule = BxDolModule::getInstance($aModule['name']);
+            if (bx_srv($aModule['name'], 'act_as_profile') && (!isset($oModule->_oConfig->CNF['ALLOW_AS_CONTEXT']) || $oModule->_oConfig->CNF['ALLOW_AS_CONTEXT'] == false))
+                continue;
+
+            $a = BxDolService::call($aModule['name'], 'get_participating_profiles', array($oProfile->id()));
+
+            // for an organization we should treat the organization profile itself as a participant of itself
+            // to be able to post into an organization's context while being logged as that organization
+            if ($aModule['name'] == 'bx_organizations' && $oProfile->getModule() == 'bx_organizations') $a = array_merge($a, [$oProfile->id()]);
+
+            $aSpaces = array();       
+            foreach ($a as $iProfileId) {
+                if (!($o = BxDolProfile::getInstance($iProfileId)))
+                    continue;
+
+                // check whether a profile is allowed to post this type of content to a context
+                $oConnectedProfile = BxDolProfile::getInstance($iProfileId);
+                if (bx_srv($aModule['name'], 'check_allowed_post_in_profile', array($oConnectedProfile->getContentId(), $this->_aObject['module'])) !== CHECK_ACTION_RESULT_ALLOWED)
+                    continue;
+
+                $aSpaces[-$iProfileId] = array('key' => -$iProfileId, 'value' => $o->getDisplayName());
+            }
+
+            if ($aSpaces) {
+                $aItemStart = array(array('type' => 'group_header', 'value' => mb_strtoupper(BxDolService::call($aModule['name'], 'get_space_title'))));
+                $aItemEnd = array(array('type' => 'group_end'));
+                $aValues = array_merge($aValues, $aItemStart, array_values($aSpaces), $aItemEnd);
+            }
+        }
+        
+        return $aValues;
+    }
+
+    /**
+     * Get database field name for action.
+     *
+     * @param  string $sObject privacy object name.
+     * @param  string $sAction action name.
+     * @return string with field name.
+     */
+    public static function getFieldName($sObject, $sAction = '')
+    {
+        $oPrivacy = BxDolPrivacy::getObjectInstance($sObject);
+        if(empty($oPrivacy))
+            return '';
+
+        if(empty($sAction))
+            $sAction = $oPrivacy->_aObject['action'];
+
+        return $oPrivacy->convertActionToField($sAction);
+    }
+
+    /**
+     * Get necessary condition array to use privacy in search classes
+     * @param $mixedGroupId group ID or array of group IDs
+     * @return array of conditions, for now with 'restriction' part only is returned
+     */
+    public function getContentByGroupAsCondition($mixedGroupId)
+    {
+        $aResult = array(
+            'restriction' => array (
+                'privacy_' . $this->_sObject => array(
+                    'value' => $mixedGroupId,
+                    'field' => $this->convertActionToField($this->_aObject['action']),
+                    'operator' => is_array($mixedGroupId) ? 'in' : '=',
+                    'table' => $this->_aObject['table'],
+                ),
+            ),
+        );   
+         /**
+         * @hooks
+         * @hookdef hook-system-privacy_condition 'system', 'privacy_condition' - hook on get necessary condition array to use privacy in search classes
+         * - $unit_name - equals `profile`
+         * - $action - equals `privacy_condition` 
+         * - $object_id - not used
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `group_id` - [int] group ID or array of group IDs
+         *      - `field` - [string] field name
+         *      - `object` - [array] array with object privacy 
+         *      - `privacy_object` - [array] object privacy 
+         *      - `result` - [bool] by ref, on success return true otherwise false, can be overridden in hook processing
+         * @hook @ref hook-system-privacy_condition
+         */
+        bx_alert('system', 'privacy_condition', 0, false, array(
+            'group_id' => $mixedGroupId,
+            'field' => $this->convertActionToField($this->_aObject['action']),
+            'object' => $this->_aObject,
+            'privacy_object' => $this,
+            'result' => &$aResult
+            )
+        );
+        return $aResult;
+    }
+
+    /**
+     * Get necessary condition array to use privacy in search classes
+     * @param $iProfileIdOwner owner profile ID
+     * @return array of conditions, for now with 'restriction' part only is returned
+     */
+    public function getContentPublicAsCondition($iProfileIdOwner = 0, $aCustomGroups = array())
+    {
+        $mixedPrivacyGroups = $this->getPrivacyGroupsForContentPublic($iProfileIdOwner, $aCustomGroups);
+        if($mixedPrivacyGroups === true)
+        	return array();
+
+        return $this->getContentByGroupAsCondition($mixedPrivacyGroups);
+    }
+
+    /**
+     * Get necessary parts of SQL query to use privacy in other queries
+     * @param $mixedGroupId group ID or array of group IDs
+     * @return array of SQL string parts, for now 'where' part only is returned
+     */
+    public function getContentByGroupAsSQLPart($mixedGroupId)
+    {
+        $sField = $this->convertActionToField($this->_aObject['action']);
+        return $this->_oDb->getContentByGroupAsSQLPart($sField, $mixedGroupId);
+    }
+
+    /**
+     * Get necessary parts of SQL query to use privacy in other queries
+     * @param $iProfileIdOwner owner profile ID
+     * @return array of SQL string parts, for now 'where' part only is returned
+     */
+    public function getContentPublicAsSQLPart($iProfileIdOwner = 0, $aCustomGroups = array())
+    {
+        $mixedPrivacyGroups = $this->getPrivacyGroupsForContentPublic($iProfileIdOwner, $aCustomGroups);
+        if($mixedPrivacyGroups === true)
+            return array();
+
+        return $this->getContentByGroupAsSQLPart($mixedPrivacyGroups);
+    }
+    
+    /**
+     * Get necessary parts of SQL query to use privacy in other queries
+     * @param $iProfileIdOwner owner profile ID
+     * @return array of SQL string parts, for now 'where' part only is returned
+     */
+    public function getContentPublicAndInContextAsSQLPart($iProfileIdOwner = 0, $aCustomGroups = [], $aCustomContexts = [])
+    {
+        $mixedPrivacyGroups = $this->getPrivacyGroupsForContentPublic($iProfileIdOwner, $aCustomGroups);
+        if($mixedPrivacyGroups === true)
+            return [];
+
+        $sField = $this->convertActionToField($this->_aObject['action']);
+        return $this->_oDb->getContentByGroupAndContextAsSQLPart($sField, $mixedPrivacyGroups, $aCustomContexts);
+    }
+
+    /**
+     * Check whether the viewer can make requested action.
+     *
+     * @param  integer $iObjectId object ID the action to be performed with.
+     * @param  integer $iViewerId viewer ID.
+     * @return boolean result of operation.
+     */
+    public function check($iObjectId, $iViewerId = 0)
+    {
+        $aObject = $this->getObjectInfo($this->convertActionToField($this->_aObject['action']), $iObjectId);
+        $bRv = $this->_check($iObjectId, $iViewerId, $aObject);
+        /**
+         * @hooks
+         * @hookdef hook-system-check_privacy 'system', 'check_privacy' - check privacy for object
+         * - $unit_name - equals `profile`
+         * - $action - equals `check_privacy` 
+         * - $object_id - not used
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_id` - [int] object id
+         *      - `viewer_id` - [int] profile_id for viewer
+         *      - `object` - [array] object privacy 
+         *      - `object_privacy` - [array] arry with object privacy 
+         *      - `result` - [bool] by ref, on success return true otherwise false, can be overridden in hook processing
+         * @hook @ref hook-system-check_privacy
+         */
+        bx_alert('system', 'check_privacy', 0, 0, array(
+           'object_id' => $iObjectId,
+           'viewer_id' => $iViewerId,
+           'object' => $aObject,
+           'object_privacy' => $this->_aObject,
+           'result' => &$bRv
+        ));
+        return $bRv;
+    }
+
+    public function checkSpace($aObject, $iViewerId)
+    {
+        $oProfile = BxDolProfile::getInstance(-(int)$aObject['group_id']);
+        if (!$oProfile)
+            return false;
+
+        return CHECK_ACTION_RESULT_ALLOWED === bx_srv($oProfile->getModule(), 'check_space_privacy_for_profile', [$oProfile->getContentId(), $iViewerId]);
+    }
+
+    public function checkMeOnly($iOwnerId, $iViewerId)
+    {
+        return false;
+    }
+
+    public function checkPublic($iOwnerId, $iViewerId)
+    {
+        return true;
+    }
+
+    public function checkMembers($iOwnerId, $iViewerId)
+    {
+        return isMember();
+    }
+
+    public function checkFriends($iOwnerId, $iViewerId)
+    {
+        return BxDolConnection::getObjectInstance('sys_profiles_friends')->isConnected($iOwnerId, $iViewerId, true);
+    }
+
+    public function checkFriendsSelectedByObject($aObject, $iViewerId)
+    {
+        if(!$this->checkFriends($aObject['owner_id'], $iViewerId))
+            return false;
+
+        $aGroupCustom = $this->getGroupCustom(array(
+            'type' => 'pcog_ext', 
+            'profile_id' => $aObject['owner_id'], 
+            'content_id' => $aObject['id'], 
+            'object' => $this->_sObject, 
+            'group_id' => $aObject['group_id'],
+            'group_items_table' => $this->_aGroupsSettings[$aObject['group_id']]['db_table_items'],
+            'group_items_field' => $this->_aGroupsSettings[$aObject['group_id']]['db_field_item']
+        ));
+
+        return !empty($aGroupCustom['items']) && is_array($aGroupCustom['items']) && in_array($iViewerId, $aGroupCustom['items']);
+    }
+
+    public function checkRelations($iOwnerId, $iViewerId)
+    {
+        return BxDolConnection::getObjectInstance('sys_profiles_relations')->isConnected($iOwnerId, $iViewerId, true);
+    }
+
+    public function checkRelationsSelectedByObject($aObject, $iViewerId)
+    {
+        if(!$this->checkRelations($aObject['owner_id'], $iViewerId))
+            return false;
+
+        $aGroupCustom = $this->getGroupCustom(array(
+            'type' => 'pcog_ext', 
+            'profile_id' => $aObject['owner_id'], 
+            'content_id' => $aObject['id'], 
+            'object' => $this->_sObject, 
+            'group_id' => $aObject['group_id'],
+            'group_items_table' => $this->_aGroupsSettings[$aObject['group_id']]['db_table_items'],
+            'group_items_field' => $this->_aGroupsSettings[$aObject['group_id']]['db_field_item']
+        ));
+
+        return !empty($aGroupCustom['items']) && is_array($aGroupCustom['items']) && in_array($iViewerId, $aGroupCustom['items']);
+    }
+
+    public function checkMembershipsSelectedByObject($aObject, $iViewerId)
+    {
+        $aGroupCustom = $this->getGroupCustom(array(
+            'type' => 'pcog_ext', 
+            'profile_id' => $aObject['owner_id'], 
+            'content_id' => $aObject['id'], 
+            'object' => $this->_sObject, 
+            'group_id' => $aObject['group_id'],
+            'group_items_table' => $this->_aGroupsSettings[$aObject['group_id']]['db_table_items'],
+            'group_items_field' => $this->_aGroupsSettings[$aObject['group_id']]['db_field_item']
+        ));
+
+        return !empty($aGroupCustom['items']) && is_array($aGroupCustom['items']) && BxDolAcl::getInstance()->isMemberLevelInSet($aGroupCustom['items'], $iViewerId);
+    }
+
+    public function checkCustomByObject($aObject, $iViewerId)
+    {
+        return '';
+    }
+
+    public function setTableFieldAuthor($sValue)
+    {
+        $this->_aObject['table_field_author'] = $sValue;
+
+        $this->_oDb->init($this->_aObject);
+    }
+
+    protected function getObjectInfo($sAction, $iObjectId)
+    {
+        return $this->_oDb->getObjectInfo($sAction, $iObjectId);
+    }
+
+    protected function getPrivacyGroupsForContentPublic($iProfileIdOwner = 0, $aCustomGroups = array())
+    {
+        $aGroups = array(BX_DOL_PG_ALL);
+        if(isLogged()) {
+            $iProfileIdLogged = bx_get_logged_profile_id();
+            if($iProfileIdLogged == $iProfileIdOwner)
+                return true;
+
+            $aGroups[] = BX_DOL_PG_MEMBERS;
+            if($iProfileIdOwner && $this->checkFriends($iProfileIdOwner, $iProfileIdLogged))
+                $aGroups[] = BX_DOL_PG_FRIENDS;
+        }
+
+        return array_merge($aGroups, $aCustomGroups);
+    }
+
+    protected function getCheckMethod($s)
+    {
+        if(substr($s, 0, 1) != '@')
+            return false;
+
+        return bx_gen_method_name(str_replace('@', 'check_', $s));
+    }
+
+    protected function convertActionToField($sAction)
+    {
+    	return 'allow_' . strtolower(str_replace(' ', '-', $sAction)) . '_to';
+    }
+
+    /**
+     * Check whethere viewer is a member of dynamic group.
+     *
+     * @param  mixed   $mixedGroupId   dynamic group ID.
+     * @param  integer $iObjectOwnerId object owner ID.
+     * @param  integer $iViewerId      viewer ID.
+     * @return boolean result of operation.
+     */
+    protected function isDynamicGroupMember($mixedGroupId, $iObjectOwnerId, $iViewerId, $iObjectId)
+    {
+        return false;
+    }
+
+    /**
+     * get privacy groups for getGroupChooser
+     */ 
+    protected function getGroups($iOwnerId = 0, $aParams = []) 
+    {
+        $aValues = array();
+
+        $aGroups = $this->_oDb->getGroupsBy(array('type' => 'active'));
+        foreach($aGroups as $aGroup) {
+            $iGroupId = (int)$aGroup['id'];
+
+            if((int)$aGroup['active'] == 0 || in_array($iGroupId, $this->_aGroupsExclude))
+               continue;
+
+            if(isset($this->_aGroupsSettings[$iGroupId]) && !empty($this->_aGroupsSettings[$iGroupId]['is_allowed'])) {
+                $sMethodIsAllowed = $this->_aGroupsSettings[$iGroupId]['is_allowed'];
+                if(method_exists($this, $sMethodIsAllowed) && $this->$sMethodIsAllowed($iOwnerId) !== true)
+                    continue;
+            }
+
+            $aValues[] = array('key' => $aGroup['id'], 'value' => _t($aGroup['title']));
+        }
+
+        return $aValues;
+    }
+
+    /**
+     * Check whethere a custom group (based on users list) is allowed in current circumstances.
+     * NOTE. Can be overwritten if it's needed.
+     * 
+     * @param type $aParams an array of parameters.
+     * @return boolean result of operation.
+     */
+    protected function isSelectGroupCustomUsers($aParams)
+    {
+        return true;
+    }
+
+    /**
+     * Check whethere a custom group (based on memberships list) is allowed in current circumstances.
+     * NOTE. Can be overwritten if it's needed.
+     * 
+     * @param type $aParams an array of parameters.
+     * @return boolean result of operation.
+     */
+    protected function isSelectGroupCustomMemberships($aParams)
+    {
+        return true;
+    }
+
+    public function isAllowedRelations($iUserId)
+    {
+        return BxDolConnectionRelation::isEnabled();
+    }
+
+    public function isAllowedMemberships($iUserId)
+    {
+        $aCheck = checkActionModule($iUserId, 'show membership levels in privacy groups', 'system', false);
+        return $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * Check whether the viewer can make requested action.
+     *
+     * @param  integer $iObjectId object ID the action to be performed with.
+     * @param  integer $iViewerId viewer ID.
+     * @return boolean result of operation.
+     */
+    protected function _check($iObjectId, $iViewerId, $aObject)
+    {
+        if(empty($iViewerId))
+            $iViewerId = (int)bx_get_logged_profile_id();
+
+        if(empty($aObject) || !is_array($aObject))
+            return false;
+
+        if($aObject['group_id'] == BX_DOL_PG_HIDDEN)
+            return false;
+
+        $aViewer = BxDolProfileQuery::getInstance()->getInfoById($iViewerId);
+        if(($aViewer && isAdmin($aViewer['account_id'])) || $iViewerId == $aObject['owner_id'])
+            return true;
+
+        if(strncmp($aObject['group_id'], 'ml', 2) === 0) {
+            $iLevel = (int)substr($aObject['group_id'], 2);
+            return (bool)BxDolAcl::getInstance()->isMemberLevelInSet(array($iLevel), $iViewerId);
+        }
+
+        // A space (context) is stored as its negative profile id. Compare numerically: under
+        // PHP 8 an empty or other non-numeric value compares as a string, so '' < 0 is true.
+        if(is_numeric($aObject['group_id']) && (int)$aObject['group_id'] < 0)
+            return $this->checkSpace($aObject, $iViewerId);
+
+        $aGroup = $this->_oDb->getGroupsBy(array('type' => 'id', 'id' => $aObject['group_id']));
+        if(!empty($aGroup) && is_array($aGroup) && (int)$aGroup['active'] == 1 && !empty($aGroup['check'])) {
+            $sCheckMethod = $this->getCheckMethod($aGroup['check']);
+            if(method_exists($this, $sCheckMethod) && $this->$sCheckMethod((substr($sCheckMethod, -8) == 'ByObject' ? $aObject : $aObject['owner_id']), $iViewerId))
+                return true;
+        }
+
+        return $this->isDynamicGroupMember($aObject['group_id'], $aObject['owner_id'], $iViewerId, $iObjectId);
+    }
+}
+
+/** @} */

+ 856 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolProfile.php

@@ -0,0 +1,856 @@
+<?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
+ * @{
+ */
+
+define('BX_PROFILE_ACTION_AUTO', 0); ///< automatic action without any checking
+define('BX_PROFILE_ACTION_MANUAL', 1); ///< manual action performed by human
+define('BX_PROFILE_ACTION_ROBOT', 2); ///< action peformed by some robot based on some conditions
+define('BX_PROFILE_ACTION_EXTERNAL', 2); ///< action peformed by external service, like join using OAuth 
+
+class BxDolProfile extends BxDolFactory implements iBxDolProfile
+{
+    protected $_iProfileID;
+    protected $_aProfile;
+    protected $_oQuery;
+
+    /**
+     * Constructor
+     */
+    protected function __construct ($iProfileId)
+    {
+        $iProfileId = (int)$iProfileId;
+        $sClass = get_class($this) . '_' . $iProfileId;
+        if (isset($GLOBALS['bxDolClasses'][$sClass]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+        $this->_iProfileID = $iProfileId; // since constructor is protected $iProfileId is always valid
+        $this->_oQuery = BxDolProfileQuery::getInstance();
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        $sClass = get_class($this) . '_' . $this->_iProfileID;
+        if (isset($GLOBALS['bxDolClasses'][$sClass]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of Account Profile by account id
+     */
+    public static function getInstanceAccountProfile($iAccountId = false, $bClearCache = false)
+    {
+        if (!$iAccountId)
+            $iAccountId = getLoggedId();
+        $oQuery = BxDolProfileQuery::getInstance();
+        $aProfile = $oQuery->getProfileByContentTypeAccount($iAccountId, 'system', $iAccountId);
+        if (!$aProfile)
+            return false;
+        return self::getInstance($aProfile['id'], $bClearCache);
+    }
+
+    /**
+     * Get singleton instance of Profile by account id, content id and type
+     */
+    public static function getInstanceByContentTypeAccount($iContent, $sType, $iAccountId = false)
+    {
+        if (!$iAccountId)
+            $iAccountId = getLoggedId();
+        $oQuery = BxDolProfileQuery::getInstance();
+        $aProfile = $oQuery->getProfileByContentTypeAccount($iContent, $sType, $iAccountId);
+        if (!$aProfile)
+            return false;
+        return self::getInstance($aProfile['id']);
+    }
+
+    /**
+     * Get singleton instance of Profile by content id and type
+     */
+    public static function getInstanceByContentAndType($iContent, $sType, $bClearCache = false)
+    {
+        $oQuery = BxDolProfileQuery::getInstance();
+        $aProfile = $oQuery->getProfileByContentAndType($iContent, $sType, $bClearCache);
+        if (!$aProfile)
+            return false;
+        return self::getInstance($aProfile['id']);
+    }
+
+    /**
+     * Get singleton instance of Profile by Account id (currently active profile is returned)
+     */
+    public static function getInstanceByAccount($iAccountId = false, $bClearCache = false)
+    {
+        $oQuery = BxDolProfileQuery::getInstance();
+        $mixedProfileId = $oQuery->getCurrentProfileByAccount($iAccountId, $bClearCache);
+
+        return self::getInstance($mixedProfileId);
+    }
+
+    /**
+     * Get singleton instance of Profile by profile id, if profile isn't found it returns instance of BxDolProfileAnonymous or BxDolProfileUndefined
+     */
+    public static function getInstanceMagic($mixedProfileId = false, $bClearCache = false)
+    {
+        if ($mixedProfileId < 0)
+            return BxDolProfileAnonymous::getInstance($mixedProfileId);
+
+        if (0 === $mixedProfileId || !($oProfile = self::getInstance($mixedProfileId, $bClearCache)))
+            return BxDolProfileUndefined::getInstance();
+        
+        return $oProfile;
+    }
+
+    /**
+     * Get singleton instance of Profile by profile id
+     */
+    public static function getInstance($mixedProfileId = false, $bClearCache = false)
+    {
+        $oQuery = BxDolProfileQuery::getInstance();
+
+        if (!$mixedProfileId) {
+            if (isset($GLOBALS['glForceCurrentProfileId']) && $GLOBALS['glForceCurrentProfileId'] > 0)
+                $mixedProfileId = $GLOBALS['glForceCurrentProfileId'];
+            else
+                $mixedProfileId = $oQuery->getCurrentProfileByAccount(getLoggedId(), $bClearCache);
+        }
+
+        $aProfileInfo = $oQuery->getInfoById($mixedProfileId);
+        if (empty($aProfileInfo['id']) || !BxDolModuleDb::getInstance()->isEnabledByName($aProfileInfo['type']))
+            return false;
+
+        $sClass = __CLASS__ . '_' . $aProfileInfo['id'];
+        if (!isset($GLOBALS['bxDolClasses'][$sClass]))
+            $GLOBALS['bxDolClasses'][$sClass] = new BxDolProfile($aProfileInfo['id']);
+
+        return $GLOBALS['bxDolClasses'][$sClass];
+    }
+
+    public static function getData($mixedProfileId = false, $aParams = [])
+    {
+        $sDisplayType = 'unit';
+        if(isset($aParams['display_type']))
+            $sDisplayType = $aParams['display_type'];
+
+        if(!($mixedProfileId instanceof BxDolProfile))
+            $oProfile = BxDolProfile::getInstanceMagic($mixedProfileId);
+        else
+            $oProfile = $mixedProfileId;
+
+        $aRv = [
+            'id' => $oProfile->id(),
+            'display_name' => $oProfile->getDisplayName(),
+            'url' => ($sUrl = $oProfile->getUrl()) !== false ? bx_api_get_relative_url($sUrl) : false,
+            'url_avatar' => $oProfile->{isset($aParams['get_avatar']) && method_exists($oProfile, $aParams['get_avatar']) ? $aParams['get_avatar'] : 'getAvatar'}(),
+            'module' => $oProfile->getModule(),
+        ];
+
+        // Online status (the online dot on the site), only on request: it costs a query per profile.
+        if(isset($aParams['with_online']) && (bool)$aParams['with_online']) {
+            $aRv['online'] = $oProfile->isOnline() ? 1 : 0;
+        }
+
+        if(isset($aParams['with_info']) && (bool)$aParams['with_info'])
+            $aRv['info'] = bx_srv($oProfile->getModule(), 'get_info', [$oProfile->getContentId(), false]);
+        
+        return $aRv;
+    }
+    
+    public static function getDataForPage($mixedProfileId = false, $aParams = [])
+    {
+        $oAcl = BxDolAcl::getInstance();
+        $oTemplate = BxDolTemplate::getInstance();
+
+        if(!($mixedProfileId instanceof BxDolProfile))
+            $oProfile = BxDolProfile::getInstanceMagic($mixedProfileId);
+        else
+            $oProfile = $mixedProfileId;
+
+        $oAccount = $oProfile->getAccountObject();
+
+        $iId = $oProfile->id();
+        $sUrl = bx_api_get_relative_url($oProfile->getUrl());
+
+        $aMembershipInfo = $oAcl->getMemberMembershipInfo($iId);
+        $aMembershipLevelInfo =  $oAcl->getMembershipInfo($aMembershipInfo['id']);
+
+        $sIconS = $aMembershipLevelInfo['icon'];
+        $sIconD = $oTemplate->getImage($sIconS, ['wrap_in_tag' => false]);
+
+        $aRv = [
+            'id' => $iId,
+            'account_id' => $oAccount->id(),
+            'email' => $oAccount->getEmail(),
+            'display_name' => $oProfile->getDisplayName(),
+            'url' => $sUrl,
+            'url_postfix' => substr($sUrl, strrpos($sUrl, '/') + 1),
+            'avatar' => $oProfile->getAvatar(),
+            'settings' => $oProfile->getSettings(),
+            'membership' => $aMembershipInfo['id'],
+            'membership_name' => _t($aMembershipInfo['name']),
+            'membership_icon' => strcmp($sIconS, $sIconD) != 0 ? $sIconD : BxDolIconset::getObjectInstance()->getIcon($sIconD),
+            'moderator' => (bool)BxDolAcl::getInstance()->isMemberLevelInSet([MEMBERSHIP_ID_ADMINISTRATOR, MEMBERSHIP_ID_MODERATOR], $iId),
+            'operator' => isAdmin(),
+            //'info' => $oProfile->getInfo(),
+            'confirmed' => $oAccount->isConfirmed(),
+            'profiles_count' => $oAccount->getProfilesNumber(true),
+            'hash' => encryptUserId($iId),
+            'profiles_limit' => (int)getParam('sys_account_limit_profiles_number'),
+            'active' => $oProfile->isActive(),
+            'status' => $oProfile->getStatus(),
+        ];
+        
+        if ($iId == bx_get_logged_profile_id()){
+            $aRv['counters'] = bx_srv('system', 'profile_counters', [defined('BX_API_PAGE')], 'TemplServiceProfiles');
+
+            $oInformer = BxDolInformer::getInstance($oTemplate);
+            $sRet = $oInformer ? $oInformer->display() : '';
+            if ($sRet){
+                $aRv['informer'] = $sRet;
+            }
+
+            /*  
+            $oPayments = BxDolPayments::getInstance();
+            if($oPayments->isActive())
+                $aRv['cart'] = $oPayments->getCartItemsCount();
+
+            $sModuleNotifications = 'bx_notifications';
+            if(BxDolRequest::serviceExists($sModuleNotifications, 'get_unread_notifications_num'))
+                $aRv['notifications'] = bx_srv($sModuleNotifications, 'get_unread_notifications_num', [$iId]);
+            */
+
+            if($oProfile !== false && BxDolAccount::isAllowedCreateMultiple($iId)) {
+                $oAccount = BxDolAccount::getInstance();
+                if($oAccount != false && !$oAccount->isProfilesLimitReached()) {
+                    $oMenuProfileAdd = BxDolMenu::getObjectInstance('sys_add_profile');
+                    if($oMenuProfileAdd !== false)
+                        $aRv['menu'] = $oMenuProfileAdd->getCodeAPI();
+                }
+            }
+        }
+
+        bx_alert('system', 'get_profile_for_page_api', 0, 0, ['override_result' => &$aRv]);
+        
+        return $aRv;
+    }
+
+    /**
+     * Get profile id
+     */
+    public function id()
+    {
+        $aProfile = $this->getInfo($this->_iProfileID);
+        return isset($aProfile['id']) ? $aProfile['id'] : false;
+    }
+
+    /**
+     * Get account id associated with the profile
+     */
+    public function getAccountId($iProfileId = false)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return $aInfo['account_id'];
+    }
+
+    /**
+     * Get account object associated with the profile
+     */
+    public function getAccountObject($iProfileId = false)
+    {
+        return BxDolAccount::getInstance($this->getAccountId($iProfileId));
+    }
+
+    /**
+     * Get content id associated with the profile
+     */
+    public function getContentId()
+    {
+        $aInfo = $this->getInfo();
+        return $aInfo['content_id'];
+    }
+
+    /**
+     * Check if profile status is active
+     */
+    public function isActive($iProfileId = false)
+    {
+        if($this->getStatus($iProfileId) != BX_PROFILE_STATUS_ACTIVE)
+            return false;
+
+        return (($oAccount = $this->getAccountObject($iProfileId)) !== false && $oAccount->isConfirmed()) || !getParam('sys_account_hide_unconfirmed_accounts');
+    }
+
+    /**
+     * Is profile online
+     */
+    public function isOnline($iProfileId = false)
+    {
+        $iProfileId = (int)$iProfileId ? $iProfileId : $this->_iProfileID;
+        return $this->_oQuery->isOnline($iProfileId);
+    }
+
+    /**
+     * Is profile can 'Act as Profile'
+     */
+    public function isActAsProfile($iProfileId = false)
+    {
+        $aInfo = $this->_oQuery->getInfoById((int)$iProfileId ? $iProfileId : $this->_iProfileID);
+        return BxDolService::call($aInfo['type'], 'act_as_profile');
+    }
+
+    /**
+     * Get profile status
+     */
+    public function getStatus($iProfileId = false)
+    {
+        $aInfo = $this->_oQuery->getInfoById((int)$iProfileId ? $iProfileId : $this->_iProfileID);
+        if(empty($aInfo) || !is_array($aInfo))
+            return false;
+
+        return $aInfo['status'];
+    }
+
+    /**
+     * Get profile module name
+     */
+    public function getModule($iProfileId = false)
+    {
+        $aInfo = $this->_oQuery->getInfoById((int)$iProfileId ? $iProfileId : $this->_iProfileID);
+        return $aInfo['type'];
+    }
+
+    /**
+     * Get profile info
+     */
+    public function getInfo($iProfileId = 0)
+    {
+        if ($iProfileId && $iProfileId != $this->_iProfileID)
+            return $this->_oQuery->getInfoById((int)$iProfileId ? $iProfileId : $this->_iProfileID);
+
+        if ($this->_aProfile)
+            return $this->_aProfile;
+        
+        $this->_aProfile = $this->_oQuery->getInfoById((int)$iProfileId ? $iProfileId : $this->_iProfileID);
+        return $this->_aProfile;
+    }
+
+    /**
+     * Validate profile id.
+     * @param $s - profile id
+     * @return profile id or false if profile was not found
+     */
+    static public function getID($s)
+    {
+        $iId = BxDolProfileQuery::getInstance()->getIdById((int)$s);
+        return $iId ? $iId : false;
+    }
+
+    /**
+     * Get name to display in thumbnail
+     */
+    public function getDisplayName($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        $sDisplayName = BxDolService::call($aInfo['type'], 'profile_name', array($aInfo['content_id']));
+        /**
+         * @hooks
+         * @hookdef hook-profile-profile_name 'profile', 'profile_name' - hook on before profile deletion
+         * - $unit_name - equals `profile`
+         * - $action - equals `profile_name` 
+         * - $object_id - profile_id 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `info` - [array] profile info
+         *      - `display_name` - [bool] by ref, display profile name, can be overridden in hook processing
+         * @hook @ref hook-profile-profile_name
+         */
+        bx_alert('profile', 'profile_name', $iProfileId, 0, array(
+            'info' => $aInfo, 
+            'display_name' => &$sDisplayName,
+            'display_name_ref' => &$sDisplayName
+        ));
+        return $sDisplayName;
+    }
+    
+    public function getSettings($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return  BxDolService::call($aInfo['type'], 'profile_settings', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get profile url
+     */
+    public function getUrl($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_url', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get profile unit
+     */
+    public function getUnit($iProfileId = 0, $aParams = array())
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_unit', array($aInfo['content_id'], $aParams));
+    }
+
+    /**
+     * Get profile unit for API calls
+     */
+    public function getUnitAPI($iProfileId = 0, $aParams = array())
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_unit_api', array($aInfo['content_id'], $aParams));
+    }
+
+    /**
+     * Get badges
+     */
+    public function getBadges($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'get_badges', array($aInfo['content_id'], false, true));
+    }
+
+	/**
+     * Check whether a profile has real image uploaded by user.
+     */
+    public function hasImage($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'has_image', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get picture url
+     */
+    public function getPicture($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_picture', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get avatar url
+     */
+    public function getAvatar($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_avatar', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get big (2x) avatar url
+     */
+    public function getAvatarBig($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_avatar_big', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get cover url
+     */
+    public function getCover($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_cover', array($aInfo['content_id']));
+    }
+
+	/**
+     * Get unit cover url
+     */
+    public function getUnitCover($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_unit_cover', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get thumbnail url
+     */
+    public function getThumb($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_thumb', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get icon url
+     */
+    public function getIcon($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_icon', array($aInfo['content_id']));
+    }
+
+    /**
+     * Get module icon
+     */
+    public function getIconModule($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'module_icon');
+    }
+
+    /**
+     * get profile edit page url
+     */
+    public function getEditUrl($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'profile_edit_url', array($aInfo['content_id']));
+    }
+
+    /**
+     * @see iBxDolProfile::checkAllowedProfileView
+     */
+    public function checkAllowedProfileView($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'check_allowed_profile_view', array($aInfo['content_id']));
+    }
+
+    /**
+     * @see iBxDolProfile::checkAllowedProfileContact
+     */
+    public function checkAllowedProfileContact($iProfileId = 0)
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'check_allowed_profile_contact', array($aInfo['content_id']));
+    }
+    
+    
+    /**
+     * @see iBxDolProfile::checkAllowedPostInProfile
+     */
+    public function checkAllowedPostInProfile($iProfileId = 0, $sPostModule = '')
+    {
+        $aInfo = $this->getInfo($iProfileId);
+        return BxDolService::call($aInfo['type'], 'check_allowed_post_in_profile', array($aInfo['content_id'], $sPostModule));
+    }
+    
+    /**
+     * Delete profile.
+     * @param $ID - optional profile id to delete
+     * @param $bDeleteWithContent - delete profile with all its contents
+     * @param $bForceDelete - force deletetion is case of account profile deletion
+     * @return false on error, or true on success
+     */
+    function delete($ID = false, $bDeleteWithContent = false, $bForceDelete = false)
+    {
+        $ID = (int)$ID;
+        if (!$ID)
+            $ID = $this->_iProfileID;
+
+        $aProfileInfo = $this->_oQuery->getInfoById($ID);
+        if (!$aProfileInfo)
+            return false;
+
+        // delete system profiles (accounts) is not allowed, instead - delete whole account
+        if (!$bForceDelete && 'system' == $aProfileInfo['type'])
+            return false;
+
+        // delete actual profile
+        if ($sErrorMsg = BxDolService::call($aProfileInfo['type'] , 'delete_entity_service', array($aProfileInfo['content_id'], $bDeleteWithContent)))
+            return false;
+
+        // switch profile context if deleted profile is active profile context
+        $oAccount = BxDolAccount::getInstance ($aProfileInfo['account_id']);
+        $aAccountInfo = $oAccount->getInfo();
+        if (!$bForceDelete && $ID == $aAccountInfo['profile_id'])
+            $oAccount->updateProfileContextAuto($ID);
+
+        $isStopDeletion = false;
+        /**
+         * @hooks
+         * @hookdef hook-profile-before_delete 'profile', 'before_delete' - hook on before profile deletion
+         * - $unit_name - equals `profile`
+         * - $action - equals `before_delete` 
+         * - $object_id - profile_id 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `delete_with_content` - [bool] also delete content for profile or not
+         *      - `stop_deletion` - [bool] by ref, if true then stop profile deletion, can be overridden in hook processing
+         *      - `type` - [string] module name
+         * @hook @ref hook-profile-before_delete
+         */
+        bx_alert('profile', 'before_delete', $ID, 0, array('delete_with_content' => $bDeleteWithContent, 'stop_deletion' => &$isStopDeletion, 'stop_deletion_ref' => &$isStopDeletion, 'type' => $aProfileInfo['type']));
+        if ($isStopDeletion)
+            return false;
+
+        // delete associated content
+        if($bDeleteWithContent) {
+	        BxDolCmts::onAuthorDelete($ID);
+
+	        BxDolReport::onAuthorDelete($ID);
+
+	        BxDolVote::onAuthorDelete($ID);
+
+	        BxDolFavorite::onAuthorDelete($ID);
+
+	        BxDolView::onAuthorDelete($ID);
+        }
+
+        // delete connections
+        $oConn = BxDolConnection::getObjectInstance('sys_profiles_friends');
+        $oConn->onDeleteInitiatorAndContent($ID);
+
+        $oConn = BxDolConnection::getObjectInstance('sys_profiles_subscriptions');
+        $oConn->onDeleteInitiatorAndContent($ID);
+
+        // delete profile's acl levels
+        BxDolAcl::getInstance()->onProfileDelete($ID);
+
+        // delete SEO links
+        BxDolPage::deleteSeoLinkByParam ('profile_id', $ID);
+
+        // delete profile
+        if (!$this->_oQuery->delete($ID))
+            return false;
+
+        /**
+         * @hooks
+         * @hookdef hook-profile-delete 'profile', 'delete' - hook on profile deleted
+         * - $unit_name - equals `profile`
+         * - $action - equals `delete` 
+         * - $object_id - profile_id 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `delete_with_content` - [bool] also delete content for profile or not
+         *      - `type` - [string] module name
+         * @hook @ref hook-profile-delete
+         */
+        bx_alert('profile', 'delete', $ID, 0, array('delete_with_content' => $bDeleteWithContent, 'type' => $aProfileInfo['type']));
+
+        // unset class instance to prevent creating the instance again
+        $this->_iProfileID = 0;
+        $sClass = get_class($this) . '_' . $ID;
+        unset($GLOBALS['bxDolClasses'][$sClass]);
+
+        return true;
+    }
+
+    /**
+     * Insert account and content id association. Also if currect profile id is not defined - it updates current profile id in account.
+     * @param $iAccountId account id
+     * @param $iContentId content id
+     * @param $sStatus profile status
+     * @param $sType profile content type
+     * @return inserted profile's id
+     */
+    static public function add ($iAction, $iAccountId, $iContentId, $sStatus, $sType = 'system')
+    {
+        $oQuery = BxDolProfileQuery::getInstance();
+        if (!($iProfileId = $oQuery->insertProfile ($iAccountId, $iContentId, $sStatus, $sType)))
+            return false;
+         /**
+         * @hooks
+         * @hookdef hook-profile-add 'profile', 'add' - hook on profile added
+         * - $unit_name - equals `profile`
+         * - $action - equals `add` 
+         * - $object_id - profile_id 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] action id
+         *      - `content` - [int] content_id in module
+         *      - `account` - [int] account_id
+         *      - `status` - [string] status 
+         *      - `action` - [int] action id
+         *      - `profile_id` - [int] by ref, iprofile_id, can be overridden in hook processing
+         * @hook @ref hook-profile-add
+         */
+        bx_alert('profile', 'add', $iProfileId, 0, array('module' => $sType, 'content' => $iContentId, 'account' => $iAccountId, 'status' => $sStatus, 'action' => $iAction, 'profile_id' => &$iProfileId, 'profile_id_ref' => &$iProfileId));
+        return $iProfileId;
+    }
+
+    /**
+     * Change profile status to 'Active'
+     */
+    public function activate($iAction, $iProfileId = 0, $bSendEmailNotification = true)
+    {
+        $sStatus = $this->getStatus($iProfileId);
+        return $this->changeStatus(BX_PROFILE_STATUS_ACTIVE, BX_PROFILE_STATUS_PENDING == $sStatus ? 'approve' : 'activate', $iAction, $iProfileId, $bSendEmailNotification);
+    }
+
+    /**
+     * Change profile status from 'Pending' to the next level - 'Active'
+     */
+    public function approve($iAction, $iProfileId = 0, $bSendEmailNotification = true)
+    {
+        return $this->changeStatus(BX_PROFILE_STATUS_ACTIVE, 'approve', $iAction, $iProfileId, $bSendEmailNotification);
+    }
+
+    /**
+     * Change profile status to 'Pending'
+     */
+    public function disapprove($iAction, $iProfileId = 0, $bSendEmailNotification = true)
+    {
+        return $this->changeStatus(BX_PROFILE_STATUS_PENDING, 'disapprove', $iAction, $iProfileId, $bSendEmailNotification);
+    }
+
+    /**
+     * Move profile to another account
+     */
+    public function move($iAccountId, $iProfileId = 0)
+    {
+        if (!$iProfileId)
+            $iProfileId = $this->_iProfileID;
+
+        if($this->getAccountId($iProfileId) == $iAccountId)
+            return true;
+
+        return $this->_oQuery->changeAccountId($iProfileId, $iAccountId) !== false;
+    }
+
+    public function doAudit($sAction, $aData = array())
+    {
+        bx_audit(
+            $this->getContentId(), 
+            $this->getModule(), 
+            $sAction,  
+            array('content_title' => $this->getDisplayName(), 'data' => $aData)
+        );  
+    }
+    
+    /**
+     * Change profile status to 'Suspended'
+     */
+    public function suspend($iAction, $iProfileId = 0, $bSendEmailNotification = true)
+    {
+        if (!$iProfileId)
+            $iProfileId = $this->_iProfileID;
+        
+        //moderators shouldn't be able to suspend other moderators and admins
+        if (BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR), bx_get_logged_profile_id()) && BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR, MEMBERSHIP_ID_ADMINISTRATOR), $iProfileId))
+            return false;
+        
+        return $this->changeStatus(BX_PROFILE_STATUS_SUSPENDED, 'suspend', $iAction, $iProfileId, $bSendEmailNotification);
+    }
+
+    protected function changeStatus($sStatus, $sAlertActionName, $iAction, $iProfileId = 0, $bSendEmailNotification = true)
+    {
+        if (!$iProfileId)
+            $iProfileId = $this->_iProfileID;
+
+        // get account and profile objects
+        $oProfile = BxDolProfile::getInstance($iProfileId);
+        $oAccount = $oProfile->getAccountObject();
+        if (!$oProfile || !$oAccount)
+            return false;
+
+        // change status
+        if (!$this->_oQuery->changeStatus($iProfileId, $sStatus))
+            return false;
+
+        $this->_aProfile = array();
+
+        /**
+         * @hooks
+         * @hookdef hook-profile-approve 'profile', 'approve' - hook on switch profile
+         * - $unit_name - equals `profile`
+         * - $action - equals `approve` 
+         * - $object_id - profile_id for current user
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `action` - [int] action id
+         *      - `status` - [string] by ref, status, can be overridden in hook processing
+         *      - `send_email_notification` - [bool] by ref, if need to send notification about changed status = true, otherwise false, can be overridden in hook processing
+         * @hook @ref hook-profile-approve
+         */
+        bx_alert('profile', $sAlertActionName, $iProfileId, false, array(
+            'action' => $iAction, 
+            'status' => &$sStatus, 
+            'status_ref' => &$sStatus, 
+            'send_email_notification' => &$bSendEmailNotification,
+            'send_email_notification_ref' => &$bSendEmailNotification
+        ));
+        
+        $this->doAudit('_sys_audit_action_set_status_' . $sStatus);
+        
+        // send email to member about status change
+        if ($bSendEmailNotification)
+            sendMailTemplate('t_ChangeStatus' . ucfirst($sStatus), $oAccount->id(), $iProfileId, array('status' => $sStatus), BX_EMAIL_SYSTEM);
+
+        return true;
+    }
+
+    public static function getSwitchToProfileRedirectUrl($iProfileId)
+    {
+        return BxDolPermalinks::getInstance()->permalink('page.php?i=account-profile-switcher', [
+            'switch_to_profile' => $iProfileId, 
+            'redirect' => getParam('sys_account_switch_to_profile_redirect')
+        ]);
+    }
+
+    /**
+     * Display informer message if it is possible to switch to this profile
+     */
+    public function checkSwitchToProfile($oTemplate = null, $iViewerAccountId = false, $iViewerProfileId = false)
+    {
+        if (false === $iViewerAccountId)
+            $iViewerAccountId = getLoggedId();
+        if (false === $iViewerProfileId)
+            $iViewerProfileId = bx_get_logged_profile_id();
+
+        if (!$iViewerAccountId || !$iViewerProfileId)
+            return;
+
+        $aCheck = checkActionModule($iViewerProfileId, 'switch to any profile', 'system', false);
+        $bAllowSwitchToAnyProfile = $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED;
+        
+        $iSwitchToAccountId = $this->getAccountId();
+        $iSwitchToProfileId = $this->id();
+        $bCanSwitch = ($iSwitchToAccountId == $iViewerAccountId || $bAllowSwitchToAnyProfile);
+        /**
+         * @hooks
+         * @hookdef hook-account-check_switch_context 'account', 'check_switch_context' - hook on switch profile
+         * - $unit_name - equals `account`
+         * - $action - equals `check_switch_context` 
+         * - $object_id - account id 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `switch_to_profile` - [int] profile_id for switched profile
+         *      - `viewer_account` - [int] profile_id for viewer profile
+         *      - `override_result` - [bool] by ref, if allow to switch to profile = true, otherwise false, can be overridden in hook processing
+         * @hook @ref hook-account-check_switch_context
+         */
+        bx_alert('account', 'check_switch_context', $iSwitchToAccountId, $iViewerProfileId, array('switch_to_profile' => $iSwitchToProfileId, 'viewer_account' => $iViewerAccountId, 'override_result' => &$bCanSwitch));
+
+        if(!$bCanSwitch ||  $iViewerProfileId == $iSwitchToProfileId)
+            return;
+
+        $oInformer = BxDolInformer::getInstance($oTemplate);
+        if($oInformer)
+            $oInformer->add('sys-switch-profile-context', _t('_sys_txt_account_profile_context_change' . ($iSwitchToAccountId != $iViewerAccountId ? '_to_another' : '') . '_suggestion', self::getSwitchToProfileRedirectUrl($this->id())), BX_INFORMER_INFO);
+    }
+
+    /**
+     * Add permament messages.
+     */
+    public function addInformerPermanentMessages ($oInformer)
+    {
+    	$aInfo = $this->getInfo();
+    	$aProfiles = $this->_oQuery->getProfilesByAccount($aInfo['account_id']);
+
+        if ($aInfo['type'] == 'system' && count($aProfiles) == 1) {
+            $oInformer->add('sys-account-profile-system', _t('_sys_txt_account_profile_system'), BX_INFORMER_INFO, [
+                'icon' => 'user-plus',
+                'action_url' => BxDolPermalinks::getInstance()->permalink('page.php?i=account-profile-switcher'),
+                'action_title' => _t('_sys_txt_account_profile_system_action'),
+            ]);
+        }
+    }
+}
+
+/** @} */

+ 500 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolScore.php

@@ -0,0 +1,500 @@
+<?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
+ * @{
+ */
+
+bx_import('BxDolAcl');
+
+define('BX_DOL_SCORE_USAGE_BLOCK', 'block');
+define('BX_DOL_SCORE_USAGE_INLINE', 'inline');
+define('BX_DOL_SCORE_USAGE_DEFAULT', BX_DOL_SCORE_USAGE_BLOCK);
+
+define('BX_DOL_SCORE_DO_UP', 'up');
+define('BX_DOL_SCORE_DO_DOWN', 'down');
+
+/**
+ * Score for any content
+ *
+ * Related classes:
+ * - BxDolScoreQuery - vote database queries
+ * - BxBaseScore - vote base representation
+ * - BxTemplScore - custom template representation
+ *
+ * AJAX vote for any content. Stars and Plus based representations are supported.
+ *
+ * To add vote section to your feature you need to add a record to 'sys_objects_vote' table:
+ *
+ * - ID - autoincremented id for internal usage
+ * - Name - your unique module name, with vendor prefix, lowercase and spaces are underscored
+ * - TableMain - table name where summary votigs are stored
+ * - TableTrack - table name where each vote is stored
+ * - PostTimeout - number of seconds to not allow duplicate vote
+ * - MinValue - min vote value, 1 by default
+ * - MaxValue - max vote value, 5 by default
+ * - IsUndo - is Undo enabled for Plus based votes
+ * - IsOn - is this vote object enabled
+ * - TriggerTable - table to be updated upon each vote
+ * - TriggerFieldId - TriggerTable table field with unique record id, primary key
+ * - TriggerFieldRate - TriggerTable table field with average rate
+ * - TriggerFieldRateCount - TriggerTable table field with votes count
+ * - ClassName - your custom class name, if you overrride default class
+ * - ClassFile - your custom class path
+ *
+ * You can refer to BoonEx modules for sample record in this table.
+ *
+ *
+ *
+ * @section example Example of usage:
+ * To get Star based vote you need to have different values for MinValue and MaxValue (for example 1 and 5)
+ * and IsUndo should be equal to 0. To get Plus(Like) based vote you need to have equal values
+ * for MinValue and MaxValue (for example 1) and IsUndo should be equal to 1. After filling the other
+ * paramenters in the table you can show vote in any place, using the following code:
+ * @code
+ * $o = BxDolScore::getObjectInstance('system object name', $iYourEntryId);
+ * if (!$o->isEnabled()) return '';
+ *     echo $o->getElementBlock();
+ * @endcode
+ *
+ *
+ * @section acl Memberships/ACL:
+ * - vote
+ *
+ *
+ *
+ * @section alerts Alerts:
+ * Alerts type/unit - every module has own type/unit, it equals to ObjectName.
+ * The following alerts are rised:
+ *
+ * - rate - comment was posted
+ *      - $iObjectId - entry id
+ *      - $iSenderId - rater user id
+ *      - $aExtra['rate'] - rate
+ *
+ */
+
+class BxDolScore extends BxDolObjectVote
+{
+    protected static $_sCounterStyleSimple = 'simple'; // total counter only.
+    protected static $_sCounterStyleDivided = 'divided'; // counters [icon + counter] divided by action (up/down).
+
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct($sSystem, $iId, $iInit, $oTemplate);
+        if(empty($this->_sSystem))
+            return;
+
+        $this->_oQuery = new BxDolScoreQuery($this);
+    }
+
+    /**
+     * get votes object instanse
+     * @param $sSys vote object name
+     * @param $iId associated content id, where vote is available
+     * @param $iInit perform initialization
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstance($sSys, $iId, $iInit = true, $oTemplate = false)
+    {
+        $sKey = 'BxDolScore!' . $sSys . $iId . ($oTemplate ? $oTemplate->getClassName() : '');
+        if(isset($GLOBALS['bxDolClasses'][$sKey]))
+            return $GLOBALS['bxDolClasses'][$sKey];
+
+        $aSystems = self::getSystems();
+        if(!isset($aSystems[$sSys]))
+            return null;
+
+        $sClassName = 'BxTemplScore';
+        if(!empty($aSystems[$sSys]['class_name'])) {
+            $sClassName = $aSystems[$sSys]['class_name'];
+            if(!empty($aSystems[$sSys]['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSystems[$sSys]['class_file']);
+        }
+
+        $o = new $sClassName($sSys, $iId, $iInit, $oTemplate);
+        return ($GLOBALS['bxDolClasses'][$sKey] = $o);
+    }
+
+    public static function &getSystems()
+    {
+        $sKey = 'bx_dol_cache_memory_score_systems';
+
+        if(!isset($GLOBALS[$sKey]))
+            $GLOBALS[$sKey] = BxDolDb::getInstance()->fromCache('sys_objects_score', 'getAllWithKey', '
+                SELECT
+                    `id` as `id`,
+                    `name` AS `name`,
+                    `module` AS `module`,
+                    `table_main` AS `table_main`,
+                    `table_track` AS `table_track`,
+                    `post_timeout` AS `post_timeout`,
+                    `pruning` AS `pruning`,
+                    `is_undo` AS `is_undo`,
+                    `is_on` AS `is_on`,
+                    `trigger_table` AS `trigger_table`,
+                    `trigger_field_id` AS `trigger_field_id`,
+                    `trigger_field_author` AS `trigger_field_author`,
+                    `trigger_field_score` AS `trigger_field_score`,
+                    `trigger_field_cup` AS `trigger_field_cup`,
+                    `trigger_field_cdown` AS `trigger_field_cdown`,
+                    `class_name` AS `class_name`,
+                    `class_file` AS `class_file`
+                FROM `sys_objects_score`', 'name');
+
+        return $GLOBALS[$sKey];
+    }
+
+    public function isPerformed($iObjectId, $iAuthorId, $iAuthorIp = 0)
+    {
+        return parent::isPerformed($iObjectId, $iAuthorId) && !$this->_oQuery->isPostTimeoutEnded($iObjectId, $iAuthorId, $iAuthorIp);        
+    }
+
+
+    /**
+     * Interface functions for outer usage
+     */
+    public function getStatCounterUp()
+    {
+        $aScore = $this->_getVote();
+        return $aScore['count_up'];
+    }
+
+    public function getStatCounterDown()
+    {
+        $aScore = $this->_getVote();
+        return $aScore['count_down'];
+    }
+
+    public function getStatScore()
+    {
+        $aScore = $this->_getVote();
+        return $aScore['score'];
+    }
+
+    public function getSocketName()
+    {
+        return $this->_sSystem . '_scores';
+    }
+
+    /**
+     * Actions functions
+     */
+    public function actionVoteUp()
+    {
+        if(!$this->isEnabled())
+            return echoJson(array('code' => 1, 'message' => _t('_sys_score_err_not_enabled')));
+        
+        $aVoteData = ['type' => BX_DOL_SCORE_DO_UP];
+        $aRequestParamsData = $this->_getRequestParamsData();
+        
+        return echoJson($this->vote($aVoteData, $aRequestParamsData));
+    }
+
+    public function actionVoteDown()
+    {
+        if(!$this->isEnabled())
+            return echoJson(array('code' => 1, 'message' => _t('_sys_score_err_not_enabled')));
+
+        $aVoteData = ['type' => BX_DOL_SCORE_DO_DOWN];
+        $aRequestParamsData = $this->_getRequestParamsData();
+
+        return echoJson($this->vote($aVoteData, $aRequestParamsData));
+    }
+
+    public function actionGetVotedBy()
+    {
+        if (!$this->isEnabled())
+           return '';
+
+        $aParams = $this->_getRequestParamsData();
+
+        if(($sType = bx_get('type')) !== false) {
+            $sType = bx_process_input($sType);
+
+            if(in_array($sType, [BX_DOL_SCORE_DO_UP, BX_DOL_SCORE_DO_DOWN]))
+                $aParams['type'] = $sType;
+        }
+
+        return $this->_getVotedBy($aParams);
+    }
+
+    public function vote($aVoteData = [], $aRequestParamsData = [])
+    {
+        if(!$this->isAllowedVote(true))
+            return ['code' => BX_DOL_OBJECT_ERR_ACCESS_DENIED, 'message' => $this->msgErrAllowedVote()];
+
+        $sType = $aVoteData['type'];
+        $iObjectId = $this->getId();
+        $iObjectAuthorId = $this->getObjectAuthorId($iObjectId);
+        $iAuthorId = $this->_getAuthorId();
+        $iAuthorIp = $this->_getAuthorIp();
+
+        $bUndo = $this->isUndo();
+        $bVoted = $this->isPerformed($iObjectId, $iAuthorId, $iAuthorIp);
+        $bPerformUndo = $bVoted && $bUndo;
+
+        if(!$bPerformUndo && !$this->isAllowedVote())
+            return ['code' => BX_DOL_OBJECT_ERR_ACCESS_DENIED, 'message' => $this->msgErrAllowedVote()];
+
+        if($bVoted && !$bUndo)
+            return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_sys_score_err_duplicate_vote')];
+
+        if($bPerformUndo) {
+            $aTrack = $this->_getTrack($iObjectId, $iAuthorId);
+            if(!empty($aTrack) && is_array($aTrack))
+                $aVoteData = array_intersect_key($aTrack, $aVoteData);
+        }
+
+        $iId = $this->_putVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo);
+        if($iId === false)
+            return ['code' => BX_DOL_OBJECT_ERR_CANNOT_PERFORM];
+
+        if(!$bPerformUndo)
+            $this->isAllowedVote(true);
+
+        $this->_trigger();
+
+        $sTypeUc = ucfirst($sType);
+        $aAlertExtras = [
+            'object_author_id' => $iObjectAuthorId,
+            'source' => 'sys_score_' . $iId,
+            'source_mac' => 'sys_score_' . $iAuthorId . '_' . $iId,
+        ];
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-doVoteUp '{object_name}', 'doVoteUp' - hook after score vote 
+         * - $unit_name - score object name
+         * - $action - equals `doVoteUp`
+         * - $object_id - object id which got a vote
+         * - $sender_id - profile id who voted
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `score_id` - [int] vote id
+         *      - `score_author_id` - [int] profile id who voted
+         *      - `object_author_id` - [int] author id of the object which got a vote
+         * @hook @ref hook-bx_dol_score-doVoteUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-doVoteDown '{object_name}', 'doVoteDown' - hook after score vote 
+         * It's equivalent to @ref hook-bx_dol_score-doVoteUp
+         * @hook @ref hook-bx_dol_score-doVoteDown
+         */
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-undoVoteUp '{object_name}', 'undoVoteUp' - hook after undo score vote 
+         * It's equivalent to @ref hook-bx_dol_score-doVoteUp
+         * @hook @ref hook-bx_dol_score-undoVoteUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-undoVoteDown '{object_name}', 'undoVoteDown' - hook after undo score vote 
+         * It's equivalent to @ref hook-bx_dol_score-doVoteUp
+         * @hook @ref hook-bx_dol_score-undoVoteDown
+         */
+        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'doVote' . $sTypeUc, $iObjectId, $iAuthorId, array_merge([
+            'score_id' => $iId, 
+            'score_author_id' => $iAuthorId
+        ], $aAlertExtras));
+
+        /**
+         * @hooks
+         * @hookdef hook-score-doUp 'score', 'doUp' - hook after score vote 
+         * - $unit_name - equals `score`
+         * - $action - equals `doUp`
+         * - $object_id - score vote id
+         * - $sender_id - profile id who voted
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_system` - [string] vote object name
+         *      - `object_id` - [int] object id which got a vote
+         *      - `object_author_id` - [int] author id of the object which got a vote
+         * @hook @ref hook-score-doUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-score-doDown 'score', 'doDown' - hook after score vote 
+         * It's equivalent to @ref hook-score-doUp
+         * @hook @ref hook-score-doDown
+         */
+        /**
+         * @hooks
+         * @hookdef hook-score-undoUp 'score', 'undoUp' - hook after undo score vote 
+         * It's equivalent to @ref hook-score-doUp
+         * @hook @ref hook-score-undoUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-score-undoDown 'score', 'undoDown' - hook after undo score vote 
+         * It's equivalent to @ref hook-score-doUp
+         * @hook @ref hook-score-undoDown
+         */
+        bx_alert('score', ($bPerformUndo ? 'un' : '') . 'do' . $sTypeUc, $iId, $iAuthorId, array_merge([
+            'object_system' => $this->_sSystem, 
+            'object_id' => $iObjectId
+        ], $aAlertExtras));
+
+        $aRequestParamsData['show_script'] = false;
+
+        $bVoted = !$bVoted;
+        $aScore = $this->_getVote($iObjectId, true);
+        $iCup = (int)$aScore['count_up'];
+        $iCdown = (int)$aScore['count_down'];
+
+        $aResult = [
+            'code' => 0,
+            'type' => $sType,
+            'score' => $aScore['score'],
+            'scoref' => $iCup > 0 || $iCdown > 0 ? $this->_getCounterLabel($aScore['score'], $aRequestParamsData) : '',
+            'cup' => $iCup,
+            'cdown' => $iCdown,
+            'counter' => $this->getCounter($aRequestParamsData),
+            'label_icon' => $this->_getIconDo($sType),
+            'label_title' => _t($this->_getTitleDo($sType)),
+            'voted' => $bVoted,
+            'disabled' => $bVoted && !$bUndo,
+        ];
+
+        $aResult['api'] = [
+            'ts' => microtime(true),
+            'performer_id' => $iAuthorId,
+            'is_voted' => $aResult['voted'], //TODO: Remove after 14.0.0-RC3 is released.
+            'is_disabled' => $aResult['disabled'], //TODO: Remove after 14.0.0-RC3 is released.
+            $sType => [
+                'is_voted' => $aResult['voted'],
+                'is_disabled' => $aResult['disabled'],
+                'icon' => $aResult['label_icon'],
+                'title' => $aResult['label_title'],
+            ],
+            'counter' => $this->getVote()
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled())
+            $oSockets->sendEvent($this->getSocketName(), $iObjectId, 'voted', json_encode($this->_returnVoteDataForSocket($aResult)));
+
+        return $aResult;
+    }
+
+    /**
+     * Internal functions
+     */
+    protected function _isAllowedVoteByObject($aObject)
+    {
+        return bx_srv($this->_aSystem['module'], 'check_allowed_view_for_profile', [$aObject]) === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    protected function _putVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bPerformUndo)
+    {
+        return $this->_oQuery->putVote($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bPerformUndo);
+    }
+
+    protected function _returnVoteDataForSocket($aData, $aMask = [])
+    {
+        if(empty($aMask) || !is_array($aMask))
+            $aMask = ['code', 'type', 'score', 'scoref', 'cup', 'cdown', 'counter', 'api'];
+
+        return array_intersect_key($aData, array_flip($aMask));
+    }
+
+    protected function _isVote($iObjectId = 0, $bForceGet = false)
+    {
+        $aScore = $this->_getVote($iObjectId, $bForceGet);
+
+        return $this->_isCount($aScore);
+    }
+
+    protected function _isCount($aScore = [])
+    {
+        if(empty($aScore))
+            $aScore = $this->_getVote();
+
+        return (isset($aScore['count_up']) && (int)$aScore['count_up'] != 0) || (isset($aScore['count_down']) && (int)$aScore['count_down'] != 0);
+    }
+
+    /**
+     * Note. By default image based controls aren't used.
+     * Therefore it can be overwritten in custom template.
+     */
+    protected function _getImageDo($sType)
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_DOL_SCORE_DO_UP:
+                $sResult = '';
+                break;
+            case BX_DOL_SCORE_DO_DOWN:
+                $sResult = '';
+                break;
+        }
+
+    	return $sResult;
+    }
+
+    protected function _getIconDo($sType = '')
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_DOL_SCORE_DO_UP:
+                $sResult = 'arrow-up';
+                break;
+
+            case BX_DOL_SCORE_DO_DOWN:
+                $sResult = 'arrow-down';
+                break;
+
+            default:
+                $sResult = 'arrows-alt-v';
+        }
+
+    	return $sResult;
+    }
+
+    protected function _getTitleDo($sType)
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_DOL_SCORE_DO_UP:
+                $sResult = '_sys_score_do_up';
+                break;
+            case BX_DOL_SCORE_DO_DOWN:
+                $sResult = '_sys_score_do_down';
+                break;
+        }
+
+    	return $sResult;
+    }
+
+    protected function _getTitleDoBy()
+    {
+    	return '_sys_score_do_by';
+    }
+
+    protected function _encodeElementParams($aParams)
+    {
+        if(empty($aParams) || !is_array($aParams))
+            return '';
+
+        return urlencode(base64_encode(json_encode($aParams)));
+    }
+
+    protected function _decodeElementParams($sParams, $bMergeWithDefaults = true)
+    {
+        $aParams = array();
+        if(!empty($sParams))
+            $aParams = json_decode(base64_decode(urldecode($sParams)), true);
+
+        if(empty($aParams) || !is_array($aParams))
+            $aParams = array();
+
+        if($bMergeWithDefaults)
+            $aParams = array_merge($this->_aElementDefaults, $aParams);
+
+        return $aParams;
+    }
+}
+
+/** @} */

+ 1543 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolSearch.php

@@ -0,0 +1,1543 @@
+<?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
+ * @{
+ */
+
+define('BX_DOL_SEARCH_RESULTS_PER_PAGE_DEFAULT', 10);
+define('BX_DOL_SEARCH_KEYWORD_PAGE', 'site-search-page');
+
+/**
+ * Search class for different content.
+ *
+ * Allows present content in main search area for all modules.
+ *
+ * To add taking part content from your module to search area you need add a record to `sys_objects_search` table:
+ * @code
+ *   ID - autoincremented id for internal usage
+ *   ObjectName - your unique module name, with vendor prefix, lowercase and spaces are underscored
+ *   Title - language key for module's choicer in global search form
+ *   ClassName - class name which is responsible for search process ad showing result for found results
+ *   ClassPath - file where your ClassName is stored (if class is stored in template folder then you can use key {tmpl})
+ * @endcode
+ *
+ * For using this class you should have unit of BxDolSearchResult class (see description below) with method processing which is responsible for processing of search request.
+ *
+ * Example of usage can be seen in the BoonEx modules, for example: Posts
+ *
+ * Memberships/ACL:
+ * Doesn't depend on user's membership.
+ *
+ * Alerts:
+ * no alerts available
+ *
+ */
+class BxDolSearch extends BxDol
+{
+    protected $_bIsApi;
+        
+    protected $aClasses = array(); ///< array of all search classes
+    protected $aChoice  = array(); ///< array of current search classes which were choosen in search area
+    protected $_bRawProcessing = false; ///< display search results without design box and paginate
+    protected $_bDataProcessing = false; ///< return result as array of data
+
+    protected $_bLiveSearch = false;
+    protected $_sMetaType = '';
+    protected $_sCategoryObject = ''; ///< by default = object from sys_objects_category table, for multicategories set to 'multi' string 
+    protected $_aCustomSearchCondition = array();
+    protected $_aCustomCurrentCondition = array();
+    protected $_sUnitTemplate = '';
+
+    /**
+     * Constructor
+     * @param array $aChoice - array of choosen classes (will take a part only existing in `sys_objects_search` table)
+     */
+    public function __construct ($aChoice = '')
+    {
+        parent::__construct();
+
+        $this->_bIsApi = bx_is_api();
+
+        $this->aClasses = self::getSections();
+
+        if (is_array($aChoice) && !empty($aChoice)) {
+            foreach ($aChoice as $sValue) {
+                if (isset($this->aClasses[$sValue]))
+                    $this->aChoice[$sValue] = $this->aClasses[$sValue];
+            }
+        } else {
+            $this->aChoice = $this->aClasses;
+        }
+    }
+
+    public static function getSections($bGlobal = null) 
+    {
+        $sWhere = '';
+        if ($bGlobal != null)
+            $sWhere = ' AND `GlobalSearch` = ' . ($bGlobal ? '1' : '0');
+
+        return BxDolDb::getInstance()->fromCache('sys_objects_search', 'getAllWithKey',
+           'SELECT `ID` as `id`,
+                   `Title` as `title`,
+                   `ClassName` as `class`,
+                   `ClassPath` as `file`,
+                   `ObjectName`,
+                   `GlobalSearch`
+            FROM `sys_objects_search`
+            WHERE 1 ' . $sWhere . '
+            ORDER BY `Order` ASC', 'ObjectName'
+        );
+    }
+
+    public function setApiOutput($bIsApi)
+    {
+        $this->_bIsApi = $bIsApi;
+    }
+
+    /**
+     * create units for all classes and calling their processing methods
+     */
+    public function response ()
+    {
+        $sCode = $this->_bDataProcessing ? [] : '';
+
+        $bSingle = count($this->aChoice) == 1;
+        foreach($this->aChoice as $sKey => $aValue) {
+            if(!$this->_sMetaType && !$aValue['GlobalSearch'])
+                continue;
+
+            $sClassName = 'BxTemplSearchResult';
+            if(!empty($aValue['class'])) {
+                $sClassName = $aValue['class'];
+                if(!empty($aValue['file']))
+                    require_once(BX_DIRECTORY_PATH_ROOT . $aValue['file']);
+            }
+
+            $oEx = new $sClassName();            
+            if($this->_sMetaType && !$oEx->isMetaEnabled($this->_sMetaType))
+                continue;
+
+            $oEx->setId($aValue['id']);
+            $oEx->setLiveSearch($this->_bLiveSearch);
+            $oEx->setMetaType($this->_sMetaType);
+            $oEx->setCategoryObject($this->_sCategoryObject);
+            $oEx->setCenterContentUnitSelector(false);
+            $oEx->setSingleSearch($bSingle);
+            $oEx->setCustomSearchCondition($this->_aCustomSearchCondition);
+            
+            $oEx->aCurrent = array_merge_recursive($oEx->aCurrent, $this->_aCustomCurrentCondition);
+            if($this->_sUnitTemplate)
+                $oEx->setUnitTemplate($this->_sUnitTemplate);
+
+            if($this->_bDataProcessing) {
+                if($this->_bIsApi) {
+                    if($bSingle) {
+                        $sMethod = 'decodeDataAPI';
+                        if($this->_bLiveSearch && ($sMethodLs = 'decodeLsDataAPI') && method_exists($oEx, $sMethodLs))
+                            $sMethod = $sMethodLs;
+
+                        $sCode = $oEx->$sMethod($oEx->getSearchData());
+                    }
+                    else
+                        $sCode[$sKey] = $oEx->getSearchQuery($sKey);
+                }
+                else
+                    $sCode[$sKey] = $oEx->getSearchData();
+            }
+            else
+                $sCode .= $this->_bRawProcessing ? $oEx->processingRaw() : $oEx->processing();
+        }
+
+        if($this->_bIsApi && !$bSingle && is_array($sCode)) {
+            $bExtendedUnits = getParam('sys_api_extended_units') == 'on';
+
+            $aQueries = [];
+            foreach($sCode as $aQuery)
+                if(!empty($aQuery['query']))
+                    $aQueries[] = trim($aQuery['query'] . ' ' . $aQuery['order'] . ' ' . $aQuery['limit']);
+
+            $aItems = BxDolDb::getInstance()->getAll('(' . implode(') UNION (', $aQueries) . ')');
+
+            $sCode = [];
+            foreach($aItems as $aItem)
+                if(($oContentInfo = BxDolContentInfo::getObjectInstance($aItem['content_info'])))
+                    $sCode[] = $oContentInfo->getContentInfoAPI($aItem['id'], $bExtendedUnits);
+        }
+
+        return $sCode;
+    }
+
+    public function getEmptyResult ()
+    {
+        $sKey = _t('_Empty');
+        return DesignBoxContent($sKey, MsgBox($sKey), BX_DB_PADDING_DEF);
+    }
+
+    public function getSearchResultObject($sChoice = '')
+    {
+        $aSearchResult = [];
+
+        if($sChoice) {
+            if(empty($this->aChoice[$sChoice]) || !is_array($this->aChoice[$sChoice]))
+                return false;
+
+            $aSearchResult = $this->aChoice[$sChoice];
+        }
+        else
+            $aSearchResult = reset($this->aChoice);
+
+        $sClassName = 'BxTemplSearchResult';
+        if(!empty($aSearchResult['class'])) {
+            $sClassName = $aSearchResult['class'];
+            if(!empty($aSearchResult['file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSearchResult['file']);
+        }
+
+        return new $sClassName();
+    }
+
+    protected function getKeyTitlesPairs ()
+    {
+        $a = array();
+        foreach ($this->aChoice as $sKey => $r)
+            if ($this->_sMetaType || $r['GlobalSearch'])
+                $a[$sKey] = _t($r['title']);
+        return $a;
+    }
+
+    public function setLiveSearch($bLiveSearch)
+    {
+        $this->_bLiveSearch = $bLiveSearch;
+    }
+
+    public function setMetaType($s)
+    {
+        $aMetaTypes = array('location_country', 'location_country_city', 'location_country_state', 'mention', 'keyword');
+        if (in_array($s, $aMetaTypes))
+            $this->_sMetaType = $s;
+    }
+    
+    public function setCategoryObject($s)
+    {
+        $this->_sCategoryObject = $s;
+    }
+
+    /**
+     * Set custom search condition to use istead of GET/POST variables
+     * @param $a array of params, such as 'keyword', 'state', 'city'
+     * @return nothing
+     */
+    public function setCustomSearchCondition($a)
+    {
+        $this->_aCustomSearchCondition = $a;
+    }
+
+    /**
+     * Set custom data for aCurrent array
+     * @param $a array of params
+     * @return nothing
+     */
+    public function setCustomCurrentCondition($a)
+    {
+        $this->_aCustomCurrentCondition = $a;
+    }
+
+    /**
+     * Set custom unit template
+     * @param $s template name
+     * @return nothing
+     */
+    public function setUnitTemplate($s)
+    {
+        $this->_sUnitTemplate = $s;
+    }
+    
+    /**
+     * Display search results without design box and paginate
+     */ 
+    public function setRawProcessing($b)
+    {
+        $this->_bRawProcessing = $b;
+    }
+
+    /**
+     * Return search result as array of data
+     */ 
+    public function setDataProcessing($b)
+    {
+        $this->_bDataProcessing = $b;
+    }
+}
+
+/*
+ * Search class for processing search requests and displaying search results.
+ *
+ * Allows present content from modules on search params or internal (via fields of class) conditions.
+ *
+ * Example of usage (you can see example in any of default Dolhpin's modules):
+ *
+ * 1. Extends your own search class from this one (or from BxBaseSearchResult or BxBaseSearchResultSharedMedia classes)
+ * 2. Set necessary fields of class (using as example BxFilesSearch):
+ *
+ * @code
+ *
+ *  // main field of search class
+ *  $this->aCurrent = array(
+ *
+ *      // name of module
+ *      'name' => 'bx_files',
+ *
+ *      // language key with name of module
+ *      'title' => '_bx_files',
+ *
+ *      // main content table
+ *      'table' => 'bx_files_main',
+ *
+ *      // array of all fields which can be choosen for select in result query
+ *      'ownFields' => array('ID', 'Title', 'Uri', 'Desc', 'Date', 'Size', 'Ext', 'Views', 'Rate', 'RateCount', 'Type'),
+ *
+ *      // array of fields which take a part in search by keyword (global search and related words only - in other cases leave it blank)
+ *      'searchFields' => array('Title', 'Tags', 'Desc', 'Categories'),
+ *
+ *      // array of join tables
+ *      //   'type' - type of join
+ *      //   'table' - join table
+ *      //   'mainField' - field from main table for 'on' condition
+ *      //   'onField' - field from joining table for 'on' condition
+ *      //   'joinFields' - array of fields from joining table
+ *
+ *      'join' => array(
+ *          'profile' => array(
+ *              'type' => 'left',
+ *              'table' => 'Profiles',
+ *              'mainField' => 'Owner',
+ *              'onField' => 'ID',
+ *              'joinFields' => array('NickName'),
+ *          ),
+ *          // ...
+ *      ),
+ *
+ *      // array of search parameters
+ *      //   'value' - value of search parameter (can be number, string or array)
+ *      //   'field' - field which will take value of search, may be lefy as blank then in search query will be pasted to WHERE conditon via operator AGAINST
+ *      //   'operator' - operator between field and value (can be 'in', 'not in', '>', '<', 'against', 'like' and '=' by default),
+ *      //   'paramName' - GET param which will keep 'value' for pagianation
+ *
+ *      'restriction' => array(
+ *          'activeStatus' => array('value'=>'approved', 'field'=>'Status', 'operator'=>'=', 'paramName' => 'status'),
+ *          'albumType' => array('value'=>'', 'field'=>'Type', 'operator'=>'=', 'paramName'=>'albumType', 'table'=>'sys_albums'),
+ *      ),
+ *
+ *      // array of pagination
+ *      //   'perPage' - units per page
+ *      //   'start' - show search results starting from this number
+ *      //   'num' - number of element to show on current page + 1
+ *
+ *      'paginate' => array('perPage' => 10, 'start' => 0, 'num' => 11),
+ *
+ *      // sort mode - by default it was last (DESC by date)
+ *      'sorting' => 'last',
+ *
+ *      // mode of units presentation in search result, can be 'short' or 'full' view
+ *      'view' => 'full',
+ *
+ *      // field of id
+ *      'ident' => 'ID',
+ *
+ *      // rss feed array
+ *      'rss' => array(
+ *          'title' => '',
+ *          'link' => '',
+ *          'image' => '',
+ *          'profile' => 0,
+ *          'fields' => array (
+ *              'Link' => '',
+ *              'Title' => 'Title',
+ *              'DateTimeUTS' => 'Date',
+ *              'Desc' => 'Desc',
+ *              'Photo' => '',
+ *          ),
+ *      ),
+ *  );
+ *
+ *  // array of fields renamings
+ *  $aPseud - filling in costructor by function _getPseud
+ *
+ *  // unique identificator from `sys_objects_search` table
+ *  $id;
+ *
+ * @endcode
+ *
+ * Memberships/ACL:
+ * Doesn't depend on user's membership.
+ *
+ * Alerts:
+ * no alerts available
+ *
+ */
+
+class BxDolSearchResult implements iBxDolReplaceable
+{
+    public $aCurrent; ///< search results configuration
+
+    protected $aPseud; ///< array of fields renamings
+    protected $id; ///< unique identificator of block on mixed search results page
+    protected $bDisplayEmptyMsg = false; ///< display empty message instead of nothing, when no results
+    protected $sDisplayEmptyMsgKey = ''; ///< custom empty message language key, instead of default "empty" message
+    protected $bProcessPrivateContent = true; ///< check each item for privacy, if view isn't allowed then display private view instead
+    protected $aPrivateConditionsIndexes = array('restriction' => array(), 'join' => array()); ///< conditions indexes for bProcessPrivateContent
+    protected $bForceAjaxPaginate = false; ///< force ajax paginate
+    protected $iPaginatePerPage = BX_DOL_SEARCH_RESULTS_PER_PAGE_DEFAULT; ///< default 'per page' value for paginate.
+
+    protected $_bIsApi;
+    protected $_bSingleSearch = true;
+    protected $_bLiveSearch = false;
+    protected $_sMetaType = '';
+    protected $_sMode = '';
+    protected $_aParams = [];
+    protected $_sCategoryObject = '';
+    protected $_aCustomSearchCondition = array();
+
+    protected $_aMarkers = array (); ///< markers to replace somewhere, usually title and browse url (defined in custom class)
+    
+    /**
+     * constructor
+     * filling identificator field
+     */
+    function __construct ()
+    {
+        $this->_bIsApi = bx_is_api();
+
+        if (isset($this->aPseud['id']))
+            $this->aCurrent['ident'] = $this->aPseud['id'];
+    }
+
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    public function setId($sId)
+    {
+        $this->id = $sId;
+    }
+
+    public function getModuleName()
+    {
+        return isset($this->aCurrent['module_name']) ? $this->aCurrent['module_name'] : '';
+    }
+
+    public function getContentInfoName()
+    {
+        return isset($this->aCurrent['name']) ? $this->aCurrent['name'] : '';
+    }
+
+    public function setAjaxPaginate($b = true)
+    {
+        $this->bForceAjaxPaginate = $b;
+    }
+
+    public function setPaginatePerPage($iPerPage)
+    {
+        $this->iPaginatePerPage = $iPerPage;
+    }
+
+    public function setSingleSearch($bSingleSearch)
+    {
+        $this->_bSingleSearch = $bSingleSearch;
+    }
+
+    public function setLiveSearch($bLiveSearch)
+    {
+        $this->_bLiveSearch = $bLiveSearch;
+    }
+
+    public function setMetaType($s)
+    {
+        $this->_sMetaType = $s;
+    }
+
+    public function isMetaEnabled($s)
+    {
+        if (empty($this->aCurrent['object_metatags']))
+            return false;
+            
+        if (!($o = BxDolMetatags::getObjectInstance($this->aCurrent['object_metatags'])))
+            return false;
+
+        switch ($s) {
+            case 'location_country_city':
+            case 'location_country_state':            
+            case 'location_country':
+                return $o->locationsIsEnabled();
+            case 'mention':
+                return $o->mentionsIsEnabled();
+            case 'keyword':
+                return $o->keywordsIsEnabled();
+        }
+
+        return false;
+    }
+    
+    public function setCategoryObject($s)
+    {
+        $this->_sCategoryObject = $s;
+    }
+
+    public function setCustomSearchCondition($a)
+    {
+        $this->_aCustomSearchCondition = $a;
+    }
+    
+    public function setCustomCurrentCondition($a)
+    {
+        $this->aCurrent = array_merge_recursive($this->aCurrent, $a);
+    }
+
+    public function setCategoriesCondition($sKeyword)
+    {
+        $this->aCurrent['join']['multicat'] = [
+            'type' => 'INNER',
+            'table' => 'sys_categories2objects',
+            'mainField' => 'id',
+            'onField' => 'object_id',
+            'joinFields' => [],
+        ];
+
+        if(isset($this->aCurrent['tableSearch']))
+            $this->aCurrent['join']['multicat']['mainTable'] = $this->aCurrent['tableSearch'];
+
+        $this->aCurrent['join']['multicat2'] = [
+            'type' => 'INNER',
+            'table' => 'sys_categories',
+            'mainField' => 'category_id',
+            'mainTable' => 'sys_categories2objects',
+            'onField' => 'id',
+            'joinFields' => []
+        ];
+
+        $this->aCurrent['restriction']['multicat'] = [
+           'value' => $sKeyword, 
+           'field' => 'value', 
+           'operator' => '=', 
+           'table' => 'sys_categories'
+        ];
+    }
+    
+    /**
+     * Display empty message if there is no content, custom empty message can be used.
+     * @param $b - boolan value to enable or disable 'empty' message
+     * @param $sLangKey [optional] - custom 'empty' message
+     */
+    public function setDisplayEmptyMsg($b, $sLangKey = '')
+    {
+        $this->bDisplayEmptyMsg = $b;
+        if ($sLangKey)
+           $this->sDisplayEmptyMsgKey = $sLangKey;
+    }
+
+    /**
+     * Perform privacy checking for every unit
+     * @param $b - boolan value to enable or disable privacy checking
+     */
+    public function setProcessPrivateContent ($b)
+    {
+        $this->bProcessPrivateContent = $b;
+        if ($b) {
+            // unset condition which was set when 'bProcessPrivateContent' was set to 'false'
+            foreach ($this->aPrivateConditionsIndexes['restriction'] as $sKey) 
+                unset($this->aCurrent['restriction'][$sKey]);
+            foreach ($this->aPrivateConditionsIndexes['join'] as $sKey)
+                unset($this->aCurrent['join'][$sKey]);
+        }
+    }
+
+    /**
+     * Get search results without design box and paginate
+     * @return html code
+     */
+    function processingRaw ()
+    {
+        $sCode = $this->displayResultBlock();
+        return $this->aCurrent['paginate']['num'] > 0 ? $sCode : '';
+    }
+    
+    /**
+     * Get html box of search results (usually used in grlobal search)
+     * @return html code
+     */
+    function processing ()
+    {
+        if($this->_bIsApi)
+            return $this->processingAPI();
+
+        $sCode = $this->displayResultBlock();
+        if ($this->aCurrent['paginate']['num'] > 0) {
+            $sPaginate = $this->showPagination();
+            $sCode = $this->displaySearchBox($sCode, $sPaginate);
+        }
+        else {
+            $sCode = $this->bDisplayEmptyMsg ? $this->displaySearchBox($this->displayResultBlockEmpty()) : '';
+        }
+
+        return $sCode;
+    }
+
+    function processingAPI ($bForceGetData = false)
+    {
+        $sModule = 'system';
+        $sUnitType = 'content';
+        $sHomeUrl = '';
+        
+        if(!empty($this->oModule)) {
+            $sModule = $this->oModule->getName();
+
+            if(method_exists($this->oModule, 'serviceActAsProfile') && $this->oModule->serviceActAsProfile())
+                 $sUnitType = 'profile';
+            if(method_exists($this->oModule, 'serviceIsGroupProfile') && $this->oModule->serviceIsGroupProfile())
+                 $sUnitType = 'context';
+            
+            $sHomeUrl = bx_api_get_relative_url(BxDolPermalinks::getInstance()->permalink($this->oModule->_oConfig->CNF['URL_HOME']));
+        }
+
+        $sUnit =  'list';
+        if(in_array($this->sUnitViewDefault, ['showcase', 'gallery']))
+            $sUnit = 'card';
+        
+        $aData = defined('BX_API_PAGE') && !$bForceGetData ? [] : $this->decodeDataAPI($this->getSearchData());
+
+        $aParams =  [
+            'type' => $this->_sMode,
+            'start' => $this->aCurrent['paginate']['start'],
+            'per_page' => $this->aCurrent['paginate']['perPage'],
+            'view' => $this->_aParams['unit_view'] ?? '',
+        ];
+
+        if($sHomeUrl)
+            $aParams['home_url'] = $sHomeUrl;
+
+        if(($sK = 'class_search_result') && isset($this->_aParams[$sK]))
+            $aParams[$sK] = $this->_aParams[$sK];
+
+        $aParamsAdd = [];
+        if(($sMethod = 'addProcessingParamsAPI') && method_exists($this, $sMethod))
+            $aParamsAdd = $this->$sMethod();
+
+        foreach($aParamsAdd as $sParamAdd)
+            if(isset($this->_aParams[$sParamAdd]))
+                $aParams[$sParamAdd] = $this->_aParams[$sParamAdd];
+
+        return [
+            'module' => $sModule,
+            'unit' => 'general-' . $sUnitType . '-' . $sUnit,
+            'request_url' => !empty($this->aCurrent['api_request_url']) ? $this->aCurrent['api_request_url'] : '/api.php?r=' . $sModule . '/browse/&params[]=',
+            'data' =>  $aData,
+            'params' => $aParams
+        ];
+    }
+
+    function addProcessingParamsAPI()
+    {
+        return ['author', 'category', 'context', 'joined_profile', 'followed_contexts', 'level'];
+    }
+
+    function decodeDataAPI ($a)
+    {
+        return $a;
+    }
+
+    /**
+     * Get html output of search result
+     * @return html code
+     */
+    function displayResultBlock ()
+    {
+        $aData = $this->getSearchData();
+
+        $sCode = '';
+        if (count($aData) > 0) {
+            $sCode = $this->addCustomParts();
+            foreach ($aData as $iKey => $aValue) {
+                $sCode .= $this->displaySearchUnit($aValue);
+            }
+        }
+        return $sCode;
+    }
+
+    /**
+     * Get html output of empty search result
+     * @return html code
+     */
+    function displayResultBlockEmpty ()
+    {
+        return MsgBox(_t($this->sDisplayEmptyMsgKey ? $this->sDisplayEmptyMsgKey : '_Empty'));
+    }
+            
+    /**
+     * Add different code before html output (usually redeclared)
+     * no return result
+     */
+    function addCustomParts ()
+    {
+
+    }
+
+    /**
+     * Get XML string for rss output
+     */
+    function rss ()
+    {
+        if (!isset($this->aCurrent['rss']['fields']) || !isset($this->aCurrent['rss']['link']))
+            return '';
+
+        $aData = $this->getSearchData();
+        $f = &$this->aCurrent['rss']['fields'];
+        if ($aData) {
+            foreach ($aData as $k => $a) {
+                $aData[$k][$f['Link']] = $this->getRssUnitLink ($a);
+
+                if(isset($f['Image']))
+                    $aData[$k][$f['Image']] = $this->getRssUnitImage ($a, $f['Image']);
+            }
+        }
+
+        $oRss = new BxDolRssFactory ();
+
+        return $oRss->GenRssByCustomData(
+            $aData,
+            isset($this->aCurrent['rss']['title']) && $this->aCurrent['rss']['title'] ? $this->aCurrent['rss']['title'] : $this->aCurrent['title'],
+            $this->aCurrent['rss']['link'],
+            $this->aCurrent['rss']['fields'],
+            isset($this->aCurrent['rss']['image']) ? $this->aCurrent['rss']['image'] : '',
+            isset($this->aCurrent['rss']['profile']) ? $this->aCurrent['rss']['profile'] : 0
+        );
+    }
+
+    /**
+     * Output RSS XML with XML header
+     */
+    function outputRSS ()
+    {
+        header('Content-Type: text/xml; charset=UTF-8');
+        echo $this->rss();
+    }
+
+    /**
+     * Return rss unit link (redeclared)
+     */
+    function getRssUnitLink (&$a)
+    {
+        // override this functions to return permalink to rss unit
+    }
+
+	/**
+     * Return rss unit image (redeclared)
+     */
+    function getRssUnitImage (&$a, $sField)
+    {
+        // override this functions to return image for rss unit
+    }
+
+    /**
+     * Naming fields in query's body
+     * @param string  $sFieldName  name of field
+     * @param string  $sTableName  name of field's table
+     *                             $param string $sOperator of field's calculation (like MAX)
+     * @param boolean $bRenameMode indicator for renaming and unsetting fields from field of class $this->aPseud
+     *                             return $sqlUnit sql code and unsetting elements from aPseud field
+     */
+    function setFieldUnit ($sFieldName, $sTableName, $sOperator = '', $bRenameMode = true)
+    {
+        $bOperator = !empty($sOperator);
+
+        $sqlUnit  = "`$sTableName`.`$sFieldName`";
+        if($bOperator)
+            $sqlUnit = $sOperator . '(' . $sqlUnit . ')';
+
+        if(!empty($this->aPseud) && $bRenameMode !== false && ($sKey = array_search($sFieldName . ($bOperator ? '_' . strtolower($sOperator) : ''), $this->aPseud)) !== false) {
+            $sqlUnit .= " as `$sKey`";
+            unset($this->aPseud[$sKey]);
+        }
+
+        return $sqlUnit . ', ';
+    }
+
+    /**
+     * Get html code of of every search unit
+     * @param array $aData array of every search unit
+     *                     return html code
+     */
+    function displaySearchUnit ($aData)
+    {
+
+    }
+
+    /**
+     * Get html code of search box with search results
+     * @param string $sCode html code of search results
+     *                      $param $sPaginate html code of paginate
+     *                      return html code
+     */
+    function displaySearchBox ($sCode, $sPaginate = '')
+    {
+
+    }
+
+    /**
+     * Get html code of pagination
+     */
+    function showPagination ($bAdmin = false, $bChangePage = true, $bPageReload = true)
+    {
+
+    }
+
+    /**
+     * Get array of data with search results
+     * return array with data
+     */
+    function getSearchData ()
+    {
+        /**
+         * @hooks
+         * @hookdef hook-simple_search-before_get_data 'simple_search', 'before_get_data' - hook on before after get data 
+         * - $unit_name - equals `simple_search`
+         * - $action - equals `before_get_data` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object` - [array] by ref, array of parameters, can be overridden in hook processing
+         *      - `mode` - [string] search mode
+         * @hook @ref hook-simple_search-before_get_data
+         */
+        bx_alert('simple_search', 'before_get_data', 0, false, [
+            'object' => &$this->aCurrent, 
+            'object_ref' => &$this->aCurrent, 
+            'mode' => $this->_sMode
+        ]);
+
+        if(($this->aPseud = $this->_getPseudFromParam()) === false)
+            $this->aPseud = $this->_getPseud();
+
+        $this->setConditionParams();
+        $aData = $this->aCurrent['paginate']['num'] > 0 ? $this->getSearchDataByParams() : [];
+
+        /**
+         * @hooks
+         * @hookdef hook-simple_search-get_data 'simple_search', 'get_data' - hook on after get data 
+         * - $unit_name - equals `simple_search`
+         * - $action - equals `get_data` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object` - [array] by ref, array of parameters, can be overridden in hook processing
+         *      - `mode` - [string] search mode
+         *      - `search_object` - [string] search object name
+         * @hook @ref hook-simple_search-get_data
+         */
+        bx_alert('simple_search', 'get_data', 0, false, [
+            'object' => &$this->aCurrent, 
+            'object_ref' => &$this->aCurrent, 
+            'mode' => $this->_sMode, 
+            'search_results' => &$aData,
+            'search_results_ref' => &$aData
+        ]);
+
+        return $aData;
+    }
+
+    /**
+     * Get query [query, limit] for search results. Is used for combined search from different sections.
+     * @param type $aParams array with params
+     * @return type array with query and limit
+     */
+    function getSearchQuery($sObject, $aParams = [])
+    {
+        if(!is_array($aParams))
+            $aParams = [];
+        $aParams = array_merge($aParams, ['for_union' => true]);
+
+        $this->aPseud = [
+            'id' => !empty($this->aCurrent['ident']) ? $this->aCurrent['ident'] : 'id', 
+            'added' => !empty($this->aCurrent['added']) ? $this->aCurrent['added'] : 'added'
+        ];
+
+        $this->setConditionParams();
+        $aQuery = $this->getSearchDataByParams($aParams);
+
+        /**
+         * @hooks
+         * @hookdef hook-simple_search-get_query 'simple_search', 'get_query' - hook on get sql queries
+         * - $unit_name - equals `simple_search`
+         * - $action - equals `get_query` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object` - [array] by ref, array of parameters, can be overridden in hook processing
+         *      - `mode` - [string] search mode
+         *      - `search_object` - [string] search object name
+         *      - `search_query` - [array] by ref, array of sql, can be overridden in hook processing
+         * @hook @ref hook-simple_search-get_query
+         */
+        bx_alert('simple_search', 'get_query', 0, false, [
+            'object' => &$this->aCurrent, 
+            'object_ref' => &$this->aCurrent, 
+            'mode' => $this->_sMode, 
+            'search_object' => $sObject,
+            'search_query' => &$aQuery,
+            'search_query_ref' => &$aQuery
+        ]);
+
+        return $aQuery;
+    }
+
+    function getFieldsOwn()
+    {
+        return false;
+    }
+
+    function getFieldsJoin($sJoin)
+    {
+        return false;
+    }
+
+    /**
+     * Get array with code for sql elements
+     * @param $bRenameMode indicator of renmaing fields
+     * return array with joinFields, ownFields, groupBy and join elements
+     */
+    function getJoins ($bRenameMode = true)
+    {
+        if(!isset($this->aCurrent['join']) || !is_array($this->aCurrent['join']))
+            return false;
+
+        $aSql = [
+            'join' => '', 
+            'ownFields' => '', 
+            'joinFields' => '', 
+            'groupBy' => '', 
+            'groupHaving' => ''
+        ];
+
+        foreach($this->aCurrent['join'] as $sKey => $aValue) {
+            $sAlias = isset($aValue['table_alias']) ? $aValue['table_alias'] : $aValue['table'];
+            $sTableAlias = isset($aValue['table_alias']) ? " AS {$aValue['table_alias']} " : '';
+            $sOperator = isset($aValue['operator']) ? $aValue['operator'] : null;
+
+            // joinFields
+            $aJoinFields = $this->getFieldsJoin($sKey);
+            if($aJoinFields === false && !empty($aValue['joinFields']) && is_array($aValue['joinFields']))
+                $aJoinFields = $aValue['joinFields'];
+
+            if($aJoinFields)
+                foreach($aJoinFields as $mixedField) {
+                    list($sFldName, $sFldOperator) = is_array($mixedField) ? $mixedField : [$mixedField, $sOperator];
+
+                    $aSql['joinFields'] .= $this->setFieldUnit($sFldName, $sAlias, $sFldOperator, $bRenameMode);
+                }
+
+            // group by
+            if(isset($aValue['groupTable']))
+                $aSql['groupBy'] .= "`{$aValue['groupTable']}`.`{$aValue['groupField']}`, ";
+
+            // having
+            if(isset($aValue['groupHaving']))
+                $aSql['groupHaving'] .= $aValue['groupHaving'];
+
+            $sOn = isset($aValue['mainTable']) ? $aValue['mainTable'] : $this->aCurrent['table'];
+            $aSql['join'] .= " {$aValue['type']} JOIN `{$aValue['table']}` $sTableAlias ON " . (!empty($aValue['on_sql']) ? $aValue['on_sql'] : "`{$sAlias}`.`{$aValue['onField']}`=" . trim($this->setFieldUnit($aValue['mainField'], $sOn, isset($aValue['mainFieldFunc']) ? $aValue['mainFieldFunc'] : '', false), ", "));
+            $aSql['ownFields'] .= $this->setFieldUnit($aValue['mainField'], $sOn, '', $bRenameMode);
+        }
+
+        $aSql['joinFields'] = trim($aSql['joinFields'], ', ');
+
+        if(!empty($aSql['groupBy']))
+            $aSql['groupBy'] = trim('GROUP BY ' . $aSql['groupBy'], ', ');
+
+        if(!empty($aSql['groupHaving']))
+            $aSql['groupHaving'] = 'HAVING ' . $aSql['groupHaving'];
+
+        return $aSql;
+    }
+
+    /**
+     * Concat sql parts of query, run it and return result array
+     * @param $aParams addon param
+     * return $aData multivariate array
+     */
+    function getSearchDataByParams ($aParams = '')
+    {
+        $bForUnion = isset($aParams['for_union']) && $aParams['for_union'] === true;
+
+        $aSql = [
+            'ownFields' => '', 
+            'order' => ''
+        ];
+
+        // searchFields
+        if($bForUnion) {
+            $sTable = isset($this->aCurrent['tableSearch']) ? $this->aCurrent['tableSearch'] : $this->aCurrent['table'];
+
+            $aSql['ownFields'] .= $this->setFieldUnit(!empty($this->aCurrent['ident']) ? $this->aCurrent['ident'] : 'id', $sTable);
+            $aSql['ownFields'] .= $this->setFieldUnit(!empty($this->aCurrent['added']) ? $this->aCurrent['added'] : 'added', $sTable);
+            $aSql['ownFields'] .= "'" . $this->getContentInfoName()  . "' AS `content_info`";
+        }
+        else {
+            $aOwnFields = $this->getFieldsOwn();
+            if($aOwnFields === false)
+                $aOwnFields = $this->aCurrent['ownFields'];
+
+            foreach($aOwnFields as $mixedField) {
+                list($sFldName, $sFldOperator) = is_array($mixedField) ? $mixedField : [$mixedField, ''];
+
+                $aSql['ownFields'] .= $this->setFieldUnit($sFldName, $this->aCurrent['table'], $sFldOperator);
+            }
+        }
+
+        // joinFields & join
+        if(($aJoins = $this->getJoins()) !== false) {
+            if(!$bForUnion) {
+                $aSql['ownFields'] .= $aJoins['ownFields'];
+                $aSql['ownFields'] .= $aJoins['joinFields'];
+            }
+
+            $aSql['join'] = $aJoins['join'];
+            $aSql['groupBy'] = $aJoins['groupBy'];
+            $aSql['groupHaving'] = $aJoins['groupHaving'];
+        }
+
+        $aSql['ownFields'] = trim($aSql['ownFields'], ', ');
+
+        // from
+        $aSql['from'] = " FROM `{$this->aCurrent['table']}`";
+
+        // where
+        $aSql['where'] = $this->getRestriction();
+
+        // limit
+        $aSql['limit'] = $this->getLimit();
+
+        // sorting
+        $this->setSorting();
+
+        $aSort = $this->getSorting($this->aCurrent['sorting']);
+        foreach ($aSort as $sKey => $sValue)
+            $aSql[$sKey] .= $sValue;
+
+        // execution
+        $sqlQuery = 'SELECT';
+
+        if(isset($this->aCurrent['distinct']) && $this->aCurrent['distinct'] === true)
+            $sqlQuery .= ' DISTINCT';
+
+        $sqlQuery .= ' ' . $aSql['ownFields'];
+
+        $sqlQuery .= ' ' . $aSql['from'];
+
+        if (isset($aSql['join']))
+            $sqlQuery .= ' ' . $aSql['join'];
+
+        if (isset($aSql['where']))
+            $sqlQuery .= ' ' . $aSql['where'];
+
+        if (isset($aSql['groupBy']))
+            $sqlQuery .= ' ' . $aSql['groupBy'];
+
+        if (isset($aSql['groupHaving']))
+            $sqlQuery .= ' ' . $aSql['groupHaving'];
+
+        if($bForUnion)
+            return [
+                'query' => $sqlQuery, 
+                'order' => $aSql['order'] ?? '',
+                'limit' => $aSql['limit'] ?? ''
+            ];
+
+        if (isset($aSql['order']))
+            $sqlQuery .= ' ' . $aSql['order'];
+
+        if (isset($aSql['limit']))
+            $sqlQuery .= ' ' . $aSql['limit'];
+
+        //echoDbg($sqlQuery);
+        $aRes = BxDolDb::getInstance()->getAll($sqlQuery);
+        return $aRes;
+    }
+
+    /**
+     * Set class fields condition params and paginate array
+     */
+    function setConditionParams()
+    {
+        $this->aCurrent['paginate']['num'] = 0;
+
+        // keyword
+        $sKeyword = bx_process_input(isset($this->_aCustomSearchCondition['keyword']) ? $this->_aCustomSearchCondition['keyword'] : bx_get('keyword'));
+        $bKeyword = !empty($sKeyword);
+
+        if ($this->_bLiveSearch && !$bKeyword)
+            return;
+
+        if ($bKeyword) {
+            if (substr($sKeyword, 0, 1) == '@') {
+                $sModule = $this->aCurrent['module_name'];
+                $sMethod = 'act_as_profile';
+                if(!bx_is_srv($sModule, $sMethod) || !bx_srv($sModule, $sMethod))
+                    return;
+
+                $sKeyword = substr($sKeyword, 1);
+            }
+
+            $this->aCurrent['restriction']['keyword'] = array(
+                'value' => $sKeyword,
+                'field' => '',
+                'operator' => 'against'
+            );
+
+            // for 'keyword' search results (Site/Live search) we need to show all items, not only public content
+            if(!$this->_sCategoryObject)
+                $this->setProcessPrivateContent(true);
+        }
+
+        // owner
+        if (isset($_GET['ownerName'])) {
+            $sName = bx_process_input($_GET['ownerName']);
+            $iUser = (int)BxDolProfileQuery::getInstance()->getIdByNickname($sName);
+            BxDolMenu::getInstance()->setCurrentProfileID($iUser);
+        } elseif (isset($_GET['userID']))
+            $iUser = bx_process_input($_GET['userID'], BX_DATA_INT);
+
+        if (!empty($iUser))
+            $this->aCurrent['restriction']['owner']['value'] = $iUser;
+        
+        // meta info
+        if ($this->_sMetaType && !empty($this->aCurrent['object_metatags'])) {
+            $o = BxDolMetatags::getObjectInstance($this->aCurrent['object_metatags']);
+            if ($o) {
+                unset($this->aCurrent['restriction']['keyword']);
+
+                $aLocationString = [];
+                switch ($this->_sMetaType) {
+                    case 'location_country':
+                        $aLocationString['country'] = $sKeyword;
+                        $o->locationsSetSearchCondition($this, $sKeyword);
+                        break;
+                    case 'location_country_state':
+                        $aLocationString['country'] = $sKeyword;
+                        $aLocationString['state'] = bx_process_input(isset($this->_aCustomSearchCondition['state']) ? $this->_aCustomSearchCondition['state'] : bx_get('state'));
+                        $o->locationsSetSearchCondition($this, $sKeyword, $aLocationString['state']);
+                        break;
+                    case 'location_country_city':
+                        $aLocationString['country'] = $sKeyword;
+                        $aLocationString['state'] = bx_process_input(isset($this->_aCustomSearchCondition['state']) ? $this->_aCustomSearchCondition['state'] : bx_get('state'));
+                        $aLocationString['city'] = bx_process_input(isset($this->_aCustomSearchCondition['city']) ? $this->_aCustomSearchCondition['city'] : bx_get('city'));
+                        $o->locationsSetSearchCondition($this, $sKeyword, $aLocationString['state'], $aLocationString['city']);
+                        break;
+                    case 'location_state':
+                        $aLocationString['state'] = $sKeyword;
+                        $o->locationsSetSearchCondition($this, false, $sKeyword);
+                        break;
+                    case 'location_city':
+                        $aLocationString['city'] = $sKeyword;
+                        $o->locationsSetSearchCondition($this, false, false, $sKeyword);
+                        break;
+                    case 'mention':
+                        $oCmts = !empty($this->sModuleObjectComments) ? BxDolCmts::getObjectInstance($this->sModuleObjectComments, 0, false) : false;
+                        $o->mentionsSetSearchCondition($this, $sKeyword, $oCmts ? $oCmts->getSystemId() : 0);
+
+                        if(($oProfile = BxDolProfile::getInstance((int)$sKeyword)) !== false)
+                            $this->_setPageDescription('_sys_page_description_mention', $oProfile->getDisplayName(), $this->aCurrent['title']);
+                        break;
+                    case 'keyword':
+                        $oCmts = !empty($this->sModuleObjectComments) ? BxDolCmts::getObjectInstance($this->sModuleObjectComments, 0, false) : false;
+                        $o->keywordsSetSearchCondition($this, $sKeyword, $oCmts ? $oCmts->getSystemId() : 0);
+
+                        $this->_setPageDescription('_sys_page_description_hashtag', $sKeyword, $this->aCurrent['title']);
+                        break;
+                }
+
+                if(in_array($this->_sMetaType, ['location_country', 'location_country_state', 'location_country_city', 'location_state', 'location_city']))
+                    $this->_setPageDescription('_sys_page_description_location', $o->locationsStringFromArray($aLocationString, false), $this->aCurrent['title']);
+            }
+        }
+
+        // category
+        if($this->_sCategoryObject) {
+            if(($o = BxDolCategory::getObjectInstance($this->_sCategoryObject)) && $this->_bSingleSearch) {
+                if ($this->aCurrent['name'] == $o->getSearchObject()) {
+                    unset($this->aCurrent['restriction']['keyword']);
+                    $o->setSearchCondition($this, $sKeyword);
+                }
+
+                $this->_setPageDescription('_sys_page_description_category', $o->getCategoryTitle($sKeyword), $this->aCurrent['title']);
+            }
+
+            if($this->_sCategoryObject == 'multi'){
+                unset($this->aCurrent['restriction']['keyword']);
+                $this->setCategoriesCondition($sKeyword);
+            }
+        }
+
+        if($bKeyword && !$this->_sMetaType && !$this->_sCategoryObject)
+            $this->_setPageDescription('_sys_page_description_keyword', $sKeyword, $this->aCurrent['title']);
+
+        $this->setPaginate();
+        $iNum = $this->getNum();
+        if ($iNum > 0)
+            $this->aCurrent['paginate']['num'] = $iNum;
+    }
+
+    /**
+     * Check number of records on current page
+     * return number of records on current page + 1
+     */
+    function getNum ()
+    {
+        $aJoins = $this->getJoins(false);
+        $sqlQuery =  "SELECT * FROM `{$this->aCurrent['table']}` " . (isset($aJoins['join']) ? ' ' . $aJoins['join'] : '' ) . $this->getRestriction() . (isset($aJoins['groupBy']) ? ' ' . $aJoins['groupBy'] : '') . ' ' . $this->getLimit(true);
+        return count(BxDolDb::getInstance()->getAll($sqlQuery));
+    }
+
+    /**
+     * Get total number of records
+     * return total number of records
+     */
+    function getTotal ()
+    {
+        $aJoins = $this->getJoins(false);
+        $sqlQuery =  "SELECT COUNT(*) FROM `{$this->aCurrent['table']}` " . (isset($aJoins['join']) ? ' ' . $aJoins['join'] : '' ) . $this->getRestriction() . (isset($aJoins['groupBy']) ? ' ' . $aJoins['groupBy'] : '');
+        return BxDolDb::getInstance()->getOne($sqlQuery);
+    }
+
+    /**
+     * Check restriction params and make condition part of query
+     * return $sqlWhere sql code of query for WHERE part
+     */
+    function getRestriction ()
+    {
+        $oDb = BxDolDb::getInstance();
+        $sqlWhere = '';
+        if (isset($this->aCurrent['restriction'])) {
+            $aWhere[] = '1';
+            foreach ($this->aCurrent['restriction'] as $sKey => $aValue) {
+                $sqlCondition = '';
+                if (isset($aValue['operator']) && isset($aValue['value']) && $aValue['value'] !== '' && $aValue['value'] !== false && $aValue['value'] !== null) {
+                   $sFieldTable = isset($aValue['table']) ? $aValue['table'] : $this->aCurrent['table'];
+                   $sFieldName = isset($aValue['field']) ? $aValue['field'] : '';
+                   if(!$oDb->isValidFieldName($sFieldTable, true))
+                       continue;
+                   if($sFieldName !== '' && !$oDb->isValidFieldName($sFieldName, true))
+                       continue;
+                   if($sFieldName === '' && ($aValue['operator'] ?? '') !== 'against')
+                       continue;
+
+                   $sqlCondition = "`{$sFieldTable}`.`{$sFieldName}` ";
+                   switch ($aValue['operator']) {
+                       case 'empty value':
+                            $sqlCondition .= " = '' ";
+                            break;
+                       case 'not empty value':
+                            $sqlCondition .= " != '' ";
+                            break;
+                       case 'nothing':
+                            $sqlCondition = " 0 ";
+                            break;
+                       case 'against':
+                            $aCond = isset($aValue['field']) && strlen($aValue['field']) > 0 ? $aValue['field'] : $this->aCurrent['searchFields'];
+                            $sqlCondition = !empty($aCond) ? $this->getSearchFieldsCond($aCond, $aValue['value']) : "";
+                            break;
+                       case 'like':
+                            $sqlCondition .= "LIKE " . $oDb->escape('%' . $aValue['value'] . '%');
+                            break;
+                       case 'between':
+                            $sqlCondition = '';
+                            if(is_array($aValue['value'])) {
+                                $aRange = array_values($aValue['value']);
+                                $aParts = [];
+                                if(($mixedMin = $aRange[0] ?? '') !== '' && $mixedMin !== null)
+                                    $aParts[] = "`{$sFieldTable}`.`{$aValue['field']}` >= " . $oDb->escape($mixedMin);
+                                if(($mixedMax = $aRange[1] ?? '') !== '' && $mixedMax !== null)
+                                    $aParts[] = "`{$sFieldTable}`.`{$aValue['field']}` <= " . $oDb->escape($mixedMax);
+                                if($aParts)
+                                    $sqlCondition = '(' . implode(' AND ', $aParts) . ')';
+                            }
+                            break;
+                       case 'in':
+                       case 'not in':
+                            $sValuesString = $this->getMultiValues($aValue['value']);
+                            $sqlCondition .= strtoupper($aValue['operator']) . '(' . $sValuesString . ')';
+                            break;
+                       case 'in_set':
+                            $sqlCondition = "1 << (" . $sqlCondition . " - 1) & " . (int)$aValue['value'];
+                            break;
+                       default:
+                            if (!$oDb->isValidOperator($aValue['operator'])) {
+                                throw new Exception('Invalid operator in getRestriction method');
+                            }
+                            $sqlCondition .= $aValue['operator'] . (isset($aValue['no_quote_value']) && $aValue['no_quote_value'] ?  $aValue['value'] : $oDb->escape($aValue['value']));
+                            break;
+                    }
+                }
+                if (strlen($sqlCondition) > 0)
+                    $aWhere[] = $sqlCondition;
+            }
+            $sqlWhere .= "WHERE ". implode(' AND ', $aWhere) . (isset($this->aCurrent['restriction_sql']) ? $this->aCurrent['restriction_sql'] : '');
+        }
+
+        return $sqlWhere;
+    }
+
+    /**
+     * Get limit part of query
+     * return $sqlFrom code for limit part pf query
+     */
+    function getLimit ($isAddPlusOne = false)
+    {
+        if (!isset($this->aCurrent['paginate']))
+            return;
+
+        $sqlFrom = (int)$this->aCurrent['paginate']['start'] > 0 ? (int)$this->aCurrent['paginate']['start'] : 0;
+        $sqlTo = $this->aCurrent['paginate']['perPage'] + ($isAddPlusOne ? 1 : 0);
+        if ($sqlTo > 0)
+            return 'LIMIT ' . $sqlFrom . ', ' . $sqlTo;
+    }
+
+    /**
+     * Set sorting field of class
+     */
+    function setSorting ()
+    {
+        $this->aCurrent['sorting'] = isset($_GET[$this->aCurrent['name'] . '_mode']) ? $_GET[$this->aCurrent['name'] . '_mode'] : $this->aCurrent['sorting'];
+    }
+
+    /**
+     * Get sorting part of query according current sorting mode
+     * @param string $sSortType sorting type
+     *                          return array with sql elements order and ownFields
+     */
+    function getSorting ($sSortType = 'last')
+    {
+        if (isset($this->aCurrent['sorting_sql']))
+            return array('order' => $this->aCurrent['sorting_sql']);
+        
+        $aOverride = $this->getAlterOrder();
+        if (is_array($aOverride) && !empty($aOverride))
+            return $aOverride;
+
+       $aSql = array();
+       switch ($sSortType) {
+           case 'rand':
+                $aSql['order'] = "ORDER BY RAND()";
+                break;
+           case 'score':
+                if (is_array($this->aCurrent['restriction']['keyword'])) {
+                    $sPseud = '';
+                    if ('on' == getParam('useLikeOperator')) {
+                        $aSql['order'] = "ORDER BY `score` DESC";
+                        $sPseud = 'score';
+                    }
+                    $aSql['ownFields'] .= $this->getSearchFieldsCond($this->aCurrent['searchFields'], $this->aCurrent['restriction']['keyword']['value'], $sPseud) . ',';
+                }
+                break;
+           case 'none':
+                $aSql['order'] = "";
+                break;
+           default:
+                $aSql['order'] = "ORDER BY `date` DESC";
+        }
+        return $aSql;
+    }
+
+    /**
+     * Return own varaint for sorting (redeclare if necessary)
+     * return array of sql elements
+     */
+    function getAlterOrder ()
+    {
+        return array();
+    }
+
+    /**
+     * Set paginate fields of class according GET params 'start' and 'per_page'
+     * forcePage is need for setting most important number of current page
+     */
+    function setPaginate ()
+    {
+        $iStart = 0;
+        $iPerPage = 0;
+
+        //--- Check in aCurrent (low priority).
+        if(isset($this->aCurrent['paginate']['perPage']) && (int)$this->aCurrent['paginate']['perPage'] != 0)
+            $iPerPage = (int)$this->aCurrent['paginate']['perPage'];
+        
+        //--- Check in aParams (middle priority).
+        if(isset($this->_aParams['per_page']) && (int)$this->_aParams['per_page'] != 0)
+            $iPerPage = (int)$this->_aParams['per_page'];
+
+        //--- Check in GET params (high priority).
+        if(isset($_GET['per_page']) && (int)$_GET['per_page'] != 0)
+            $iPerPage = (int)$_GET['per_page'];
+
+        //--- Trying to get from System settings.
+        if (empty($iPerPage))
+            $iPerPage = (int)getParam('sys_per_page_search_keyword_' . ($this->_bSingleSearch ? 'single' : 'plural'));
+
+        //--- Use default value in case of emergency.
+        if (empty($iPerPage))
+            $iPerPage = BX_DOL_SEARCH_RESULTS_PER_PAGE_DEFAULT;
+        
+        if ($this->iPaginatePerPage != BX_DOL_SEARCH_RESULTS_PER_PAGE_DEFAULT)
+            $iPerPage = $this->iPaginatePerPage;
+
+        $this->aCurrent['paginate']['perPage'] = $iPerPage;
+
+        if(!isset($this->aCurrent['paginate']['forceStart'])) {
+            if(isset($this->_aParams['start']))
+                $iStart = (int)$this->_aParams['start'];
+
+            if(($iStartGet = bx_get('start')) !== false)
+                $iStart = (int)$iStartGet;
+        }
+        else
+            $iStart = (int)$this->aCurrent['paginate']['forceStart'];
+
+        if($iStart < 0)
+            $iStart = 0;
+
+        $this->aCurrent['paginate']['start'] = $iStart;
+    }
+
+    function unsetPaginate()
+    {
+    	unset($this->aCurrent['paginate']);
+    }
+
+    /**
+     * Get sql where condition for search fields
+     * @param array of search fields
+     * @param string $sKeyword keyword value for search
+     * @param string sPseud for setting new name for generated set of fields in query
+     *                         return sql code of WHERE part in query
+     */
+    function getSearchFieldsCond ($aFields, $sKeyword, $sPseud = '')
+    {
+        if (!$sKeyword)
+            return '';
+
+        $oDb = BxDolDb::getInstance();
+
+        $sTable = empty($this->aCurrent['tableSearch']) ? $this->aCurrent['table'] : $this->aCurrent['tableSearch'];
+        if(!$oDb->isValidFieldName($sTable, true))
+            return '';
+
+        $bLike = getParam('useLikeOperator');
+
+        if (!is_array($aFields))
+            $aFields = array($aFields);
+
+        if ($bLike == 'on') {
+            $sKeyword = $oDb->escape('%' . preg_replace('/\s+/', '%', $sKeyword) . '%');
+
+            $sSqlWhere = '';
+            foreach ($aFields as $sValue) {
+                if(!$oDb->isValidFieldName($sValue, true))
+                    continue;
+
+                $sSqlWhere .= "`{$sTable}`.`$sValue` LIKE  " . $sKeyword . " OR ";
+            }
+
+            if($sSqlWhere === '')
+                return '';
+
+            $sSqlWhere = '(' . trim($sSqlWhere, 'OR ') . ')';
+
+        } else {
+        	$sKeyword = $oDb->escape($sKeyword);
+
+            $sSqlWhere = '';
+            foreach ($aFields as $sValue) {
+                if(!$oDb->isValidFieldName($sValue, true))
+                    continue;
+
+                $sSqlWhere .= "`{$sTable}`.`$sValue`, ";
+            }
+
+            if($sSqlWhere === '')
+                return '';
+
+            $sSqlWhere = trim($sSqlWhere, ', ');
+            $sSqlWhere = " MATCH({$sSqlWhere}) AGAINST (" . $sKeyword . ") ";
+
+            if (!empty($sPseud))
+                $sSqlWhere .= " AS `$sPseud` ";
+        }
+
+        return $sSqlWhere;
+    }
+
+    /**
+     * Get set from several values for 'in' and 'not in' operators
+     * @param $aValues array of values
+     * return sql code for field with operator IN (NOT IN)
+     */
+    function getMultiValues ($aValues)
+    {
+        $oDb = BxDolDb::getInstance();
+        return $oDb->implode_escape($aValues);
+    }
+
+    /**
+     * System method for filling aPseud array. Fill field aPseud for current class from system option.
+     * It may be useful when Fields are taken from system option too.
+     */
+    function _getPseudFromParam ()
+    {
+        return false;
+    }
+
+    /**
+     * System method for filling aPseud array.
+     * Fill field aPseud for current class (if you will use own getSearchUnit methods then not necessary to redeclare).
+     */
+    function _getPseud ()
+    {
+
+    }
+
+    /**
+     * Add replace markers. Markers are replaced in titles and browse urls
+     * @param $a array of markers as key => value
+     * @return true on success or false on error
+     */
+    public function addMarkers ($a)
+    {
+        if (empty($a) || !is_array($a))
+            return false;
+        $this->_aMarkers = array_merge ($this->_aMarkers, $a);
+        return true;
+    }
+
+    /**
+     * Replace provided markers in a string
+     * @param $mixed string or array to replace markers in
+     * @return string where all occured markers are replaced
+     */
+    protected function _replaceMarkers ($mixed)
+    {
+        return bx_replace_markers($mixed, $this->_aMarkers);
+    }
+
+    protected function _setPageDescription()
+    {
+        $aArgs = func_get_args();
+
+        if($this->_bSingleSearch)
+            $aArgs[0] .= '_in_section';
+
+        BxDolTemplate::getInstance()->setPageDescription(call_user_func_array('_t', $aArgs));
+    }
+}
+
+/** @} */

+ 284 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolSearchExtended.php

@@ -0,0 +1,284 @@
+<?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
+ * @{
+ */
+
+bx_import('BxDolForm');
+
+class BxDolSearchExtended extends BxDolFactory implements iBxDolFactoryObject
+{
+    public static $SEARCHABLE_TYPES = array(
+        'text', 'textarea', 'number', 'price', 'slider',
+        'select', 'radio_set',
+        'checkbox_set', 'select_multiple',
+        'checkbox', 'switcher',
+        'datepicker', 'datetime',
+        'location', 'location_radius'
+    );
+
+    public static $TYPE_TO_TYPE_SEARCH = array(
+        'text' => array('text', 'text_range'),
+        'textarea' => array('text'),
+        'number' => array('text_range', 'text'),
+        'price' => array('text_range', 'text'),
+        'slider' => array('text_range', 'text'),
+        'text_auto' => array('text_auto'),
+        'select' => array('checkbox_set', 'select_multiple', 'select'),
+        'radio_set' => array('checkbox_set', 'select_multiple', 'select'),
+        'checkbox_set' => array('checkbox_set', 'select_multiple', 'select'),
+        'select_multiple' => array('checkbox_set', 'select_multiple', 'select'),
+        'checkbox' => array('checkbox', 'switcher'),
+        'switcher' => array('checkbox', 'switcher'),
+        'datepicker' => array('datepicker_range', 'datepicker_range_age'),
+        'datetime' => array('datetime_range', 'datetime_range_age'),
+        'location' => array('location', 'location_radius')
+    );
+
+    public static $TYPE_TO_OPERATOR = array(
+        'text' => array('like', '=', 'between'),
+        'textarea' => array('like', '='),
+        'number' => array('between', '='),
+        'price' => array('between', '='),
+        'slider' => array('between', '='),
+        'text_auto' => array('in'),
+        'select' => array('in'),
+        'radio_set' => array('in'),
+        'checkbox_set' => array('and'), 
+        'select_multiple' => array('and'),
+        'checkbox' => array('='),
+        'switcher' => array('='),
+        'datepicker' => array('between'), 
+        'datetime' => array('between'),
+        'location' => array('locate')
+    );
+
+    protected $_bIsApi;
+
+    protected $_oDb;
+
+    protected $_sObject;
+    protected $_aObject;
+
+    protected $_bFilterMode;
+
+    /**
+     * Constructor
+     * @param $aObject array of search options
+     */
+    protected function __construct($aObject)
+    {
+        parent::__construct();
+
+        $this->_bIsApi = bx_is_api();
+
+        $this->_oDb = new BxDolSearchExtendedQuery($this->_aObject);
+
+        $this->_sObject = $aObject['object'];
+        $this->_aObject = $aObject;
+
+        $this->_bFilterMode = false;
+        if(isset($this->_aObject['filter']))
+            $this->_bFilterMode = (int)$this->_aObject['filter'] != 0;
+    }
+
+    /**
+     * Get editor object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    static public function getObjectInstance($sObject, $oTemplate = false)
+    {
+        if(isset($GLOBALS['bxDolClasses']['BxDolSearchExtended!' . $sObject]))
+            return $GLOBALS['bxDolClasses']['BxDolSearchExtended!' . $sObject];
+
+        $aObject = BxDolSearchExtendedQuery::getSearchObject($sObject);
+        if(!$aObject || !is_array($aObject))
+            return false;
+
+        $sClass = 'BxTemplSearchExtended';
+        if(!empty($aObject['class_name'])) {
+            $sClass = $aObject['class_name'];
+            if(!empty($aObject['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aObject['class_file']);
+        }
+
+        $o = new $sClass($aObject, $oTemplate);
+        return ($GLOBALS['bxDolClasses']['BxDolSearchExtended!' . $sObject] = $o);
+    }
+
+    static public function actionGetAuthors()
+    {
+        self::getMention("@");
+    }
+    
+    static public function actionGetHashtags()
+    {
+        self::getMention("#");
+    }
+    
+    static public function actionGetMention()
+    {
+        self::getMention(bx_get('symbol'));
+    }
+
+    static function getMention($sSymbol)
+    {
+        $aResult = [];
+        switch($sSymbol) {
+            case '@':
+                $aSearchParams = [];
+                if(($iCntId = bx_get('ci')) !== false)
+                    $aSearchParams['ci'] = $iCntId;
+                if(($sCntModule = bx_get('cm')) !== false)
+                    $aSearchParams['cm'] = bx_process_input($sCntModule);
+                if(($sCntPrivacy = bx_get('cp')) !== false)
+                    $aSearchParams['cp'] = bx_process_input($sCntPrivacy);
+
+                $aSrvParams = [];
+                if($aSearchParams)
+                    $aSrvParams = [
+                        'search_params' => $aSearchParams,
+                        'order_by' => 'weight'
+                ];
+
+                $aResult = bx_srv('system', 'profiles_search', [bx_get('term'), $aSrvParams], 'TemplServiceProfiles');
+                foreach ($aResult as &$aItem)
+                    $aItem['symbol'] = bx_get('symbol');
+                break;
+
+            case '#':
+                $aData = BxDolMetatags::getMetatagsDataByTerm('keywords', 'keyword', bx_get('term'));
+                foreach ($aData as $aItem)
+                    $aResult[] = [
+                        'label' => $aItem['meta'], 
+                        'value' => $aItem['id'], 
+                        'url' => $aItem['url'], 
+                        'symbol' => bx_get('symbol')
+                    ];
+                break;
+            
+        }
+
+        $a = bx_get_base_url_inline();
+
+        /**
+         * @hooks
+         * @hookdef hook-search-get_mention 'search', 'get_mention' - hook on get mention list
+         * - $unit_name - equals `search`
+         * - $action - equals `get_mention` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `params` - [array] array of parameters
+         *      - `override_result` - [array] by ref, array of colors, can be overridden in hook processing
+         * @hook @ref hook-search-get_mention
+         */
+        bx_alert('search', 'get_mention', 0, 0, array('params' => $a[1], 'override_result' => &$aResult));
+        
+        header('Content-Type:text/javascript; charset=utf-8');
+        echo json_encode($aResult);
+    }
+
+    static public function encodeConditions($aConditions) 
+    {
+        return urlencode(base64_encode(json_encode($aConditions)));
+    }
+
+    static public function decodeConditions($mixedConditions) 
+    {
+        return is_string($mixedConditions) ? json_decode(base64_decode(urldecode($mixedConditions)), true) : $mixedConditions;
+    }
+
+    static public function processParams($aParams, $aQueryParts)
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $sWhereConditions = "1";
+        foreach($aParams as $sSearchParam => $aSearchParam) {
+            $sSearchValue = "";
+            switch ($aSearchParam['operator']) {
+                case 'like':
+                    $sSearchValue = " LIKE " . $oDb->escape("%" . $aSearchParam['value'] . "%");
+                    break;
+
+                case 'in':
+                    $sSearchValue = " IN (" . $oDb->implode_escape($aSearchParam['value']) . ")";
+                    break;
+
+                case 'and':
+                    $iResult = 0;
+                    if (is_array($aSearchParam['value']))
+                        foreach ($aSearchParam['value'] as $iValue)
+                            $iResult |= pow (2, $iValue - 1);
+                    else 
+                        $iResult = (int)$aSearchParam['value'];
+
+                    $sSearchValue = " & " . $iResult . "";
+                    break;
+
+                default:
+                    if (!$oDb->isValidOperator($aSearchParam['operator'])) {
+                        throw new Exception('Invalid operator in processParams method');
+                    }
+                    $sSearchValue = " " . $aSearchParam['operator'] . " :" . $sSearchParam;
+
+                    $aQueryParts['bindings'][$sSearchParam] = $aSearchParam['value'];
+            }
+
+            $sWhereConditions .= " AND `" . $aQueryParts['table'] . "`.`" . $sSearchParam . "`" . $sSearchValue;
+        }
+
+        $aQueryParts['where_clause'] .= " AND (" . $sWhereConditions . ")";
+    }
+
+    public function isEnabled()
+    {
+        return isset($this->_aObject['active']) && (int)$this->_aObject['active'] != 0;
+    }
+
+    public function clean()
+    {
+        return $this->cleanFields() & $this->cleanSortableFields();
+    }
+    
+    public function cleanFields()
+    {
+        return $this->_oDb->deleteFields(array('object' => $this->_sObject)) !== false;
+    }
+    
+    public function cleanSortableFields()
+    {
+        return $this->_oDb->deleteSortableFields(array('object' => $this->_sObject)) !== false;
+    }
+
+    public function reset()
+    {   
+        $this->resetFields();
+        $this->resetSortableFields();
+        return true;
+    }
+    
+    public function resetFields()
+    {
+        if(!$this->cleanFields())
+            return false;
+        
+        $this->_aObject['fields'] = BxDolSearchExtendedQuery::getSearchFields($this->_aObject);
+        return true;
+    }
+    
+    public function resetSortableFields()
+    {
+        if(!$this->cleanSortableFields())
+            return false;
+        
+        $this->_aObject['fields'] = BxDolSearchExtendedQuery::getSearchSortableFields($this->_aObject);
+        return true;
+    }
+}
+
+/** @} */

+ 96 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolSounds.php

@@ -0,0 +1,96 @@
+<?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
+ * @{
+ */
+
+/**
+ * Interface sounds settings (Studio > Settings > Global > Sounds) for Cuelume's cues: where they play, the sound set
+ * and volume, the level people start at, and a set (or silence) for each kind of action.
+ *
+ * The site and Studio read it in BxBaseFunctions::getInjHeadSounds; apps get the same config with every page
+ * (BxBasePage::getPageAPI), so playing a sound never waits on a request.
+ */
+class BxDolSounds extends BxDolFactory implements iBxDolSingleton
+{
+    public const PLATFORMS = ['site', 'studio', 'neo_web', 'ios', 'android'];
+
+    /** What each person can pick for themselves; the option sets where they start. */
+    public const LEVELS = ['full', 'quiet', 'off'];
+
+    /** Cuelume's themes: the same cues in another material. */
+    public const SETS = ['default', 'mech', 'bubble', 'press'];
+
+    public const VOLUMES = [100, 75, 50, 25];
+
+    /** Kinds of action that can have their own set, or no sound. */
+    public const GROUPS = ['buttons', 'choices', 'reactions', 'menus', 'navigation', 'results', 'notifications'];
+
+    public const GROUP_OFF = 'off';
+
+    protected function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolSounds();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function isOn($sPlatform)
+    {
+        return in_array($sPlatform, $this->getPlatforms(), true);
+    }
+
+    public function getPlatforms()
+    {
+        return array_values(array_intersect(self::PLATFORMS, explode(',', (string)getParam('sys_sounds_platforms'))));
+    }
+
+    /**
+     * The config every client applies: `level` is where people start until they pick their own, `volume` is 0-1,
+     * `groups` lists only the kinds of action that differ from `set` (a set name or 'off').
+     */
+    public function getConfig()
+    {
+        $sLevel = getParam('sys_sounds_level');
+        $sSet = getParam('sys_sounds_set');
+        $iVolume = (int)getParam('sys_sounds_volume');
+
+        $aGroups = [];
+        foreach(self::GROUPS as $sGroup) {
+            $sGroupSet = getParam('sys_sounds_' . $sGroup);
+            if($sGroupSet == self::GROUP_OFF || (in_array($sGroupSet, self::SETS, true) && $sGroupSet != $sSet))
+                $aGroups[$sGroup] = $sGroupSet;
+        }
+
+        return [
+            'platforms' => $this->getPlatforms(),
+            'level' => in_array($sLevel, self::LEVELS, true) ? $sLevel : self::LEVELS[0],
+            'set' => in_array($sSet, self::SETS, true) ? $sSet : self::SETS[0],
+            'volume' => (in_array($iVolume, self::VOLUMES, true) ? $iVolume : self::VOLUMES[0]) / 100,
+            'groups' => (object)$aGroups,
+        ];
+    }
+}
+
+/** @} */

+ 1656 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolStorage.php

@@ -0,0 +1,1656 @@
+<?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
+ * @{
+ */
+
+define('BX_DOL_STORAGE_ERR_OK', 0);
+
+// 1-8 are standard upload form errors
+/*
+UPLOAD_ERR_INI_SIZE
+Value: 1; The uploaded file exceeds the upload_max_filesize directive in php.ini.
+
+UPLOAD_ERR_FORM_SIZE
+Value: 2; The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.
+
+UPLOAD_ERR_PARTIAL
+Value: 3; The uploaded file was only partially uploaded.
+
+UPLOAD_ERR_NO_FILE
+Value: 4; No file was uploaded.
+
+UPLOAD_ERR_NO_TMP_DIR
+Value: 6; Missing a temporary folder. Introduced in PHP 4.3.10 and PHP 5.0.3.
+
+UPLOAD_ERR_CANT_WRITE
+Value: 7; Failed to write file to disk. Introduced in PHP 5.1.0.
+
+UPLOAD_ERR_EXTENSION
+Value: 8; A PHP extension stopped the file upload. PHP does not provide a way to ascertain which extension caused the file upload to stop; examining the list of loaded extensions with phpinfo() may help. Introduced in PHP 5.2.0.
+*/
+
+define('BX_DOL_STORAGE_ERR_NO_INPUT_METHOD', 1000); ///< there is such input method available
+define('BX_DOL_STORAGE_ERR_NO_FILE', 1001); ///< there is no file to upload
+define('BX_DOL_STORAGE_INVALID_FILE', 1002); ///< uploaded file is invalid or hack attempts
+define('BX_DOL_STORAGE_ERR_FILE_TOO_BIG', 1003); ///< file is too big
+define('BX_DOL_STORAGE_ERR_WRONG_EXT', 1004); ///< wrong file extension
+define('BX_DOL_STORAGE_ERR_USER_QUOTA_EXCEEDED', 1005); ///< user quota exceeded
+define('BX_DOL_STORAGE_ERR_OBJECT_QUOTA_EXCEEDED', 1006); ///< storage object quota exceeded
+define('BX_DOL_STORAGE_ERR_SITE_QUOTA_EXCEEDED', 1007); ///< site quota exceeded
+define('BX_DOL_STORAGE_ERR_ENGINE_ADD', 1008); ///< some other error during file adding occured, related to particular storage engine
+
+define('BX_DOL_STORAGE_ERR_FILE_NOT_FOUND', 2001); ///< file not found
+define('BX_DOL_STORAGE_ERR_UNLINK', 2002); ///< file deletion failed
+
+define('BX_DOL_STORAGE_ERR_DB', 5001); ///< database error
+define('BX_DOL_STORAGE_ERR_FILESYSTEM_PERM', 5002); ///< filesystem permissions error
+define('BX_DOL_STORAGE_ERR_PERMISSION_DENIED', 5003); ///< permission denied
+define('BX_DOL_STORAGE_ERR_ENGINE_GET', 5004); ///< some other error during file getting occured, related to particular storage engine
+define('BX_DOL_STORAGE_ERR_NOT_IMPLEMENTED', 5005); ///< this feature isn't implemented in storage engine
+
+define('BX_DOL_STORAGE_DEFAULT_MIME_TYPE', 'octet/stream'); ///< default mime type if it is not find by extension
+
+define('BX_DOL_STORAGE_DEFAULT_ICON', 'mime-type-any.svg'); ///< default icon if no other icon can be determined by file extension
+define('BX_DOL_STORAGE_DEFAULT_ICON_FONT', 'far file'); ///< default font icon if no other icon can be determined by file extension
+
+define('BX_DOL_STORAGE_QUEUED_DELETIONS_PER_RUN', 200); ///< max number of file deletions per one cron run, @see BxDolStorage::pruneDeletions
+
+define('BX_DOL_STORAGE_GHOST_LIFETIME', 600); ///< ununsed ghost lifetime, @see BxDolStorage::pruneGhosts
+
+/**
+ * This class unify storage.
+ * As the result there are many advantages:
+ * - files can be stored as on localhost as on remote storage, for example Amazon s3
+ * - all files are in one place and separated from other files, so the data can be organised more easily,
+ *   for example moved to dedicated disk if there is not enough storage
+ * - simplicity of usage, there are hight level classes to handle all necessary operations, including upload and security
+ * - quotas settings, so you always control how much space you are going to use
+ * - persistent storage; uploaded, but not saved files appear upon page reload or future submission of the same form
+ *
+ *
+ * Usage.
+ *
+ * Step 1:
+ * Add record to 'sys_objects_storage' table, like you doing this for Comments or Voting objects:
+ * - object - your storage object name, usually it is in the following format - vendor prefix, underscore, module prefix;
+ *   for example for BoonEx Forum module it can be bx_forum.
+ * - engine - storage engine, for now the following engines are supported:
+ *     1. Local - local storage, by default files are stored in /storage/ subfolder 
+ *     2. S3 - Amazon S3 storage, files are stored on Amazon S3 storage, you need to point AWS Access Key, AWS Secret Key and AWS Bucket in the settings
+ * - params - custom storage engine params as php serialized string, supported params:
+ *      1. fields - list of additional fields to add to database as key(field name) and value(func or serialized service call to get the value)
+ * - token_life - life of the security token in seconds for private files
+ * - cache_control - control browser cache, allow browser to store files in browser's cache for this number of seconds, to disable browser cache,
+ *   or let browser to decide on its own set it to 0(zero)
+ * - levels - store files in subfolders, generated from filename; it is useful when there is limit of number of files/folders per directory;
+ *   for example if level is 2 and file name is abc.jpg then the file will be stored in a/b/abc.jpg folder, set to to 0(zero) to disable this feature
+ * - table_files - table where file info is stored, please refer to step 2 for more details
+ * - ext_mode - file extensions restriction mode:
+ *     1. allow-deny - allow only file types in ext_allow field and deny all other file types, ext_deny field is ignored.
+ *     2. deny-allow - allow all files except the ones specified in ext_deny field, ext_allow field is ignored.
+ * - ext_allow - allowed file extensions, comma separated, it is in effect when ext_mode is allow-deny; example - jpg,gif,png
+ * - ext_deny - denied file extensions, comma separated, it is in effect when ext_mode is deny-allow; example - exe,com,bat
+ * - quota_size - storage engine quota in bytes, the summary of all uploaded files can not be bigger than this number
+ * - current_size - current storage engine usage, the sum of all uploaded file sizes
+ * - quota_number - max number of files allowed in this storage engine
+ * - current_number - current number of files in this storage engine
+ * - max_file_size - max file size for this storage engine, please note that other server settings are used if they are less than this setting option
+ * - ts - unix timestamp of the last file upload
+ *
+ *
+ * Step 2:
+ * Create table for files.
+ *
+ * @code
+ * CREATE TABLE `my_sample_files` (
+ *   `id` int(11) NOT NULL AUTO_INCREMENT,
+ *   `profile_id` int(10) unsigned NOT NULL,
+ *   `remote_id` varchar(255) NOT NULL,
+ *   `path` varchar(255) NOT NULL,
+ *   `file_name` varchar(255) NOT NULL,
+ *   `mime_type` varchar(128) NOT NULL,
+ *   `ext` varchar(32) NOT NULL,
+ *   `size` int(11) NOT NULL,
+ *   `added` int(11) NOT NULL,
+ *   `modified` int(11) NOT NULL,
+ *   `private` int(11) NOT NULL,
+ *   PRIMARY KEY (`id`),
+ *   UNIQUE KEY `remote_id` (`remote_id`)
+ * );
+ * @endcode
+ *
+ * You need to enter this table name in 'table_files' field in 'sys_objects_storage' table, mentioned in step 1.
+ * The files will be added to this table automatically, all you need is to save 'id' from this table, so you can refer to the file by the 'id'.
+ * It is not recommended to change this table, it is better to create another table which will be connected with this one by file 'id'.
+ *
+ *
+ * Step 3:
+ * Handling upload.
+ *
+ * Sample HTML form:
+ *  @code
+ * <form enctype="multipart/form-data" method="POST" action="store_file.php">
+ *     Choose a file to upload:
+ *     <input name="file" type="file" />
+ *     <br />
+ *     <input type="submit" name="add" value="Upload File" />
+ * </form>
+ * @endcode
+ *
+ * Add server code in sample store_file.php file:
+ *
+ * @code
+ * require_once('./inc/header.inc.php');
+ * require_once(BX_DIRECTORY_PATH_INC . "design.inc.php");
+ *
+ * BxDolStorage::pruning(); // pruning is needed to clear expired security tokens, you can call it on cron when your server is not busy
+ * $oStorage = BxDolStorage::getObjectInstance('my_module'); // create storage object instance, 'my_module' is value of 'object' field in 'sys_objects_storage' table
+ *
+ * if (isset($_POST['add'])) { // if form is submitted
+ *         $iId = $oStorage->storeFileFromForm($_FILES['file'], true, 0); // store file from submitted HTML form, 'file' is input name with field, true means store file as private, 0 is profile id
+ *         if ($iId) { // storeFileFromForm returns file id, not false value means operation is successful.
+ *             // save $iId somewhere, so you can refer to the file after
+ *             $iCount = $oStorage->afterUploadCleanup($iId, $iProfileId); // since we saved $iId, we remove it from the orphans list, so it will not appear on the form next time (persistent storage)
+ *             echo "uploaded file id: " . $iId . "(deleted orphans:" . $iCount . ")";
+ *         } else {
+ *             // something went wrong - print the error
+ *             echo "error uploading file: " . $oStorage->getErrorString()
+ *         }
+ * }
+ * @endcode
+ *
+ * Please refer to the functions definition for more additional description of functions params.
+ *
+ *
+ * Step 4:
+ * Displaying the file.
+ *
+ * Use the following code to retrieve saved file. Remember you saved filed id somewhere in the previous step.
+ * Lets assume that the uploaded file is image, then we can show it using the following code:
+ *
+ * @code
+ * require_once('./inc/header.inc.php');
+ * require_once(BX_DIRECTORY_PATH_INC . "design.inc.php");
+ *
+ * $oStorage = BxDolStorage::getObjectInstance('my_module');
+ *
+ * $iId = 1234; // since you've saved it somewhere in the previous step, you can retrieve it here
+ *
+ * echo "Uploaded image: <img src="' . $oStorage->getFileUrlById($iId) . '" />;";
+ * @endcode
+ *
+ * It will show the file, regardless if it is private or public.
+ * You need to control it by yourself who will view the file.
+ * The difference in viewing private files is that link to the file is expiring after N seconds,
+ * you control this period using 'token_life' field in 'sys_objects_storage' table.
+ *
+ */
+
+abstract class BxDolStorage extends BxDolFactory implements iBxDolFactoryObject
+{
+    protected $_aObject; ///< object properties
+    protected $_iCacheControl; ///< browser cache in seconds, 0 - disabled
+    protected $_aParams; ///< custom params
+    protected $_iErrorCode; ///< last error code
+    protected $_oDb; ///< database relates function are in this object
+    protected $_aMimeTypesViewable = ['audio/', 'image/', 'video/']; ///< file types (by mime type) to allow view file in browser instead of downloading
+    protected $_bSanitizeSvg = true; ///< enable/disable SVG sanitization
+    
+    /**
+     * constructor
+     */
+    protected function __construct($aObject)
+    {
+        parent::__construct();
+        $this->_aObject = $aObject;
+        $this->_iCacheControl = $aObject['cache_control'];
+        $this->_aParams = $aObject['params'] ? unserialize($aObject['params']) : '';
+        $this->_oDb = new BxDolStorageQuery($aObject);
+    }
+
+    /**
+     * Get storage object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    public static function getObjectInstance($sObject)
+    {
+        if (isset($GLOBALS['bxDolClasses']['BxDolStorage!'.$sObject]))
+            return $GLOBALS['bxDolClasses']['BxDolStorage!'.$sObject];
+
+        $aObject = BxDolStorageQuery::getStorageObject($sObject);        
+        if (!$aObject || !is_array($aObject))
+            return false;
+
+        $aExtMarkers = [
+            'image' => getParam('sys_files_ext_images'),
+            'video' => getParam('sys_files_ext_video'),
+            'imagevideo' => getParam('sys_files_ext_imagevideo'), 
+            'audio' => getParam('sys_files_ext_audio'),
+            'dangerous' => getParam('sys_files_ext_dangerous')
+        ];
+        
+        $aObject['ext_allow'] = bx_replace_markers($aObject['ext_allow'], $aExtMarkers);
+        $aObject['ext_deny'] = bx_replace_markers($aObject['ext_deny'], $aExtMarkers);
+
+        $sClass = 'BxDolStorage' . $aObject['engine'];
+        $o = new $sClass($aObject);
+
+        if (!$o->isInstalled() || !$o->isAvailable())
+            return false;
+
+        return ($GLOBALS['bxDolClasses']['BxDolStorage!'.$sObject] = $o);
+    }
+
+    public static function getGhostLifetime()
+    {
+        return ($iLifetime = getParam('sys_storage_ghost_lifetime')) !== false ? (int)$iLifetime * 60 : BX_DOL_STORAGE_GHOST_LIFETIME;
+    }
+
+    /**
+     * Delete old security tokens from database.
+     * It is alutomatically called upin cron execution, usually once in a day.
+     * @return number of deleted records
+     */
+    public static function pruning()
+    {
+        $iDeleted = 0;
+        $a = BxDolStorageQuery::getStorageObjects();
+        foreach ($a as $aObject) {
+            $oDb = new BxDolStorageQuery($aObject);
+            $iDeleted += $oDb->prune();
+        }
+        return $iDeleted;
+    }
+
+    /**
+     * Delete files queued for deletions
+     * It is alutomatically called upon cron execution, usually one time per minute.
+     * Max number of deletetion per time is defined in @see BX_DOL_STORAGE_QUEUED_DELETIONS_PER_RUN
+     * @return number of deleted records
+     */
+    public static function pruneDeletions()
+    {
+        $iDeleted = 0;
+        $a = BxDolStorageQuery::getQueuedFilesForDeletion(BX_DOL_STORAGE_QUEUED_DELETIONS_PER_RUN);
+        foreach ($a as $r) {
+            $o = BxDolStorage::getObjectInstance($r['object']);
+            $iDeleted += ($o && $o->deleteFile($r['file_id']) ? 1 : 0);
+        }
+
+        return $iDeleted;
+    }
+
+    /**
+     * Delete outdated ghosts
+     * It is alutomatically called upon cron execution, usually one time per minute.
+     */
+    public static function pruneGhosts()
+    {
+        $iLifetime = self::getGhostLifetime();
+        if($iLifetime == 0)
+            return;
+
+        $iDeleted = 0;
+
+        $aGhosts = BxDolStorageQuery::getOutdatedUnusedGhosts($iLifetime);
+        foreach($aGhosts as $aGhost)
+            if(($oStorage = BxDolStorage::getObjectInstance($aGhost['object'])) !== false)
+                $iDeleted += $oStorage->deleteFile($aGhost['id']);
+
+        return $iDeleted;
+    }
+
+    /**
+     * Check if module has any files pending for deletion, it is supposed that all module storage object names are prefixed with module name
+     * @param $sPrefix - usually module name
+     * @return number of files pending for deletion which were found by prefix
+     */
+    public static function isQueuedFilesForDeletion ($sPrefix)
+    {
+        return BxDolStorageQuery::isQueuedFilesForDeletion($sPrefix);
+    }
+
+    /**
+     * Get file token for private files.
+     * @param $iFileId file
+     * @return file token string
+     */
+    public function genToken($iFileId)
+    {
+        return $this->_oDb->genToken($iFileId);
+    }
+    
+    /**
+     * Change storage engine. It's possible to change it when there is no files in storage engine.
+     * @param $sEngine new storage engine
+     * @return true on success or false on error
+     */ 
+    public function changeStorageEngine ($sEngine)
+    {
+        if (0 == $this->_aObject['current_size'] && 0 == $this->_aObject['current_number'])
+            return $this->_oDb->changeStorageEngine($sEngine);
+        return false;
+    }
+
+    /**
+     * Is storage engine available?
+     * @return boolean
+     */
+    function isAvailable()
+    {
+        return true;
+    }
+
+    /**
+     * Are required php modules installed for this storage engine ?
+     * @return boolean
+     */
+    public function isInstalled()
+    {
+        return true;
+    }
+
+    public function getObject()
+    {
+    	return $this->_aObject['object'];
+    }
+
+    public function getObjectData()
+    {
+    	return $this->_aObject;
+    }
+
+    /**
+     * Get error code from the last occured error
+     * @return error code
+     */
+    public function getErrorCode()
+    {
+        return $this->_iErrorCode;
+    }
+
+    /**
+     * Get error string from the last occured error
+     * @return error string
+     */
+    public function getErrorString()
+    {
+        bx_import('BxDolLanguages');
+        $a = array (
+            1000 => '_sys_storage_err_no_input_method',
+            1001 => '_sys_storage_err_no_file',
+            1002 => '_sys_storage_invalid_file',
+            1003 => '_sys_storage_err_file_too_big',
+            1004 => '_sys_storage_err_wrong_ext',
+            1005 => '_sys_storage_err_user_quota_exceeded',
+            1006 => '_sys_storage_err_object_quota_exceeded',
+            1007 => '_sys_storage_err_site_quota_exceeded',
+            1008 => '_sys_storage_err_engine_add',
+
+            2001 => '_sys_storage_err_file_not_found',
+            2002 => '_sys_storage_err_unlink',
+
+            5001 => '_sys_storage_err_db',
+            5002 => '_sys_storage_err_filesystem_perm',
+            5003 => '_sys_storage_err_permission_denied',
+            5004 => '_sys_storage_err_engine_get',
+            5005 => '_sys_storage_err_not_implemented',
+        );
+        return _t($a[$this->_iErrorCode]);
+    }
+
+    /**
+     * Get max file size allowed for current user, it checks user quota, object quota, site quota and php setting
+     * @param $iProfileId profile id to check quota for
+     * @return quota size in bytes
+     */
+    public function getMaxUploadFileSize ($iProfileId)
+    {
+        $iMin = PHP_INT_MAX;
+
+        $aUserQuota = $this->_oDb->getUserQuota($iProfileId);
+        if ($aUserQuota['max_file_size'] && $aUserQuota['max_file_size'] < $iMin)
+            $iMin = $aUserQuota['max_file_size'];
+
+        $aObjectQuota = $this->_oDb->getStorageObjectQuota();
+        if ($aObjectQuota['max_file_size'] && $aObjectQuota['max_file_size'] < $iMin)
+            $iMin = $aObjectQuota['max_file_size'];
+
+        // TODO: get and check site quota
+
+        if (!defined('BX_DOL_CRON_EXECUTE')) {
+            $iUploadMaxFilesize = return_bytes(ini_get('upload_max_filesize'));
+            if ($iUploadMaxFilesize && $iUploadMaxFilesize < $iMin)
+                $iMin = $iUploadMaxFilesize;
+        }
+
+        return $iMin;
+    }
+
+    /**
+     * Store file in the storage area. It is not recommended to use this function directly,
+     * use other funtions like: storeFileFromForm, storeFileFromXhr, storeFileFromPath, storeFileFromUrl
+     * @param $sMethod upload method, like regular Form upload, upload from URL, etc
+     * @param $aMethodParams upload method params
+     * @param $sName file name with extention
+     * @param $isPrivate private or public file
+     * @param $iProfileId profile id of the upload action performer
+     * @param $iContentId content id to associate with ghost file
+     * @return id of added file on success, false on error - to get exact error string call getErrorString()
+     */
+    public function storeFile($sMethod, $aMethodParams, $sName = false, $isPrivate = true, $iProfileId = 0, $iContentId = 0)
+    {
+        // setup input source using helper classes, like $_FILES or some URL for example
+
+        $sHelperClass = 'BxDolStorageHelper' . $sMethod;
+        if (!class_exists($sHelperClass)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_NO_INPUT_METHOD);
+            return false;
+        }
+
+        $oHelper = new $sHelperClass($aMethodParams);
+
+        // check for errors, like size and extentions checking
+
+        if ($iImmediateError = $oHelper->getImmediateError()) {
+            $this->setErrorCode($iImmediateError);
+            return false;
+        }
+
+        $sExt = $this->getFileExt($oHelper->getName());
+        $sMimeType = $this->getMimeTypeByFileName($oHelper->getName());
+
+        if (!$this->isValidExt($sExt)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_WRONG_EXT);
+            return false;
+        }
+
+        // before upload callback + additional checking
+
+        if (!$this->onBeforeFileAdd (array(
+            'profile_id' => $iProfileId,
+            'content_id' => $iContentId,
+            'file_name' => $oHelper->getName(),
+            'mime_type' => $sMimeType,
+            'ext' => $sExt,
+            'size' => $oHelper->getSize(),
+            'private' => $isPrivate ? 1 : 0,
+        ))) {
+            return false;
+        }
+
+        // create tmp file
+
+        $sTmpFile = tempnam(BX_DIRECTORY_PATH_TMP, $this->_aObject['object']);
+        if (!$oHelper->save($sTmpFile)) {
+            $this->setErrorCode(BX_DOL_STORAGE_INVALID_FILE);
+            return false;
+        }
+
+        // perform SVG sanitization
+
+        if($this->isMimeTypeSvg($sMimeType) && $this->isSanitizeSvg()) {
+            $oSanitizer = new enshrined\svgSanitize\Sanitizer();
+
+            $sSvgOrig = @file_get_contents($sTmpFile);
+            if($sSvgOrig === false) {
+                $this->setErrorCode(BX_DOL_STORAGE_INVALID_FILE);
+                return false;
+            }
+
+            $sSvgClean = $oSanitizer->sanitize($sSvgOrig);
+            if($sSvgClean === false) {
+                $this->setErrorCode(BX_DOL_STORAGE_INVALID_FILE);
+                return false;
+            }
+
+            file_put_contents($sTmpFile, $sSvgClean);
+        }
+
+        // process additional custom fields, like video duration and video dimension
+
+        $aAdditionalFields = array();
+        if (isset($this->_aParams['fields']) && is_array($this->_aParams['fields'])) {
+            foreach ($this->_aParams['fields'] as $sField => $mixedMethod) {
+                if (is_string($mixedMethod) && method_exists($this, $mixedMethod)) {
+                    $aAdditionalFields[$sField] = $this->$mixedMethod($sTmpFile, $sMimeType, $sExt, $this);
+                }
+                elseif (is_array($mixedMethod) && isset($mixedMethod['module']) && isset($mixedMethod['method'])) {
+                    $mixedMethod['params'] = array($sTmpFile, $sMimeType, $sExt, $this);
+                    $aAdditionalFields[$sField] = call_user_func_array('bx_srv', array($mixedMethod['module'], $mixedMethod['method'], $mixedMethod['params'], isset($mixedMethod['class']) ? $mixedMethod['class'] : 'Module'));
+                } 
+            }
+        }
+
+        bx_alert('system', 'store_file', $iContentId, $iProfileId, array(
+            'storage_object' => $this->_aObject['object'],
+            'object' => $this,
+            'file_name' => $oHelper->getName(),
+            'mime_type' => $sMimeType,
+            'ext' => $sExt,
+            'size' => $oHelper->getSize(),
+            'file_path' => $sTmpFile,
+            'store_method' => $sMethod,
+            'store_method_params' => $aMethodParams,
+        ));
+
+        // store file to storage engine
+
+        $sLocalId = $this->genRandName();
+        $sPath = $this->genPath($sLocalId, $this->_aObject['levels']);
+        $sRemoteNamePath = $this->genRemoteNamePath ($sPath, $sLocalId, $sExt);
+
+        if (!$this->addFileToEngine($sTmpFile, $sLocalId, $oHelper->getName(), $isPrivate, $iProfileId)) {
+            unlink($sTmpFile);
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_ENGINE_ADD);
+            return false;
+        }
+        unlink($sTmpFile);
+
+        // add record in db
+
+        $iTime = time();
+        $iSize = $oHelper->getSize();
+        $bFileAdded = $this->_oDb->addFile($iProfileId, $sLocalId, $sRemoteNamePath, $oHelper->getName(), $sMimeType, $sExt, $iSize, $iTime, $isPrivate, $aAdditionalFields);
+        $iId = $this->_oDb->lastId();
+        if (!$bFileAdded || !$iId) {
+            $this->deleteFileFromEngine($sPath . $sLocalId, $isPrivate);
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+
+        // after upload callback + triggers update
+
+        if (!$this->onFileAdded (array(
+            'id' => $iId,
+            'profile_id' => $iProfileId,
+            'content_id' => $iContentId,
+            'remote_id' => $sLocalId,
+            'path' => $sPath . $sLocalId,
+            'file_name' => $oHelper->getName(),
+            'mime_type' => $sMimeType,
+            'size' => $iSize,
+            'private' => $isPrivate ? 1 : 0,
+        ))) {
+            $this->deleteFileFromEngine($sPath . $sLocalId, $isPrivate);
+            $this->_oDb->deleteFile($iId);
+            return false;
+        }
+
+        return $iId;
+    }
+
+    /**
+     * convert default multiple files array into more logical one
+     */
+    public function convertMultipleFilesArray($aFiles)
+    {
+        if (!is_array($aFiles) || !is_array($aFiles['name']))
+            return false;
+        $aRet = array ();
+        foreach ($aFiles['name'] as $i => $sName) {
+            foreach ($aFiles as $sKey => $r) {
+                if (!$aFiles['name'][$i])
+                    break;
+                $aRet[$i][$sKey] = $aFiles[$sKey][$i];
+            }
+        }
+        return $aRet;
+    }
+
+    /**
+     * the same as storeFile, but it tries to do it directly from uploaded file
+     */
+    public function storeFileFromForm($aFile, $isPrivate = true, $iProfileId = 0, $iContentId = 0)
+    {
+        return $this->storeFile('Form', array('file' => $aFile), false, $isPrivate, $iProfileId, $iContentId);
+    }
+
+    /**
+     * the same as storeFile, but it tries to do it directly from HTML5 file upload method
+     */
+    public function storeFileFromXhr($sName, $isPrivate = true, $iProfileId = 0, $iContentId = 0)
+    {
+        return $this->storeFile('Xhr', array('name' => $sName), false, $isPrivate, $iProfileId, $iContentId);
+    }
+
+    /**
+     * the same as storeFile, but it tries to do it directly from local file
+     */
+    public function storeFileFromPath($sPath, $isPrivate = true, $iProfileId = 0, $iContentId = 0)
+    {
+        return $this->storeFile('Path', array('path' => $sPath), false, $isPrivate, $iProfileId, $iContentId);
+    }
+
+    /**
+     * the same as storeFileFromPath, but it tries to do it from URL
+     */
+    public function storeFileFromUrl($sUrl, $isPrivate = true, $iProfileId = 0, $iContentId = 0)
+    {
+        return $this->storeFile('Url', array('url' => $sUrl), false, $isPrivate, $iProfileId, $iContentId);
+    }
+
+    /**
+     * the same as storeFile, but it tries to do it directly from the same or another storage by file id
+     * @param $aParams['id'] - file ID in the storage
+     * @param $aParams['storage'] - the storage name
+     */
+    public function storeFileFromStorage($aParams, $isPrivate = true, $iProfileId = 0, $iContentId = 0)
+    {
+        if (!isset($aParams['id']) || !(int)$aParams['id'])
+            $aParams['id'] = 0;
+
+        if (!isset($aParams['storage']))
+            $aParams['storage'] = $this->_aObject['object'];
+
+        return $this->storeFile('Storage', $aParams, false, $isPrivate, $iProfileId, $iContentId = 0);
+    }
+
+    /**
+     * Delete file by file id.
+     */
+    public function deleteFile($iFileId, $iProfileId = 0)
+    {
+        $aFile = $this->_oDb->getFileById ($iFileId);
+        if (!$aFile) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_FILE_NOT_FOUND);
+            return false;
+        }
+
+        if (!$this->onBeforeFileDelete ($aFile, $iProfileId)) {
+            return false;
+        }
+
+        if (!$this->deleteFileFromEngine($aFile['path'], $aFile['private'])) {
+            return false;
+        }
+
+        $aGhost = $this->_oDb->getGhost($aFile['id']);
+
+        if (!$this->_oDb->deleteFile($aFile['id'])) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+
+        if (!$this->onFileDeleted ($aFile, $iProfileId, $aGhost)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Queue file(s) for deletion. File(s) will be deleted later upon cron call (usually every minute).
+     * @param $mixedFileId file id or array of file ids.
+     * @return number of queued files
+     */
+    public function queueFilesForDeletion($mixedFileId)
+    {
+        if (!is_array($mixedFileId))
+            $mixedFileId = array ($mixedFileId);
+        bx_import('BxDolForm');
+        $oChecker = new BxDolFormCheckerHelper();
+        return $this->_oDb->queueFilesForDeletion (array_unique($oChecker->passInt($mixedFileId)));
+    }
+
+    /**
+     * Queue file(s) for deletion by getting neccesary files from ghosts table by profile id and content id
+     * @param $iProfileId profile id associated with files
+     * @param $iContentId content id associated with files, or false if to check by profile id only
+     * @return number of queued files
+     */
+    public function queueFilesForDeletionFromGhosts($iProfileId, $iContentId = false)
+    {
+        $aFiles = $this->getGhosts ($iProfileId, $iContentId);
+        return $this->queueFiles($aFiles);
+    }
+
+    /**
+     * Queue file(s) for deletion of the whole storage object
+     * @return number of queued files
+     */
+    public function queueFilesForDeletionFromObject()
+    {
+        $aFiles = $this->getFiles (false);
+        return $this->queueFiles($aFiles);
+    }
+
+    /**
+     * Download file.
+     * @param array $aFile downloading file info.
+     * @param boolean $bForceDownloadDialog if downloading to a local file system first is required and/or send the outout as attachment rather than inline.
+     */
+    public function download ($aFile, $sToken = false, $bForceDownloadDialog = 'auto')
+    {
+    	$bRet = true;
+        bx_alert($this->_aObject['object'], 'file_downloaded', $aFile['id'], bx_get_logged_profile_id(), array(
+        	'profile_ip' => getVisitorIP(),
+        	'file_info' => $aFile, 
+        	'return_value' => &$bRet
+        ));
+
+        return $bRet;
+    }
+
+    /**
+     * Set file private or public.
+     */
+    public function setFilePrivate($iFileId, $isPrivate = true)
+    {
+        if (!$this->_oDb->modifyFilePrivate ($iFileId, $isPrivate)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Get file url.
+     * @param $sRemoteId file remote id
+     * @return file url or false on error
+     */
+    public function getFileUrlByRemoteId($sRemoteId)
+    {
+        $aFile = $this->_oDb->getFileByRemoteId($sRemoteId);
+        if (!$aFile) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_FILE_NOT_FOUND);
+            return false;
+        }
+
+        return $this->getFileUrlById($aFile['id']);
+    }
+
+    /**
+     * Get file url.
+     * @param $iFileId file id
+     * @return file url or false on error
+     */
+    public function getFileUrlById($iFileId) { }
+
+    /**
+     * Get the absolute local filesystem path of a stored file. Only local storage
+     * engines can answer this; other engines return false and callers fall back to
+     * the file URL.
+     * @param $mixedHandler file id or remote id
+     * @return string absolute path on success, false otherwise
+     */
+    public function getFileLocalPath($mixedHandler) { return false; }
+
+    /**
+     * Get file info array by file id.
+     * @param $iFileId file id
+     * @return array
+     */
+    public function getFile($iFileId)
+    {
+        $a = $this->_oDb->getFileById($iFileId);
+        if (!$a) return false;
+
+        // update custom fields for previously uploaded files
+        if (!defined('BX_DOL_STORAGE_CUSTOM_FIELDS_UPDATE_SKIP') && isset($this->_aParams['fields']) && is_array($this->_aParams['fields'])) {
+            foreach ($this->_aParams['fields'] as $sField => $mixedMethod) {
+                if ($a[$sField])
+                    continue;
+
+                $sFileUrl = $this->getFileUrlById($iFileId);
+
+                if (is_string($mixedMethod) && method_exists($this, $mixedMethod)) {
+                    $a[$sField] = $this->$mixedMethod($sFileUrl, $a['mime_type'], $a['ext'], $this);
+                }
+                elseif (is_array($mixedMethod) && isset($mixedMethod['module']) && isset($mixedMethod['method'])) {
+                    $mixedMethod['params'] = array($sFileUrl, $a['mime_type'], $a['ext'], $this);
+                    $a[$sField] = call_user_func_array('bx_srv', array($mixedMethod['module'], $mixedMethod['method'], $mixedMethod['params'], isset($mixedMethod['class']) ? $mixedMethod['class'] : 'Module'));
+                } 
+                if ($a[$sField])
+                    $this->_oDb->modifyCustomField($iFileId, $sField, $a[$sField], false);
+            }
+        }
+
+        return $a;
+    }
+
+    /**
+     * Get ghost file info array by file id.
+     * @param $iFileId file id
+     * @return array
+     */
+    public function getGhost($iFileId)
+    {    
+        return $this->_oDb->getGhost($iFileId);
+    }
+
+    /**
+     * check if file is private or public
+     * @param $iFileId file id
+     * @return boolean
+     */
+    public function isFilePrivate($iFileId)
+    {
+        $aFile = $this->getFile ($iFileId);
+        return $aFile['private'] ? true : false;
+    }
+
+    /**
+     * Call this function after saving/associate just uploaded file id, so file is not orphaned/ghost.
+     * Ghost files appear on download form automaticaly during next upload,
+     * for example if file was uploaded but was not submitted for some reason.
+     * This mechanism ensure that the file is not lost.
+     * @param $mixedFileIds array of file ids or just one file id
+     * @param $iProfileId profile id
+     * @param return number of deleted ghost files
+     */
+    public function afterUploadCleanup($mixedFileIds, $iProfileId, $iContentId = false)
+    {
+        return $this->_oDb->deleteGhosts($mixedFileIds, $iProfileId, $iContentId);
+    }
+
+    /**
+     * Get ghost/orphaned files for particular user.
+     * @param $iProfileId profile id
+     * @param $mixedContent - int Content ID | array Content ID + Uploader ID | false to not consider content id at all
+     * @param $isCheckAllAccountProfiles get all files associated with all account profiles
+     * @param $isAdmin if true, then don't check files ownership, it makes sense when $iContentId is provided, so it will return all files assiciated with content
+     * @return array of arrays
+     */
+    public function getGhosts($iProfileId, $mixedContent = false, $isCheckAllAccountProfiles = false, $isAdmin = false)
+    {
+        if($isCheckAllAccountProfiles && ($oProfile = BxDolProfile::getInstance($iProfileId)) !== false && ($oAccount = $oProfile->getAccountObject()) !== false)
+            $iProfileId = $oAccount->getProfilesIds(false, false);
+
+        return $this->_oDb->getGhosts($iProfileId, $mixedContent, $isAdmin);
+    }
+    
+    /**
+     * Reorder ghost/orphaned files for particular content/user.
+     * @param $iProfileId profile id
+     * @param $iContentId content id, or false to not consider content id at all
+     * @param $aGhosts an ordered list of ghost/orphaned files' IDs.
+     * @return boolean result of operation
+     */
+    public function reorderGhosts($iProfileId, $iContentId, $aGhosts)
+    {
+        $bResult = true;
+
+        $iGhosts = count($aGhosts);
+        for($i = 0; $i < $iGhosts; $i++)
+            $bResult &= $this->_oDb->updateGhostOrder($iProfileId, $iContentId, (int)$aGhosts[$i], $i);
+
+        return $bResult;
+    }
+
+    /**
+     * Update ghosts' content id.
+     * @param $mixedFileIds array of file ids or just one file id
+     * @param $iProfileId profile id
+     * @param $iContentId content id
+     * @param $isAdmin if true, then don't check files ownership
+     * @return true on success or false otherwise
+     */
+    public function updateGhostsContentId($mixedFileIds, $iProfileId, $iContentId, $isAdmin = false)
+    {
+        $aProfiles = array();
+        if ($oProfile = BxDolProfile::getInstance($iProfileId)) {
+            $oAccount = $oProfile->getAccountObject();
+            $aProfiles = $oAccount->getProfilesIds(false);
+        }
+
+        return $this->_oDb->updateGhostsContentId($mixedFileIds, $iProfileId, $iContentId, $aProfiles, $isAdmin);
+    }
+    
+    /**
+     * Update ghosts' uploader id.
+     * @param $mixedFileIds array of file ids or just one file id
+     * @param $iUploaderId uploader id
+     * @return true on success or false otherwise
+     */
+    public function updateGhostsUploaderId($mixedFileIds, $iUploaderId)
+    {
+        return $this->_oDb->updateGhostsUploaderId($mixedFileIds, $iUploaderId);
+    }
+
+    /**
+     * Get files list for particular user.
+     * @param $iProfileId profile id
+     * @return array of arrays
+     */
+    public function getFiles($iProfileId)
+    {
+        return $this->_oDb->getFiles($iProfileId);
+    }
+
+    /**
+     * Get all files in the storage
+     */
+    public function getFilesAll($iStart = 0, $iPerPage = 1000)
+    {
+        return $this->_oDb->getFilesAll($iStart, $iPerPage);
+    }
+
+    /**
+     * Get readable representation of restrictions by file extentions.
+     * @param $iProfileId profile id
+     * @return string
+     */
+    public function getRestrictionsTextExtensions ($iProfileId)
+    {
+        switch ($this->_aObject['ext_mode']) {
+            case 'allow-deny':
+                if (!$this->_aObject['ext_allow'])
+                    return _t('_sys_storage_restriction_ext_all_denied');
+                return _t('_sys_storage_restriction_ext_allowed', _t_format_extensions($this->_aObject['ext_allow']));
+            case 'deny-allow':
+                if (!$this->_aObject['ext_deny'])
+                    return _t('_sys_storage_restriction_ext_all_allowed');
+                return _t('_sys_storage_restriction_ext_denied', _t_format_extensions($this->_aObject['ext_deny']));
+            default:
+                return _t('_sys_storage_restriction_ext_all_denied');
+        }
+    }
+
+    /**
+     * @return list of allowed extensions, if ext_mode = 'allow-deny', returns false otherwise.
+     */
+    public function getAllowedExtensions ()
+    {
+        if ('allow-deny' == $this->_aObject['ext_mode'])
+            return _t_format_extensions($this->_aObject['ext_allow']);
+        return null;
+    }
+    
+    /**
+     * Get readable representation of restrictions by file size.
+     * @param $iProfileId profile id
+     * @return string
+     */
+    public function getRestrictionsTextFileSize ($iProfileId)
+    {
+        return _t('_sys_storage_restriction_size', _t_format_size($this->getMaxUploadFileSize($iProfileId)));
+    }
+
+    /**
+     * Get readable representation of all restrictions.
+     * @param $iProfileId profile id
+     * @return array of strings
+     */
+    public function getRestrictionsTextArray ($iProfileId)
+    {
+        $aTypes = array('Extensions', 'FileSize');
+        $aRet = array();
+        foreach ($aTypes as $sType) {
+            $sFunc = 'getRestrictionsText' . $sType;
+            $s = $this->$sFunc ($iProfileId);
+            if ($s)
+                $aRet[] = $s;
+        }
+        return $aRet;
+    }
+
+    /**
+     * Reread available mimetypes from particular file.
+     * It clears 'sys_storage_mime_types' table and fill it with data form provided file.
+     * The format of file is: mime/type _space_or_tab_ extentions_sperated_by_space.
+     * Usually the file is mime.types file from apache or /etc/mime.types from unix systems.
+     * @param $sFile file to read mime types from
+     * @return false if file was not found or can not be read, string with result on other case - it can contains file markup errors or localized "Success" string if everything went fine.
+     */
+    public function reloadMimeTypesFromFile ($sFile)
+    {
+        $sResult = '';
+
+        /* 
+         * These mime types must be manually added/replaced if they aren't defined in the mime type file
+         *
+
+            text/x-php                  php
+            text/x-coffeescript         coffee
+            text/x-common-lisp          lsp lisp
+            text/x-diff                 diff
+            text/x-go                   go
+            text/x-java                 java
+            text/x-lua                  lua
+            text/x-perl                 pl prl perl
+            text/x-python               py
+            text/nginx                  nginx
+            text/x-ini                  ini
+            text/x-ruby                 rb
+            text/x-sass                 sass
+            text/x-sh                   bash sh
+            text/x-swift                swift
+            text/x-vb                   vb
+            text/vbscript               vbs
+            text/x-vue                  vue
+            text/x-yaml                 yaml
+            text/x-sql                  sql
+            text/x-markdown             md
+            application/xquery          xq xquery
+            application/x-powershell    ps1
+            application/x-aspx          aps
+            application/x-jsp           jsp
+
+        */
+
+        $aIconsFont = array (
+            'far file-pdf' => array('pdf'),
+            'far file-word' => array('doc', 'docx'),
+            'far file-excel' => array('xls', 'xlt', 'xlsx', 'sxc', 'stc', 'ods', 'ots', 'sdc', 'csv', 'dif', 'slk', 'pxl'),
+            'far file-powerpoint' => array('ppt', 'pptx', 'sxi', 'sti', 'odp', 'sdp', 'sdd'),
+            'far file-code' => array('1st', 'aspx', 'asp', 'json', 'js', 'jsp', 'java', 'php', 'xml', 'html', 'xhtml', 'htm', 'rdf', 'xsd', 'xsl', 'xslt', 'sax', 'rss', 'dtd', 'cfm', 'js', 'asm', 'pl', 'prl', 'bas', 'b', 'fs', 'src', 'cs', 'ws', 'cgi', 'bat', 'py', 'c', 'cpp', 'cc', 'cp', 'h', 'hh', 'cxx', 'hxx', 'c++', 'm', 'lua', 'swift', 'sh', 'as', 'cob', 'tpl', 'lsp', 'x', 'cmd', 'rb', 'cbl', 'pas', 'pp', 'vb', 'vbs', 'f', 'perl', 'jl', 'lol', 'bal', 'pli', 'css', 'less', 'sass', 'saas', 'scss', 'bcc', 'coffee', 'jade', 'j', 'tea', 'c#', 'sas', 'diff', 'pro', 'for', 'sh', 'bsh', 'bash', 'twig', 'csh', 'lisp', 'lsp', 'cobol', 'pl', 'd', 'git', 'rb', 'hrl', 'cr', 'inp', 'a', 'go', 'as3', 'm', 'sql', 'md', 'mbox', 'nginx', 'pgp', 'asc', 'sig', 'ps1', 'rq', 'ttl', 'vue', 'xquery', 'xq'),
+            'far file-image' => 'image/', 
+            'far file-video' => 'video/',
+            'far file-audio' => 'audio/',
+            'far file-alt' => 'text/',
+            'far file-archive' => array('7z', '7zip', 'aar', 'ace', 'alz', 'arj', 'bz2', 'bza', 'bzip2', 'bzp', 'bzp2', 'cab', 'czip', 'gnutar', 'gz', 'gza', 'gzi', 'gzip', 'ha', 'lhz', 'lzma', 'pzip', 'rar', 'roo', 's7z', 'tar', 'tar-gz', 'tar-lzma', 'tar-z', 'taz', 'tbz', 'tbz2', 'tgz', 'tz', 'z', 'zip', 'zipx', 'zix', 'zoo'),
+        );
+        
+        $aIcons = array (
+            'mime-type-psd.svg' => array('psd'),
+            'mime-type-png.svg' => array('png'),
+            'mime-type-image.svg' => 'image/',
+            'mime-type-video.svg' => 'video/',
+            'mime-type-audio.svg' => 'audio/',
+            'mime-type-presentation.svg' => array('ppt', 'pptx', 'sxi', 'sti', 'odp', 'sdp', 'sdd'),
+            'mime-type-spreadsheet.svg' => array('xls', 'xlt', 'xlsx', 'sxc', 'stc', 'ods', 'ots', 'sdc', 'csv', 'dif', 'slk', 'pxl'),
+            'mime-type-document.svg' => array('doc', 'docx', 'odt', 'ott', 'sxw', 'stw', 'rtf', 'sdw', 'txt', 'pdb', 'psw', 'pdf'),
+            'mime-type-vector.svg' => array('ac5', 'ac6', 'aff', 'agd1', 'ai', 'ait', 'art', 'awg', 'b2f', 'cag', 'cbd', 'cdl', 'cdr', 'cdr3', 'cdr4', 'cdr5', 'cdr6', 'cdrw', 'cdx', 'cgm', 'cht', 'cil', 'cit', 'cnv', 'csl', 'ctn', 'cv5', 'cvg', 'cvi', 'cvl', 'cvs', 'cvx', 'dcs', 'ddoc', 'ddrw', 'ded', 'design', 'dmw', 'do', 'dpp', 'dpr', 'draw', 'drw', 'dsf', 'dsf', 'dsx', 'dvg', 'dxb', 'emb', 'evf', 'fcd', 'fh', 'fhd', 'fmv', 'fs', 'ft10', 'ft11', 'ft9', 'ft8', 'gem', 'gl2', 'graffle', 'gsd', 'gsd', 'hpg', 'hpgl', 'hpgl2', 'hpl', 'hplj', 'hpp', 'hppcl', 'idw', 'ima', 'macdraw', 'mgcb', 'mgs', 'mvg', 'nap', 'naplps', 'odg', 'p10', 'pat', 'pct', 'pd', 'pdw', 'pgs', 'pic', 'pif', 'pix', 'plo', 'plot', 'plt', 'ps', 'psid', 'pws', 'rdl', 's57', 'sdw', 'sif', 'sk2', 'slddwg', 'sp', 'spa', 'svf', 'svg', 'svgb', 'svgz', 'sxd', 'tdr', 'tlc', 'tng', 'vbr', 'vec', 'vect', 'veh', 'vml', 'vss', 'web', 'web', 'web', 'yal'),
+            'mime-type-archive.svg' => array('7z', '7zip', 'aar', 'ace', 'alz', 'arj', 'bz2', 'bza', 'bzip2', 'bzp', 'bzp2', 'cab', 'czip', 'gnutar', 'gz', 'gza', 'gzi', 'gzip', 'ha', 'lhz', 'lzma', 'pzip', 'rar', 'roo', 's7z', 'tar', 'tar-gz', 'tar-lzma', 'tar-z', 'taz', 'tbz', 'tbz2', 'tgz', 'tz', 'z', 'zip', 'zipx', 'zix', 'zoo'),
+        );
+
+        $f = fopen ($sFile, 'r');
+        if (!$f)
+            return false;
+
+        $this->_oDb->clearAllMimeTypes();
+
+        while (!feof($f) && ($s = fgets($f, 4096)) !== false) {
+            $s = trim($s);
+            if (!$s || '#' == $s[0])
+                continue;
+
+            $a = preg_split ("/[\s\\b]+/", $s, 2);
+            if (!isset($a[0]) || !isset($a[1]))
+                continue;
+
+            $sMimeType = $a[0];
+            $aExts = preg_split ("/[\s]+/", $a[1]);
+
+            foreach ($aExts as $sExt) {
+                $sIcon = $this->determineIcon($aIcons, $sExt, $sMimeType);
+                $sIconFont = $this->determineIcon($aIconsFont, $sExt, $sMimeType);
+
+                if (!$this->_oDb->addMimeType($sMimeType, $sExt, $sIcon, $sIconFont))
+                    $sResult .= _t('_Error') . ': ' . $sMimeType . "\t" . $sExt . "\n";
+            }
+        }
+
+        fclose($f);
+
+        if (!$sResult)
+            $sResult = _t('_Success');
+
+        return $sResult;
+    }
+
+    protected function determineIcon($aIcons, $sExt, $sMimeType)
+    {
+        foreach ($aIcons as $sIc => $if) {
+            if (!(is_array($if) && in_array($sExt, $if)) && !(is_string($if) && 0 === strncmp($if, $sMimeType, strlen($if))))
+                continue;
+            return $sIc;
+        }
+        return false;
+    }
+
+    /**
+     * Get file extension by file name.
+     * @param $sFileName file name
+     * @return file extention string
+     */
+    public function getFileExt ($sFileName)
+    {
+        return strtolower(pathinfo($sFileName, PATHINFO_EXTENSION));
+    }
+
+    /**
+     * Get file title by file name, actually file title is file name without extension.
+     * @param $sFileName file name
+     * @return file title string
+     */
+    public function getFileTitle ($sFileName)
+    {
+        return pathinfo($sFileName, PATHINFO_FILENAME);
+    }
+
+    /**
+     * Get file mime/type by file name.
+     * @param $sFileName file name
+     * @return file mime type string
+     */
+    public function getMimeTypeByFileName ($sFileName)
+    {
+        $sExt = $this->getFileExt($sFileName);
+        $sMimeType = $this->_oDb->getMimeTypeByExt($sExt);
+        if (!$sMimeType)
+            $sMimeType = BX_DOL_STORAGE_DEFAULT_MIME_TYPE;
+        return $sMimeType;
+    }
+
+    /**
+     * Get file icon by file name.
+     * File icon is just icon filename without patch or URL.
+     * File icons must be located in images/icons directory in your template subfolder.
+     * @param $sFileName file name
+     * @return file icon string
+     */
+    public function getIconNameByFileName ($sFileName)
+    {
+        $sExt = $this->getFileExt($sFileName);
+        $sIcon = $this->_oDb->getIconByExt($sExt);
+        if (!$sIcon)
+            $sIcon = BX_DOL_STORAGE_DEFAULT_ICON;
+        return $sIcon;
+    }
+
+    /**
+     * Get file font icon by file name.
+     * Actually just a class name of icon is returnted.
+     * @param $sFileName file name
+     * @return file icon string
+     */
+    public function getFontIconNameByFileName ($sFileName)
+    {
+        $sExt = $this->getFileExt($sFileName);
+        $sIcon = $this->_oDb->getIconFontByExt($sExt);
+        if (!$sIcon)
+            $sIcon = BX_DOL_STORAGE_DEFAULT_ICON_FONT;
+        return $sIcon;
+    }
+
+    public function setSanitizeSvg($bSanitize)
+    {
+        $this->_bSanitizeSvg = $bSanitize;
+    }
+
+    public function isSanitizeSvg()
+    {
+        return $this->_bSanitizeSvg && !isAdmin();
+    }
+
+    public function isMimeTypeSvg($sMimeType)
+    {
+        return strncmp('image/svg', $sMimeType, 9) === 0;
+    }
+
+    // ------------ internal functions - events
+
+    protected function onBeforeFileAdd ($aFileInfo)
+    {
+        if ($aFileInfo['size'] > $this->getMaxUploadFileSize($aFileInfo['profile_id'])) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_FILE_TOO_BIG);
+            return false;
+        }
+
+        // TODO: check site quota - BX_DOL_STORAGE_ERR_SITE_QUOTA_EXCEEDED
+
+        $aObjectQuota = $this->_oDb->getStorageObjectQuota();
+        if (
+            ($aObjectQuota['quota_size'] && ($aObjectQuota['current_size'] + $aFileInfo['size'] > $aObjectQuota['quota_size']))
+            ||
+            ($aObjectQuota['quota_number'] && ($aObjectQuota['current_number'] + 1 > $aObjectQuota['quota_number']))
+            ) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_OBJECT_QUOTA_EXCEEDED);
+            return false;
+        }
+
+        $aUserQuota = $this->_oDb->getUserQuota($aFileInfo['profile_id']);
+        if (
+            ($aUserQuota['quota_size'] && ($aUserQuota['current_size'] + $aFileInfo['size'] > $aUserQuota['quota_size']))
+            ||
+            ($aUserQuota['quota_number'] && ($aUserQuota['current_number'] + 1 > $aUserQuota['quota_number']))
+            ) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_USER_QUOTA_EXCEEDED);
+            return false;
+        }
+
+        $this->setErrorCode(BX_DOL_STORAGE_ERR_OK);
+
+        $bRet = true;
+        bx_alert($this->_aObject['object'], 'before_file_add', 0, $aFileInfo['profile_id'], array('file_info' => $aFileInfo, 'return_value' => &$bRet));
+        return $bRet;
+    }
+
+    protected function onFileAdded ($aFileInfo)
+    {
+        // TODO: update site quota - BX_DOL_STORAGE_ERR_SITE_QUOTA_EXCEEDED
+
+        if (!$this->_oDb->updateStorageObjectQuota($aFileInfo['size'], 1)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+
+        if (!$this->_oDb->updateUserQuota($aFileInfo['profile_id'], $aFileInfo['size'], 1)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+
+        if (!$this->insertGhost ($aFileInfo['id'], $aFileInfo['profile_id'], $aFileInfo['content_id'])) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+
+        $this->setErrorCode(BX_DOL_STORAGE_ERR_OK);
+
+        $bRet = true;
+        bx_alert($this->_aObject['object'], 'file_added', $aFileInfo['id'], $aFileInfo['profile_id'], array('file_info' => $aFileInfo, 'return_value' => &$bRet));
+        return $bRet;
+    }
+
+    function insertGhost($iFileId, $iProfileId, $iContentId = 0)
+    {
+        return $this->_oDb->insertGhosts ($iFileId, $iProfileId, $iContentId);
+    }
+
+    function onBeforeFileDelete ($aFileInfo, $iProfileId)
+    {
+        $this->setErrorCode(BX_DOL_STORAGE_ERR_OK);
+
+        $bRet = true;
+        bx_alert($this->_aObject['object'], 'before_file_delete', $aFileInfo['id'], $iProfileId, array('file_info' => $aFileInfo, 'return_value' => &$bRet));
+        return $bRet;
+    }
+
+    function onFileDeleted ($aFileInfo, $iProfileId, $aGhost = false)
+    {
+        // TODO: update site quota
+
+        if (!$this->_oDb->updateStorageObjectQuota(-$aFileInfo['size'], -1)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+
+        if (!$this->_oDb->updateUserQuota($aFileInfo['profile_id'], -$aFileInfo['size'], -1)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_DB);
+            return false;
+        }
+
+        $this->setErrorCode(BX_DOL_STORAGE_ERR_OK);
+
+        $bRet = true;
+        bx_alert($this->_aObject['object'], 'file_deleted', $aFileInfo['id'], $iProfileId, array('file_info' => $aFileInfo, 'ghost' => $aGhost, 'return_value' => &$bRet));
+        return $bRet;
+    }
+
+    // ------------ internal functions
+
+    protected function setErrorCode($i)
+    {
+        return ($this->_iErrorCode = $i);
+    }
+
+    protected function genRandName($isCheckForUniq = true)
+    {
+        $sRandName = strtolower(genRndPwd(32, false));
+        if ($isCheckForUniq) {
+            $iTries = 10;
+            do {
+                $aFile = $this->_oDb->getFileByRemoteId($sRandName);
+                $bExist = is_array($aFile) && $aFile;
+            } while (--$iTries && $bExist);
+        }
+        return $sRandName;
+    }
+
+    protected function genPath($s, $iLevels)
+    {
+        $sRet = '';
+        $i = 1;
+        while ($iLevels-- > 0)
+            $sRet .= substr($s, 0, $i++) . '/';
+        return $sRet;
+    }
+
+    protected function genRemoteNamePath ($sPath, $sLocalId, $sExt)
+    {
+        return $sPath . $sLocalId;
+    }
+
+    protected function isValidExt ($sExt)
+    {
+        switch ($this->_aObject['ext_mode']) {
+            case 'allow-deny':
+                if ($this->isAllowedExt($sExt))
+                    return true;
+                return false;
+            case 'deny-allow':
+                if ($this->isDeniedExt($sExt))
+                    return false;
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    protected function isAllowedExt ($sExt)
+    {
+        return $this->isAllowedDeniedExt($sExt, 'ext_allow');
+    }
+
+    protected function isDeniedExt ($sExt)
+    {
+        return $this->isAllowedDeniedExt($sExt, 'ext_deny');
+    }
+
+    protected function isAllowedDeniedExt ($sExt, $sExtMode)
+    {
+        if ('' == $this->_aObject[$sExtMode])
+            return false;
+        if (!is_array($this->_aObject[$sExtMode]))
+            $this->_aObject[$sExtMode] = explode(',', $this->_aObject[$sExtMode]);
+        return in_array ($sExt, $this->_aObject[$sExtMode]);
+    }
+
+    public function queueFiles($aFiles)
+    {
+        if (!$aFiles)
+            return 0;
+
+        $a = array();
+        foreach ($aFiles as $aFile)
+            $a[] = $aFile['id'];
+
+        return $this->queueFilesForDeletion($a);
+    }
+
+    protected function getFileDuration($sFilePath, $sMimeType, $sExt, $oStorage)
+    {
+        if (strncmp('video/', $sMimeType, 6) === 0)
+            return (int)BxDolTranscoderVideo::getDuration($sFilePath);
+        return 0;
+    }
+
+    protected function getFileDimensions($sFilePath, $sMimeType, $sExt, $oStorage)
+    {
+        if (strncmp('image/', $sMimeType, 6) === 0 && $o = BxDolTranscoderImage::getObjectAbstract()) {
+            $a = $o->getSize($sFilePath);
+            if ($a && isset($a['w']) && isset($a['h']))
+                return $a['w'] . 'x' . $a['h'];
+        }
+        
+        if (strncmp('video/', $sMimeType, 6) === 0 && $o = BxDolTranscoderVideo::getObjectAbstract()) {
+            $a = $o->getSize($sFilePath);
+            if ($a && isset($a['w']) && isset($a['h']))
+                return $a['w'] . 'x' . $a['h'];
+        }
+        return '';
+    }
+
+    protected function isAuthUrl ($aFile)
+    {
+        $s = trim(getParam('sys_storage_s3_force_auth_urls'));
+        if ('*' == $s) 
+            return true;
+
+        if ($s && ($aStorages = explode(',', $s)))
+            return in_array($this->getObject(), $aStorages);
+
+        return $aFile['private'];
+    }
+}
+
+
+/**
+ * Handle file uploads via XMLHttpRequest
+ */
+class BxDolStorageHelperXhr
+{
+    protected $sName;
+
+    function __construct ($aParams)
+    {
+        $this->sName = $aParams['name'];
+    }
+
+    function getImmediateError()
+    {
+        return BX_DOL_STORAGE_ERR_OK;
+    }
+
+    function save($path)
+    {
+        $input = fopen("php://input", "r");
+        $temp = tmpfile();
+        $realSize = stream_copy_to_stream($input, $temp);
+        fclose($input);
+
+        if (false === $this->getSize() || $realSize != $this->getSize())
+            return false;
+
+        $target = fopen($path, "w");
+        fseek($temp, 0, SEEK_SET);
+        stream_copy_to_stream($temp, $target);
+        fclose($target);
+        fclose($temp);
+
+        return true;
+    }
+
+    function getName()
+    {
+        return $this->sName;
+    }
+
+    function getSize()
+    {
+        if (isset($_SERVER["CONTENT_LENGTH"]))
+            return (int)$_SERVER["CONTENT_LENGTH"];
+        else
+            return false;
+    }
+}
+
+/**
+ * Handle file uploads via regular form post (uses the $_FILES array)
+ */
+class BxDolStorageHelperForm
+{
+    protected $aFile;
+
+    function __construct ($aParams)
+    {
+        $this->aFile = $aParams['file'];
+    }
+
+    function getImmediateError()
+    {
+        if (!$this->aFile['size'] || !$this->aFile['tmp_name'])
+            return BX_DOL_STORAGE_ERR_NO_FILE;
+
+        if (UPLOAD_ERR_OK != $this->aFile['error'])
+            return (int)$this->aFile['error'];
+
+        return BX_DOL_STORAGE_ERR_OK;
+    }
+
+    function save($path)
+    {
+        if (!move_uploaded_file($this->aFile['tmp_name'], $path)){
+            return false;
+        }
+        return true;
+    }
+
+    function getName()
+    {
+        return $this->aFile['name'];
+    }
+
+    function getSize()
+    {
+        return $this->aFile['size'];
+    }
+}
+
+/**
+ * Store file from local file path
+ */
+class BxDolStorageHelperPath
+{
+    protected $sPath;
+
+    function __construct ($aParams)
+    {
+        $this->sPath = $aParams['path'];
+    }
+
+    function getImmediateError()
+    {
+        if (!$this->sPath || !file_exists($this->sPath))
+            return BX_DOL_STORAGE_ERR_NO_FILE;
+
+        return BX_DOL_STORAGE_ERR_OK;
+    }
+
+    function save($path)
+    {
+        if (!copy($this->sPath, $path)) {
+            return false;
+        }
+        return true;
+    }
+
+    function getName()
+    {
+        return pathinfo($this->sPath, PATHINFO_BASENAME);
+    }
+
+    function getSize()
+    {
+        return filesize($this->sPath);
+    }
+}
+
+/**
+ * Store file from URL
+ */
+class BxDolStorageHelperUrl extends BxDolStorageHelperPath
+{
+    protected $aMime2Ext = array (
+        'image/bmp' => 'bmp',
+        'image/gif' => 'gif',
+        'image/jpeg' => 'jpg',
+        'image/pjpeg' => 'jpg',
+        'image/png' => 'png',
+    );
+
+    function __construct ($aParams)
+    {   
+        $aParams['path'] = '';
+        $sExt = pathinfo(parse_url($aParams['url'], PHP_URL_PATH), PATHINFO_EXTENSION);
+        if ($sTmpFilename = tempnam(BX_DIRECTORY_PATH_TMP, '')) {
+            $s = '';
+            if (!$sExt) {
+                $s = @file_get_contents($aParams['url']);
+                $oFinfo = new finfo(FILEINFO_MIME_TYPE);
+                $sMime = $oFinfo->buffer($s);
+                if (isset($this->aMime2Ext[$sMime]))
+                    $sExt = $this->aMime2Ext[$sMime];
+            }
+
+            $aParams['path'] =  $sTmpFilename . '.' . $sExt;
+            @rename($sTmpFilename, $aParams['path']);
+
+            if ($s) {
+                file_put_contents($aParams['path'], $s);
+            } 
+            else {
+                $hRead = @fopen($aParams['url'], "rb");
+                $hWrite = fopen($aParams['path'], "wb");
+                if (false !== $hRead && false !== $hWrite) {
+                    while (!feof($hRead)) {
+                        $data = fread($hRead, 8192);
+                        if (false === fwrite($hWrite, $data)) {
+                            fclose($hWrite);
+                            file_put_contents($aParams['path'], '');
+                            break;
+                        }
+                    }
+                    fclose($hRead);
+                    fclose($hWrite);
+                }
+            }
+        }
+        parent::__construct($aParams);
+    }
+
+    function getImmediateError()
+    {
+        $iRet = parent::getImmediateError();
+        if (BX_DOL_STORAGE_ERR_OK != $iRet)
+            return $iRet;
+        return $this->getSize() > 0 ? BX_DOL_STORAGE_ERR_OK : BX_DOL_STORAGE_INVALID_FILE;
+    }
+
+    function __destruct() 
+    {
+        @unlink($this->sPath);
+    }
+}
+
+/**
+ * Handle file uploads from the same or another storage object
+ */
+class BxDolStorageHelperStorage
+{
+    protected $iFileId;
+    protected $oStorage;
+    protected $aFile;
+
+    function __construct ($aParams)
+    {
+        $this->iFileId = $aParams['id'];
+        $this->oStorage = BxDolStorage::getObjectInstance($aParams['storage']);
+
+        $this->aFile = false;
+        if ($this->oStorage)
+            $this->aFile = $this->oStorage->getFile($this->iFileId);
+    }
+
+    function getImmediateError()
+    {
+        if (!$this->iFileId)
+            return BX_DOL_STORAGE_ERR_NO_FILE;
+
+        if (!$this->oStorage)
+            return BX_DOL_STORAGE_ERR_ENGINE_GET;
+
+        if (!$this->aFile)
+            return BX_DOL_STORAGE_ERR_NO_FILE;
+
+        return BX_DOL_STORAGE_ERR_OK;
+    }
+
+    function save($path)
+    {
+        $s = bx_file_get_contents ($this->oStorage->getFileUrlById($this->iFileId));
+        if (!$s)
+            return false;
+
+        return file_put_contents($path, $s) ? true : false;
+    }
+
+    function getName()
+    {
+        return $this->aFile['file_name'];
+    }
+
+    function getSize()
+    {
+        return $this->aFile['size'];
+    }
+}
+
+/** @} */

+ 179 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolStorageLocal.php

@@ -0,0 +1,179 @@
+<?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
+ * @{
+ */
+
+define ('BX_DOL_STORAGE_DIR_RIGHTS', BX_DOL_DIR_RIGHTS);
+define ('BX_DOL_STORAGE_FILE_RIGHTS', BX_DOL_FILE_RIGHTS);
+
+/**
+ * File storage in local folder.
+ * @see BxDolStorage
+ */
+class BxDolStorageLocal extends BxDolStorage
+{
+    /**
+     * constructor
+     */
+    public function __construct($aObject)
+    {
+        parent::__construct($aObject);
+    }
+
+    /**
+     * Get file url.
+     * @param $iFileId file
+     * @return file url or false if file was not found
+     */
+    public function getFileUrlById($iFileId)
+    {
+        $aFile = $this->_oDb->getFileById($iFileId);
+        if (!$aFile)
+            return false;
+
+        $sUrl =  $this->getObjectBaseUrl($aFile['private']) . $aFile['remote_id'] . '.' . $aFile['ext'];
+        if ($aFile['private']) {
+            $sToken = $this->_oDb->genToken($iFileId);
+            $sUrl = bx_append_url_params($sUrl, array ('t' => $sToken));
+        }
+        return $sUrl;
+    }
+
+    /**
+     * Start file downloading by remote id. If file is private then token is checked.
+     */
+    public function download ($sRemoteId, $sToken = false, $mixedForceDownloadDialog = 'auto')
+    {
+        $this->setErrorCode(BX_DOL_STORAGE_ERR_OK);
+
+        $aFile = $this->_oDb->getFileByRemoteId($sRemoteId);
+        if (!$aFile) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_FILE_NOT_FOUND);
+            return false;
+        }
+
+        if ($aFile['private'] && !$this->_oDb->isTokenValid($aFile['id'], $sToken)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_PERMISSION_DENIED);
+            return false;
+        }
+        $sFileLocation = $this->getObjectBaseDir($aFile['private']) . $aFile['path'];
+        if (!file_exists($sFileLocation)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_FILE_NOT_FOUND);
+            return false;
+        }
+
+        if ('auto' === $mixedForceDownloadDialog) {
+            $bForceDownloadDialog = true;
+            foreach ($this->_aMimeTypesViewable as $sType) {
+                if (0 === strpos($aFile['mime_type'], $sType)) {
+                    $bForceDownloadDialog = false;
+                    break;
+                }
+            }
+        } 
+        else {
+            $bForceDownloadDialog = $mixedForceDownloadDialog;
+        }
+        
+
+        if (!bx_smart_readfile($sFileLocation, $aFile['file_name'], $aFile['mime_type'], $aFile['private'] && $this->_iCacheControl > $this->_aObject['token_life'] ? $this->_aObject['token_life'] : $this->_iCacheControl, $aFile['private'] ? 'private' : 'public', $bForceDownloadDialog ? 'attachment' : 'inline')) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_ENGINE_GET);
+            return false;
+        }
+
+        return parent::download($aFile);
+    }
+
+    // ----------------
+
+    protected function addFileToEngine($sTmpFile, $sLocalId, $sName, $isPrivate, $iProfileId)
+    {
+        $sPath = $this->genPath($sLocalId, $this->_aObject['levels']);
+        $sNewFileDir = $this->getObjectBaseDir($isPrivate) . $sPath;
+        $sNewFilePath = $sNewFileDir . $sLocalId;
+
+        $this->mkdir($sNewFileDir);
+        if (!file_exists($sNewFileDir) || !is_writable($sNewFileDir)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_FILESYSTEM_PERM);
+            return false;
+        }
+
+        if (!copy($sTmpFile, $sNewFilePath)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_ENGINE_ADD);
+            return false;
+        }
+
+        if (!chmod ($sNewFilePath, BX_DOL_STORAGE_FILE_RIGHTS)) {
+            unlink($sNewFilePath);
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_ENGINE_ADD);
+            return false;
+        }
+
+        return true;
+    }
+
+    protected function deleteFileFromEngine($sFilePath, $isPrivate)
+    {
+        $sFileLocation = $this->getObjectBaseDir($isPrivate) . $sFilePath;
+
+        if (!file_exists($sFileLocation))
+            return true;
+
+        if (!unlink($sFileLocation)) {
+            $this->setErrorCode(BX_DOL_STORAGE_ERR_UNLINK);
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Get the absolute local filesystem path of a stored file, or false when it is
+     * not available on disk.
+     * @param $mixedHandler file id or remote id
+     * @return string absolute path on success, false otherwise
+     */
+    public function getFileLocalPath ($mixedHandler)
+    {
+        $aFile = $this->getFile($mixedHandler);
+        if (!$aFile || empty($aFile['path']))
+            return false;
+
+        $sPath = $this->getObjectBaseDir($aFile['private']) . $aFile['path'];
+        return file_exists($sPath) ? $sPath : false;
+    }
+
+    protected function getObjectBaseDir ($isPrivate = false)
+    {
+        return BX_DIRECTORY_STORAGE . $this->_aObject['object'] . '/';
+    }
+
+    protected function getObjectBaseUrl ($isPrivate = false)
+    {
+        $o = BxDolPermalinks::getInstance();
+        return bx_absolute_url($o->permalink('storage.php?o=') . $this->_aObject['object'] . ($o->_isEnabled('storage.php?o=') ? '/' : '&f='));
+    }
+
+    protected function mkdir($sDirName, $sRights = false)
+    {
+        if (false == $sRights) 
+            $sRights = BX_DOL_STORAGE_DIR_RIGHTS;
+
+        $sDirName = bx_ltrim_str($sDirName, BX_DIRECTORY_PATH_ROOT); 
+        $aDirs = explode('/', $sDirName);
+        $sDir = BX_DIRECTORY_PATH_ROOT;
+        foreach ($aDirs as $sPart) {
+            $sDir .= $sPart.'/';
+            if (!is_dir($sDir) && strlen($sDir) > 0 && !file_exists($sDir)) {
+                mkdir($sDir); 
+                chmod($sDir, $sRights); 
+            }
+        }
+    }
+}
+
+/** @} */

+ 4001 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolTemplate.php

@@ -0,0 +1,4001 @@
+<?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
+ * @{
+ */
+
+ini_set('pcre.backtrack_limit', 1000000);
+
+define('BX_DOL_TEMPLATE_INJECTIONS_CACHE', 'sys_injections.inc');
+
+define('BX_DOL_TEMPLATE_CHECK_IN_BOTH', 'both');
+define('BX_DOL_TEMPLATE_CHECK_IN_BASE', 'base');
+define('BX_DOL_TEMPLATE_CHECK_IN_TMPL', 'tmpl');
+
+define('BX_DOL_COLOR_BG', 'bg');
+define('BX_DOL_COLOR_FT', 'ft');
+
+define('BX_DOL_PAGE_WIDTH', '1024px');
+
+/**
+ * Page display levels.
+ * Note. Both levels may refer to the same HTML templates. 
+ * 
+ * 'System' level. It uses page's NameIndex and
+ * is mainly used for pages which aren't registered 
+ * in 'sys_objects_page' table. Also it's used 
+ * in 'Injections' engine.  
+ */
+define('BX_PAGE_DEFAULT', 0); ///< default, regular page
+define('BX_PAGE_CLEAR', 2); ///< clear page, without any headers and footers
+define('BX_PAGE_EMBED', 22); ///< page used for embeds
+define('BX_PAGE_POPUP', 44); ///< popup page, without any headers and footers
+define('BX_PAGE_CONTENT_PRELOAD', 50); ///< page content preload for dynamic (htmx) loading
+define('BX_PAGE_CONTENT_WITH_COVER', 54); ///< page content with cover and submenu for dynamic (htmx) loading
+define('BX_PAGE_CONTENT_WITH_SUBMENU', 56); ///< page content with submenu for dynamic (htmx) loading
+define('BX_PAGE_CONTENT', 58); ///< page content only for dynamic (htmx) loading
+define('BX_PAGE_TRANSITION', 150); ///< transition page with redirect to display some msg, like 'please wait', without headers footers
+
+/**
+ * 'Builder based' level. It uses page's Type 
+ * from BxDolPage class and is used for pages 
+ * which are registered in 'sys_objects_page' table.
+ * Changeable in Studio -> Pages Builder -> Settings. 
+ */
+define('BX_PAGE_TYPE_DEFAULT', 1); ///< default, depends on the settins
+define('BX_PAGE_TYPE_DEFAULT_WO_HF', 2); ///< clear page, without any headers and footers
+define('BX_PAGE_TYPE_STANDARD', 3); ///< regular page divided on columns
+define('BX_PAGE_TYPE_APPLICATION', 4); ///< regular page divided on columns with left vertical menu(s) column
+
+/**
+ * Template engine.
+ *
+ * An object of the class allows to:
+ *  1. Manage HTML templates.
+ *  2. Get URL/path for any template image/icon.
+ *  3. Attach CSS/JavaScript files to the output.
+ *  4. Add some content to any template key using Injection engine.
+ *
+ *
+ * Avalable constructions.
+ *  1. &lt;bx_include_auto:template_name.html /&gt; - the content of the file would be inserted. File would be taken from current template if it existes there, and from base directory otherwise.
+ *  2. &lt;bx_include_base:template_name.html /&gt; - the content of the file would be inserted. File would be taken from base directory.
+ *  3. &lt;bx_include_tmpl:template_name.html /&gt; - the content of the file would be inserted. File would be taken from tmpl_xxx directory.
+ *  4. &lt;bx_url_root /&gt; - the value of BX_DOL_URL_ROOT variable will be inserted.
+ *  5. &lt;bx_url_admin /&gt; - the value of BX_DOL_URL_ADMIN variable will be inserted.
+ *  6. &lt;bx_text:_language_key /&gt; - _language_key will be translated using language file(function _t()) and inserted.
+       &lt;bx_text_js:_language_key /&gt; - _language_key will be translated using language file(function _t()) and inserted, use it to insert text into js string.
+       &lt;bx_text_attribute:_language_key /&gt; - _language_key will be translated using language file(function _t()) and inserted, use it to insert text into html attribute.
+ *  7. &lt;bx_image_url:image_file_name /&gt; - image with 'image_file_name' file name will be searched in the images folder of current template.
+ *     If it's not found, then it will be searched in the images folder of base template. On success full URL will be inserted, otherwise an empty string.
+ *  8. &lt;bx_icon_url:icon_file_name /&gt; - the same with &lt;bx_image_url:image_file_name /&gt;, but icons will be searched in the images/icons/ folders.
+ *  9. &lt;bx_injection:injection_name /&gt; - will be replaced with injections registered with the page and injection_name in the `sys_injections`/`sys_injections_admin`/ tables.
+ *  10. &lt;bx_if:tag_name&gt;some_HTML&lt;/bx_if:tag_name&gt; - will be replaced with provided content if the condition is true, and with empty string otherwise.
+ *  11. &lt;bx_repeat:cycle_name&gt;some_HTML&lt;/bx_repeat:cycle_name&gt; - an inner HTML content will be repeated in accordance with received data.
+ *
+ *
+ * Related classes:
+ *  BxDolTemplateAdmin - for processing admin templates.
+ *  Template classes in modules - for processing modiles' templates.
+ *
+ *
+ * Global variables:
+ *  oSysTemplate - is used for template processing in user part.
+ *  oAdmTemplate - is used for template processing in admin part.
+ *
+ *
+ * Add injection:
+ *  1. Register it in the `sys_injections` table or `sys_injections_admin` table for admin panel.
+ *  2. Clear injections cache(sys_injections.inc and sys_injections_admin.inc in cache folder).
+ *
+ *
+ * Predefined template keys to add injections:
+ *  1. injection_head - add injections in the &lt;head&gt; tag.
+ *  2. injection_body - add ingection(attribute) in the &lt;body&gt; tag.
+ *  3. injection_header - add injection inside the &lt;body&gt; tag at the very beginning.
+ *  4. injection_logo_before - add injection at the left of the main logo(inside logo's DIV).
+ *  5. injection_logo_after - add injection at the right of the main logo(inside logo's DIV).
+ *  6. injection_between_logo_top_menu - add injection between logo and top menu.
+ *  7. injection_top_menu_before - add injection at the left of the top menu(inside top menu's DIV).
+ *  8. injection_top_menu_after - add injection at the right of the top menu(inside top menu's DIV).
+ *  13. injection_content_before - add injection just before main content(inside content's DIV).
+ *  14. injection_content_after - add injection just after main content(inside content's DIV).
+ *  15. injection_between_content_footer - add injection between content and footer.
+ *  16. injection_footer_before - add injection at the left of the footer(inside footer's DIV).
+ *  17. injection_footer_after - add injection at the right of the footer(inside footer's DIV).
+ *  18. injection_footer - add injection inside the &lt;body&gt; tag at the very end.
+ *
+ *
+ * Example of usage:
+ * @code
+ *  $oSysTemplate = BxDolTemplate::getInstance();
+ *
+ *  $oSysTemplate->addCss(array('test1.css', 'test2.css'));
+ *  $oSysTemplate->addJs(array('test1.js', 'test2.js'));
+ *  $oSysTemplate->parseHtmlByName('messageBox.html', array(
+ *    'id' => $iId,
+ *     'msgText' => $sText,
+ *     'bx_if:timer' => array(
+ *        'condition' => $iTimer > 0,
+ *        'content' => array(
+ *           'id' => $iId,
+ *           'time' => 1000 * $iTimer,
+ *           'on_close' => $sOnClose,
+ *        )
+ *     ),
+ *     'bx_if:timer' => array(
+ *        array(
+ *           'name' => $sName,
+ *           'title' => $sTitle
+ *        ),
+ *        array(
+ *           'name' => $sName,
+ *           'title' => $sTitle
+ *        )
+ *     )
+ *  ));
+ * @endcode
+ *
+ *
+ * Memberships/ACL:
+ * Doesn't depend on user's membership.
+ *
+ *
+ * Alerts:
+ * no alerts available
+ *
+ */
+class BxDolTemplate extends BxDolFactory implements iBxDolSingleton
+{
+    protected static $_sColorClassPrefix = 'col-';
+    protected static $_sColorClassPrefixBg = 'bg-col-';
+    protected static $_aColors = array(
+    	'red1' => array(216, 9, 96), 
+    	'red1-dark' => array(194, 7, 86), 
+    	'red2' => array(231, 68, 30), 
+    	'red2-dark' => array(207, 60, 25), 
+    	'red3' => array(243, 143, 0), 
+    	'red3-dark' => array(218, 128, 0), 
+    	'green1' => array(96, 174, 0), 
+    	'green1-dark' => array(86, 156, 0), 
+    	'green2' => array(209, 211, 0), 
+    	'green2-dark' => array(186, 188, 0), 
+    	'green3' => array(48, 116, 36), 
+    	'green3-dark' => array(43, 104, 32), 
+    	'blue1' => array(10, 61, 143), 
+    	'blue1-dark' => array(9, 54, 128), 
+    	'blue2' => array(0, 164, 165), 
+    	'blue2-dark' => array(0, 146, 148), 
+    	'blue3' => array(0, 160, 206), 
+    	'blue3-dark' => array(0, 143, 184), 
+    	'gray' => array(97, 97, 97), 
+    	'gray-dark' => array(87, 87, 87)
+    );
+
+    protected static $_aImages;
+    protected static $_sImagesCacheKey;
+    protected static $_iImagesCacheTTL;
+
+    /**
+     * Main fields
+     */
+    protected $_sName;
+    protected $_sPrefix;
+    protected $_sRootPath;
+    protected $_sRootUrl;
+    protected $_sSubPath;
+    protected $_sInjectionsTable;
+    protected $_sInjectionsCache;
+    protected $_sCode;
+    protected $_sCodeKey;
+    protected $_iMix;
+    protected $_sMixKey;
+    protected $_sKeyWrapperHtml;
+    protected $_sFolderHtml;
+    protected $_sFolderCss;
+    protected $_sFolderImages;
+    protected $_sFolderIcons;
+    protected $_aTemplates;
+    protected $_aTemplatePatterns;
+
+    protected $_aLocations;
+    protected $_aLocationsJs;
+
+    /**
+     * Cache related fields
+     */
+    protected $_bCacheEnable;
+    protected $_sCacheFolderUrl;
+    protected $_sCachePublicFolderUrl;
+    protected $_sCachePublicFolderPath;
+    protected $_sCacheFilePrefix;
+    protected $_aCacheExceptions;
+
+    protected $_bImagesInline;
+    protected $_iImagesMaxSize;
+
+    protected $_bCssLess;
+    protected $_bCssCache;
+    protected $_bCssMinify;
+    protected $_bCssArchive;
+    protected $_sCssLessPrefix;
+    protected $_sCssCachePrefix;
+
+    protected $_bJsLess;
+    protected $_bJsCache;
+    protected $_bJsMinify;
+    protected $_bJsArchive;
+    protected $_sJsCachePrefix;
+
+    protected $aPage;
+    protected $aPageContent;
+    protected $aPageSnapshot = array();
+
+    protected $_oTemplateConfig;
+    protected $_oTemplateFunctions;
+
+    /**
+     * Constructor
+     */
+    protected function __construct($sRootPath = BX_DIRECTORY_PATH_ROOT, $sRootUrl = BX_DOL_URL_ROOT)
+    {
+        if(isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+        $this->_sPrefix = 'BxDolTemplate';
+
+        $this->_sRootPath = $sRootPath;
+        $this->_sRootUrl = $sRootUrl;
+        $this->_sInjectionsTable = 'sys_injections';
+        $this->_sInjectionsCache = BX_DOL_TEMPLATE_INJECTIONS_CACHE;
+
+        $this->_sCodeKey = BX_DOL_TEMPLATE_CODE_KEY;
+        $this->_sMixKey = BX_DOL_TEMPLATE_MIX_KEY;
+        list(
+            $this->_sCode, 
+            $this->_sName, 
+            $this->_sSubPath
+        ) = self::retrieveCode($this->_sCodeKey, $this->_sMixKey, $this->_sRootPath);
+
+        $this->_iMix = 0;
+        if(is_array($this->_sCode))
+            list($this->_sCode, $this->_iMix) = $this->_sCode;
+
+        if(!$this->_sSubPath)
+            $this->_sSubPath = 'boonex/' . BX_DOL_TEMPLATE_DEFAULT_CODE . '/';
+
+        if(!file_exists(BX_DIRECTORY_PATH_MODULES . $this->_sSubPath)) // just for 8.0.0-A6 upgrade
+            $this->_sSubPath = 'boonex/uni/';
+
+        if(isset($_GET[$this->_sCodeKey])) {
+            if(BxDolPermalinks::getInstance()->redirectIfNecessary(array($this->_sCodeKey)))
+                exit;
+        }
+
+        $this->_sKeyWrapperHtml = '__';
+        $this->_sFolderHtml = '';
+        $this->_sFolderCss = 'css/';
+        $this->_sFolderImages = 'images/';
+        $this->_sFolderIcons = 'images/icons/';
+        $this->_aTemplates = ['html_tags', 'menu_item_addon', 'menu_item_addon_small', 'menu_item_addon_middle'];
+        $this->_aTemplatePatterns = [
+            "'<bx_include_auto:([^\s]+) \/>'s" => BX_DOL_TEMPLATE_CHECK_IN_BOTH,
+            "'<bx_include_auto_mod_general:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BOTH, 'sub' => 'mod_general'],
+            "'<bx_include_auto_mod_profile:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BOTH, 'sub' => 'mod_profile'],
+            "'<bx_include_auto_mod_group:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BOTH, 'sub' => 'mod_group'],
+            "'<bx_include_auto_mod_text:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BOTH, 'sub' => 'mod_text'],
+            "'<bx_include_base:([^\s]+) \/>'s" => BX_DOL_TEMPLATE_CHECK_IN_BASE,
+            "'<bx_include_base_mod_general:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BASE, 'sub' => 'mod_general'],
+            "'<bx_include_base_mod_profile:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BASE, 'sub' => 'mod_profile'],
+            "'<bx_include_base_mod_group:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BASE, 'sub' => 'mod_group'],
+            "'<bx_include_base_mod_text:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_BASE, 'sub' => 'mod_text'],
+            "'<bx_include_tmpl:([^\s]+) \/>'s" => BX_DOL_TEMPLATE_CHECK_IN_TMPL,
+            "'<bx_include_tmpl_mod_general:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_TMPL, 'sub' => 'mod_general'],
+            "'<bx_include_tmpl_mod_profile:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_TMPL, 'sub' => 'mod_profile'],
+            "'<bx_include_tmpl_mod_group:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_TMPL, 'sub' => 'mod_group'],
+            "'<bx_include_tmpl_mod_text:([^\s]+) \/>'s" => ['in' => BX_DOL_TEMPLATE_CHECK_IN_TMPL, 'sub' => 'mod_text']
+        ];
+
+        $this->addLocation('system', $this->_sRootPath, $this->_sRootUrl);
+
+        $this->addLocationJs('system_inc_js', BX_DIRECTORY_PATH_INC . 'js/' , BX_DOL_URL_ROOT . 'inc/js/');
+        $this->addLocationJs('system_inc_js_classes', BX_DIRECTORY_PATH_INC . 'js/classes/' , BX_DOL_URL_ROOT . 'inc/js/classes/');
+        $this->addLocationJs('system_plugins_public', BX_DIRECTORY_PATH_PLUGINS_PUBLIC, BX_DOL_URL_PLUGINS_PUBLIC);
+
+        $this->_bCacheEnable = !bx_is_dbg() && !defined('BX_DOL_CRON_EXECUTE') && getParam('sys_template_cache_enable') == 'on';
+        $this->_sCacheFolderUrl = '';
+        $this->_sCachePublicFolderUrl = BX_DOL_URL_CACHE_PUBLIC;
+        $this->_sCachePublicFolderPath = BX_DIRECTORY_PATH_CACHE_PUBLIC;
+        $this->_sCacheFilePrefix = "bx_templ_";
+        $this->_aCacheExceptions = ['pt_standard.html', 'menu_icon.html', 'unit_showcase.html'];
+
+        $this->_bImagesInline = getParam('sys_template_cache_image_enable') == 'on';
+        $this->_iImagesMaxSize = (int)getParam('sys_template_cache_image_max_size') * 1024;
+
+        $bArchive = getParam('sys_template_cache_compress_enable') == 'on';
+
+        $this->_bCssLess = true; //--- Less cannot be disabled for CSS.
+        $this->_bCssCache = !bx_is_dbg() && !defined('BX_DOL_CRON_EXECUTE') && getParam('sys_template_cache_css_enable') == 'on';
+        $this->_bCssMinify = $this->_bCssCache && getParam('sys_template_cache_minify_css_enable') == 'on';
+        $this->_bCssArchive = $this->_bCssCache && $bArchive;
+        $this->_sCssLessPrefix = $this->_sCacheFilePrefix . 'less_';
+        $this->_sCssCachePrefix = $this->_sCacheFilePrefix . 'css_';
+
+        $this->_bJsLess = false; //--- Less language isn't available for JS at all.
+        $this->_bJsCache = !bx_is_dbg() && !defined('BX_DOL_CRON_EXECUTE') && getParam('sys_template_cache_js_enable') == 'on';
+        $this->_bJsMinify = $this->_bJsCache && getParam('sys_template_cache_minify_js_enable') == 'on';
+        $this->_bJsArchive = $this->_bJsCache && $bArchive;
+        $this->_sJsCachePrefix = $this->_sCacheFilePrefix . 'js_';
+
+        $this->aPage = [];
+        $this->aPageContent = [];
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__])) {
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolTemplate();
+            $GLOBALS['bxDolClasses'][__CLASS__]->init();
+        }
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    /**
+     * Retrieve template code and check whether it's associated with active template or not.
+     *
+     * @param string $sCodeKey template's code key.
+     * @param string $sMixKey template's mix key.
+     * @param string $sRootPath path to root directory.
+     */
+    public static function retrieveCode($sCodeKey = BX_DOL_TEMPLATE_CODE_KEY, $sMixKey = BX_DOL_TEMPLATE_MIX_KEY, $sRootPath = BX_DIRECTORY_PATH_ROOT)
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $fCheckCode = function($sCode, $bSetCookie) use($sCodeKey, $sRootPath) {
+            if(empty($sCode) || !preg_match('/^[A-Za-z0-9_-]+$/', $sCode))
+                return false;
+
+            $aModule = BxDolModuleQuery::getInstance()->getModuleByUri($sCode);
+            if(empty($aModule) || !is_array($aModule) || (int)$aModule['enabled'] != 1 || !file_exists(BX_DIRECTORY_PATH_MODULES . $aModule['path'] . 'data/template/'))
+                return false;
+            
+            $aResult = array(
+                $aModule['uri'], //--- Template module's URI is used as template Code.
+                $aModule['name'],
+                $aModule['path'],
+            );
+
+            if(!$bSetCookie || bx_get('preview'))
+                return $aResult;
+
+            bx_setcookie($sCodeKey, $sCode, time() + 60*60*24*365);
+
+            return $aResult;
+        };
+
+        $fCheckMix = function($aResult, $iMix, $bSetCookie) use($sMixKey, $sRootPath, $oDb) {
+            list($sCode, $sName) = $aResult;
+            if(empty($sName) || empty($iMix))
+                return false;
+
+            $aMix = $oDb->getParamsMix($iMix);
+            if(empty($aMix) || !is_array($aMix) || $aMix['type'] != $sName)
+                return false;
+
+            if(!$bSetCookie)
+                return $iMix;
+
+            bx_setcookie($sMixKey, $iMix, time() + 60*60*24*365);
+
+            return $iMix;
+        };
+
+        $sCode = getParam('template');
+        if(empty($sCode))
+            $sCode = BX_DOL_TEMPLATE_DEFAULT_CODE;
+        $aResult = $fCheckCode($sCode, false);
+
+        //--- Check selected template in COOKIE(the lowest priority) ---//
+        $sCode = !empty($_COOKIE[$sCodeKey]) ? $_COOKIE[$sCodeKey] : '';
+        $aResultCheck = $fCheckCode($sCode, false);
+        if($aResultCheck !== false)
+            $aResult = $aResultCheck;
+
+        //--- Check selected template in GET(the highest priority) ---//
+        $sCode = !empty($_GET[$sCodeKey]) ? $_GET[$sCodeKey] : '';
+        $aResultCheck = $fCheckCode($sCode, true);
+        if($aResultCheck !== false)
+            $aResult = $aResultCheck;
+
+        if($aResult === false) 
+            return $aResult;
+
+        if(!is_array($aResult[0]))
+            $aResult[0] = array($aResult[0]);
+
+        $iMixDefault = 0;        
+        if (!empty($aResult[1])) {
+            if (!BxDolDb::getInstance()->isParamInCache($aResult[1] . '_default_mix')) {
+                $GLOBALS['glMixesDisabled'] = true; // disable mixes, if template doesn't support mixes
+            } else {
+                $iMixDefault = (int)getParam($aResult[1] . '_default_mix');
+            }
+        }
+
+        //--- Check selected mix in COOKIE(the lowest priority) ---//
+        $iMix = !empty($_COOKIE[$sMixKey]) ? (int)$_COOKIE[$sMixKey] : 0;
+        $iResultCheck = $fCheckMix($aResult, $iMix, false);
+        if($iResultCheck !== false) {
+            $aMix = $oDb->getParamsMix($iMix);
+            if((int)$aMix['published'] == 0 && $iMix != $iMixDefault) {
+                $aUrl = parse_url(BX_DOL_URL_ROOT);
+                $sPath = isset($aUrl['path']) && !empty($aUrl['path']) ? $aUrl['path'] : '/';
+
+                setcookie($sMixKey, '', time() - 96 * 3600, $sPath);
+                unset($_COOKIE[$sMixKey]);
+            }
+            else
+                $aResult[0][1] = $iResultCheck;
+        }
+
+        //--- Check selected mix in GET(the highest priority) ---//
+        $iMix = !empty($_GET[$sMixKey]) ? (int)$_GET[$sMixKey] : 0;
+        $iResultCheck = $fCheckMix($aResult, $iMix, true);
+        if($iResultCheck !== false)
+            $aResult[0][1] = $iResultCheck;
+
+        //--- Get default mix for currently selected template ---//
+        if(empty($aResult[0][1]) && !empty($iMixDefault)) {
+            $iResultCheck = $fCheckMix($aResult, $iMixDefault, false);
+            if($iResultCheck !== false)
+                $aResult[0][1] = $iResultCheck;
+        }
+
+        if(is_array($aResult[0]) && count($aResult[0]) == 1)
+           $aResult[0] = $aResult[0][0];
+
+        return $aResult;
+    }
+
+    public function getIncludedUrls($sType)
+    {
+        if (!isset($this->aPage[$sType]))
+            return array();
+        $a = array();
+        foreach ($this->aPage[$sType] as $r)
+            $a[] = $r['url'];
+        return $a;
+    }
+
+    /**
+     * Remember current state of aPage variable with all css, js, etc
+     */
+    public function collectingStart()
+    {
+        $this->aPageSnapshot = $this->aPage;
+    }
+
+    public function collectingInject($aCss, $aJs)
+    {
+        $a = array('css' => 'aCss', 'js' => 'aJs');
+        foreach ($a as $s => $sVar) {
+            if (empty($$sVar))
+                continue;
+            $sKey = $s . '_compiled';
+            foreach ($$sVar as $r)
+                $this->aPage[$sKey][] = $r;
+        }
+    }
+
+    /**
+     * Get difference for non-system css and js files from previously remembered state as ready HTML code, 
+     * additionally filter out css and js from $aExcludeCss and $aExcludeJs arrays
+     */
+    public function collectingEndGetCode($aExcludeCss = array(), $aExcludeJs = array(), $sFormat = 'html')
+    {
+        if (!is_array($aExcludeCss))
+            $aExcludeCss = [];
+        if (!is_array($aExcludeJs))
+            $aExcludeJs = [];
+
+        $aPageSave = $this->aPage; // save current state to restore later
+
+        // filter funcs
+        $fFilterCss = function ($a) use ($aExcludeCss) {
+            if (in_array($a['url'], $aExcludeCss))
+                return false;
+            if (isset($this->aPageSnapshot['css_compiled']))
+                foreach ($this->aPageSnapshot['css_compiled'] as $r)
+                    if ($r['url'] == $a['url'])
+                        return false;
+            return true;
+        };
+        $fFilterJs = function ($a) use ($aExcludeJs) {
+            if (in_array($a['url'], $aExcludeJs))
+                return false;
+            if (isset($this->aPageSnapshot['js_compiled']))
+                foreach ($this->aPageSnapshot['js_compiled'] as $r)
+                    if ($r['url'] == $a['url'])
+                        return false;
+            return true;
+        };    
+
+        // diff aPageSnapshot and aPage and output only newly added css/js
+        $this->aPage['css_compiled'] = array_filter($this->aPage['css_compiled'], $fFilterCss);
+        $this->aPage['js_compiled'] = array_filter($this->aPage['js_compiled'], $fFilterJs);
+
+        // return js/css
+        $mixedRet = '';
+        if ('html' == $sFormat) {
+            $mixedRet .= $this->includeFiles('css');
+            $mixedRet .= $this->includeFiles('js');
+        }
+        else {
+            $mixedRet = array(
+                'css' => $this->aPage['css_compiled'],
+                'js' => $this->aPage['js_compiled'],
+            );
+        }
+
+        // restore original state
+        $this->aPageSnapshot = array();
+        $this->aPage = $aPageSave; 
+
+        return $mixedRet;
+    }
+
+    public function getClassName()
+    {
+        return get_class($this);
+    }
+
+    public static function getColorPalette()
+    {
+        $aResult = self::$_aColors;
+
+        /**
+         * @hooks
+         * @hookdef hook-system-get_color_palette 'system', 'get_color_palette' - hook on get color palette
+         * - $unit_name - equals `system`
+         * - $action - equals `get_color_palette` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `override_result` - [array] by ref, array of colors, can be overridden in hook processing
+         * @hook @ref hook-system-get_color_palette
+         */
+        bx_alert('system', 'get_color_palette', 0, false, array(
+            'override_result' => &$aResult
+        ));
+
+        if($aResult != self::$_aColors) {
+            $oTemplate = self::getInstance();
+            foreach($aResult as $sName => $aRgb) {
+                $sRgb = 'rgb(' . trim(implode(', ', $aRgb)) . ') !important';
+
+                $oTemplate->addCssStyle('.' . self::$_sColorClassPrefix . $sName, array(
+                    'color' => $sRgb
+                ));
+                $oTemplate->addCssStyle('.' . self::$_sColorClassPrefixBg . $sName, array(
+                    'background-color' => $sRgb
+                ));
+            }
+        }
+
+        return $aResult;
+    }
+
+    public static function getColorCode($mixedName = false, $fOpacity = false)
+    {
+        $aPalette = self::getColorPalette();
+        $aClasses = array_keys($aPalette);
+
+        if($mixedName === false || (is_string($mixedName) && !is_numeric($mixedName) && !in_array($mixedName, $aClasses)))
+            $mixedName = $aClasses[rand(0, count($aClasses) - 1)];
+        else if(is_numeric($mixedName))
+            $mixedName = $aClasses[(int)$mixedName % count($aClasses)];
+
+        $aColor = $aPalette[$mixedName];
+        if($fOpacity !== false && is_numeric($fOpacity))
+            $aColor[] = $fOpacity;
+
+        return $aColor;
+    }
+
+    public static function getColorClass($sType = BX_DOL_COLOR_FT, $sName = '')
+    {
+        $aClasses = array_keys(self::getColorPalette());
+
+        if(empty($sName) || !in_array($sName, $aClasses))
+            $sName = $aClasses[rand(0, count($aClasses) - 1)];
+
+        $sPrefix = '';
+        switch ($sType) {
+            case BX_DOL_COLOR_FT:
+                $sPrefix = self::$_sColorClassPrefix;
+                break;
+
+            case BX_DOL_COLOR_BG:
+                $sPrefix = self::$_sColorClassPrefixBg;
+                break;
+        }
+
+        return $sPrefix . $sName;
+    }
+
+    /**
+     * Load templates.
+     */
+    function loadTemplates()
+    {
+        $aResult = array();
+        foreach($this->_aTemplates as $sName)
+            $aResult[$sName] = $this->getHtml($sName . '.html');
+        $this->_aTemplates = $aResult;
+    }
+    /**
+     * Initialize template engine.
+     * Note. The method is executed with the system, you shouldn't execute it in your subclasses.
+     */
+    function init()
+    {
+        $this->loadTemplates();
+
+        //--- Load page elements related static variables ---//
+        $this->aPage = array(
+            'name_index' => BX_PAGE_DEFAULT,
+            'type' => BX_PAGE_TYPE_DEFAULT,
+            'url' => '',
+            'header' => '',
+            'header_text' => '',
+            'keywords' => array(),
+            'location' => array(),
+            'title' => '',
+            'description'  => '',
+            'robots' => '',
+            'base' => ['href' =>  BX_DOL_URL_ROOT],
+            'css_name' => array(),
+            'css_compiled' => array(),
+            'css_system' => array(),
+            'css_async' => array(),
+            'js_name' => array(),
+            'js_compiled' => array(),
+            'js_system' => array(),
+            'js_options' => array(),
+            'js_translations' => array(),
+            'js_images' => array(),
+            'injections' => array()
+        );
+
+        //--- Load default CSS, JS, etc ---//
+        BxDolPreloader::getInstance()->perform($this);
+
+        //--- Load injection's cache ---//
+        if (getParam('sys_db_cache_enable')) {
+            $oDb = BxDolDb::getInstance();
+            $oCache = $oDb->getDbCacheObject();
+            $sCacheKey = $oDb->genDbCacheKey($this->_sInjectionsCache);
+
+            $aInjections = $oCache->getData($sCacheKey);
+            if ($aInjections === null) {
+                $aInjections = $this->getInjectionsData();
+                $oCache->setData ($sCacheKey, $aInjections);
+            }
+        } 
+        else
+            $aInjections = $this->getInjectionsData();
+
+        $this->aPage['injections'] = $aInjections;
+
+        //--- Load images/icons cache ---//
+        $this->initImages();
+
+        bx_import('BxTemplConfig'); // TODO: for some reason autoloader isn't working here....
+        $this->_oTemplateConfig = BxTemplConfig::getInstance();
+
+        bx_import('BxTemplFunctions');
+        $this->_oTemplateFunctions = BxTemplFunctions::getInstance($this);
+
+        $this->addJsOption('sys_fixed_header');
+        $this->addJsOption('sys_confirmation_before_redirect');
+        $this->addJsOption('sys_storage_s3_endpoint');
+    }
+    
+    protected function initImages()
+    {
+        self::$_iImagesCacheTTL = 86400;
+        self::$_sImagesCacheKey = 'sys_layout_images_' . $this->_sCode .  '_' . bx_site_hash('images') . '.php';
+        self::$_aImages = BxDolDb::getInstance()->getDbCacheObject()->getData(self::$_sImagesCacheKey);
+        if(!self::$_aImages)
+            self::$_aImages = [];
+
+        /**
+         * @hooks
+         * @hookdef hook-system-get_layout_images 'system', 'get_layout_images' - hook on get layout images
+         * - $unit_name - equals `system`
+         * - $action - equals `get_layout_images` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `code` - [string] page code
+         *      - `override_result` - [array] by ref, array of images, can be overridden in hook processing
+         * @hook @ref hook-system-get_layout_images
+         */
+        bx_alert('system', 'get_layout_images', 0, false, [
+            'code' => $this->_sCode,
+            'override_result' => &self::$_aImages
+        ]);
+    }
+
+    protected function saveImages()
+    {
+        if(!self::$_iImagesCacheTTL)
+            $this->initImages();
+
+        BxDolDb::getInstance()->getDbCacheObject()->setData(self::$_sImagesCacheKey, self::$_aImages, self::$_iImagesCacheTTL);
+    }
+
+    protected function getInjectionsData ()
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $aInjections = $oDb->getAll("SELECT `page_index`, `name`, `key`, `type`, `data`, `replace` FROM `" . $this->_sInjectionsTable . "` WHERE `active`='1'");
+        if (!$aInjections)
+            return array();
+
+        foreach ($aInjections as $aInjection)
+            $aInjections['page_' . $aInjection['page_index']][$aInjection['key']][] = $aInjection;
+
+        return $aInjections;
+    }
+
+    /**
+     * Set page name index
+     * @param int $i name index
+     */
+    function setPageNameIndex($i)
+    {
+        $this->aPage['name_index'] = $i;
+    }
+    
+    /**
+     * Set page name index by target.
+     * @param string $s target. 
+     */
+    function setPageNameIndexByTarget($s)
+    {
+        $i = BX_PAGE_DEFAULT;
+
+        switch($s) {
+            case 'bx-content-preload':
+                $i = BX_PAGE_CONTENT_PRELOAD;
+                break;
+
+            case 'bx-content-with-toolbar-wrapper':
+                //$i = 52;
+                break;
+
+            case 'bx-content-with-cover-wrapper':
+                $i = BX_PAGE_CONTENT_WITH_COVER;
+                break;
+
+            case 'bx-content-with-submenu-wrapper':
+                $i = BX_PAGE_CONTENT_WITH_SUBMENU;
+                break;
+
+            case 'bx-content-wrapper':
+                $i = BX_PAGE_CONTENT;
+                break;
+        }
+
+        $this->aPage['name_index'] = $i;
+    }
+
+    /**
+     * Get page name index
+     * @return int $i name index
+     */
+    function getPageNameIndex()
+    {
+        return isset($this->aPage['name_index']) ? (int)$this->aPage['name_index'] : 0;
+    }
+
+	/**
+     * Set page type
+     * @param int $i page type
+     */
+    function setPageType($i)
+    {
+        $this->aPage['type'] = $i;
+    }
+    
+    /**
+     * Set page url
+     * @param string $s page url
+     */
+    function setPageUrl($s)
+    {
+        $this->aPage['url'] = $s;
+    }
+
+    /**
+     * Get page type
+     * @return int $i page type
+     */
+    function getPageType()
+    {
+        $iType = BX_PAGE_TYPE_DEFAULT;
+        if(isset($this->aPage['type']))
+            $iType = (int)$this->aPage['type'];
+
+        if($iType == BX_PAGE_TYPE_DEFAULT) 
+            $iType = (int)getParam('sys_pt_default_' . (isLogged() ? 'member' : 'visitor'));
+
+        return $iType;
+    }
+
+    /**
+     * Set page header
+     * @param string $s page header
+     */
+    function setPageHeader($s)
+    {
+        $this->aPage['header'] = $s;
+    }
+
+    /**
+     * Get page header
+     * @return string $s page header
+     */
+    function getPageHeader()
+    {
+        return $this->aPage['header'] ?? '';
+    }
+
+    /**
+     * Set page params. Available page params are: name_index, header
+     * @param array $a page params
+     */
+    function setPageParams($a)
+    {
+        if (!empty($this->aPage))
+            $this->aPage = array_merge($this->aPage, $a);
+        else
+            $this->aPage = $a;
+    }
+
+    /**
+     * Get page params.
+     * @return array $a page params
+     */
+    function getPageParams()
+    {
+        return $this->aPage;
+    }
+
+    /**
+     * Set page meta title.
+     *
+     * @param string sTitle necessary page description.
+     */
+    function setPageMetaTitle($sTitle)
+    {
+        $this->aPage['title'] = $sTitle;
+    }
+    
+    /**
+     * Set page description.
+     *
+     * @param string $sDescription necessary page description.
+     */
+    function setPageDescription($sDescription)
+    {
+        $this->aPage['description'] = $sDescription;
+    }
+
+    /**
+     * Set page meta robots.
+     *
+     * @param string $s page meta robots.
+     */
+    function setPageMetaRobots($s)
+    {
+        $this->aPage['robots'] = $s;
+    }
+
+    /**
+     * Set page injections.
+     *
+     * @param array $aInjections name => value injections.
+     */
+    function setPageInjections($aInjections)
+    {
+        if(empty($aInjections) || !is_array($aInjections))
+            return;
+
+        foreach($aInjections as $sName => $sValue)
+            $this->addInjection('injection_' . $sName, 'text', $sValue);
+    }
+    
+    /**
+     * Set page content for some variable.
+     * @param string $sVar     name of content variable
+     * @param string $sContent content for $sVar variable
+     * @param int    $iIndex   optional page index, default is index which was set before with @see setPageNameIndex function, or 0
+     */
+    function setPageContent($sVar, $sContent, $iIndex = false)
+    {
+        $i = false !== $iIndex ? $iIndex : $this->getPageNameIndex();
+        $this->aPageContent[$i][$sVar] = $sContent;
+    }
+
+    /**
+     * Get page content for some variable.
+     * @param  string $sVar   name of content variable
+     * @param  int    $iIndex optional page index, default is index which was set before with @see setPageNameIndex function, or 0
+     * @return string page content for some variable or for the whole page.
+     */
+    function getPageContent($sVar = false, $iIndex = false)
+    {
+        $i = false !== $iIndex ? $iIndex : $this->getPageNameIndex();
+        return false !== $sVar ? $this->aPageContent[$i][$sVar] : $this->aPageContent[$i];
+    }
+
+    /**
+     * Get currently active template name.
+     *
+     * @return string template's name.
+     */
+    function getName()
+    {
+        return $this->_sName;
+    }
+    
+    /**
+     * Get currently active template name.
+     *
+     * @return string template's name.
+     */
+    function getCssClassName()
+    {
+        return str_replace('_', '-', $this->_sName);
+    }
+
+    /**
+     * Get currently active template code.
+     *
+     * @return string template's code.
+     */
+    function getCode()
+    {
+        return $this->_sCode;
+    }
+    
+    /**
+     * Get embed code.
+     *
+     * @return string embed's code.
+     */
+    function getEmbed($sContent)
+    {
+        if ($sContent == ''){
+            header('Content-Security-Policy: frame-ancestors ' . getParam('sys_csp_frame_ancestors')) ;
+            $this->displayPageNotFound('', BX_PAGE_EMBED);
+            exit;
+        }
+        
+        $this->addJs(['inc/js/|embed.js']);
+        $this->addCss(['embed.css']);
+        $this->aPage['base']['target'] = '_blank';
+        $this->setPageNameIndex (BX_PAGE_EMBED);
+        $this->setPageContent('page_main_code', '<div class="bx-embed">' . $sContent . '</div>');
+        $this->getPageCode();
+    }
+
+    /**
+     * Get code key.
+     *
+     * @return string template's code key.
+     */
+    function getCodeKey()
+    {
+        return $this->_sCodeKey;
+    }
+
+    /**
+     * Get currently active template mix.
+     *
+     * @return integer template's mix.
+     */
+    function getMix()
+    {
+        return $this->_iMix;
+    }
+
+    /**
+     * Get currently active template path.
+     *
+     * @return string template's path.
+     */
+    function getPath()
+    {
+        return $this->_sSubPath;
+    }
+
+    /**
+     * Set page title.
+     * @deprecated use setPageHeader
+     *
+     * @param string $sTitle necessary page title.
+     */
+    function setPageTitle($sTitle)
+    {
+        $this->setPageHeader($sTitle);
+    }
+
+    /**
+     * Set page's main box title.
+     * @deprecated use setPageParams
+     *
+     * @param string $sTitle necessary page's main box title.
+     */
+    function setPageMainBoxTitle($sTitle)
+    {
+        $this->setPageParams(array('header_text' => $sTitle));
+    }
+
+    /**
+     * Check whether location exists or not.
+     * @param string $sKey - location's unique key.
+     */
+    function isLocation($sKey)
+    {
+        return isset($this->_aLocations[$sKey]);
+    }
+
+    /**
+     * Get a lis of all added locations.
+     * @return array with locations.
+     */
+    function getLocations()
+    {
+        return $this->_aLocations;
+    }
+
+    /**
+     * Add location in array of locations.
+     * Note. Location is the path/url to folder where 'templates' folder is stored.
+     *
+     * @param string $sKey          - location's unique key.
+     * @param string $sLocationPath - location's path. For modules: '[path_to_script]/modules/[vendor_name]/[module_name]/'
+     * @param string $sLocationUrl  - location's url. For modules: '[url_to_script]/modules/[vendor_name]/[module_name]/'
+     */
+    function addLocation($sKey, $sLocationPath, $sLocationUrl)
+    {
+        $this->_aLocations[$sKey] = array(
+            'path' => $sLocationPath,
+            'url' => $sLocationUrl,
+        );
+
+        return $sKey;
+    }
+    /**
+     * Add dynamic location.
+     *
+     * @param  string   $sLocationPath - location's path. For modules: '[path_to_script]/modules/[vendor_name]/[module_name]/'
+     * @param  string   $sLocationUrl  - location's url. For modules: '[url_to_script]/modules/[vendor_name]/[module_name]/'
+     * @return location key. Is needed to remove the location.
+     */
+    function addDynamicLocation($sLocationPath, $sLocationUrl)
+    {
+        $sLocationKey = time() . mt_rand();
+        $this->addLocation($sLocationKey, $sLocationPath, $sLocationUrl);
+
+        return $sLocationKey;
+    }
+    /**
+     * Remove location from array of locations.
+     * Note. Location is the path/url to folder where templates are stored.
+     *
+     * @param string $sKey - location's    unique key.
+     */
+    function removeLocation($sKey)
+    {
+        if(isset($this->_aLocations[$sKey]))
+           unset($this->_aLocations[$sKey]);
+    }
+	/**
+     * Check whether JS location exists or not.
+     * @param string $sKey - JS location's unique key.
+     */
+    function isLocationJs($sKey)
+    {
+        return isset($this->_aLocationsJs[$sKey]);
+    }
+    /**
+     * Add JS location in array of JS locations.
+     * Note. Location is the path/url to folder where JS files are stored.
+     *
+     * @param string $sKey          - location's    unique key.
+     * @param string $sLocationPath - location's path. For modules: '[path_to_script]/modules/[vendor_name]/[module_name]/js/'
+     * @param string $sLocationUrl  - location's url. For modules: '[url_to_script]/modules/[vendor_name]/[module_name]/js/'
+     */
+    function addLocationJs($sKey, $sLocationPath, $sLocationUrl)
+    {
+        $this->_aLocationsJs[$sKey] = array(
+            'path' => $sLocationPath,
+            'url' => $sLocationUrl
+        );
+
+        return $sKey;
+    }
+    /**
+     * Add dynamic JS location.
+     *
+     * @param  string   $sLocationPath - location's path. For modules: '[path_to_script]/modules/[vendor_name]/[module_name]/'
+     * @param  string   $sLocationUrl  - location's url. For modules: '[url_to_script]/modules/[vendor_name]/[module_name]/'
+     * @return location key. Is needed to remove the location.
+     */
+    function addDynamicLocationJs($sLocationPath, $sLocationUrl)
+    {
+        $sLocationKey = time() . mt_rand();
+        $this->addLocationJs($sLocationKey, $sLocationPath, $sLocationUrl);
+
+        return $sLocationKey;
+    }
+    /**
+     * Remove JS location from array of locations.
+     * Note. Location is the path/url to folder where templates are stored.
+     *
+     * @param string $sKey - JS location's    unique key.
+     */
+    function removeLocationJs($sKey)
+    {
+        if(isset($this->_aLocationsJs[$sKey]))
+           unset($this->_aLocationsJs[$sKey]);
+    }
+    /**
+     * Add Option in JS output.
+     *
+     * @param mixed $mixedName option's name or an array of options' names.
+     */
+    function addJsOption($mixedName)
+    {
+        if(is_string($mixedName))
+            $mixedName = array($mixedName);
+
+        foreach($mixedName as $sName)
+            $this->aPage['js_options'][$sName] = getParam($sName);
+    }
+    /**
+     * Add language translation for key in JS output.
+     *
+     * @param mixed $mixedKey language key or an array of keys.
+     */
+    function addJsTranslation($mixedKey, $bDynamic = false)
+    {
+        if(is_string($mixedKey))
+            $mixedKey = array($mixedKey);
+
+        foreach($mixedKey as $sKey)
+            $this->aPage['js_translations'][$sKey] = _t($sKey, '{0}', '{1}');
+
+        return $bDynamic ? $this->_processJsTranslations() : '';
+    }
+
+    /**
+     * get added js translations
+     */ 
+    function getJsTranslation($bDynamic = false)
+    {
+        return $bDynamic ? $this->_processJsTranslations() : $this->aPage['js_translations'];
+    }
+
+    /**
+     * Add image in JS output.
+     *
+     * @param array $aImages an array of image descriptors.
+     *                       The descriptor is a key/value pear in the array of descriptors.
+     */
+    function addJsImage($aImages)
+    {
+        if(!is_array($aImages))
+            return;
+
+        foreach($aImages as $sKey => $sFile) {
+            $sUrl = $this->getImageUrl($sFile);
+            if(empty($sUrl))
+                continue;
+
+            $this->aPage['js_images'][$sKey] = $sUrl;
+        }
+    }
+    /**
+     * Add icon in JS output.
+     *
+     * @param array $aIcons an array of icons descriptors.
+     *                      The descriptor is a key/value pear in the array of descriptors.
+     */
+    function addJsIcon($aIcons)
+    {
+        if(!is_array($aIcons))
+            return;
+
+        foreach($aIcons as $sKey => $sFile) {
+            $sUrl = $this->getIconUrl($sFile);
+            if(empty($sUrl))
+                continue;
+
+            $this->aPage['js_images'][$sKey] = $sUrl;
+        }
+    }
+	/**
+	 * Add CSS style.
+	 *
+	 * @param string $sName CSS class name.
+	 * @param string $sContent CSS class styles.
+	 */
+	function addCssStyle($sName, $sContent)
+	{
+		$this->aPage['css_styles'][$sName] = $sContent;
+	}
+    /**
+     * Set page keywords.
+     *
+     * @param mixed  $mixedKeywords necessary page keywords(string - single keyword, array - an array of keywords).
+     * @param string $sDevider      - string devider.
+     */
+    function addPageKeywords($mixedKeywords, $sDevider = ',')
+    {
+        if(is_string($mixedKeywords))
+            $mixedKeywords = strpos($mixedKeywords, $sDevider) !== false ? explode($sDevider, $mixedKeywords) : array($mixedKeywords);
+
+        foreach($mixedKeywords as $iKey => $sValue)
+            $mixedKeywords[$iKey] = trim($sValue);
+
+        $this->aPage['keywords'] = isset($this->aPage['keywords']) && is_array($this->aPage['keywords']) ? array_merge($this->aPage['keywords'], $mixedKeywords) : $mixedKeywords;
+    }
+    /**
+     * Set page locatoin coordinates.
+     *
+     * @param $fLat latitude
+     * @param $fLng longitude
+     */
+    function addPageMetaLocation($fLat, $fLng, $sCountryCode)
+    {
+        $this->aPage['location'] = array('lat' => $fLat, 'lng' => $fLng, 'country' => $sCountryCode);
+    }
+    /**
+     * Set page meta image.
+     *
+     * @param $sImageUrl meta image url
+     */
+    function addPageMetaImage($sImageUrl)
+    {
+        $this->aPage['image'] = $sImageUrl;
+    }
+    /**
+     * Set page rss link.
+     *
+     * @param $sTitle - rss feed title
+     * @param $sUrl - rss feed URL
+     */
+    function addPageRssLink($sTitle, $sUrl)
+    {
+        if (!isset($this->aPage['rss']))
+            $this->aPage['rss'] = array('title' => $sTitle, 'url' => $sUrl);
+        else
+            $this->aPage['rss'] = false;
+    }
+    /**
+     * Returns page meta info, like meta keyword, meta description, location, etc
+     */
+    function getMetaInfo()
+    {
+        $sRet = '';
+
+        $oPage = BxDolPage::getObjectInstanceByURI();
+        $bPage = $oPage !== false;
+
+        // general meta tags
+        if(!empty($this->aPage['keywords']) && is_array($this->aPage['keywords']))
+            $sRet .= '<meta name="keywords" content="' . bx_html_attribute(implode(',', $this->aPage['keywords'])) . '" />';
+
+        $sUrl = $sUri = '';
+        if(($sKu = 'url') && !empty($this->aPage[$sKu])) {
+            $sUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink($this->aPage[$sKu]));
+
+            list(, $aPageParams) = bx_get_base_url($this->aPage[$sKu]);
+            $sUri = $aPageParams['i'] ?? '';
+        }
+            
+        
+        // Process header
+        $sHeader = '';
+        if(($sKh = 'header') && !empty($this->aPage[$sKh]) && is_string($this->aPage[$sKh]))
+            $sHeader = $this->aPage[$sKh];
+        $bHeader = !empty($sHeader);
+
+        if($bHeader) {
+            $sHeader = strip_tags($sHeader);
+
+            // Collapse spaces/newlines and fix run-on words
+            $sHeader = preg_replace('/\s+/', ' ', $sHeader);                     
+            $sHeader = preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $sHeader);
+            $sHeader = trim($sHeader);
+
+            $bHeader = !empty($sHeader);
+        }
+
+        $sHeaderAttr = $bHeader ? bx_html_attribute($sHeader) : '';
+
+        // Process description
+        $sDescription = '';
+        if(($sKd = 'description') && !empty($this->aPage[$sKd]) && is_string($this->aPage[$sKd]))
+            $sDescription = $this->aPage[$sKd];
+        if(!$sDescription && $bPage)
+            $sDescription = $oPage->getMetaDescription();
+        $bDescription = !empty($sDescription);
+
+        if($bDescription) {
+            // Convert HTML breaks/lists to spaces before stripping tags
+            $sDescription = preg_replace('#<(?:br\s*/?|/p|/li)>#i', ' ', $sDescription);
+            $sDescription = strip_tags($sDescription);
+
+            // Normalize whitespace
+            $sDescription = preg_replace('/\s+/u', ' ', $sDescription);
+
+            // Ensure space after punctuation if missing (".This" → ". This")
+            $sDescription = preg_replace('/([.,;:])([^\s])/u', '$1 $2', $sDescription);
+            $sDescription = trim($sDescription);
+
+            if(($iDescriptionMaxLen = 300) && mb_strlen($sDescription) > $iDescriptionMaxLen) {
+                $sDescription = mb_substr($sDescription, 0, $iDescriptionMaxLen);
+
+                // Trim partial word and remove dangling punctuation
+                $sDescription = preg_replace('/\s+\S*$/u', '', $sDescription);
+                $sDescription = rtrim($sDescription, " ,;:-");
+
+                $sDescription .= '...';
+            }
+            
+            $bDescription = !empty($sDescription);
+        }
+
+        $sDescriptionAttr = '';
+        if($bDescription && ($sDescriptionAttr = bx_html_attribute($sDescription)))
+            $sRet .= '<meta name="description" content="' . $sDescriptionAttr . '" />';
+
+        // location
+        if(!empty($this->aPage['location']) && isset($this->aPage['location']['lat']) && isset($this->aPage['location']['lng']) && isset($this->aPage['location']['country']))
+            $sRet .= '
+                <meta name="ICBM" content="' . $this->aPage['location']['lat'] . ';' . $this->aPage['location']['lng'] . '" />
+                <meta name="geo.position" content="' . $this->aPage['location']['lat'] . ';' . $this->aPage['location']['lng'] . '" />
+                <meta name="geo.region" content="' . bx_html_attribute($this->aPage['location']['country']) . '" />';
+
+        //set meta[image] value
+        if(empty($this->aPage['image'])) {
+            // use cover image if exists
+            if($bPage && ($aCover = $oPage->getPageCoverImage()))
+                $this->aPage['image'] = BxDolCover::getInstance($this)->getCoverImageUrl($aCover);
+
+            // use system Apple/Android icons if exists
+            if(empty($this->aPage['image'])) {
+                $oImgStorage = BxDolStorage::getObjectInstance(BX_DOL_STORAGE_OBJ_IMAGES);
+                foreach(['icon_android_splash', 'icon_android', 'icon_apple'] as $sIcon)
+                    if(($iIcon = (int)getParam('sys_site_' . $sIcon)) != 0 && ($sImageUrl = $oImgStorage->getFileUrlById($iIcon))) {
+                        $this->aPage['image'] = $sImageUrl;
+                        break;
+                    }
+            }
+        }
+
+        // facebook / twitter
+        $bPageImage = !empty($this->aPage['image']);
+        $sRet .= '<meta name="twitter:card" content="' . ($bPageImage ? 'summary_large_image' : 'summary') . '" />';
+
+        $sOgType = 'website';
+        if(preg_match("/(discussion|glossary|item|post)/", $sUri))
+            $sOgType = 'article';
+        else if(preg_match("/(ad|product|shopify\-entry|snipcart\-entry)/", $sUri))
+            $sOgType = 'product';
+        else if(preg_match("/(album|album\-media)/", $sUri))
+            $sOgType = 'album';
+        else if(strpos($sUri, 'profile') !== false)
+            $sOgType = 'profile';
+        else if(strpos($sUri, 'photo') !== false)
+            $sOgType = 'image';
+        else if(strpos($sUri, 'video') !== false)
+            $sOgType = 'video.other';
+
+        $sRet .= '<meta property="og:type" content="' . $sOgType . '" />';
+        $sRet .= '<meta property="og:title" content="' . $sHeaderAttr . '" />';
+        $sRet .= '<meta property="og:description" content="' . $sDescriptionAttr . '" />';
+        if($bPageImage)
+            $sRet .= '<meta property="og:image" content="' . $this->aPage['image'] . '" />';
+
+        // Smart App Banner
+        if(getParam('smart_app_banner') && false === strpos($_SERVER['HTTP_USER_AGENT'], 'UNAMobileApp')) {
+            if($sAppIdIOS = getParam('smart_app_banner_ios_app_id'))
+                $sRet .= '<meta name="apple-itunes-app" content="app-id=' . $sAppIdIOS . '" />';
+        }
+
+        // RSS
+        $oFunctions = BxTemplFunctions::getInstance();
+        $sRet .= $oFunctions->getManifests();
+        $sRet .= $oFunctions->getMetaIcons();
+        
+        if(!empty($this->aPage['rss']) && !empty($this->aPage['rss']['url']))
+            $sRet .= '<link rel="alternate" type="application/rss+xml" href="' . $this->aPage['rss']['url'] . '" title="' . bx_html_attribute($this->aPage['rss']['title'], BX_ESCAPE_STR_QUOTE) . '" />';
+        $sRet .= '<link rel="alternate" type="application/json+oembed" href="' . BX_DOL_URL_ROOT . 'em.php?url=' . urlencode($_SERVER["REQUEST_URI"]) . '&format=json" title="' . $sHeaderAttr . '" />';
+
+        if($sUrl)
+            $sRet .= '<link rel="canonical" href="' . $sUrl . '" />';
+
+        return $sRet;
+    }
+    /**
+     * Get template, which was loaded earlier.
+     * @see method this->loadTemplates and field this->_aTemplates
+     *
+     * @param  string $sName - template name.
+     * @return string template's content.
+     */
+    public function getTemplate($sName)
+    {
+        return $this->_aTemplates[$sName];
+    }
+
+    /**
+     * Get template functions object.
+     * @see BxBaseFunctions
+     */
+    public function getTemplateFunctions()
+    {
+        return $this->_oTemplateFunctions;
+    }
+
+    /**
+     * Get image MIME type.
+     * 
+     * @param string $sExtension - image file's extension.
+     * @return string with MIME type. 
+     */
+    function getImageMimeType($sExtension)
+    {
+    	$sExtension = strtolower($sExtension);
+    
+    	$sResult = '';
+    	switch($sExtension) {
+    		case 'svg':
+    			$sResult = 'svg+xml';
+    			break;
+    
+    		default:
+    			$sResult = $sExtension;
+    	}
+    
+    	return 'data:image/' . $sResult;
+    }
+
+    /**
+     * Get icon template in dependence of a value, provided in $mixedId.
+     * 
+     * @param  mixed $mixedId numeric id from Storage, string with template's file name or string with font icon.
+     */
+	public function getIcon($mixedId, $aParams = array())
+    {
+        return $this->_getImage('icon', $mixedId, $aParams);
+    }
+
+    /**
+     * Get image template in dependence of a value, provided in $mixedId.
+     * 
+     * @param  mixed $mixedId numeric id from Storage, string with template's file name or string with font icon.
+     */
+    public function getImage($mixedId, $aParams = [])
+    {
+        return $this->_getImage('image', $mixedId, $aParams);
+    }
+
+    protected function _getImage($sType, $mixedId, $aParams = [])
+    {
+        $bWrap = ($sKey = 'wrap_in_tag') && (!isset($aParams[$sKey]) || $aParams[$sKey] === true);
+
+        $sUrl = "";
+        $aType2Method = ['image' => 'getImageUrl', 'icon' => 'getIconUrl'];
+
+        //--- Check in System Storage.
+        if(is_numeric($mixedId) && (int)$mixedId > 0) {
+            $sStorage = BX_DOL_STORAGE_OBJ_IMAGES;
+            if(!empty($aParams['storage'])) {
+                $sStorage = $aParams['storage'];
+                unset($aParams['storage']);
+            }
+
+            if(($sResult = BxDolStorage::getObjectInstance($sStorage)->getFileUrlById((int)$mixedId)) !== false)
+                $sUrl = $sResult;
+        }
+
+        //--- Check in template folders.
+        if($sUrl == "" && is_string($mixedId) && strpos($mixedId, '.') !== false)
+            $sUrl = $this->{$aType2Method[$sType]}($mixedId);
+
+        if($sUrl != "")
+            return $bWrap ? $this->parseImage($sUrl, [
+                'class' => isset($aParams['class']) && !empty($aParams['class']) ? $aParams['class'] : '',
+            	'alt' => isset($aParams['alt']) && !empty($aParams['alt']) ? $aParams['alt'] : ''
+            ]) : $sUrl;
+
+        //--- Use iconic font.
+        return $bWrap ? $this->parseIcon($mixedId, $aParams) : $mixedId;
+    }
+
+    /**
+     * Get full URL for the icon.
+     *
+     * @param  string $sName    icon's file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string full URL.
+     */
+    function getIconUrl($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        $sContent = "";
+        if(($sContent = $this->_getInlineData('icon', $sName, $sCheckIn)) !== false)
+            return $sContent;
+
+        return $this->_getAbsoluteLocation('url', $this->_sFolderIcons, $sName, $sCheckIn);
+    }
+    /**
+     * Get absolute Path for the icon.
+     *
+     * @param  string $sName    - icon's file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string absolute path.
+     */
+    function getIconPath($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        return $this->_getAbsoluteLocation('path', $this->_sFolderIcons, $sName, $sCheckIn);
+    }
+
+    function getIconContent($sName)
+    {
+        if(strpos($sName, '.svg') === false)
+            return false;
+
+        $mixedResult = false;
+        if(($sIconPath = $this->getIconPath($sName)))
+            $mixedResult = file_get_contents($sIconPath);
+        else
+            $mixedResult = bx_file_get_contents($sName);
+
+        return $mixedResult;
+    }
+    /**
+     * Get image/icon by name automatically. Cache item description:
+     * [
+     *      'v' => value (name, url or source),
+     *      'c' => classes list divided with comma (,)
+     *      't' => parse type (ic - icon, im - image, sc - source)
+     * ]
+     * Cached images can be overwritten by listening 'system' - 'get_layout_images' alert.
+     * 
+     * @param string $sName unique name. The following format can be used: name|classes. 
+     * Where name and classes can consists of multiple parts (divided with comma (,) in HTML variant). For example, 
+     *     in PHP: $this->getImageAuto('far star|class1 class2');
+     *     in HTML: <bx_image_auto:far,star|class1,class2 />
+     * @param boolean $bWrapped wrap in HTML tag or not.
+     * @param string $sCheckIn where the content would be searched(base, template, both)
+     * @return string icon/image value (name, url or source) or final HTML code.
+     */
+    function getImageAuto($sName, $bWrapped = true, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        $sDivParts = '|';
+        $sDivItems = ',';
+
+        $sKey = md5($sName);
+
+        $sClasses = '';
+        if(strpos($sName, $sDivParts) !== false)
+            list($sName, $sClasses) = explode($sDivParts, $sName);
+
+        if(strpos($sName, $sDivItems) !== false)
+            $sName = implode(' ', explode($sDivItems, $sName));
+
+        if(!isset(self::$_aImages[$sKey])) {
+            $aResult = [
+                'v' => $sName,
+                't' => 'ic',
+                'c' => $sClasses
+            ];
+
+            $sUrl = '';
+            foreach(['Image', 'Icon'] as $sType) 
+                foreach(['svg', 'png', 'jpg', 'gif'] as $sExt)
+                    if(($sUrl = $this->{'get' . $sType . 'Url'}($sName . '.' . $sExt, $sCheckIn)) != '') {
+                        $aResult = [
+                            'v' => $sUrl,
+                            't' => 'im',
+                            'c' => $sClasses
+                        ];
+                        break 2;
+                    }
+
+            self::$_aImages[$sKey] = $aResult;
+
+            $this->saveImages();
+        }
+
+        if(!self::$_aImages[$sKey]['v'])
+            return '';
+
+        if(!$bWrapped || self::$_aImages[$sKey]['t'] == 'sc')
+            return self::$_aImages[$sKey]['v'];       
+
+        $aAttrs = [];
+        if(self::$_aImages[$sKey]['c'] != '')
+            $aAttrs['class'] = implode(' ', explode($sDivItems, self::$_aImages[$sKey]['c']));
+
+        $aType2Method = ['ic' => 'parseIcon', 'im' => 'parseImage'];
+        return $this->{$aType2Method[self::$_aImages[$sKey]['t']]}(self::$_aImages[$sKey]['v'], $aAttrs);
+    }
+
+    /**
+     * Get full URL for the image.
+     *
+     * @param  string $sName    - images's file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string full URL.
+     */
+    function getImageUrl($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        $sContent = "";
+        if(($sContent = $this->_getInlineData('image', $sName, $sCheckIn)) !== false)
+            return $sContent;
+
+        return $this->_getAbsoluteLocation('url', $this->_sFolderImages, $sName, $sCheckIn);
+    }
+    /**
+     * Get absolute Path for the image.
+     *
+     * @param  string $sName    - image's file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string absolute path.
+     */
+    function getImagePath($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        return $this->_getAbsoluteLocation('path', $this->_sFolderImages, $sName, $sCheckIn);
+    }
+    /**
+     * Get full URL of CSS file.
+     *
+     * @param  string $sName    - CSS file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string full URL.
+     */
+    function getCssUrl($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        if(($aFile = $this->_locateFile('css', $sName)) !== false)
+            return $aFile[0];
+
+        return $this->_getAbsoluteLocation('url', $this->_sFolderCss, $sName, $sCheckIn);
+    }
+    function getCssUrlWithRevision($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        $sUrl = $this->getCssUrl($sName, $sCheckIn);
+        if(!empty($sUrl))
+            $sUrl = $this->addRevision($sUrl);
+
+        return $sUrl;
+    }
+
+    /**
+     * Get full Path of CSS file.
+     *
+     * @param  string $sName    - CSS file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string full URL.
+     */
+    function getCssPath($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        if(($aFile = $this->_locateFile('css', $sName)) !== false)
+            return $aFile[1];
+
+        return $this->_getAbsoluteLocation('path', $this->_sFolderCss, $sName, $sCheckIn);
+    }
+    /**
+     * Get full URL of JS file.
+     *
+     * @param  string $sName    - JS file name.
+     * @return string full URL.
+     */
+    function getJsUrl($sName)
+    {
+        if(($aFile = $this->_locateFile('js', $sName)) !== false)
+            return $aFile[0];
+
+        return $this->_getAbsoluteLocationJs('url', $sName);
+    }
+    function getJsUrlWithRevision($sName)
+    {
+        $sUrl = $this->getJsUrl($sName);
+        if(!empty($sUrl))
+            $sUrl = $this->addRevision($sUrl);
+
+        return $sUrl;
+    }
+    /**
+     * Get full Path of JS file.
+     *
+     * @param  string $sName    - JS file name.
+     * @return string full URL.
+     */
+    function getJsPath($sName)
+    {
+        if(($aFile = $this->_locateFile('js', $sName)) !== false)
+            return $aFile[1];
+
+        return $this->_getAbsoluteLocationJs('path', $sName);
+    }
+    /**
+     * Get full URL of Template (HTML) file.
+     *
+     * @param  string $sName    - Template file name.
+     * @return string full URL.
+     */
+    function getTemplateUrl($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        return $this->_getAbsoluteLocation('url', $this->_sFolderHtml, $sName, $sCheckIn);
+    }
+    /**
+     * Get full Path of JS file.
+     *
+     * @param  string $sName    - JS file name.
+     * @return string full URL.
+     */
+    function getTemplatePath($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        return $this->_getAbsoluteLocation('path', $this->_sFolderHtml, $sName, $sCheckIn);
+    }
+
+    /**
+     * Get menu.
+     * @param $s menu object name
+     * @return html or empty string
+     */
+    function getMenu ($s)
+    {
+        $oMenu = BxDolMenu::getObjectInstance($s);
+        
+        if($s == 'sys_site_submenu'){
+            $oPage = BxDolPage::getObjectInstanceByURI();
+            if ($oPage && $oPage->getSubMenu() == 'disabled'){
+                return;
+            }
+        }
+        return $oMenu ? $oMenu->getCode () : '';
+    }
+
+    /**
+     * Check whether HTML file exists or not.
+     *
+     * @param  string $sName    - HTML file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return boolean result of operation.
+     */
+    function isHtml($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        return $this->_getAbsoluteLocation('path', $this->_sFolderHtml, $sName, $sCheckIn) != '';
+    }
+
+    /**
+     * Get content of HTML file.
+     *
+     * @param  string $sName    - HTML file name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string full content of the file and false on failure.
+     */
+    function getHtml($sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        $sAbsolutePath = $this->_getAbsoluteLocation('path', $this->_sFolderHtml, $sName, $sCheckIn);
+        return !empty($sAbsolutePath) ? trim(file_get_contents($sAbsolutePath)) : false;
+    }
+
+    /**
+     * Parse HTML template. Search for the template with accordance to it's file name.
+     *
+     * @see allows to use cache.
+     *
+     * @param  string $sName               - HTML file name.
+     * @param  array  $aVariables          - key/value pairs. key should be the same as template's key, but without prefix and postfix.
+     * @param  mixed  $mixedKeyWrapperHtml - key wrapper(string value if left and right parts are the same, array(left, right) otherwise).
+     * @param  string $sCheckIn            where the content would be searched(base, template, both)
+     * @return string the result of operation.
+     */
+    function parseHtmlByName($sName, $aVariables, $mixedKeyWrapperHtml = null, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        if (isset($GLOBALS['bx_profiler'])) $GLOBALS['bx_profiler']->beginTemplate($sName, $sRand = time().rand());
+
+        if (($sContent = $this->getCached($sName, $aVariables, $mixedKeyWrapperHtml, $sCheckIn)) !== false) {
+            if (isset($GLOBALS['bx_profiler'])) $GLOBALS['bx_profiler']->endTemplate($sName, $sRand, $sContent, true);
+            return $sContent;
+        }
+
+        $sRet = '';
+        if (($sContent = $this->getHtml($sName, $sCheckIn)) !== false)
+            $sRet = $this->_parseContent($sContent, $aVariables, $mixedKeyWrapperHtml);
+
+        if (isset($GLOBALS['bx_profiler'])) $GLOBALS['bx_profiler']->endTemplate($sName, $sRand, $sRet, false);
+
+        return $sRet;
+    }
+    /**
+     * Parse HTML template.
+     *
+     * @see Doesn't allow to use cache.
+     *
+     * @param  string $sContent            - HTML file content.
+     * @param  array  $aVariables          - key/value pairs. key should be the same as template's key, but without prefix and postfix.
+     * @param  mixed  $mixedKeyWrapperHtml - key wrapper(string value if left and right parts are the same, array(left, right) otherwise).
+     * @return string the result of operation.
+     */
+    function parseHtmlByContent($sContent, $aVariables, $mixedKeyWrapperHtml = null)
+    {
+        if(empty($sContent))
+            return "";
+
+        return $this->_parseContent($sContent, $aVariables, $mixedKeyWrapperHtml);
+    }
+    /**
+     * Parse earlier loaded HTML template.
+     *
+     * @see Doesn't allow to use cache.
+     *
+     * @param  string $sName      - template name.
+     * @param  array  $aVariables - key/value pairs. Key should be the same as template's key, excluding prefix and postfix.
+     * @return string the result of operation.
+     * @see $this->_aTemplates
+     */
+    function parseHtmlByTemplateName($sName, $aVariables, $mixedKeyWrapperHtml = null)
+    {
+        if(!isset($this->_aTemplates[$sName]) || empty($this->_aTemplates[$sName]))
+            return "";
+
+        return $this->_parseContent($this->_aTemplates[$sName], $aVariables, $mixedKeyWrapperHtml);
+    }
+    /**
+     * Parse page HTML template. Search for the page's template with accordance to it's file name.
+     *
+     * @see allows to use cache.
+     *
+     * @param  string $sName      - HTML file name.
+     * @param  array  $aVariables - key/value pairs. key should be the same as template's key, but without prefix and postfix.
+     * @return string the result of operation.
+     */
+    function parsePageByName($sName, $aVariables)
+    {
+        if (isset($GLOBALS['bx_profiler'])) $GLOBALS['bx_profiler']->beginPage($sName);
+
+        $sContent = $this->parseHtmlByName($sName, $aVariables, $this->_sKeyWrapperHtml, BX_DOL_TEMPLATE_CHECK_IN_BOTH);
+        if(empty($sContent)) {
+            $aType = BxDolPageQuery::getPageType($this->getPageType());
+            if(!empty($aType) && is_array($aType))
+                $sContent = $this->parseHtmlByName($aType['template'], $aVariables, $this->_sKeyWrapperHtml, BX_DOL_TEMPLATE_CHECK_IN_BOTH);
+        }
+        if(empty($sContent))
+            $sContent = $this->parseHtmlByName('default.html', $aVariables, $this->_sKeyWrapperHtml, BX_DOL_TEMPLATE_CHECK_IN_BOTH);
+
+        //---Process injection at the very last ---//
+        $oTemplate = &$this;
+        $sContent = preg_replace_callback("'<bx_injection:([^\s]+) />'s", function($aMatches) use($oTemplate) {
+            return $oTemplate->processInjection($oTemplate->getPageNameIndex(), $aMatches[1]);
+        }, $sContent);
+
+        //--- Add CSS and JS at the very last ---//
+        if(strpos($sContent, '<bx_include_css_styles />') !== false) {
+            $aStyles = array(
+                'display' => 'none !important'
+            );
+
+            if(isLogged())
+                $this->addCssStyle('.bx-hide-when-logged-in', $aStyles);
+            else
+                $this->addCssStyle('.bx-hide-when-logged-out', $aStyles);
+
+            $sContent = str_replace('<bx_include_css_styles />', $this->includeCssStyles(), $sContent);
+        }
+
+        if(strpos($sContent , '<bx_include_css_system />') !== false) {
+            $sContent = str_replace('<bx_include_css_system />', $this->includeFiles('css', true), $sContent);
+        }
+
+        if(strpos($sContent , '<bx_include_css />') !== false) {
+            if (!empty($this->aPage['css_name']))
+                $this->addCss($this->aPage['css_name']);
+            $sContent = str_replace('<bx_include_css />', $this->includeFiles('css'), $sContent);
+        }
+        
+        if(strpos($sContent , '<bx_include_js_system />') !== false) {
+            $sContent = str_replace('<bx_include_js_system />', $this->includeFiles('js', true), $sContent);
+        }
+
+        if(strpos($sContent , '<bx_include_js />') !== false) {
+            if (!empty($this->aPage['js_name']))
+                $this->addJs($this->aPage['js_name']);
+            $sContent = str_replace('<bx_include_js />', $this->includeFiles('js') . $this->includeCssAsync(), $sContent);
+        }
+
+        if (isset($GLOBALS['bx_profiler'])) $GLOBALS['bx_profiler']->endPage($sContent);
+
+        return $sContent;
+    }
+    /**
+     * Parse system keys.
+     *
+     * @param  string $sKey key
+     * @return string value associated with the key.
+     */
+    function parseSystemKey($sKey, $mixedKeyWrapperHtml = null, $bProcessInjection = true)
+    {
+        $aKeyWrappers = $this->_getKeyWrappers($mixedKeyWrapperHtml);
+
+        $sRet = '';
+        switch( $sKey ) {
+            case 'page_charset':
+                $sRet = 'UTF-8';
+                break;
+            case 'page_robots':
+                if(!empty($this->aPage['robots']) && is_string($this->aPage['robots']))
+                    $sRet = '<meta name="robots" content="' . bx_html_attribute($this->aPage['robots']) . '" />';
+                break;
+            case 'meta_info':
+                $sRet = $this->getMetaInfo();
+                break;
+            case 'page_header':
+                if (isset($this->aPage['title']) && !empty($this->aPage['title']))
+                    $sRet = bx_process_output(strip_tags($this->aPage['title']));
+                if(empty($sRet) && isset($this->aPage['header']))
+                    $sRet = bx_process_output(strip_tags($this->aPage['header']));
+                break;
+            case 'page_header_text':
+                if(isset($this->aPage['header_text']))
+                    $sRet = bx_process_output($this->aPage['header_text']);
+                break;
+            case 'page_width':
+                if (false === strpos($this->_oTemplateConfig->aLessConfig['bx-page-width'], 'px'))
+                    $sRet = BX_DOL_PAGE_WIDTH;
+                else
+                    $sRet = $this->_oTemplateConfig->aLessConfig['bx-page-width'];
+                break;
+            case 'page_viewport':
+                $sRet = getParam('sys_viewport_meta_tag');
+                break;
+            case 'system_injection_head':
+                $sRet = $this->_oTemplateFunctions->getInjectionHead();
+                break;
+            case 'system_injection_header':
+                $sRet = $this->_oTemplateFunctions->getInjectionHeader();
+                break;
+            case 'system_injection_footer':
+                $sRet = $this->_oTemplateFunctions->getInjectionFooter();
+                break;
+            case 'lang':
+                $sRet = bx_lang_code();
+                break;
+            case 'lang_direction':
+                $sRet = bx_lang_direction();
+                break;
+            case 'lang_country':
+                $sRet = bx_lang_country();
+                break;
+            case 'lang_with_country':
+                $sRet = bx_lang_code_with_country();
+                break;
+            case 'main_logo':
+                $sRet = BxTemplFunctions::getInstance()->getMainLogo();
+                break;
+            case 'informer':
+                $oInformer = BxDolInformer::getInstance($this);
+                $sRet = $oInformer ? $oInformer->display() : '';
+                break;
+            case 'cover':
+            	$oCover = BxDolCover::getInstance($this);
+
+            	$bCover = $oCover->isEnabled();
+            	if($bCover && ($oPage = BxDolPage::getObjectInstanceByURI()) !== false) {
+                    $bCover = $oPage->isPageCover();
+                    if($bCover && !$oCover->isCover()) {
+                        $aCover = $oPage->getPageCoverImage();
+                        if(!empty($aCover))
+                            $oCover->setCoverImageUrl($aCover);
+                    }
+            	}
+
+                $sRet = $bCover ? $oCover->display() : $oCover->displayEmpty();
+                break;
+            case 'site_submenu_class':
+                $oMenu = BxDolMenu::getObjectInstance('sys_site_submenu');
+                if($oMenu)
+                    $sRet = $oMenu->getClass();
+                break;
+            case 'site_submenu_hidden':
+                $sClass = 'bx-menu-main-bar-hidden';
+
+                $oPage = BxDolPage::getObjectInstanceByURI();
+                if($oPage !== false && !$oPage->isVisiblePageSubmenu()) {
+                    $sRet = $sClass;
+                    break;
+                }
+
+                $oMenuSubmenu = BxDolMenu::getObjectInstance('sys_site_submenu');
+                $oMenuManage = BxDolMenu::getObjectInstance('sys_site_manage');
+                if($oMenuSubmenu !== false && !$oMenuSubmenu->isVisible() && $oMenuManage !== false && !$oMenuManage->isVisible()) {
+                    $sRet = $sClass;
+                    break;
+                }
+
+                break;
+            case 'dol_images':
+                $sRet = $this->_processJsImages();
+                break;
+            case 'dol_lang':
+                $sRet = $this->_processJsTranslations();
+                break;
+            case 'dol_options':
+                $sRet = $this->_processJsOptions();
+                break;
+            case 'copyright':
+                $sRet = _t( '_copyright', date('Y') ) . getVersionComment();
+                break;
+            case 'copyright_attr':
+                $sRet = bx_html_attribute(_t('_copyright', date('Y')));
+                break;
+            case 'extra_js':
+                $sRet = empty($this->aPage['extra_js']) ? '' : $this->aPage['extra_js'];
+                break;
+            case 'is_profile_page':
+                $sRet = (defined('BX_PROFILE_PAGE')) ? 'true' : 'false';
+                break;
+            case 'system_js_requred':
+                $sRet = _t('_sys_javascript_requred');
+                break;
+            case 'included_css':
+                $sRet = json_encode($this->getIncludedUrls('css_compiled'));
+                break;
+            case 'included_js':
+                $sRet = json_encode($this->getIncludedUrls('js_compiled'));
+                break;
+            case 'base':
+                $sRet = bx_convert_array2attrs($this->aPage['base']);
+                break;
+            case 'class_name':
+                $sRet = $this->getCssClassName();
+    
+                if (preg_match('/^[A-Za-z0-9_\-]+$/', bx_get('i')))
+                    $sRet .= ' bx-page-' . bx_get('i');
+
+                if(!empty($this->_iMix)) {
+                    $aMix = BxDolDb::getInstance()->getParamsMix($this->_iMix);
+                    if(isset($aMix['dark']) && (int)$aMix['dark'] == 1)
+                        $sRet .= ' dark';
+                }
+
+                $sRet .= ' bx-dir-' . strtolower(bx_lang_direction());
+                break;
+            case 'css_media_phone':
+            case 'css_media_phone2':
+            case 'css_media_tablet':
+            case 'css_media_tablet2':
+            case 'css_media_desktop':
+                $aData = json_decode(getParam('sys_css_media_classes'), true);
+                $sKey = str_replace('css_media_', '', $sKey);
+                $sRet = $aData[$sKey];
+                break;
+            case 'service_worker':
+                if(getParam('sys_pwa_sw_enable') != 'on')
+                    break;
+                $sRet = "if(navigator && navigator.serviceWorker) navigator.serviceWorker.register('sw.js.php');";
+                break;
+            case 'socket_engine':
+                $sRet = BxDolSockets::getInstance()->getJsCode();
+                break;
+
+            case 'info':
+                $sRet = 'L:' . bx_get_logged_profile_id();
+                if(($oPage = BxDolPage::getObjectInstanceByURI()) !== false && method_exists($oPage, 'getContentInfo')) {
+                    $aContentInfo = $oPage->getContentInfo();
+                    if(isset($aContentInfo['id']))
+                        $sRet .= '-C:' . (int)$aContentInfo['id'];
+                    if(isset($aContentInfo['profile_id']))
+                        $sRet .= '-P:' . (int)$aContentInfo['profile_id'];
+                }
+                break;
+
+            default:
+                $sRet = ($sTemplAdd = BxTemplFunctions::getInstance()->TemplPageAddComponent($sKey)) !== false ? $sTemplAdd : $aKeyWrappers['left'] . $sKey . $aKeyWrappers['right'];
+        }
+
+        if($bProcessInjection)
+            $sRet = $this->processInjection($this->getPageNameIndex(), $sKey, $sRet);
+
+        return $sRet;
+    }
+
+    /**
+     * Parse tag <A>
+     * 
+     * @param string $sLink link URL
+     * @param string $sContent link content
+     * @param array $aAttrs an array of key => value pairs
+     */
+    function parseLink($sLink, $sContent, $aAttrs = array())
+    {
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return '<a href="' . $sLink . '"' . $sAttrs . '>' . $sContent . '</a>';
+    }
+
+    /**
+     * Parse tag <A> using provided HTML template
+     * 
+     * @param string $sName template's file name
+     * @param string $sLink link URL
+     * @param string $sContent link content
+     * @param array $aAttrs an array of key => value pairs
+     */
+    function parseLinkByName($sName, $sLink, $sContent, $aAttrs = array())
+    {
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return $this->parseHtmlByName($sName, array(
+            'href' => $sLink,
+            'attrs' => $sAttrs,
+            'content' => $sContent
+        ));
+    }
+
+    /**
+     * Parse tag <BUTTON>
+     * 
+     * @param string $sContent link content
+     * @param array $aAttrs an array of key => value pairs
+     */
+    function parseButton($sContent, $aAttrs = array())
+    {
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return '<button' . $sAttrs . '>' . $sContent . '</button>';
+    }
+
+    /**
+     * Parse tag <IMG>
+     * 
+     * @param string $sLink URL to image source 
+     * @param array $aAttrs an array of key => value pairs
+     */
+    function parseImage($sLink, $aAttrs = [])
+    {
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return '<img src="' . $sLink . '"' . $sAttrs . ' />';
+    }
+    
+    function parseImageInline($sName, $aAttrs = [])
+    {
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return '<img src="' . $this->_getInlineData('image', $sName, BX_DOL_TEMPLATE_CHECK_IN_BOTH, true) . '"' . $sAttrs . ' />';
+    }
+
+    /**
+     * Parse font based icon in <I> tag
+     * 
+     * @param string $sName font icon name
+     * @param array $aAttrs an array of key => value pairs
+     */
+    function parseIcon($sName, $aAttrs = [])
+    {
+        $aIcons = BxTemplFunctions::getInstance()->getIcon($sName, $aAttrs);
+        if($aIcons[0] != '')
+            $aIcons[0] = '';
+
+        return implode($aIcons);
+    }
+    
+    function parseIconInline($sName, $aAttrs = [])
+    {
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return '<img src="' . $this->_getInlineData('icon', $sName, BX_DOL_TEMPLATE_CHECK_IN_BOTH, true) . '"' . $sAttrs . ' />';
+    }
+
+    function parseTag($sName, $sContent, $aAttrs = [], $aParseParams = [])
+    {
+        if(!is_array($aAttrs))
+            $aAttrs = [];
+
+        $aTags = ['span', 'a', 'button', 'sbutton', 'custom', 'nl', 'extended'];
+
+        $sTmplVarsClass = ''; 
+        $aTmplVarsAttrs = [];
+        foreach($aAttrs as $sKey => $sValue) {
+            if($sKey == 'class')
+                $sTmplVarsClass = $aAttrs[$sKey];
+            else
+                $aTmplVarsAttrs[] = ['key' => $sKey, 'value' => bx_html_attribute($sValue)];
+        }
+
+        $aTemplateVars = $aParseParams['template_vars'] ?? [];
+
+        $aTmplVars = [];
+        foreach($aTags as $sTag) {
+            $aTmplVarsTag = [];
+            $bTmplVarsTag = $sTag == $sName;
+            if($bTmplVarsTag)
+                $aTmplVarsTag = array_merge([
+                    'class' => $sTmplVarsClass,
+                    'content' => $sContent,
+                    'bx_repeat:attrs' => $aTmplVarsAttrs
+                ], $aTemplateVars);
+
+            $aTmplVars['bx_if:' . $sTag] = [
+            	'condition' => $bTmplVarsTag,
+                'content' => $aTmplVarsTag
+            ];
+        }
+
+        $sTemplate = $aParseParams['template_name'] ?? '_tags.html';
+        return $this->parseHtmlByName($sTemplate, $aTmplVars);
+    }
+
+    function getCacheFilePrefix($sType)
+    {
+    	$sResult = '';
+    	switch($sType) {
+            case 'template':
+                $sResult = $this->_sCacheFilePrefix;
+                break;
+
+            case 'less':
+                $sResult = $this->_sCssLessPrefix;
+                break;
+
+            case 'css':
+                $sResult = $this->_sCssCachePrefix;
+                break;
+
+            case 'js':
+                $sResult = $this->_sJsCachePrefix;
+                break;
+    	}
+
+    	return $sResult;
+    }
+    /**
+     * Get cache object for templates
+     * @return cache class instance
+     */
+    function getTemplatesCacheObject ()
+    {
+        $sCacheEngine = getParam('sys_template_cache_engine');
+        $oCacheEngine = bx_instance('BxDolCache' . $sCacheEngine);
+        if(!$oCacheEngine->isAvailable())
+            $oCacheEngine = bx_instance('BxDolCacheFileHtml');
+        return $oCacheEngine;
+    }
+    /**
+     * Get template from cache if it's enabled.
+     *
+     * @param  string  $sName               template name
+     * @param  string  $aVariables          key/value pairs. key should be the same as template's key, but without prefix and postfix.
+     * @param  mixed   $mixedKeyWrapperHtml - key wrapper(string value if left and right parts are the same, array(0 => left, 1 => right) otherwise).
+     * @param  string  $sCheckIn            where the content would be searched(base, template, both)
+     * @param  boolean $bEvaluate           need to evaluate the template or not.
+     * @return string  result of operation or false on failure.
+     */
+    function getCached($sName, &$aVariables, $mixedKeyWrapperHtml = null, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH, $bEvaluate = true)
+    {
+        // initialization
+
+        if(!$this->_bCacheEnable)
+            return false;
+
+        if (in_array($sName, $this->_aCacheExceptions))
+           return false;
+        
+        $sAbsolutePath = $this->_getAbsoluteLocation('path', $this->_sFolderHtml, $sName, $sCheckIn);
+        if(empty($sAbsolutePath))
+            return false;
+
+        $oCacheEngine = $this->getTemplatesCacheObject ();
+        $isFileBasedEngine = $bEvaluate && method_exists($oCacheEngine, 'getDataFilePath');
+
+        // try to get cached content
+
+        $sCacheVariableName = "a";
+        $sCacheKey = $this->_getCacheFileName('html', $sAbsolutePath) . '.php';
+        if ($isFileBasedEngine)
+            $sCacheContent = $oCacheEngine->getDataFilePath($sCacheKey);
+        else
+            $sCacheContent = $oCacheEngine->getData($sCacheKey);
+
+
+        // recreate cache if it is empty
+
+        if ($sCacheContent === null && ($sContent = file_get_contents($sAbsolutePath)) !== false && ($sContent = $this->_compileContent($sContent, "\$" . $sCacheVariableName, 1, $aVariables, $mixedKeyWrapperHtml)) !== false) {
+            if (false === $oCacheEngine->setData($sCacheKey, trim($sContent)))
+                return false;
+
+            if ($isFileBasedEngine)
+                $sCacheContent = $oCacheEngine->getDataFilePath($sCacheKey);
+            else
+                $sCacheContent = $sContent;
+        }
+
+        if ($sCacheContent === null)
+            return false;
+
+        // return simple cache content
+
+        if(!$bEvaluate)
+            return $sCacheContent;
+
+        // return evaluated cache content
+
+        ob_start();
+
+        $$sCacheVariableName = &$aVariables;
+
+        if ($isFileBasedEngine)
+            include($sCacheContent);
+        else
+            eval('?'.'>' . $sCacheContent);
+
+        $sContent = ob_get_clean();
+
+        return $sContent;
+    }
+
+    public function clearTemplatesCache($sType = '')
+    {
+        if(!$sType)
+            $sType = 'template';
+
+        $this->getTemplatesCacheObject()->removeAllByPrefix($this->getCacheFilePrefix($sType));
+    }
+
+    public function clearImagesCache($sCode = '')
+    {
+        if(!$sCode)
+            $sCode = $this->_sCode;
+
+        BxDolDb::getInstance()->getDbCacheObject()->removeAllByPrefix('sys_layout_images_' . $sCode .  '_');
+    }
+
+    /**
+     * Add JS file(s) to global output.
+     *
+     * @param  mixed          $mixedFiles string value represents a single JS file name. An array - array of JS file names.
+     * @param  boolean        $bDynamic   in the dynamic mode JS file(s) are not included to global output, but are returned from the function directly.
+     * @return boolean/string result of operation.
+     */
+    function addJs($mixedFiles, $bDynamic = false)
+    {
+        return $this->_processFiles('js', 'add', $mixedFiles, $bDynamic);
+    }
+
+    function addJsPreloaded($aFiles, $sCallback = false, $sCondition = false, $sConditionElseCallback = false)
+    {
+        if(!$aFiles)
+            return '';
+
+        if(!is_array($aFiles))
+            $aFiles = [$aFiles];
+
+        $sMaskLoad = "bx_get_scripts(%s);";
+        $sMaskLoadWithCallback = "bx_get_scripts(%s, function() {%s});";
+
+        $sMaskCondition = "if(%s) {%s}";
+        $sMaskConditionWithElse = "if(%s) {%s} else {setTimeout(function() {%s}, 10);}";
+
+        $aFilesLocated = [];
+        foreach($aFiles as $sFile) {
+            $mixedFile = $this->_locateFile('js', $sFile);
+            if($mixedFile === false)
+                continue;
+
+            list($sUrl) = $mixedFile;
+
+            $aFilesLocated[] = $this->addRevision($sUrl);
+        }
+        $sFilesLocated = json_encode($aFilesLocated);
+
+        if($sCallback !== false)
+            $sResult = sprintf($sMaskLoadWithCallback, $sFilesLocated, $sCallback);
+        else
+            $sResult = sprintf($sMaskLoad, $sFilesLocated);
+
+        if($sCondition === false)
+            return $sResult;
+
+        if($sConditionElseCallback !== false)
+            $sResult = sprintf($sMaskConditionWithElse, $sCondition, $sResult, $sConditionElseCallback);
+        else
+            $sResult = sprintf($sMaskCondition, $sCondition, $sResult);
+
+        return $sResult;
+    }
+
+    function addJsPreloadedWrapped($aFiles, $sCallback = false, $sCondition = false, $sConditionElseCallback = false)
+    {
+        $sCode = $this->addJsPreloaded($aFiles, $sCallback, $sCondition, $sConditionElseCallback);
+        if(!$sCode)
+            return '';
+
+        return $this->_wrapInTagJsCode($sCode);
+    }
+
+    function addJsCodeOnLoad($sCallback)
+    {
+        $sMaskLoad = "$(document).ready(function() {%s});";
+
+        return sprintf($sMaskLoad, $sCallback);
+    }
+    
+    function addJsCodeOnLoadWrapped($sCallback)
+    {
+        $sCode = $this->addJsCodeOnLoad($sCallback);
+
+        return $this->_wrapInTagJsCode($sCode); 
+    }
+    
+    /**
+     * get added js files
+     */ 
+    function getJs()
+    {
+        return $this->aPage['js_compiled'];
+    }
+
+    /**
+     * Add System JS file(s) to global output.
+     * System JS files are the files which are attached to all pages. They will be cached separately from the others.
+     *
+     * @param  mixed          $mixedFiles string value represents a single JS file name. An array - array of JS file names.
+     * @param  boolean        $bDynamic   in the dynamic mode JS file(s) are not included to global output, but are returned from the function directly.
+     * @return boolean/string result of operation.
+     */
+    function addJsSystem($mixedFiles)
+    {
+        return $this->_processFiles('js', 'add', $mixedFiles, false, true);
+    }
+
+    /**
+     * Delete JS file(s) from global output.
+     *
+     * @param  mixed   $mixedFiles string value represents a single JS file name. An array - array of JS file names.
+     * @return boolean result of operation.
+     */
+    function deleteJs($mixedFiles)
+    {
+        return $this->_processFiles('js', 'delete', $mixedFiles);
+    }
+
+    /**
+     * Delete System JS file(s) from global output.
+     *
+     * @param  mixed   $mixedFiles string value represents a single JS file name. An array - array of JS file names.
+     * @return boolean result of operation.
+     */
+    function deleteJsSystem($mixedFiles)
+    {
+        return $this->_processFiles('js', 'delete', $mixedFiles, false, true);
+    }
+
+    /**
+     * Compile JS files in one file.
+     *
+     * @param  string $sAbsolutePath CSS file absolute path(full URL for external CSS/JS files).
+     * @param  array  $aIncluded     an array of already included JS files.
+     * @return string result of operation.
+     */
+    function _compileJs($sAbsolutePath, &$aIncluded)
+    {
+        if(isset($aIncluded[$sAbsolutePath]))
+           return '';
+
+        $bExternal = strpos($sAbsolutePath, "http://") !== false || strpos($sAbsolutePath, "https://") !== false;
+        if($bExternal) {
+            $sPath = $sAbsolutePath;
+            $sName = '';
+
+            $sContent = bx_file_get_contents($sAbsolutePath);
+        } else {
+            $aFileInfo = pathinfo($sAbsolutePath);
+            $sPath = $aFileInfo['dirname'] . DIRECTORY_SEPARATOR;
+            $sName = $aFileInfo['basename'];
+
+            $sContent = file_get_contents($sPath . $sName);
+        }
+
+        if(empty($sContent))
+            return '';
+
+        $sUrl = bx_ltrim_str($sPath, realpath(BX_DIRECTORY_PATH_ROOT), BX_DOL_URL_ROOT);
+        $sUrl = str_replace(DIRECTORY_SEPARATOR, '/', $sUrl);
+
+        $sContent = "\r\n/*--- BEGIN: " . $sUrl . $sName . "---*/\r\n" . $sContent . ";\r\n/*--- END: " . $sUrl . $sName . "---*/\r\n";
+        $sContent = preg_replace("/\/\/# sourceMappingURL\s*=.*/si", "", $sContent);
+        $sContent = str_replace(["\n\r", "\r\n", "\r"], "\n", $sContent);
+
+        $aIncluded[$sAbsolutePath] = 1;
+
+        return preg_replace(
+            array(
+                "'<bx_url_root />'",
+                "'\r\n'"
+            ),
+            array(
+                BX_DOL_URL_ROOT,
+                "\n"
+            ),
+            $sContent
+        );
+    }
+
+	/**
+     * Minify JS
+     *
+     * @param  string $s JS string to minify
+     * @return string minified JS string.
+     */
+	function _minifyJs($s)
+    {
+        // since each JS file is minified separately, it has to be in own scope
+    	return "\n {\n" . BxDolMinify::getInstance()->minifyJs($s) . "\n }\n";
+    }
+
+    /**
+     * Wrap an URL to JS file into JS tag.
+     *
+     * @param  string $sFile - URL to JS file.
+     * @return string the result of operation.
+     */
+    function _wrapInTagJs($sFile)
+    {
+        if (false !== strpos($sFile, '.mjs.js'))
+            return "<script type=\"module\" src=\"" . $sFile . "\"></script>";
+        return "<script language=\"javascript\" src=\"" . $sFile . "\"></script>";
+    }
+
+    /**
+     * Wrap JS code into JS tag.
+     *
+     * @param  string $sCode - JS code.
+     * @return string the result of operation.
+     */
+    function _wrapInTagJsCode($sCode)
+    {
+        return "<script language=\"javascript\">\n" . $sCode . "\n</script>";
+    }
+
+    /**
+     * Add CSS file(s) to global output.
+     *
+     * @param  mixed          $mixedFiles string value represents a single CSS file name. An array - array of CSS file names.
+     * @param  boolean        $bDynamic   in the dynamic mode CSS file(s) are not included to global output, but are returned from the function directly.
+     * @return boolean/string result of operation
+     */
+    function addCss($mixedFiles, $bDynamic = false)
+    {
+        if($bDynamic)
+            return $this->addCssPreloadedWrapped($mixedFiles);
+        else
+            return $this->_processFiles('css', 'add', $mixedFiles, $bDynamic);
+    }
+
+    function addCssPreloaded($aFiles)
+    {
+        if(!$aFiles)
+            return '';
+
+        if(!is_array($aFiles))
+            $aFiles = [$aFiles];
+
+        $sMaskLoad = "bx_get_style(%s);";
+
+        $aFilesLocated = [];
+        foreach($aFiles as $sFile) {
+            $mixedFile = $this->_locateFile('css', $sFile);
+            if($mixedFile === false)
+                continue;
+
+            list($sUrl) = $mixedFile;
+
+            $aFilesLocated[] = $this->addRevision($sUrl);
+        }
+        $sFilesLocated = json_encode($aFilesLocated);
+
+        return sprintf($sMaskLoad, $sFilesLocated);
+    }
+
+    function addCssPreloadedWrapped($aFiles)
+    {
+        $sCode = $this->addCssPreloaded($aFiles);
+        if(!$sCode)
+            return '';
+
+        return $this->_wrapInTagJsCode($sCode);
+    }
+
+    /**
+     * get added css files
+     */ 
+    function getCss()
+    {
+        return $this->aPage['css_compiled'];
+    }
+
+    /**
+     * Add additional heavy css file (not very necessary) to load asynchronously for desktop browsers only
+     * @param  mixed          $mixedFiles string value represents a single CSS file name. An array - array of CSS file names.
+     */
+    function addCssAsync($mixedFiles)
+    {
+        if (!is_array($mixedFiles))
+            $mixedFiles = array($mixedFiles);
+
+        foreach ($mixedFiles as $sFile)
+            $this->aPage['css_async'][] = $this->_getAbsoluteLocationCss('url', $sFile);
+
+        $this->addJs('loadCSS.js');
+    }
+
+    /**
+     * Return script tag with special code to load async css.
+     * This tag is added after js files list
+     */
+    function includeCssAsync ()
+    {
+        if (empty($this->aPage['css_async']))
+            return '';
+
+        $this->aPage['css_async'] = array_unique($this->aPage['css_async']);
+
+        $sList = '';
+        foreach ($this->aPage['css_async'] as $sUrl)
+            $sList .= 'loadCSS("' . $sUrl . '", document.getElementById("bx_css_async"));';
+
+        // don't load css for mobile devices
+        return '
+            <script id="bx_css_async">
+                if(!/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ) {
+                    ' . $sList . '
+                }
+            </script>
+        ';
+    }
+
+    /**
+     * Add System CSS file(s) to global output.
+     * System CSS files are the files which are attached to all pages. They will be cached separately from the others.
+     *
+     * @param  mixed          $mixedFiles string value represents a single CSS file name. An array - array of CSS file names.
+     * @return boolean/string result of operation
+     */
+    function addCssSystem($mixedFiles)
+    {
+        return $this->_processFiles('css', 'add', $mixedFiles, false, true);
+    }
+
+    /**
+     * Delete CSS file(s) from global output.
+     *
+     * @param  mixed   $mixedFiles string value represents a single CSS file name. An array - array of CSS file names.
+     * @return boolean result of operation.
+     */
+    function deleteCss($mixedFiles)
+    {
+        return $this->_processFiles('css', 'delete', $mixedFiles);
+    }
+    /**
+     * Delete System CSS file(s) from global output.
+     *
+     * @param  mixed   $mixedFiles string value represents a single CSS file name. An array - array of CSS file names.
+     * @return boolean result of operation.
+     */
+    function deleteCssSystem($mixedFiles)
+    {
+        return $this->_processFiles('css', 'delete', $mixedFiles, false, true);
+    }
+    /**
+     * Compile CSS files' structure(@see \@import css_file_path) in one file.
+     *
+     * @param  string $sAbsolutePath CSS file absolute path(full URL for external CSS/JS files).
+     * @param  array  $aIncluded     an array of already included CSS files.
+     * @return string result of operation.
+     */
+    function _compileCss($sAbsolutePath, &$aIncluded)
+    {
+        if(isset($aIncluded[$sAbsolutePath]))
+           return '';
+
+        $bExternal = strpos($sAbsolutePath, "http://") !== false || strpos($sAbsolutePath, "https://") !== false;
+        if($bExternal) {
+        	$sPath = $sAbsolutePath;
+            $sName = '';
+
+        	$aAPUrl = parse_url($sAbsolutePath);
+        	if(!empty($aAPUrl['path'])) {
+        		$aAPPath = pathinfo($aAPUrl['path']);
+        		if(!empty($aAPPath['basename'])) {
+        			$sPath = bx_rtrim_str($sAbsolutePath, $aAPPath['basename']);
+        			$sName = $aAPPath['basename'];
+        		}
+        	}
+
+            $sContent = bx_file_get_contents($sAbsolutePath);
+        } else {
+            $aFileInfo = pathinfo($sAbsolutePath);
+            $sPath = $aFileInfo['dirname'] . DIRECTORY_SEPARATOR;
+            $sName = $aFileInfo['basename'];
+
+            $sContent = file_get_contents($sPath . $sName);
+        }
+
+        if(empty($sContent))
+            return '';
+
+        $sUrl = bx_ltrim_str($sPath, realpath(BX_DIRECTORY_PATH_ROOT), BX_DOL_URL_ROOT);
+        $sUrl = str_replace(DIRECTORY_SEPARATOR, '/', $sUrl);
+
+        $sContent = "\r\n/*--- BEGIN: " . $sUrl . $sName . "---*/\r\n" . $sContent . "\r\n/*--- END: " . $sUrl . $sName . "---*/\r\n";
+        $aIncluded[$sAbsolutePath] = 1;
+
+        $sContent = str_replace(array("\n\r", "\r\n", "\r"), "\n", $sContent);
+        if($bExternal) {
+            $sContent = preg_replace(
+                array(
+                    "'@import\s+url\s*\(\s*[\'|\"]*\s*([a-zA-Z0-9\.\/_-]+)\s*[\'|\"]*\s*\)\s*;'",
+                    "'url\s*\(\s*[\'|\"]*\s*([a-zA-Z0-9\.\/\?\#_=-]+)\s*[\'|\"]*\s*\)'"
+                ),
+                array(
+                    "",
+                    "url(" . $sPath . "\\1)"
+                ),
+                $sContent
+            );
+        }
+        else {
+        	try {
+        		$oTemplate = &$this;
+
+        		/* Match URL based imports like the following:
+        		 * @import 'http://[domain]/modules/[vendor]/[module]/template/css/view.css';
+        		 * Is mainly needed for CSS files which are gotten from LESS compiler.
+        		 */
+        		$sContent = preg_replace_callback(
+	                "'@import\s+[\'|\"]*\s*" . str_replace("/", "\/", BX_DOL_URL_ROOT) . "([a-zA-Z0-9\.\/_-]+)\s*[\'|\"]*\s*;'", function ($aMatches)  use($oTemplate, $sPath, &$aIncluded) {
+	                	return $oTemplate->_compileCss(realpath(BX_DIRECTORY_PATH_ROOT . $aMatches[1]), $aIncluded);
+	                }, $sContent);
+
+				/* Match relative path based imports like the following:
+				 * @import url(../../../../../../base/profile/template/css/main.css);
+				 * Is mainly needed for default CSS files.
+				 */
+	            $sContent = preg_replace_callback(
+	                "'@import\s+url\s*\(\s*[\'|\"]*\s*([a-zA-Z0-9\.\/_-]+)\s*[\'|\"]*\s*\)\s*;'", function ($aMatches)  use($oTemplate, $sPath, &$aIncluded) {
+	                	return $oTemplate->_compileCss(realpath($sPath . dirname($aMatches[1])) . DIRECTORY_SEPARATOR . basename($aMatches[1]), $aIncluded);
+	                }, $sContent);
+
+	            $sContent = preg_replace_callback(
+	                "'url\s*\(\s*[\'|\"]*\s*([a-zA-Z0-9\.\/\?\#_=-]+)\s*[\'|\"]*\s*\)'", function ($aMatches)  use($oTemplate, $sPath) {
+						$sFile = basename($aMatches[1]);
+				        $sDirectory = dirname($aMatches[1]);
+
+				        $sRootPath = realpath(BX_DIRECTORY_PATH_ROOT) . '/';
+				        $sAbsolutePath = realpath(addslashes($sPath) . $sDirectory) . DIRECTORY_SEPARATOR . $sFile;
+
+				        $sRootPath = str_replace(DIRECTORY_SEPARATOR, '/', $sRootPath);
+				        $sAbsolutePath = str_replace(DIRECTORY_SEPARATOR, '/', $sAbsolutePath);
+
+				        return 'url(' . bx_ltrim_str($sAbsolutePath, $sRootPath, BX_DOL_URL_ROOT) . ')';
+	                }, $sContent);
+			}
+	    	catch(Exception $oException) {
+	        	return '';
+	        }
+        }
+
+        return $sContent;
+    }
+
+    /**
+     * Less CSS
+     *
+     * @param  mixed $mixed CSS string to process with Less compiler or an array with CSS file's Path and URL.
+     * @return mixed string or an array with CSS file's Path and URL.
+     */
+    function _lessCss($mixed)
+    {
+        if(is_array($mixed) && isset($mixed['url']) && isset($mixed['path'])) {
+            $sPathFile = realpath($mixed['path']);
+            $aInfoFile = pathinfo($sPathFile);
+            if (!isset($aInfoFile['extension']) || $aInfoFile['extension'] != 'less')
+                return $mixed;
+
+        	$aFiles = array($mixed['path'] => $mixed['url']);
+        	$aOptions = array('cache_dir' => $this->_sCachePublicFolderPath, 'prefix' => $this->_sCssLessPrefix);
+        	$sFile = Less_Cache::Get($aFiles, $aOptions, $this->_oTemplateConfig->aLessConfig);
+
+            return array('url' => $this->_sCachePublicFolderUrl . $sFile, 'path' => $this->_sCachePublicFolderPath . $sFile);
+        }
+
+        $oLess = new Less_Parser();
+        $oLess->ModifyVars($this->_oTemplateConfig->aLessConfig);
+        $oLess->parse($mixed);
+        return $oLess->getCss();
+    }
+
+    /**
+     * Minify CSS
+     *
+     * @param  string $s CSS string to minify
+     * @return string minified CSS string.
+     */
+    function _minifyCss($s)
+    {
+    	return BxDolMinify::getInstance()->minifyCss($s);
+    }
+
+    /**
+     * Wrap an URL to CSS file into CSS tag.
+     *
+     * @param  string $sFile - URL to CSS file.
+     * @return string the result of operation.
+     */
+    function _wrapInTagCss($sFile)
+    {
+        if (!$sFile)
+            return '';
+        return "<link href=\"" . $sFile . "\" rel=\"stylesheet\" type=\"text/css\" />";
+    }
+    /**
+     * Wrap CSS code into CSS tag.
+     *
+     * @param  string $sCode - CSS code.
+     * @return string the result of operation.
+     */
+    function _wrapInTagCssCode($sCode)
+    {
+        return "<style>" . $sCode . "</style>";
+    }
+	/**
+     *  Include CSS style(s) in the page's head section.
+     */
+	function includeCssStyles()
+	{
+		$sResult = "";
+		if(empty($this->aPage['css_styles']) || !is_array($this->aPage['css_styles']))
+			return $sResult;
+
+		foreach($this->aPage['css_styles'] as $sName => $aContent) {
+			$sContent = "";
+			if(!empty($aContent) && is_array($aContent))
+				foreach($aContent as $sStyleName => $sStyleValue)
+					$sContent .= "\t" . $sStyleName . ": " . $sStyleValue . ";\r\n";
+
+			$sResult .= $sName . " {\r\n" . $sContent . "}\r\n";
+		}
+
+		return !empty($sResult) ? $this->_wrapInTagCssCode($sResult) : '';
+	}
+    /**
+     * Include CSS/JS file(s) attached to the page in its head section.
+     * @see the method is system and would be called automatically.
+     *
+     * @param  string $sType the type of file('js' or 'css')
+     * @return string the result CSS code.
+     */
+    function includeFiles($sType, $bSystem = false, $bWrap = true)
+    {
+        $sUpcaseType = ucfirst($sType);
+
+        $sArrayKey = $sType . ($bSystem ? '_system' : '_compiled');
+        $aFiles = isset($this->aPage[$sArrayKey]) ? $this->aPage[$sArrayKey] : array();
+        if(empty($aFiles) || !is_array($aFiles))
+            return "";
+
+        if(!$this->{'_b' . $sUpcaseType . 'Cache'})
+            return $this->_includeFiles($sType, $aFiles, $bWrap);
+
+        //--- If cache already exists, return it ---//
+        $sMethodWrap = '_wrapInTag' . $sUpcaseType;
+        $sMethodCompile = '_compile' . $sUpcaseType;
+        $sMethodLess = '_less' . $sUpcaseType;
+        $sMethodMinify = '_minify' . $sUpcaseType;
+
+        ksort($aFiles);
+
+        $sName = "";
+        foreach($aFiles as $aFile)
+            $sName .= $aFile['url'];
+        $sName = $this->_getCacheFileName($sType, $sName);
+
+        $sCacheAbsoluteUrl = $this->_sCachePublicFolderUrl . $sName . '.' . $sType;
+        $sCacheAbsolutePath = $this->_sCachePublicFolderPath . $sName . '.' . $sType;
+        if(file_exists($sCacheAbsolutePath)) {
+            if($this->{'_b' . $sUpcaseType . 'Archive'})
+                $sCacheAbsoluteUrl = $this->_getLoaderUrl($sType, $sName);
+
+           return $bWrap ? $this->$sMethodWrap($sCacheAbsoluteUrl) : $sCacheAbsoluteUrl;
+        }
+
+        //--- Collect all attached CSS/JS in one file ---//
+
+        $sResult = "";
+        $iLine = 0;
+        $aIncluded = array();
+        $aSections = array();
+        foreach($aFiles as $aFile) {
+            if($this->{'_b' . $sUpcaseType . 'Less'})
+                $aFile = $this->$sMethodLess($aFile);
+
+            if(($sContent = $this->$sMethodCompile($aFile['path'], $aIncluded)) === false)
+                continue;                
+
+            $bMinify = !preg_match('/[\.-]min.(js|css)$/i', $aFile['path']) && $this->{'_b' . $sUpcaseType . 'Minify'}; // don't minify minified files
+            if($bMinify)
+                $sContent = $this->$sMethodMinify($sContent);
+
+            $iLines = substr_count($sContent, "\n");
+            if($sType == 'js' && $sContent !== '')
+                $aSections[] = array(
+                    'file' => $aFile,
+                    'line' => $iLine,
+                    'lines' => $iLines,
+                    'exact' => !$bMinify && strncmp($sContent, "\n/*--- BEGIN:", 13) === 0,
+                );
+
+            $sResult .= $sContent;
+            $iLine += $iLines;
+        }
+
+        //--- The JS bundle gets a source map next to it (see _getSourceMapJs), served through the same loader ---//
+        if($sType == 'js' && !empty($sResult) && ($sMap = $this->_getSourceMapJs($sName, $aSections)) !== '') {
+            $sMapAbsolutePath = $this->_sCachePublicFolderPath . $sName . '.js.map';
+            if(($rHandler = fopen($sMapAbsolutePath, 'w')) !== false) {
+                fwrite($rHandler, $sMap);
+                fclose($rHandler);
+                @chmod($sMapAbsolutePath, BX_DOL_FILE_RIGHTS);
+
+                $sResult .= "\n//# sourceMappingURL=" . $this->_getLoaderUrl('js.map', $sName) . "\n";
+            }
+        }
+
+        $mixedWriteResult = false;
+        if(!empty($sResult) && ($rHandler = fopen($sCacheAbsolutePath, 'w')) !== false) {
+            $mixedWriteResult = fwrite($rHandler, $sResult);
+            fclose($rHandler);
+            @chmod ($sCacheAbsolutePath, BX_DOL_FILE_RIGHTS);
+        }
+
+        if($mixedWriteResult === false)
+            return $this->_includeFiles($sType, $aFiles, $bWrap);
+
+        if($this->{'_b' . $sUpcaseType . 'Archive'})
+            $sCacheAbsoluteUrl = $this->_getLoaderUrl($sType, $sName);
+
+        return $bWrap ? $this->$sMethodWrap($sCacheAbsoluteUrl) : $sCacheAbsoluteUrl;
+    }
+    /**
+     * Include CSS/JS files without caching.
+     *
+     * @param  string $sType  the file type (css or js)
+     * @param  array  $aFiles CSS/JS files to be added to the page.
+     * @return string result of operation.
+     */
+    function _includeFiles($sType, &$aFiles, $bWrap = true)
+    {
+        $sUpcaseType = ucfirst($sType);
+
+        $sMethodWrap = '_wrapInTag' . $sUpcaseType;
+        $sMethodLess = '_less' . $sUpcaseType;
+
+        $mixedResult = $bWrap ? "" : [];
+        foreach($aFiles as $aFile) {
+            if($this->{'_b' . $sUpcaseType . 'Less'})
+                $aFile = $this->$sMethodLess($aFile);
+
+            $sFileUrl = $aFile['url'];
+            if(!$this->{'_b' . $sUpcaseType . 'Cache'})
+                $sFileUrl = $this->addRevision($sFileUrl);
+
+            if($bWrap)
+                $mixedResult .= $this->$sMethodWrap($sFileUrl);
+            else
+                $mixedResult[] = $sFileUrl;
+        }
+
+        return $mixedResult;
+    }
+    /**
+     * Insert/Delete CSS file from output stack.
+     *
+     * @param  string  $sType      the file type (css or js)
+     * @param  string  $sAction    add/delete
+     * @param  mixed   $mixedFiles string value represents a single CSS file name. An array - array of CSS file names.
+     * @return boolean result of operation.
+     */
+    function _processFiles($sType, $sAction, $mixedFiles, $bDynamic = false, $bSystem = false)
+    {
+        if(empty($mixedFiles))
+            return $bDynamic ? "" : false;
+
+        if(is_string($mixedFiles))
+            $mixedFiles = array($mixedFiles);
+
+        $sUpcaseType = ucfirst($sType);
+        $sMethodLocate = '_getAbsoluteLocation' . $sUpcaseType;
+        $sMethodWrap = '_wrapInTag' . $sUpcaseType;
+        $sResult = '';
+        foreach($mixedFiles as $sFile) {
+            $mixedFile = $this->_locateFile($sType, $sFile);
+            if($mixedFile === false)
+                continue;
+
+            list($sUrl, $sPath) = $mixedFile;
+
+            $sArrayKey = $sType . ($bSystem ? '_system' : '_compiled');
+            switch($sAction) {
+                case 'add':
+                    /**
+                     * @hooks
+                     * @hookdef hook-system-add_files 'system', 'add_files' - hook on add file to page 
+                     * - $unit_name - equals `system`
+                     * - $action - equals `add_files` 
+                     * - $object_id - not used 
+                     * - $sender_id - not used 
+                     * - $extra_params - array of additional params with the following array keys:
+                     *      - `file` - [string] file name
+                     *      - `type` - [string] file type
+                     *      - `dynamic` - [bool] true if added as dynamic
+                     *      - `system` - [bool] true if system file
+                     *      - `url` - [string] by ref, file url, can be overridden in hook processing
+                     *      - `path` - [string] by ref, file path, can be overridden in hook processing
+                     * @hook @ref hook-system-add_files
+                     */
+                    bx_alert('system', 'add_files', 0, 0, [
+                        'file' => $sFile,
+                        'type' => $sType,
+                        'dynamic' => $bDynamic,
+                        'system' => $bSystem,
+                        'url' => &$sUrl,
+                        'path' => &$sPath,
+                        'url_ref' => &$sUrl,
+                        'path_ref' => &$sPath,
+                    ]);
+
+                    if($bDynamic)
+                        $sResult .= $this->$sMethodWrap($this->addRevision($sUrl));
+                    else {
+                        $bFound = false;
+                        $aSearchIn = $bSystem ? $this->aPage[$sArrayKey] : array_merge($this->aPage[$sType . '_system'], $this->aPage[$sArrayKey]);
+                        foreach($aSearchIn as $iKey => $aValue)
+                            if($aValue['url'] == $sUrl && $aValue['path'] == $sPath) {
+                                $bFound = true;
+                                break;
+                            }
+
+                        if(!$bFound)
+                            $this->aPage[$sArrayKey][] = array('url' => $sUrl, 'path' => $sPath);
+                    }
+                    break;
+                case 'delete':
+                    if(!$bDynamic)
+                        foreach($this->aPage[$sArrayKey]  as $iKey => $aValue)
+                            if($aValue['url'] == $sUrl) {
+                                unset($this->aPage[$sArrayKey][$iKey]);
+                                break;
+                            }
+                    break;
+            }
+        }
+
+        return $bDynamic ? $sResult : true;
+    }
+
+    function _locateFile($sType, $sFile)
+    {
+        //--- Process 3d Party CSS/JS file ---//
+        if(strpos($sFile, "http://") !== false || strpos($sFile, "https://") !== false) {
+            $sUrl = $sFile;
+            $sPath = $sFile;
+        }
+        //--- Process Custom CSS/JS file ---//
+        else if(strpos($sFile, "|") !== false) {
+            $sFile = implode('', explode("|", $sFile));
+            $sFile = bx_ltrim_str($sFile, BX_DIRECTORY_PATH_ROOT);
+
+            $sUrl = BX_DOL_URL_ROOT . $sFile;
+            $sPath = realpath(BX_DIRECTORY_PATH_ROOT . $sFile);
+        }
+        //--- Process Common CSS/JS file(check in default locations) ---//
+        else {
+            $sMethodLocate = '_getAbsoluteLocation' . ucfirst($sType);
+
+            $sUrl = $this->$sMethodLocate('url', $sFile);
+            $sPath = $this->$sMethodLocate('path', $sFile);
+        }
+
+        return !empty($sUrl) && !empty($sPath) ? [$sUrl, $sPath] : false;
+    }
+
+    /**
+     * Parse content.
+     *
+     * @param  string $sContent            - HTML file's content.
+     * @param  array  $aVariables          - key/value pairs. key should be the same as template's key, but without prefix and postfix.
+     * @param  mixed  $mixedKeyWrapperHtml - key wrapper(string value if left and right parts are the same, array(0 => left, 1 => right) otherwise).
+     * @return string the result of operation.
+     */
+    function _parseContent($sContent, $aVariables, $mixedKeyWrapperHtml = null)
+    {
+        $aKeysSrc = array_keys($aVariables);
+        $aValuesSrc = array_values($aVariables);
+
+        $aKeyWrappers = $this->_getKeyWrappers($mixedKeyWrapperHtml);
+
+        $sKeyIf = 'bx_if:';
+        $sKeyRepeat = 'bx_repeat:';
+        $iCountKeys = count($aKeysSrc);
+        $aKeys = $aValues = array();
+
+        //--- Parse simple keys ---//
+        for ($i = 0; $i < $iCountKeys; $i++) {
+            if (strncmp($aKeysSrc[$i], $sKeyRepeat, 10) === 0 || strncmp($aKeysSrc[$i], $sKeyIf, 6) === 0)
+                continue;
+
+            $aKeys[] = "'" . $aKeyWrappers['left'] . $aKeysSrc[$i] . $aKeyWrappers['right'] . "'s";
+            if (is_string($aValuesSrc[$i]) || is_null($aValuesSrc[$i]))
+                $aValues[] = is_null($aValuesSrc[$i]) ? '' : str_replace('$', '\\$', str_replace('\\', '\\\\', $aValuesSrc[$i]));
+            else if(is_array($aValuesSrc[$i]))
+                $aValues[] = _t('_error occured');
+            else
+                $aValues[] = $aValuesSrc[$i];
+        }
+
+        //--- Parse keys with constructions ---//
+        for ($i = 0; $i < $iCountKeys; $i++) {
+            if (strncmp($aKeysSrc[$i], $sKeyRepeat, 10) === 0) {
+                $sKey = "'<" . $aKeysSrc[$i] . ">(.*)<\/" . $aKeysSrc[$i] . ">'s";
+
+                $aMatches = array();
+                preg_match($sKey, $sContent, $aMatches);
+
+                $sValue = '';
+                if(isset($aMatches[1]) && !empty($aMatches[1])) {
+                    if(is_array($aValuesSrc[$i]))
+                        foreach($aValuesSrc[$i] as $aValue)
+                            if(is_array($aValue))
+                                $sValue .= $this->parseHtmlByContent($aMatches[1], $aValue, $mixedKeyWrapperHtml);
+                            else if(is_string($aValue))
+                                $sValue .= $aValue;
+                    else if(is_string($aValuesSrc[$i]))
+                        $sValue = $aValuesSrc[$i];
+                }
+            } 
+            else if (strncmp($aKeysSrc[$i], $sKeyIf, 6) === 0) {
+                $sKey = "'<" . $aKeysSrc[$i] . ">(.*)<\/" . $aKeysSrc[$i] . ">'s";
+
+                $aMatches = array();
+                preg_match($sKey, $sContent, $aMatches);
+
+                $sValue = '';
+                if(isset($aMatches[1]) && !empty($aMatches[1]))
+                    if(is_array($aValuesSrc[$i]) && isset($aValuesSrc[$i]['content']) && isset($aValuesSrc[$i]['condition']) && $aValuesSrc[$i]['condition'])
+                        $sValue .= $this->parseHtmlByContent($aMatches[1], $aValuesSrc[$i]['content'], $mixedKeyWrapperHtml);
+            } 
+            else 
+                continue;
+
+            $aKeys[] = $sKey;
+            $aValues[] = str_replace('$', '\\$', str_replace('\\', '\\\\', $sValue));
+        }
+
+        try {
+            $oTemplate = &$this;
+
+            foreach($this->_aTemplatePatterns as $sPattern => $sCheckIn)
+                $sContent = preg_replace_callback($sPattern, function($aMatches) use($oTemplate, $aVariables, $mixedKeyWrapperHtml, $sCheckIn) {
+                    return $oTemplate->parseHtmlByName($aMatches[1], $aVariables, $mixedKeyWrapperHtml, $sCheckIn);
+                }, $sContent);
+
+            $sContent = $this->_parseContentKeys($sContent, array(
+                "'<bx_menu:([^\s]+) \/>'s" => 'get_menu',
+            ));
+        }
+        catch(Exception $oException) {
+            bx_log('sys_template', "Error in _parseContent method. Cannot parse template insertion (<bx_include... />).\n" . 
+                "  Error ({$oException->getCode()}): {$oException->getMessage()}\n" . 
+                (getLoggedId() ? "  Account ID: " . getLoggedId() . "\n" : ""),
+                BX_LOG_ERR
+            );
+
+            return '';
+        }
+
+        $aKeys = array_merge($aKeys, array(
+            "'<bx_url_root />'",
+            "'<bx_url_studio />'",
+        ));
+        $aValues = array_merge($aValues, array(
+            BX_DOL_URL_ROOT,
+            BX_DOL_URL_STUDIO,
+        ));
+
+        //--- Parse Predefined Keys ---//
+        $sContent = preg_replace($aKeys, $aValues, $sContent);
+
+        //--- Parse System Keys ---//
+        try {
+            $sContent = preg_replace_callback("'" . $aKeyWrappers['left'] . "([a-zA-Z0-9_-]+)" . $aKeyWrappers['right'] . "'", function($aMatches) use($oTemplate, $mixedKeyWrapperHtml) {
+                return $oTemplate->parseSystemKey($aMatches[1], $mixedKeyWrapperHtml);
+            }, $sContent);
+        }
+        catch(Exception $oException) {
+            bx_log('sys_template', "Error in _parseContent method. Cannot parse System Keys.\n" . 
+                "  Error ({$oException->getCode()}): {$oException->getMessage()}\n" . 
+                (getLoggedId() ? "  Account ID: " . getLoggedId() . "\n" : ""),
+                BX_LOG_ERR
+            );
+
+            return '';
+        }
+
+        return $sContent;
+    }
+
+    /**
+     * Compile content
+     *
+     * @param  string  $sContent            template.
+     * @param  string  $aVarName            variable name to be saved in the output file.
+     * @param  integer $iVarDepth           depth is used to process nesting, for example, in cycles.
+     * @param  array   $aVarValues          values to be compiled in.
+     * @param  mixed   $mixedKeyWrapperHtml key wrapper(string value if left and right parts are the same, array(0 => left, 1 => right) otherwise).
+     * @return string  the result of operation.
+     */
+    function _compileContent($sContent, $aVarName, $iVarDepth, $aVarValues, $mixedKeyWrapperHtml = null)
+    {
+        $aKeys = array_keys($aVarValues);
+        $aValues = array_values($aVarValues);
+
+        $aKeyWrappers = $this->_getKeyWrappers($mixedKeyWrapperHtml);
+
+        for($i = 0; $i < count($aKeys); $i++) {
+            $sVarNameKey = $aVarName . "['" . $aKeys[$i] . "']";
+
+            if(strpos($aKeys[$i], 'bx_repeat:') === 0) {
+                $sKey = "'<" . $aKeys[$i] . ">(.*)<\/" . $aKeys[$i] . ">'s";
+
+                $aMatches = array();
+                preg_match($sKey, $sContent, $aMatches);
+
+                $sValue = '';
+                if(isset($aMatches[1]) && !empty($aMatches[1])) {
+                    if(empty($aValues[$i]) || !is_array($aValues[$i]))
+                        return false;
+
+                    $sIndex = "\$" . str_repeat("i", $iVarDepth);
+                    $sValue .= "<?php if(is_array(" . $sVarNameKey . ")) for(" . $sIndex . "=0; " . $sIndex . "<count(" . $sVarNameKey . "); " . $sIndex . "++){ ?>";
+                    if(($sInnerValue = $this->_compileContent($aMatches[1], $sVarNameKey . "[" . $sIndex . "]", $iVarDepth + 1, current($aValues[$i]), $mixedKeyWrapperHtml)) === false)
+                        return false;
+
+                    $sValue .= $sInnerValue;
+                    $sValue .= "<?php } else if(is_string(" . $sVarNameKey . ")) echo " . $sVarNameKey . "; ?>";
+                }
+            } 
+            else if(strpos($aKeys[$i], 'bx_if:') === 0) {
+                $sKey = "'<" . $aKeys[$i] . ">(.*)<\/" . $aKeys[$i] . ">'s";
+
+                $aMatches = array();
+                preg_match($sKey, $sContent, $aMatches);
+
+                $sValue = '';
+                if(isset($aMatches[1]) && !empty($aMatches[1])) {
+                    if(!is_array($aValues[$i]) || empty($aValues[$i]['content']) || !is_array($aValues[$i]['content']))
+                        return false;
+
+                    $sValue .= "<?php if(isset(" . $sVarNameKey .") && " . $sVarNameKey . "['condition']){ ?>";
+                    if(($sInnerValue = $this->_compileContent($aMatches[1], $sVarNameKey . "['content']", $iVarDepth, $aValues[$i]['content'], $mixedKeyWrapperHtml)) === false)
+                        return false;
+
+                    $sValue .= $sInnerValue;
+                    $sValue .= "<?php } ?>";
+                }
+            } 
+            else {
+                $sKey = "'" . $aKeyWrappers['left'] . $aKeys[$i] . $aKeyWrappers['right'] . "'s";
+                $sValue = "<?php echo(isset(" . $sVarNameKey . ") ? " . $sVarNameKey . " : '');?>";
+            }
+
+            $aKeys[$i] = $sKey;
+            $aValues[$i] = $sValue;
+        }
+
+        try {
+            $oTemplate = &$this;
+
+            foreach($this->_aTemplatePatterns as $sPattern => $sCheckIn)
+                $sContent = preg_replace_callback($sPattern, function($aMatches) use($oTemplate, $aVarValues, $mixedKeyWrapperHtml, $sCheckIn) {
+                    $mixedResult = $oTemplate->getCached($aMatches[1], $aVarValues, $mixedKeyWrapperHtml, $sCheckIn, false);
+                    if($mixedResult === false)
+                        throw new Exception("Unable to create cache file ({$aMatches[1]}).", 1);
+
+                    return $mixedResult;
+                }, $sContent);
+
+            $sContent = $this->_parseContentKeys($sContent);
+        }
+        catch(Exception $oException) {
+            if(($iCode = $oException->getCode()) != 1)
+                bx_log('sys_template', "Error in _compileContent method. Cannot parse template insertion (<bx_include... />).\n" . 
+                    "  Error ({$iCode}): {$oException->getMessage()}\n" . 
+                    (getLoggedId() ? "  Account ID: " . getLoggedId() . "\n" : ""),
+                    BX_LOG_ERR
+                );
+
+            return false;
+        }
+
+        $aKeys = array_merge($aKeys, [
+            "'<bx_menu:([^\s]+) \/>'s",
+            "'<bx_url_root />'",
+            "'<bx_url_studio />'"
+        ]);
+        $aValues = array_merge($aValues, [
+            "<?php echo \$this->getMenu('\\1'); ?>",
+            BX_DOL_URL_ROOT,
+            BX_DOL_URL_STUDIO
+        ]);
+
+        //--- Parse Predefined Keys ---//
+        $sContent = preg_replace($aKeys, $aValues, $sContent);
+
+        //--- Parse System Keys ---//
+        $sContent = preg_replace( "'" . $aKeyWrappers['left'] . "([a-zA-Z0-9_-]+)" . $aKeyWrappers['right'] . "'", "<?php echo \$this->parseSystemKey('\\1', \$mixedKeyWrapperHtml);?>", $sContent);
+
+        return $sContent;
+    }
+    protected function _parseContentKeys($sContent, $aCallbackPatterns = array())
+    {
+    	$oTemplate = &$this;
+
+    	$aCallbackPatterns = array_merge($aCallbackPatterns, array(
+            "'<bx_image_auto:([^\s]+) \/>'s" => "get_image_auto",
+            "'<bx_image_url:([^\s]+) \/>'s" => "get_image_url",
+            "'<bx_icon_url:([^\s]+) \/>'s" => "get_icon_url",
+            "'<bx_icon_inline:([^\s]+) \/>'s" => "get_icon_inline",
+            "'<bx_text:([_\{\}\w\d\s]+[^\s]{1}) \/>'s" => "get_text",
+            "'<bx_text_js:([^\s]+) \/>'s" => "get_text_js",
+            "'<bx_text_attribute:([^\s]+) \/>'s" => "get_text_attribute",
+            "'<bx_page:([^\s]+) \/>'s" => "get_page",
+        ));
+
+        foreach($aCallbackPatterns as $sPattern => $sAction)
+            $sContent = preg_replace_callback($sPattern, function($aMatches) use($oTemplate, $sAction) {
+            $sResult = '';
+
+            switch($sAction) {
+                case 'get_image_auto':
+                    $sResult = $oTemplate->getImageAuto($aMatches[1]);
+                    break;
+                case 'get_image_url':
+                    $sResult = $oTemplate->getImageUrl($aMatches[1]);
+                    break;
+                case 'get_icon_url':
+                    $sResult = $oTemplate->getIconUrl($aMatches[1]);
+                    break;
+                case 'get_icon_inline':
+                    if(($sResult = $this->getIconContent($aMatches[1])) === false)
+                        $sResult = '';
+                    break;
+                case 'get_text':
+                    $sResult = _t($aMatches[1]);
+                    break;
+                case 'get_text_js':
+                    $sResult = bx_js_string(_t($aMatches[1]));
+                    break;
+                case 'get_text_attribute':
+                    $sResult = bx_html_attribute(_t($aMatches[1]));
+                    break;
+                case 'get_injection':
+                    $sResult = $oTemplate->processInjection($oTemplate->getPageNameIndex(), $aMatches[1]);
+                    break;
+                case 'get_menu':
+                    $sResult = $oTemplate->getMenu($aMatches[1]);
+                    break;
+                case 'get_page':
+                    $oPage = BxDolPage::getObjectInstanceByURI($aMatches[1], false, true);
+                    $oPage->setSubPage(true);
+                    $sResult = $oPage ? $oPage->getCode() : '';
+                    break;
+            }
+
+            return $sResult;
+        }, $sContent);
+
+        return $sContent;
+    }
+
+    /**
+     * Get absolute location of some template's part.
+     *
+     * @param  string $sType    - result type. Available values 'url' and 'path'.
+     * @param  string $sFolder  - folders to be searched in. @see $_sFolderHtml, $_sFolderCss, $_sFolderImages and $_sFolderIcons
+     * @param  string $sName    - requested part name.
+     * @param  string $sCheckIn where the content would be searched(base, template, both)
+     * @return string absolute location (path/url) of the part.
+     */
+    function _getAbsoluteLocation($sType, $sFolder, $sName, $sCheckIn = BX_DOL_TEMPLATE_CHECK_IN_BOTH)
+    {
+        $sDirectory = $this->getPath();
+
+        if($sType == 'path') {
+            $sDivider = DIRECTORY_SEPARATOR;
+            $sRoot = BX_DIRECTORY_PATH_ROOT;
+        } else if($sType == 'url') {
+            $sDivider = '/';
+            $sRoot = BX_DOL_URL_ROOT;
+        }
+
+        $aParts = [];
+        if(strpos($sName,'|') !== false) {
+            $aParts = explode('|', $sName);
+            $sName = $aParts[1];
+
+            if(strpos($aParts[0],'@') !== false) {
+                $aLocationParts = explode('@', $aParts[0]);
+                $sLocationKey = $this->addLocation($aLocationParts[0], BX_DIRECTORY_PATH_ROOT . $aLocationParts[1], BX_DOL_URL_ROOT . $aLocationParts[1]);
+            }
+        }
+
+        /** 
+         * Module(mod) related locations will be checked first in TMPL and BASE,
+         * then system(sys) location(s) will be checked in TMPL and BASE.
+         */
+        $aLocationsList = array_reverse($this->_aLocations, true);
+        $aLocationsGrouped = ['mod' => [], 'sys' => []];
+        foreach($aLocationsList as $sLocation => $aLocation) {
+            if(in_array($sLocation, ['system', 'studio']))
+                $aLocationsGrouped['sys'][$sLocation] = $aLocation;
+            else
+                $aLocationsGrouped['mod'][$sLocation] = $aLocation;
+        }
+
+        $sResult = '';
+        foreach($aLocationsGrouped as $aLocations) {
+            //--- Check it Template.
+            $bInSub = false;
+            $aCheckIn = [BX_DOL_TEMPLATE_CHECK_IN_BOTH, BX_DOL_TEMPLATE_CHECK_IN_TMPL];
+            if(in_array($sCheckIn, $aCheckIn) || $bInSub = (isset($sCheckIn['in'], $sCheckIn['sub']) && in_array($sCheckIn['in'], $aCheckIn)))
+                foreach($aLocations as $sKey => $aLocation)
+                    if((!$bInSub || $sCheckIn['sub'] == $sKey) && extFileExists(BX_DIRECTORY_PATH_MODULES . $this->getPath(). 'data' . DIRECTORY_SEPARATOR . BX_DOL_TEMPLATE_FOLDER_ROOT . DIRECTORY_SEPARATOR . $sKey . DIRECTORY_SEPARATOR . $sFolder . $sName)) {
+                        $sResult = $sRoot . 'modules' . $sDivider . $sDirectory. 'data' . $sDivider . BX_DOL_TEMPLATE_FOLDER_ROOT . $sDivider . $sKey . $sDivider . $sFolder . $sName;
+                        break 2;
+                    }
+
+            //--- Check it Base.
+            $bInSub = false;
+            $aCheckIn = [BX_DOL_TEMPLATE_CHECK_IN_BOTH, BX_DOL_TEMPLATE_CHECK_IN_BASE];
+            if(empty($sResult) && (in_array($sCheckIn, $aCheckIn) || $bInSub = (isset($sCheckIn['in'], $sCheckIn['sub']) && in_array($sCheckIn['in'], $aCheckIn))))
+                foreach($aLocations as $sKey => $aLocation)
+                    if((!$bInSub || $sCheckIn['sub'] == $sKey) && extFileExists($aLocation['path'] . BX_DOL_TEMPLATE_FOLDER_ROOT . DIRECTORY_SEPARATOR . $sFolder . $sName)) {
+                        $sResult = $aLocation[$sType] . BX_DOL_TEMPLATE_FOLDER_ROOT . $sDivider . $sFolder . $sName;
+                        break 2;
+                    }
+        }
+
+        /**
+         * try to find from received path
+         */
+        if(!$sResult && isset($aParts[0], $aParts[1]) && @is_file(BX_DIRECTORY_PATH_ROOT . $aParts[0] . DIRECTORY_SEPARATOR . $aParts[1]))
+            $sResult = $sRoot . $aParts[0] . $sDivider . $aParts[1];
+
+        if(isset($sLocationKey))
+           $this->removeLocation($sLocationKey);
+
+        return $sType == 'path' && !empty($sResult) ? realpath($sResult) : $sResult;
+    }
+    /**
+     * Get absolute location of some template's part.
+     *
+     * @param  string $sType result type. Available values 'url' and 'path'.
+     * @param  string $sName requested part name.
+     * @return string absolute location (path/url) of the part.
+     */
+    function _getAbsoluteLocationJs($sType, $sName)
+    {
+        $sResult = '';
+        $aLocations = array_reverse($this->_aLocationsJs, true);
+        foreach($aLocations as $sKey => $aLocation) {
+            if(extFileExists($aLocation['path'] . $sName))
+                $sResult = $aLocation[$sType] . $sName;
+            else
+                continue;
+            break;
+        }
+        return $sType == 'path' && !empty($sResult) ? realpath($sResult) : $sResult;
+    }
+    function _getAbsoluteLocationCss($sType, $sName)
+    {
+    	$sNameLess = str_replace('.css', '.less', $sName);
+
+    	$sResult = $this->_getAbsoluteLocation($sType, $this->_sFolderCss, $sNameLess);
+    	if(!empty($sResult))
+    		return $sResult;
+
+        return $this->_getAbsoluteLocation($sType, $this->_sFolderCss, $sName);
+    }
+    /**
+     * Get inline data for Images and Icons.
+     *
+     * @param  string  $sType    image/icon
+     * @param  string  $sName    file name
+     * @param  string  $sCheckIn where the content would be searched(base, template, both)
+     * @return unknown
+     */
+    function _getInlineData($sType, $sName, $sCheckIn, $bForceInline = false)
+    {
+        switch($sType) {
+            case 'image':
+                $sFolder = $this->_sFolderImages;
+                break;
+            case 'icon':
+                $sFolder = $this->_sFolderIcons;
+                break;
+        }
+        $sPath = $this->_getAbsoluteLocation('path', $sFolder, $sName, $sCheckIn);
+
+        $iFileSize = 0;
+        if(($this->_bImagesInline && ($iFileSize = filesize($sPath)) !== false && $iFileSize < $this->_iImagesMaxSize) || $bForceInline) {
+            $aFileInfo = pathinfo($sPath);
+            return $this->getImageMimeType($aFileInfo['extension']) . ";base64," . base64_encode(file_get_contents($sPath));
+        }
+
+        return false;
+    }
+
+    /**
+     * Source map for a compiled JS bundle, at file level: every line of the bundle points at the file it came from.
+     * A file that went into the bundle as it is (already minified, or minification off) maps line by line; a file
+     * minified here maps to its first line, because the PHP minifier emits no mappings. Sources are embedded, so
+     * DevTools and Lighthouse need nothing but the map.
+     *
+     * @param  string $sName     bundle name without extension.
+     * @param  array  $aSections one entry per bundled file: 'file' (url, path), 'line' (first line in the bundle), 'lines', 'exact'.
+     * @return string JSON source map, or an empty string when there is nothing to map.
+     */
+    protected function _getSourceMapJs($sName, $aSections)
+    {
+        $aSources = $aContents = $aLines = array();
+        foreach($aSections as $aSection) {
+            $sPath = $aSection['file']['path'];
+            $bExternal = strpos($sPath, 'http://') !== false || strpos($sPath, 'https://') !== false;
+            $sContent = $bExternal ? bx_file_get_contents($sPath) : @file_get_contents($sPath);
+
+            $iSource = count($aSources);
+            $aSources[] = $aSection['file']['url'];
+            $aContents[] = is_string($sContent) ? str_replace(array("\r\n", "\r"), "\n", $sContent) : '';
+
+            for($i = 0; $i < $aSection['lines']; $i++) {
+                if(!$aSection['exact'])
+                    $aLines[$aSection['line'] + $i] = array($iSource, 0);
+                else if($i >= 2 && $i < $aSection['lines'] - 1) // the BEGIN and END markers around the file are not in the source
+                    $aLines[$aSection['line'] + $i] = array($iSource, $i - 2);
+            }
+        }
+
+        if(empty($aLines))
+            return '';
+
+        $aMappings = array();
+        $iPrevSource = $iPrevLine = 0;
+        for($i = 0, $iLines = max(array_keys($aLines)) + 1; $i < $iLines; $i++) {
+            if(!isset($aLines[$i])) {
+                $aMappings[] = '';
+                continue;
+            }
+
+            list($iSource, $iLine) = $aLines[$i];
+            $aMappings[] = $this->_encodeVlq(0) . $this->_encodeVlq($iSource - $iPrevSource) . $this->_encodeVlq($iLine - $iPrevLine) . $this->_encodeVlq(0);
+            $iPrevSource = $iSource;
+            $iPrevLine = $iLine;
+        }
+
+        $sMap = json_encode(array(
+            'version' => 3,
+            'file' => $sName . '.js',
+            'sources' => $aSources,
+            'sourcesContent' => $aContents,
+            'names' => array(),
+            'mappings' => implode(';', $aMappings),
+        ), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+
+        return is_string($sMap) ? $sMap : '';
+    }
+
+    /**
+     * Base64 VLQ, the number encoding of source map mappings.
+     */
+    protected function _encodeVlq($iValue)
+    {
+        $sChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+
+        $iVlq = $iValue < 0 ? ((-$iValue) << 1) | 1 : $iValue << 1;
+        $sResult = '';
+        do {
+            $iDigit = $iVlq & 31;
+            $iVlq >>= 5;
+            if($iVlq > 0)
+                $iDigit |= 32;
+
+            $sResult .= $sChars[$iDigit];
+        } while($iVlq > 0);
+
+        return $sResult;
+    }
+
+    /**
+     * Get file name where the template would be cached.
+     *
+     * @param  string $sAbsolutePath template's real path.
+     * @return string the result of operation.
+     */
+    function _getCacheFileName($sType, $sAbsolutePath)
+    {
+        $sResult = bx_site_hash($sAbsolutePath);
+        switch($sType) {
+            case 'html':
+                $sResult = $this->_sCacheFilePrefix . bx_lang_name() . '_' . $this->_sCode .  '_' . $sResult;
+                break;
+            case 'css':
+                $sResult = $this->_sCssCachePrefix . (!empty($this->_iMix) ? $this->_iMix . '_' : '') .  $sResult;
+                break;
+            case 'js':
+                $sResult = $this->_sJsCachePrefix . $sResult;
+                break;
+        }
+
+        return $sResult;
+    }
+    /**
+     * Get template key wrappers(left, right)
+     *
+     * @param  mixed $mixedKeyWrapperHtml key wrapper(string value if left and right parts are the same, array(0 => left, 1 => right) otherwise).
+     * @return array result of operation.
+     */
+    function _getKeyWrappers($mixedKeyWrapperHtml)
+    {
+        $aResult = array();
+        if(!empty($mixedKeyWrapperHtml) && is_string($mixedKeyWrapperHtml))
+            $aResult = array('left' => $mixedKeyWrapperHtml, 'right' => $mixedKeyWrapperHtml);
+        else if(!empty($mixedKeyWrapperHtml) && is_array($mixedKeyWrapperHtml))
+            $aResult = array('left' => $mixedKeyWrapperHtml[0], 'right' => $mixedKeyWrapperHtml[1]);
+        else
+            $aResult = array('left' => $this->_sKeyWrapperHtml, 'right' => $this->_sKeyWrapperHtml);
+        return $aResult;
+    }
+
+    /**
+     * Process all added language translations and return them as a string.
+     *
+     * @return string with JS code.
+     */
+    function _processJsTranslations()
+    {
+        $sReturn = '';
+        if(isset($this->aPage['js_translations']) && is_array($this->aPage['js_translations'])) {
+            foreach($this->aPage['js_translations'] as $sKey => $sString)
+                $sReturn .= "'" .  bx_js_string($sKey) . "': '" . bx_js_string($sString) . "',";
+
+            $sReturn = substr($sReturn, 0, -1);
+        }
+
+        return '
+<script language="javascript">
+    if (\'undefined\' === typeof(aDolLang)) 
+        var aDolLang = {' . $sReturn . '};
+    else
+        $.extend(aDolLang, {' . $sReturn . '});
+</script>';
+    }
+    /**
+     * Process all added options and return them as a string.
+     *
+     * @return string with JS code.
+     */
+    function _processJsOptions()
+    {
+        $sReturn = '';
+        if(isset($this->aPage['js_options']) && is_array($this->aPage['js_options'])) {
+            foreach($this->aPage['js_options'] as $sName => $mixedValue)
+                $sReturn .= "'" .  bx_js_string($sName) . "': '" . bx_js_string($mixedValue) . "',";
+
+            $sReturn = substr($sReturn, 0, -1);
+        }
+
+        return '<script language="javascript">var aDolOptions = {' . $sReturn . '};</script>';
+    }
+    /**
+     * Process all added images and return them as a string.
+     *
+     * @return string with JS code.
+     */
+    function _processJsImages()
+    {
+        $sReturn = '';
+        if(isset($this->aPage['js_images']) && is_array($this->aPage['js_images'])) {
+            foreach($this->aPage['js_images'] as $sKey => $sUrl)
+                $sReturn .= "'" .  bx_js_string($sKey) . "': '" . bx_js_string($sUrl) . "',";
+
+            $sReturn = substr($sReturn, 0, -1);
+        }
+
+        return '<script language="javascript">var aDolImages = {' . $sReturn . '};</script>';
+    }
+
+    /**
+     * Get Gzip loader URL.
+     *
+     * @param $sType content type CSS/JS
+     * @param $sName file name.
+     * @return string with URL
+     */
+    function _getLoaderUrl($sType, $sName)
+    {
+        return BX_DOL_URL_ROOT . 'gzip_loader.php?file=' . $sName . '.' . $sType;
+    }
+
+    /**
+     * Get current revision number.
+     * 
+     * @return integer number
+     */
+    public function getRevision()
+    {
+        return (int)getParam('sys_revision');
+    }
+
+    /**
+     * Add current revision number to URL.
+     * 
+     * @return string with URL 
+     */
+    public function addRevision($sUrl)
+    {
+        return bx_append_url_params($sUrl, ['rev' => $this->getRevision()]);
+    }
+
+    /**
+     *
+     * Functions to display pages with errors, messages and so on.
+     *
+     */
+    function displayAccessDenied ($sMsg = '', $iPage = BX_PAGE_DEFAULT, $iDesignBox = BX_DB_PADDING_DEF)
+    {
+        bx_import('BxDolLanguages');
+        header('HTTP/1.0 403 Forbidden');
+        header('Status: 403 Forbidden');
+        
+        $a = [
+            'title' => _t('_access_denied_page_title'),
+            'content' => _t('_access_denied_page_content'),
+        ];
+        
+        $this->displayMsg($sMsg ? $sMsg : _t('_Access denied'), false, $iPage, $iDesignBox);
+    }
+
+    function displayNoData ($sMsg = '', $iPage = BX_PAGE_DEFAULT, $iDesignBox = BX_DB_PADDING_DEF)
+    {
+        bx_import('BxDolLanguages');
+        header('HTTP/1.0 204 No Content');
+        header('Status: 204 No Content');
+        $this->displayMsg($sMsg ? $sMsg : _t('_Empty'), false, $iPage, $iDesignBox);
+    }
+
+    function displayErrorOccured ($sMsg = '', $iPage = BX_PAGE_DEFAULT, $iDesignBox = BX_DB_PADDING_DEF)
+    {
+        bx_import('BxDolLanguages');
+        header('HTTP/1.0 500 Internal Server Error');
+        header('Status: 500 Internal Server Error');
+        $this->displayMsg($sMsg ? $sMsg : _t('_error occured'), false, $iPage, $iDesignBox);
+    }
+
+    function displayPageNotFound ($sMsg = '', $iPage = BX_PAGE_DEFAULT, $iDesignBox = BX_DB_PADDING_DEF)
+    {
+        bx_import('BxDolLanguages');
+        header('HTTP/1.0 404 Not Found');
+        header('Status: 404 Not Found');
+        $this->displayMsg($sMsg ? $sMsg : _t('_sys_request_page_not_found_cpt'), false, $iPage, $iDesignBox);
+    }
+
+    function displayMsg ($s, $bTranslate = false, $iPage = BX_PAGE_DEFAULT, $iDesignBox = BX_DB_PADDING_DEF)
+    {
+        $sError = '_Error';
+        $bArray = is_array($s);
+
+        $sTitle = $bArray ? $s['title'] : ($bTranslate ? $sError : _t($sError));
+        $sContent = $bArray ? $s['content'] : $s;
+
+        if($bTranslate) {
+            $sTitle = _t($sTitle);
+            $sContent = _t($sContent);
+        }
+        
+        if (bx_is_api())
+            return [bx_api_get_msg($sContent)];
+
+        $sContent = MsgBox($sContent);
+        if($iPage == BX_PAGE_DEFAULT)
+            $sContent = DesignBoxContent($sTitle, $sContent, $iDesignBox);
+
+        $oTemplate = BxDolTemplate::getInstance();
+        $oTemplate->setPageNameIndex ($iPage);
+        $oTemplate->setPageHeader ($sTitle);
+        $oTemplate->setPageContent ('page_main_code', $sContent);
+        $oTemplate->getPageCode();
+        exit;
+    }
+    
+    /**
+     * * * * Static methods for work with template injections * * *
+     *
+     * Static method is used to add/replace the content of some key in the template.
+     * It's usefull when you don't want to modify existing template but need to add some data to existing template key.
+     *
+     * @param  integer $iPageIndex - page index where injections would processed. Use 0 if you want it to be done on all the pages.
+     * @param  string  $sKey       - template key.
+     * @param  string  $sValue     - the data to be added.
+     * @return string  the result of operation.
+     */
+    function processInjection($iPageIndex, $sKey, $sValue = "")
+    {
+        if($iPageIndex != 0 && isset($this->aPage['injections']['page_0'][$sKey]) && isset($this->aPage['injections']['page_' . $iPageIndex][$sKey]))
+           $aSelection = @array_merge($this->aPage['injections']['page_0'][$sKey], $this->aPage['injections']['page_' . $iPageIndex][$sKey]);
+        else if(isset($this->aPage['injections']['page_0'][$sKey]))
+           $aSelection = $this->aPage['injections']['page_0'][$sKey];
+        else if(isset($this->aPage['injections']['page_' . $iPageIndex][$sKey]))
+            $aSelection = $this->aPage['injections']['page_' . $iPageIndex][$sKey];
+        else
+            $aSelection = array();
+
+        if(is_array($aSelection))
+            foreach($aSelection as $aInjection) {
+
+                if (isset($GLOBALS['bx_profiler'])) $GLOBALS['bx_profiler']->beginInjection($sRand = time().rand());
+
+                $sInjData = '';
+                switch($aInjection['type']) {
+                    case 'text':
+                        $sInjData = $aInjection['data'];
+                        break;
+
+                    case 'service':
+                    	if(BxDolService::isSerializedService($aInjection['data']))
+                    		$sInjData = BxDolService::callSerialized($aInjection['data']);                    	
+                        break;
+                }
+
+                if((int)$aInjection['replace'] == 1)
+                    $sValue = $sInjData;
+                else
+                    $sValue .= $sInjData;
+
+                if (isset($GLOBALS['bx_profiler'])) $GLOBALS['bx_profiler']->endInjection($sRand, $aInjection);
+
+            }
+
+        return $sValue != '__' . $sKey . '__' ? str_replace('__' . $sKey . '__', '', $sValue) : $sValue;
+    }
+    /**
+     * Static method to add ingection available on the current page only.
+     *
+     * @param string  $sKey     - template's key.
+     * @param string  $sType    - injection type(text, php).
+     * @param string  $sData    - the data to be added.
+     * @param integer $iReplace - replace already existed data or not.
+     */
+    function addInjection($sKey, $sType, $sData, $iReplace = 0)
+    {
+        $this->aPage['injections']['page_0'][$sKey][] = array(
+            'page_index' => 0,
+            'key' => $sKey,
+            'type' => $sType,
+            'data' => $sData,
+            'replace' => $iReplace
+        );
+    }
+
+    function getPageCode($oTemplate = null)
+    {
+        if (empty($oTemplate))
+           $oTemplate = $this;
+
+        /**
+         * @hooks
+         * @hookdef hook-system-design_before_output 'system', 'design_before_output' - hook on before page's html generated
+         * - $unit_name - equals `system`
+         * - $action - equals `design_before_output` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `page` - [array] by ref, page object paramenters, can be overridden in hook processing
+         *      - `page_content` - [array] by ref, page content values, can be overridden in hook processing
+         * @hook @ref hook-system-design_before_output
+         */
+        bx_alert('system', 'design_before_output', 0, 0, [
+            'page' => &$this->aPage, 
+            'page_content' => &$this->aPageContent,
+            'page_ref' => &$this->aPage, 
+            'page_content_ref' => &$this->aPageContent
+        ]);
+
+        header( 'Content-type: text/html; charset=utf-8' );
+        $sXFrameOpts = getParam('sys_x_frame_options');
+        if ('Off' !== $sXFrameOpts)
+            header( 'X-Frame-Options: ' . $sXFrameOpts );
+        if (BX_PAGE_EMBED == $oTemplate->getPageNameIndex())
+            header('Content-Security-Policy: frame-ancestors ' . getParam('sys_csp_frame_ancestors'));
+
+        $sResult = $oTemplate->parsePageByName('page_' . $oTemplate->getPageNameIndex() . '.html', $oTemplate->getPageContent());
+        
+        /**
+         * @hooks
+         * @hookdef hook-system-design_after_output 'system', 'design_after_output' - hook on after page's html generated
+         * - $unit_name - equals `system`
+         * - $action - equals `design_after_output` 
+         * - $object_id - not used 
+         * - $sender_id - not used 
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `override_result` - [string] by ref, html content for current page, can be overridden in hook processing
+         * @hook @ref hook-system-design_after_output
+         */
+        bx_alert('system', 'design_after_output', 0, false, ['override_result' => &$sResult]);
+        
+        echo $sResult;
+    }
+}
+
+/** @} */

+ 1016 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolTranscoder.php

@@ -0,0 +1,1016 @@
+<?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
+ * @{
+ */
+
+define('BX_DOL_QUEUE_PENDING', 'pending'); ///< file is pending for processing
+define('BX_DOL_QUEUE_PROCESSING', 'processing'); ///< file is in the process
+define('BX_DOL_QUEUE_COMPLETE', 'complete'); ///< file is already processed
+define('BX_DOL_QUEUE_FAILED', 'failed'); ///< file processing was failed
+define('BX_DOL_QUEUE_DELETE', 'delete'); ///< file is subject to delete
+
+if (!defined('BX_DOL_MAX_RETINA_DIMENSION_MOBILE'))
+    define('BX_DOL_MAX_RETINA_DIMENSION_MOBILE', 500); ///< max image dimension which will be processes as retine on mobiles
+
+if (!defined('BX_DOL_SKIP_ATIME_UPDATE'))
+    define('BX_DOL_SKIP_ATIME_UPDATE', 43200); ///< time interval to not update last access time, it should be comaparatively low compared to atime_prining
+
+/**
+ * Base class for @see BxDolTranscoderImage and @see BxDolTranscoderVideo transcoders
+ */
+class BxDolTranscoder extends BxDolFactory implements iBxDolFactoryObject
+{
+    protected $_sLog = ''; ///< file conversion log
+    protected $_aObject; ///< object properties
+    protected $_oStorage; ///< storage object, transcoded files are stored here
+    protected $_oDb; ///< database queries object
+    protected $_sRetinaSuffix = '@2x'; ///< handler suffix for retina image file
+    protected $_sQueueTable = ''; ///< table with queued files for transcoding
+    protected $_sQueueStorage = ''; ///< storage object to storage temporary files
+    protected $_iDevicePixelRatio = false; ///< force device pixel ratio, 1 or 2
+
+    /**
+     * constructor
+     */
+    protected function __construct($aObject, $oStorage)
+    {
+        parent::__construct();
+        $this->_aObject = $aObject;
+        $this->_oStorage = $oStorage;
+        $this->_oDb = null;
+    }
+
+    /**
+     * Get transcode object instance.
+     * @param $sObject - name of transcode object.
+     * @return false on error or instance of transcoder class.
+     */
+    public static function getObjectInstance($sObject)
+    {
+        if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '!' . $sObject]))
+            return $GLOBALS['bxDolClasses'][__CLASS__ . '!'.$sObject];
+
+        // get transcode object
+        $aObject = BxDolTranscoderQuery::getTranscoderObject($sObject);
+        if (!$aObject || !is_array($aObject))
+            return false;
+
+        if ($aObject['source_params'])
+            $aObject['source_params'] = unserialize($aObject['source_params']);
+
+        $aObject['filters'] = false; // filters are initialized on demand
+
+        // create storage object to store transcoded data
+        $oStorage = BxDolStorage::getObjectInstance($aObject['storage_object']);
+        if (!$oStorage)
+            return false;
+
+        // create instance
+        $sClass = $aObject['override_class_name'] ? $aObject['override_class_name'] : 'BxDolTranscoderImage';
+        if (!empty($aObject['override_class_file']))
+            require_once(BX_DIRECTORY_PATH_ROOT . $aObject['override_class_file']);
+
+        $o = new $sClass ($aObject, $oStorage);
+
+        return ($GLOBALS['bxDolClasses'][__CLASS__ . '!' . $sObject] = $o);
+    }
+
+    /**
+     * Delete outdated transcoed data from all transcoeeed objects, by last access time.
+     * It called on cron, usually every day.
+     * @return total number of pruned/deleted files
+     */
+    static public function pruning ()
+    {
+        $iCount = 0;
+        $aObjects = self::getTranscoderObjects();
+        foreach ($aObjects as $aObject) {
+            if (!$aObject['atime_tracking'] || !$aObject['atime_pruning'])
+                continue;
+            $oTranscoder = self::getObjectInstance($aObject['object']);
+            if (!$oTranscoder)
+                continue;
+            $iCount += $oTranscoder->prune();
+        }
+        return $iCount;
+    }
+
+    /**
+     * Get transcoder objects array
+     * @return array
+     */
+    static protected function getTranscoderObjects ()
+    {
+        return BxDolTranscoderQuery::getTranscoderObjects ();
+    }
+
+    /**
+     * Process files queued for transcoding
+     * Can be performed on separate server.
+     */
+    static public function processQueue ($bQueuePruning = true)
+    {
+        if ($bQueuePruning)
+            self::pruneQueue();
+
+        $a = BxDolTranscoderQuery::getNextInQueue (gethostname());
+        foreach ($a as $r) {
+            $o = self::getObjectInstance($r['transcoder_object']);
+            if (!$o) {
+                BxDolTranscoderQuery::deleteFromQueueById($r['id']);
+                continue;
+            }
+
+            $aQueue = $o->getDb()->getFromQueue($r['file_id_source']); 
+            if (!$aQueue || $aQueue['status'] != BX_DOL_QUEUE_PENDING)
+                continue;
+            
+            $o->transcode($aQueue['file_id_source'], $aQueue['profile_id']);
+        }
+    }
+
+    static public function pruneQueue () 
+    {
+        $a = BxDolTranscoderQuery::getForDeletionFromQueue (gethostname());
+        if (!$a)
+            return false;
+
+        foreach ($a as $aQueue) {
+            $o = self::getObjectInstance($aQueue['transcoder_object']);
+            if ($o)
+                $o->deleteFromQueue($aQueue['file_id_source']);
+        }
+    }
+
+    /**
+     * Store completed files from tmp queue storage to the final storage
+     * Must be performed on the actual site.
+     */
+    static public function processCompleted ()
+    {
+        $a = BxDolTranscoderQuery::getCompletedFromQueue ();
+        foreach ($a as $r) {
+            $o = self::getObjectInstance($r['transcoder_object']);
+            $aQueue = $o->getDb()->getFromQueue($r['file_id_source']); 
+            if (!$aQueue || $aQueue['status'] != BX_DOL_QUEUE_COMPLETE)
+                continue;
+            
+            if ($aQueue['file_url_result'] && $aQueue['file_id_result']) {
+  
+                // get transcoded file from provided URL              
+                $sFileData = bx_file_get_contents ($aQueue['file_url_result']);
+                if (false === $sFileData) {
+                    $o->getDb()->updateQueueStatus($aQueue['file_id_source'], BX_DOL_QUEUE_FAILED, "download file failed: {$aQueue['file_url_result']}\n");
+                    bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: processCompleted failed, get transcoded file from URL ({$aQueue['file_url_result']}) failed", BX_LOG_ERR);
+                    continue;   
+                }
+                
+                $sTmpFile = $o->getTmpFilename ('.' . $aQueue['file_ext_result']);
+                if (!file_put_contents($sTmpFile, $sFileData)) {
+                    $o->getDb()->updateQueueStatus($aQueue['file_id_source'], BX_DOL_QUEUE_FAILED, "store downloaded file failed\n");
+                    @unlink($sTmpFile);
+                    bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: processCompleted failed, store transcoded file to tmp file ({$sTmpFile}) failed", BX_LOG_ERR);
+                    continue;
+                }
+                
+                // store downloaded file in final storage
+                if ($o->storeTranscodedFile($aQueue['file_id_source'], $sTmpFile, $aQueue['profile_id'])) {
+                   $o->getDb()->updateQueueStatus($aQueue['file_id_source'], BX_DOL_QUEUE_DELETE); // mark to delete it (deletion is performed on the server where it was transcoded)
+                } else {
+                    $o->getDb()->updateQueueStatus($aQueue['file_id_source'], BX_DOL_QUEUE_FAILED, "store file failed:\n" . $o->getLog());
+                    bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: processCompleted failed, store transcoded file ({$aQueue['file_id_source']}) in final storage failed: " . $o->getLog(), BX_LOG_ERR);
+                }
+
+                // delete tmp local file
+                @unlink($sTmpFile);
+            }
+        }
+    }
+
+    /**
+     * Register handlers array
+     * It can be called upon module enable event.
+     * @param $mixed array of transcoders objects, or just one object
+     */
+    static public function registerHandlersArray ($mixed)
+    {
+        self::_callFuncForObjectsArray ($mixed, 'registerHandlers');
+    }
+
+    /**
+     * Register system handlers
+     */
+    static public function registerHandlersSystem()
+    {
+        $sPrefix = 'sys_';
+
+        $aResults = array();
+        $aTranscoders = BxDolTranscoderQuery::getTranscoderObjects();
+        foreach($aTranscoders as $aTranscoder) {
+            if(strncasecmp($aTranscoder['object'], $sPrefix, strlen($sPrefix)) !== 0)
+                continue;
+
+            $aResults[] = $aTranscoder['object'];
+        }
+
+        if(!empty($aResults))
+            self::registerHandlersArray($aResults);
+    }
+
+    /**
+     * Unregister handlers array
+     * It can be called upon module disbale event.
+     * @param $mixed array of transcoders objects, or just one object
+     */
+    static public function unregisterHandlersArray ($mixed)
+    {
+        self::_callFuncForObjectsArray ($mixed, 'unregisterHandlers');
+    }
+
+    /**
+     * Cleanup (queue for deletion all resized files)
+     * It can be called upon module disbale event.
+     * @param $mixed array of transcoders objects, or just one object
+     */
+    static public function cleanupObjectsArray ($mixed)
+    {
+        self::_callFuncForObjectsArray ($mixed, 'cleanup');
+    }
+
+    /**
+     * Called automatically, upon local(transcoded) file deletetion.
+     */
+    static public function onAlertResponseFileDeleteLocal ($oAlert, $sObject)
+    {
+        $oTranscoder = self::getObjectInstance($sObject);
+        if (!$oTranscoder)
+            return;
+
+        if ($oAlert->sAction != 'file_deleted')
+            return;
+
+        $oTranscoder->onDeleteFileLocal($oAlert->iObject);
+    }
+
+    /**
+     * Called automatically, upon original file deletetion.
+     */
+    static public function onAlertResponseFileDeleteOrig ($oAlert, $sObject)
+    {
+        $oTranscoder = self::getObjectInstance($sObject);
+        if (!$oTranscoder)
+            return;
+
+        if ($oAlert->sAction != 'file_deleted')
+            return;
+
+        $oTranscoder->onDeleteFileOrig($oAlert->iObject);
+    }
+
+    /**
+     * Called automatically, upon local(transcoded) file deletetion.
+     */
+    public function onDeleteFileLocal($iFileId)
+    {
+        return $this->_oDb->deleteFileTraces($iFileId);
+    }
+
+    /**
+     * Called automatically, upon original file deletetion.
+     */
+    public function onDeleteFileOrig($mixedHandler)
+    {
+        // deleted files queued for transcoding
+        $this->_oDb->deleteFromQueue ($mixedHandler);
+
+        // delete main file
+        $iFileId = $this->_oDb->getFileIdByHandler($mixedHandler);
+        if ($iFileId && !$this->_oStorage->deleteFile($iFileId)) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: onDeleteFileOrig failed for handler ({$mixedHandler}) delete failed", BX_LOG_ERR);
+            return false;
+        }
+
+        // delete retina file
+        $iFileId = $this->_oDb->getFileIdByHandler($mixedHandler . $this->_sRetinaSuffix);
+        if ($iFileId && !$this->_oStorage->deleteFile($iFileId)) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: onDeleteFileOrig failed for handler ({$mixedHandler}) delete retina file failed", BX_LOG_ERR);
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Register necessary alert handlers for automatic deletetion of transcoded data if source file is deleted.
+     * Make sure that you call it once, before first usage, for example upon module installation.
+     */
+    public function registerHandlers ()
+    {
+        if (!$this->_oDb->registerHandlers ())
+            return false;
+        return $this->clearCacheDB();
+    }
+
+    /**
+     * Unregister alert handlers for automatic deletetion of transcoded data if source file is deleted.
+     * Make sure that you call it once, for example upon module uninstallation.
+     */
+    public function unregisterHandlers ()
+    {
+        if (!$this->_oDb->unregisterHandlers ())
+            return false;
+        return $this->clearCacheDB();
+    }
+
+    /**
+     * Delete (queue for deletion) all resized files for the current object
+     */
+    public function cleanup ()
+    {
+        return $this->_oStorage->queueFilesForDeletionFromObject();
+    }
+
+    /**
+     * Get storage object where transcoded data is stored
+     */
+    public function getStorage()
+    {
+        return $this->_oStorage;
+    }
+
+    /**
+     * Get database object instance
+     */
+    public function getDb()
+    {
+        return $this->_oDb;
+    }
+
+    /**
+     * check if transcoder suppors given file mime type
+     */ 
+    public function isMimeTypeSupported($sMimeType)
+    {
+        return false;
+    }
+
+    /**
+     * Get transcoded file url.
+     * If transcoded file is ready then direct url to the file is returned.
+     * If there is no transcoded data available, then special url is returned, upon opening this url image is transcoded automatically and redirects to the ready transcoed image.
+     * @param $mixedHandler - file handler
+     * @return file url, or false on error.
+     */
+    public function getFileUrl($mixedHandler)
+    {
+        if (bx_is_api() && !($this instanceof BxDolTranscoderVideo))
+            return $this->getOrigFileUrl($mixedHandler);
+        
+        if ($this->isFileReady($mixedHandler)) {
+
+            $mixedHandler = $this->processHandlerForRetinaDevice($mixedHandler);
+
+            $iFileId = $this->_oDb->getFileIdByHandler($mixedHandler);
+            if (!$iFileId) {
+                bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: getFileUrl failed for handler ({$mixedHandler}) source file id wasn't found", BX_LOG_ERR);
+                return false;
+            }
+
+            $aFile = $this->_oStorage->getFile($iFileId);
+            if (!$aFile) {
+                bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: getFileUrl failed for handler ({$mixedHandler}) source file wasn't found in the source storage", BX_LOG_ERR);
+                return false;
+            }
+
+            if ($this->_aObject['atime_tracking'])
+                $this->_oDb->updateAccessTime($mixedHandler);
+
+            return $this->_oStorage->getFileUrlById($iFileId);
+        }
+
+        if ($this->_sQueueTable && !$this->addToQueue($mixedHandler)) { // add file to queue (when queue is enabled) if it isn't transcoded yet
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: getFileUrl failed for handler ({$mixedHandler}), can't add file to queue", BX_LOG_ERR);
+            return false;
+        }
+
+        return $this->getFileUrlNotReady($mixedHandler);
+    }
+
+    /**
+     * alias for @see getFileUrl
+     */
+    public function getFileUrlById($mixedHandler)
+    {
+        if (bx_is_api() && !($this instanceof BxDolTranscoderVideo))
+            return $this->getOrigFileUrl($mixedHandler);
+
+        return $this->getFileUrl($mixedHandler);
+    }
+
+    /**
+     * Check if transcoded data is available. No need to call it directly, it is called automatically when it is needed.
+     * @param $mixedHandler - file handler
+     * @param $isCheckOutdated - check if transcoded file is outdated
+     * @return false if there is no ready transcoded file is available or it is outdated, true if file is ready
+     */
+    public function isFileReady ($mixedHandler, $isCheckOutdated = true)
+    {
+        $sMethodFileReady = 'isFileReady_' . $this->_aObject['source_type'];
+        return $this->$sMethodFileReady($mixedHandler, $isCheckOutdated);
+    }
+
+    /**
+     * Transcode file, no need to call it directly, it is called automatically when it is needed.
+     * @param $mixedHandler - file handler
+     * @param $iProfileId - optional profile id, to assign transcoded file ownership to, usually it is NOT assigned to any particular profile, so just leave it default
+     * @return true on success, false on error
+     */
+    public function transcode ($mixedHandler, $iProfileId = 0)
+    {
+        $sExtChange = false;
+        $bRet = true;
+
+        $iPID = function_exists('posix_getpid') ? posix_getpid() : 0;
+        $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_PROCESSING, '', gethostname(), '', '', '', $iPID);
+
+        do {
+
+            // create tmp file locally
+            $sSuffix = $this->_sQueueStorage ? 'Queue' : $this->_aObject['source_type'];
+            $sMethodStoreFile = 'storeFileLocally_' . $sSuffix;
+            $sTmpFile = $this->$sMethodStoreFile($mixedHandler);
+            if (!$sTmpFile) {
+                $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_FAILED, "store local tmp file failed ($sMethodStoreFile)");
+                $bRet = false;
+                break;
+            }
+
+            // appply filters to tmp file
+            $this->initFilters ();
+            foreach ($this->_aObject['filters'] as $aParams) {
+                $this->clearLog();
+                $sMethodFilter = 'applyFilter_' . $aParams['filter'];
+                if (!method_exists($this, $sMethodFilter))
+                    continue;
+                if (!$this->$sMethodFilter($sTmpFile, $aParams['filter_params'])) {
+                    unlink($sTmpFile);
+                    $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_FAILED, "filter failed: $sMethodFilter\n" . $this->getLog());
+                    $bRet = false;
+                    break 2;
+                }
+
+                if (!empty($aParams['filter_params']['force_type'])) {
+                    switch ($aParams['filter_params']['force_type']) {
+                        case 'jpeg':
+                        case 'jpg':
+                            $sExtChange = 'jpg';
+                            break;
+                        default:
+                            $sExtChange = $aParams['filter_params']['force_type'];
+                    }
+                }
+
+                if ($sExtChange) {
+                    $iDotPos = strrpos($sTmpFile, '.');
+                    $sExtOld = false === $iDotPos ? '' : substr($sTmpFile, $iDotPos+1);
+                    if ($sExtOld != $sExtChange) {
+                        $sTmpFileOld = $sTmpFile;
+                        $sTmpFile = false === $iDotPos ? $sTmpFile . '.' . $sExtChange : substr_replace($sTmpFile, $sExtChange, $iDotPos+1, strlen($sExtOld));
+                        if (!rename($sTmpFileOld, $sTmpFile)) {
+                            @unlink($sTmpFileOld);
+                            $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_FAILED, "change file extension failed: $sTmpFileOld => $sTmpFile");
+                            $bRet = false;
+                            break 2;
+                        }
+                    }
+                }
+            }
+
+            /**
+             * @hooks
+             * @hookdef hook-system-transcode 'system', 'transcode' - hook before file is transcoded
+             * - $unit_name - equals `system`
+             * - $action - equals `transcode` 
+             * - $object_id - file handler
+             * - $sender_id - equals `false`
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `transcoder_object` - [array] transcoder object properties
+             *      - `object` - [object] transcoder object
+             *      - `db` - [object] database object
+             *      - `file_path` - [string] file path
+             *      - `ret` - [bool] by ref, not used
+             * @hook @ref hook-system-transcode
+             */
+            bx_alert('system', 'transcode', $mixedHandler, false, array(
+                'transcoder_object' => $this->_aObject['object'],
+                'object' => $this,
+                'db' => $this->_oDb,
+                'file_path' => $sTmpFile,
+                'ret' => &$bRet,
+            ));
+
+            $mixedHandler = $this->processHandlerForRetinaDevice($mixedHandler);
+                    
+            if ($this->_sQueueStorage) {
+
+                $sFileUrlResult = '';
+                if (!($iFileIdResult = $this->storeTranscodedFileInQueueStorage($mixedHandler, $sTmpFile, $sFileUrlResult)))
+                    $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_FAILED, "store queue tmp file failed:\n" . $this->getLog());
+                else
+                    $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_COMPLETE, '', '', $iFileIdResult, $sFileUrlResult, $sExtChange);
+
+            } else {
+
+                $bRet = $this->storeTranscodedFile($mixedHandler, $sTmpFile, $iProfileId);
+                
+                if ($bRet)
+                    $this->deleteFromQueue($mixedHandler);
+                else
+                    $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_FAILED, "store file failed:\n" . $this->getLog());
+                    
+            }
+
+            @unlink($sTmpFile);
+
+        } while (false);
+
+        /**
+         * @hooks
+         * @hookdef hook-system-transcoded 'system', 'transcoded' - hook after file is transcoded
+         * - $unit_name - equals `system`
+         * - $action - equals `transcoded` 
+         * - $object_id - file handler
+         * - $sender_id - equals `false`
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object` - [object] transcoder object
+         *      - `ret` - [bool] by ref, override return value
+         * @hook @ref hook-system-transcoded
+         */
+        bx_alert($this->_aObject['object'], 'transcoded', $mixedHandler, false, array(
+            'object' => $this,
+            'ret' => &$bRet,
+        ));
+
+        return $bRet;
+    }
+
+    /**
+     * Delete outdated files by last access time.
+     * @return number of pruned/deleted files
+     */
+    public function prune ()
+    {
+        $aFiles = $this->_oDb->getFilesForPruning();
+        if (!$aFiles)
+            return false;
+        $iCount = 0;
+        foreach ($aFiles as $r)
+            $iCount += $this->_oStorage->deleteFile($r['file_id']) ? 1 : 0;
+        return $iCount;
+    }
+
+    public function forceDevicePixelRatio ($i)
+    {
+        if (!$i)
+            $this->_iDevicePixelRatio = false;
+        elseif ($i >= 2) 
+            $this->_iDevicePixelRatio = 2;
+        else
+            $this->_iDevicePixelRatio = 1;
+    }
+
+    public function getDevicePixelRatio ()
+    {   
+        $iRet = 1;
+        if($this->_iDevicePixelRatio)
+            $iRet = $this->_iDevicePixelRatio;
+        else if(!isset($this->_aObject['source_params']['disable_retina']) || !$this->_aObject['source_params']['disable_retina'])
+            $iRet = bx_get_device_pixel_ratio();
+
+        // don't resize images on mobile if they are already big enough
+        if ($iRet > 1 && $this->isMobile() && $this->getMaxResizeDimention() >= BX_DOL_MAX_RETINA_DIMENSION_MOBILE)
+            $iRet = 1;
+
+        return $iRet;
+    }
+
+    public function getOrigFileUrl ($mixedHandler)
+    {
+        $sMethodStoreFile = 'getOrigFileUrl_' . $this->_aObject['source_type'];
+        return $this->$sMethodStoreFile($mixedHandler);
+    }
+
+    /**
+     * Get file url when file isn't transcoded yet
+     */
+    public function getFileUrlNotReady($mixedHandler)
+    {
+        return false;
+    }
+
+    public function getTmpFilename ($sOverrideName = false)
+    {
+        if ($sOverrideName)
+            return BX_DIRECTORY_PATH_TMP . rand(10000, 99999) . $sOverrideName;
+        return tempnam(BX_DIRECTORY_PATH_TMP, $this->_aObject['object']);
+    }
+
+    public function clearLog()
+    {
+        $this->_sLog = '';
+    }
+
+    public function addToLog($s)
+    {
+        $this->_sLog .= $s;
+    }
+
+    public function getLog()
+    {
+        return $this->_sLog;
+    }
+
+    public function deleteFromQueue ($mixedHandler)
+    {
+        $sErrMsg = "delete queue tmp file failed:\n";
+        $a = $this->_oDb->getFromQueue($mixedHandler);
+        if (!$a || $a['server'] != gethostname()) // deletion must be performed on the server where it was transcoded
+            return false;
+        
+        if ($a['file_id_result'] && $this->_sQueueStorage && 0 !== strncmp($a['log'], $sErrMsg, strlen($sErrMsg))) {
+            $oStorage = BxDolStorage::getObjectInstance($this->_sQueueStorage);
+            if (!$oStorage->deleteFile($a['file_id_result'])) {
+                $this->_oDb->updateQueueStatus($mixedHandler, BX_DOL_QUEUE_FAILED, $sErrMsg . $this->getLog());
+                bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: deleteFromQueue failed for handler ({$mixedHandler}): " . $this->getLog(), BX_LOG_ERR);
+                return false;
+            }
+        }
+
+        return $this->_oDb->deleteFromQueue($mixedHandler);
+    }
+
+    public function storeTranscodedFile ($mixedHandler, $sTmpFile, $iProfileId)
+    {
+        $sMethodIsPrivate = 'isPrivate_' . $this->_aObject['source_type'];
+        $isPrivate = $this->$sMethodIsPrivate($mixedHandler);
+        $iFileId = $this->_oStorage->storeFileFromPath ($sTmpFile, $isPrivate, $iProfileId);
+        if (!$iFileId) {
+            $this->addToLog($this->_oStorage->getErrorString());
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeTranscodedFile failed for handler ({$mixedHandler}): " . $this->_oStorage->getErrorString(), BX_LOG_ERR);
+            return false;
+        }
+
+        if (!$this->_oDb->updateHandler($iFileId, $mixedHandler)) {
+            $this->_oStorage->deleteFile($iFileId);
+            return false;
+        }
+
+        $this->_oStorage->afterUploadCleanup($iFileId, $iProfileId);
+
+        return true;
+    }
+
+    protected function storeTranscodedFileInQueueStorage ($mixedHandler, $sTmpFile, &$sFileUrlResult)
+    {
+        if (!$this->_sQueueStorage)
+            return false;
+
+        $oStorage = BxDolStorage::getObjectInstance($this->_sQueueStorage);
+
+        $iFileIdResult = $oStorage->storeFileFromPath ($sTmpFile, 1, 0);
+        if (!$iFileIdResult) {
+            $this->addToLog($oStorage->getErrorString());
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeTranscodedFileInQueueStorage failed for handler ({$mixedHandler}): " . $oStorage->getErrorString(), BX_LOG_ERR);
+            return false;
+        }
+
+        if (!($sFileUrlResult = $oStorage->getFileUrlById($iFileIdResult))) {
+            $this->_oStorage->deleteFile($iFileIdResult);
+            return false;
+        }
+
+        $oStorage->afterUploadCleanup($iFileIdResult, 0);
+
+        return $iFileIdResult;
+    }
+
+    protected function addToQueue($mixedHandler, $iProfileId = 0)
+    {
+        if ($this->_oDb->getFromQueue($mixedHandler))
+            return true;
+        $sFileUrl = $this->getOrigFileUrl($mixedHandler);
+        return $this->_oDb->addToQueue ($mixedHandler, $sFileUrl, $iProfileId);
+    }
+
+    protected function applyFilter_Resize ($sFile, $aParams)
+    {
+        $o = BxDolImageResize::getInstance();
+        $o->removeCropOptions ();
+
+        $o->setSize (isset($aParams['w']) ? round($aParams['w'] * $this->getDevicePixelRatio()) : null, isset($aParams['h']) ? round($aParams['h'] * $this->getDevicePixelRatio()) : null);
+
+        if (isset($aParams['crop_resize']) && $aParams['crop_resize'])
+            $o->setAutoCrop (true);
+        elseif (isset($aParams['square_resize']) && $aParams['square_resize'])
+            $o->setSquareResize (true);
+        else
+            $o->setSquareResize (false);
+
+        // Optional per-transcoder JPEG quality. BxDolImageResize is a singleton reused for every
+        // image in the request, so the previous quality is restored afterwards.
+        $iQualityPrev = null;
+        if (isset($aParams['quality']) && (int)$aParams['quality'] > 0) {
+            $iQualityPrev = $o->getJpegQuality();
+            $o->setJpegQuality((int)$aParams['quality']);
+        }
+
+        $iResult = $o->resize($sFile);
+
+        if ($iQualityPrev !== null)
+            $o->setJpegQuality($iQualityPrev);
+
+        if (IMAGE_ERROR_SUCCESS == $iResult)
+            return true;
+
+        bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: applyFilter_Resize failed for file ({$sFile}): " . $o->getError(), BX_LOG_ERR);
+
+        return false;
+    }
+
+    protected function isFileReady_Folder ($mixedHandlerOrig, $isCheckOutdated = true)
+    {
+        $mixedHandler = $this->processHandlerForRetinaDevice($mixedHandlerOrig);
+
+        $iFileId = $this->_oDb->getFileIdByHandler($mixedHandler);
+        if (!$iFileId)
+            return false;
+
+        $aFile = $this->_oStorage->getFile($iFileId);
+        if (!$aFile)
+            return false;
+
+        if ($isCheckOutdated) { // warning, $isCheckOutdated is partially supported for Folder source type - file modification is not checked
+            if ($this->_aObject['ts'] > $aFile['modified'] || !$this->getFilePath_Folder($mixedHandlerOrig)) { // if we changed transcoder object params or original file is deleted
+                // delete file, so it will be recreated next time
+                if ($this->_oStorage->deleteFile($aFile['id']))
+                    return false;
+            }
+        }
+
+        return true;
+    }
+
+    protected function isFileReady_Storage ($mixedHandlerOrig, $isCheckOutdated = true)
+    {
+        $mixedHandler = $this->processHandlerForRetinaDevice($mixedHandlerOrig);
+
+        $iFileId = $this->_oDb->getFileIdByHandler($mixedHandler);
+        if (!$iFileId)
+            return false;
+
+        $aFile = $this->_oStorage->getFile($iFileId);
+        if (!$aFile)
+            return false;
+
+        if ($isCheckOutdated) {
+            $oStorageOriginal = BxDolStorage::getObjectInstance($this->_aObject['source_params']['object']);
+            if ($oStorageOriginal) {
+                $aFileOriginal = $oStorageOriginal->getFile($mixedHandlerOrig);
+                if (!$aFileOriginal || $aFileOriginal['modified'] > $aFile['modified'] || $this->_aObject['ts'] >= $aFile['modified']) { // if original file was changed OR we changed transcoder object params
+                    // delete file, so it will be recreated next time
+                    if ($this->_oStorage->deleteFile($aFile['id']))
+                        return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    protected function isPrivate_Folder ($mixedHandler)
+    {
+        if ('no' == $this->_aObject['private'])
+            return false;
+        return true;
+    }
+
+    protected function isPrivate_Storage ($mixedHandler)
+    {
+        switch ($this->_aObject['private']) {
+            case 'no':
+                return false;
+            case 'yes':
+                return true;
+            default:
+            case 'auto':
+                $oStorageOriginal = BxDolStorage::getObjectInstance($this->_aObject['source_params']['object']);
+                if (!$oStorageOriginal)
+                    return true; // in case of error - make sure that file is not public accidentally
+                $aFile = $oStorageOriginal->getFile($mixedHandler);
+                return $oStorageOriginal->isFilePrivate($aFile['id']);
+        }
+    }
+
+    protected function getFilePath_Folder ($mixedHandler)
+    {
+        $sPath = $this->_aObject['source_params']['path'] . $mixedHandler;
+        if (!file_exists($sPath))
+            $sPath = BX_DIRECTORY_PATH_ROOT . $sPath;
+
+        if (!file_exists($sPath)) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: getFilePath_Folder failed, file ({$sPath}) doesn't exist for handler ({$mixedHandler})", BX_LOG_ERR);
+            return false;
+        }
+
+        return $sPath;
+    }
+
+    protected function storeFileLocally_Folder ($mixedHandler)
+    {
+        $sPath = $this->getFilePath_Folder($mixedHandler);
+        if (!$sPath) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Folder failed, source file ({$sPath}) doesn't exist for handler ({$mixedHandler})", BX_LOG_ERR);
+            return false;
+        }
+
+        $sTmpFile = $this->getTmpFilename ($mixedHandler);
+        if (!copy($sPath, $sTmpFile)) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Folder failed, copy from file ({$sPath}) to file ({$sTmpFile}) failed", BX_LOG_ERR);
+            return false;
+        }
+
+        return $sTmpFile;
+    }
+
+    protected function storeFileLocally_Storage ($mixedHandler)
+    {
+        $oStorageOriginal = BxDolStorage::getObjectInstance($this->_aObject['source_params']['object']);
+        if (!$oStorageOriginal) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Storage failed, source storage({$this->_aObject['source_params']['object']}) isn't available", BX_LOG_ERR);
+            return false;
+        }
+
+        $aFile = $oStorageOriginal->getFile($mixedHandler);
+        if (!$aFile) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Storage failed, file({$mixedHandler}) wasn't found in source storage ({$this->_aObject['source_params']['object']})", BX_LOG_ERR);
+            return false;
+        }
+
+        $sTmpFile = $this->getTmpFilename ($aFile['file_name']);
+
+        // Prefer reading a local-engine source straight from disk. It avoids a
+        // self-referential HTTP round trip for every transcode (a small speed-up),
+        // and it works where the app cannot reach its own public URL from inside
+        // (e.g. a container whose BX_DOL_URL_ROOT is not routable locally).
+        $sLocalPath = $oStorageOriginal->getFileLocalPath($mixedHandler);
+        if ($sLocalPath && @copy($sLocalPath, $sTmpFile))
+            return $sTmpFile;
+
+        // Fallback: fetch over HTTP (remote storage engines, or if the disk copy failed).
+        $sUrl = $oStorageOriginal->getFileUrlById($mixedHandler);
+        if (!$sUrl) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Storage failed, file({$mixedHandler}) wasn't found in source storage ({$this->_aObject['source_params']['object']})", BX_LOG_ERR);
+            return false;
+        }
+
+        $sFileData = bx_file_get_contents ($sUrl, [], 'get', []);
+        if (false === $sFileData) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Storage failed, getting file from URL({$sUrl}) failed", BX_LOG_ERR);
+            return false;
+        }
+
+        if (!file_put_contents($sTmpFile, $sFileData)) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Storage failed, file save failed ({$sTmpFile})", BX_LOG_ERR);
+            return false;
+        }
+
+        return $sTmpFile;
+    }
+
+    protected function storeFileLocally_Queue ($mixedHandler)
+    {   
+        $aQueue = $this->_oDb->getFromQueue($mixedHandler);
+        if (!$aQueue || !$aQueue['file_url_source']) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Queue failed, file ({$mixedHandler}) wasn't found in queue", BX_LOG_ERR);
+            return false;
+        }
+
+        $sFileData = bx_file_get_contents ($aQueue['file_url_source'], [], 'get', []);
+        if (false === $sFileData) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Queue failed, getting file from URL({$aQueue['file_url_source']}) failed", BX_LOG_ERR);
+            return false;
+        }
+
+        $sTmpFile = $this->getTmpFilename ($mixedHandler);
+        if (!file_put_contents($sTmpFile, $sFileData)) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Queue failed, file save failed ({$sTmpFile})", BX_LOG_ERR);
+            return false;
+        }
+
+        return $sTmpFile;
+    }
+
+    protected function getOrigFileUrl_Folder ($mixedHandler)
+    {
+        $sPath = $this->_aObject['source_params']['path'] . $mixedHandler;
+        if (!file_exists(BX_DIRECTORY_PATH_ROOT . $sPath)) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: getOrigFileUrl_Folder failed, source file ({$sPath}) doesn't exist", BX_LOG_ERR);
+            return false;
+        }
+        
+        return BX_DOL_URL_ROOT . $sPath;
+    }
+
+    protected function getOrigFileUrl_Storage ($mixedHandler)
+    {
+        $oStorageOriginal = BxDolStorage::getObjectInstance($this->_aObject['source_params']['object']);
+        if (!$oStorageOriginal) {
+            bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: getOrigFileUrl_Storage failed, source storage ({$this->_aObject['source_params']['object']}) doesn't exist", BX_LOG_ERR);
+            return false;
+        }
+
+        return $oStorageOriginal->getFileUrlById($mixedHandler);
+    }
+
+    protected function initFilters ()
+    {
+        if (false !== $this->_aObject['filters']) // filters are already initilized
+            return;
+
+        // get transcoder filters
+        $aFilters = $this->_oDb->getTranscoderFilters();
+        $this->_aObject['filters'] = array();
+        foreach ($aFilters as $aFilter) {
+            $aFilter['filter_params'] = $aFilter['filter_params'] ? unserialize($aFilter['filter_params']) : array();
+            $this->_aObject['filters'][] = $aFilter;
+        }
+
+    }
+
+    protected function clearCacheDB()
+    {
+        $oCacheDb = $this->_oDb->getDbCacheObject();
+        return $oCacheDb->removeAllByPrefix('db_');
+    }
+
+    public function getDevicePixelRatioHandlerSuffix ()
+    {
+        $iDevicePixelRatio = $this->getDevicePixelRatio ();
+        if ($iDevicePixelRatio >= 2)
+            return $this->_sRetinaSuffix;
+        return '';
+    }
+
+    protected function processHandlerForRetinaDevice ($mixedHandler)
+    {
+        return '' . $mixedHandler . $this->getDevicePixelRatioHandlerSuffix ();
+    }
+
+    static protected function _callFuncForObjectsArray ($mixed, $sFunc)
+    {
+        if (!is_array($mixed))
+            $mixed = array($mixed);
+        foreach ($mixed as $sObject) {
+            $oTranscoder = self::getObjectInstance($sObject);
+            if ($oTranscoder)
+                $oTranscoder->$sFunc();
+        }
+    }
+
+    /**
+     * Get filter params
+     * @param $sName filter name
+     * @return array of filter params if filter was found, or false if given filter isn't enabled for the transcoder
+     */
+    public function getFilterParams ($sName)
+    {
+        $this->initFilters ();
+        foreach ($this->_aObject['filters'] as $aParams)
+            if ($sName == $aParams['filter'])
+                return $aParams['filter_params'];
+        return false;
+    }
+
+    public function getMaxResizeDimention ($sFilter = 'Resize', $sKey = '')
+    {
+        $a = $this->_oDb->getTranscoderFilters($sFilter);
+        if (!$a)
+            return 0;
+        $aFilter = array_pop($a);
+        $aFilterParams = $aFilter['filter_params'] ? unserialize($aFilter['filter_params']) : array();
+        if ($sKey)
+            return isset($aFilterParams[$sKey]) ? $aFilterParams[$sKey] : 0;
+        return $aFilterParams ? max(isset($aFilterParams['w']) ? $aFilterParams['w'] : 0, isset($aFilterParams['h']) ? $aFilterParams['h'] : 0) : 0;
+    }
+
+    public function isMobile ()
+    {
+        return bx_is_mobile();
+    }
+}
+
+/** @} */

+ 435 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVote.php

@@ -0,0 +1,435 @@
+<?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
+ * @{
+ */
+
+bx_import('BxDolAcl');
+
+define('BX_DOL_VOTE_TYPE_STARS', 'stars');
+define('BX_DOL_VOTE_TYPE_LIKES', 'likes');
+define('BX_DOL_VOTE_TYPE_REACTIONS', 'reactions');
+
+define('BX_DOL_VOTE_USAGE_BLOCK', 'block');
+define('BX_DOL_VOTE_USAGE_INLINE', 'inline');
+define('BX_DOL_VOTE_USAGE_DEFAULT', BX_DOL_VOTE_USAGE_BLOCK);
+
+/**
+ * Vote for any content
+ *
+ * Related classes:
+ * - BxDolVoteQuery - vote database queries
+ * - BxBaseVote - vote base representation
+ * - BxTemplVote - custom template representation
+ *
+ * AJAX vote for any content. Stars and Plus based representations are supported.
+ *
+ * To add vote section to your feature you need to add a record to 'sys_objects_vote' table:
+ *
+ * - ID - autoincremented id for internal usage
+ * - Name - your unique module name, with vendor prefix, lowercase and spaces are underscored
+ * - TableMain - table name where summary votigs are stored
+ * - TableTrack - table name where each vote is stored
+ * - PostTimeout - number of seconds to not allow duplicate vote
+ * - MinValue - min vote value, 1 by default
+ * - MaxValue - max vote value, 5 by default
+ * - IsUndo - is Undo enabled for Plus based votes
+ * - IsOn - is this vote object enabled
+ * - TriggerTable - table to be updated upon each vote
+ * - TriggerFieldId - TriggerTable table field with unique record id, primary key
+ * - TriggerFieldRate - TriggerTable table field with average rate
+ * - TriggerFieldRateCount - TriggerTable table field with votes count
+ * - ClassName - your custom class name, if you overrride default class
+ * - ClassFile - your custom class path
+ *
+ * You can refer to BoonEx modules for sample record in this table.
+ *
+ *
+ *
+ * @section example Example of usage:
+ * To get Star based vote you need to have different values for MinValue and MaxValue (for example 1 and 5)
+ * and IsUndo should be equal to 0. To get Plus(Like) based vote you need to have equal values
+ * for MinValue and MaxValue (for example 1) and IsUndo should be equal to 1. After filling the other
+ * paramenters in the table you can show vote in any place, using the following code:
+ * @code
+ * $o = BxDolVote::getObjectInstance('system object name', $iYourEntryId);
+ * if (!$o->isEnabled()) return '';
+ *     echo $o->getElementBlock();
+ * @endcode
+ *
+ *
+ * @section acl Memberships/ACL:
+ * - vote
+ *
+ *
+ *
+ * @section alerts Alerts:
+ * Alerts type/unit - every module has own type/unit, it equals to ObjectName.
+ * The following alerts are rised:
+ *
+ * - rate - comment was posted
+ *      - $iObjectId - entry id
+ *      - $iSenderId - rater user id
+ *      - $aExtra['rate'] - rate
+ *
+ */
+
+class BxDolVote extends BxDolObjectVote
+{
+    protected $_sType;
+
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct($sSystem, $iId, $iInit, $oTemplate);
+        if(empty($this->_sSystem))
+            return;
+    }
+
+    /**
+     * get votes object instanse
+     * @param $sSys vote object name
+     * @param $iId associated content id, where vote is available
+     * @param $iInit perform initialization
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstance($sSys, $iId, $iInit = true, $oTemplate = false)
+    {
+        $sKey = 'BxDolVote!' . $sSys . $iId . ($oTemplate ? $oTemplate->getClassName() : '');
+        if(isset($GLOBALS['bxDolClasses'][$sKey]))
+            return $GLOBALS['bxDolClasses'][$sKey];
+
+        $aSystems = self::getSystems();
+        if(!isset($aSystems[$sSys]))
+            return null;
+
+        $sClassName = 'BxTemplVoteLikes';
+        if(!empty($aSystems[$sSys]['class_name'])) {
+            $sClassName = $aSystems[$sSys]['class_name'];
+            if(!empty($aSystems[$sSys]['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSystems[$sSys]['class_file']);
+        }
+
+        $o = new $sClassName($sSys, $iId, false, $oTemplate);
+        if($iInit && method_exists($o, 'init'))
+            $o->init($iId);
+
+        return ($GLOBALS['bxDolClasses'][$sKey] = $o);
+    }
+
+    public static function &getSystems()
+    {
+        $sKey = 'bx_dol_cache_memory_vote_systems';
+
+        if(!isset($GLOBALS[$sKey]))
+            $GLOBALS[$sKey] = BxDolDb::getInstance()->fromCache('sys_objects_vote', 'getAllWithKey', '
+                SELECT
+                    `ID` as `id`,
+                    `Name` AS `name`,
+                    `Module` AS `module`,
+                    `TableMain` AS `table_main`,
+                    `TableTrack` AS `table_track`,
+                    `PostTimeout` AS `post_timeout`,
+                    `MinValue` AS `min_value`,
+                    `MaxValue` AS `max_value`,
+                    `Pruning` AS `pruning`,
+                    `IsUndo` AS `is_undo`,
+                    `IsOn` AS `is_on`,
+                    `TriggerTable` AS `trigger_table`,
+                    `TriggerFieldId` AS `trigger_field_id`,
+                    `TriggerFieldAuthor` AS `trigger_field_author`,
+                    `TriggerFieldRate` AS `trigger_field_rate`,
+                    `TriggerFieldRateCount` AS `trigger_field_count`,
+                    `ClassName` AS `class_name`,
+                    `ClassFile` AS `class_file`
+                FROM `sys_objects_vote`', 'name');
+
+        return $GLOBALS[$sKey];
+    }
+
+
+    /**
+     * Interface functions for outer usage
+     */
+    public function getType()
+    {
+        return $this->_sType;
+    }
+
+    public function getMinValue()
+    {
+        return (int)$this->_aSystem['min_value'];
+    }
+
+    public function getMaxValue()
+    {
+        return (int)$this->_aSystem['max_value'];
+    }
+
+    public function getStatCounter()
+    {
+        $aVote = $this->_getVote();
+        return $aVote['count'];
+    }
+
+    public function getStatRate()
+    {
+        $aVote = $this->_getVote();
+        return $aVote['rate'];
+    }
+
+    public function getSocketName()
+    {
+        return $this->_sSystem . '_' . $this->_sType;
+    }
+
+    /**
+     * Actions functions
+     */
+    public function actionVote()
+    {
+        if(!$this->isEnabled())
+            return echoJson(['code' => BX_DOL_OBJECT_ERR_NOT_AVAILABLE, 'message' => _t('_vote_err_not_enabled')]);
+
+        $aVoteData = $this->_getVoteData();
+        $aRequestParamsData = $this->_getRequestParamsData();
+        if($aVoteData === false)
+            return echoJson(['code' => BX_DOL_OBJECT_ERR_WRONG_DATE]);
+
+        return echoJson($this->vote($aVoteData, $aRequestParamsData));
+    }
+
+    public function vote($aVoteData = [], $aRequestParamsData = [])
+    {
+        $iObjectId = $this->getId();
+        $iAuthorId = $this->_getAuthorId();
+        $iAuthorIp = $this->_getAuthorIp();
+
+        $bUndo = $this->isUndo();
+        $bVoted = $this->isPerformed($iObjectId, $iAuthorId);
+        $bPerformUndo = $bVoted && $bUndo;
+
+        if(!$bPerformUndo && !$this->isAllowedVote())
+            return ['code' => BX_DOL_OBJECT_ERR_ACCESS_DENIED, 'message' => $this->msgErrAllowedVote()];
+
+        if($this->_isDuplicate($iObjectId, $iAuthorId, $iAuthorIp, $bVoted))
+            return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_vote_err_duplicate_vote')];
+
+        if($bPerformUndo) {
+            $aTrack = $this->_getTrack($iObjectId, $iAuthorId);
+            if(!empty($aTrack) && is_array($aTrack))
+                $aVoteData = array_intersect_key($aTrack, $aVoteData);
+        }
+
+        return $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo, $aRequestParamsData);
+    }
+
+    /**
+     * Store a vote (or take it back when $bPerformUndo), fire the alerts and the socket event.
+     * Checks (permissions, duplicates) are the caller's job.
+     */
+    protected function _vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo, $aRequestParamsData = [])
+    {
+        $iObjectAuthorId = $this->getObjectAuthorId($iObjectId);
+
+        $iId = $this->_putVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo);
+        if($iId === false)
+            return ['code' => BX_DOL_OBJECT_ERR_CANNOT_PERFORM];
+
+        if(!$bPerformUndo)
+            $this->isAllowedVote(true);
+
+        $this->_trigger();
+
+        $aAlertExtras = [
+            'object_author_id' => $iObjectAuthorId,
+            'source' => 'sys_vote_' . $iId,
+            'source_mac' => 'sys_vote_' . $iAuthorId . '_' . $iId,
+        ];
+        /**
+         * @hooks
+         * @hookdef hook-vote-undo 'vote', 'undo' - hook on cancel vote 
+         * - $unit_name - equals `vote`
+         * - $action - equals `undo` 
+         * - $object_id - vote id 
+         * - $sender_id - profile_id for vote's author
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_system` - [string] system name, ex: bx_posts
+         *      - `object_id` - [int] reported object id 
+         *      - `object_author_id` - [int] author's profile_id for reported object_id 
+         * @hook @ref hook-vote-undo
+         */
+        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'doVote', $iObjectId, $iAuthorId, array_merge([
+            'vote_id' => $iId, 
+            'vote_author_id' => $iAuthorId
+        ], $aAlertExtras, $aVoteData));
+        /**
+         * @hooks
+         * @hookdef hook-vote-do 'vote', 'do' - hook on new vote 
+         * - $unit_name - equals `vote`
+         * - $action - equals `do` 
+         * - $object_id - vote id 
+         * - $sender_id - profile_id for vote's author
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_system` - [string] system name, ex: bx_posts
+         *      - `object_id` - [int] reported object id 
+         *      - `object_author_id` - [int] author's profile_id for reported object_id 
+         * @hook @ref hook-vote-do
+         */
+        bx_alert('vote', ($bPerformUndo ? 'un' : '') . 'do', $iId, $iAuthorId, array_merge([
+            'object_system' => $this->_sSystem, 
+            'object_id' => $iObjectId
+        ], $aAlertExtras, $aVoteData));
+
+        $aResult = $this->_returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, !$bPerformUndo, $aRequestParamsData);
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled())
+            $oSockets->sendEvent($this->getSocketName(), $iObjectId, 'voted', json_encode($this->_returnVoteDataForSocket($aResult)));
+
+        return $aResult;
+    }
+
+    public function actionGetVotedBy()
+    {
+        if (!$this->isEnabled())
+           return '';
+
+        return $this->_getVotedBy();
+    }
+
+
+    /**
+     * Internal functions
+     */
+    protected function _isDuplicate($iObjectId, $iAuthorId, $iAuthorIp, $bVoted)
+    {
+        return false;
+    }
+
+    protected function _isCount($aVote = array())
+    {
+        if(empty($aVote))
+            $aVote = $this->_getVote();
+
+        return isset($aVote['count']) && (int)$aVote['count'] != 0;
+    }
+
+    protected function _isAllowedVoteByObject($aObject)
+    {
+        return bx_srv($this->_aSystem['module'], 'check_allowed_view_for_profile', [$aObject]) === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    protected function _getVoteData()
+    {
+        $iValue = bx_get('value');
+        if($iValue === false)
+            return false;
+
+        $iValue = bx_process_input($iValue, BX_DATA_INT);
+
+        $iMinValue = $this->getMinValue();
+        if($iValue < $iMinValue)
+            $iValue = $iMinValue;
+
+        $iMaxValue = $this->getMaxValue();
+        if($iValue > $iMaxValue)
+            $iValue = $iMaxValue;
+
+        return array('value' => $iValue);
+    }
+
+    protected function _putVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bPerformUndo)
+    {
+        return $this->_oQuery->putVote($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bPerformUndo);
+    }
+
+    protected function _returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bVoted, $aParams = [])
+    {
+        $bUndo = $this->isUndo();
+        $aVote = $this->_getVote($iObjectId, true);
+
+        $aResult = [
+            'code' => 0,
+            'rate' => $aVote['rate'],
+            'count' => $aVote['count'],
+            'countf' => (int)$aVote['count'] > 0 ? $this->_getCounterLabel($aVote['count'], $aParams) : '',
+            'label_use' => $this->_useIconAs($aParams),
+            'label_icon' => $this->_getIconDo($bVoted),
+            'label_emoji' => $this->_getEmojiDo($bVoted),
+            'label_image' => $this->_getImageDo($bVoted),
+            'label_title' => _t($this->_getTitleDo($bVoted)),
+            'voted' => $bVoted,
+            'disabled' => $bVoted && !$bUndo,
+        ];
+
+        $aResult['api'] = [
+            'ts' => microtime(true),
+            'performer_id' => $iAuthorId,
+            'is_voted' => $aResult['voted'],
+            'is_disabled' => $aResult['disabled'],
+            'value' => $aResult['voted'] ? $aData['value'] : 0,
+            'icon' => $aResult['label_emoji'],
+            'title' => $aResult['label_title'],
+            'counter' => $this->getVote()
+        ];
+
+        return $aResult;
+    }
+
+    protected function _returnVoteDataForSocket($aData, $aMask = [])
+    {
+        if(empty($aMask) || !is_array($aMask))
+            $aMask = ['code', 'rate', 'count', 'countf', 'api'];
+
+        return array_intersect_key($aData, array_flip($aMask));
+    }
+
+    protected function _prepareRequestParamsData($aParams, $aParamsAdd = array())
+    {
+        if(isset($aParams['is_voted']))
+            $aParamsAdd['is_voted'] = $aParams['is_voted'];
+
+        return parent::_prepareRequestParamsData($aParams, $aParamsAdd);
+    }
+
+    protected function _isVote($iObjectId = 0, $bForceGet = false)
+    {
+        $aVote = $this->_getVote($iObjectId, $bForceGet);
+
+        return (int)$aVote['count'] > 0;
+    }
+
+    protected function _getIconDo($bVoted)
+    {
+    	return '';
+    }
+
+    protected function _getImageDo($bVoted)
+    {
+        return '';
+    }
+
+    protected function _getEmojiDo($bVoted)
+    {
+    	return '';
+    }
+
+    protected function _getTitleDo($bVoted)
+    {
+    	return '';
+    }
+
+    protected function _getTitleDoBy($aParams = [])
+    {
+    	return _t('_vote_do_by');
+    }
+
+    protected function _useIconAs($aParams = [])
+    {
+    	return 'emoji';
+    }
+}
+
+/** @} */

+ 83 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVoteLikes.php

@@ -0,0 +1,83 @@
+<?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
+ * @{
+ */
+
+/**
+ * Like based votes for any content
+ */
+class BxDolVoteLikes extends BxTemplVote
+{
+    /**
+     * A like can always be taken back, whatever the object's IsUndo setting.
+     * Votes that are likes only in form (poll answers) set it to false to
+     * keep IsUndo.
+     */
+    protected $_bUndoAlways = true;
+
+    public function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct($sSystem, $iId, $iInit, $oTemplate);
+
+        $this->_oQuery = new BxDolVoteLikesQuery($this);
+        $this->_sType = BX_DOL_VOTE_TYPE_LIKES;
+    }
+
+    /**
+     * Interface functions for outer usage
+     */
+    public function getValue()
+    {
+        return (int)$this->_aSystem['min_value'];
+    }
+
+    public function isUndo()
+    {
+        return $this->_bUndoAlways || parent::isUndo();
+    }
+
+    /**
+     * Internal functions
+     */
+    protected function _isDuplicate($iObjectId, $iAuthorId, $iAuthorIp, $bVoted)
+    {
+        return $bVoted && !$this->isUndo();
+    }
+
+    protected function _getIconDo($bVoted)
+    {
+    	return $bVoted && $this->isUndo() ? 'fas thumbs-up' : 'far thumbs-up';
+    }
+
+    protected function _getEmojiDo($bVoted)
+    {
+    	return $bVoted && $this->isUndo() ? '👍' : '👍';
+    }
+
+    protected function _getImageDo($bVoted)
+    {
+        $sImageDo = '<svg aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.633 10.5c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75A2.25 2.25 0 0116.5 4.5c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23H5.904M14.25 9h2.25M5.904 18.75c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 01-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 10.203 4.167 9.75 5 9.75h1.053c.472 0 .745.556.5.96a8.958 8.958 0 00-1.302 4.665c0 1.194.232 2.333.654 3.375z" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
+        $sImageUndo = '<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 016 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75 2.25 2.25 0 012.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23h-.777zM2.331 10.977a11.969 11.969 0 00-.831 4.398 12 12 0 00.52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 01-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227z"></path></svg>';
+
+        return $bVoted && $this->isUndo() ? $sImageUndo : $sImageDo;
+    }
+
+    protected function _getTitleDo($bVoted)
+    {
+    	return $bVoted && $this->isUndo() ? '_vote_do_unlike' : '_vote_do_like';
+    }
+    
+    protected function _useIconAs($aParams = [])
+    {
+        if(!empty($aParams['use_icon_as']) && in_array($aParams['use_icon_as'], ['icon', 'emoji', 'image']))
+            return $aParams['use_icon_as'];
+
+        return parent::_useIconAs($aParams);
+    }
+}
+
+/** @} */

+ 642 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVoteReactions.php

@@ -0,0 +1,642 @@
+<?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
+ * @{
+ */
+
+/**
+ * Like based votes for any content
+ */
+class BxDolVoteReactions extends BxTemplVote
+{
+    protected static $_sCounterStyleSimple = 'simple'; // total counter only.
+    protected static $_sCounterStyleDivided = 'divided'; // counters [icon + counter] divided by reactions.
+    protected static $_sCounterStyleCompound = 'compound'; // total counter with a list of reaction icons.
+
+    public static $sModeSolo = 'solo'; // one reaction (the default one), cast with a click.
+    public static $sModeFew = 'few'; // the reactions list.
+    public static $sModeMany = 'many'; // the reactions list plus any emoji.
+
+    public static $iPerUserMax = 5; // most reactions one person may give to one object.
+
+    protected static $_sEmojiCodePrefix = 'e_'; // free-picked emoji are stored as e_<hex code points>.
+    protected static $_iReactionLengthMax = 32; // the `reaction` column is varchar(32).
+
+    protected $_sMenuDoVote; //--- Do vote (reaction)  menu name.
+
+    protected $_sDataList; //--- Reactions list name.
+    protected $_aDataList; //--- Reactions list with all reactions.
+    protected $_aDataListAll; //--- The whole Reactions list, also the reactions Solo mode leaves out.
+
+    protected $_sDefault; //--- Default reaction name.
+
+    protected $_bQuickMode; //--- Give 'default' reaction when clicked (a default reaction is set).
+    protected $_bSingleMode; //--- Only one reaction is available (Solo mode, or one item in Reactions list).
+
+    protected $_sMode; //--- Reactions mode: solo, few or many.
+    protected $_sDefaultReaction; //--- Default reaction name from settings, '' when there is none.
+    protected $_iPerUser; //--- How many different reactions one person may give to one object.
+    protected $_bSplitCounters; //--- Show a counter per reaction instead of one total.
+
+    public function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct($sSystem, $iId, $iInit, $oTemplate);
+
+        $this->_oQuery = new BxDolVoteReactionsQuery($this);
+        $this->_sType = BX_DOL_VOTE_TYPE_REACTIONS;
+
+        $this->_sMenuDoVote = 'sys_vote_reactions_do';
+
+        $this->_sDataList = 'sys_vote_reactions';
+        $this->_aDataList = [];
+        $this->_aDataListAll = [];
+
+        $this->_sDefault = 'default';
+
+        $this->_sMode = getParam('sys_vote_reactions_mode');
+        if(!in_array($this->_sMode, [self::$sModeSolo, self::$sModeFew, self::$sModeMany]))
+            $this->_sMode = self::$sModeFew;
+
+        $this->_sDefaultReaction = trim((string)getParam('sys_vote_reactions_default'));
+
+        $this->_iPerUser = $this->_sMode == self::$sModeSolo ? 1 : max(1, min(self::$iPerUserMax, (int)getParam('sys_vote_reactions_per_user')));
+
+        $this->_bSplitCounters = getParam('sys_vote_reactions_split_counters') == 'on';
+    }
+
+    public function init($iId)
+    {
+        if(!parent::init($iId))
+            return false;
+
+        $aReactions = BxDolFormQuery::getDataItems($this->_sDataList, false, BX_DATA_VALUES_ALL);
+
+        $sPlaceholder = ''; //--- The reaction which carries the 'not voted yet' look in its data.
+        foreach($aReactions as $sReaction => $aReaction) {
+            $aData = !empty($aReaction['Data']) ? unserialize($aReaction['Data']) : array();
+
+            if(!empty($aData['default']))
+                $sPlaceholder = $sReaction;
+
+            $this->_aDataList[$sReaction] = array(
+                'name' => $sReaction,
+                'title' => $aReaction['LKey'],
+                'title_aux' => $aReaction['LKey2'],
+                'use' => isset($aData['use']) ? $aData['use'] : 'emoji',
+                'icon' => isset($aData['icon']) ? $aData['icon'] : '',
+                'emoji' => isset($aData['emoji']) ? $aData['emoji'] : '',
+                'image' => isset($aData['image']) ? $aData['image'] : '',
+                'color' => isset($aData['color']) ? $aData['color'] : '',
+                'weight' => isset($aData['weight']) ? $aData['weight'] : 1,
+                'default' => isset($aData['default']) ? $aData['default'] : '',
+            );
+        }
+
+        //--- The default reaction must be on the list.
+        if($this->_sDefaultReaction !== '' && !isset($this->_aDataList[$this->_sDefaultReaction]))
+            $this->_sDefaultReaction = '';
+
+        $this->_aDataListAll = $this->_aDataList;
+
+        //--- Solo: only the default reaction (or the first one on the list) is available.
+        if($this->_sMode == self::$sModeSolo && !empty($this->_aDataList)) {
+            if($this->_sDefaultReaction === '')
+                $this->_sDefaultReaction = (string)key($this->_aDataList);
+
+            $this->_aDataList = [$this->_sDefaultReaction => $this->_aDataList[$this->_sDefaultReaction]];
+        }
+
+        $this->_bSingleMode = count($this->_aDataList) == 1;
+        $this->_bQuickMode = $this->_sDefaultReaction !== '';
+
+        //--- The 'default' item is what the do-button shows before a vote: the default
+        //--- reaction, or the 'not voted yet' look (e.g. 'React' with a smile) when there is none.
+        $sBase = $this->_sDefaultReaction;
+        if($sBase === '')
+            $sBase = $sPlaceholder !== '' && isset($this->_aDataList[$sPlaceholder]) ? $sPlaceholder : (string)key($this->_aDataList);
+
+        if($sBase !== '' && isset($this->_aDataList[$sBase])) {
+            $aDefault = $this->_aDataList[$sBase];
+            if(!$this->_bQuickMode && !$this->_bSingleMode && !empty($aDefault['default'])) {
+                if(is_array($aDefault['default']))
+                    $aDefault = array_merge ($aDefault, $aDefault['default']);
+                else
+                    $aDefault['icon'] = $aDefault['default'];
+            }
+
+            $this->_aDataList[$this->_sDefault] = $aDefault;
+        }
+    }
+    /**
+     * Interface functions for outer usage
+     */
+    public function getValue()
+    {
+        return (int)$this->_aSystem['min_value'];
+    }
+
+    public function getDefault()
+    {
+        return $this->_sDefault;
+    }
+
+    /**
+     * Default reaction name from settings, '' when there is none.
+     */
+    public function getDefaultReaction()
+    {
+        return $this->_sDefaultReaction;
+    }
+
+    public function getMode()
+    {
+        return $this->_sMode;
+    }
+
+    public function getPerUser()
+    {
+        return $this->_iPerUser;
+    }
+
+    public function isSplitCounters()
+    {
+        return $this->_bSplitCounters;
+    }
+
+    /**
+     * Any emoji may be given, not only the reactions on the list.
+     */
+    public function isFreeEmojiAllowed()
+    {
+        return $this->_sMode == self::$sModeMany;
+    }
+
+    public function getReaction($sName)
+    {
+        if(!empty($this->_aDataList[$sName]))
+            return $this->_aDataList[$sName];
+
+        //--- Left out by Solo mode, but people may still have it.
+        if(!empty($this->_aDataListAll[$sName]))
+            return $this->_aDataListAll[$sName];
+
+        //--- A free-picked emoji (Many mode), stored under its code.
+        $sEmoji = self::decodeEmoji($sName);
+        if($sEmoji === false)
+            return false;
+
+        return [
+            'name' => $sName,
+            'title' => $sEmoji,
+            'title_aux' => '',
+            'use' => 'emoji',
+            'icon' => '',
+            'emoji' => $sEmoji,
+            'image' => '',
+            'color' => '',
+            'weight' => 1,
+            'default' => '',
+        ];
+    }
+
+    public function getReactions($bFullInfo = false)
+    {
+        return $bFullInfo ? $this->_aDataList : array_keys($this->_aDataList);
+    }
+
+    public function getTrackBy($aParams)
+    {
+        return $this->_oQuery->getTrackBy($aParams);
+    }
+
+    /**
+     * Reactions which have a counter in the given vote data: the list, then free-picked emoji (Many mode).
+     */
+    public function getCountedReactions($aVote)
+    {
+        $aNames = [];
+        foreach(array_keys($aVote) as $sKey)
+            if(strncmp($sKey, 'count_', 6) === 0)
+                $aNames[] = substr($sKey, 6);
+
+        return $aNames;
+    }
+
+    /**
+     * The author's reactions to the object, oldest first.
+     */
+    public function getReactionsBy($iObjectId, $iAuthorId)
+    {
+        return $this->_oQuery->getReactionsBy($iObjectId, $iAuthorId);
+    }
+
+    /**
+     * Turn a requested reaction into the name it is stored under, or false when it may not be given:
+     * a name from the list, or (Many mode) a single emoji, given as is or as its e_<hex> code.
+     * An emoji which is on the list is that reaction.
+     */
+    public function normalizeReaction($sReaction)
+    {
+        $sReaction = trim((string)$sReaction);
+        if($sReaction === '')
+            return false;
+
+        if($sReaction === $this->_sDefault)
+            return isset($this->_aDataList[$this->_sDefault]) ? $this->_aDataList[$this->_sDefault]['name'] : false;
+
+        if(isset($this->_aDataList[$sReaction]))
+            return $sReaction;
+
+        if(!$this->isFreeEmojiAllowed())
+            return false;
+
+        $sEmoji = self::decodeEmoji($sReaction);
+        if($sEmoji === false)
+            $sEmoji = $sReaction;
+
+        if(!self::isEmoji($sEmoji))
+            return false;
+
+        $sKey = self::_emojiKey($sEmoji);
+        foreach($this->_aDataList as $sName => $aReaction)
+            if($sName !== $this->_sDefault && !empty($aReaction['emoji']) && self::_emojiKey($aReaction['emoji']) === $sKey)
+                return $sName;
+
+        $sCode = self::encodeEmoji($sEmoji);
+        return get_mb_len($sCode) <= self::$_iReactionLengthMax ? $sCode : false;
+    }
+
+    /**
+     * One emoji: a pictograph (with optional variation selector, skin tone and ZWJ parts),
+     * a keycap or a flag.
+     */
+    public static function isEmoji($s)
+    {
+        $sPart = '(?:\p{Extended_Pictographic}(?:\x{FE0F}|[\x{1F3FB}-\x{1F3FF}])?)';
+        return preg_match('/^(?:' . $sPart . '(?:\x{200D}' . $sPart . ')*|[#*0-9]\x{FE0F}?\x{20E3}|\p{Regional_Indicator}{2})$/u', (string)$s) === 1;
+    }
+
+    /**
+     * e_<hex code points joined with _>, e.g. e_2764_fe0f. Stored as ASCII because the
+     * tables' unicode collation gives every emoji above U+FFFF the same weight.
+     */
+    public static function encodeEmoji($sEmoji)
+    {
+        $aCodes = [];
+        foreach(mb_str_split($sEmoji, 1, 'UTF-8') as $sChar)
+            $aCodes[] = dechex(mb_ord($sChar, 'UTF-8'));
+
+        return self::$_sEmojiCodePrefix . implode('_', $aCodes);
+    }
+
+    /**
+     * The emoji for an e_<hex> code, or false.
+     */
+    public static function decodeEmoji($sCode)
+    {
+        if(!is_string($sCode) || !preg_match('/^e(?:_[0-9a-f]{1,6})+$/', $sCode))
+            return false;
+
+        $aHex = explode('_', $sCode);
+        array_shift($aHex); //--- the 'e' of the prefix
+
+        $sEmoji = '';
+        foreach($aHex as $sHex) {
+            $iCode = hexdec($sHex);
+            if($iCode > 0x10FFFF || ($iCode >= 0xD800 && $iCode <= 0xDFFF))
+                return false;
+
+            $sEmoji .= mb_chr($iCode, 'UTF-8');
+        }
+
+        return self::isEmoji($sEmoji) ? $sEmoji : false;
+    }
+
+    /**
+     * Compare emoji without variation selectors (U+FE0E/U+FE0F).
+     */
+    protected static function _emojiKey($sEmoji)
+    {
+        return preg_replace('/[\x{FE0E}\x{FE0F}]/u', '', (string)$sEmoji);
+    }
+
+    protected function _getReactionOrDefault($sReaction)
+    {
+        $aReaction = $this->getReaction($sReaction);
+        return $aReaction !== false ? $aReaction : $this->_aDataList[$this->_sDefault];
+    }
+    
+    public function getIcon($sReaction, $bWithColor = true)
+    {
+        $aReaction = $this->_getReactionOrDefault($sReaction);
+
+    	return $aReaction['icon'] . ($bWithColor && !empty($aReaction['color']) ? ' ' . $aReaction['color'] : '');
+    }
+    
+    public function getEmoji($sReaction, $bWithColor = true)
+    {
+        $aReaction = $this->_getReactionOrDefault($sReaction);
+
+    	return $aReaction['emoji'];
+    }
+
+    public function getImage($sReaction, $bWithColor = true)
+    {
+        $aReaction = $this->_getReactionOrDefault($sReaction);
+
+        list($sIconFont, $sIconUrl, $sIconA, $sIconHtml) = BxTemplFunctions::getInstanceWithTemplate($this->_oTemplate)->getIcon($aReaction['image']);
+        if ($sIconUrl)
+            return $this->_oTemplate->parseImage($sIconUrl, ['class' => 'sys-icon-image']);
+
+    	return $sIconHtml;
+    }
+
+    /**
+     * Actions functions
+     */
+    public function actionGetDoVotePopup()
+    {
+        if(!$this->isEnabled())
+           return '';
+
+        return $this->_getDoVotePopup((int)bx_get('value'));
+    }
+
+    public function actionGetVotedBy()
+    {
+        if(!$this->isEnabled())
+           return '';
+
+        $aParams = array();
+
+        $sReaction = bx_get('reaction');
+        if($sReaction !== false) {
+            $sReaction = bx_process_input($sReaction);
+            if($this->getReaction($sReaction) === false)
+                $sReaction = $this->_sDefault;
+
+            $aParams['reaction'] = $sReaction;
+        }
+
+        return $this->_getVotedBy($aParams);
+    }
+
+    /**
+     * Give or take back one reaction. A person may hold up to getPerUser() different reactions;
+     * with one allowed, a new reaction replaces the old one. Giving a reaction one already has
+     * takes it back.
+     */
+    public function vote($aVoteData = [], $aRequestParamsData = [])
+    {
+        $iObjectId = $this->getId();
+        $iAuthorId = $this->_getAuthorId();
+        $iAuthorIp = $this->_getAuthorIp();
+
+        $sRequested = isset($aVoteData['reaction']) ? trim((string)$aVoteData['reaction']) : '';
+        $aReactions = $this->getReactionsBy($iObjectId, $iAuthorId);
+
+        //--- One's own reaction can be taken back even when the settings no longer offer it
+        //--- (an emoji picked before Many mode was turned off, a reaction Solo mode leaves out).
+        $sReaction = $this->_getOwnReaction($sRequested, $aReactions);
+        if($sReaction === false)
+            $sReaction = $this->normalizeReaction($sRequested);
+        if($sReaction === false)
+            return ['code' => BX_DOL_OBJECT_ERR_WRONG_DATE, 'message' => _t('_vote_err_reaction_not_available')];
+
+        $aVoteData = ['reaction' => $sReaction, 'value' => $this->getValue()];
+
+        $bUndo = $this->isUndo();
+
+        //--- Take back a reaction one already has.
+        if(in_array($sReaction, $aReactions)) {
+            if(!$bUndo)
+                return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_vote_err_duplicate_vote')];
+
+            $aTrack = $this->_oQuery->getTrackReaction($iObjectId, $iAuthorId, $sReaction);
+            if(!empty($aTrack['value']))
+                $aVoteData['value'] = (int)$aTrack['value'];
+
+            return $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, true, $aRequestParamsData);
+        }
+
+        if(!$this->isAllowedVote())
+            return ['code' => BX_DOL_OBJECT_ERR_ACCESS_DENIED, 'message' => $this->msgErrAllowedVote()];
+
+        if(count($aReactions) >= $this->_iPerUser) {
+            if(!$bUndo)
+                return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_vote_err_duplicate_vote')];
+
+            if($this->_iPerUser > 1)
+                return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_vote_err_reactions_limit', $this->_iPerUser)];
+
+            //--- One reaction per person: the new one replaces the old one.
+            foreach($aReactions as $sReactionOld) {
+                $aTrack = $this->_oQuery->getTrackReaction($iObjectId, $iAuthorId, $sReactionOld);
+                $aResult = $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, [
+                    'reaction' => $sReactionOld,
+                    'value' => !empty($aTrack['value']) ? (int)$aTrack['value'] : $this->getValue()
+                ], true, $aRequestParamsData);
+
+                if((int)$aResult['code'] != 0)
+                    return $aResult;
+            }
+        }
+
+        return $this->_vote($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, false, $aRequestParamsData);
+    }
+
+    /**
+     * Internal functions
+     */
+
+    /**
+     * The track the do-button reflects: the author's latest reaction.
+     */
+    protected function _getTrack($iObjectId, $iAuthorId)
+    {
+        return $this->_oQuery->getTrackLatest($iObjectId, $iAuthorId);
+    }
+
+    protected function _isVote($iObjectId = 0, $bForceGet = false)
+    {
+        $aVote = $this->_getVote($iObjectId, $bForceGet);
+        foreach($aVote as $sKey => $iValue)
+            if(strpos($sKey, 'count_') !== false && !empty($iValue))
+                return true;
+
+        return false;
+    }
+
+    protected function _isDuplicate($iObjectId, $iAuthorId, $iAuthorIp, $bVoted)
+    {
+        return $bVoted && !$this->isUndo();
+    }
+
+    protected function _isCount($aVote = array())
+    {
+        if(empty($aVote))
+            $aVote = $this->_getVote();
+
+        foreach($aVote as $sKey => $mixedValue)
+            if(substr($sKey, 0, 5) == 'count' && (int)$mixedValue != 0)
+                return true;
+
+        return false;
+    }
+
+    protected function _getVoteData()
+    {
+        $aData = parent::_getVoteData();
+        if($aData === false)
+            return false;
+
+        $sReaction = bx_get('reaction');
+        if($sReaction === false)
+            return false;
+
+        $sRequested = trim((string)bx_process_input($sReaction));
+        $sReaction = $this->normalizeReaction($sRequested);
+        if($sReaction === false)
+            $sReaction = $this->_getOwnReaction($sRequested, $this->getReactionsBy($this->getId(), $this->_getAuthorId()));
+        if($sReaction === false)
+            return false;
+
+        $aData['reaction'] = $sReaction;
+        return $aData;
+    }
+
+    /**
+     * The stored name of the author's own reaction which `$sRequested` names (by its name, e_<hex> code or emoji),
+     * or false. One's own reaction can always be taken back, even when the settings no longer offer it.
+     */
+    protected function _getOwnReaction($sRequested, $aReactions)
+    {
+        if(in_array($sRequested, $aReactions, true))
+            return $sRequested;
+
+        if(self::isEmoji($sRequested) && in_array(($sCode = self::encodeEmoji($sRequested)), $aReactions, true))
+            return $sCode;
+
+        return false;
+    }
+
+    protected function _returnVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bVoted, $aParams = [])
+    {
+        $sReaction = $aData['reaction']; //--- the reaction just given or taken back
+        $bUndo = $this->isUndo();
+
+        //--- The author's state after the change: all their reactions, the latest one on the button.
+        $aReactionsBy = $this->getReactionsBy($iObjectId, $iAuthorId);
+        $bVotedAny = !empty($aReactionsBy);
+        $bDisabled = $bVotedAny && !$bUndo && count($aReactionsBy) >= $this->_iPerUser;
+
+        $aTrack = $bVotedAny ? $this->_getTrack($iObjectId, $iAuthorId) : [];
+        $sShown = $bVotedAny ? $aTrack['reaction'] : $this->_sDefault;
+        $aShown = $this->_getReactionOrDefault($sShown);
+
+        $aVote = $this->_getVote($iObjectId, true);
+
+        $sLabelUse = !empty($aShown['use']) ? $aShown['use'] : 'emoji';
+        $sLabelIcon = $this->_getIconDoWithTrack($bVotedAny, $aTrack);
+        if(!$bVotedAny && $sLabelUse != 'icon') {
+            $sLabelUse = 'icon';
+            $sLabelIcon = $this->_oTemplate->parseIcon($sLabelIcon);
+        }
+
+        $sJsClick = '';
+        if(!$bDisabled)
+            $sJsClick = $bVotedAny && $bUndo ? $this->getJsClickDo($sShown) : $this->getJsClick();
+
+        $iTotalC = $iTotalS = 0;
+        foreach($aVote as $sKey => $mixedValue)
+            if(strncmp($sKey, 'count_', 6) === 0)
+                $iTotalC += (int)$mixedValue;
+            else if(strncmp($sKey, 'sum_', 4) === 0)
+                $iTotalS += (int)$mixedValue;
+        $fTotalR = $iTotalC != 0 ? round($iTotalS / $iTotalC, 2) : 0;
+
+        $iCount = isset($aVote['count_' . $sReaction]) ? (int)$aVote['count_' . $sReaction] : 0;
+        $aResult = [
+            'code' => 0,
+            'reaction' => $this->_bIsApi ? $aShown['name'] : $sReaction,
+            'rate' => isset($aVote['rate_' . $sReaction]) ? $aVote['rate_' . $sReaction] : 0,
+            'count' => $iCount,
+            'countf' => $iCount > 0 ? $this->_getCounterLabel($iCount, array('reaction' => $sReaction)) : '',
+            'label_use' => $sLabelUse,
+            'label_icon' => $sLabelIcon,
+            'label_emoji' => $this->_getEmojiDoWithTrack($bVotedAny, $aTrack),
+            'label_image' => $this->_getImageDoWithTrack($bVotedAny, $aTrack),
+            'label_title' => _t($this->_getTitleDoWithTrack($bVotedAny, $aTrack)),
+            'label_click' => $sJsClick,
+            'voted' => $bVotedAny,
+            'disabled' => $bDisabled,
+            'total' => [
+                'rate' => $fTotalR,
+                'count' => $iTotalC,
+                'countf' => $iTotalC > 0 ? $this->_getCounterLabel($iTotalC, ['show_counter_label_icon' => false, 'reaction' => '']) : '',
+            ]
+        ];
+
+        $aResult['api'] = [
+            'ts' => microtime(true),
+            'performer_id' => $iAuthorId,
+            'is_voted' => $bVotedAny,
+            'is_disabled' => $bDisabled,
+            'reaction' => $bVotedAny ? $sShown : $this->_sDefault,
+            'reactions' => $aReactionsBy,
+            'default' => $this->_sDefaultReaction,
+            'icon' => !empty($aResult['label_emoji']) ? $aResult['label_emoji'] : $aShown['emoji'],
+            'title' => !empty($aResult['label_title']) ? $aResult['label_title'] : '',
+            'counter' => $aVote
+        ];
+
+        return $aResult;
+    }
+
+    protected function _returnVoteDataForSocket($aData, $aMask = [])
+    {
+        return parent::_returnVoteDataForSocket($aData, ['code', 'reaction', 'rate', 'count', 'countf', 'total', 'api']);
+    }
+
+    protected function _getIconDoWithTrack($bVoted, $aTrack = [])
+    {
+        $sReaction = $bVoted ? $aTrack['reaction'] : $this->_sDefault;
+
+    	return $this->getIcon($sReaction);
+    }
+
+    protected function _getEmojiDoWithTrack($bVoted, $aTrack = [])
+    {
+        $sReaction = $bVoted ? $aTrack['reaction'] : $this->_sDefault;
+
+    	return $this->getEmoji($sReaction);
+    }
+
+    protected function _getImageDoWithTrack($bVoted, $aTrack = [])
+    {
+        $sReaction = $bVoted ? $aTrack['reaction'] : $this->_sDefault;
+
+    	return $this->getImage($sReaction);
+    }
+
+    protected function _getTitleDoWithTrack($bVoted, $aTrack = [])
+    {
+        $sReaction = $bVoted ? $aTrack['reaction'] : $this->_sDefault;
+
+    	return $this->_getReactionOrDefault($sReaction)['title'];
+    }
+
+    protected function _getTitleDoBy($aParams = [])
+    {
+        if(isset($aParams['show_counter_style']) && $aParams['show_counter_style'] == self::$_sCounterStyleCompound)
+            return _t('_vote_do_by_reactions');
+
+        $sReaction = !empty($aParams['reaction']) ? $aParams['reaction'] : $this->_sDefault;
+    	return _t('_vote_do_by_x_reaction', _t($this->_getReactionOrDefault($sReaction)['title']));
+    }
+}
+
+/** @} */

+ 226 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/classes/BxDolVoteReactionsQuery.php

@@ -0,0 +1,226 @@
+<?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
+ * @{
+ */
+
+/**
+ * @see BxDolVote
+ */
+class BxDolVoteReactionsQuery extends BxDolVoteQuery
+{
+    public function __construct(&$oModule)
+    {
+        parent::__construct($oModule);
+    }
+
+    public function getVote($iObjectId)
+    {
+        $aReactions = $this->_oModule->getReactions();
+        $aReactionsGot = $this->getAllWithKey('SELECT * FROM ' . $this->_sTable . ' WHERE `object_id` = :object_id ORDER BY `count` DESC', 'reaction', array('object_id' => $iObjectId));
+
+        // Free-picked emoji (Many mode) are counted under their code; add the ones this object has.
+        if($this->_oModule->isFreeEmojiAllowed())
+            foreach($aReactionsGot as $sName => $aRow)
+                if((int)$aRow['count'] > 0 && !in_array($sName, $aReactions) && $this->_oModule->getReaction($sName) !== false)
+                    $aReactions[] = $sName;
+
+        $aResult = array();
+        foreach($aReactions as $sName) {
+            $iCount = $iSum = 0;
+            if(!empty($aReactionsGot[$sName])) {
+                $iCount = (int)$aReactionsGot[$sName]['count'];
+                $iSum = (int)$aReactionsGot[$sName]['sum'];
+            }
+
+            $aResult['count_' . $sName] = $iCount;
+            $aResult['sum_' . $sName] = $iSum;
+            $aResult['rate_' . $sName] = $iCount != 0 ? $iSum / $iCount : 0;
+        }
+
+        return $aResult;
+    }
+
+    public function putVote($iObjectId, $iAuthorId, $sAuthorIp, $aData, $bUndo = false)
+    {
+        $sReaction = $aData['reaction'];
+        $aReaction = $this->_oModule->getReaction($sReaction);
+        if($aReaction === false)
+            return false;
+
+        $iValue = (int)$aData['value'];
+        $fValueFinal = $iValue * (float)$aReaction['weight'];
+
+        $sQuery = "SELECT `object_id` FROM `{$this->_sTable}` WHERE `object_id` = :object_id AND `reaction` = :reaction LIMIT 1";
+        $bExists = (int)$this->getOne($sQuery, array('object_id' => $iObjectId, 'reaction' => $sReaction)) != 0;
+        if(!$bExists && $bUndo)
+            return false;      
+
+        if(!$bExists)
+            $sQuery = $this->prepare("INSERT INTO {$this->_sTable} SET `object_id` = ?, `reaction` = ?, `count` = '1', `sum` = ?", $iObjectId, $sReaction, $fValueFinal);
+        else
+            $sQuery = $this->prepare("UPDATE `{$this->_sTable}` SET `count` = `count` " . ($bUndo ? "-" : "+") . " 1, `sum` = `sum` " . ($bUndo ? "-" : "+") . " ? WHERE `object_id` = ? AND `reaction` = ?", $fValueFinal, $iObjectId, $sReaction);
+
+        if((int)$this->query($sQuery) == 0)
+            return false;
+
+        if($bUndo)
+            return $this->_deleteTrackReaction($iObjectId, $iAuthorId, $sReaction);
+
+        $iNow = time();
+        $iAuthorNip = bx_get_ip_hash($sAuthorIp);
+        $sQuery = $this->prepare("INSERT INTO `{$this->_sTableTrack}` SET `object_id` = ?, `author_id` = ?, `author_nip` = ?, `reaction` = ?, `value` = ?, `date` = ?", $iObjectId, $iAuthorId, $iAuthorNip, $sReaction, $iValue, $iNow);
+        if((int)$this->query($sQuery) > 0)
+            return $this->lastId();
+
+        return false;
+    }
+
+    /**
+     * The author's reactions to the object, oldest first.
+     */
+    public function getReactionsBy($iObjectId, $iAuthorId)
+    {
+        return $this->getColumn("SELECT `reaction` FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id ORDER BY `id` ASC", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId
+        ]);
+    }
+
+    /**
+     * The author's latest reaction to the object (what the do-button shows).
+     */
+    public function getTrackLatest($iObjectId, $iAuthorId)
+    {
+        return $this->getRow("SELECT * FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id ORDER BY `id` DESC LIMIT 1", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId
+        ]);
+    }
+
+    public function getTrackReaction($iObjectId, $iAuthorId, $sReaction)
+    {
+        return $this->getRow("SELECT * FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id AND `reaction` = :reaction LIMIT 1", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId,
+            'reaction' => $sReaction
+        ]);
+    }
+
+    protected function _deleteTrackReaction($iObjectId, $iAuthorId, $sReaction)
+    {
+        $iId = (int)$this->getOne("SELECT `id` FROM `{$this->_sTableTrack}` WHERE `object_id` = :object_id AND `author_id` = :author_id AND `reaction` = :reaction LIMIT 1", [
+            'object_id' => $iObjectId,
+            'author_id' => $iAuthorId,
+            'reaction' => $sReaction
+        ]);
+        if(!$iId)
+            return false;
+
+        if((int)$this->query("DELETE FROM `{$this->_sTableTrack}` WHERE `id` = :id LIMIT 1", ['id' => $iId]) > 0)
+            return $iId;
+
+        return false;
+    }
+
+    public function getSqlParts($sMainTable, $sMainField)
+    {
+        if(empty($this->_sTable) || empty($sMainTable) || empty($sMainField))
+            return array();
+
+        $sFields = $sJoin = '';
+        $aReactions = $this->_oModule->getReactions();
+        foreach($aReactions as $iIndex => $sName) {
+            $sFields .= ", `t{$iIndex}`.`count` as `vote_count_{$sName}`, (`t{$iIndex}`.`sum` / `t{$iIndex}`.`count`) AS `vote_rate_{$sName}` ";
+            $sJoin .= " LEFT JOIN `{$this->_sTable}` AS `t{$iIndex}` ON (`t{$iIndex}`.`object_id` = `{$sMainTable}`.`{$sMainField}` AND `t{$iIndex}`.`reaction` = '{$sName}') ";
+        }
+
+        return array (
+            'fields' => $sFields,
+            'join' => $sJoin,
+        );
+    }
+
+    public function getPerformed($aParams = array())
+    {
+    	$aMethod = array('name' => 'getAll', 'params' => array(0 => 'query'));
+        $aBindings = array();
+
+    	$sSelectClause = '`tt`.*';
+    	$sJoinClause = $sWhereClause = '';
+    	$sLimitClause = isset($aParams['start']) && !empty($aParams['per_page']) ? "LIMIT " . $aParams['start'] . ", " . $aParams['per_page'] : "";
+
+    	if(!empty($aParams['type']))
+            switch($aParams['type']) {
+                case 'by':
+                    $aBindings['object_id'] = $aParams['object_id'];
+
+                    $sJoinClause = "INNER JOIN `sys_profiles` AS `tp` ON `tt`.`author_id`=`tp`.`id` AND `tp`.`status`='active'";
+                    $sWhereClause = "AND `tt`.`object_id` = :object_id";
+
+                    if(!empty($aParams['reaction'])) {
+                        $aMethod['name'] = 'getColumn';
+                        $aBindings['reaction'] = $aParams['reaction'];
+
+                        $sSelectClause = "`tt`.`author_id`";
+                        $sWhereClause .= " AND `tt`.`reaction`=:reaction";
+                    }
+                    else {
+                        $aMethod['name'] = 'getAll';
+
+                        $sSelectClause = "`tt`.`author_id`, `tt`.`reaction`";
+                    }
+                    break;
+            }
+
+        $aMethod['params'][0] = "SELECT " . $sSelectClause . " FROM `" . $this->_sTableTrack . "` AS `tt` " . $sJoinClause . " WHERE 1 " . $sWhereClause . $sLimitClause;
+        $aMethod['params'][] = $aBindings;
+
+        return call_user_func_array(array($this, $aMethod['name']), $aMethod['params']);
+    }
+
+    /**
+     * Update trigger table.
+     * @param integer $iObjectId - object ID;
+     * @param array $aEntry - contains data received from BxDolVoteReactionsQuery::getVote method;
+     * @return boolean - operation result.
+     */
+    protected function _updateTriggerTable($iObjectId, $aEntry)
+    {
+        $iCounted = $iTotalCount = $fTotalRate = 0;
+        foreach($aEntry as $sKey => $mixedValue) {
+            if(strncmp($sKey, 'count_', 6) !== 0)
+                continue;
+
+            $sName = substr($sKey, 6);
+            $iCount = (int)$mixedValue;
+            if($iCount == 0)
+                continue;
+
+            $iTotalCount += $iCount;
+            $fTotalRate += (float)$aEntry['rate_' . $sName];
+            $iCounted += 1;
+        }
+
+        return (int)$this->query("UPDATE `{$this->_sTriggerTable}` SET `{$this->_sTriggerFieldCount}`=:count, `{$this->_sTriggerFieldRate}`=:rate  WHERE `{$this->_sTriggerFieldId}`=:id", array(
+            'count' => $iTotalCount, 
+            'rate' => $iCounted != 0 ? $fTotalRate / $iCounted : 0, 
+            'id' => $iObjectId
+        )) > 0;
+    }
+
+    protected function _deleteAuthorEntriesTableMain($aTrack)
+    {
+        $aReaction = $this->_oModule->getReaction($aTrack['reaction']);
+        return $this->query("UPDATE `{$this->_sTable}` SET `count`=`count`-1, `sum`=`sum`-:value WHERE `object_id`=:object_id AND `reaction`=:reaction", array(
+            'value' => (int)$aTrack['value'] * (float)$aReaction['weight'],
+            'object_id' => $aTrack['object_id'],
+            'reaction' => $aTrack['reaction']
+        ));
+    }
+}
+
+/** @} */

+ 1009 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolCmts.js

@@ -0,0 +1,1009 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function BxDolCmts (options) {
+    this._sObjName = undefined == options.sObjName ? 'oCmts' : options.sObjName;    // javascript object name, to run current object instance from onTimer
+    this._sActionsUrl = options.sRootUrl + 'cmts.php'; // actions url address
+
+    this._sSystem = options.sSystem; // current comment system
+    this._iAuthorId = options.iAuthorId; // this comment's author ID.
+    this._iObjId = options.iObjId; // this object id comments
+    this._sBaseUrl = options.sBaseUrl; // base url to view comment's listing.
+    this._sSocket = options.sSocket === undefined ? this._sSystem : options.sSocket;
+
+    this._iMinPostForm = undefined == options.iMinPostForm ? 0 : options.iMinPostForm;
+    this._sPostFormPosition = undefined == options.sPostFormPosition ? 'top' : options.sPostFormPosition;
+    this._sDisplayType = undefined == options.sDisplayType ? 'threaded' : options.sDisplayType;
+    this._iDisplayStructure = undefined == options.iDisplayStructure ? 0 : options.iDisplayStructure;
+
+    this._sBrowseType = undefined == options.sBrowseType ? 'tail' : options.sBrowseType;
+    this._sBrowseFilter = undefined == options.sBrowseFilter ? 'all' : options.sBrowseFilter;
+
+    this._sSP = options.sStylePrefix === undefined ? 'cmt' : options.sStylePrefix;
+    this._aParams = options.aParams === undefined ? false : options.aParams;
+    this._aHtmlIds = options.aHtmlIds;
+    this._sAnimationEffect = 'fade';
+    this._iAnimationSpeed = 'slow';
+
+    this._oSavedTexts = {};
+    this._sRootId = '#cmts-box-' + this._sSystem + '-' + this._iObjId;
+
+    this._bLiveUpdatePaused = false;
+}
+
+/*--- Main layout functionality ---*/
+BxDolCmts.prototype.cmtInit = function()
+{
+    var $this = this;
+
+    $(document).ready(function() {
+        // init socket
+        if(window.oBxDolSockets !== undefined && $this._sSocket)
+            oBxDolSockets.subscribe($this._sSocket, $this._iObjId, 'comment_added', function(oData) {
+                $this.cmtUpdateCounterAs(oData);
+                $this.showLiveUpdateForSocket(oData);
+            });
+
+        // init post comment form    
+        var sFormId = $this._sRootId + ' .cmt-post-reply form';
+        if ($(sFormId).length) {
+            $(sFormId).each(function() {
+                $this.cmtInitFormPost($(this));
+            });
+        }
+
+        // blink (highlight) necessary comments
+        $this._cmtsBlink($($this._sRootId));
+
+        // check content to show 'See More'
+        $this._cmtsInitSeeMore($($this._sRootId));
+
+        // init gallery
+        $this._cmtsInitGallery($($this._sRootId));
+    });
+};
+
+BxDolCmts.prototype.cmtInitFormPost = function(oCmtForm)
+{
+    var $this = this;
+
+    oCmtForm.ajaxForm({
+        dataType: "json",
+        beforeSubmit: function (formData, jqForm, options) {
+            window[$this._sObjName].cmtBeforePostSubmit(oCmtForm);
+        },
+        success: function (oData) {
+            window[$this._sObjName].cmtAfterPostSubmit(oCmtForm, oData);
+        }
+    });
+};
+
+BxDolCmts.prototype.cmtShowForm = function(oElement)
+{
+    var oForm = $(oElement).parents('.cmt-reply.cmt-reply-min:first');
+    oForm.find('.cmt-body-min').bx_anim('hide', this._sAnimationEffect, this._iAnimationSpeed, function() {
+        oForm.find('.cmt-body').show(function() {
+            var oTextarea = oForm.find("[name='cmt_text']");
+            if(oTextarea && oTextarea.hasClass('bx-form-input-html') && typeof bx_editor_get_htmleditable === 'function')
+                oTextarea = bx_editor_get_htmleditable(oTextarea);
+
+            if(!oTextarea || oTextarea.length == 0)
+                return;
+            
+            if (oTextarea.attr('object_editor'))
+                eval('bx_editor_activate(' + oTextarea.attr('object_editor') + ')');
+            else
+                oTextarea.focus();
+        });
+    });
+};
+
+BxDolCmts.prototype.cmtBeforePostSubmit = function(oCmtForm)
+{
+    this._loadingInButton($(oCmtForm).children().find(':submit'), true);
+};
+
+BxDolCmts.prototype.cmtAfterPostSubmit = function (oCmtForm, oData)
+{
+    var $this = this;
+    var fContinue = function() {
+        var oParent = oCmtForm.parents('.cmt-reply:first');
+
+        if(oData && oData.id != undefined) {
+            $this._getCmt(oCmtForm, oData.id);
+
+            //--- Update form
+            var iCmtParentId = parseInt(oData.parent_id);
+            if(iCmtParentId == 0)
+                $this._getForm(undefined, {CmtParent: iCmtParentId}, function(sFormWrp) {
+                    if(sFormWrp && sFormWrp.length > 0)
+                        sFormWrp = $(sFormWrp).html();
+
+                    oParent.hide().html(sFormWrp).bxProcessHtml().show();
+
+                    $this.cmtInitFormPost(oParent.find('form'));
+                });
+            else
+                oParent.remove();
+
+            //--- Update counter
+            $this.cmtUpdateCounter(oCmtForm, oData);
+
+            return;
+        }
+
+        if(oData && oData.form != undefined && oData.form_id != undefined) {
+            oParent.find('form').replaceWith(oData.form);
+
+            $this.cmtInitFormPost(oParent.find('form'));
+
+            return;
+        }
+    };
+
+    this._loadingInButton($(oCmtForm).children().find(':submit'), false);
+
+    // Posted: a subtle success; turned down (the form comes back, or a message instead of the comment): error.
+    if(oData && typeof(bx_sound) == 'function')
+        bx_sound(oData.id != undefined ? 'success' : 'error', oData.id != undefined ? {emphasis: 'subtle'} : undefined);
+
+    if(oData && oData.msg != undefined)
+        bx_alert(oData.msg, fContinue);
+    else 
+        fContinue();
+};
+
+BxDolCmts.prototype.cmtInitFormEdit = function(sCmtFormId)
+{
+	var $this = this;
+	var oCmtForm = $('#' + sCmtFormId);
+
+	oCmtForm.ajaxForm({
+        dataType: "json",
+        beforeSubmit: function (formData, jqForm, options) {
+            window[$this._sObjName].cmtBeforeEditSubmit(oCmtForm);
+        },
+        success: function (oData) {
+            window[$this._sObjName].cmtAfterEditSubmit(oCmtForm, oData);
+        }
+    });
+};
+
+BxDolCmts.prototype.cmtBeforeEditSubmit = function(oCmtForm)
+{
+    this._loadingInButton($(oCmtForm).children().find(':submit'), true);
+};
+
+BxDolCmts.prototype.cmtAfterEditSubmit = function (oCmtForm, oData, onComplete)
+{
+    var $this = this;
+    var fContinue = function() {
+        if(oData && oData.id != undefined && oData.content != undefined) {
+            var iCmtId = parseInt(oData.id);
+            if(iCmtId > 0) {
+                $('#cmt' + iCmtId + ' .cmt-cont-cnt:first').bx_anim('hide', $this._sAnimationEffect, $this._iAnimationSpeed, function() {
+                    $(this).html(oData.content).bxProcessHtml().bx_anim('show', $this._sAnimationEffect, $this._iAnimationSpeed);
+                });
+            }
+        }
+
+        if(oData && oData.form != undefined && oData.form_id != undefined) {
+            $('#' + oData.form_id).replaceWith(oData.form);
+            $this.cmtInitFormEdit(oData.form_id);
+        }
+
+        if(typeof onComplete == 'function')
+            onComplete(oCmtForm, oData);
+    };
+
+    this._loadingInButton($(oCmtForm).children().find(':submit'), false);
+
+    // Saved: a subtle success; the form back with an error: error.
+    if(oData && typeof(bx_sound) == 'function')
+        if(oData.form != undefined && oData.form_id != undefined)
+            bx_sound('error');
+        else if(oData.id != undefined)
+            bx_sound('success', {emphasis: 'subtle'});
+
+    if(oData && oData.msg != undefined)
+        bx_alert(oData.msg, fContinue);
+    else 
+        fContinue();
+};
+
+BxDolCmts.prototype.cmtUpdateCounter = function (oElement, oData)
+{
+    if(oData && (oData.count == undefined || parseInt(oData.count) == 0))
+        return;
+
+    if(oData.countf != undefined && oData.countf.length != 0) {
+        var oCounter = this._getCounter(oElement);
+        if(oCounter && oCounter.length > 0)
+            oCounter.replaceWith(oData.countf);
+    }
+
+    var oCounter = this._getCounter(oElement, true);
+    if(oCounter && oCounter.length > 0)
+        oCounter.html(oData.count);
+
+    var sClassHidden = 'sys-ac-hidden';
+    if(!oCounter.is('.' + sClassHidden))
+        oCounter = oCounter.parents('.' + sClassHidden + ':first');
+
+    oCounter.toggleClass(sClassHidden);
+};
+
+BxDolCmts.prototype.cmtUpdateCounterAs = function (oData)
+{
+    this.cmtUpdateCounter(null, JSON.parse(oData));
+};
+
+BxDolCmts.prototype.cmtPin = function(oLink, iCmtId, iWay, bHideMenu) {
+    var $this = this;
+
+    if(bHideMenu == undefined || bHideMenu)
+        $(oLink).parents('.bx-popup-applied:first:visible').dolPopupHide();
+
+    var oParams = this._getDefaultActions();
+    oParams['action'] = 'Pin';
+    oParams['Cmt'] = iCmtId;
+    oParams['way'] = iWay;
+
+    var oCmt = $(this._sRootId + ' #cmt' + iCmtId);
+    this._loadingInBlock(oCmt, true);
+
+    jQuery.post (
+        this._sActionsUrl,
+        oParams,
+        function (oData) {
+            var fContinue = function() {
+                if(oData && oData.parent_id != undefined) {
+                    $this._getCmts(oLink, {
+                        CmtParent: oData.parent_id,
+                        CmtBrowse: $this._sBrowseType,
+                        CmtFilter: $this._sBrowseFilter,
+                        CmtDisplay: $this._sDisplayType,
+                        CmtPinned: 1
+                    }, function(sListId, sContent) {
+                        $this._loadingInBlock(oCmt, false);
+
+                        $this._sDisplayType = $this._sDisplayType;
+                        $this._cmtsReplaceContent($(sListId), sContent);
+
+                        var oDivider = $(sListId).siblings('.cmts-divider');
+                        if(oDivider.length > 0) {
+                            if(oDivider.is(':hidden') && sContent.length != 0)
+                                oDivider.show();
+                            else if(!oDivider.is(':hidden') && sContent.length == 0)
+                                oDivider.hide();
+                        }
+                    });
+            	}
+            };
+
+            if(oData && oData.msg != undefined)
+                bx_alert(oData.msg, fContinue);
+            else
+                fContinue();
+        },
+        'json'
+    );
+};
+
+BxDolCmts.prototype.cmtEdit = function(oLink, iCmtId, bHideMenu) {
+    var $this = this;
+
+    if(bHideMenu == undefined || bHideMenu)
+        $(oLink).parents('.bx-popup-applied:first:visible').dolPopupHide();
+
+    var sContentId = this._sRootId + ' #cmt' + iCmtId + ' .cmt-cont-cnt:first';
+    if ($(sContentId + ' > form').length) {
+        $(sContentId).bx_anim('hide', $this._sAnimationEffect, $this._iAnimationSpeed, function() {
+            $(this).html($this._oSavedTexts[iCmtId]).bxProcessHtml().bx_anim('show', $this._sAnimationEffect, $this._iAnimationSpeed);
+        });
+
+        return;
+    }
+
+    var oParams = this._getDefaultActions();
+    oParams['action'] = 'GetFormEdit';
+    oParams['Cmt'] = iCmtId;
+
+    this._oSavedTexts[iCmtId] = $(sContentId).html();
+
+    this._loadingInContent(oLink, true);
+
+    jQuery.post (
+        this._sActionsUrl,
+        oParams,
+        function (oData) {
+            var fContinue = function() {
+                if(oData && oData.form != undefined && oData.form_id != undefined) {
+                    $(sContentId).bx_anim('hide', $this._sAnimationEffect, $this._iAnimationSpeed, function() {
+                        $(this).html(oData.form).bxProcessHtml().bx_anim('show', $this._sAnimationEffect, $this._iAnimationSpeed, function() {
+                            $this.cmtInitFormEdit(oData.form_id);
+                        });
+                    });
+            	}
+            };
+
+            $this._loadingInContent(oLink, false);
+
+            if(oData && oData.msg != undefined)
+                bx_alert(oData.msg, fContinue);
+            else
+                fContinue();
+        },
+        'json'
+    );
+};
+
+BxDolCmts.prototype.cmtRemove = function(e, iCmtId) {
+	var $this = this;
+
+	$(e).parents('.bx-popup-active:first').dolPopupHide();
+
+	bx_confirm('', function() {
+	    var oParams = $this._getDefaultActions();
+	    oParams['action'] = 'Remove';
+	    oParams['Cmt'] = iCmtId;
+
+	    $this._loadingInContent(e, true);
+
+	    jQuery.post (
+	        $this._sActionsUrl,
+	        oParams,
+	        function(oData) {
+	            var fContinue = function() {
+	            	if(oData && oData.id != undefined) {
+                            $(e).parents('.bx-popup-applied:first:visible').dolPopupHide();
+
+                            var fDelete = function() {
+                                var sCmtSel = '#cmt' + oData.id;
+
+                                $(sCmtSel).bx_anim('hide', $this._sAnimationEffect, $this._iAnimationSpeed, function() {
+                                    //--- Update counter
+                                    if(oData && oData.count != undefined && parseInt(oData.count) >= 0) {
+                                        var oSource = $(this);
+
+                                        var oCounter = $this._getCounter(oSource);
+                                        if(oCounter && oCounter.length > 0) {
+                                            if(oData.countf != undefined && oData.countf.length != 0)
+                                                oCounter.html(oData.countf);
+
+                                            if(parseInt(oData.count) == 0)
+                                                oCounter.toggleClass('sys-ac-hidden');
+                                        }
+
+                                        var oCounter = $this._getCounter(oSource, true);
+                                        if(oCounter && oCounter.length > 0)
+                                            oCounter.html(oData.count);
+                                    }
+
+                                    $(this).remove();
+
+                                    var oCmt = $(sCmtSel);
+                                    if(oCmt && oCmt.length > 0)
+                                        fDelete();
+                                });
+                            };
+
+                            fDelete();
+                        }
+	            };
+
+	            $this._loadingInContent(e, false);
+
+	            if(oData && oData.msg != undefined)
+	                bx_alert(oData.msg, fContinue);	            
+	            else
+	            	fContinue();
+	        },
+	        'json'
+	    );
+	});
+};
+
+BxDolCmts.prototype.cmtLoad = function(oLink, iCmtParentId, iStart, iPerView)
+{
+	var $this = this;
+	var bButton = $(oLink).hasClass('bx-btn');
+
+	if(bButton)
+		this._loadingInButton(oLink, true);
+	else 
+		this._loading($(oLink).parents('ul.cmts:first'), true);
+
+	this._getCmts(oLink, {
+		CmtParent: iCmtParentId,
+		CmtStart: iStart,
+		CmtPerView: iPerView,
+		CmtBrowse: this._sBrowseType,
+		CmtFilter: this._sBrowseFilter,
+		CmtDisplay: this._sDisplayType,
+                CmtDisplayStructure: this._iDisplayStructure
+	}, function(sListId, sContent) {
+		if(bButton)
+			$this._loadingInButton(oLink, false);
+		else 
+			$this._loading($(oLink).parents('ul.cmts:first'), false);
+
+		$this._cmtsReplace($(oLink).parents('li:first'), sContent);
+	});
+};
+
+BxDolCmts.prototype.cmtChangeDisplay = function(oLink, sType)
+{
+	var $this = this;
+	this._getCmts(oLink, {
+		CmtParent: 0,
+		CmtBrowse: this._sBrowseType,
+		CmtFilter: this._sBrowseFilter,
+		CmtDisplay: sType
+	}, function(sListId, sContent) {
+		$this._sDisplayType = sType;
+		$this._cmtsReplaceContent($(sListId), sContent);
+	});
+};
+
+BxDolCmts.prototype.cmtChangeBrowse = function(oLink, sType)
+{
+	var $this = this;
+	this._getCmts(oLink, {
+		CmtParent: 0,
+		CmtBrowse: sType,
+		CmtFilter: this._sBrowseFilter,
+		CmtDisplay: this._sDisplayType
+	}, function(sListId, sContent) {
+		$this._sBrowseType = sType;
+		$this._cmtsReplaceContent($(sListId), sContent);
+	});
+};
+
+BxDolCmts.prototype.cmtChangeFilter = function(oLink, sType)
+{
+	var $this = this;
+	this._getCmts(oLink, {
+		CmtParent: 0,
+		CmtBrowse: this._sBrowseType,
+		CmtFilter: sType,
+		CmtDisplay: this._sDisplayType
+	}, function(sListId, sContent) {
+		$this._sBrowseFilter = sType;
+		$this._cmtsReplaceContent($(sListId), sContent);
+	});
+};
+
+BxDolCmts.prototype.showReplacement = function(iCmtId)
+{
+    $('#cmt' + iCmtId + '-hidden').bx_anim('hide', this._sAnimationEffect, this._iAnimationSpeed, function(){
+        $(this).next('#cmt' + iCmtId).bx_anim('show', this._sAnimationEffect, this._iAnimationSpeed);
+    });
+};
+
+BxDolCmts.prototype.toggleReply = function(oElement, iCmtParentId, iQuote)
+{
+    var $this = this;
+    var aParams = {
+        CmtParent: iCmtParentId,
+        CmtQuote: iQuote != undefined ? parseInt(iQuote) : 0
+    };
+    var fOnShow = function() {
+        $(this).find('textarea:first').focus();
+    };
+
+    var oParentId = null;
+    if(oElement)
+        oParentId = $(oElement).parents('#cmt' + iCmtParentId);
+    else
+        oParentId = $(this._sRootId + ' #cmt' + iCmtParentId);
+
+    var sReplyQuoteId = '.cmt-reply-quote';
+    var sReplyQuoteIdOpst = ':not(' + sReplyQuoteId + ')';
+
+    var sReplyId = '';
+    var sReplyIdOpst = '';
+    if(aParams['CmtQuote']) {
+        sReplyId = '.cmt-reply' + sReplyQuoteId;
+        sReplyIdOpst = '.cmt-reply' + sReplyQuoteIdOpst;
+    }
+    else {
+        sReplyId = '.cmt-reply' + sReplyQuoteIdOpst;
+        sReplyIdOpst = '.cmt-reply' + sReplyQuoteId;
+    }
+
+    //--- Hide opposite form.
+    if (oParentId.find('> ' + sReplyIdOpst + ':visible').length)
+        oParentId.find('> ' + sReplyIdOpst).hide();
+
+    if (oParentId.find('> ' + sReplyId).length) {
+        oParentId.find('> ' + sReplyId).bx_anim('toggle', this._sAnimationEffect, this._iAnimationSpeed, fOnShow);
+        return;
+    }
+
+    this._getForm(oElement, aParams, function(sForm) {
+        var oForm = $(sForm).hide().addClass('cmt-reply-' + $this._sPostFormPosition).addClass('cmt-reply-margin');
+        var oFormSibling = oParentId.find('> ul.cmts:first');
+        switch($this._sPostFormPosition) {
+            case 'top':
+                oFormSibling.before(oForm);
+                break;
+
+            case 'bottom':
+                oFormSibling.after(oForm);
+                break;
+        }
+
+        $this.cmtInitFormPost(oForm.find('form'));
+
+        oParentId.children(sReplyId).bx_anim('toggle', $this._sAnimationEffect, $this._iAnimationSpeed, fOnShow);
+    });
+};
+
+BxDolCmts.prototype.toggleQuote = function(e, iCmtParentId)
+{
+    this.toggleReply(e, iCmtParentId, 1);
+};
+
+BxDolCmts.prototype.goTo = function(oLink, sGoToId, sBlinkIds, onLoad)
+{
+    $(oLink).parents('.bx-popup-applied:first:visible').dolPopupHide();
+
+    var $this = this;
+    this._getCmts(null, {
+        CmtParent: 0,
+        CmtBrowse: this._sBrowseType,
+        CmtFilter: this._sBrowseFilter,
+        CmtDisplay: this._sDisplayType,
+        CmtBlink: sBlinkIds
+    }, function(sListId, sContent) {
+        $this._cmtsReplaceContent($(sListId), sContent, function() {
+            location.hash = sGoToId;
+
+            if(typeof onLoad == 'function')
+                onLoad();
+        });
+    });
+};
+
+BxDolCmts.prototype.goToBtn = function(oLink, sGoToId, sBlinkIds, onLoad)
+{
+    var $this = this;
+
+    this._loadingInButton(oLink, true);
+
+    this._getCmts(null, {
+        CmtParent: 0,
+        CmtBrowse: this._sBrowseType,
+        CmtFilter: this._sBrowseFilter,
+        CmtDisplay: this._sDisplayType,
+        CmtBlink: sBlinkIds
+    }, function(sListId, sContent) {
+        $this._cmtsReplaceContent($(sListId), sContent, function() {
+            location.hash = sGoToId;
+
+            if(typeof onLoad == 'function')
+                onLoad();
+
+            $(oLink).parents('.cmt-lu-button:first').remove();
+
+            $this.resumeLiveUpdates();
+        });
+    });
+};
+
+BxDolCmts.prototype.goToAndReply = function(oLink, sGoToId, sBlinkIds)
+{
+    var $this = this;
+    this.goTo(oLink, sGoToId, sBlinkIds, function() {
+        var aBlinkIds = sBlinkIds.split(",");
+        $.each(aBlinkIds, function(iIndex, iValue) {
+            $this.toggleReply(null, iValue);
+        });
+    });
+};
+
+/*----------------------------*/
+/*--- Live Updates methods ---*/
+/*----------------------------*/
+BxDolCmts.prototype.showLiveUpdate = function(oData)
+{
+    if(!oData.code)
+        return;
+
+    var oButton = $(oData.code);
+    var sId = oButton.attr('id');
+    $('#' + sId).remove();
+
+    oButton.prependTo(this._sRootId).bxProcessHtml();
+};
+
+BxDolCmts.prototype.showLiveUpdateForSocket = function(sData)
+{
+    var oData = JSON.parse(sData);
+    if(this._iAuthorId == oData.author_id)
+        return;
+
+    var sSelector = this._sRootId + ' .' + this._sSP + '-lu-button';
+    if($(sSelector + ':visible').length != 0)
+        return;
+
+    var oElement = $(sSelector + ':hidden').clone();
+    if(!oElement || oElement.length == 0)
+        return;
+
+    var sOnClick = oElement.find('.bx-btn').attr('onclick');
+    oElement.find('.bx-btn').attr('onclick', sOnClick.replace('{cmt_id}', oData.id).replace('{cmt_anchor}', oData.anchor));
+    oElement.prependTo(this._sRootId).show();
+};
+
+BxDolCmts.prototype.showLiveUpdates = function(oData)
+{
+	/*
+	 * Note. oData.count_old and oData.count_new are also available and can be checked or used in notification popup.  
+	 */
+	if(!oData.code)
+            return;
+
+	var $this = this;
+
+	var oNotifs = $(oData.code);
+	var sId = oNotifs.attr('id');
+	$('#' + sId).remove();
+
+	oNotifs.prependTo('body').dolPopup({
+    	position: 'fixed',
+    	left: '1rem',
+    	top: 'auto',
+    	bottom: '1rem',
+    	fog: false,
+    	onBeforeShow: function() {
+    	},
+    	onBeforeHide: function() {
+    	},
+        onShow: function() {
+            setTimeout(function() {
+                $('.bx-popup-chain.bx-popup-applied:visible:first').dolPopupHide();
+            }, 5000);
+        },
+    	onHide: function() {
+            $this.resumeLiveUpdates();
+    	}
+    });
+};
+
+BxDolCmts.prototype.previousLiveUpdate = function(oLink)
+{
+	var fPrevious = function() {
+		var sClass = 'bx-popup-chain-item';
+		$(oLink).parents('.' + sClass + ':first').hide().prev('.' + sClass).show();
+	};
+
+	if(!this.pauseLiveUpdates(fPrevious));
+		fPrevious();
+};
+
+BxDolCmts.prototype.hideLiveUpdate = function(oLink)
+{
+	$(oLink).parents('.bx-popup-applied:visible:first').dolPopupHide();
+};
+
+BxDolCmts.prototype.resumeLiveUpdates = function(onLoad)
+{
+	if(!this._bLiveUpdatePaused)
+		return false;
+
+	var $this = this;
+	this.changeLiveUpdates('ResumeLiveUpdate', function() {
+		$this._bLiveUpdatePaused = false;
+
+		if(typeof onLoad == 'function')
+			onLoad();
+	});
+
+	return true;
+};
+
+BxDolCmts.prototype.pauseLiveUpdates = function(onLoad)
+{
+	if(this._bLiveUpdatePaused)
+		return false;
+
+	var $this = this;
+	this.changeLiveUpdates('PauseLiveUpdate', function() {
+		$this._bLiveUpdatePaused = true;
+
+		if(typeof onLoad == 'function')
+			onLoad();
+	});
+
+	return true;
+};
+
+BxDolCmts.prototype.changeLiveUpdates = function(sAction, onLoad)
+{
+	var $this = this;
+    var oParams = this._getDefaultActions();
+    oParams['action'] = sAction;
+
+	jQuery.get(
+	    this._sActionsUrl,
+	    oParams,
+	    function() {
+	    	if(typeof onLoad == 'function')
+				onLoad();
+	    }
+	);
+};
+
+BxDolCmts.prototype.cmtShowMore = function(oLink)
+{
+    var oShowMore = $(oLink).parents('.' + this._sSP + '-body-show-more:first');
+    if(!oShowMore.length)
+        return;
+
+    var sClassOverflow = 'bx-overflow';
+
+    oShowMore.siblings('.' + sClassOverflow).css('max-height', 'none').removeClass(sClassOverflow);
+    oShowMore.remove();
+};
+
+BxDolCmts.prototype.cmtOnFindOverflow = function(oElement) {
+    $(oElement).after($(oElement).parents(this._sRootId + ':first').find('.' + this._sSP + '-body-show-more:hidden:first').clone().show());
+};
+
+/*----------------------------------*/
+/*--- Methods for internal usage ---*/
+/*----------------------------------*/
+BxDolCmts.prototype._getCmt = function (e, iCmtId)
+{
+    var $this = this;
+    var oData = this._getDefaultActions();
+    oData['action'] = 'GetCmt';
+    oData['Cmt'] = iCmtId;
+    oData['CmtBrowse'] = this._sBrowseType;
+    oData['CmtDisplay'] = this._sDisplayType;
+
+    this._loadingInBlock (e);
+
+    jQuery.post (
+        this._sActionsUrl,
+        oData,
+        function (oData) {
+            $this._loadingInBlock (e, false);
+
+            var sListId = $this._sRootId + ' #cmt' + oData.vparent_id + ' > ul.cmts-all';
+            var sReplyFormId = $this._sRootId + ' #cmt' + oData.parent_id + ' > .cmt-reply';
+
+            //--- Hide reply form ---//
+            if($(sReplyFormId).length)
+            	$(sReplyFormId).bx_anim('hide', $this._sAnimationEffect, $this._iAnimationSpeed);
+
+            $(sListId).each(function() {
+                //--- Some number of comments already loaded ---//
+                if($(this).children('li.cmt').length) {
+                    var oAdded = null;
+                    if($('#cmt' + iCmtId).length) {
+                        $('#cmt' + iCmtId).replaceWith($(oData.content).hide());
+
+                        oAdded = $('#cmt' + iCmtId);
+                    }
+                    else {
+                        switch($this._sPostFormPosition) {
+                            case 'top':
+                                oAdded = $(this).children('li.cmt:first').before($(oData.content).hide()).prevAll('li.cmt:hidden');
+                                break;
+
+                            case 'bottom':
+                                oAdded = $(this).children('li.cmt:last').after($(oData.content).hide()).nextAll('li.cmt:hidden');
+                                break;
+                        }
+                    }
+
+                    oAdded.each(function() {
+                        $(this).bxProcessHtml().bx_anim('toggle', $this._sAnimationEffect, $this._iAnimationSpeed);
+                    });
+                }
+                //-- There is no comments at all ---//
+                else
+                    $(this).hide().html(oData.content).bxProcessHtml().bx_anim('show', $this._sAnimationEffect, $this._iAnimationSpeed);
+
+                $this._cmtsInitSeeMore($(this));
+                $this._cmtsInitGallery($(this));
+            });
+        },
+        'json'
+    );
+};
+
+BxDolCmts.prototype._getCmts = function (oElement, oRequestParams, onLoad)
+{
+    var $this = this;
+    var oData = this._getDefaultActions();    
+    oData['action'] = 'GetCmts';
+    oData = $.extend({}, oData, oRequestParams);
+
+    var sListId =  this._sRootId + ' #cmt' + oData['CmtParent'] + ' > ul.cmts-' + (oRequestParams.CmtPinned ? 'pinned' : 'all') + ':first';
+
+    if(oElement)
+        this._loadingInBlock(oElement, true);
+
+    jQuery.post (
+        this._sActionsUrl,
+        oData,
+        function(s) {
+            if(oElement)
+                $this._loadingInBlock(oElement, false);
+
+            if(typeof onLoad == 'function')
+                onLoad(sListId, s);
+            else
+                $this._cmtsAppend(sListId, s);
+        }
+    );
+};
+
+BxDolCmts.prototype._getForm = function (e, oParams, onLoad)
+{
+    var $this = this;
+    var oData = $.extend({}, this._getDefaultActions(), {
+        action: 'GetFormPost',
+        CmtType: 'reply',
+        CmtBrowse: this._sBrowseType, 
+        CmtDisplay: this._sDisplayType, 
+        CmtMinPostForm: this._iMinPostForm
+    }, oParams);
+
+    if(e)
+    	this._loadingInContent(e, true);
+
+    jQuery.post (
+        this._sActionsUrl,
+        oData,
+        function (s) {
+            if(e)
+                $this._loadingInContent(e, false);
+
+            if(typeof onLoad == 'function')
+                onLoad(s);
+        }
+    );
+};
+
+BxDolCmts.prototype._cmtsAppend = function(sIdTo, sContent)
+{
+	$(sIdTo).append($(sContent).hide()).children(':hidden').bxProcessHtml().bx_anim('show', this._sAnimationEffect, this._iAnimationSpeed);
+};
+
+BxDolCmts.prototype._cmtsPrepend = function(sIdTo, sContent)
+{
+	$(sIdTo).prepend($(sContent).hide()).children(':hidden').bxProcessHtml().bx_anim('show', this._sAnimationEffect, this._iAnimationSpeed);
+};
+
+BxDolCmts.prototype._cmtsReplace = function(oReplace, sContent, onLoad)
+{
+    var $this = this;
+    $(oReplace).bx_anim('hide', this._sAnimationEffect, this._iAnimationSpeed, function() {
+        $(this).after($(sContent).hide()).nextAll(':hidden').bxProcessHtml().bx_anim('show', $this._sAnimationEffect, $this._iAnimationSpeed, function() {
+            $this._cmtsBlink($(this));
+
+            $this._cmtsInitSeeMore($(this));
+            $this._cmtsInitGallery($(this));
+
+            if(typeof onLoad == 'function')
+                onLoad();
+        });
+        $(this).remove();
+    });
+};
+BxDolCmts.prototype._cmtsReplaceContent = function(oParent, sContent, onLoad)
+{
+    var $this = this;
+    $(oParent).bx_anim('hide', this._sAnimationEffect, this._iAnimationSpeed, function() {
+        $(this).html(sContent).bxProcessHtml().bx_anim('show', $this._sAnimationEffect, $this._iAnimationSpeed, function() {
+            $this._cmtsBlink($(this));
+
+            $this._cmtsInitSeeMore($(this));
+            $this._cmtsInitGallery($(this));
+
+            if(typeof onLoad == 'function')
+            onLoad();
+        });
+    });
+};
+
+BxDolCmts.prototype._cmtsBlink = function(oParent)
+{
+    var sBlinkClass = 'cmt-blink';
+
+    oParent.find('.' + sBlinkClass + '-plate:visible').animate({
+        opacity: 0
+    }, 5000, function() {
+        oParent.find('.' + sBlinkClass).removeClass(sBlinkClass);
+    });
+};
+
+BxDolCmts.prototype._cmtsInitGallery = function(oParent) 
+{
+    // init gallery
+    const lightbox = new PhotoSwipeLightbox({
+        gallery: '.pswp-gallery',
+        children: 'a',
+        pswpModule: PhotoSwipe,
+    });
+    lightbox.init();
+}
+
+BxDolCmts.prototype._cmtsInitSeeMore = function(oParent)
+{
+    var $this = this;
+
+    oParent.find('.cmt-body.bx-overflow-ready').bxCheckOverflowHeight('', function(oElement) {
+        $this.cmtOnFindOverflow(oElement);
+    });
+};
+        
+        
+        
+BxDolCmts.prototype._getCounter = function(oElement, bText)
+{
+    var oCounter = null;
+    var sSelector = '.' + this._sSP + '-counter' + (bText ? '-text' : '');
+
+    if(oElement) {
+        if($(oElement).hasClass(this._sSP))
+            oCounter = $(oElement).find(sSelector);
+        else 
+            oCounter = $(oElement).parents('.' + this._sSP + ':first').find(sSelector);
+    }
+
+    if((!oCounter || !oCounter.length) && this._aHtmlIds['counter'] != undefined) {
+        oCounter = $('#' + this._aHtmlIds['counter']);
+        if(!oCounter.is(sSelector))
+            oCounter = oCounter.find(sSelector);
+    }
+
+    return oCounter;
+};
+
+BxDolCmts.prototype._getDefaultActions = function() {
+    var oDate = new Date();
+    var oResult = {
+        sys: this._sSystem,
+        id: this._iObjId,
+        _t: oDate.getTime()
+    };
+    if(this._aParams)
+        oResult['params'] = this._aParams;
+
+    return oResult;
+};
+
+BxDolCmts.prototype._loading = function(e, bShow) {
+    var oParent = $(e).length ? $(e) : $('body'); 
+    bx_loading(oParent, bShow);
+};
+
+BxDolCmts.prototype._loadingInBlock = function(e, bShow) {
+    var oParent = $(e).length ? $(e).parents('.bx-db-content:first') : $('body'); 
+    bx_loading(oParent, bShow);
+};
+
+BxDolCmts.prototype._loadingInContent = function(e, bShow) {
+    var oParent = $(e).length ? $(e).parents('li.cmt:first,.cmt-reply:first') : $('body'); 
+    bx_loading(oParent, bShow);
+};
+
+BxDolCmts.prototype._loadingInButton = function(e, bShow) {
+    if($(e).length)
+        bx_loading_btn($(e), bShow);
+    else
+        bx_loading($('body'), bShow);
+};
+
+/** @} */

+ 153 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolConnection.js

@@ -0,0 +1,153 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function BxDolConnection(oOptions)
+{
+    this._sSystem = oOptions.sSystem;
+    this._iContentId = oOptions.iContentId; // profile or content ID to connect with
+    this._sContentType = oOptions.sContentType; 
+    this._bIsMutual = oOptions.bIsMutual; 
+
+    this._sObjName = undefined == oOptions.sObjName ? 'oConnection' : oOptions.sObjName; // javascript object name, to run current object instance from onTimer
+    this._sActionsUri = 'conn.php';
+    this._sActionsUrl = oOptions.sRootUrl + this._sActionsUri; // actions url address
+
+    this._sAnimationEffect = 'fade';
+    this._iAnimationSpeed = 'slow';
+    this._sSP = undefined == oOptions.sStylePrefix ? 'bx-view' : oOptions.sStylePrefix;
+    this._aHtmlIds = oOptions.aHtmlIds;
+    this._aRequestParams = oOptions.aRequestParams;
+}
+
+BxDolConnection.prototype.connect = function(oLink, sAction, iContentId, bConfirm) {
+    var $this = this;
+    var oData = this._getDefaultParams();
+    oData['act'] = sAction;
+    oData['out'] = 'eval';
+
+    if(!iContentId)
+        iContentId = this._iContentId;
+
+    var fPerform = function() {
+        bx_loading_btn(oLink, 1);
+
+        $.post(
+            $this._sActionsUrl, 
+            oData,
+            function (oData) {
+                bx_loading_btn(oLink, 0);
+
+                $(oLink).parents('.bx-popup-applied:first:visible').dolPopupHide();
+
+                if(typeof(oData) != 'object')
+                    return;
+
+                // Following or befriending is a toggle: on when made, off when removed or declined.
+                if(typeof(bx_sound) == 'function')
+                    if(oData.err)
+                        bx_sound('error');
+                    else
+                        bx_sound('toggle', {direction: /^(remove|reject|cancel|unfollow|unsubscribe)/i.test(sAction) ? 'back' : 'forward', group: 'reactions'});
+
+                if(oData.err)
+                    bx_alert(oData.msg);
+                else {
+                    if(oData.eval != undefined)
+                        eval(oData.eval);
+                    else if(!loadDynamicBlockAuto(oLink))
+                        location.reload();
+                }
+            },
+            'json'
+        );
+    };
+
+    if (typeof(bConfirm) != 'undefined' && bConfirm)
+    	bx_confirm(_t('_Are_you_sure'), fPerform);
+    else
+    	fPerform();
+};
+
+BxDolConnection.prototype.onConnect = function(oData) {
+    if(oData.data != 'undefined')
+        $('#' + this._aHtmlIds['main'] + this._iContentId).replaceWith(oData.data).bxProcessHtml();
+};
+
+BxDolConnection.prototype.toggleConnectPopup = function(oLink, iContentId) {
+    if(!iContentId)
+        iContentId = this._iContentId;
+
+    bx_menu_popup_inline('#' + this._aHtmlIds['do_popup'] + iContentId, oLink);
+};
+
+BxDolConnection.prototype.toggleByPopup = function(oLink) {
+    var $this = this;
+    var oData = this._getDefaultParams();
+    oData['act'] = 'GetConnected';
+    oData['fmt'] = 'html';
+
+    $(oLink).dolPopupAjax({
+        id: this._aHtmlIds['by_popup'] + this._iContentId, 
+        url: bx_append_url_params(this._sActionsUri, oData)
+    });
+};
+
+BxDolConnection.prototype.getUsers = function(oLink, iStart, iPerPage) {
+    var $this = this;
+    var oData = this._getDefaultParams();
+    oData['act'] = 'GetUsers';
+    oData['start'] = iStart;
+    oData['per_page'] = iPerPage;
+
+    this._loadingInPopup(oLink, true);
+
+    $.get(
+        this._sActionsUri,
+        oData,
+        function(oData) {
+            $this._loadingInPopup(oLink, false);
+
+            oData.source = oLink;
+            processJsonData(oData);
+        },
+        'json'
+    );
+};
+
+BxDolConnection.prototype.onGetUsers = function(oData) {
+    if(!oData.content)
+        return;
+
+    $(oData.source).parents('.bx-popup-content-wrapped:first').html(oData.content);
+};
+
+BxDolConnection.prototype._loadingInButton = function(e, bShow) {
+    if($(e).length)
+        bx_loading_btn($(e), bShow);
+    else
+        bx_loading($('body'), bShow);
+};
+
+BxDolConnection.prototype._loadingInPopup = function(e, bShow) {
+    var oParent = $(e).length ? $(e).parents('.bx-popup-content:first') : $('body'); 
+    bx_loading(oParent, bShow);
+};
+
+BxDolConnection.prototype._getDefaultParams = function() {
+    var oDate = new Date();
+    return {
+        obj: this._sSystem,
+        id: this._iContentId,
+        mutual: this._bIsMutual,
+        content_type: this._sContentType,
+        params: $.param(this._aRequestParams),
+        _t: oDate.getTime(),
+    };
+};
+
+/** @} */

+ 212 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolFavorite.js

@@ -0,0 +1,212 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function BxDolFavorite(options)
+{
+	this._sObjName = undefined == options.sObjName ? 'oFavorite' : options.sObjName; // javascript object name, to run current object instance from onTimer
+	this._sSystem = options.sSystem; // current comment system
+	this._iAuthorId = options.iAuthorId; // this comment's author ID.
+    this._iObjId = options.iObjId; // this object id comments
+
+    this._sActionsUri = 'favorite.php';
+    this._sActionsUrl = options.sRootUrl + this._sActionsUri; // actions url address
+
+    this._sAnimationEffect = 'fade';
+    this._iAnimationSpeed = 'slow';
+    this._sSP = undefined == options.sStylePrefix ? 'bx-favorite' : options.sStylePrefix;
+    this._aHtmlIds = options.aHtmlIds;
+
+    this._oParent = null;
+}
+
+BxDolFavorite.prototype.toggleByPopup = function(oLink) {
+	var $this = this;
+    var oData = this._getDefaultParams();
+    oData['action'] = 'GetFavoritedBy';
+
+	$(oLink).dolPopupAjax({
+		id: this._aHtmlIds['by_popup'], 
+		url: bx_append_url_params(this._sActionsUri, oData)
+	});
+};
+
+BxDolFavorite.prototype.favorite = function(oLink) {
+    var $this = this;
+    var oData = this._getDefaultParams();
+    oData['action'] = 'Favorite';
+
+    this._oParent = $(oLink);
+    $.get(
+    	this._sActionsUrl,
+    	oData,
+    	function (oData) {
+
+    	    $this.processJson(oData, this._oParent);
+    	},
+    	'json'
+    );
+};
+
+BxDolFavorite.prototype.showNewList = function (obj) {
+    $(obj).parents('form').find("#bx-form-element-title").show();
+    $(obj).parents('form').find("#bx-form-element-allow_view_favorite_list_to").show();
+    $(obj).parents('form').find("#bx-form-element-new_list").hide();
+}
+
+BxDolFavorite.prototype.cmtDelete = function (obj, list_id) {
+    var $this = this;
+    bx_confirm('', function () {
+        var oData = $this._getDefaultParams();
+        oData['action'] = 'DeleteList';
+        oData['list_id'] = list_id;
+        $.get(
+            $this._sActionsUrl,
+            oData,
+            function (oData) {
+                $this.processJson(oData, this._oParent);
+            },
+            'json'
+        );
+    });
+}
+
+BxDolFavorite.prototype.cmtEdit = function (obj, list_id) {
+    var $this = this;
+    var oData = $this._getDefaultParams();
+    oData['action'] = 'EditList';
+    oData['list_id'] = list_id;
+
+    $.get(
+        $this._sActionsUrl,
+        oData,
+        function (oData) {
+            $this.processJson(oData, $(obj));
+        },
+        'json'
+    );
+}
+
+BxDolFavorite.prototype.onEditFavoriteList = function (oData, oElement) {
+    var $this = this;
+    var fContinue = function() {
+        if(oData && oData.code != 0)
+            return;
+        location.reload();
+    };
+
+    if (oData && oData.msg != undefined && oData.msg.length > 0)
+        bx_alert(oData.msg, fContinue);
+    else
+        fContinue();
+}
+
+
+BxDolFavorite.prototype.onFavorite = function(oData, oElement)
+{
+    var $this = this;
+
+    // A favorite is a toggle: on when added, off when taken back.
+    if(typeof(bx_sound) == 'function' && oData)
+        if(oData.code != undefined && parseInt(oData.code) != 0)
+            bx_sound('error');
+        else
+            bx_sound('toggle', {direction: oData.favorited ? 'forward' : 'back', group: 'reactions'});
+    var fContinue = function() {
+        if(oData && oData.code != 0)
+            return;
+
+        if(oData && oData.label_icon)
+            $(oElement).find('.sys-action-do-icon .sys-icon').attr('class', 'sys-icon ' + oData.label_icon);
+
+        if(oData && oData.label_title) {
+            $(oElement).attr('title', oData.label_title);
+            $(oElement).find('.sys-action-do-text').html(oData.label_title);
+        }
+
+        if(oData && oData.disabled)
+            $(oElement).removeAttr('onclick').addClass($(oElement).hasClass('bx-btn') ? 'bx-btn-disabled' : 'bx-favorite-disabled');
+
+        var oCounter = $this._getCounter(oElement);
+        if(oCounter && oCounter.length > 0) {
+            oCounter.html(oData.countf);
+
+            oCounter.parents('.' + $this._sSP + '-counter-holder:first').bx_anim(oData.count > 0 ? 'show' : 'hide');
+        }
+    };
+
+    if(oData && oData.msg != undefined && oData.msg.length > 0)
+        bx_alert(oData.msg, fContinue);
+    else
+        fContinue();
+};
+
+BxDolFavorite.prototype.processJson = function (oData, oElement)
+{
+    oElement = oElement != undefined ? oElement : this._oParent;
+    var $this = this;
+
+    var fContinue = function() {
+        //--- Show Popup
+        if(oData && oData.popup != undefined) {
+            $('.' + $this._sSP + '-do-form').parents(".bx-popup-wrapper").remove();
+            $(oData.popup).hide().prependTo('body').bxProcessHtml().dolPopup({
+                pointer: {
+                    el: oElement
+                },
+                fog: {
+                    color: '#fff',
+                    opacity: .7
+                }
+            });
+        }
+
+        //--- Evaluate JS code
+        if (oData && oData.eval != undefined)
+            eval(oData.eval);
+    };
+
+    //--- Show Message
+    if(oData && oData.message != undefined)
+        bx_alert(oData.message, fContinue);
+    else if (oData && oData.redirect != undefined)
+        document.location = oData.redirect;
+    else
+        fContinue();
+};
+
+BxDolFavorite.prototype._getButtons = function(oElement) {
+    if($(oElement).hasClass(this._sSP))
+        return $(oElement).find('.' + this._sSP + '-button');
+    else
+        return $(oElement).parents('.' + this._sSP + ':first').find('.' + this._sSP + '-button');
+};
+
+BxDolFavorite.prototype._getCounter = function(oElement) {
+    if($(oElement).hasClass(this._sSP))
+        return $(oElement).find('.' + this._sSP + '-counter');
+    else 
+        return $(oElement).parents('.' + this._sSP + ':first').find('.' + this._sSP + '-counter');
+};
+
+BxDolFavorite.prototype._loadingInButton = function(e, bShow) {
+    if($(e).length)
+        bx_loading_btn($(e), bShow);
+    else
+        bx_loading($('body'), bShow);	
+};
+
+BxDolFavorite.prototype._getDefaultParams = function() {
+    var oDate = new Date();
+    return {
+        sys: this._sSystem,
+        object_id: this._iObjId,
+        _t: oDate.getTime()
+    };
+};
+
+/** @} */

+ 605 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolGrid.js

@@ -0,0 +1,605 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function BxDolGrid (sObject, oOptions) {
+    this._iSearchTimeoutId = false;
+    this._sObject = sObject;
+    this._sIdWrapper = 'bx-grid-wrap-' + sObject;
+    this._sIdContainer = 'bx-grid-cont-' + sObject;
+    this._sIdTable = 'bx-grid-table-' + sObject;
+    this._oOptions = oOptions;
+    
+    this._sCsrfToken = oOptions.csrf_token;
+
+    this._sOrderField = undefined == oOptions.order_field ? '' : oOptions.order_field;
+    this._sOrderDir = undefined == oOptions.order_dir ? 'ASC' : oOptions.order_dir;
+    this._sSearchInputText = undefined == oOptions.text_search_input ? '' : oOptions.text_search_input;
+    this._oPopupOptions = $.extend({
+    	onBeforeShow: function(oElement) {
+    		jQuery(oElement).bxProcessHtml();
+    	}
+    }, oOptions.popup_options);
+    this._oQueryAppend = oOptions.query_append;
+    this._oConfirmMessages = oOptions.confirm_messages;
+    this.onReload = null;
+
+    this.init(oOptions);
+}
+
+BxDolGrid.prototype.init = function (oOptions) {
+    this._sFilter = undefined == oOptions.filter ? '' : oOptions.filter;
+    if (this._sFilter.length)
+        this._sSearchInputText = this._sFilter;
+
+    $('#bx-grid-search-' + this._sObject).val(this._sSearchInputText);
+
+    this._oQueryAppend = oOptions.query_append;
+
+    this._onDataReloaded();
+
+    if (this._sOrderField.length)
+        this.setSorting (this._sOrderField, this._sOrderDir, true);
+};
+
+BxDolGrid.prototype.resetSorting = function (sField, sDir) {
+    this._sOrderField = '';
+    this._sOrderDir = 'ASC';
+    this.reload(0);
+};
+
+BxDolGrid.prototype.setSorting = function (sField, sDir, isDisableReload) {
+    
+    if (typeof(sField) == "undefined" || 0 == sField.length)
+        return;
+
+    if (typeof(sDir) != "undefined")
+        this._sOrderDir = 'DESC' == sDir ? 'DESC' : 'ASC'; // explicit order
+    else
+        this._sOrderDir = 'ASC'; // of sorting new field, always order in asceding order    
+    
+    this._sOrderField = sField;
+    
+    var sHtmlIndi = 'ASC' == this._sOrderDir ? ' &darr;' : ' &uarr;';
+    jQuery('#' + this._sIdTable + ' *[bx_grid_sort_head] .bx-grid-sort-indi').hide();
+    jQuery('#' + this._sIdTable + ' *[bx_grid_sort_head=' + sField + '] .bx-grid-sort-indi').html(sHtmlIndi).show();    
+    
+    if (typeof(isDisableReload) == "undefined" || !isDisableReload)
+        this.reload(0);
+};
+
+BxDolGrid.prototype.setFilter = function (sFilter, isReload) {
+
+    if (this._sFilter == sFilter)
+        return;
+
+    this._sFilter = sFilter;
+
+    if (isReload) {
+        if (sFilter.length > 0)
+            this.reload(0);
+        else
+            this.reload();
+    }
+};
+
+BxDolGrid.prototype.orderable = function () {
+    var $this = this;
+    jQuery('#' + this._sIdTable + ' *[bx_grid_sort_head] .bx-grid-sort-handle').bind('click', function () {                
+        var sField = $(this).parents('*[bx_grid_sort_head]').attr('bx_grid_sort_head');
+        if (sField) {
+            $this.setSorting(sField, sField == $this._sOrderField ? ('ASC' == $this._sOrderDir ? 'DESC' : 'ASC') : undefined);
+        }
+    });
+    
+    if (this._sOrderField)
+        this.setSorting (this._sOrderField, this._sOrderDir, true);
+};
+
+BxDolGrid.prototype.sortable = function () {
+    var $this = this;
+    jQuery('#' + this._sIdTable + ' tbody .bx-grid-drag-handle').show();
+    jQuery('#' + this._sIdTable + ' tbody').sortable({
+        handle: '.bx-grid-drag-handle',
+        placeholder: 'bx-grid-drag-placeholder',
+        items:'tr', 
+        forcePlaceholderSize: true, 
+
+        start: function(oEvent, oUi) {
+
+            jQuery('#' + $this._sIdTable + ' tbody tr').removeClass('bx-def-color-bg-hl bx-grid-table-row-trans'); // remove rows highlighting and transitions  
+
+            oUi.placeholder.html('<td colspan="' + $this._oOptions.columns + '">&rarr;</td>'); // add placeholder with arrow
+        
+            oUi.item.addClass('bx-grid-gragging-row bx-def-color-bg-active'); // apply classes for dragged row            
+        },
+
+        stop: function(oEvent, oUi) {
+            oUi.item.removeClass('bx-grid-gragging-row'); // remove classes from dragged row
+            
+            jQuery('#' + $this._sIdTable + ' tbody tr:odd').addClass('bx-def-color-bg-hl'); // highlight odd rows            
+
+            $this.blink(oUi.item.attr('id')); // make dropped row to blink, so we clearly see where dropped row is places
+
+            // searialize current rows order and send result to the server for saving
+            var s = jQuery('#' + $this._sIdTable + ' tbody').sortable('serialize'); 
+            $this.action('reorder', {}, s, true);
+        }
+    });
+};
+
+BxDolGrid.prototype.blink = function (sId) {
+    var e = jQuery('#' + sId);
+    e.removeClass('bx-grid-table-row-trans');
+    e.addClass('bx-def-color-bg-active');    
+    setTimeout('glGrids.' + this._sObject + '._blinkCallback("' + sId + '")', 200);
+};
+
+BxDolGrid.prototype.enable = function (sId, isEnable) {
+    var e = jQuery('#' + sId);
+    var eSwitch = e.find('.bx-switcher-cont:has([bx_grid_action_single = "enable"])');
+    var eActions = e.find('.bx-grid-cell-single-actions-wrapper *[bx_grid_action_single]').not('*[bx_grid_permanent_state]');
+
+    if (isEnable) {        
+        e.removeClass('bx-grid-table-row-disabled bx-def-font-grayed');
+        eSwitch.removeClass('off').addClass('on');
+        eActions.removeClass('bx-btn-disabled');
+        if (eActions.length)
+            this._bindActionsSingle(e);
+    } else {
+        eActions.addClass('bx-btn-disabled');
+        eSwitch.removeClass('on').addClass('off');
+        e.addClass('bx-grid-table-row-disabled bx-def-font-grayed');        
+        if (eActions.length)
+            this._unbindActionsSingle(e);
+    }
+};
+
+BxDolGrid.prototype._blinkCallback = function (sId) {
+    var e = jQuery('#' + sId);
+    e.addClass('bx-grid-table-row-trans');
+    e.removeClass('bx-def-color-bg-active');
+};
+
+BxDolGrid.prototype.actionSingle = function (sAction, sId, oExtra) {
+    oExtra = oExtra || {};
+    var isConfirm = typeof(oExtra.confirm) != 'undefined' ? oExtra.confirm : 0;
+    var iActionResetPaginate = typeof(oExtra.reset_paginate) != 'undefined' ? parseInt(oExtra.reset_paginate) : (String(sAction).indexOf('delete') === -1 ? 0 : 1);
+    var oData = iActionResetPaginate ? {} : this._getActionDataForReload();
+    if (typeof(oExtra.data) == 'object')
+        oData = $.extend({}, oData, oExtra.data);
+    this.actionWithId(sId, sAction, oData, typeof(oExtra.post_data) != 'undefined' ? oExtra.post_data : '', false, isConfirm);
+};
+
+BxDolGrid.prototype.setOnReload = function (fCallback) {
+    this.onReload = typeof(fCallback) == 'function' ? fCallback : null;
+};
+
+BxDolGrid.prototype.actionWithId = function (sId, sAction, oData, sData, isDisableLoading, isConfirm) {
+    var sDataAdd = 'ids[]=' + sId;
+    if (typeof(sData) == "undefined" || !sData.length) {
+        sData = sDataAdd;
+    } else {
+        if ('&' != sData[sData.length-1])
+            sData += '&';
+        sData += sDataAdd;
+    } 
+
+    this.action(sAction, oData, sData, isDisableLoading, isConfirm);
+};
+
+BxDolGrid.prototype.actionWithSelected = function (sActionData, sAction, oData, sData, isDisableLoading, isConfirm) {    
+
+    var sDataAdd = '';
+    jQuery("#" + this._sIdTable + " input[name=" + this._sObject + "_check]:checked").each(function () {
+        if (sDataAdd.length)   
+            sDataAdd += "&";
+        sDataAdd += 'ids[]=' + $(this).val();
+    });
+
+    if (!sDataAdd.length)
+        return;
+
+    if (typeof(sData) == "undefined" || !sData.length) {
+        sData = sDataAdd;
+    } else {
+        if ('&' != sData[sData.length-1])
+            sData += '&';
+        sData += sDataAdd;
+    } 
+
+    this.action(sAction, oData, sData, isDisableLoading, isConfirm);
+};
+
+BxDolGrid.prototype.action = function (sAction, oData, sData, isDisableLoading, isConfirm) {
+    var $this = this;
+    var fPerform = function() {
+    	var i;
+    	var sUrl = sUrlRoot + "grid.php?o=" + $this._sObject + "&a=" + sAction;
+
+    	if (typeof($this._oQueryAppend) == 'object')
+            oData = $.extend({}, $this._oQueryAppend, oData);
+
+        for (i in oData)
+            sUrl += "&" + i + "=" + encodeURIComponent(oData[i]);
+        if (typeof(sData) != 'undefined')
+            sUrl += '&' + sData;
+        if ($this._sFilter.length)
+            sUrl += '&' + $this._oOptions.filter_get + '=' + encodeURIComponent ($this._sFilter);
+        if ($this._sOrderField.length)
+            sUrl += '&' + $this._oOptions.order_get_field + '=' + encodeURIComponent ($this._sOrderField);
+        if ($this._sOrderDir.length)
+            sUrl += '&' + $this._oOptions.order_get_dir + '=' + encodeURIComponent ($this._sOrderDir);
+        if ($this._sCsrfToken.length)
+            sUrl += '&csrf_token=' + encodeURIComponent ($this._sCsrfToken);
+        sUrl += '&_r=' + Math.random();
+
+        if (typeof(isDisableLoading) == 'undefined' || !isDisableLoading)
+            $this.loading(true);
+
+        if(sUrl.length < 2048)
+            $.getJSON(sUrl, function(oResult) {
+                $this.processJson(oResult, sAction, isDisableLoading);
+            });
+        else {
+            var oUri = new URI(sUrl);
+
+            oData = oUri.query(true);
+            sUrl = oUri.query("").toString();
+
+            $.post(sUrl, oData, function(oResult) {
+                $this.processJson(oResult, sAction, isDisableLoading);
+            }, 'json');
+        }
+    };
+
+    if(typeof(isConfirm) != 'undefined' && parseInt(isConfirm) == 1)
+    	bx_confirm(typeof(this._oConfirmMessages[sAction]) == 'undefined' ? _t('_sys_grid_confirmation') : this._oConfirmMessages[sAction], function() {
+    		fPerform();
+    	});
+    else
+    	fPerform();
+};
+
+BxDolGrid.prototype.processJson = function (oData, sAction, isDisableLoading) {
+    var $this = this;
+    var fContinue = function() {
+    	if (oData && undefined != oData.grid) {
+            if (typeof($this.onReload) == 'function') {
+                $this.onReload(oData);
+            } else {
+                $('#' + $this._sIdWrapper).find('.bx-grid-header-controls-counter-value').html(oData.total_count_f);
+
+                switch(sAction) {
+                    case 'load_more':
+                        var sClassData = 'bx-grid-table-data';
+                        $('#' + $this._sIdContainer).find('.' + sClassData).append($(oData.grid).find('#' + $this._sIdContainer + ' .' + sClassData).html());
+                        break;
+
+                    default:
+                        $('#' + $this._sIdContainer).html($(oData.grid).find('#' + $this._sIdContainer).html());
+                        break;
+                }
+
+                var oFooter = $(oData.grid).find('.bx-grid-footer');
+                $('#' + $this._sIdWrapper).find('.bx-grid-footer').html(oFooter.size() ? oFooter.html() : '');
+
+                $this._onDataReloaded(true);
+            }
+        }
+    	if (oData && undefined != oData.blink) {
+            if ('object' == typeof(oData.blink)) {
+                for(var i in oData.blink)
+                    $this.blink($this._sObject + '_row_' + oData.blink[i]);
+            } else {
+                $this.blink($this._sObject + '_row_' + oData.blink);
+            }
+        }
+        if (oData && undefined != oData.disable) {        
+            if ('object' == typeof(oData.disable)) {
+                for(var i in oData.disable)
+                    $this.enable($this._sObject + '_row_' + oData.disable[i], false);
+            } else {
+                $this.enable($this._sObject + '_row_' + oData.disable, false);
+            }
+        }
+        if (oData && undefined != oData.enable) {
+            if ('object' == typeof(oData.enable)) {
+                for(var i in oData.enable)
+                    $this.enable($this._sObject + '_row_' + oData.enable[i], true);
+            } else {
+                $this.enable($this._sObject + '_row_' + oData.enable, true);
+            }
+        }
+        if (oData && undefined != oData.popup) {
+            var sId = 'grid-popup-' + $this._sObject + '-' + sAction;
+            $('#' + sId).remove();
+            if ('object' == typeof(oData.popup)) {
+                var o = $.extend({}, $this._oPopupOptions, oData.popup.options);
+                $('<div id="' + sId + '" style="display: none;"></div>').prependTo('body').html(oData.popup.html);
+                $('#' + sId).dolPopup(o);
+            } else {
+                $('<div id="' + sId + '" style="display: none;"></div>').prependTo('body').html(oData.popup);
+                $('#' + sId).dolPopup($this._oPopupOptions);
+            }        
+        }
+        if (oData && oData.reload != undefined && parseInt(oData.reload) == 1) {
+            document.location = document.location;
+        }
+        if (oData && oData.redirect != undefined && oData.redirect.length != 0) {
+            document.location = oData.redirect;
+        }
+        if (oData && oData.open_url != undefined && oData.open_url.length != 0) {
+            window.open(oData.open_url, '_blank');
+        }
+        if (oData && undefined != oData.eval) {
+            eval(oData.eval);
+        }
+        if (oData && undefined != oData.secret) {
+            $this.setSecretValue(oData.secret.id, oData.secret.field, oData.secret.value);
+        }
+    };
+
+    if (typeof(isDisableLoading) == 'undefined' || !isDisableLoading)
+        this.loading(false);
+
+    if (oData && undefined != oData.msg) {
+        bx_alert(oData.msg, function() {
+        	fContinue();
+        });
+    }
+    else if (oData && undefined != oData.toast) {
+        bx_toast(oData.toast);
+        fContinue();
+    }
+    else
+    	fContinue();
+};
+
+BxDolGrid.prototype.loading = function (bShow) {
+    if (!$('#' + this._sIdContainer).length)
+        return;
+    bx_loading(this._sIdContainer, bShow);
+};
+
+BxDolGrid.prototype.reload = function (iStart, iPerPage) {
+    var oData = this._getActionDataForReload(iStart, iPerPage);
+    this.action('display', oData);
+};
+
+BxDolGrid.prototype.loadMore = function (iStart, iPerPage) {
+    var oData = this._getActionDataForReload(iStart, iPerPage);
+    this.action('load_more', oData);
+};
+
+BxDolGrid.prototype._getActionDataForReload = function (iStart, iPerPage) {
+    var oData = {};
+
+    if (typeof(iStart) != 'undefined') {
+        oData[this._oOptions.paginate_get_start] = iStart;
+        this._oOptions.start = iStart;
+    } 
+    else {
+        oData[this._oOptions.paginate_get_start] = this._oOptions.start;
+    }
+
+    if (this._oOptions.paginate_get_per_page.length) {
+        if (typeof(iPerPage) != 'undefined') {
+            oData[this._oOptions.paginate_get_per_page] = iPerPage;
+            this._oOptions.per_page = iPerPage;
+        }
+        else {
+            oData[this._oOptions.paginate_get_per_page] = this._oOptions.per_page;
+        }
+    }
+
+    return oData;
+};
+
+BxDolGrid.prototype._unbindActionsSingle = function (eElement) {
+    var elements;
+    
+    if ('undefined' == typeof(eElement))
+        elements = jQuery('#' + this._sIdWrapper + ' *[bx_grid_action_single]');
+    else
+        elements = eElement.find('*[bx_grid_action_single]');
+
+    elements.unbind('click');
+};
+
+BxDolGrid.prototype._bindActionsSingle = function (eElement) {
+    var $this = this;
+    var elements;
+
+    if ('undefined' == typeof(eElement))
+        elements = jQuery('#' + this._sIdWrapper + ' *[bx_grid_action_single]');
+    else
+        elements = eElement.find('*[bx_grid_action_single]');
+
+    elements.not('.bx-btn-disabled').bind('click', function () {
+        if ($(this).hasClass('bx-btn-disabled'))
+            return;
+        var sAction = $(this).attr('bx_grid_action_single');
+        var sActionConfirm = $(this).attr('bx_grid_action_confirm');
+        var sActionData = $(this).attr('bx_grid_action_data');
+        var iActionResetPaginate = parseInt($(this).attr('bx_grid_action_reset_paginate'));
+        $this.actionSingle(sAction, sActionData, {
+            confirm: sActionConfirm,
+            reset_paginate: iActionResetPaginate,
+            data: $this._checkAppend(this, {})
+        });
+    });
+};
+
+BxDolGrid.prototype._bindActions = function (isSkipSearchInput) {
+    var $this = this;
+
+    jQuery('#' + this._sIdContainer).bind('scroll', function () {
+        if (-$(this).find('.bx-grid-table').position().left >= ($(this).find('.bx-grid-table').width() - $(this).width()))
+            $(this).parents('.bx-grid-table-wrapper2').addClass('bx-grid-table-hide-shadow-after');
+        else
+            $(this).parents('.bx-grid-table-wrapper2').removeClass('bx-grid-table-hide-shadow-after');
+        
+        if ($(this).find('.bx-grid-table').position().left >= 0)
+            $(this).parents('.bx-grid-table-wrapper2').addClass('bx-grid-table-hide-shadow-before');
+        else
+            $(this).parents('.bx-grid-table-wrapper2').removeClass('bx-grid-table-hide-shadow-before');
+    });
+
+    jQuery('#' + this._sIdWrapper + ' *[bx_grid_action_bulk]').bind('click', function () {
+        if ($(this).hasClass('bx-btn-disabled'))
+            return;
+        var sAction = $(this).attr('bx_grid_action_bulk');
+        var sActionConfirm = $(this).attr('bx_grid_action_confirm');
+        var sActionData = $(this).attr('bx_grid_action_data');
+        var iActionResetPaginate = parseInt($(this).attr('bx_grid_action_reset_paginate'));
+        var oData = iActionResetPaginate ? {} : $this._getActionDataForReload();
+        oData = $this._checkAppend(this, oData);
+        $this.actionWithSelected (sActionData, sAction, oData, '', false, sActionConfirm);
+    });
+
+    this._bindActionsSingle ();
+
+    jQuery('#' + this._sIdWrapper + ' .bx-grid-secret-toggle').bind('click', function () {
+        var eWrap = $(this).closest('.bx-grid-secret');
+        if (eWrap.hasClass('bx-grid-secret-shown')) {
+            $this.hideSecret(eWrap);
+            return;
+        }
+        $this.revealSecret($(this).attr('bx_grid_secret_id'), $(this).attr('bx_grid_secret_field'));
+    });
+    
+    if (jQuery('#' + this._sIdWrapper + ' .bx-switcher-cont input').length) 
+        jQuery('#' + this._sIdWrapper).addWebForms(); 
+    jQuery('#' + this._sIdWrapper + ' .bx-switcher-cont input').bind('change', function () {
+        var sAction = $(this).attr('bx_grid_action_single');
+        var sActionConfirm = $(this).attr('bx_grid_action_confirm');
+        var sActionData = $(this).attr('bx_grid_action_data');
+        var iActionResetPaginate = parseInt($(this).attr('bx_grid_action_reset_paginate'));
+        var oData = iActionResetPaginate ? {} : $this._getActionDataForReload();
+        $this.actionWithId (sActionData, sAction, $.extend({}, oData, {checked:this.checked ? 1 : 0}), '', false, sActionConfirm);
+    });
+
+    if (typeof(isSkipSearchInput) == 'undefined' || false == isSkipSearchInput) {
+
+        jQuery('#' + this._sIdWrapper + ' *[bx_grid_action_independent]').bind('click', function () {
+            if ($(this).hasClass('bx-btn-disabled'))
+                return;
+            var sAction = $(this).attr('bx_grid_action_independent');
+            var sActionConfirm = $(this).attr('bx_grid_action_confirm');
+            var iActionResetPaginate = parseInt($(this).attr('bx_grid_action_reset_paginate'));
+            var oData = iActionResetPaginate ? {} : $this._getActionDataForReload();
+            oData = $this._checkAppend(this, oData);
+            $this.action (sAction, oData, '', false, sActionConfirm);
+        });    
+
+        var callbackSearch = function (sTerm) {
+            clearTimeout($this._iSearchTimeoutId);
+            $this._iSearchTimeoutId = setTimeout(function () {
+            	if(glGrids[$this._sObject])
+            		glGrids[$this._sObject].setFilter(sTerm, true);
+            }, 500);
+        };
+        
+        jQuery('#bx-grid-search-' + this._sObject).bind({
+            keyup: function (e) {
+                callbackSearch(this.value);
+            },
+            focusout: function (e) {
+                if (0 == this.value.length)
+                    this.value = $this._sSearchInputText;
+                callbackSearch(this.value == $this._sSearchInputText ? '' : this.value);
+            },
+            focusin: function (e) {
+                if (this.value == $this._sSearchInputText)
+                    this.value = '';
+            }
+        });
+    }
+};
+
+BxDolGrid.prototype._onDataReloaded = function (isSkipSearchInput) {
+
+    jQuery('#' + this._sIdTable + ' tbody tr:odd').addClass('bx-def-color-bg-hl');
+    
+    jQuery('#' + this._sIdTable).bxProcessHtml();
+
+    if (this._oOptions.sorting)
+        this.orderable();
+
+    if (this._oOptions.sortable && 0 == this._sFilter.length && 0 == this._sOrderField.length)
+        this.sortable();
+
+    this._bindActions(isSkipSearchInput);
+
+    this.onDataReloaded(isSkipSearchInput);
+};
+
+BxDolGrid.prototype.onDataReloaded = function (isSkipSearchInput) {};
+
+BxDolGrid.prototype._checkAppend = function (oInput, oData) {
+	var oAppend = $(oInput).attr('bx_grid_action_append') ? $.parseJSON($(oInput).attr('bx_grid_action_append')) : false;
+    if(typeof(oAppend) == 'object')
+        oData = $.extend({}, oData, oAppend);
+
+    return oData;
+};
+
+BxDolGrid.prototype.revealSecret = function (sId, sField) {
+    var $this = this;
+    var oInput = $('#bx-popup-prompt-value');
+
+    $(document).dolPopupPrompt({
+        message: _t('_sys_grid_secret_password_prompt'),
+        value: '',
+        onClickOk: function (oPopup) {
+            var oData = {
+                o: $this._sObject,
+                a: 'reveal_secret',
+                'ids[]': sId,
+                field: sField,
+                password: oPopup.getValue(),
+                csrf_token: $this._sCsrfToken,
+                _r: Math.random()
+            };
+            if (typeof($this._oQueryAppend) == 'object')
+                oData = $.extend({}, $this._oQueryAppend, oData);
+
+            $this.loading(true);
+            $.post(sUrlRoot + 'grid.php', oData, function (oResult) {
+                $this.processJson(oResult, 'reveal_secret', false);
+            }, 'json');
+        },
+        onShow: function () {
+            oInput.attr('type', 'password').attr('autocomplete', 'current-password').val('').focus();
+        },
+        onHide: function () {
+            oInput.attr('type', 'text').removeAttr('autocomplete').val('');
+        }
+    });
+};
+
+BxDolGrid.prototype.hideSecret = function (eWrap) {
+    var sMasked = eWrap.find('.bx-grid-secret-value').attr('data-masked') || '';
+    eWrap.removeClass('bx-grid-secret-shown');
+    eWrap.find('.bx-grid-secret-value').text(sMasked);
+    eWrap.find('.bx-grid-secret-toggle').attr('title', _t('_sys_grid_secret_reveal'));
+    eWrap.find('.bx-grid-secret-toggle .sys-icon').removeClass('eye-slash').addClass('eye');
+};
+
+BxDolGrid.prototype.setSecretValue = function (sId, sField, sValue) {
+    var eToggle = jQuery('#' + this._sIdWrapper + ' .bx-grid-secret-toggle').filter(function () {
+        return $(this).attr('bx_grid_secret_id') == sId && $(this).attr('bx_grid_secret_field') == sField;
+    });
+    var eWrap = eToggle.closest('.bx-grid-secret');
+    eWrap.addClass('bx-grid-secret-shown');
+    eWrap.find('.bx-grid-secret-value').text(sValue);
+    eToggle.attr('title', _t('_sys_grid_secret_hide'));
+    eToggle.find('.sys-icon').removeClass('eye').addClass('eye-slash');
+};
+
+/** @} */

+ 578 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolMenuMoreAuto.js

@@ -0,0 +1,578 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * A horizontal menu that moves the items which don't fit into its 'more' popup.
+ *
+ * Which items stay in the row is computed from the items' natural widths, measured on an invisible copy of the
+ * row (so the visible row is never torn up to be measured), and the space the row has. The answer is applied
+ * only when it changes, and it is remembered per window width: the next page applies it while it is parsed,
+ * before the first paint, so on page switches the row shows at once and nothing moves.
+ *
+ * The first real measurement waits until the document is parsed (toolbar items that share the header with the
+ * menu exist only then) and web fonts have loaded. After that a ResizeObserver on the menu's container updates
+ * the row when the space really changes; only those updates glide the items to their new places.
+ */
+function BxDolMenuMoreAuto(options)
+{
+    this._sObject = options.sObject;
+    this._iItemsStatic = undefined == options.iItemsStatic ? 0 : options.iItemsStatic;
+    this._bItemsStaticOnly = undefined == options.bItemsStaticOnly ? 0 : options.bItemsStaticOnly;
+    this._aHtmlIds = undefined == options.aHtmlIds ? {} : options.aHtmlIds;
+
+    this._sClassReady = 'bx-mma-ready';
+    this._sClassItem = '.bx-menu-item';
+    this._sClassItemMore = this._sClassItem + '.bx-menu-item-more-auto';
+    this._sClassItemMoreSubmenu = '.bx-menu-submenu-more-auto';
+    this._sClassItemStatic = 'bx-menu-item-static';
+
+    this._sStorageKey = 'bx-mma-' + this._sObject;
+    this._iStorageWidths = 12;
+
+    this._bStaticMode = this._bItemsStaticOnly && this._iItemsStatic > 0;
+
+    this._oMenu = null;
+    this._oItemMore = null;
+    this._oSubmenu = null;
+    this._aItems = [];
+    this._eSubmenuFirst = null;
+
+    this._sLayout = '';
+    this._fLayoutWidth = -1;
+    this._bSettled = false;
+    this._oResizeObserver = null;
+}
+
+/**
+ * Menus set up on this page. One web fonts listener (and, without ResizeObserver, one window resize handler) serves
+ * them all. A menu whose element has left the page, e.g. a submenu in content that htmx swapped out, is dropped and
+ * its observer disconnected, so nothing keeps it in memory.
+ */
+BxDolMenuMoreAuto._aInstances = [];
+BxDolMenuMoreAuto._bListening = false;
+
+BxDolMenuMoreAuto._register = function(oInstance)
+{
+    BxDolMenuMoreAuto._prune();
+    BxDolMenuMoreAuto._aInstances.push(oInstance);
+
+    if(BxDolMenuMoreAuto._bListening)
+        return;
+    BxDolMenuMoreAuto._bListening = true;
+
+    // Item labels change width when a web font finishes loading after a menu has settled.
+    if(document.fonts && document.fonts.addEventListener)
+        document.fonts.addEventListener('loadingdone', function() {
+            BxDolMenuMoreAuto._each(function(o) {
+                if(o._bSettled)
+                    o._layout(true);
+            });
+        });
+
+    if(typeof(window.ResizeObserver) == 'undefined')
+        $(window).on('resize', function() {
+            BxDolMenuMoreAuto._each(function(o) {
+                o._onResize();
+            });
+        });
+};
+
+BxDolMenuMoreAuto._each = function(fCallback)
+{
+    BxDolMenuMoreAuto._prune();
+    $.each(BxDolMenuMoreAuto._aInstances.slice(), function(i, oInstance) {
+        fCallback(oInstance);
+    });
+};
+
+BxDolMenuMoreAuto._prune = function()
+{
+    BxDolMenuMoreAuto._aInstances = $.grep(BxDolMenuMoreAuto._aInstances, function(oInstance) {
+        if(oInstance._isOnPage())
+            return true;
+
+        oInstance._release();
+        return false;
+    });
+};
+
+BxDolMenuMoreAuto.prototype.init = function()
+{
+    var $this = this;
+
+    var oMenu = $('#' + this._aHtmlIds['main']);
+    if(!oMenu.length)
+        return;
+
+    // Pages may run the init code again for a menu that is already set up (a re-rendered block keeps the element).
+    var eMenu = oMenu.get(0);
+    if(eMenu._bxMenuMoreAuto)
+        return;
+    eMenu._bxMenuMoreAuto = this;
+
+    this._oMenu = oMenu;
+    this._oItemMore = oMenu.children(this._sClassItemMore);
+    this._oSubmenu = this._oItemMore.find(this._sClassItemMoreSubmenu);
+    this._eSubmenuFirst = this._oSubmenu.children(this._sClassItem).get(0) || null;
+    this._aItems = oMenu.children(this._sClassItem).not(this._sClassItemMore).toArray();
+
+    // The first N items are persistent: they never move into 'more'.
+    var iStatic = 0;
+    $.each(this._aItems, function(i, eItem) {
+        if(iStatic >= $this._iItemsStatic)
+            return false;
+
+        if(!$(eItem).hasClass($this._sClassItemStatic)) {
+            $(eItem).addClass($this._sClassItemStatic);
+            iStatic++;
+        }
+    });
+
+    oMenu.css('overflow', 'visible');
+    oMenu.parents('.bx-menu-more-auto-wrapper:first').css('overflow', 'visible');
+
+    /**
+     * The row this window width had on the previous page is applied right away, so the row can show before the
+     * first paint. Without one, a first guess is applied and the row stays hidden (templates keep it hidden until
+     * it is marked ready) until it is measured for real.
+     */
+    var sRemembered = this._bStaticMode ? '' : this._recall();
+    if(sRemembered) {
+        this._apply(sRemembered);
+        this._reveal(false);
+    }
+    else
+        this._layout(false);
+
+    if(this._bStaticMode) {
+        this._reveal(false);
+        return;
+    }
+
+    var fSettle = function() {
+        $this._whenFontsReady(function() {
+            $this._settle();
+        });
+    };
+
+    if(document.readyState == 'loading')
+        $(document).ready(fSettle);
+    else
+        fSettle();
+
+    //--- Track the space the row has: other header items appearing, a sidebar toggling, the window resizing ---//
+    if(typeof(window.ResizeObserver) != 'undefined') {
+        this._oResizeObserver = new ResizeObserver(function() {
+            // Removing the element reports a resize too: that is when a menu leaves the page.
+            if($this._isOnPage())
+                $this._onResize();
+            else
+                BxDolMenuMoreAuto._prune();
+        });
+        this._oResizeObserver.observe(oMenu.parent().get(0));
+    }
+
+    BxDolMenuMoreAuto._register(this);
+};
+
+BxDolMenuMoreAuto.prototype._isOnPage = function()
+{
+    return !!this._oMenu && this._oMenu.get(0).isConnected;
+};
+
+BxDolMenuMoreAuto.prototype._release = function()
+{
+    if(!this._oResizeObserver)
+        return;
+
+    this._oResizeObserver.disconnect();
+    this._oResizeObserver = null;
+};
+
+/**
+ * Recalculate the row now, e.g. after an item's content has changed.
+ */
+BxDolMenuMoreAuto.prototype.update = function(bForceCalculate)
+{
+    if(this._bStaticMode || !this._oMenu)
+        return;
+
+    this._layout(this._bSettled);
+};
+
+BxDolMenuMoreAuto.prototype.more = function(oElement)
+{
+    var oElement = $(oElement);
+
+    oElement.parents('li:first').find('#' + this._aHtmlIds['more_auto_popup']).dolPopup({
+        pointer: {
+            el: oElement
+        },
+        moveToDocRoot: false,
+        cssClass: 'bx-popup-menu',
+        onShow: function() {
+            oElement.attr('aria-expanded', 'true');
+        },
+        onHide: function() {
+            oElement.attr('aria-expanded', 'false');
+        }
+    });
+};
+
+BxDolMenuMoreAuto.prototype._settle = function()
+{
+    if(this._bSettled)
+        return;
+
+    /**
+     * Usually the row is still hidden here, or it already is the remembered one and nothing moves. If the remembered
+     * row turns out to be wrong (the toolbar changed since), the shown items glide to their places.
+     */
+    this._bSettled = true;
+    this._layout(this._oMenu.hasClass(this._sClassReady));
+    this._reveal(true);
+};
+
+/**
+ * Mark the row ready, which shows it in templates that hide it until then. A remembered row may have been styled
+ * hidden already (the browser can paint before the init code runs), so it skips the template's reveal transition.
+ */
+BxDolMenuMoreAuto.prototype._reveal = function(bTransition)
+{
+    var eMenu = this._oMenu.get(0);
+    if(this._oMenu.hasClass(this._sClassReady))
+        return;
+
+    if(bTransition) {
+        this._oMenu.addClass(this._sClassReady);
+        return;
+    }
+
+    var sTransition = eMenu.style.transition;
+    eMenu.style.transition = 'none';
+    this._oMenu.addClass(this._sClassReady);
+    this._fRevealOpacity = Number.parseFloat(window.getComputedStyle(eMenu).opacity); // Reading it applies the change before the transition comes back.
+    eMenu.style.transition = sTransition;
+};
+
+BxDolMenuMoreAuto.prototype._onResize = function()
+{
+    if(!this._bSettled)
+        return;
+
+    // Our own changes to the row don't change the container's width; skip the callbacks they cause.
+    if(Math.abs(this._getAvailable() - this._fLayoutWidth) < 0.5)
+        return;
+
+    this._layout(true);
+};
+
+BxDolMenuMoreAuto.prototype._whenFontsReady = function(fCallback)
+{
+    if(!document.fonts || typeof(document.fonts.ready) != 'object' || document.fonts.status == 'loaded') {
+        fCallback();
+        return;
+    }
+
+    var bDone = false;
+    var fOnce = function() {
+        if(bDone)
+            return;
+
+        bDone = true;
+        fCallback();
+    };
+
+    document.fonts.ready.then(fOnce, fOnce);
+    setTimeout(fOnce, 1000); // A font that never loads doesn't keep the menu hidden.
+};
+
+/**
+ * Measure, decide which items stay in the row and apply it.
+ * @param bAnimate - glide the items that stay to their new places (only for a row that is already shown)
+ */
+BxDolMenuMoreAuto.prototype._layout = function(bAnimate)
+{
+    if(this._bStaticMode) {
+        this._apply(this._compute(null, 0));
+        return;
+    }
+
+    var fAvailable = this._getAvailable();
+    if(fAvailable <= 0)
+        return; // Not displayed yet: the ResizeObserver calls again once it is.
+
+    var sLayout = this._compute(this._measure(), fAvailable);
+
+    var aBefore = bAnimate && sLayout != this._sLayout ? this._getPositions() : null;
+    this._apply(sLayout);
+    if(aBefore)
+        this._animateFrom(aBefore);
+
+    this._fLayoutWidth = this._getAvailable();
+    if(this._bSettled)
+        this._remember(sLayout);
+};
+
+/**
+ * The width the row can use: its container's content width less the row's own padding.
+ */
+BxDolMenuMoreAuto.prototype._getAvailable = function()
+{
+    var eParent = this._oMenu.parent().get(0);
+    var oStyleParent = window.getComputedStyle(eParent);
+    var oStyleMenu = window.getComputedStyle(this._oMenu.get(0));
+
+    return eParent.getBoundingClientRect().width
+        - Number.parseFloat(oStyleParent.paddingLeft) - Number.parseFloat(oStyleParent.paddingRight)
+        - Number.parseFloat(oStyleParent.borderLeftWidth) - Number.parseFloat(oStyleParent.borderRightWidth)
+        - Number.parseFloat(oStyleMenu.paddingLeft) - Number.parseFloat(oStyleMenu.paddingRight)
+        - Number.parseFloat(oStyleMenu.borderLeftWidth) - Number.parseFloat(oStyleMenu.borderRightWidth);
+};
+
+/**
+ * Natural widths of all items and of 'more', taken from an invisible copy of the row placed next to it, so it
+ * gets the same styles. Each item's share includes the gap before it, so any run of items adds up.
+ * @return {items: [share, ...], more: share}
+ */
+BxDolMenuMoreAuto.prototype._measure = function()
+{
+    var $this = this;
+    var eMenu = this._oMenu.get(0);
+
+    var eProbe = eMenu.cloneNode(false);
+    eProbe.removeAttribute('id');
+    eProbe.setAttribute('aria-hidden', 'true');
+    eProbe.style.cssText = 'position:absolute;top:0;left:0;width:max-content;max-width:none;overflow:visible;visibility:hidden;pointer-events:none;';
+
+    var aClones = $.map(this._aItems, function(eItem) {
+        var eClone = eItem.cloneNode(true);
+        $this._styleForRow($(eClone));
+        eProbe.appendChild(eClone);
+        return eClone;
+    });
+
+    var eMoreClone = null;
+    if(this._oItemMore.length) {
+        // Without its popup, which holds the items that are in 'more' now.
+        eMoreClone = this._oItemMore.get(0).cloneNode(true);
+        $(eMoreClone).find('#' + this._aHtmlIds['more_auto_popup']).remove();
+        eMoreClone.style.display = 'list-item';
+        eProbe.appendChild(eMoreClone);
+    }
+
+    $(eProbe).find('[id]').removeAttr('id');
+    eMenu.parentNode.insertBefore(eProbe, eMenu);
+
+    var bRtl = window.getComputedStyle(eProbe).direction == 'rtl';
+    var oProbe = eProbe.getBoundingClientRect();
+    var oStyleProbe = window.getComputedStyle(eProbe);
+    var fStart = bRtl ? oProbe.right - Number.parseFloat(oStyleProbe.paddingRight) - Number.parseFloat(oStyleProbe.borderRightWidth) : oProbe.left + Number.parseFloat(oStyleProbe.paddingLeft) + Number.parseFloat(oStyleProbe.borderLeftWidth);
+    var fEdge = function(eElement) {
+        var oRect = eElement.getBoundingClientRect();
+        return bRtl ? fStart - oRect.left : oRect.right - fStart;
+    };
+
+    var fPrev = 0;
+    var aItems = $.map(aClones, function(eClone) {
+        if(!eClone.getClientRects().length)
+            return 0; // Not displayed at this width.
+
+        var fEnd = fEdge(eClone);
+        var fShare = fEnd - fPrev;
+        fPrev = fEnd;
+        return fShare;
+    });
+    var fMore = eMoreClone && eMoreClone.getClientRects().length ? fEdge(eMoreClone) - fPrev : 0;
+
+    eProbe.remove();
+
+    return {items: aItems, more: fMore};
+};
+
+/**
+ * Which items stay in the row: a string with '1' (row) or '0' (more) per item, in their order.
+ * In static mode only the persistent items stay and no sizes are needed.
+ */
+BxDolMenuMoreAuto.prototype._compute = function(oSizes, fAvailable)
+{
+    var $this = this;
+    var fEps = 0.5;
+
+    var fAll = 0;
+    if(oSizes)
+        $.each(oSizes.items, function(i, fShare) {
+            fAll += fShare;
+        });
+
+    var bAllFit = !this._bStaticMode && fAll <= fAvailable + fEps;
+
+    var aResult = [];
+    var bRelocate = false;
+    var fUsed = oSizes ? oSizes.more : 0;
+    $.each(this._aItems, function(i, eItem) {
+        var bStatic = $(eItem).hasClass($this._sClassItemStatic);
+        var fShare = oSizes ? oSizes.items[i] : 0;
+
+        if(bAllFit || bStatic || (!$this._bStaticMode && !bRelocate && fUsed + fShare <= fAvailable + fEps)) {
+            aResult.push('1');
+            fUsed += fShare;
+            return;
+        }
+
+        aResult.push('0');
+        bRelocate = true;
+    });
+
+    return aResult.join('');
+};
+
+/**
+ * Put each item in the row or in 'more', keeping their order, and touch only the ones that change place.
+ */
+BxDolMenuMoreAuto.prototype._apply = function(sLayout)
+{
+    if(sLayout.length != this._aItems.length || sLayout == this._sLayout)
+        return;
+
+    var eMenu = this._oMenu.get(0);
+    var eSubmenu = this._oSubmenu.get(0);
+    var eRowNext = this._oItemMore.get(0) || null;
+    var eSubmenuNext = this._eSubmenuFirst;
+
+    for(var i = this._aItems.length - 1; i >= 0; i--) {
+        var eItem = this._aItems[i];
+
+        if(sLayout.charAt(i) == '1' || !eSubmenu) {
+            if(eItem.parentNode !== eMenu) {
+                this._styleForRow($(eItem));
+                eMenu.insertBefore(eItem, eRowNext); // eRowNext may be null (no 'more' item): append.
+            }
+            eRowNext = eItem;
+        }
+        else {
+            if(eItem.parentNode !== eSubmenu) {
+                this._styleForSubmenu($(eItem));
+                eSubmenu.insertBefore(eItem, eSubmenuNext);
+            }
+            eSubmenuNext = eItem;
+        }
+    }
+
+    // Shown and hidden at once: a jQuery show() animation would grow the item while the others move.
+    if(sLayout.includes('0'))
+        this._oItemMore.show();
+    else
+        this._oItemMore.hide();
+
+    this._sLayout = sLayout;
+};
+
+BxDolMenuMoreAuto.prototype._styleForRow = function(oItem)
+{
+    oItem.removeClass('bx-def-color-bg-hl-hover');
+    oItem.find('a.bx-nobtn').removeClass('bx-nobtn').addClass('bx-btn');
+    oItem.find('a.bx-nobtn-height').removeClass('bx-nobtn-height').addClass('bx-btn-height');
+};
+
+BxDolMenuMoreAuto.prototype._styleForSubmenu = function(oItem)
+{
+    oItem.addClass('bx-def-color-bg-hl-hover');
+    oItem.find('a.bx-btn').removeClass('bx-btn').addClass('bx-nobtn');
+    oItem.find('a.bx-btn-height').removeClass('bx-btn-height').addClass('bx-nobtn-height');
+};
+
+/**
+ * The remembered row is stored per window width, together with the item names it was computed for.
+ */
+BxDolMenuMoreAuto.prototype._getSignature = function()
+{
+    return $.map(this._aItems, function(eItem) {
+        return (eItem.className.match(/bx-menu-item-([\w-]+)/) || ['', ''])[1] + ':' + $(eItem).text().trim().length;
+    }).join(',');
+};
+
+BxDolMenuMoreAuto.prototype._recall = function()
+{
+    try {
+        var oStored = JSON.parse(window.localStorage.getItem(this._sStorageKey) || 'null');
+        if(!oStored || oStored.sig != this._getSignature() || !oStored.rows)
+            return '';
+
+        var sLayout = oStored.rows[window.innerWidth];
+        return typeof(sLayout) == 'string' && sLayout.length == this._aItems.length ? sLayout : '';
+    }
+    catch(e) {
+        return ''; // Storage unavailable (private mode, blocked) or corrupt: lay out from scratch.
+    }
+};
+
+BxDolMenuMoreAuto.prototype._remember = function(sLayout)
+{
+    try {
+        var sSignature = this._getSignature();
+        var oStored = JSON.parse(window.localStorage.getItem(this._sStorageKey) || 'null');
+        if(!oStored || oStored.sig != sSignature)
+            oStored = {sig: sSignature, rows: {}, order: []};
+
+        var sWidth = String(window.innerWidth);
+        if(oStored.rows[sWidth] == sLayout)
+            return;
+
+        oStored.rows[sWidth] = sLayout;
+        oStored.order = $.grep(oStored.order || [], function(s) { return s != sWidth; });
+        oStored.order.push(sWidth);
+        while(oStored.order.length > this._iStorageWidths)
+            delete oStored.rows[oStored.order.shift()];
+
+        window.localStorage.setItem(this._sStorageKey, JSON.stringify(oStored));
+    }
+    catch(e) {
+        // Storage unavailable or full: the row is only not remembered for the next page.
+    }
+};
+
+/**
+ * Where each shown item sits before an update, so the items that stay can glide to their new place
+ * (a centered menu shifts when an item moves in or out of 'more') instead of jumping.
+ */
+BxDolMenuMoreAuto.prototype._getPositions = function()
+{
+    var aResult = [];
+
+    this._oMenu.children(this._sClassItem + ':visible').each(function() {
+        aResult.push({item: this, left: this.getBoundingClientRect().left});
+    });
+
+    return aResult;
+};
+
+BxDolMenuMoreAuto.prototype._animateFrom = function(aBefore)
+{
+    if(!aBefore.length || typeof(Element.prototype.animate) != 'function' || (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches))
+        return;
+
+    var oOptions = {duration: 200, easing: 'cubic-bezier(0.2, 0, 0, 1)'};
+
+    this._oMenu.children(this._sClassItem + ':visible').each(function(iIndex, eItem) {
+        var oBefore = aBefore.find(function(o) {
+            return o.item === eItem;
+        });
+
+        if(!oBefore) {
+            eItem.animate([{opacity: 0}, {opacity: 1}], oOptions);
+            return;
+        }
+
+        var fShift = oBefore.left - eItem.getBoundingClientRect().left;
+        if(Math.abs(fShift) >= 1)
+            eItem.animate([{transform: 'translateX(' + fShift + 'px)'}, {transform: 'none'}], oOptions);
+    });
+};
+
+/** @} */

+ 147 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolVote.js

@@ -0,0 +1,147 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function BxDolVote(oOptions)
+{
+    if(typeof oOptions === 'undefined')
+        return;
+
+    this._sObjName = oOptions.sObjName === undefined ? 'oVote' : oOptions.sObjName; // javascript object name, to run current object instance from onTimer
+    this._sSystem = oOptions.sSystem; // current comment system
+    this._iAuthorId = oOptions.iAuthorId; // this comment's author ID.
+    this._iObjId = oOptions.iObjId; // this object id comments
+
+    this._sActionsUri = 'vote.php';
+    this._sActionsUrl = oOptions.sRootUrl + this._sActionsUri; // actions url address
+    this._sSocket = oOptions.sSocket === undefined ? this._sSystem : oOptions.sSocket;
+
+    this._sAnimationEffect = 'fade';
+    this._iAnimationSpeed = 'slow';
+    this._sSP = oOptions.sStylePrefix === undefined ? 'bx-vote' : oOptions.sStylePrefix;
+    this._aHtmlIds = oOptions.aHtmlIds;
+    this._aRequestParams = oOptions.aRequestParams;
+
+    var $this = this;
+    $(document).ready(function() {
+    	$this.init();
+    });
+}
+
+BxDolVote.prototype.init = function()
+{
+    var $this = this; 
+    $(document).ready(function() {
+        if(window.oBxDolSockets !== undefined && $this._sSocket)
+            oBxDolSockets.subscribe($this._sSocket, $this._iObjId, 'voted', function(oData) {
+                $this.onVoteAs(oData);
+            });
+    });
+
+    return;
+};
+
+BxDolVote.prototype.toggleByPopup = function(oLink)
+{
+    var oData = this._getDefaultParams();
+    oData['action'] = 'GetVotedBy';
+
+    $(oLink).dolPopupAjax({
+        id: this._aHtmlIds['by_popup'], 
+        url: bx_append_url_params(this._sActionsUri, oData)
+    });
+};
+
+BxDolVote.prototype.vote = function(oLink, iValue, onComplete)
+{
+    var $this = this;
+    var oParams = this._getDefaultParams();
+    oParams['action'] = 'Vote';
+    oParams['value'] = iValue;
+
+    $.post(
+    	this._sActionsUrl,
+    	oParams,
+    	function(oData) {
+            $this._cueOnVote(oData);
+
+            if(oData && oData.message != undefined)
+                bx_alert(oData.message, function() {
+                    $this.onVote(oLink, oData, onComplete);
+                });
+            else
+                $this.onVote(oLink, oData, onComplete);
+        },
+        'json'
+    );
+};
+
+/**
+ * The interface sound of the visitor's own vote: a like is a toggle, on when given and off when taken back.
+ */
+BxDolVote.prototype._cueOnVote = function(oData)
+{
+    if(typeof(bx_sound) != 'function' || !oData)
+        return;
+
+    if(oData.code != undefined && parseInt(oData.code) != 0)
+        bx_sound('error');
+    else
+        bx_sound('toggle', {direction: oData.voted ? 'forward' : 'back', group: 'reactions'});
+};
+
+BxDolVote.prototype.onVoteAs = function(oData)
+{
+    var oLink = $('#' + this._aHtmlIds['main']).find('.' + this._sSP + '-do-vote');
+    if(oLink && oLink.length)
+        this.onVote(oLink, JSON.parse(oData));
+};
+
+BxDolVote.prototype.onVote = function(oLink, oData, onComplete)
+{
+    if(oData && oData.code != 0)
+        return;
+
+    var oCounter = this._getCounter(oLink);
+    if(oCounter && oCounter.length > 0) {
+        oCounter.html(oData.countf);
+
+        oCounter.parents('.' + this._sSP + '-counter-holder:first').bx_anim(oData.count > 0 ? 'show' : 'hide');
+    }
+
+    if(typeof onComplete == 'function')
+        onComplete(oLink, oData);
+};
+
+BxDolVote.prototype._getCounter = function(oElement)
+{
+    if($(oElement).hasClass(this._sSP))
+        return $(oElement).find('.' + this._sSP + '-counter');
+    else 
+        return $(oElement).parents('.' + this._sSP + ':first').find('.' + this._sSP + '-counter');
+};
+
+BxDolVote.prototype._loadingInButton = function(e, bShow)
+{
+    if($(e).length)
+        bx_loading_btn($(e), bShow);
+    else
+        bx_loading($('body'), bShow);
+};
+
+BxDolVote.prototype._getDefaultParams = function() 
+{
+    var oDate = new Date();
+    return {
+        sys: this._sSystem,
+        id: this._iObjId,
+        params: $.param(this._aRequestParams),
+        _t: oDate.getTime()
+    };
+};
+
+/** @} */

+ 327 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolVoteReactions.js

@@ -0,0 +1,327 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function BxDolVoteReactions(oOptions)
+{
+    BxDolVote.call(this, oOptions);
+    this._bQuickMode = oOptions.bQuickMode === undefined ? 0 : oOptions.bQuickMode; // enable 'quick' mode - vote with default reaction when clicked.
+    this._bSingleMode = oOptions.bSingleMode === undefined ? 0 : oOptions.bSingleMode; // enable 'single' mode - vote with the only one available reaction when clicked.
+
+    this._iTimeoutShowId = 0;
+    this._iTimeoutShowDelay = 750;
+    this._iTimeoutHideId = 0;
+    this._iTimeoutHideDelay = 1000;
+    this._fOnVoteIn = null;
+    this._fOnVoteOut = null;
+    this._fOnDoPopupIn = null;
+    this._fOnDoPopupOut = null;
+
+    this._sClassDo = 'bx-vote-do-vote';
+    this._sClassDoVoted = 'bx-vote-voted';
+}
+
+BxDolVoteReactions.prototype = Object.create(BxDolVote.prototype);
+BxDolVoteReactions.prototype.constructor = BxDolVoteReactions;
+
+BxDolVoteReactions.prototype.init = function()
+{
+    BxDolVote.prototype.init.call(this);
+
+    var $this = this;
+
+    if(this._bQuickMode && !this._bSingleMode) {
+        if(bx_is_mouse())
+            $('#' + this._aHtmlIds['main'] + ' .' + this._sClassDo).hover(function() {
+                $this.onVoteIn(this);
+            }, function() {
+                $this.onVoteOut(this);
+            });
+        else
+            $('#' + this._aHtmlIds['main'] + ' .' + this._sClassDo).onLongTouch(function(oElement) {
+                $this.onTouch(oElement);
+            });
+    }
+};
+
+BxDolVoteReactions.prototype.vote = function(oLink, iValue, sReaction, onComplete)
+{
+    var $this = this;
+    var oParams = this._getDefaultParams();
+    oParams['action'] = 'Vote';
+    oParams['value'] = iValue;
+    oParams['reaction'] = sReaction;
+
+    if(this._iTimeoutShowId)
+        clearTimeout(this._iTimeoutShowId);
+
+    $('#' + this._aHtmlIds['do_popup']).dolPopupHide({});
+
+    $.post(
+    	this._sActionsUrl,
+    	oParams,
+    	function(oData) {
+            $this._cueOnVote(oData);
+
+            if(oData && oData.message != undefined)
+                bx_alert(oData.message, function() {
+                    $this.onVote(oLink, oData, onComplete);
+                });
+            else
+                $this.onVote(oLink, oData, onComplete);
+        },
+        'json'
+    );
+};
+
+BxDolVoteReactions.prototype.onVote = function (oLink, oData, onComplete)
+{
+    var $this = this;
+
+    if(oData && oData.code != 0)
+        return;
+
+    oLink = $('.' + this._aHtmlIds['main'] + ' .' + this._sClassDo);
+
+    //--- Update Do button.
+    oLink.each(function() {
+        if(oData && oData.label_use)
+            switch(oData.label_use) {
+                case 'icon':
+                    if($(this).find('.sys-action-do-icon .sys-icon').length && !$(oData.label_icon).hasClass('sys-icon'))
+                        $(this).find('.sys-action-do-icon .sys-icon').attr('class', 'sys-icon ' + oData.label_icon).html('');
+                    else
+                        $(this).find('.sys-action-do-icon').html(oData.label_icon);
+                    break;
+
+                case 'emoji':
+                    $(this).find('.sys-action-do-icon .sys-icon').attr('class', 'sys-icon sys-icon-emoji').html(oData.label_emoji);
+                    break;
+
+                case 'image':
+                    $(this).find('.sys-action-do-icon').html(oData.label_image);
+                    break;
+            }
+
+        if(oData && oData.label_title) {
+            $(this).attr('title', oData.label_title);
+            $(this).find('.sys-action-do-text').html(oData.label_title);
+        }
+
+        if(oData && oData.label_click)
+            $(this).attr('onclick', 'javascript:' + oData.label_click)
+
+        if(oData && oData.disabled)
+            $(this).removeAttr('onclick').addClass($(this).hasClass('bx-btn') ? 'bx-btn-disabled' : 'bx-vote-disabled');
+
+        if(oData && oData.voted != undefined)
+            $(this).toggleClass($this._sClassDoVoted, oData.voted);
+    });
+
+    //--- Update Counter.
+    var oCounter = this._getCounter(oLink);
+    if(oCounter && oCounter.length > 0) {
+        oCounter.filter('.' + oData.reaction).each(function() {
+            $(this).html(oData.countf).toggleClass('bx-vc-hidden', !oData.count);            
+        });
+
+        //--- Update Total.
+        if(oData.total)
+            oCounter.filter('.total-count').each(function() {
+                $(this).html(oData.total.countf).toggleClass('bx-vc-hidden', !oData.total.count);
+            });
+
+        //--- Show counter.
+        oCounter.filter('.' + oData.reaction).each(function() {
+            $(this).parents('.' + $this._sSP + '-counter-wrapper:first').toggleClass('bx-vc-hidden', !oData.count && !oData.total.count);
+        });
+    }
+
+    if(typeof onComplete == 'function')
+        onComplete(oLink, oData);
+};
+
+BxDolVoteReactions.prototype.onVoteIn = function(oLink)
+{
+    var $this = this;
+
+    if($(oLink).hasClass(this._sClassDoVoted))
+        return;
+
+    if(this._iTimeoutHideId)
+        clearTimeout(this._iTimeoutHideId);
+
+    var oPopup = this.getDoPopup();
+    if(oPopup !== false)
+        return;
+
+    this._iTimeoutShowId = setTimeout(function() {
+        $this.toggleDoPopup(oLink, $(oLink).attr('bx-vote-value'));
+    }, this._iTimeoutShowDelay);
+};
+
+BxDolVoteReactions.prototype.onVoteOut = function(oLink)
+{
+    if($(oLink).hasClass(this._sClassDoVoted))
+        return;
+
+    if(this._iTimeoutShowId)
+        clearTimeout(this._iTimeoutShowId);
+
+    this.hideDoPopup();
+};
+
+BxDolVoteReactions.prototype.onTouch = function(oLink)
+{
+    if($(oLink).hasClass(this._sClassDoVoted))
+        return;
+
+    var oPopup = this.getDoPopup();
+    if(oPopup !== false)
+        return;
+
+    this.toggleDoPopup(oLink, $(oLink).attr('bx-vote-value'), {
+        closeOnOuterClick: false
+    });
+};
+
+BxDolVoteReactions.prototype.getDoPopup = function()
+{
+    var oPopup = $('#' + this._aHtmlIds['do_popup'] + ':visible');
+    return oPopup.length > 0 && oPopup.hasClass('bx-popup-applied') ? oPopup : false;
+};
+
+BxDolVoteReactions.prototype.toggleDoPopup = function(oLink, iValue, oOptions)
+{
+    var $this = this;
+    var oParams = this._getDefaultParams();
+    oParams['action'] = 'GetDoVotePopup';
+
+    if(this._iTimeoutShowId)
+        clearTimeout(this._iTimeoutShowId);
+
+    oOptions = oOptions || {};
+    oOptions = $.extend({}, {
+        id: {value: this._aHtmlIds['do_popup'], force: true}, 
+        url: bx_append_url_params(this._sActionsUri, oParams),
+        value: iValue,
+        onShow: function(oPopup) {
+            $this.onDoPopupShow(oPopup);
+        },
+        onHide: function(oPopup) {
+            $this.onDoPopupHide(oPopup);
+        },
+        cssClass: 'bx-popup-vote-reactions ' + $this._sSystem.replaceAll('_', '-')
+    }, oOptions);
+
+    $(oLink).dolPopupAjax(oOptions);
+};
+
+BxDolVoteReactions.prototype.hideDoPopup = function()
+{
+    var oPopup = this.getDoPopup();
+    if(!oPopup)
+        return;
+
+    this._iTimeoutHideId = setTimeout(function() {
+        oPopup.dolPopupHide();
+    }, this._iTimeoutHideDelay);
+};
+
+BxDolVoteReactions.prototype.onDoPopupShow = function(oPopup)
+{
+    var $this = this;
+
+    this._fOnDoPopupIn = function() {
+        $this.onDoPopupIn(this);
+    };
+
+    this._fOnDoPopupOut = function() {
+        $this.onDoPopupOut(this);
+    };
+
+    $(oPopup).hover(this._fOnDoPopupIn, this._fOnDoPopupOut);
+};
+
+BxDolVoteReactions.prototype.onDoPopupHide = function(oPopup)
+{
+    $(oPopup).unbind('mouseenter', this._fOnDoPopupIn).unbind('mouseleave', this._fOnDoPopupOut);
+};
+
+BxDolVoteReactions.prototype.onDoPopupIn = function(oPopup)
+{
+    if(this._iTimeoutHideId)
+        clearTimeout(this._iTimeoutHideId);
+};
+
+BxDolVoteReactions.prototype.onDoPopupOut = function(oPopup)
+{
+    this.hideDoPopup();
+};
+
+BxDolVoteReactions.prototype.toggleByPopup = function(oLink, sReaction)
+{
+    var oParams = this._getDefaultParams();
+    oParams['action'] = 'GetVotedBy';
+    if(sReaction)
+        oParams['reaction'] = sReaction;
+
+    $(oLink).dolPopupAjax({
+        id: {value: this._aHtmlIds['by_popup'], force: true}, 
+        url: bx_append_url_params(this._sActionsUri, oParams),
+        removeOnClose: true
+    });
+};
+
+BxDolVoteReactions.prototype.changeVotedBy = function(oLink, sReaction)
+{
+    var oContent = $('#' + this._aHtmlIds['by_popup'] + ' .' + this._sSP + '-bls-content');
+    if(oContent.length > 0)
+        oContent.children(':visible').hide().siblings('.' + this._sSP + '-bl-' + sReaction).show();
+};
+
+BxDolVoteReactions.prototype._getCounter = function(oElement)
+{
+    var oCounter = BxDolVote.prototype._getCounter.call(this, oElement);
+    if(oCounter && oCounter.length > 0)
+        return oCounter;
+
+    return $('.' + this._aHtmlIds['counter']).find('.' + this._sSP + '-counter');
+};
+
+(function($) {
+    $.fn.onLongTouch = function(fCallback) {
+        return this.each(function() {
+            var oSource = this;
+            var iTimeoutId = null;
+
+            this.addEventListener('touchstart', function(e) {
+                iTimeoutId = setTimeout(function() {
+                    iTimeoutId = null;
+                    e.stopPropagation();
+                    fCallback(oSource);
+                }, 500);
+            });
+
+            this.addEventListener('contextmenu', function(e) {
+                e.preventDefault();
+            });
+
+            this.addEventListener('touchend', function() {
+                if(iTimeoutId) 
+                    clearTimeout(iTimeoutId);
+            });
+
+            this.addEventListener('touchmove', function() {
+                if(iTimeoutId) 
+                    clearTimeout(iTimeoutId);
+            });
+        });
+    };
+})(jQuery);
+
+/** @} */

+ 123 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/classes/BxDolVoteStars.js

@@ -0,0 +1,123 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function BxDolVoteStars(oOptions)
+{
+    BxDolVote.call(this, oOptions);
+
+    this._iSaveWidth = -1;
+}
+
+BxDolVoteStars.prototype = Object.create(BxDolVote.prototype);
+BxDolVoteStars.prototype.constructor = BxDolVoteStars;
+
+BxDolVoteStars.prototype.init = function ()
+{
+    BxDolVote.prototype.init.call(this);
+
+    var $this = this;
+
+    $('.' + this._sSP + '.' + this._sSP + '-stars').each(function() {
+        var oDoVote = $(this);
+        var fRate = oDoVote.attr('bx_vote_data_rate');
+        var iStarWidth = $this._getStarWidthDo(oDoVote);
+
+        $this._getSliderDo(oDoVote).width(Math.round(fRate * iStarWidth));
+    });
+
+    $('.' + this._sSP + '-legend.' + this._sSP + '-legend-stars').each(function() {
+        var oLegend = $(this);
+        var iStarWidth = $this._getStarWidthLegend(oLegend);
+
+        oLegend.find('.' + $this._sSP + '-legend-item').each(function() {
+            var oItem = $(this);
+
+            oItem.find('.' + $this._sSP + '-slider').width(parseInt(oItem.attr('bx_vote_item_value')) * iStarWidth); 
+        });
+    });
+};
+
+/**
+ * Rating is choosing a value: a select, and a toggle going off when the rating is taken back.
+ */
+BxDolVoteStars.prototype._cueOnVote = function(oData)
+{
+    if(typeof(bx_sound) != 'function' || !oData)
+        return;
+
+    if(oData.code != undefined && parseInt(oData.code) != 0)
+        bx_sound('error');
+    else if(oData.voted)
+        bx_sound('select', {group: 'reactions'});
+    else
+        bx_sound('toggle', {direction: 'back', group: 'reactions'});
+};
+
+BxDolVoteStars.prototype.onVote = function (oLink, oData, onComplete)
+{
+    if(oData && oData.code != 0)
+        return;
+
+    this._iSaveWidth = Math.round(oData.rate * this._getStarWidthDo(oLink));
+
+    BxDolVote.prototype.onVote.call(this, oLink, oData, onComplete);
+};
+
+BxDolVoteStars.prototype.over = function (oLink)
+{
+    var oSlider = this._getSliderDo(oLink);
+    var iIndex = this._getButtons(oLink).index(oLink);
+
+    this._iSaveWidth = parseInt(oSlider.width());
+    oSlider.width((iIndex + 1) * this._getStarWidthDo(oLink));
+};
+
+BxDolVoteStars.prototype.out = function (oLink)
+{
+	var oSlider = this._getSliderDo(oLink);
+
+	oSlider.width(this._iSaveWidth);
+};
+
+BxDolVoteStars.prototype._getButtons = function(oElement)
+{
+    if($(oElement).hasClass(this._sSP))
+        return $(oElement).find('.' + this._sSP + '-button');
+    else
+        return $(oElement).parents('.' + this._sSP + ':first').find('.' + this._sSP + '-button');
+};
+
+BxDolVoteStars.prototype._getSliderDo = function(oElement)
+{
+    return this._getSlider(oElement, '.' + this._sSP + '-do');
+};
+
+BxDolVoteStars.prototype._getSliderLegend = function(oElement) 
+{
+    return this._getSlider(oElement, '.' + this._sSP + '-legend');
+};
+
+BxDolVoteStars.prototype._getSlider = function(oElement, sParent) {
+    var sSlider = (sParent.length > 0 ? sParent + ' ' : '') + '.' + this._sSP + '-slider';
+    if($(oElement).hasClass(this._sSP))
+        return $(oElement).find(sSlider);
+    else
+        return $(oElement).parents('.' + this._sSP + ':first').find(sSlider);
+};
+
+BxDolVoteStars.prototype._getStarWidthDo = function(oElement)
+{
+    return this._getSliderDo(oElement).find('.sys-icon').width();
+};
+
+BxDolVoteStars.prototype._getStarWidthLegend = function(oElement)
+{
+    return this._getSliderLegend(oElement).find('.sys-icon').width();
+};
+
+/** @} */

+ 2362 - 0
upgrade/files/15.0.0.RC2-15.0.0.RC3/files/inc/js/functions.js

@@ -0,0 +1,2362 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+function processJsonData(oData) {
+	var fContinue = function(oData) {
+		if(oData && oData.reload != undefined && parseInt(oData.reload) == 1)
+	    	document.location = document.location;
+
+	    if(oData && oData.redirect != undefined && oData.redirect.length != 0)
+	    	document.location = oData.redirect;
+
+	    if(oData && oData.popup != undefined) {
+	    	var oPopup = null;
+	    	var oOptions = {
+	            fog: {
+					color: '#fff',
+					opacity: .7
+	            },
+	            closeOnOuterClick: false
+	        };
+
+	    	if(typeof(oData.popup) == 'object') {
+	    		oOptions = $.extend({}, oOptions, oData.popup.options);
+	    		oPopup = $(oData.popup.html);
+	    	}
+	    	else 
+	    		oPopup = $(oData.popup);
+
+            if ('undefined' !== typeof(bx_editor_remove_all))
+                bx_editor_remove_all($('#' + oPopup.attr('id')));
+            
+	    	$('#' + oPopup.attr('id')).remove();
+	        oPopup.hide().prependTo('body').bxProcessHtml().dolPopup(oOptions);
+	    }
+
+	    if(oData && oData.form != undefined && oData.form_id != undefined)
+	    	$('form#' + oData.form_id).replaceWith(oData.form).bxProcessHtml();
+
+	    if (oData && oData.eval != undefined)
+	        eval(oData.eval);
+	};
+
+	// A reply can name its interface sound: 'error', or {cue: 'success', emphasis: 'subtle'}. It plays now, before any alert.
+	if(oData && oData.cue != undefined)
+		bx_sound(typeof(oData.cue) == 'object' ? oData.cue.cue : oData.cue, typeof(oData.cue) == 'object' ? oData.cue : undefined);
+
+	if(oData && oData.object != undefined && oData.grid != undefined)
+		if(glGrids[oData.object] != undefined)
+			glGrids[oData.object].processJson(oData);
+
+	if(oData && oData.message != undefined && oData.message.length != 0)
+		bx_alert(oData.message, function() {
+			fContinue(oData);
+		});
+	else if(oData && oData.msg != undefined && oData.msg.length != 0)
+		bx_alert(oData.msg, function() {
+			fContinue(oData);
+		});
+    else if(oData && oData.toast != undefined && oData.toast.length != 0) {
+        bx_toast(oData.toast);
+        fContinue(oData);
+    }
+	else
+		fContinue(oData);
+};
+
+function getHtmlData( elem, url, callback, method, confirmation, values, loading)
+{
+    var fPerform = function() {
+		// in most cases it is element ID, in other cases - object of jQuery
+		if (typeof elem == 'string')
+		    elem = '#' + elem; // create selector from ID
+
+		var $block = $(elem);
+
+		var blockPos = $block.css('position');
+
+		$block.css('position', 'relative'); // set temporarily for displaying "loading icon"
+
+        if ('undefined' === typeof(loading) || loading) {
+    		bx_loading_content($block, true);
+	    	var $loadingDiv = $block.find('.bx-loading-ajax');
+
+    		var iLeftOff = parseInt(($block.innerWidth() / 2.0) - ($loadingDiv.outerWidth()  / 2.0));
+	    	var iTopOff  = parseInt(($block.innerHeight() / 2.0) - ($loadingDiv.outerHeight()));
+		    if (iTopOff<0) iTopOff = 0;
+
+    		$loadingDiv.css({
+	    	    position: 'absolute',
+		        left: iLeftOff,
+    		    top:  iTopOff
+	    	});
+        }
+
+		if (undefined != method && (method == 'post' || method == 'POST')) {
+            if (typeof values == 'undefined')
+                values = {};
+		    $.post(url, values, function(data) {
+		        $block.html(data);
+		        $block.css('position', blockPos).bxProcessHtml();
+
+		        if (typeof callback == 'function')
+		            callback.apply($block);
+		    });		
+		} 
+		else {
+		    $block.load(url + '&_r=' + Math.random(), function() {
+		        $(this).css('position', blockPos).bxProcessHtml();
+
+		        if (typeof callback == 'function')
+		            callback.apply(this);
+		    });
+		}
+    };
+
+    if (typeof(confirmation) != 'undefined' && confirmation)
+    	bx_confirm(_t('_Are_you_sure'), fPerform);
+    else 
+    	fPerform();
+}
+
+function loadDynamicBlockAutoPaginate (e, iStart, iPerPage, sAdditionalUrlParams, sStartParamName, sPerPageParamName) {
+
+    sUrl = location.href;
+    sStartParamName = typeof sStartParamName !== 'undefined' ?  sStartParamName : 'start';
+    sPerPageParamName = typeof sPerPageParamName !== 'undefined' ?  sPerPageParamName : 'per_page';
+
+    sUrl = sUrl.replace(/start=\d+/, '').replace(/per_page=\d+/, '').replace(/[&\?]+$/, '');
+    sUrl = bx_append_url_params(sUrl, sStartParamName + '=' + parseInt(iStart) + '&' + sPerPageParamName + '=' + parseInt(iPerPage));
+    if ('undefined' != typeof(sAdditionalUrlParams))
+        sUrl = bx_append_url_params(sUrl, sAdditionalUrlParams);
+
+	if ($(e).parents('.bx-search-result-block-pagination').length > 0){
+		$([document.documentElement, document.body]).animate({
+			scrollTop: $(e).parents('.bx-search-result-block-pagination').first().offset().top
+		}, 500);
+	}
+    return loadDynamicBlockAuto(e, sUrl);
+}
+
+function loadDynamicBlockAutoSort (e, sort, sAdditionalUrlParams) {
+
+    sUrl = location.href;
+    console.log(sort);
+
+    sUrl = sUrl.replace(/sort=\d+/, '').replace(/[&\?]+$/, '');
+    sUrl = bx_append_url_params(sUrl, 'sort' + '=' + sort);
+    if ('undefined' != typeof(sAdditionalUrlParams))
+        sUrl = bx_append_url_params(sUrl, sAdditionalUrlParams);
+
+    if ($(e).parents('.bx-search-result-block-pagination').length > 0){
+        $([document.documentElement, document.body]).animate({
+            scrollTop: $(e).parents('.bx-search-result-block-pagination').first().offset().top
+        }, 500);
+    }
+    return loadDynamicBlockAuto(e, sUrl);
+}
+
+/**
+ * This function reloads page block automatically, 
+ * just provide any element inside the block and this function will reload the block.
+ * @param e - element inside the block
+ * @return true on success, or false on error - particularly, if block isn't found
+ */
+function loadDynamicBlockAuto(e, sUrl, oPostData) {
+    var oContainer = $(e).parents('.bx-page-block-container:first:not(.no-dynamic)');
+    var sId = oContainer.attr('id');
+
+    if ('undefined' == typeof(sUrl))
+        sUrl = location.href;
+
+    if (!sId || !sId.length)
+        return false;
+
+    var aMatches = sId.match(/\-(\d+)$/);
+    if (!aMatches || !aMatches[1])
+        return false;
+
+    var oPaginate = oContainer.find('.bx-paginate'); 
+    if(oPaginate.length > 0) {
+    	var iStart = parseInt(oPaginate.attr('bx-data-start'));
+    	var iPerPage = parseInt(oPaginate.attr('bx-data-perpage'));
+
+    	var oParams = {};
+    	if(!isNaN(iStart) && sUrl.indexOf('start') == -1)
+    		oParams.start = iStart;
+    	if(!isNaN(iPerPage) && sUrl.indexOf('per_page') == -1)
+    		oParams.per_page = iPerPage;
+
+    	sUrl = bx_append_url_params(sUrl, oParams);
+    }
+
+    loadDynamicBlock(parseInt(aMatches[1]), sUrl, oPostData);
+    return true;
+}
+
+/**
+ * Reload the current page block to show or hide a form secret field.
+ * Reveal is POSTed with a CSRF token so it cannot be triggered by a GET param alone.
+ * When the form is not inside a page block, the whole page is reloaded via POST.
+ */
+function bx_form_secret_toggle(e) {
+    var oToggle = $(e);
+    var bHide = oToggle.closest('.bx-form-secret').hasClass('bx-form-secret-shown');
+    var sField = oToggle.attr('bx_form_secret_field');
+    var sCsrfToken = oToggle.attr('bx_form_secret_csrf');
+
+    var sUrl = location.href;
+    try {
+        var oUrl = new URL(sUrl);
+        oUrl.searchParams.delete('form_secret_reveal');
+        oUrl.searchParams.delete('csrf_token');
+        sUrl = oUrl.toString();
+    } catch (oErr) {
+        sUrl = sUrl.replace(/([?&])form_secret_reveal=[^&]*/g, '$1').replace(/([?&])csrf_token=[^&]*/g, '$1').replace(/[?&]$/, '');
+    }
+
+    if (bHide) {
+        if (!loadDynamicBlockAuto(e, sUrl))
+            location.href = sUrl;
+        return false;
+    }
+
+    var oData = {form_secret_reveal: sField};
+    if (sCsrfToken)
+        oData.csrf_token = sCsrfToken;
+
+    return loadDynamicBlockAuto(e, sUrl, oData);
+}
+
+function loadDynamicBlock(iBlockID, sUrl, oPostData) {
+    
+    var oCallback = null;
+    if($('#bx-page-block-' + iBlockID + ' .bx-base-unit-showcase-wrapper').length){
+        oCallback = bx_showcase_view_init;
+    }
+    if (typeof oPostData != 'undefined' && oPostData)
+        getHtmlData($('#bx-page-block-' + iBlockID), bx_append_url_params(sUrl, 'dynamic=tab&pageBlock=' + iBlockID), oCallback, 'post', false, oPostData);
+    else
+        getHtmlData($('#bx-page-block-' + iBlockID), bx_append_url_params(sUrl, 'dynamic=tab&pageBlock=' + iBlockID), oCallback);
+    return true;
+}
+
+function loadDynamicPopupBlock(iBlockID, sUrl) {
+    if (!$('#dynamicPopup').length) {
+        $('<div id="dynamicPopup" style="display:none;"></div>').prependTo('body');
+    }
+
+    $('#dynamicPopup').load(
+        (sUrl + '&dynamic=popup&pageBlock=' + iBlockID),
+        function() {
+            $(this).dolPopup({
+                left: 0,
+                top: 0
+            });
+        }
+    );
+}
+
+function closeDynamicPopupBlock() {
+    $('#dynamicPopup').dolPopupHide();
+}
+
+function loadDynamicBlockAsync(iBlockID, sUrl, fCallback, bLoading, bLazyLoad) {
+    if(!sUrl)
+        sUrl = document.location.href;
+
+    var fLoadBlock = function(iBlockID, sUrl) {
+        if(fCallback == undefined || fCallback == true)
+            fCallback = onAsyncBlockLoad;
+
+        getHtmlData($('#bx-page-block-' + iBlockID), bx_append_url_params(sUrl, 'dynamic=tab&pageBlock=' + iBlockID), fCallback, 'post', false, {
+            includedCss: JSON.stringify(aIncludedCss), 
+            includedJs: JSON.stringify(aIncludedJs)
+        }, (bLoading != undefined && bLoading == true));
+    };
+
+    if(bLazyLoad == undefined || bLazyLoad == true) {
+        const oObserver = new IntersectionObserver((aEntries, obs) => {
+            aEntries.forEach(oEntry => {
+                if(oEntry.isIntersecting) {
+                    const oElement = oEntry.target;
+
+                    fLoadBlock(iBlockID, sUrl);
+
+                    obs.unobserve(oElement);
+                }
+            });
+        }, {
+            root: null,        // viewport
+            threshold: 0.1     // trigger when 10% visible
+        });
+
+        document.querySelectorAll('.bx-db-placeholder-async').forEach(oElement => {
+            oObserver.observe(oElement);
+        });
+    }
+    else
+        $(document).ready(function() {
+            fLoadBlock(iBlockID, sUrl);
+        });
+}
+
+function onAsyncBlockLoad() {
+    var oBlock = $(this);
+
+    if(!oBlock.html()) 
+        oBlock.remove();
+}
+
+/**
+ * Translate string
+ */
+function _t(s, arg0, arg1, arg2) {
+    if (!window.aDolLang || !aDolLang[s])
+        return s;
+
+    cs = aDolLang[s];
+    cs = cs.replace(/\{0\}/g, arg0);
+    cs = cs.replace(/\{1\}/g, arg1);
+    cs = cs.replace(/\{2\}/g, arg2);
+    return cs;
+}
+
+function showPopupAnyHtml(sUrl, sId) {
+
+    var oPopupOptions = {};
+
+    if (!sId || !sId.length)
+        sId = 'login_div';
+
+    $('#' + sId).remove();
+    $('<div id="' + sId + '" style="display: none;"></div>').prependTo('body').load(
+        sUrl.match('^http[s]{0,1}:\/\/') ? sUrl : sUrlRoot + sUrl,
+        function() {
+            $(this).dolPopup(oPopupOptions);
+        }
+    );
+}
+
+function bx_loading_svg(sType, sClass) {
+	sClass = sClass != undefined && sClass.length > 0 ? sClass : '';
+
+	if(sUseSvgLoading != undefined && sUseSvgLoading.length > 0)
+		return sUseSvgLoading.replace(new RegExp('__type__','g'), sType).replace(new RegExp('__class__','g'), sClass);
+
+	var s = '';
+	s += '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 520 520" class="' + sClass + '" style="enable-background:new 0 0 520 520;" xml:space="preserve">';
+	s += '<g class="' + sType + '">';
+	s += '<g class="inner">';
+	s += '<path class="p1" d="M152,260C152,260,152,260,152,260c0-59.8,48.4-108.4,108.1-108.4c43.3,0,80.9,25.6,97.9,62.4V107.3c-28-18.3-61.9-28.9-98.1-28.9C159.8,78.4,78.6,159.9,78.6,260c0,59.5,28.4,112.4,73.4,145.6V260z"/>';
+	s += '<path class="p2" d="M368,114.4V260c0,59.8-48.4,108.4-108.1,108.4c-43.3,0-80.9-25.6-97.9-62.4v106.7c28,18.3,61.9,28.9,98.1,28.9c100.1,0,181.3-81.4,181.3-181.6C441.4,200.5,413,147.6,368,114.4z"/>';
+	s += '</g>';
+	s += '<g class="outer">';
+	s += '<path class="p1" d="M146.9,106.7c-8.1-15-36.9-29.9-68.9-27.3v124.2C90,164.3,114.6,130.5,146.9,106.7z"/>';
+	s += '<path class="p2" d="M373.1,413.3c8.1,15,36.7,29.9,68.9,27.3V316.4C429.8,355.7,405.4,389.5,373.1,413.3z"/>';
+	s += '</g>';
+	s += '</g>';
+	s += '</svg>';
+	return s;
+}
+
+function bx_loading_animate (e, aOptions) {
+    e = $(e);
+    if (!e.length)
+        return false;
+    if (e.find('.bx-sys-spinner').length)
+        return false;
+    return new Spinner(aOptions).spin(e.get(0));
+}
+
+function bx_loading_btn (oElement, bEnable) {
+    var oButton = $(oElement);
+    var sClassHeight = oButton.hasClass('bx-btn-small') ? 'bx-btn-small-height' : 'bx-btn-height';
+
+    if (oButton.children('div').size())
+        oButton = oButton.children('div').first();
+
+    if(!bEnable)
+    	oButton.find('.bx-loading-ajax-btn').remove();
+    else if (!oButton.find('.bx-loading-ajax-btn').length)
+    	oButton.append('<b class="bx-loading-ajax-btn ' + sClassHeight + '">' + (bUseSvgLoading ? bx_loading_svg('colored', sClassHeight) : '') + '</b>');
+
+    if(!bUseSvgLoading)
+    	bx_loading_animate(oButton.find('.bx-loading-ajax-btn'), aSpinnerSmallOpts);    
+}
+
+function bx_loading_content (oElement, bEnable, isReplace) {
+    var oBlock = $(oElement);
+    var oLoading = $('<div class="bx-loading-ajax bx-def-z-index-front">' + (bUseSvgLoading ? bx_loading_svg('colored') : '') + '</div>');
+    
+    if(!bEnable)
+    	oBlock.find(".bx-loading-ajax").remove();
+    else if(!oBlock.find('.bx-loading-ajax').length) {
+        if('undefined' != typeof(isReplace) && isReplace)
+            oBlock.html(oLoading.addClass('static'));
+        else
+            oBlock.append(oLoading);
+
+        if(!bUseSvgLoading)
+        	bx_loading_animate(oBlock.find('.bx-loading-ajax'), aSpinnerOpts);
+    } 
+}
+
+function bx_loading (elem, b) {
+
+    if (typeof elem == 'string')
+        elem = '#' + elem;
+
+    var block = $(elem);
+
+    if (block.hasClass('bx-btn')) {
+        bx_loading_btn (block, b);
+        return;
+    }
+
+    if (1 == b || true == b) {
+
+        bx_loading_content(block, b);
+
+        e = block.find(".bx-loading-ajax");
+        e.css('left', parseInt(block.width()/2.0 - e.width()/2.0));
+
+        var he = e.outerHeight();
+        var hc = block.outerHeight();
+
+        if (block.css('position') != 'relative' && block.css('position') != 'absolute') {
+            if (!block.data('css-save-position'))
+                block.data('css-save-position', block.css('position'));
+            block.css('position', 'relative');
+        }
+
+        if (hc > he) {
+            e.css('top', parseInt(hc/2.0 - he/2.0));
+        }
+
+        if (hc < he) {
+            if (!block.data('css-save-min-height'))
+                block.data('css-save-min-height', block.css('min-height'));
+            block.css('min-height', he);
+            e.css('top', 0);
+        }
+
+    } else {
+
+        if (block.data('css-save-position'))
+            block.css('position', block.data('css-save-position'));
+
+        if (block.data('css-save-min-height'))
+            block.css('min-height', block.data('css-save-min-height'));
+
+        bx_loading_content(block, b);
+
+    }
+
+}
+
+
+/**
+ * Center content with floating blocks.
+ * sSel - jQuery selector of content to be centered
+ * sBlockSel - jquery selector of blocks
+ */
+function bx_center_content (sSel, sBlockStyle, bListenOnResize) {
+
+    var sId;
+    if ($(sSel).parent().hasClass('bx-center-content-wrapper')) {
+        sId = $(sSel).parent().attr('id');
+    } else {
+        sId = 'id' + (new Date()).getTime();
+        $(sSel).wrap('<div id="'+sId+'" class="bx-center-content-wrapper"></div>');
+    }
+
+    var eCenter = $('#' + sId);
+    var iAll = $('#' + sId + ' ' + sBlockStyle).size();
+    var iWidthUnit = $('#' + sId + ' ' + sBlockStyle + ':first').outerWidth(true);
+    var iWidthContainer = eCenter.innerWidth();           
+    var iPerRow = parseInt(iWidthContainer/iWidthUnit);
+    var iLeft = (iWidthContainer - (iAll > iPerRow ? iPerRow * iWidthUnit : iAll * iWidthUnit)) / 2;
+
+    if (iWidthUnit > iWidthContainer)
+        return;
+
+    if ('undefined' != typeof(bListenOnResize) && bListenOnResize) {
+        $(window).on('resize.bx-center-content', function () {
+            bx_center_content(sSel, sBlockStyle);
+        });
+    }
+
+    eCenter.css("padding-left", iLeft);
+}
+
+/**
+ * Show pointer popup with menu from URL.
+ * @param o - menu object name
+ * @param e - element to show popup at
+ * @param options - popup options
+ * @param vars - additional GET variables
+ */
+function bx_menu_popup (o, e, options, vars) {
+    var options = options || {};
+    var vars = vars || {};
+
+    if ('undefined' == typeof(e))
+        e = window;
+
+    //--- Only a trigger that names no target of its own is this popup's disclosure. A trigger carrying
+    //--- aria-controls belongs to whatever it names - a sidebar, the search box - and that owner keeps
+    //--- its state; writing to it from here would leave it describing the wrong thing.
+    var bExpanded = e !== window && $(e).length != 0 && $(e).is('[aria-expanded]') && !$(e).is('[aria-controls]');
+    var fOnShow = options.onShow, fOnHide = options.onHide;
+
+    // a string onShow is handed back to dolPopup, which evaluates it; a string onHide has never been
+    // supported on this path, since dolPopupAjax replaces onHide before dolPopup sees it
+    var aExpanded = {};
+    if(typeof fOnShow != 'string')
+        aExpanded.onShow = function(oPopup) {
+            if(bExpanded)
+                $(e).attr('aria-expanded', 'true');
+
+            // remembered on the popup: the hide handler dolPopup keeps is the one from the first show, whichever trigger opened it since
+            oPopup.data('bx-popup-trigger', bExpanded ? $(e) : null);
+
+            if(typeof fOnShow == 'function')
+                fOnShow(oPopup);
+        };
+
+    if(typeof fOnHide != 'string')
+        aExpanded.onHide = function(oPopup) {
+            var oTrigger = oPopup.data('bx-popup-trigger');
+            if(oTrigger)
+                oTrigger.attr('aria-expanded', 'false');
+
+            if(typeof fOnHide == 'function')
+                fOnHide(oPopup);
+        };
+
+    var o = $.extend({}, $.fn.dolPopupDefaultOptions, {
+        id: o,
+        url: bx_append_url_params('menu.php', $.extend({o:o}, vars)),
+        cssClass: 'bx-popup-menu'
+    }, options, aExpanded);
+
+    $(e).dolPopupAjax(o);
+}
+
+/**
+ * Show pointer popup with menu from existing HTML.
+ * @param jSel - jQuery selector for html to show in popup
+ * @param e - element to show popup at
+ * @param options - popup options
+ */
+function bx_menu_popup_inline (jSel, e, options) {
+    if ($(jSel + ':visible').length) 
+        $(jSel).dolPopupHide(); 
+    else {
+        var options = options || {};
+
+        var bElement = e != undefined && $(e).length != 0;
+
+        var bExpanded = bElement && $(e).is('[aria-expanded]');
+        var fOnShow = options.onShow, fOnHide = options.onHide;
+
+        var o = $.extend({}, $.fn.dolPopupDefaultOptions, options, {
+            pointer: bElement ? {el:$(e)} : false, 
+            cssClass: 'bx-popup-menu',
+            onShow: function(oPopup) {
+                if(bExpanded)
+                    $(e).attr('aria-expanded', 'true');
+
+                // remembered on the popup: the hide handler dolPopup keeps is the one from the first show, whichever trigger opened it since
+                oPopup.data('bx-popup-trigger', bExpanded ? $(e) : null);
+
+                oPopup.find('a').each(function () {
+                    $(this).off('click.bx-popup-menu');
+                    $(this).on('click.bx-popup-menu', function() {
+                        $(jSel).dolPopupHide();
+                    });
+                });
+
+                // keyboard focus leaving the menu (Shift+Tab from its first item) closes it, as Escape and an outer click do
+                oPopup.off('focusout.bx-popup-menu').on('focusout.bx-popup-menu', function(oEvent) {
+                    var oTarget = oEvent.relatedTarget;
+                    if(!oTarget || oPopup.get(0).contains(oTarget) || (bElement && $(e).get(0).contains && $(e).get(0).contains(oTarget)))
+                        return;
+
+                    $(jSel).dolPopupHide();
+                });
+
+                if(typeof fOnShow == 'function')
+                    fOnShow(oPopup);
+            },
+            onHide: function(oPopup) {
+                oPopup.off('focusout.bx-popup-menu');
+
+                var oTrigger = oPopup.data('bx-popup-trigger');
+                if(oTrigger && oTrigger.length)
+                    oTrigger.attr('aria-expanded', 'false');
+
+                if(typeof fOnHide == 'function')
+                    fOnHide(oPopup);
+            },
+        });
+
+        $(jSel).dolPopup(o);
+    }
+}
+
+/**
+ * Show pointer popup with menu from URL.
+ * @param sObject - menu object name
+ * @param oElement - element to show popup at
+ * @param oOptions - popup options
+ * @param oVars - additional GET variables
+ */
+function bx_menu_slide (sObject, oElement, sPosition, oOptions, oVars) {
+    var oVars = oVars || {};
+    var oOptions = oOptions || {};
+    oOptions = $.extend({}, {parent: 'body', container: '.bx-sliding-menu-content'}, oOptions);
+
+    var sId = '';
+    var sIdSel = '';
+    var sIdPfx = 'bx-sliding-menu-wrapper-';
+    if(typeof(oOptions.id) != 'undefined')
+    	switch(typeof(oOptions.id)) {
+    		case 'string':
+    			sId = sIdPfx + oOptions.id;
+    			break;
+
+    		case 'object':
+    			sId = typeof(oOptions.id.force) != 'undefined' && oOptions.id.force ? oOptions.id.value : sIdPfx + oOptions.id.value;
+    			break;
+    	}
+    else
+    	sId = sIdPfx + parseInt(2147483647 * Math.random());
+
+    sIdSel = '#' + sId;
+    
+    //--- If slider exists
+    if ($(sIdSel).length) {
+    	bx_menu_slide_inline (sIdSel, oElement, sPosition);
+    	return;
+    }
+    //--- If slider doesn't exists
+    else {
+    	var oMenuLoading = $('#bx-sliding-menu-loading');
+        if(!oMenuLoading || !oMenuLoading.length)
+            return;
+
+    	$('<div id="' + sId + '" style="display:none;">' + oMenuLoading.html() + '</div>').addClass(oMenuLoading.attr('class')).appendTo(oOptions.parent);
+
+        var oLoading = $(sIdSel + ' .bx-sliding-menu-loading');
+        bx_loading_content(oLoading, true, true);
+
+        bx_menu_slide_inline (sIdSel, oElement, sPosition);
+        
+        var fOnLoad = function() {
+        	bx_loading_content(oLoading, false);
+
+        	$(sIdSel).bxProcessHtml().show();
+        };
+
+        $(sIdSel).find(oOptions.container).load(bx_append_url_params('menu.php', $.extend({o:sObject}, oVars)), function () {
+            var f = function () {
+            	if($(sIdSel).find('img').length > 0 && !$(sIdSel).find('img').get(0).complete)
+            		$(sIdSel).find('img').load(fOnLoad);
+            	else
+            		fOnLoad();
+            };
+            setTimeout(f, 100); // TODO: better way is to check if item is animating before positioning it in the code where popup is positioning
+        });
+    }
+    	
+}
+
+/**
+ * Show sliding menu from existing HTML.
+ * @param sMenu - jQuery object or selector for html to show in popup
+ * @param e - element to click to open/close slider
+ * @param sPosition - 'block' for sliding menu in blocks, 'site' - for sliding main menu
+ */
+function bx_menu_slide_inline (sMenu, e, sPosition) {
+    var options = options || {};
+    var eSlider = $(sMenu);
+
+    if ('undefined' !== typeof (bx_menu_slide_inline_custom)) {
+        return bx_menu_slide_inline_custom(sMenu, e, sPosition);
+    }
+
+    if ('undefined' == typeof(e))
+        e = eSlider.data('data-control-btn');
+
+    var eIcon = $(e).find('.sys-icon-a');    
+
+    var fPositionElement = function (oElement) {
+        eSlider.css({
+            position: 'absolute',
+            top: oElement.outerHeight(true),
+            left: 0
+        });
+    };
+
+    var fPositionBlock = function () {
+        var eBlock = eSlider.parents('.bx-page-block-container');
+        eSlider.css({
+            position: 'absolute',
+            top: eBlock.find('.bx-db-header').outerHeight(true),
+            left: 0
+            //width: eBlock.width() TODO: Commited to test. Remove if everything is OK.
+        });
+    };
+
+    var fPositionSite = function () {
+        var eToolbar = $('#bx-toolbar');
+        eSlider.css({
+            position: 'fixed',
+            top: eToolbar.outerHeight(true) + (typeof iToolbarSubmenuTopOffset != 'undefined' ? parseInt(iToolbarSubmenuTopOffset) : 0),
+            left: 0
+        });
+    };
+
+    var fClose = function () {
+        if (eIcon.length)
+            (new Marka(eIcon[0])).set(eIcon.attr('data-icon-orig'));
+        eSlider.slideUp()
+    };
+
+    var fOpen = function () {
+        if (eIcon.length) {
+            eIcon.attr('data-icon-orig', eIcon.attr('data-icon'));
+            (new Marka(eIcon[0])).set('times');
+        }
+
+        if(typeof sPosition == 'object')
+            fPositionElement(sPosition);
+        else if(typeof sPosition == 'string' && sPosition == 'block')
+            fPositionBlock();
+        else
+            fPositionSite();
+
+        eSlider.slideDown();
+
+        eSlider.data('data-control-btn', e);        
+    };    
+    
+    if ('undefined' == typeof(sPosition))
+        sPosition = 'block';
+
+    if (eSlider.is(':visible')) {
+        if(typeof(bx_sound) == 'function')
+            bx_sound('close', {emphasis: 'subtle'});
+
+        fClose();
+        $(e).focus();
+
+        $(document).off('click.bx-sliding-menu touchend.bx-sliding-menu');
+        $(window).off('resize.bx-sliding-menu');
+    }
+    else {
+        bx_menu_slide_close_all_opened();
+
+        $(document).off('click.bx-sliding-menu touchend.bx-sliding-menu');
+        $(window).off('resize.bx-sliding-menu');
+
+        // Some templates (Artificer) draw a sidebar instead and keep this call on the trigger: no menu, no sound.
+        if(eSlider.length && typeof(bx_sound) == 'function')
+            bx_sound('open', {emphasis: 'subtle'});
+
+        fOpen();
+        eSlider.find('.bx-popup-content a:first').focus();
+        eSlider.find('a').each(function () {
+            $(this).off('click.bx-sliding-menu');
+            $(this).on('click.bx-sliding-menu', function () {
+                fClose();
+            });
+        });
+
+        setTimeout(function () {
+
+            var iWidthPrev = $(window).width();
+            $(window).on('resize.bx-sliding-menu', function () {
+                if ($(this).width() == iWidthPrev)
+                    return;
+                iWidthPrev = $(this).width();
+                bx_menu_slide_close_all_opened();
+            });
+ 
+            $(document).on('click.bx-sliding-menu touchend.bx-sliding-menu', function (event) {
+                if ($(event.target).parents('.bx-sliding-menu, .bx-sliding-menu-main, .bx-popup-slide-wrapper, .bx-db-header').length || $(event.target).filter('.bx-sliding-menu-main, .bx-popup-slide-wrapper, .bx-db-header').length || e === event.target)
+                    event.stopPropagation();
+                else
+                    bx_menu_slide_close_all_opened();
+            });
+
+        }, 10);
+    }
+}
+
+/**
+ * Close all opened sliding menus
+ */
+function bx_menu_slide_close_all_opened () {
+    $('.bx-sliding-menu:visible, .bx-sliding-menu-main:visible, .bx-popup-slide-wrapper:visible').each(function () {
+        bx_menu_slide_inline('#' + this.id);
+    });
+}
+
+/*
+ * Note. oData.count_old and oData.count_new are also available and can be checked or used in notification popup.  
+ */
+function bx_menu_show_live_update(oData) {
+    var sSelectorAddon = '.bx-menu-item-addon';
+
+    // Something arrived (a notification, a message, a request): one ready, however many counters went up.
+    if(parseInt(oData.count_new) > parseInt(oData.count_old))
+        bx_sound('ready');
+
+    //--- Update Child Menu Item
+    if(oData.mi_child) {
+        var oMenuItem = $('.bx-menu-object-' + oData.mi_child.menu_object + ' .bx-menu-item-' + oData.mi_child.menu_item);
+        var oMenuItemAddon = oMenuItem.find(sSelectorAddon);
+
+        if(oMenuItemAddon.length > 0)
+            oMenuItemAddon.html(oData.count_new);
+        else
+            oMenuItem.append(oData.code.replace('{count}', oData.count_new));
+
+        //+++ Check for 0 value
+        oMenuItemAddon = oMenuItem.find(sSelectorAddon);
+        if(parseInt(oData.count_new) > 0)
+            oMenuItemAddon.show();
+        else
+            oMenuItemAddon.hide();
+    }
+
+    //--- Update Parent Menu Item
+    if(oData.mi_parent) {
+        var oMenuItem = $('.bx-menu-object-' + oData.mi_parent.menu_object + ' .bx-menu-item-' + oData.mi_parent.menu_item);
+        var oMenuItemAddon = oMenuItem.find(sSelectorAddon);
+
+        var sMediaType = bx_check_mq();
+        if(sMediaType == 'mobile')
+            sMediaType = 'phone';
+
+        var iSum = 0;
+        $('.bx-media-' + sMediaType + ' .bx-menu-object-' + oData.mi_child.menu_object + ' .bx-menu-item:not(.bx-def-media-' + sMediaType + '-hide) .bx-menu-item-addon').each(function() {
+            iValue = parseInt($(this).html());
+            if(iValue && iValue > 0)
+                iSum += iValue;
+        });
+
+        if(oMenuItemAddon.length > 0)
+            oMenuItemAddon.html(iSum);
+        else
+            oMenuItem.append(oData.code.replace('{count}', iSum));
+
+        //+++ Check for 0 value
+        oMenuItemAddon = oMenuItem.find(sSelectorAddon);
+        if(iSum > 0)
+            oMenuItemAddon.show();
+        else
+            oMenuItemAddon.hide();
+    }
+}
+
+function bx_menu_show_more_less(oLink, sMenu, sSelectorParent) {
+    if(!sSelectorParent)
+        sSelectorParent = 'ul';
+
+    var sClass = 'bx-mi-hidden';
+    $(oLink).parents(sSelectorParent + ':first').find('.bx-mi-aux, .bx-psmi-show-more, .bx-psmi-show-less').toggleClass(sClass);
+
+    $.get(sUrlRoot + 'menu.php', {
+        o: sMenu, 
+        a: 'set_collapsed', 
+        v: $(oLink).parents(sSelectorParent + ':first').find('.bx-mi-aux').hasClass(sClass) ? 1 : 0
+    });
+}
+
+function bx_menu_toggle_contents(oLink, sSelectorParent) {
+    if(!sSelectorParent)
+        sSelectorParent = '.bx-menu-contents';
+
+    var sClass = 'bx-m-collapsed';
+    var oParent = $(oLink).parents(sSelectorParent + ':first');
+
+    oParent.toggleClass(sClass);
+    oParent.siblings('.bx-menu-content').toggle();
+
+    return false;
+}
+
+function bx_menu_toggle(oLink, sMenu, sMenuItem, sSelectorParent) {
+    if(!sSelectorParent)
+        sSelectorParent = 'li';
+
+    var sClass = 'bx-mi-collapsed';
+    var oParent = $(oLink).parents(sSelectorParent + ':first');
+
+    oParent.toggleClass(sClass);
+
+    $.get(sUrlRoot + 'menu.php', {
+        o: sMenu, 
+        a: 'set_collapsed_submenu',
+        i: sMenuItem,
+        v: oParent.hasClass(sClass) ? 1 : 0
+    });
+    
+    return false;
+}
+
+function bx_menu_followings_load_more(oLink, sMenu, sContextModule, iStart, iPerPage) {
+    var oLoadingParent = $(oLink).length ? $(oLink).parents('.bx-menu-item:first') : $('body'); 
+
+    bx_loading(oLoadingParent, true);
+
+    $.get(sUrlRoot + 'menu.php', {
+        o: sMenu, 
+        a: 'load_more', 
+        v: sContextModule,
+        start: iStart,
+        per_page: iPerPage
+    }, function(oData) {
+        bx_loading(oLoadingParent, false);
+
+        if(oData && oData.content != undefined)
+            $(oLink).parents('.bx-menu-subitem:first').replaceWith(oData.content);
+    }, 'json');
+}
+
+/**
+ * Set ACL level for specified profile
+ * @param iProfileId - profile id to set acl level for
+ * @param iAclLevel - acl level id to assign to a given rofile
+ */
+function bx_set_acl_level (iProfileId, iAclLevel, mixedLoadingElement) {
+    var bBulk = !$.isNumeric(iProfileId);
+    var iAclCard = !bBulk && $('#sys-acl-profile-' + iProfileId).length > 0 ? 1 : 0;
+
+    var bLoading = typeof(mixedLoadingElement) != 'undefined';
+    if(bLoading)
+        bx_loading($(mixedLoadingElement), true);
+
+    $.post(sUrlRoot + 'menu.php', {o:'sys_set_acl_level', profile_id: iProfileId, level_id: iAclLevel, card:iAclCard}, function(oData) {
+        bx_on_set_acl_level(oData, mixedLoadingElement);
+    }, 'json');
+}
+
+/**
+ * Reset ACL level for specified profile to Standard.
+ * @param iProfileId - profile id to reset acl level for
+ */
+function bx_reset_acl_level (iProfileId, mixedLoadingElement) {
+    var bBulk = !$.isNumeric(iProfileId);
+    var iAclCard = !bBulk && $('#sys-acl-profile-' + iProfileId).length > 0 ? 1 : 0;
+
+    var fPerform = function() {
+        var bLoading = typeof(mixedLoadingElement) != 'undefined';
+        if(bLoading)
+            bx_loading($(mixedLoadingElement), true);
+
+        $.post(sUrlRoot + 'menu.php', {o:'sys_set_acl_level', profile_id: iProfileId, level_id: 3, card: iAclCard}, function(oData) {
+            bx_on_set_acl_level(oData, mixedLoadingElement);
+        }, 'json');
+    };
+
+    bx_confirm(_t('_Are_you_sure'), fPerform);
+}
+
+function bx_on_set_acl_level(oData, oLoadingElement) {
+    if(typeof(oLoadingElement) != 'undefined')
+        bx_loading($(oLoadingElement), false);
+
+    if(oData.msg != undefined && oData.msg.length) {
+        bx_alert(oData.msg);
+        return;
+    }
+
+    if($(oLoadingElement).hasClass('bx-popup-applied'))
+        $(oLoadingElement).dolPopupHide().remove();
+
+    if(typeof(oData.card) == 'object')
+        for(var iField in oData.card) {
+            var oCard = $(oData.card[iField]);
+            $('#' + oCard.attr('id')).replaceWith(oCard.bxTime());
+        }
+}
+
+/**
+ * Set badge for specified content
+ * @param sModule - current module for content
+ * @param iContentId - content id to set badge for
+ * @param iBadgeId - badge id to assign to a given content
+ */
+function bx_set_badge (sModule, iContentId, iBadgeId, mixedLoadingElement) {
+   
+    var bLoading = typeof(mixedLoadingElement) != 'undefined';
+    if(bLoading)
+        bx_loading($(mixedLoadingElement), true);
+    $.post(sUrlRoot + 'menu.php', {o:'sys_set_badges', module: sModule, content_id: iContentId, badge_id: iBadgeId}, function(oData) {
+        if(bLoading)
+            bx_loading($(mixedLoadingElement), false);
+
+        if(oData.msg != undefined && oData.msg.length) {
+            bx_alert(oData.msg);
+            return;
+        }
+
+        if($(mixedLoadingElement).hasClass('bx-popup-applied'))
+            $(mixedLoadingElement).dolPopupHide().remove();
+
+        if(typeof(oData.card) == 'object')
+            for(var iField in oData.card) {
+                var oCard = $(oData.card[iField]);
+                $('#' + oCard.attr('id')).replaceWith(oCard);
+            }
+		
+		$('.bx-base-bages-container').html(oData.html);
+		
+    }, 'json');
+}
+
+function bx_get_notes(oSource,  sModule, iContentId, oOptions, oVars) {
+    var oOptions = oOptions || {};
+    var oVars = oVars || {};
+
+    $.post(
+        sUrlRoot + 'modules/?r=' + sModule + '/get_notes/',
+        $.extend({content_id: iContentId}, oVars),
+        function(oData) {
+            if(oData && oData.popup != undefined) {
+                if(typeof(oData.popup) == 'string')
+                    oData.popup = {html: oData.popup, options: {}};
+
+                oData.popup.options = $.extend({}, $.fn.dolPopupDefaultOptions, oData.popup.options, {
+                    id: sModule + '_notes_' + iContentId, 
+                    closeOnOuterClick: false,
+                    removeOnClose: true,
+                    onBeforeShow: function(oPopup) {
+                        $(oPopup).find('.bx-popup-element-close').removeClass('bx-def-media-desktop-hide bx-def-media-tablet-hide');
+                    }
+                }, oOptions);
+            }
+            
+            processJsonData(oData);
+        },
+        'json'
+    );
+}
+
+function bx_approve(oSource,  sModule, iContentId, oOptions, oVars) {
+    var oOptions = oOptions || {};
+    var oVars = oVars || {};
+
+    var fPopupInit = function(oData) {
+        if(oData && oData.popup != undefined) {
+            if(typeof(oData.popup) == 'string')
+                oData.popup = {html: oData.popup, options: {}};
+
+            oData.popup.options = $.extend({}, $.fn.dolPopupDefaultOptions, oData.popup.options, {
+                id: sModule + '_approve_' + iContentId, 
+                closeOnOuterClick: false,
+                removeOnClose: true,
+                onBeforeShow: function(oPopup) {
+                    $(oPopup).find('.bx-popup-element-close').removeClass('bx-def-media-desktop-hide bx-def-media-tablet-hide');
+
+                    var oForm = $(oPopup).find('form');
+                    if(oForm.length > 0) {
+                        oForm.ajaxForm({ 
+                            dataType: 'json',
+                            beforeSubmit: function(aFormData, oForm, oOptions) {
+                                bx_loading(oForm, true);
+                            },
+                            success: function(oData) {
+                                $('.bx-popup-applied:visible').dolPopupHide();
+
+                                fPopupInit(oData);
+
+                                processJsonData(oData);
+                            }
+                        });
+                    }
+                }
+            }, oOptions);
+        }
+    };
+
+    $.post(
+        sUrlRoot + 'modules/?r=' + sModule + '/approve/',
+        $.extend({content_id: iContentId}, oVars),
+        function(oData) {
+            fPopupInit(oData);
+
+            processJsonData(oData);
+        },
+        'json'
+    );
+}
+
+function bx_showcase_init(sSelWrapper, sSelItem) {
+    var oWrapper = $(sSelWrapper);
+
+    if(oWrapper.closest('.bx-popup-wrapper').length != 0)
+        return;
+
+    oWrapper.each(function() {
+        var sClassEnabled = 'bx-sc-enabled';
+        var oShowcase = $(this);
+        var oCells = oShowcase.find(sSelItem);
+
+        if(oCells.width() * oCells.length <= oShowcase.parent().width()) {
+            if(oShowcase.hasClass('flickity-enabled'))
+                oShowcase.flickity('destroy').removeClass(sClassEnabled);
+
+            return;
+        }
+
+        if(oShowcase.hasClass('flickity-enabled'))
+            return;
+
+        var oShowcaseOptions = {
+            cellSelector: sSelItem,
+            cellAlign: 'left',
+            imagesLoaded: true,
+            wrapAround: true,
+            pageDots: false
+        };
+
+        var iGroupCells = oShowcase.attr('bx-sc-group-cells');
+        if(iGroupCells != undefined)
+            oShowcaseOptions.groupCells = parseInt(iGroupCells);
+
+        oShowcase.on('ready.flickity', function() {
+            $(this).addClass(sClassEnabled);
+        }).flickity(oShowcaseOptions);
+    });
+}
+
+function validateLoginForm(eForm) {
+    return true;
+}
+
+/**
+ * convert <time> tags to human readable format
+ * @param sLang - use sLang localization
+ * @param isAutoupdate - for internal usage only
+ */
+function bx_time(sLang, isAutoupdate, sRootSel) {
+    if ('undefined' == typeof(sRootSel))
+        sRootSel = 'body';
+    var iAutoupdate = 22*60*60; // autoupdate time in realtime if less than 22 hours
+    var sSel = 'time';
+
+    if (typeof(sLang) != 'undefined')
+        glBxTimeLang = sLang;
+    else if(typeof(glBxTimeLang) != 'undefined')
+        sLang = glBxTimeLang;
+
+    if(!sLang)
+        return;
+
+    if ('undefined' != typeof(isAutoupdate) && isAutoupdate)
+        sSel += '.bx-time-autoupdate';
+
+    $(sRootSel).find(sSel).each(function () {
+        var s;
+        var sTime = $(this).attr('datetime');
+        var iSecondsDiff = moment(sTime).unix() - moment().unix();
+        if (iSecondsDiff < 0)
+            iSecondsDiff = -iSecondsDiff;
+
+        if (iSecondsDiff < $(this).attr('data-bx-autoformat'))
+            s = moment(sTime).locale(sLang).fromNow(); // 'time ago' format
+        else if (1 == $(this).attr('data-bx-utc'))
+            s = moment.utc(sTime).locale(sLang).format($(this).attr('data-bx-format')); 
+        else
+            s = moment(sTime).locale(sLang).format($(this).attr('data-bx-format'));
+
+        if (iSecondsDiff < iAutoupdate)
+            $(this).addClass('bx-time-autoupdate');
+        else
+            $(this).removeClass('bx-time-autoupdate');
+
+        $(this).html(s);
+    });
+
+    if ($('time.bx-time-autoupdate').size()) {
+        setTimeout(function () {
+            bx_time(sLang, true);
+        }, 20000);
+    }
+}
+
+(function($) {
+
+    $.fn.bxTime = function() {
+        bx_time(undefined, undefined, this);
+        return this;
+    };
+
+    /**
+     * process HTML which was added dynamicly on the page
+     */ 
+    $.fn.bxProcessHtml = function (oCallback) {
+        var eElement = $(this);
+        if ('undefined' !== typeof(eElement)) {
+            // process js time
+            eElement.bxTime();
+            bx_process_links();
+            
+            // process web forms
+            if ($.isFunction($.fn.addWebForms))
+	            eElement.addWebForms();
+            
+            // process animating icons
+        	bx_activate_anim_icons();
+
+            // process syntax hightlighing
+            if ('undefined' !== typeof(Prism) && eElement[0] && eElement[0].querySelectorAll)
+                Prism.highlightAllUnder(eElement[0]);
+
+            // process links
+            bx_redirect_for_external_links(eElement);
+
+            // flickity update
+            setTimeout(function () {
+                const oFlickity = eElement.find('.flickity-enabled');
+                if (oFlickity.length)
+                    oFlickity.flickity('resize');
+            }, 250);
+        }
+        if ('undefined' !== typeof(oCallback)) {
+            oCallback(eElement);
+        }
+
+        if (typeof glOnProcessHtml !== 'undefined' && glOnProcessHtml instanceof Array) {
+            for (var i = 0; i < glOnProcessHtml.length; i++)
+                if (typeof glOnProcessHtml[i] === "function")
+                    glOnProcessHtml[i](this);
+        }
+        
+        return this;
+    };
+
+    $.fn.bxCheckOverflowHeight = function(sClass, onFind) {
+    	if(!sClass)
+            sClass = 'bx-overflow';
+
+        return this.each(function() {
+            var oElement = $(this);
+            if(oElement.hasClass(sClass) || oElement.css('overflow') != 'hidden')
+            	return;
+
+            if(oElement.find('img').length > 0) {
+                var oImg = oElement.find('img:first').get(0);
+
+                const oImgCopy = new Image();
+                oImgCopy.src = oImg.src;
+                oImgCopy.onload = function() {
+                    var iHeightParent = oElement.height();
+                    var iHeightImg = oImg.clientHeight;
+                    var iOffsetTopImg = oImg.offsetTop;
+                    if(iOffsetTopImg < iHeightParent && iOffsetTopImg + iHeightImg > iHeightParent)
+                        oElement.css('max-height', (iOffsetTopImg + iHeightImg +  50) + 'px');
+
+                    if(oElement.prop('scrollHeight') <= Math.ceil(oElement.height()))
+                        return;
+
+                    oElement.addClass(sClass);
+                    if(typeof onFind === 'function')
+                        onFind(oElement);
+                }
+            }
+            else {
+                if(oElement.prop('scrollHeight') <= Math.ceil(oElement.height()))
+                    return;
+
+                oElement.addClass(sClass);
+                if(typeof onFind === 'function')
+                    onFind(oElement);
+            }
+        });
+    };
+
+    $.fn.bxConvertEmbeds = function() {
+        return this.each(function() {
+            $(this).find(".bx-embed-link").each(function() {
+                var oItem = $(this);
+                oItem.html(oItem.attr('source'));
+                $.getJSON(sUrlRoot + '/embed.php?', {a: 'get_link', l: oItem.attr('source')}, function(oData) {
+                    oItem.html(oData.code)
+                    if(oItem.find('a').length > 0)
+                        bx_embed_link(oItem.find('a')[0]);
+                });
+            });
+        });
+    };
+} (jQuery));
+
+
+/**
+ * Perform connections AJAX request. 
+ * In case of error - it shows js alert with error message.
+ * In case of success - the page is reloaded.
+ * 
+ * @param sObj - connections object
+ * @param sAction - 'add', 'remove' or 'reject'
+ * @param iContentId - content id, initiator is always current logged in user
+ * @param bConfirm - show confirmation dialog
+ */
+function bx_conn_action(e, sObj, sAction, iContentId, bConfirm, fOnComplete) {
+    var fPerform = function() {
+		var aParams = {
+		    obj: sObj,
+		    act: sAction,
+		    id: iContentId
+		};
+		var fCallback = function (data) {
+		    bx_loading_btn(e, 0);
+		    if ('object' != typeof(data))
+		        return;
+		    if (data.err) {
+		        bx_alert(data.msg);
+		    } else {
+		        if ('function' == typeof(fOnComplete))
+		            fOnComplete(data, e);
+		        else if (!loadDynamicBlockAuto(e))
+		            location.reload();
+		        else
+		            $('#bx-popup-ajax-wrapper-bx_persons_view_actions_more').remove();
+		    }
+		};
+		
+		bx_loading_btn(e, 1);
+		
+		$.ajax({
+		    dataType: 'json',
+		    url: sUrlRoot + 'conn.php',
+		    data: aParams,
+		    type: 'POST',
+		    success: fCallback
+		});
+    };
+
+    if (typeof(bConfirm) != 'undefined' && bConfirm)
+    	bx_confirm(_t('_Are_you_sure'), fPerform);
+    else
+    	fPerform();
+}
+
+/**
+ * Perform recommendations AJAX request. 
+ * In case of error - it shows js alert with error message.
+ * In case of success - the page is reloaded.
+ * 
+ * @param sObj - recommendation object
+ * @param sAction - 'ignore'
+ * @param iContentId - content id, initiator is always current logged in user
+ * @param bConfirm - show confirmation dialog
+ */
+function bx_recommendation_action(e, sObj, sAction, iContentId, bConfirm, fOnComplete) {
+    var fPerform = function() {
+        var aParams = {
+            obj: sObj,
+            act: sAction,
+            id: iContentId
+        };
+
+        var fCallback = function (data) {
+            bx_loading_btn(e, 0);
+            if ('object' != typeof(data))
+                return;
+
+            if (data.msg)
+                bx_alert(data.msg);
+            else {
+                if ('function' == typeof(fOnComplete))
+                    fOnComplete(data, e);
+                else if (!loadDynamicBlockAuto(e))
+                    location.reload();
+            }
+        };
+
+        bx_loading_btn(e, 1);
+
+        $.ajax({
+            dataType: 'json',
+            url: sUrlRoot + 'recommendation.php',
+            data: aParams,
+            type: 'POST',
+            success: fCallback
+        });
+    };
+
+    if (typeof(bConfirm) != 'undefined' && bConfirm)
+    	bx_confirm(_t('_Are_you_sure'), fPerform);
+    else
+    	fPerform();
+}
+
+function bx_append_url_params (sUrl, mixedParams) {
+    var sParams = sUrl.indexOf('?') == -1 ? '?' : '&';
+
+    var sHash = '';
+    if(sUrl.indexOf('#') != -1) {
+        var oUrl = new URL(sUrl);
+        if(oUrl.hash.length > 0) {
+            sHash = oUrl.hash;
+            sUrl = sUrl.replace(sHash, '');
+        }
+        else
+            sUrl = sUrl.replace('#', '');
+    }
+
+    if(mixedParams instanceof Array) {
+    	for(var i in mixedParams)
+            sParams += i + '=' + mixedParams[i] + '&';
+        sParams = sParams.substr(0, sParams.length-1);
+    }
+    else if(mixedParams instanceof Object) {
+    	$.each(mixedParams, function(sKey, sValue) {
+    		sParams += sKey + '=' + sValue + '&';
+    	});
+        sParams = sParams.substr(0, sParams.length-1);
+    }
+    else
+        sParams += mixedParams;
+
+    return sUrl + sParams + sHash;
+}
+
+function bx_search_extnded_sort(obj, url){
+    location.href = url + '&sort=' +$(obj).val();
+}
+
+function bx_search_on_type (e, n, sFormSel, sResultsContSel, sLoadingContSel, bSortResults, iMinLen, onComplete, onCancel) {
+    var oForm = $(e.target).parents(sFormSel + ':first');
+
+    if ('undefined' != typeof(e)) {
+        if(e.keyCode == 13) {
+            oForm.find('input[name=live_search]').val(0);
+            oForm.submit();
+            return false;
+        }
+
+        if(e.keyCode == 27) { 
+            if(typeof onCancel === 'function')
+                onCancel(oForm);
+        }
+    }
+
+    iMinLen = typeof(iMinLen) != 'undefined' ? parseInt(iMinLen) : 0;
+    if(iMinLen > 0 && iMinLen > (oForm.find('input[name=keyword]').val().length + 1))
+        return true;
+
+    if ('undefined' != typeof(glBxSearchTimeoutHandler) && glBxSearchTimeoutHandler)
+        clearTimeout(glBxSearchTimeoutHandler);
+
+    glBxSearchTimeoutHandler = setTimeout(function () {
+        bx_search (n, sFormSel, sResultsContSel, sLoadingContSel, bSortResults, onComplete);
+    }, 500);
+
+    return true;
+}
+
+function bx_search (n, sFormSel, sResultsContSel, sLoadingContSel, bSortResults, onComplete) {
+    if ('undefined' == typeof(sLoadingContSel))
+        sLoadingContSel = sResultsContSel;
+    if ('undefined' == typeof(bSortResults))
+        bSortResults = false;
+
+    var sQuery = $('input', sFormSel).serialize();
+
+    bx_loading($(sLoadingContSel), true);
+    $.post(sUrlRoot + 'searchKeywordContent.php', sQuery, function(data) {
+        bx_loading($(sLoadingContSel), false);
+
+        if(!data)
+            return;
+
+        if (bSortResults) {
+            var sAsk = $(data).filter(".bx-btn.sys-agents-ask");
+
+            var aSortedUnits = $(data).find(".bx-def-unit-live-search").toArray().sort(function (a, b) {
+                return b.getAttribute('data-ts') - a.getAttribute('data-ts');
+            });
+
+            data = '';
+            if(sAsk.length)
+                data += sAsk.get(0).outerHTML;
+
+            $.each(aSortedUnits.slice(0, n), function (i, e) {
+                data += e.outerHTML;
+            });
+            
+            
+        } 
+
+        var oContainer = $(sResultsContSel);
+        oContainer.html(data).bxProcessHtml();
+
+        if(typeof onComplete === 'function')
+            onComplete(oContainer, data);
+    });
+
+    return false;
+}
+
+function on_filter_apply(e, sInputId, sFilterName)
+{
+	var oRegExp = new RegExp("[&]{0,1}" + sFilterName + "=.*");
+    var s = ('' + document.location).replace(oRegExp, ''); // remove filter
+    s = s.replace(/page=\d+/, 'page=1'); // goto 1st page
+    if (e.checked && $('#' + sInputId).val().length > 2)
+        s += (-1 == s.indexOf('?') ? '?' : '&') + sFilterName + '=' + $('#' + sInputId).val(); // append filter
+    document.location = s;
+}
+
+function on_filter_key_up (e, sCheckboxId)
+{
+    if (13 == e.keyCode) {
+        $('#' + sCheckboxId).click();
+        return false;
+    }
+    else {
+        $('#' + sCheckboxId).removeAttr('checked');
+        return true;
+    }
+}
+
+function on_copyright_click()
+{
+	oDate = new Date();
+
+	$(document).dolPopupAlert({
+		message: _t('_copyright', oDate.getFullYear())
+	});
+}
+
+function bx_activate_anim_icons(sColor)
+{
+    if(typeof(sColor) != 'undefined')
+        glBxAnimIconColor = sColor;
+    else if(typeof(glBxAnimIconColor) != 'undefined')
+        sColor = glBxAnimIconColor;
+
+    if(!sColor)
+        return;
+
+    $('.sys-icon-a').not('.marka').each(function () {
+        var e = $(this);
+        var m = new Marka(e.get(0)), r = e.attr('data-rotate'), c = e.attr('data-color');
+        m.set(e.attr('data-icon')).rotate(r ? r : 'down').color(c ? c : sColor);
+    });
+}
+
+function bx_get_param(s) {
+    if (!window.aDolOptions || !aDolOptions[s])
+        return false;
+
+    return aDolOptions[s];
+}
+
+function bx_autocomplete_fields(iId, sUrl, sName, bShowImg, bOnlyOnce, onSelect){
+	if (!$('#' + iId).hasClass('bx-form-input-autotoken'))
+			$('#' + iId).addClass('bx-form-input-autotoken');
+	
+	$('#' + iId + ' input[type=text]').autocomplete({
+		source: sUrl,
+        focus: function(e, ui ) {
+            $(this).val(ui.item.label); 
+            e.preventDefault();
+        },
+		select: function(e, ui) {
+			$(this).val(ui.item.label);
+			$(this).trigger('superselect', ui.item);
+					
+			if (typeof onSelect === 'function')
+				onSelect(ui);
+			
+			e.preventDefault();			
+		}
+	});
+	if ($('#' + iId + ' .val').length > 0 && bOnlyOnce)
+		$('#' + iId + ' input[type=text]').hide();
+	$('#' + iId + ' input[type=text]').on('superselect', function(e, item) {
+		$(this).hide();
+		if ('undefined' != typeof(item)) {
+			var sImage = '';
+			if(bShowImg && item.unit != undefined)
+				sImage = item.unit;
+			else if(bShowImg && item.thumb != undefined)
+				sImage = '<img class="bx-def-thumb bx-def-icon-size bx-def-margin-sec-right" src="' + item.thumb + '">';
+
+			$(this).before('<b class="val bx-def-color-bg-hl bx-def-round-corners">' + sImage + item.label + '<i class="sys-icon times ltr:ml-1 rtl:mr-1"></i><input type="hidden" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" name="' + sName + (bOnlyOnce ? '' : '[]') + '" value="' + item.value + '" /></b>');
+		}
+
+		if (!bOnlyOnce)
+		{
+			fAutoShrinkInput();
+			$(this).show();
+			this.value = '';
+		}
+
+	}).on('keydown', function(e) {
+
+		// if: comma,enter (delimit more keyCodes with | pipe)
+		if (/(13)/.test(e.which))
+			e.preventDefault();
+
+	});
+
+	$('#' + iId).on('click', 'b', function() {
+		$(this).remove();
+			
+		if (bOnlyOnce)
+			$('#' + iId + ' input[type=text]').val('').show();
+		else
+			fAutoShrinkInput();
+	});
+
+	fAutoShrinkInput = function () {
+		var iWidthCont = $('#' + iId + '.bx-form-input-autotoken').innerWidth();
+		var iWidthExisting = 0;
+		$('#' + iId + '.bx-form-input-autotoken b').each(function () {
+			iWidthExisting += $(this).outerWidth(true);
+		});
+
+		$('#' + iId + '.bx-form-input-autotoken input').innerWidth(parseInt(iWidthCont - iWidthExisting > 180 ? iWidthCont - iWidthExisting : 180) - 5);
+	};
+
+	if (!bOnlyOnce)
+		fAutoShrinkInput();
+	else
+		$('#' + iId + '.bx-form-input-autotoken input').outerWidth('100%');
+};
+
+function bx_alert(sMessage, fOnClickOk, oParams)
+{
+    $(document).dolPopupAlert({
+        message: sMessage,
+        onClickOk: fOnClickOk,
+        params: oParams
+    });
+}
+
+function bx_confirm(sMessage, fOnClickYes, fOnClickNo, oParams)
+{
+    $(document).dolPopupConfirm({
+        message: sMessage,
+        onClickYes: fOnClickYes,
+        onClickNo: fOnClickNo,
+        params: oParams
+    });
+}
+
+function bx_prompt(sMessage, sValue, fOnClickOk, fOnClickCancel, oParams)
+{
+    $(document).dolPopupPrompt({
+        message: sMessage,
+        value: sValue,
+        onClickOk: fOnClickOk,
+        onClickCancel: fOnClickCancel,
+        params: oParams
+    });
+}
+
+function bx_toast(sMessage, oParams)
+{
+    var oDate = new Date();
+    var sId = 'bx-popup-toast';
+    var sIdClone = sId + '-' + oDate.getTime();
+
+    var oHolder = $('#' + sId + '-holder');
+    var oAlert = oHolder.find('#' + sId).clone();
+    oAlert.attr('id', sIdClone).find('.bx-pt-text').html(sMessage);
+    oHolder.append(oAlert.bx_anim('show', 'fade', 'slow', function() {
+        if(oParams && typeof(oParams.onShow) == 'function')
+            oParams.onShow();
+    }));
+
+    setTimeout(function() {
+        $('#' + sIdClone).bx_anim('hide', 'fade', 'slow', function() {
+            $(this).remove();
+
+            if(oParams && typeof(oParams.onHide) == 'function')
+                oParams.onHide();
+        })
+    }, 1000 * (oParams && oParams.autoHide !== undefined ? parseInt(oParams.autoHide) : 3));
+};
+
+function bx_is_color_scheme_dark()
+{
+    return localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
+}
+
+function bx_get_color_scheme()
+{
+    return 'theme' in localStorage ? localStorage.theme : 'auto';    
+}
+
+function bx_set_color_scheme(iCode)
+{
+    switch(iCode) {
+        case 0:
+            localStorage.removeItem('theme');
+            break;
+
+        case 1:
+            localStorage.theme = 'sun'
+            break;
+
+        case 2:
+            localStorage.theme = 'dark'
+            break;
+    }
+
+    //--- Change scheme.
+    bx_set_color_scheme_html();
+
+    //--- Notify global handlers.
+    if(typeof glOnColorSchemeChange === 'undefined' || !(glOnColorSchemeChange instanceof Array)) 
+        return;
+
+    if(typeof iCode === 'undefined' || !iCode)
+        iCode = bx_is_color_scheme_dark() ? 2 : 1;
+
+    for(var i = 0; i < glOnColorSchemeChange.length; i++)
+        if(typeof glOnColorSchemeChange[i] === 'function')
+            glOnColorSchemeChange[i](iCode);
+}
+
+function bx_set_color_scheme_html()
+{
+    if(bx_is_color_scheme_dark())
+        $('html').addClass('dark')
+    else
+        $('html').removeClass('dark')
+}
+
+/*
+ * Interface sounds are Cuelume's cues (plugins_public/cuelume, MIT): one cue per
+ * interface job, picked by the "Which cue?" table at https://cuelume.dev/agents.md.
+ * Controls carry data-cuelume-* attributes, which Cuelume's bind() plays as they
+ * are pressed; outcomes call bx_sound(). Nothing plays on hover, on page load, or
+ * per item of a burst.
+ *
+ * Settings > Global > Sounds (BxDolSounds, passed to bx_sound_init) picks where
+ * sounds play, the sound set, the volume, a set or silence for each kind of action,
+ * and the level people start at. Each visitor picks their own level for this
+ * browser: full, quiet or off (bx_sound_set_level). The module loads only once a
+ * sound can play.
+ */
+var glBxSounds = {url: '', config: null, module: null, cuelume: null, readyAt: 0, restore: null};
+
+/**
+ * The kind of action each cue belongs to (its set or silence in the settings). Likes, reactions, ratings, favorites
+ * and follows say so themselves: group 'reactions' in bx_sound(), or data-bx-sound-group on a control.
+ */
+var aBxSoundGroups = {tap: 'buttons', select: 'choices', toggle: 'choices', open: 'menus', close: 'menus', navigate: 'navigation', loading: 'navigation', success: 'results', warning: 'results', error: 'results', ready: 'notifications', attention: 'notifications'};
+
+/**
+ * The attributes bind() plays, and the cue each one plays when it doesn't name another.
+ */
+var aBxSoundAttributes = {'data-cuelume-tap': 'tap', 'data-cuelume-select': 'select', 'data-cuelume-toggle': 'toggle', 'data-cuelume-open': 'open', 'data-cuelume-close': 'close', 'data-cuelume-navigate': 'navigate'};
+
+/**
+ * The cue Settings > Sounds plays for each kind of action as it is changed.
+ */
+var aBxSoundSamples = {buttons: 'tap', choices: 'toggle', reactions: 'toggle', menus: 'open', navigation: 'navigate', results: 'success', notifications: 'ready'};
+
+/**
+ * Quiet plays at this share of the volume.
+ */
+var BX_SOUND_QUIET = 0.4;
+
+function bx_sound_init(sUrl, oConfig)
+{
+    glBxSounds.url = sUrl;
+    glBxSounds.config = oConfig;
+
+    // A module that fails to load leaves the page silent; nothing else depends on it.
+    if(bx_sound_is_on())
+        bx_sound_load().catch(function() {});
+
+    $(document).ready(function() {
+        bx_sound_mark_switches();
+    });
+}
+
+function bx_sound_load()
+{
+    if(!glBxSounds.url)
+        return Promise.reject(new Error('Interface sounds are not set up on this page.'));
+
+    if(!glBxSounds.module)
+        glBxSounds.module = import(glBxSounds.url).then(function(oCuelume) {
+            glBxSounds.cuelume = oCuelume;
+            bx_sound_apply();
+
+            // Ahead of bind(), which listens on document: the kind of action decides how this one press sounds.
+            ['pointerdown', 'click', 'keydown', 'keyup', 'change'].forEach(function(sEvent) {
+                window.addEventListener(sEvent, bx_sound_gate, true);
+            });
+            oCuelume.bind();
+
+            return oCuelume;
+        });
+
+    return glBxSounds.module;
+}
+
+/**
+ * The visitor's level: their own pick in this browser, or where the settings start everyone; off where sounds are off.
+ */
+function bx_sound_level()
+{
+    var oConfig = glBxSounds.config;
+    if(!oConfig || !oConfig.on)
+        return 'off';
+
+    try {
+        var sLevel = localStorage.getItem('bx-sounds');
+        if(sLevel == 'full' || sLevel == 'quiet' || sLevel == 'off')
+            return sLevel;
+    }
+    catch(e) {}
+
+    return oConfig.level;
+}
+
+function bx_sound_is_on()
+{
+    return bx_sound_level() != 'off';
+}
+
+function bx_sound_volume()
+{
+    return glBxSounds.config.volume * (bx_sound_level() == 'quiet' ? BX_SOUND_QUIET : 1);
+}
+
+/**
+ * The set a kind of action plays: its own, the main one, or 'off'.
+ */
+function bx_sound_set(sGroup)
+{
+    var oGroups = glBxSounds.config.groups || {};
+    return sGroup && oGroups[sGroup] ? oGroups[sGroup] : glBxSounds.config.set;
+}
+
+/**
+ * Cuelume's settings for this page. A press that needs other ones changes them for itself (bx_sound_gate).
+ */
+function bx_sound_apply()
+{
+    glBxSounds.restore = null;
+
+    if(!glBxSounds.cuelume)
+        return;
+
+    glBxSounds.cuelume.setTheme(glBxSounds.config.set);
+    glBxSounds.cuelume.setVolume(bx_sound_volume());
+}
+
+/**
+ * Runs before bind() for each event that can press a marked control: a kind of action with a set of its own plays in
+ * it, and one that is off, or a visitor who turned sounds off, plays nothing. Cuelume reads the set and volume as it
+ * plays, so they are back to the page's own on the next tick.
+ */
+function bx_sound_gate(oEvent)
+{
+    if(!glBxSounds.cuelume || !(oEvent.target instanceof Element))
+        return;
+
+    var oControl = oEvent.target.closest(Object.keys(aBxSoundAttributes).map(function(sAttribute) {
+        return '[' + sAttribute + ']';
+    }).join(','));
+    if(!oControl)
+        return;
+
+    var sSet = bx_sound_is_on() ? bx_sound_set(bx_sound_group(oControl)) : 'off';
+    if(sSet == glBxSounds.config.set)
+        return;
+
+    if(sSet == 'off')
+        glBxSounds.cuelume.setVolume(0);
+    else
+        glBxSounds.cuelume.setTheme(sSet);
+
+    if(!glBxSounds.restore)
+        glBxSounds.restore = setTimeout(bx_sound_apply, 0);
+}
+
+/**
+ * The kind of action of a marked control: data-bx-sound-group on it or around it, otherwise the one of its cue.
+ */
+function bx_sound_group(oControl)
+{
+    var oGroup = oControl.closest('[data-bx-sound-group]');
+    if(oGroup)
+        return oGroup.getAttribute('data-bx-sound-group');
+
+    for(var sAttribute in aBxSoundAttributes) {
+        if(!oControl.hasAttribute(sAttribute))
+            continue;
+
+        var sCue = oControl.getAttribute(sAttribute);
+        return aBxSoundGroups[aBxSoundGroups[sCue] ? sCue : aBxSoundAttributes[sAttribute]];
+    }
+
+    return '';
+}
+
+/**
+ * Play the cue for an outcome, e.g. bx_sound('success', {emphasis: 'subtle'}) once a form is saved. `group` names
+ * the kind of action when the cue alone doesn't ('reactions' for a like). Arrivals ('ready') that come together,
+ * like several live updates in one poll, play once.
+ */
+function bx_sound(sCue, oOptions)
+{
+    if(!bx_sound_is_on())
+        return;
+
+    var sSet = bx_sound_set(oOptions && oOptions.group ? oOptions.group : aBxSoundGroups[sCue]);
+    if(sSet == 'off')
+        return;
+
+    if(sCue == 'ready') {
+        var iNow = Date.now();
+        if(iNow - glBxSounds.readyAt < 2000)
+            return;
+
+        glBxSounds.readyAt = iNow;
+    }
+
+    bx_sound_load().then(function(oCuelume) {
+        oCuelume.play(sCue, $.extend({}, oOptions, {theme: sSet}));
+    }, function() {});
+}
+
+/**
+ * The reply to an AJAX form: error when UNA refuses it (a non-zero code, or the form or a popup back with a field
+ * error), a subtle success when it says the form went through (code 0, a redirect, a reload or a script to run).
+ * A reply that names its own sound (`cue`) gets only that one, from processJsonData; a reply that only carries a
+ * message plays nothing, since a message alone can say either.
+ */
+function bx_sound_form_reply(oData)
+{
+    if(!oData || oData.cue != undefined)
+        return;
+
+    var mixedPopup = oData.popup && typeof(oData.popup) == 'object' ? oData.popup.html : oData.popup;
+    if((oData.code != undefined && Number.parseInt(oData.code) != 0) || bx_sound_has_field_error(oData.form) || bx_sound_has_field_error(mixedPopup))
+        bx_sound('error');
+    else if(oData.code != undefined || oData.redirect != undefined || oData.reload != undefined || oData.eval != undefined)
+        bx_sound('success', {emphasis: 'subtle'});
+}
+
+/**
+ * Whether a form's HTML came back with a field error in it.
+ */
+function bx_sound_has_field_error(sHtml)
+{
+    return typeof(sHtml) == 'string' && $('<div>' + sHtml + '</div>').find('.bx-form-warn').toArray().some(function(oWarn) {
+        return oWarn.textContent.trim().length > 0;
+    });
+}
+
+/**
+ * The visitor's level of interface sounds in this browser: 'full', 'quiet' or 'off'. A new level that can be heard
+ * answers from the same click, which also lets the browser start audio; off is heard as silence.
+ */
+function bx_sound_set_level(sLevel)
+{
+    if(!glBxSounds.config || !glBxSounds.config.on)
+        return;
+
+    var aLevels = ['off', 'quiet', 'full'];
+    var iFrom = aLevels.indexOf(bx_sound_level());
+    try {
+        localStorage.setItem('bx-sounds', sLevel);
+    }
+    catch(e) {}
+
+    bx_sound_mark_switches();
+
+    if(sLevel == 'off') {
+        // Whatever still rings stops at once; the module stays ready for the next level.
+        if(glBxSounds.cuelume) {
+            glBxSounds.cuelume.setEnabled(false);
+            glBxSounds.cuelume.setEnabled(true);
+        }
+        return;
+    }
+
+    bx_sound_load().then(function() {
+        bx_sound_apply();
+        bx_sound('toggle', {direction: aLevels.indexOf(sLevel) > iFrom ? 'forward' : 'back'});
+    }, function() {});
+}
+
+/**
+ * The account menu's Sounds item steps through the levels: full, quiet, off.
+ */
+function bx_sound_toggle(oSwitch)
+{
+    bx_sound_set_level({full: 'quiet', quiet: 'off', off: 'full'}[bx_sound_level()]);
+}
+
+/**
+ * Show the visitor's level on every Sounds control: the account menu item's name and icon (volume-up, volume-down,
+ * volume-mute; the Lucide iconset redraws it from a new <i>), and the checked option of Studio's radio group. Where
+ * sounds are off for everyone, the item goes away.
+ */
+function bx_sound_mark_switches()
+{
+    var oConfig = glBxSounds.config;
+    var sLevel = bx_sound_level();
+    var sIcon = {full: 'volume-up', quiet: 'volume-down', off: 'volume-mute'}[sLevel];
+    var bRedraw = false;
+
+    $('[data-bx-sounds-switch]').each(function() {
+        if(!oConfig || !oConfig.on) {
+            $(this).closest('li').hide();
+            return;
+        }
+
+        $(this).attr({'aria-label': oConfig.labels[sLevel], title: oConfig.labels[sLevel]});
+
+        $(this).find('.sys-icon.volume-up, .sys-icon.volume-down, .sys-icon.volume-mute').each(function() {
+            if($(this).hasClass(sIcon))
+                return;
+
+            if(this.tagName.toLowerCase() == 'i')
+                $(this).removeClass('volume-up volume-down volume-mute').addClass(sIcon);
+            else {
+                $(this).replaceWith($('<i>').addClass('sys-icon ' + sIcon));
+                bRedraw = true;
+            }
+        });
+    });
+
+    $('[data-bx-sounds-level]').each(function() {
+        var bChecked = $(this).attr('data-bx-sounds-level') == sLevel;
+        $(this).attr('aria-checked', bChecked ? 'true' : 'false').attr('tabindex', bChecked ? '0' : '-1');
+    });
+
+    if(bRedraw && typeof(bx_iconset_init_lucide) == 'function')
+        bx_iconset_init_lucide();
+}
+
+/**
+ * Settings > Sounds: a choice plays as it is made, with the set and volume picked in the form, before it is saved and
+ * even while sounds are off here.
+ */
+function bx_sound_preview(oSelect)
+{
+    var oForm = $(oSelect).closest('form');
+    var fValue = function(sName) {
+        return oForm.find('[name="sys_sounds_' + sName + '"]').val();
+    };
+
+    var sOption = $(oSelect).attr('data-bx-sound-option');
+    var sValue = $(oSelect).val();
+    var sSet = fValue('set') || 'default';
+    var fVolume = (parseInt(fValue('volume')) || 100) / 100;
+    var sCue = 'toggle';
+
+    if(sOption == 'level') {
+        if(sValue == 'off')
+            return;
+        if(sValue == 'quiet')
+            fVolume *= BX_SOUND_QUIET;
+    }
+    else if(aBxSoundSamples[sOption]) {
+        if(sValue == 'off')
+            return;
+        if(sValue)
+            sSet = sValue;
+        sCue = aBxSoundSamples[sOption];
+    }
+
+    bx_sound_load().then(function(oCuelume) {
+        // play() takes the volume now; the page's own is back at once.
+        oCuelume.setVolume(fVolume);
+        oCuelume.play(sCue, {theme: sSet});
+        bx_sound_apply();
+    }, function() {});
+}
+
+$(document).ready(function() {
+    // A cached menu can still carry the Sounds item where sounds are off for everyone.
+    if(!glBxSounds.config)
+        $('[data-bx-sounds-switch]').closest('li').hide();
+});
+
+function bx_is_mouse()
+{
+    return matchMedia('(pointer:fine)').matches;
+}
+
+function bx_is_touch_device()
+{
+    return (('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0));
+}
+
+/**
+ * Check if mobile or desktop device.
+ */
+function bx_check_mq()
+{
+    if (typeof glOnCheckMq === 'function') {
+        return glOnCheckMq();
+    }
+
+    return window.getComputedStyle(document.querySelector('body'), '::before').getPropertyValue('content').replace(/'/g, "").replace(/"/g, "");
+}
+
+/**
+ * This function loads the passed list of CSS files. In case when this function is called for dynamic loading 
+ * of the same file but many times it allows to avoid multiply loadings of the same file.
+ */
+function bx_get_style(mixedUrl)
+{
+    if(typeof(mixedUrl) === 'string')
+        mixedUrl = new Array(mixedUrl);
+
+    for(let i = 0; i < mixedUrl.length; i++) {
+        if(mixedUrl[i] === undefined || !mixedUrl[i].length || $("link[href = '" + mixedUrl[i] + "']").length > 0)
+            continue;
+
+        $("head").append($("<link rel='stylesheet' href='" + mixedUrl[i] + "' type='text/css' />"));
+    }
+}
+
+/**
+ * This function loads the passed list of js files and executes callback function when the all files are loaded.
+ * In case when this function is called for dynamic loading of the same file but many times (like Plyr video init)
+ * it allows to avoid multiply loadings of the same file.
+ */
+function bx_get_scripts (aFiles, fCallback)
+{
+    let iCounter = 0;
+    const
+        iLength = aFiles.length,
+        fHandler = function() {
+            if (iCounter++ >= (iLength-1) && typeof(fCallback) === 'function')
+                fCallback();
+            else
+                fExecute(aFiles[iCounter]);
+        },
+        // Check if js file is not loaded yet then start to load it and returns Promise.
+        // All functions which try to load the same file will wait for loading of the file
+        // and then continue to work
+        fExecute = function(sFile) {
+            if ('undefined' === typeof(glBxLoadedScripts[sFile]))
+                glBxLoadedScripts[sFile] = { complete : $.bxGetCachedScript(sFile).done(fHandler) };
+            else
+                glBxLoadedScripts[sFile].complete.then(fHandler);
+        };
+
+    if ('undefined' === typeof(glBxLoadedScripts))
+        glBxLoadedScripts = {};
+
+    fExecute(aFiles[iCounter]);
+}
+
+jQuery.bxGetCachedScript = function(sUrl, oOptions) 
+{
+    oOptions = $.extend(oOptions || {}, {
+        dataType: "script",
+        cache: true,
+        url: sUrl
+    });
+
+    return jQuery.ajax(oOptions);
+};
+
+function bx_is_selector_in_stylesheet(sSel)
+{
+    var a = [];
+    var aSs = document.styleSheets;
+    for (var sSheet = 0; sSheet < aSs.length; sSheet++) {
+        var ruleList = document.styleSheets[sSheet].cssRules;
+        for (var rule = 0; rule < ruleList.length; rule++)
+           a.push(ruleList[rule].selectorText);
+    }
+    return -1 !== a.indexOf(sSel);
+}
+
+function bx_copy_to_clipboard(s, onSuccess, onFail)
+{
+    if ("undefined" === typeof(onSuccess))
+        onSuccess = function (){};
+    if ("undefined" === typeof(onFail))
+        onFail = function (){};
+
+    const oClip = navigator.clipboard;
+    if ("undefined" !== typeof(oClip)) {
+        navigator.clipboard.writeText(s).then(onSuccess, onFail);
+    } 
+    else {
+        if (document.execCommand) {
+            const el = document.createElement("input");
+            el.value = s;
+            document.body.append(el);
+
+            el.select();
+            el.setSelectionRange(0, s.length);
+
+            if (document.execCommand("copy")) {
+                onSuccess();
+            }
+
+            el.remove();
+        }
+        else {
+            onFail();
+        }
+    }
+}
+
+function bx_regexp_escape(s) 
+{
+    return s
+        .replace(/[|\\{}()[\]^$+*?.]/mg, '\\$&')
+        .replace(/-/mg, '\\x2d');
+}
+
+function bx_get_regexp(sType) 
+{
+    var o = {
+        tag:"(<([^>]+bx-tag[^>]+)>)",
+        mention: "(<([^>]+bx-mention-link[^>]+)>)",
+        url: "(([A-Za-z]{3,9}:(?:\\/\\/)?)(?:[\\-;:&=\\+\\$,\\w]+@)?[A-Za-z0-9\\.\\-]+|(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)[A-Za-z0-9\\.\\-]+)((?:\\/[\\+~%#\\/\\.\\w\\-_!\\(\\)]*)?\\??(?:[\\-\\+=&;%@\\.\\w_]*)#?(?:[\\.\\!\\/\\w]*))?"
+    };
+
+    return o[sType];
+}
+
+function bx_process_links()
+{
+    // process links in container
+    $('.bx-lnk-cnt .bx-lnk-src:not(.bx-inited)').each(function() {
+        $(this).addClass('bx-inited');
+        var sUrl = $(this).prop('href');
+        $(this).parents('.bx-lnk-cnt').first().addClass('cursor-pointer').click(function(oEvent){
+            if ($(oEvent.target).closest('a').length == 0 && $(oEvent.target).closest('button').length == 0 && $(oEvent.target).closest('form').length == 0 &&  $(oEvent.target).closest('input').length == 0){
+                location.href = sUrl;
+            }
+        });
+    });
+}
+
+
+function bx_redirect_for_external_links (e)
+{
+    if ('undefined' === typeof(aDolOptions.sys_confirmation_before_redirect) || 'on' !== aDolOptions.sys_confirmation_before_redirect)
+        return;
+
+    var aExclude = ['javascript:', 'about:', sUrlRoot];
+    var sPattern = 'a';
+    $(aExclude).each(function () {
+        sPattern += ':not([href^="' + this + '"])';
+    });
+    if ('undefined' !== typeof(aDolOptions.sys_storage_s3_endpoint) && aDolOptions.sys_storage_s3_endpoint.length)
+        sPattern += ':not([href^="https://' + aDolOptions.sys_storage_s3_endpoint + '"])';
+
+    sPattern += ":is([href^='http://'],[href^='https://'])";
+
+    if ('undefined' === typeof(e))
+        e = $('document');
+
+    e.find(sPattern).each(function() {
+        if ($(this).attr('href').match(/s3\.amazonaws\.com/))
+            return;
+            
+        $(this).on('contextmenu', function(e) {
+            e.preventDefault();
+        });
+        $(this).on('click', function() {
+        	var $this = $(this);
+            bx_redirect_for_external_links_open($this.attr('href'));
+	        return false;
+        });
+    });
+}
+
+function bx_redirect_for_external_links_open (sHref)
+{
+    //bx_confirm(_t('_sys_redirect_confirmation', sHref).replace(/\&quot;/g, ''), function () {
+    //    window.open(sHref, '_blank').focus();
+    //});
+    window.open(sUrlRoot + 'page.php?i=redirect&url=' + encodeURIComponent(sHref), '_blank').focus();
+}
+
+function bx_agents_action(oElement, sTool, sAction, aParams)
+{
+    var oDate = new Date();
+    var sQueryUrl = sUrlRoot + 'agents.php';
+    var oQueryParams = jQuery.extend({}, {t: sTool, a: sAction, _t:oDate.getTime()}, aParams != undefined ? aParams : {});
+
+    bx_loading_btn(oElement, true);
+
+    jQuery.post (
+        sQueryUrl,
+        oQueryParams,
+        function(oData) {
+            bx_loading_btn(oElement, false);
+
+            processJsonData(oData);
+        },
+        'json'
+    );
+}
+
+function bx_get_focusable(oContext = 'document')
+{
+    return Array.from(oContext.querySelectorAll('button, [href], input:not([type="hidden"]), textarea, select, [tabindex]:not(.bx-focus-trap, [tabindex="-1"])')).filter(function(oElement) { 
+        return !$(oElement).closest(':hidden').length; 
+    });
+}
+
+function bx_clicked_stack_register()
+{
+    const iStackMaxLenth = 3;
+
+    $('a,button').filter(function(iIndex, oElement) {
+        return !$(oElement).closest('.bx-sidebar').length;
+    }).on('click', function() {
+        if(typeof window.glClickedStack === 'undefined')
+            window.glClickedStack = [];    
+
+        window.glClickedStack.push(this);
+
+        if(window.glClickedStack && window.glClickedStack.length > iStackMaxLenth)
+            window.glClickedStack = window.glClickedStack.slice(-iStackMaxLenth);
+    });
+}
+
+function bx_clicked_stack_lenth()
+{
+    return window.glClickedStack ? window.glClickedStack.length : 0;
+}
+
+function bx_clicked_stack_get()
+{
+    const iLenth = bx_clicked_stack_lenth();
+    return iLenth > 0 ? window.glClickedStack[iLenth - 1] : false;
+}
+
+function bx_clicked_stack_pop()
+{
+    return window.glClickedStack && window.glClickedStack.length > 0 ? window.glClickedStack.pop() : false;
+}
+
+/** @} */

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません