mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b442f634e | |||
| d74a97e571 | |||
|
|
d2aa196765 | ||
|
|
8b7fa5ebfb | ||
|
|
6a4efc6826 | ||
| 6ae7155a9a | |||
| 03c2a6162b | |||
| 51c49c32da | |||
| 1dbab6a51d | |||
| f6910cde2d | |||
| ed04879575 | |||
| 362fdc8b03 | |||
| 0d98473cbf | |||
|
|
3242a1cad5 | ||
| 9fac12b0c7 | |||
|
|
37a82842b1 | ||
|
|
46b2c820e8 | ||
|
|
715b2ab6ff | ||
|
|
3ab3c1cc76 | ||
| 3d1ddb9f26 | |||
| a286bbdfe9 | |||
| a8e158d35b | |||
| c3d267f122 | |||
| c2e4ebe242 | |||
|
|
679abe3fb6 | ||
|
|
043af15809 | ||
| 407df1d423 | |||
| e2dcebc6ee | |||
| a905ce3b4c | |||
| c017bb3d09 | |||
|
|
67c92c05a3 | ||
| e94ca2dec1 | |||
| 975744ec6f | |||
| cb7a03c1d5 | |||
| 00e5d6d3b2 | |||
| 8b489b9cc3 | |||
| 9f904a7e48 | |||
| 886c51fdca | |||
| 919a13fdd3 | |||
| 370eed1286 | |||
| 1ff6349337 | |||
| 7732f22f4e | |||
| a07c8e452f | |||
| 57642efc7b | |||
| 18a2918446 | |||
| 3e7cebc8c3 | |||
| eb1b2f86df | |||
| c72c8b3eed | |||
| ec49855173 | |||
| cdca135f7d | |||
| 145e6d8f81 | |||
| 01639c7545 | |||
| 8848ba0304 | |||
| 64a618f54a | |||
| e4281e2128 |
45
.github/workflows/lint-eslint.yml
vendored
45
.github/workflows/lint-eslint.yml
vendored
@@ -1,10 +1,5 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# Use lint-eslint together with lint-eslint-when-unrelated to make eslint a required check for GitHub actions
|
||||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
||||
# SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
name: Lint eslint
|
||||
|
||||
@@ -20,40 +15,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
src: ${{ steps.changes.outputs.src}}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- '.github/workflows/**'
|
||||
- 'src/**'
|
||||
- 'appinfo/info.xml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- '.eslintrc.*'
|
||||
- '.eslintignore'
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.vue'
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: changes
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
name: NPM lint
|
||||
|
||||
steps:
|
||||
@@ -75,13 +39,12 @@ jobs:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, lint]
|
||||
needs: lint
|
||||
|
||||
if: always()
|
||||
|
||||
# This is the summary, we just avoid to rename it so that branch protection rules still match
|
||||
name: eslint
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi
|
||||
run: if ${{ needs.lint.result != 'success' }}; then exit 1; fi
|
||||
|
||||
339
.github/workflows/phpunit-incremental.yml
vendored
Normal file
339
.github/workflows/phpunit-incremental.yml
vendored
Normal file
@@ -0,0 +1,339 @@
|
||||
# Incremental migration test workflow
|
||||
#
|
||||
# This workflow tests that migrations work correctly when upgrading from an older version.
|
||||
# It first installs the app at v0.14.0 (last version before forum_user_stats -> forum_users rename),
|
||||
# then upgrades to the current version and verifies all migrations run successfully.
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: PHPUnit Incremental Migration
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: phpunit-incremental-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
outputs:
|
||||
src: ${{ steps.changes.outputs.src }}
|
||||
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- '.github/workflows/**'
|
||||
- 'appinfo/**'
|
||||
- 'lib/**'
|
||||
- 'tests/**'
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
|
||||
incremental-pgsql:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
name: Incremental Migration (PostgreSQL)
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ghcr.io/nextcloud/continuous-integration-postgres-16:latest # zizmor: ignore[unpinned-images]
|
||||
ports:
|
||||
- 4444:5432/tcp
|
||||
env:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_PASSWORD: rootpassword
|
||||
POSTGRES_DB: nextcloud
|
||||
options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout app (current)
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: app-current
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect app ID from appinfo/info.xml
|
||||
run: |
|
||||
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-current/appinfo/info.xml | head -1)
|
||||
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
|
||||
echo "Detected app ID: $APP_ID"
|
||||
|
||||
- name: Get supported server versions
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: app-current/appinfo/info.xml
|
||||
|
||||
- name: Save current app for later
|
||||
run: |
|
||||
mkdir -p /tmp/app-backup
|
||||
cp -r app-current /tmp/app-backup/
|
||||
|
||||
- name: Checkout server
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
repository: nextcloud/server
|
||||
ref: ${{ fromJson(steps.versions.outputs.branches)[0] }}
|
||||
|
||||
- name: Checkout app at v0.14.0 (pre-rename baseline)
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: v0.14.0
|
||||
path: apps/${{ env.APP_NAME }}
|
||||
|
||||
- name: Set up php 8.3
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, pgsql, pdo_pgsql
|
||||
coverage: none
|
||||
ini-file: development
|
||||
ini-values: disable_functions=
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up dependencies (v0.14.0)
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev --no-scripts || true
|
||||
composer i --no-scripts || composer i
|
||||
|
||||
- name: Set up Nextcloud and install app at v0.14.0
|
||||
env:
|
||||
DB_PORT: 4444
|
||||
run: |
|
||||
mkdir data
|
||||
./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
|
||||
echo "::group::Installing app at v0.14.0"
|
||||
./occ app:enable --force ${{ env.APP_NAME }}
|
||||
echo "::endgroup::"
|
||||
echo "::group::Database tables after v0.14.0 install"
|
||||
./occ db:convert-filecache-bigint --no-interaction || true
|
||||
PGPASSWORD=rootpassword psql -h 127.0.0.1 -p $DB_PORT -U root -d nextcloud -c "\dt oc_forum_*"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Upgrade app to current version
|
||||
run: |
|
||||
echo "::group::Replacing app with current version"
|
||||
rm -rf apps/${{ env.APP_NAME }}
|
||||
cp -r /tmp/app-backup/app-current apps/${{ env.APP_NAME }}
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Set up dependencies (current)
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev --no-scripts || true
|
||||
composer i
|
||||
|
||||
- name: Run migrations to current version
|
||||
env:
|
||||
DB_PORT: 4444
|
||||
run: |
|
||||
echo "::group::Running upgrade migrations"
|
||||
# Disable maintenance mode if it was left on
|
||||
./occ maintenance:mode --off || true
|
||||
# Disable and re-enable the app to trigger migrations
|
||||
# This simulates what happens when a user upgrades the app
|
||||
./occ app:disable ${{ env.APP_NAME }}
|
||||
./occ app:enable ${{ env.APP_NAME }}
|
||||
echo "::endgroup::"
|
||||
echo "::group::Database tables after upgrade"
|
||||
PGPASSWORD=rootpassword psql -h 127.0.0.1 -p $DB_PORT -U root -d nextcloud -c "\dt oc_forum_*"
|
||||
echo "::endgroup::"
|
||||
echo "::group::Checking forum_users table exists"
|
||||
PGPASSWORD=rootpassword psql -h 127.0.0.1 -p $DB_PORT -U root -d nextcloud -c "SELECT COUNT(*) FROM oc_forum_users" || exit 1
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Check PHPUnit integration script is defined
|
||||
id: check_integration
|
||||
continue-on-error: true
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer run --list | grep '^ test:integration ' | wc -l | grep 1
|
||||
|
||||
- name: Run Nextcloud
|
||||
if: steps.check_integration.outcome == 'success'
|
||||
run: php -S localhost:8080 &
|
||||
|
||||
- name: PHPUnit integration
|
||||
if: steps.check_integration.outcome == 'success'
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: composer run test:integration
|
||||
|
||||
- name: Print logs
|
||||
if: always()
|
||||
run: |
|
||||
cat data/nextcloud.log
|
||||
|
||||
incremental-mysql:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
name: Incremental Migration (MySQL)
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: ghcr.io/nextcloud/continuous-integration-mariadb-10.11:latest # zizmor: ignore[unpinned-images]
|
||||
ports:
|
||||
- 4444:3306/tcp
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: rootpassword
|
||||
MYSQL_DATABASE: nextcloud
|
||||
options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout app (current)
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: app-current
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect app ID from appinfo/info.xml
|
||||
run: |
|
||||
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-current/appinfo/info.xml | head -1)
|
||||
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
|
||||
echo "Detected app ID: $APP_ID"
|
||||
|
||||
- name: Get supported server versions
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: app-current/appinfo/info.xml
|
||||
|
||||
- name: Save current app for later
|
||||
run: |
|
||||
mkdir -p /tmp/app-backup
|
||||
cp -r app-current /tmp/app-backup/
|
||||
|
||||
- name: Checkout server
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
repository: nextcloud/server
|
||||
ref: ${{ fromJson(steps.versions.outputs.branches)[0] }}
|
||||
|
||||
- name: Checkout app at v0.14.0 (pre-rename baseline)
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: v0.14.0
|
||||
path: apps/${{ env.APP_NAME }}
|
||||
|
||||
- name: Set up php 8.3
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, mysql, pdo_mysql
|
||||
coverage: none
|
||||
ini-file: development
|
||||
ini-values: disable_functions=
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up dependencies (v0.14.0)
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev --no-scripts || true
|
||||
composer i --no-scripts || composer i
|
||||
|
||||
- name: Set up Nextcloud and install app at v0.14.0
|
||||
env:
|
||||
DB_PORT: 4444
|
||||
run: |
|
||||
mkdir data
|
||||
./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
|
||||
echo "::group::Installing app at v0.14.0"
|
||||
./occ app:enable --force ${{ env.APP_NAME }}
|
||||
echo "::endgroup::"
|
||||
echo "::group::Database tables after v0.14.0 install"
|
||||
mysql -h 127.0.0.1 -P $DB_PORT -u root -prootpassword nextcloud -e "SHOW TABLES LIKE 'oc_forum_%'"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Upgrade app to current version
|
||||
run: |
|
||||
echo "::group::Replacing app with current version"
|
||||
rm -rf apps/${{ env.APP_NAME }}
|
||||
cp -r /tmp/app-backup/app-current apps/${{ env.APP_NAME }}
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Set up dependencies (current)
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev --no-scripts || true
|
||||
composer i
|
||||
|
||||
- name: Run migrations to current version
|
||||
env:
|
||||
DB_PORT: 4444
|
||||
run: |
|
||||
echo "::group::Running upgrade migrations"
|
||||
# Disable maintenance mode if it was left on
|
||||
./occ maintenance:mode --off || true
|
||||
# Disable and re-enable the app to trigger migrations
|
||||
# This simulates what happens when a user upgrades the app
|
||||
./occ app:disable ${{ env.APP_NAME }}
|
||||
./occ app:enable ${{ env.APP_NAME }}
|
||||
echo "::endgroup::"
|
||||
echo "::group::Database tables after upgrade"
|
||||
mysql -h 127.0.0.1 -P $DB_PORT -u root -prootpassword nextcloud -e "SHOW TABLES LIKE 'oc_forum_%'"
|
||||
echo "::endgroup::"
|
||||
echo "::group::Checking forum_users table exists"
|
||||
mysql -h 127.0.0.1 -P $DB_PORT -u root -prootpassword nextcloud -e "SELECT COUNT(*) FROM oc_forum_users" || exit 1
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Check PHPUnit integration script is defined
|
||||
id: check_integration
|
||||
continue-on-error: true
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer run --list | grep '^ test:integration ' | wc -l | grep 1
|
||||
|
||||
- name: Run Nextcloud
|
||||
if: steps.check_integration.outcome == 'success'
|
||||
run: php -S localhost:8080 &
|
||||
|
||||
- name: PHPUnit integration
|
||||
if: steps.check_integration.outcome == 'success'
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: composer run test:integration
|
||||
|
||||
- name: Print logs
|
||||
if: always()
|
||||
run: |
|
||||
cat data/nextcloud.log
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, incremental-pgsql, incremental-mysql]
|
||||
|
||||
if: always()
|
||||
|
||||
name: incremental-migration-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && (needs.incremental-pgsql.result != 'success' || needs.incremental-mysql.result != 'success') }}; then exit 1; fi
|
||||
24
.github/workflows/phpunit-mysql.yml
vendored
24
.github/workflows/phpunit-mysql.yml
vendored
@@ -174,16 +174,34 @@ jobs:
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: composer run test:unit
|
||||
|
||||
- name: Check PHPUnit integration script is defined
|
||||
id: check_integration
|
||||
continue-on-error: true
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer run --list | grep '^ test:integration ' | wc -l | grep 1
|
||||
|
||||
- name: Run Nextcloud
|
||||
# Only run if phpunit integration config file exists
|
||||
if: steps.check_integration.outcome == 'success'
|
||||
run: php -S localhost:8080 &
|
||||
|
||||
- name: PHPUnit integration
|
||||
# Only run if phpunit integration config file exists
|
||||
if: steps.check_integration.outcome == 'success'
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: composer run test:integration
|
||||
|
||||
- name: Print logs
|
||||
if: always()
|
||||
run: |
|
||||
cat data/nextcloud.log
|
||||
|
||||
- name: Skipped
|
||||
# Fail the action when unit tests are not specified
|
||||
if: steps.check_phpunit.outcome == 'failure'
|
||||
# Fail the action when neither unit nor integration tests ran
|
||||
if: steps.check_phpunit.outcome == 'failure' && steps.check_integration.outcome == 'failure'
|
||||
run: |
|
||||
echo 'PHPUnit tests are not specified in composer.json scripts'
|
||||
echo 'Neither PHPUnit nor PHPUnit integration tests are specified in composer.json scripts'
|
||||
exit 1
|
||||
|
||||
summary:
|
||||
|
||||
68
.github/workflows/vitest.yml
vendored
Normal file
68
.github/workflows/vitest.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
# SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
name: Vitest
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: vitest-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Vitest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: vitest
|
||||
|
||||
if: always()
|
||||
|
||||
name: vitest-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.vitest.result != 'success' }}; then exit 1; fi
|
||||
@@ -1,2 +1,2 @@
|
||||
templates/
|
||||
scaffolds/
|
||||
gen/
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.19.3"}
|
||||
{".":"0.21.0"}
|
||||
|
||||
110
CHANGELOG.md
110
CHANGELOG.md
@@ -1,5 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
## [0.21.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.6...v0.21.0) (2026-01-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* admin section with repair seeds+add role helpers ([d74a97e](https://github.com/chenasraf/nextcloud-forum/commit/d74a97e571379fb03b9d01acf73fe195ca13d644))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **l10n:** Update translations from Transifex ([d2aa196](https://github.com/chenasraf/nextcloud-forum/commit/d2aa196765c1ad0860526a6a327f3ee3f695128c))
|
||||
* **l10n:** Update translations from Transifex ([8b7fa5e](https://github.com/chenasraf/nextcloud-forum/commit/8b7fa5ebfb62368afe66cd2330646c12ee6606d2))
|
||||
* **l10n:** Update translations from Transifex ([6a4efc6](https://github.com/chenasraf/nextcloud-forum/commit/6a4efc6826c92e93ca64344f15ec822a53062d77))
|
||||
|
||||
## [0.20.6](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.5...v0.20.6) (2026-01-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* db seeds ([03c2a61](https://github.com/chenasraf/nextcloud-forum/commit/03c2a6162bf1d30e4361f8ebbdf2a4e113692da2))
|
||||
* remove post slug from seed ([51c49c3](https://github.com/chenasraf/nextcloud-forum/commit/51c49c32dac5894c6711f8924fe5a7b44e07596b))
|
||||
|
||||
## [0.20.5](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.4...v0.20.5) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow creating multiple custom roles ([362fdc8](https://github.com/chenasraf/nextcloud-forum/commit/362fdc8b032299a4f32d8e4b70163a3077dcab3c))
|
||||
* category header update ([0d98473](https://github.com/chenasraf/nextcloud-forum/commit/0d98473cbfc8f89a2b4962781449ccc881ab8eee))
|
||||
* db seeds ([ed04879](https://github.com/chenasraf/nextcloud-forum/commit/ed048795756f474ce8126aa37fb34a7c57fa2d65))
|
||||
* **l10n:** Update translations from Transifex ([3242a1c](https://github.com/chenasraf/nextcloud-forum/commit/3242a1cad51b90841ff1bde5ca7e231ea92687d9))
|
||||
|
||||
## [0.20.4](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.3...v0.20.4) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **l10n:** Update translations from Transifex ([37a8284](https://github.com/chenasraf/nextcloud-forum/commit/37a82842b13012bf9bf578baf018bfa8b678d635))
|
||||
* **l10n:** Update translations from Transifex ([46b2c82](https://github.com/chenasraf/nextcloud-forum/commit/46b2c820e8bec48e432bf350d56a2d85f41da115))
|
||||
* **l10n:** Update translations from Transifex ([715b2ab](https://github.com/chenasraf/nextcloud-forum/commit/715b2ab6ff7a94ed431e0ca2525530e9e41f42c7))
|
||||
* **l10n:** Update translations from Transifex ([3ab3c1c](https://github.com/chenasraf/nextcloud-forum/commit/3ab3c1cc76b1f55e289d96dfd681d45b6e67b171))
|
||||
|
||||
## [0.20.3](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.2...v0.20.3) (2026-01-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* db seed transactions logic ([c3d267f](https://github.com/chenasraf/nextcloud-forum/commit/c3d267f12269568e0a091cc094e043e4f1ad8cb8))
|
||||
* **l10n:** Update translations from Transifex ([679abe3](https://github.com/chenasraf/nextcloud-forum/commit/679abe3fb6ce7c8545bf99875092f20cf5d468b8))
|
||||
* **l10n:** Update translations from Transifex ([043af15](https://github.com/chenasraf/nextcloud-forum/commit/043af1580912d201a724313d4788d7a776e11934))
|
||||
|
||||
## [0.20.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.1...v0.20.2) (2026-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bbcode cursor positions after inserting ([e2dcebc](https://github.com/chenasraf/nextcloud-forum/commit/e2dcebc6ee6e4d017f7f26fc86e72e6734a1f757))
|
||||
|
||||
## [0.20.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.0...v0.20.1) (2026-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* db seed migrations ([c017bb3](https://github.com/chenasraf/nextcloud-forum/commit/c017bb3d09a517c19e772420311c23a957f25cba))
|
||||
* **l10n:** Update translations from Transifex ([67c92c0](https://github.com/chenasraf/nextcloud-forum/commit/67c92c05a3e7f58bbc05265087b763368653f7d3))
|
||||
|
||||
## [0.20.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.7...v0.20.0) (2026-01-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add max-width to post/signature images ([00e5d6d](https://github.com/chenasraf/nextcloud-forum/commit/00e5d6d3b2e14939b233a80050f645ebd7b8503a))
|
||||
* add preference to auto subscribe to replied threads ([cb7a03c](https://github.com/chenasraf/nextcloud-forum/commit/cb7a03c1d51f2dc5642a47ab222b07cec6e01731))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* forum users tables migrations ([8b489b9](https://github.com/chenasraf/nextcloud-forum/commit/8b489b9cc3919dedf1463c7c7dd54e7a8009fc6f))
|
||||
|
||||
## [0.19.7](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.6...v0.19.7) (2026-01-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* aggressive scroll-behavior interferes with mobile apps popover menu ([eb1b2f8](https://github.com/chenasraf/nextcloud-forum/commit/eb1b2f86df7e7bf75bdbd9ba8260471ec91110fb))
|
||||
* bbcode text insertion/selection logic ([919a13f](https://github.com/chenasraf/nextcloud-forum/commit/919a13fdd3da0579c7d9ebdd032e3108e9da7047))
|
||||
* main content size on mobile ([3e7cebc](https://github.com/chenasraf/nextcloud-forum/commit/3e7cebc8c3316dada42cf1ba81acb062d5b1d41a))
|
||||
|
||||
## [0.19.6](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.5...v0.19.6) (2026-01-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bbcode editor ([ec49855](https://github.com/chenasraf/nextcloud-forum/commit/ec49855173e026b683a6dd0cc29e46a72f62e98e))
|
||||
* bbcode text wrapping ([145e6d8](https://github.com/chenasraf/nextcloud-forum/commit/145e6d8f814d3899ef6327eaff5637a296b6582d))
|
||||
|
||||
## [0.19.5](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.4...v0.19.5) (2026-01-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* roles seed ([8848ba0](https://github.com/chenasraf/nextcloud-forum/commit/8848ba03045f69cba40dd9094ade214f1c1b56cc))
|
||||
|
||||
## [0.19.4](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.3...v0.19.4) (2026-01-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* seed migration ([e4281e2](https://github.com/chenasraf/nextcloud-forum/commit/e4281e2128a86fa39b8f4a8deec21b82c901b935))
|
||||
|
||||
## [0.19.3](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.2...v0.19.3) (2025-12-31)
|
||||
|
||||
|
||||
|
||||
46
Makefile
46
Makefile
@@ -17,13 +17,14 @@
|
||||
# - JS build is delegated to your package.json scripts (tool-agnostic).
|
||||
#
|
||||
# Common recipes:
|
||||
# make build → install deps & build
|
||||
# make dist → build source + appstore tarballs
|
||||
# make test → run PHP unit tests
|
||||
# make lint → lint JS & PHP
|
||||
# make openapi → generate OpenAPI JSON
|
||||
# make sign → print signature for GitHub tarball
|
||||
# make release → upload release to Nextcloud App Store
|
||||
# make build → install deps & build
|
||||
# make dist → build source + appstore tarballs
|
||||
# make test → run PHP unit tests
|
||||
# make test-integration → run PHP integration tests
|
||||
# make lint → lint JS & PHP
|
||||
# make openapi → generate OpenAPI JSON
|
||||
# make sign → print signature for GitHub tarball
|
||||
# make release → upload release to Nextcloud App Store
|
||||
#
|
||||
|
||||
app_name=forum
|
||||
@@ -242,10 +243,33 @@ test: composer
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "\x1b[32mUsing Nextcloud root: $$NC_ROOT\x1b[0m"; \
|
||||
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.xml; \
|
||||
if [ -f tests/phpunit.integration.xml ]; then \
|
||||
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.integration.xml; \
|
||||
fi
|
||||
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.xml
|
||||
|
||||
# test-integration:
|
||||
# - Run only PHP integration tests (database-dependent tests)
|
||||
# - These tests run against a real database and test migration/seeding
|
||||
.PHONY: test-integration
|
||||
test-integration: composer
|
||||
@NC_ROOT="$(NEXTCLOUD_ROOT)"; \
|
||||
if [ -n "$$NC_ROOT" ]; then \
|
||||
NC_ROOT=$$(echo "$$NC_ROOT" | sed "s|^\\\~|$$HOME|" | sed "s|^~|$$HOME|"); \
|
||||
fi; \
|
||||
if [ -z "$$NC_ROOT" ]; then \
|
||||
if [ -d "$(CURDIR)/../../../tests/bootstrap.php" ]; then \
|
||||
NC_ROOT="$(CURDIR)/../../.."; \
|
||||
fi; \
|
||||
fi; \
|
||||
if [ -z "$$NC_ROOT" ]; then \
|
||||
echo "\x1b[33mCould not find Nextcloud installation.\x1b[0m"; \
|
||||
echo "Set NEXTCLOUD_ROOT environment variable."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ ! -f tests/phpunit.integration.xml ]; then \
|
||||
echo "\x1b[31mNo integration tests found (tests/phpunit.integration.xml missing)\x1b[0m"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "\x1b[32mUsing Nextcloud root: $$NC_ROOT\x1b[0m"; \
|
||||
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.integration.xml
|
||||
|
||||
# test-docker:
|
||||
# - Run PHP unit tests inside a Nextcloud Docker container
|
||||
|
||||
@@ -37,7 +37,7 @@ This app is in early stages of development. While functional, you may encounter
|
||||
|
||||
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
|
||||
]]></description>
|
||||
<version>0.19.3</version>
|
||||
<version>0.21.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
@@ -67,6 +67,10 @@ The forum integrates seamlessly with your Nextcloud instance, using your existin
|
||||
<command>OCA\Forum\Command\SetRole</command>
|
||||
<command>OCA\Forum\Command\TestNotifier</command>
|
||||
</commands>
|
||||
<settings>
|
||||
<admin>OCA\Forum\Settings\AdminSettings</admin>
|
||||
<admin-section>OCA\Forum\Sections\AdminSection</admin-section>
|
||||
</settings>
|
||||
<navigations>
|
||||
<navigation role="all">
|
||||
<name>Forum</name>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"psalm": "psalm --threads=1 --no-cache",
|
||||
"test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky",
|
||||
"test:integration": "phpunit tests -c tests/phpunit.integration.xml --colors=always --fail-on-warning --fail-on-risky",
|
||||
"openapi": "generate-spec"
|
||||
},
|
||||
"require": {
|
||||
|
||||
64
composer.lock
generated
64
composer.lock
generated
@@ -180,12 +180,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud-deps/ocp.git",
|
||||
"reference": "45612049852ea4234dd799a4a3281a397f0749c3"
|
||||
"reference": "a79703d9f38e964b003ae1cc805b6531d142fa93"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/45612049852ea4234dd799a4a3281a397f0749c3",
|
||||
"reference": "45612049852ea4234dd799a4a3281a397f0749c3",
|
||||
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/a79703d9f38e964b003ae1cc805b6531d142fa93",
|
||||
"reference": "a79703d9f38e964b003ae1cc805b6531d142fa93",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -220,7 +220,7 @@
|
||||
"issues": "https://github.com/nextcloud-deps/ocp/issues",
|
||||
"source": "https://github.com/nextcloud-deps/ocp/tree/stable32"
|
||||
},
|
||||
"time": "2025-12-16T00:55:52+00:00"
|
||||
"time": "2026-01-09T00:57:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
@@ -1035,12 +1035,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Roave/SecurityAdvisories.git",
|
||||
"reference": "c04f8230182e06cd6b2ba948c85581a1b93887f2"
|
||||
"reference": "57ac71b06405e32e77d960bb8490683138573f04"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/c04f8230182e06cd6b2ba948c85581a1b93887f2",
|
||||
"reference": "c04f8230182e06cd6b2ba948c85581a1b93887f2",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/57ac71b06405e32e77d960bb8490683138573f04",
|
||||
"reference": "57ac71b06405e32e77d960bb8490683138573f04",
|
||||
"shasum": ""
|
||||
},
|
||||
"conflict": {
|
||||
@@ -1055,12 +1055,14 @@
|
||||
"aimeos/ai-cms-grapesjs": ">=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.9|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.10.8|>=2025.04.1,<2025.10.2",
|
||||
"aimeos/ai-controller-frontend": "<2020.10.15|>=2021.04.1,<2021.10.8|>=2022.04.1,<2022.10.8|>=2023.04.1,<2023.10.9|==2024.04.1",
|
||||
"aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7",
|
||||
"aimeos/aimeos-laravel": "==2021.10",
|
||||
"aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5",
|
||||
"airesvsg/acf-to-rest-api": "<=3.1",
|
||||
"akaunting/akaunting": "<2.1.13",
|
||||
"akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53",
|
||||
"alextselegidis/easyappointments": "<1.5.2.0-beta1",
|
||||
"alextselegidis/easyappointments": "<=1.5.2",
|
||||
"alexusmai/laravel-file-manager": "<=3.3.1",
|
||||
"algolia/algoliasearch-magento-2": "<=3.16.1|>=3.17.0.0-beta1,<=3.17.1",
|
||||
"alt-design/alt-redirect": "<1.6.4",
|
||||
"altcha-org/altcha": "<1.3.1",
|
||||
"alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1",
|
||||
@@ -1101,7 +1103,7 @@
|
||||
"backpack/filemanager": "<2.0.2|>=3,<3.0.9",
|
||||
"bacula-web/bacula-web": "<9.7.1",
|
||||
"badaso/core": "<=2.9.11",
|
||||
"bagisto/bagisto": "<=2.3.7",
|
||||
"bagisto/bagisto": "<2.3.10",
|
||||
"barrelstrength/sprout-base-email": "<1.2.7",
|
||||
"barrelstrength/sprout-forms": "<3.9",
|
||||
"barryvdh/laravel-translation-manager": "<0.6.8",
|
||||
@@ -1134,7 +1136,7 @@
|
||||
"bytefury/crater": "<6.0.2",
|
||||
"cachethq/cachet": "<2.5.1",
|
||||
"cadmium-org/cadmium-cms": "<=0.4.9",
|
||||
"cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10",
|
||||
"cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|>=5.2.10,<5.2.12|==5.3",
|
||||
"cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10",
|
||||
"cardgate/magento2": "<2.0.33",
|
||||
"cardgate/woocommerce": "<=3.1.15",
|
||||
@@ -1163,7 +1165,7 @@
|
||||
"codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5",
|
||||
"commerceteam/commerce": ">=0.9.6,<0.9.9",
|
||||
"components/jquery": ">=1.0.3,<3.5",
|
||||
"composer/composer": "<1.10.27|>=2,<2.2.24|>=2.3,<2.7.7",
|
||||
"composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3",
|
||||
"concrete5/concrete5": "<9.4.3",
|
||||
"concrete5/core": "<8.5.8|>=9,<9.1",
|
||||
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
|
||||
@@ -1173,10 +1175,11 @@
|
||||
"contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5",
|
||||
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
|
||||
"contao/managed-edition": "<=1.5",
|
||||
"coreshop/core-shop": "<=4.1.7",
|
||||
"corveda/phpsandbox": "<1.3.5",
|
||||
"cosenary/instagram": "<=2.3",
|
||||
"couleurcitron/tarteaucitron-wp": "<0.3",
|
||||
"craftcms/cms": "<=4.16.5|>=5,<=5.8.6",
|
||||
"craftcms/cms": "<=4.16.16|>=5,<=5.8.20",
|
||||
"croogo/croogo": "<=4.0.7",
|
||||
"cuyz/valinor": "<0.12",
|
||||
"czim/file-handling": "<1.5|>=2,<2.3",
|
||||
@@ -1224,7 +1227,7 @@
|
||||
"drupal/commerce_alphabank_redirect": "<1.0.3",
|
||||
"drupal/commerce_eurobank_redirect": "<2.1.1",
|
||||
"drupal/config_split": "<1.10|>=2,<2.0.2",
|
||||
"drupal/core": ">=6,<6.38|>=7,<7.102|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8",
|
||||
"drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8",
|
||||
"drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
|
||||
"drupal/currency": "<3.5",
|
||||
"drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
|
||||
@@ -1288,7 +1291,7 @@
|
||||
"ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15",
|
||||
"ezyang/htmlpurifier": "<=4.2",
|
||||
"facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2",
|
||||
"facturascripts/facturascripts": "<=2022.08",
|
||||
"facturascripts/facturascripts": "<=2025.4|==2025.11|==2025.41|==2025.43",
|
||||
"fastly/magento2": "<1.2.26",
|
||||
"feehi/cms": "<=2.1.1",
|
||||
"feehi/feehicms": "<=2.1.1",
|
||||
@@ -1339,7 +1342,7 @@
|
||||
"geshi/geshi": "<=1.0.9.1",
|
||||
"getformwork/formwork": "<2.2",
|
||||
"getgrav/grav": "<1.11.0.0-beta1",
|
||||
"getkirby/cms": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1|>=5,<5.1.4",
|
||||
"getkirby/cms": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1|>=5,<=5.2.1",
|
||||
"getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1",
|
||||
"getkirby/panel": "<2.5.14",
|
||||
"getkirby/starterkit": "<=3.7.0.2",
|
||||
@@ -1568,7 +1571,7 @@
|
||||
"october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1",
|
||||
"october/october": "<3.7.5",
|
||||
"october/rain": "<1.0.472|>=1.1,<1.1.2",
|
||||
"october/system": "<3.7.5",
|
||||
"october/system": "<=3.7.12|>=4,<=4.0.11",
|
||||
"oliverklee/phpunit": "<3.5.15",
|
||||
"omeka/omeka-s": "<4.0.3",
|
||||
"onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1",
|
||||
@@ -1595,6 +1598,7 @@
|
||||
"pagekit/pagekit": "<=1.0.18",
|
||||
"paragonie/ecc": "<2.0.1",
|
||||
"paragonie/random_compat": "<2",
|
||||
"paragonie/sodium_compat": "<1.24|>=2,<2.5",
|
||||
"passbolt/passbolt_api": "<4.6.2",
|
||||
"paypal/adaptivepayments-sdk-php": "<=3.9.2",
|
||||
"paypal/invoice-sdk-php": "<=3.9",
|
||||
@@ -1631,14 +1635,15 @@
|
||||
"phpxmlrpc/extras": "<0.6.1",
|
||||
"phpxmlrpc/phpxmlrpc": "<4.9.2",
|
||||
"pi/pi": "<=2.5",
|
||||
"pimcore/admin-ui-classic-bundle": "<1.7.6",
|
||||
"pimcore/admin-ui-classic-bundle": "<=1.7.15|>=2.0.0.0-RC1-dev,<=2.2.2",
|
||||
"pimcore/customer-management-framework-bundle": "<4.2.1",
|
||||
"pimcore/data-hub": "<1.2.4",
|
||||
"pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3",
|
||||
"pimcore/demo": "<10.3",
|
||||
"pimcore/ecommerce-framework-bundle": "<1.0.10",
|
||||
"pimcore/perspective-editor": "<1.5.1",
|
||||
"pimcore/pimcore": "<11.5.4",
|
||||
"pimcore/pimcore": "<=11.5.13|>=12.0.0.0-RC1-dev,<12.3.1",
|
||||
"pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1",
|
||||
"piwik/piwik": "<1.11",
|
||||
"pixelfed/pixelfed": "<0.12.5",
|
||||
"plotly/plotly.js": "<2.25.2",
|
||||
@@ -1662,7 +1667,7 @@
|
||||
"processwire/processwire": "<=3.0.246",
|
||||
"propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7",
|
||||
"propel/propel1": ">=1,<=1.7.1",
|
||||
"pterodactyl/panel": "<=1.11.10",
|
||||
"pterodactyl/panel": "<1.12",
|
||||
"ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2",
|
||||
"ptrofimov/beanstalk_console": "<1.7.14",
|
||||
"pubnub/pubnub": "<6.1",
|
||||
@@ -1680,7 +1685,7 @@
|
||||
"rap2hpoutre/laravel-log-viewer": "<0.13",
|
||||
"react/http": ">=0.7,<1.9",
|
||||
"really-simple-plugins/complianz-gdpr": "<6.4.2",
|
||||
"redaxo/source": "<5.20.1",
|
||||
"redaxo/source": "<=5.20.1",
|
||||
"remdex/livehelperchat": "<4.29",
|
||||
"renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1",
|
||||
"reportico-web/reportico": "<=8.1",
|
||||
@@ -1702,10 +1707,10 @@
|
||||
"setasign/fpdi": "<2.6.4",
|
||||
"sfroemken/url_redirect": "<=1.2.1",
|
||||
"sheng/yiicms": "<1.2.1",
|
||||
"shopware/core": "<6.6.10.9-dev|>=6.7,<6.7.4.1-dev",
|
||||
"shopware/core": "<6.6.10.9-dev|>=6.7,<6.7.6.1-dev",
|
||||
"shopware/platform": "<6.6.10.7-dev|>=6.7,<6.7.3.1-dev",
|
||||
"shopware/production": "<=6.3.5.2",
|
||||
"shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.5.1-dev",
|
||||
"shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev",
|
||||
"shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev",
|
||||
"shopxo/shopxo": "<=6.4",
|
||||
"showdoc/showdoc": "<2.10.4",
|
||||
@@ -1750,7 +1755,7 @@
|
||||
"snipe/snipe-it": "<=8.3.4",
|
||||
"socalnick/scn-social-auth": "<1.15.2",
|
||||
"socialiteproviders/steam": "<1.1",
|
||||
"solspace/craft-freeform": ">=5,<5.10.16",
|
||||
"solspace/craft-freeform": "<4.1.29|>=5,<5.10.16",
|
||||
"soosyze/soosyze": "<=2",
|
||||
"spatie/browsershot": "<5.0.5",
|
||||
"spatie/image-optimizer": "<1.7.3",
|
||||
@@ -1839,7 +1844,7 @@
|
||||
"thelia/thelia": ">=2.1,<2.1.3",
|
||||
"theonedemon/phpwhois": "<=4.2.5",
|
||||
"thinkcmf/thinkcmf": "<6.0.8",
|
||||
"thorsten/phpmyfaq": "<=4.0.13",
|
||||
"thorsten/phpmyfaq": "<4.0.16|>=4.1.0.0-alpha,<=4.1.0.0-beta2",
|
||||
"tikiwiki/tiki-manager": "<=17.1",
|
||||
"timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1",
|
||||
"tinymce/tinymce": "<7.2",
|
||||
@@ -1858,10 +1863,10 @@
|
||||
"twbs/bootstrap": "<3.4.1|>=4,<4.3.1",
|
||||
"twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19",
|
||||
"typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2",
|
||||
"typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
|
||||
"typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
|
||||
"typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
|
||||
"typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
|
||||
"typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
|
||||
"typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
|
||||
"typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
|
||||
"typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1",
|
||||
"typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
|
||||
@@ -1873,7 +1878,8 @@
|
||||
"typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2",
|
||||
"typo3/cms-lowlevel": ">=11,<=11.5.41",
|
||||
"typo3/cms-recordlist": ">=11,<11.5.48",
|
||||
"typo3/cms-recycler": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
|
||||
"typo3/cms-recycler": ">=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
|
||||
"typo3/cms-redirects": ">=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
|
||||
"typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30",
|
||||
"typo3/cms-scheduler": ">=11,<=11.5.41",
|
||||
"typo3/cms-setup": ">=9,<=9.5.50|>=10,<=10.4.49|>=11,<=11.5.43|>=12,<=12.4.30|>=13,<=13.4.11",
|
||||
@@ -1955,7 +1961,7 @@
|
||||
"yiisoft/yii2-redis": "<2.0.20",
|
||||
"yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6",
|
||||
"yoast-seo-for-typo3/yoast_seo": "<7.2.3",
|
||||
"yourls/yourls": "<=1.8.2",
|
||||
"yourls/yourls": "<=1.10.2",
|
||||
"yuan1994/tpadmin": "<=1.3.12",
|
||||
"yungifez/skuul": "<=2.6.5",
|
||||
"z-push/z-push-dev": "<2.7.6",
|
||||
@@ -2033,7 +2039,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-27T00:23:33+00:00"
|
||||
"time": "2026-01-16T21:05:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
85
gen/component/{{pascalCase name}}.test.ts
Normal file
85
gen/component/{{pascalCase name}}.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* Unit tests for {{pascalCase name}} component.
|
||||
*
|
||||
* See src/components/StatusBadge.test.ts for a complete example.
|
||||
*/
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
|
||||
import {{ pascalCase name }} from './{{pascalCase name}}.vue'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Mocks - uncomment as needed
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Mock @nextcloud/l10n (if your component uses t() or n())
|
||||
// vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
|
||||
|
||||
// Mock icon components (adjust path and name as needed)
|
||||
// vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('{{pascalCase name}}', () => {
|
||||
// Example: Basic rendering
|
||||
// it('renders correctly', () => {
|
||||
// const wrapper = mount({{pascalCase name}})
|
||||
// expect(wrapper.exists()).toBe(true)
|
||||
// })
|
||||
|
||||
// Example: Testing with props
|
||||
// it('renders with props', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { title: 'Hello' },
|
||||
// })
|
||||
// expect(wrapper.text()).toContain('Hello')
|
||||
// })
|
||||
|
||||
// Example: Testing CSS classes
|
||||
// it('applies correct CSS class', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { variant: 'primary' },
|
||||
// })
|
||||
// expect(wrapper.classes()).toContain('is-primary')
|
||||
// })
|
||||
|
||||
// Example: Testing emitted events
|
||||
// it('emits click event', async () => {
|
||||
// const wrapper = mount({{pascalCase name}})
|
||||
// await wrapper.trigger('click')
|
||||
// expect(wrapper.emitted('click')).toBeTruthy()
|
||||
// })
|
||||
|
||||
// Example: Testing computed properties
|
||||
// it('computes derived value', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { count: 5 },
|
||||
// })
|
||||
// const vm = wrapper.vm as InstanceType<typeof {{pascalCase name}}>
|
||||
// expect(vm.doubleCount).toBe(10)
|
||||
// })
|
||||
|
||||
// Example: Testing conditional rendering
|
||||
// it('shows content when condition is met', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// props: { showDetails: true },
|
||||
// })
|
||||
// expect(wrapper.find('.details').exists()).toBe(true)
|
||||
// })
|
||||
|
||||
// Example: Testing slots
|
||||
// it('renders slot content', () => {
|
||||
// const wrapper = mount({{pascalCase name}}, {
|
||||
// slots: { default: 'Slot content' },
|
||||
// })
|
||||
// expect(wrapper.text()).toContain('Slot content')
|
||||
// })
|
||||
|
||||
it.todo('add your tests here')
|
||||
})
|
||||
@@ -40,7 +40,7 @@ class {{pascalCase name}}Mapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $projectId
|
||||
* @param string $id
|
||||
* @return array<{{pascalCase name}}>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
|
||||
@@ -8,11 +8,11 @@ OC.L10N.register(
|
||||
"General" : "Агульныя",
|
||||
"Support" : "Падтрымка",
|
||||
"Attachment" : "Далучэнне",
|
||||
"Welcome to Nextcloud Forums" : "Вітаем на Форумах Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Вітаем на Форумах Nextcloud!",
|
||||
"Bold text" : "Тоўсты тэкст",
|
||||
"Italic text" : "Тэкст курсівам",
|
||||
"Underlined text" : "Падкрэслены тэкст",
|
||||
"Welcome to Nextcloud Forums" : "Вітаем на Форумах Nextcloud",
|
||||
"Forum" : "Форум",
|
||||
"Welcome to the forum!" : "Вітаем на форуме!",
|
||||
"Deleted user" : "Выдалены карыстальнік",
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
"General" : "Агульныя",
|
||||
"Support" : "Падтрымка",
|
||||
"Attachment" : "Далучэнне",
|
||||
"Welcome to Nextcloud Forums" : "Вітаем на Форумах Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Вітаем на Форумах Nextcloud!",
|
||||
"Bold text" : "Тоўсты тэкст",
|
||||
"Italic text" : "Тэкст курсівам",
|
||||
"Underlined text" : "Падкрэслены тэкст",
|
||||
"Welcome to Nextcloud Forums" : "Вітаем на Форумах Nextcloud",
|
||||
"Forum" : "Форум",
|
||||
"Welcome to the forum!" : "Вітаем на форуме!",
|
||||
"Deleted user" : "Выдалены карыстальнік",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Skrytý obsah",
|
||||
"Spoilers" : "Spoilery",
|
||||
"Attachment" : "Příloha",
|
||||
"Welcome to Nextcloud Forums" : "Vítejte v Nextcloud fórech",
|
||||
"Welcome to the Nextcloud Forums!" : "Vítejte v Nextcloud fórech!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Toto je komunitou řízené fórum, vestavěné přímo do vámi využívané instance Nextcloud. Je možné zde probírat témata, sdílet nápady a spolupracovat s ostatními uživateli.",
|
||||
"Features:" : "Funkce:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Skloněný text",
|
||||
"Underlined text" : "Podtržený text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Neváhejte zahájit novou diskuzi nebo odpovězte na existující vlákna. Vesele pište příspěvky!.",
|
||||
"Welcome to Nextcloud Forums" : "Vítejte v Nextcloud fórech",
|
||||
"Forum" : "Diskuzní fórum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nová odpověď v {thread} ","{count} nové odpovědi v {thread} ","{count} nových odpovědí v {thread} ","{count} nové odpovědi v {thread} "],
|
||||
"{user} mentioned you in {thread}" : "{user} vás zmínil(a) v {thread}",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Skrytý obsah",
|
||||
"Spoilers" : "Spoilery",
|
||||
"Attachment" : "Příloha",
|
||||
"Welcome to Nextcloud Forums" : "Vítejte v Nextcloud fórech",
|
||||
"Welcome to the Nextcloud Forums!" : "Vítejte v Nextcloud fórech!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Toto je komunitou řízené fórum, vestavěné přímo do vámi využívané instance Nextcloud. Je možné zde probírat témata, sdílet nápady a spolupracovat s ostatními uživateli.",
|
||||
"Features:" : "Funkce:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Skloněný text",
|
||||
"Underlined text" : "Podtržený text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Neváhejte zahájit novou diskuzi nebo odpovězte na existující vlákna. Vesele pište příspěvky!.",
|
||||
"Welcome to Nextcloud Forums" : "Vítejte v Nextcloud fórech",
|
||||
"Forum" : "Diskuzní fórum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nová odpověď v {thread} ","{count} nové odpovědi v {thread} ","{count} nových odpovědí v {thread} ","{count} nové odpovědi v {thread} "],
|
||||
"{user} mentioned you in {thread}" : "{user} vás zmínil(a) v {thread}",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Versteckter Inhalt",
|
||||
"Spoilers" : "Spoiler",
|
||||
"Attachment" : "Anhang",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Welcome to the Nextcloud Forums!" : "Willkommen in den Nextcloud Foren!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Dies ist ein Community-Forum, das direkt in deine Nextcloud-Instanz integriert ist. Hier kannst du Themen diskutieren, Ideen austauschen und mit anderen Nutzern zusammenarbeiten.",
|
||||
"Features:" : "Funktionen:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Kursiver Text",
|
||||
"Underlined text" : "Unterstrichener Text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Gerne eine neue Diskussion starten oder auf bestehende Beiträge antworten. Viel Spaß beim Posten!",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} neue Antwort in {thread}","{count} neue Antworten in {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} hat dich in {thread} erwähnt",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "Einrichten, wie du Benachrichtigungen erhältst",
|
||||
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten auf von dir erstellte Themen",
|
||||
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten in Themen, in denen du geantwortet hast",
|
||||
"Files" : "Dateien",
|
||||
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
|
||||
"Upload directory" : "Hochladeverzeichnis",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Versteckter Inhalt",
|
||||
"Spoilers" : "Spoiler",
|
||||
"Attachment" : "Anhang",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Welcome to the Nextcloud Forums!" : "Willkommen in den Nextcloud Foren!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Dies ist ein Community-Forum, das direkt in deine Nextcloud-Instanz integriert ist. Hier kannst du Themen diskutieren, Ideen austauschen und mit anderen Nutzern zusammenarbeiten.",
|
||||
"Features:" : "Funktionen:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Kursiver Text",
|
||||
"Underlined text" : "Unterstrichener Text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Gerne eine neue Diskussion starten oder auf bestehende Beiträge antworten. Viel Spaß beim Posten!",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} neue Antwort in {thread}","{count} neue Antworten in {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} hat dich in {thread} erwähnt",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "Einrichten, wie du Benachrichtigungen erhältst",
|
||||
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten auf von dir erstellte Themen",
|
||||
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhältst du automatisch Benachrichtigungen für Antworten in Themen, in denen du geantwortet hast",
|
||||
"Files" : "Dateien",
|
||||
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
|
||||
"Upload directory" : "Hochladeverzeichnis",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Versteckter Inhalt",
|
||||
"Spoilers" : "Spoilerwarnung",
|
||||
"Attachment" : "Anhang",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Welcome to the Nextcloud Forums!" : "Willkommen in den Nextcloud Foren!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Dies ist ein Community-Forum, das direkt in Ihre Nextcloud-Instanz integriert ist. Hier können Sie Themen diskutieren, Ideen austauschen und mit anderen Nutzern zusammenarbeiten.",
|
||||
"Features:" : "Funktionen:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Kursiver Text",
|
||||
"Underlined text" : "Unterstrichener Text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Gerne eine neue Diskussion starten oder auf bestehende Beiträge antworten. Viel Spaß beim Posten!",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} neue Antwort in {thread}","{count} neue Antworten in {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} hat Sie in {thread} erwähnt",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "Einrichten, wie Sie Benachrichtigungen erhalten",
|
||||
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten auf von Ihnen erstellte Themen",
|
||||
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten in Themen, in denen Sie geantwortet haben",
|
||||
"Files" : "Dateien",
|
||||
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
|
||||
"Upload directory" : "Hochladeverzeichnis",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Versteckter Inhalt",
|
||||
"Spoilers" : "Spoilerwarnung",
|
||||
"Attachment" : "Anhang",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Welcome to the Nextcloud Forums!" : "Willkommen in den Nextcloud Foren!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Dies ist ein Community-Forum, das direkt in Ihre Nextcloud-Instanz integriert ist. Hier können Sie Themen diskutieren, Ideen austauschen und mit anderen Nutzern zusammenarbeiten.",
|
||||
"Features:" : "Funktionen:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Kursiver Text",
|
||||
"Underlined text" : "Unterstrichener Text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Gerne eine neue Diskussion starten oder auf bestehende Beiträge antworten. Viel Spaß beim Posten!",
|
||||
"Welcome to Nextcloud Forums" : "Willkommen in den Nextcloud Foren",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} neue Antwort in {thread}","{count} neue Antworten in {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} hat Sie in {thread} erwähnt",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "Einrichten, wie Sie Benachrichtigungen erhalten",
|
||||
"Auto-subscribe to threads I create" : "Themen die ich erstellt habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten auf von Ihnen erstellte Themen",
|
||||
"Auto-subscribe to threads I reply to" : "Themen auf die ich geantwortet habe, automatisch abonnieren",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Wenn diese Option aktiviert ist, erhalten Sie automatisch Benachrichtigungen für Antworten in Themen, in denen Sie geantwortet haben",
|
||||
"Files" : "Dateien",
|
||||
"Configure file upload settings" : "Einstellungen für das Hochladen von Dateien",
|
||||
"Upload directory" : "Hochladeverzeichnis",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Hidden content",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Attachment",
|
||||
"Welcome to Nextcloud Forums" : "Welcome to Nextcloud Forums",
|
||||
"Welcome to the Nextcloud Forums!" : "Welcome to the Nextcloud Forums!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users.",
|
||||
"Features:" : "Features:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Italic text",
|
||||
"Underlined text" : "Underlined text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Feel free to start a new discussion or reply to existing threads. Happy posting!",
|
||||
"Welcome to Nextcloud Forums" : "Welcome to Nextcloud Forums",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} new reply in {thread}","{count} new replies in {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mentioned you in {thread}",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "Configure how you receive notifications",
|
||||
"Auto-subscribe to threads I create" : "Auto-subscribe to threads I create",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "When enabled, you will automatically receive notifications for replies to threads you create",
|
||||
"Auto-subscribe to threads I reply to" : "Auto-subscribe to threads I reply to",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "When enabled, you will automatically receive notifications for new replies in threads you have replied to",
|
||||
"Files" : "Files",
|
||||
"Configure file upload settings" : "Configure file upload settings",
|
||||
"Upload directory" : "Upload directory",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Hidden content",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Attachment",
|
||||
"Welcome to Nextcloud Forums" : "Welcome to Nextcloud Forums",
|
||||
"Welcome to the Nextcloud Forums!" : "Welcome to the Nextcloud Forums!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users.",
|
||||
"Features:" : "Features:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Italic text",
|
||||
"Underlined text" : "Underlined text",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Feel free to start a new discussion or reply to existing threads. Happy posting!",
|
||||
"Welcome to Nextcloud Forums" : "Welcome to Nextcloud Forums",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} new reply in {thread}","{count} new replies in {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mentioned you in {thread}",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "Configure how you receive notifications",
|
||||
"Auto-subscribe to threads I create" : "Auto-subscribe to threads I create",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "When enabled, you will automatically receive notifications for replies to threads you create",
|
||||
"Auto-subscribe to threads I reply to" : "Auto-subscribe to threads I reply to",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "When enabled, you will automatically receive notifications for new replies in threads you have replied to",
|
||||
"Files" : "Files",
|
||||
"Configure file upload settings" : "Configure file upload settings",
|
||||
"Upload directory" : "Upload directory",
|
||||
|
||||
@@ -2,11 +2,15 @@ OC.L10N.register(
|
||||
"forum",
|
||||
{
|
||||
"Admin" : "Administrador",
|
||||
"Administrator role with full permissions" : "Rol de administrador con permisos completos",
|
||||
"Moderator" : "Moderador",
|
||||
"Moderator role with elevated permissions" : "Rol de moderador con permisos elevados",
|
||||
"User" : "Usuario",
|
||||
"Default user role with basic permissions" : "Rol de usuario por defecto con permisos básicos",
|
||||
"Guest" : "Invitado",
|
||||
"Guest role for unauthenticated users with read-only access" : "Rol de invitado para usuarios sin autenticar con acceso de solo lectura",
|
||||
"General" : "General",
|
||||
"General discussion categories" : "Categorías de discusión general",
|
||||
"Support" : "Soporte",
|
||||
"Bold text" : "Texto en negrita",
|
||||
"Underlined text" : "Texto subrayado",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{ "translations": {
|
||||
"Admin" : "Administrador",
|
||||
"Administrator role with full permissions" : "Rol de administrador con permisos completos",
|
||||
"Moderator" : "Moderador",
|
||||
"Moderator role with elevated permissions" : "Rol de moderador con permisos elevados",
|
||||
"User" : "Usuario",
|
||||
"Default user role with basic permissions" : "Rol de usuario por defecto con permisos básicos",
|
||||
"Guest" : "Invitado",
|
||||
"Guest role for unauthenticated users with read-only access" : "Rol de invitado para usuarios sin autenticar con acceso de solo lectura",
|
||||
"General" : "General",
|
||||
"General discussion categories" : "Categorías de discusión general",
|
||||
"Support" : "Soporte",
|
||||
"Bold text" : "Texto en negrita",
|
||||
"Underlined text" : "Texto subrayado",
|
||||
|
||||
@@ -18,7 +18,6 @@ OC.L10N.register(
|
||||
"Inline code" : "Código en línea",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Adjunto",
|
||||
"Welcome to Nextcloud Forums" : "Bienvenido a los Foros de Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "¡Bienvenido a los Foros de Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Este es un foro impulsado por la comunidad integrado directamente en tu instancia de Nextcloud. Aquí puedes discutir temas, compartir ideas y colaborar con otros usuarios.",
|
||||
"Features:" : "Funciones:",
|
||||
@@ -34,6 +33,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Texto en cursiva",
|
||||
"Underlined text" : "Texto subrayado",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "No dudes en iniciar una nueva discusión o responder a hilos existentes. ¡Feliz publicación!",
|
||||
"Welcome to Nextcloud Forums" : "Bienvenido a los Foros de Nextcloud",
|
||||
"Forum" : "Foro",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nueva respuesta en {thread}","{count} nuevas respuestas en {thread}","{count} nuevas respuestas en {thread}"],
|
||||
"Welcome to the forum!" : "¡Bienvenido al foro!",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"Inline code" : "Código en línea",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Adjunto",
|
||||
"Welcome to Nextcloud Forums" : "Bienvenido a los Foros de Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "¡Bienvenido a los Foros de Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Este es un foro impulsado por la comunidad integrado directamente en tu instancia de Nextcloud. Aquí puedes discutir temas, compartir ideas y colaborar con otros usuarios.",
|
||||
"Features:" : "Funciones:",
|
||||
@@ -32,6 +31,7 @@
|
||||
"Italic text" : "Texto en cursiva",
|
||||
"Underlined text" : "Texto subrayado",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "No dudes en iniciar una nueva discusión o responder a hilos existentes. ¡Feliz publicación!",
|
||||
"Welcome to Nextcloud Forums" : "Bienvenido a los Foros de Nextcloud",
|
||||
"Forum" : "Foro",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nueva respuesta en {thread}","{count} nuevas respuestas en {thread}","{count} nuevas respuestas en {thread}"],
|
||||
"Welcome to the forum!" : "¡Bienvenido al foro!",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Peidetud sisu",
|
||||
"Spoilers" : "Spoilerid/tujurikkujad",
|
||||
"Attachment" : "Manus",
|
||||
"Welcome to Nextcloud Forums" : "Tere tulemast kasutama Nextcloudi Foorumit",
|
||||
"Welcome to the Nextcloud Forums!" : "Tere tulemast kasutama rakendust Nextcloudi Foorumid!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "See on kogukonnapõhine foorum, mis toimib otse sinu Nextcloudi serveris. Siin saad arutleda erinevatel teemadel, jagada mõtteid ning osaleda ühistöös.",
|
||||
"Features:" : "Foorumi võimalused:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Tekst kaldkirjas",
|
||||
"Underlined text" : "Allajoonitud tekst",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Alusta uut keskustelu või vasta olemasolevale jutulõngale. Soovime sulle toimekat suhtlust!",
|
||||
"Welcome to Nextcloud Forums" : "Tere tulemast kasutama Nextcloudi Foorumit",
|
||||
"Forum" : "Foorum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} uus vastus jutulõngas {thread}","{count} uut vastust jutulõngas {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mainis sind jutulõngas „{thread}“",
|
||||
@@ -281,6 +281,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "Seadista endale teavituste saatmise viisi",
|
||||
"Auto-subscribe to threads I create" : "Telli minu loodud jutulõngad automaatselt",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse sinu loodud jutulõngade vastuste puhul",
|
||||
"Auto-subscribe to threads I reply to" : "Telli minu poolt vastatud jutulõngad automaatselt",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse nende jutulõngade kohta, kuhu oled vastuse kirjutanud",
|
||||
"Files" : "Failid",
|
||||
"Configure file upload settings" : "Seadista failide üleslaadimise seadistusi",
|
||||
"Upload directory" : "Üleslaadimiskaust",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Peidetud sisu",
|
||||
"Spoilers" : "Spoilerid/tujurikkujad",
|
||||
"Attachment" : "Manus",
|
||||
"Welcome to Nextcloud Forums" : "Tere tulemast kasutama Nextcloudi Foorumit",
|
||||
"Welcome to the Nextcloud Forums!" : "Tere tulemast kasutama rakendust Nextcloudi Foorumid!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "See on kogukonnapõhine foorum, mis toimib otse sinu Nextcloudi serveris. Siin saad arutleda erinevatel teemadel, jagada mõtteid ning osaleda ühistöös.",
|
||||
"Features:" : "Foorumi võimalused:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Tekst kaldkirjas",
|
||||
"Underlined text" : "Allajoonitud tekst",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Alusta uut keskustelu või vasta olemasolevale jutulõngale. Soovime sulle toimekat suhtlust!",
|
||||
"Welcome to Nextcloud Forums" : "Tere tulemast kasutama Nextcloudi Foorumit",
|
||||
"Forum" : "Foorum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} uus vastus jutulõngas {thread}","{count} uut vastust jutulõngas {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mainis sind jutulõngas „{thread}“",
|
||||
@@ -279,6 +279,8 @@
|
||||
"Configure how you receive notifications" : "Seadista endale teavituste saatmise viisi",
|
||||
"Auto-subscribe to threads I create" : "Telli minu loodud jutulõngad automaatselt",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse sinu loodud jutulõngade vastuste puhul",
|
||||
"Auto-subscribe to threads I reply to" : "Telli minu poolt vastatud jutulõngad automaatselt",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Kui see eelistus on kasutusel, siis saad automaatselt teavituse nende jutulõngade kohta, kuhu oled vastuse kirjutanud",
|
||||
"Files" : "Failid",
|
||||
"Configure file upload settings" : "Seadista failide üleslaadimise seadistusi",
|
||||
"Upload directory" : "Üleslaadimiskaust",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Ábhar i bhfolach",
|
||||
"Spoilers" : "Spoiléirí",
|
||||
"Attachment" : "Ceangaltán",
|
||||
"Welcome to Nextcloud Forums" : "Fáilte go Fóraim Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Fáilte go dtí Fóraim Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Is fóram pobail-thiomáinte é seo atá tógtha isteach i do chás Nextcloud. Anseo is féidir leat topaicí a phlé, smaointe a roinnt agus comhoibriú le húsáideoirí eile.",
|
||||
"Features:" : "Gnéithe:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Téacs iodálach",
|
||||
"Underlined text" : "Téacs a bhfuil líne faoi",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Ná bíodh drogall ort plé nua a thosú nó freagra a thabhairt ar shnáitheanna atá ann cheana féin. Go n-éirí leat ag postáil!",
|
||||
"Welcome to Nextcloud Forums" : "Fáilte go Fóraim Nextcloud",
|
||||
"Forum" : "Fóram",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count}freagra nua i {thread}","{count} freagraí nua i {thread}","{count} freagraí nua i {thread}","{count} freagraí nua i {thread}","{count} freagraí nua i {thread}"],
|
||||
"{user} mentioned you in {thread}" : "Luaigh {user} thú i {thread}",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "Cumraigh conas a fhaigheann tú fógraí",
|
||||
"Auto-subscribe to threads I create" : "Liostáil go huathoibríoch le snáitheanna a chruthaím",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch le haghaidh freagraí ar shnáitheanna a chruthaíonn tú",
|
||||
"Auto-subscribe to threads I reply to" : "Liostáil go huathoibríoch le snáitheanna a bhfreagraím orthu",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch faoi fhreagraí nua i snáitheanna ar fhreagair tú iad",
|
||||
"Files" : "Comhaid",
|
||||
"Configure file upload settings" : "Cumraigh socruithe uaslódála comhad",
|
||||
"Upload directory" : "Uaslódáil eolaire",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Ábhar i bhfolach",
|
||||
"Spoilers" : "Spoiléirí",
|
||||
"Attachment" : "Ceangaltán",
|
||||
"Welcome to Nextcloud Forums" : "Fáilte go Fóraim Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Fáilte go dtí Fóraim Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Is fóram pobail-thiomáinte é seo atá tógtha isteach i do chás Nextcloud. Anseo is féidir leat topaicí a phlé, smaointe a roinnt agus comhoibriú le húsáideoirí eile.",
|
||||
"Features:" : "Gnéithe:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Téacs iodálach",
|
||||
"Underlined text" : "Téacs a bhfuil líne faoi",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Ná bíodh drogall ort plé nua a thosú nó freagra a thabhairt ar shnáitheanna atá ann cheana féin. Go n-éirí leat ag postáil!",
|
||||
"Welcome to Nextcloud Forums" : "Fáilte go Fóraim Nextcloud",
|
||||
"Forum" : "Fóram",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count}freagra nua i {thread}","{count} freagraí nua i {thread}","{count} freagraí nua i {thread}","{count} freagraí nua i {thread}","{count} freagraí nua i {thread}"],
|
||||
"{user} mentioned you in {thread}" : "Luaigh {user} thú i {thread}",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "Cumraigh conas a fhaigheann tú fógraí",
|
||||
"Auto-subscribe to threads I create" : "Liostáil go huathoibríoch le snáitheanna a chruthaím",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch le haghaidh freagraí ar shnáitheanna a chruthaíonn tú",
|
||||
"Auto-subscribe to threads I reply to" : "Liostáil go huathoibríoch le snáitheanna a bhfreagraím orthu",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Nuair a bheidh sé cumasaithe, gheobhaidh tú fógraí go huathoibríoch faoi fhreagraí nua i snáitheanna ar fhreagair tú iad",
|
||||
"Files" : "Comhaid",
|
||||
"Configure file upload settings" : "Cumraigh socruithe uaslódála comhad",
|
||||
"Upload directory" : "Uaslódáil eolaire",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Contido agochado",
|
||||
"Spoilers" : "Destripes",
|
||||
"Attachment" : "Anexo",
|
||||
"Welcome to Nextcloud Forums" : "Dámoslle a benvida a os Foros de Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Dámoslle a benvida a os Foros de Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Este é un foro impulsado pola comunidade integrado directamente na súa instancia de Nextcloud. Aquí pode debater temas, compartir ideas e colaborar con outros usuarios.",
|
||||
"Features:" : "Funcionalidades:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Texto en cursiva",
|
||||
"Underlined text" : "Texto subliñado",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Síntase libre de iniciar un novo debate ou de responder a fíos existentes. Feliz publicación!",
|
||||
"Welcome to Nextcloud Forums" : "Dámoslle a benvida a os Foros de Nextcloud",
|
||||
"Forum" : "Foro",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nova resposta en {thread}","{count} novas respostas en {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mencionouno a Vde. en {thread}",
|
||||
@@ -93,7 +93,7 @@ OC.L10N.register(
|
||||
"Pick file from Nextcloud" : "Seleccionar un ficheiro en Nextcloud",
|
||||
"Upload file to Nextcloud" : "Enviar un ficheiro a Nextcloud",
|
||||
"Uploading file …" : "Enviando o ficheiro…",
|
||||
"Upload failed" : "Produciuse algún fallo no envío",
|
||||
"Upload failed" : "Produciuse un fallo no envío",
|
||||
"Close" : "Pechar",
|
||||
"Pick a file to attach" : "Escolla un ficheiro para anexar",
|
||||
"Failed to upload file" : "Produciuse un fallo ao enviar o ficheiro",
|
||||
@@ -280,8 +280,10 @@ OC.L10N.register(
|
||||
"Error loading preferences" : "Produciuse un erro ao cargar as preferencias",
|
||||
"Notifications" : "Notificacións",
|
||||
"Configure how you receive notifications" : "Configure como quere recibir as notificacións",
|
||||
"Auto-subscribe to threads I create" : "Subscríbirse automaticamente aos fíos que creou",
|
||||
"Auto-subscribe to threads I create" : "Subscribirme automaticamente aos fíos que creo",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Cando estea activado, recibirá automaticamente notificacións das respostas aos fíos que cree",
|
||||
"Auto-subscribe to threads I reply to" : "Subscribirme automaticamente aos fíos que respondo",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Cando estea activado, recibirá automaticamente notificacións de novas respostas nos fíos aos que respondeu",
|
||||
"Files" : "Ficheiros",
|
||||
"Configure file upload settings" : "Configurar os axustes de envío de ficheiros",
|
||||
"Upload directory" : "Directorio ao que enviar",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Contido agochado",
|
||||
"Spoilers" : "Destripes",
|
||||
"Attachment" : "Anexo",
|
||||
"Welcome to Nextcloud Forums" : "Dámoslle a benvida a os Foros de Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Dámoslle a benvida a os Foros de Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Este é un foro impulsado pola comunidade integrado directamente na súa instancia de Nextcloud. Aquí pode debater temas, compartir ideas e colaborar con outros usuarios.",
|
||||
"Features:" : "Funcionalidades:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Texto en cursiva",
|
||||
"Underlined text" : "Texto subliñado",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Síntase libre de iniciar un novo debate ou de responder a fíos existentes. Feliz publicación!",
|
||||
"Welcome to Nextcloud Forums" : "Dámoslle a benvida a os Foros de Nextcloud",
|
||||
"Forum" : "Foro",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nova resposta en {thread}","{count} novas respostas en {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mencionouno a Vde. en {thread}",
|
||||
@@ -91,7 +91,7 @@
|
||||
"Pick file from Nextcloud" : "Seleccionar un ficheiro en Nextcloud",
|
||||
"Upload file to Nextcloud" : "Enviar un ficheiro a Nextcloud",
|
||||
"Uploading file …" : "Enviando o ficheiro…",
|
||||
"Upload failed" : "Produciuse algún fallo no envío",
|
||||
"Upload failed" : "Produciuse un fallo no envío",
|
||||
"Close" : "Pechar",
|
||||
"Pick a file to attach" : "Escolla un ficheiro para anexar",
|
||||
"Failed to upload file" : "Produciuse un fallo ao enviar o ficheiro",
|
||||
@@ -278,8 +278,10 @@
|
||||
"Error loading preferences" : "Produciuse un erro ao cargar as preferencias",
|
||||
"Notifications" : "Notificacións",
|
||||
"Configure how you receive notifications" : "Configure como quere recibir as notificacións",
|
||||
"Auto-subscribe to threads I create" : "Subscríbirse automaticamente aos fíos que creou",
|
||||
"Auto-subscribe to threads I create" : "Subscribirme automaticamente aos fíos que creo",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Cando estea activado, recibirá automaticamente notificacións das respostas aos fíos que cree",
|
||||
"Auto-subscribe to threads I reply to" : "Subscribirme automaticamente aos fíos que respondo",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Cando estea activado, recibirá automaticamente notificacións de novas respostas nos fíos aos que respondeu",
|
||||
"Files" : "Ficheiros",
|
||||
"Configure file upload settings" : "Configurar os axustes de envío de ficheiros",
|
||||
"Upload directory" : "Directorio ao que enviar",
|
||||
|
||||
128
l10n/he.js
128
l10n/he.js
@@ -1,14 +1,14 @@
|
||||
OC.L10N.register(
|
||||
"forum",
|
||||
{
|
||||
"Admin" : "מנהל",
|
||||
"Administrator role with full permissions" : "תפקיד מנהל עם הרשאות מלאות",
|
||||
"Admin" : "אדמין",
|
||||
"Administrator role with full permissions" : "תפקיד אדמין עם הרשאות מלאות",
|
||||
"Moderator" : "מפקח",
|
||||
"Moderator role with elevated permissions" : "תפקיד מפקח עם הרשאות מוגבהות",
|
||||
"User" : "משתמש",
|
||||
"Default user role with basic permissions" : "תפקיד משתמש רגיל עם הרשאות בסיסיות",
|
||||
"Default user role with basic permissions" : "תפקיד משתמש ברירת מחדל עם הרשאות בסיסיות",
|
||||
"Guest" : "אורח",
|
||||
"Guest role for unauthenticated users with read-only access" : "תפקיד אורח עם הרשאות גישה לקריאה בלבד",
|
||||
"Guest role for unauthenticated users with read-only access" : "תפקיד אורח למשתמשים לא-מחוברים עם הרשאות גישה לקריאה בלבד",
|
||||
"General" : "כללי",
|
||||
"General discussion categories" : "קטגוריות לדיונים כלליים",
|
||||
"General discussions" : "דיונים כלליים",
|
||||
@@ -20,24 +20,29 @@ OC.L10N.register(
|
||||
"Hidden content" : "תוכן מוסתר",
|
||||
"Spoilers" : "ספוילרים",
|
||||
"Attachment" : "קובץ מצורף",
|
||||
"Welcome to Nextcloud Forums" : "ברוכים הבאים לפורום Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "ברוכים הבאים לפורום של Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "זהו פורום מונחה-קהילה מובנה ישירות ל-Nextcloud שלך. כאן ניתן להתדיין בנושאים שונים, לחלוק רעיונות ולשתף פעולה עם משתמשים אחרים.",
|
||||
"Features:" : "יכולות:",
|
||||
"Create and reply to threads" : "יצירה והגבה לשרשורים",
|
||||
"Organize discussions by categories" : "אירגון דיונים על-פי קטגוריות",
|
||||
"Use BBCode for rich text formatting" : "השתמשו ב-BBCode לפורמט טקסט עשיר",
|
||||
"Attach files from your Nextcloud storage" : "צירוף קבצים מאחסון ה-Nextcloud שלך",
|
||||
"React to posts" : "תגובות לשרשורים",
|
||||
"Track read/unread threads" : "מעקב אחר שרשורים שנקראו\\לא נקראו",
|
||||
"BBCode examples:" : "דוגמאות BBCode:",
|
||||
"Bold text" : "טקסט בולט",
|
||||
"Use %1$stext%2$s" : "השתמשו ב%1$sטקסט%2$s",
|
||||
"Italic text" : "טקסט מוטה",
|
||||
"Underlined text" : "טקסט בקו תחתון",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "הרגישו חופשי להתחיל שרשור חדש או להגיב לשרשורים קיימים. כתיבה מהנה!",
|
||||
"Welcome to Nextcloud Forums" : "ברוכים הבאים לפורום Nextcloud",
|
||||
"Forum" : "פורום",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["תגובה {count} חדשה ב-{thread}","{count} תגובות חדשות ב-{thread}","{count} תגובות חדשות ב-{thread}"],
|
||||
"{user} mentioned you in {thread}" : "אוזכרת על ידי {user}בנושא {thread}",
|
||||
"Welcome to the forum!" : "ברוכים הבאים לפורום!",
|
||||
"Deleted user" : "משתמש מחוק",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "פורום מונחה-קהילה מובנה ישירות להתקנת ה Nextcloud שלכם.",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "צרו דיונים, חלקו רעיונות ושתפו פעולה עם משתמשים ישירות ב-Nextcloud.\n\n**⚠️ הודעה לגבי שלב פיתוח מוקדם:**\nהיישום הזה נמצא בשלבי פיתוח מוקדמים. בעוד היישום שמיש, אתם עלולים להיתקל בבאגים או יכולות לא שלמות. נא לדווח בעיות ב-GitHub ולשקול לגבות את המידע שלכם באופן תכוף.\n\n**יכולות מפתח:**\n- **דיונים מונחי נושאים** - צרו והגיבו לנושאי דיון\n- **ארגון קטגוריות** - צרו מבנה לפורום שלכם עם קטגוריות וכותרות הניתנות להתאמה\n- **פורמט טקסט עשיר** - השתמשו ב-BBCode לפרמוט של פוסטים עם הדגשות, הטיות, קישורים, תמונות, קטעי קוד ועוד\n- **צירוף קבצים** - צרפו קבצים מתוך ה-Nextcloud שלכם לפוסטים\n- **תגובונים לפוסטים** - הגיבו לפוסטים עם תגובוני אמוג'י\n- **מעקב נקרא\\לא נקרא** - עקבו אחרי סטטוס הקריאה של פוסטים\n- **חיפוש** - מצאו דיונים מהר בעזרת חיפוש מובנה\n- **פרופילי משתמשים** - צפו בהיסטוריה וסטטיסטיקות של משתמשים\n- **הרשאות מבוססי תפקיד** - שלטו בגישה ומודרציה עם תפקידים גמישים\n- **גישה לאורחים**: גישת רשות ציבורית למשתמשים לא מחוברים עם הרשאות ניתנות להתאמה\n- **כלי ניהול** - נהלו קטגוריות, תפקידים, BBCode והגדרות פורום\n- **כלי מודרציה** - נעלו, הדביקו, ונהלו נושאים ותגובות\n\n**מושלם עבור:**\n- דיוני צוותים ושיתוף פעולה\n- פורום קהילתי\n- ערוצי תמיכה\n- ניהול ידע\n- דיוני פרוייקטים\n- תקשורת פנימית\n\nהפורום מתממשק באופן ישיר ל-Nextcloud שלכם, ומשתמש במשתמשים קיימים וקבוצות קיימות לניהול גישה והתחברות.",
|
||||
"Loading …" : "בטעינה…",
|
||||
"Search" : "חיפוש",
|
||||
"Home" : "בית",
|
||||
@@ -48,6 +53,7 @@ OC.L10N.register(
|
||||
"Users" : "משתמשים",
|
||||
"Roles" : "תפקידים",
|
||||
"Categories" : "קטגוריות",
|
||||
"BBCodes" : "BBCodes",
|
||||
"Expand" : "הרחבה",
|
||||
"Collapse" : "קיווץ",
|
||||
"{bStart}Please note:{bEnd} Attached files will be visible to anyone in the forum, regardless of the file's sharing settings." : "{bStart}נא לשים לב:{bEnd} קבצים מצורפים יהיו זמינים לכל מי שנמצא בפורום, ללא התחשבות בהגדרות השיתוף של הקובץ.",
|
||||
@@ -73,8 +79,16 @@ OC.L10N.register(
|
||||
"List" : "רשימה",
|
||||
"List item within a list" : "פריט רשימה בתוך רשימה",
|
||||
"List item within a list (alias)" : "פריט רשימה בתוך רשימה (חלופה)",
|
||||
"BBCode help" : "עזרה עם BBCode",
|
||||
"Built-in BBCodes" : "BBCode מובנים",
|
||||
"These BBCodes are available by default." : "ה-BBCode הללו זמינים כברירת מחדל.",
|
||||
"Custom BBCodes" : "BBCode מותאמים אישית",
|
||||
"These BBCodes are custom to this forum and configured by administrators." : "ה-BBCodes הללו מותאמים לפורום הזה וניתנים להגדרה על ידי צוות הניהול.",
|
||||
"Example" : "דוגמה",
|
||||
"Replacement" : "החלפה",
|
||||
"Loading custom BBCodes …" : "טוען BBCodes מותאמים …",
|
||||
"No custom BBCodes configured." : "אין BBCodes מותאמים מוגדרים.",
|
||||
"Failed to load custom BBCodes" : "כשלון בטעינת BBCodes מותאמים",
|
||||
"Insert emoji" : "הכנסת אימוג'י",
|
||||
"Pick file from Nextcloud" : "בחירת קובץ מ-Nextcloud",
|
||||
"Upload file to Nextcloud" : "העלאת קובץ ל-Nextcloud",
|
||||
@@ -84,6 +98,7 @@ OC.L10N.register(
|
||||
"Pick a file to attach" : "בחירת קובץ לצירוף",
|
||||
"Failed to upload file" : "כשלון בהעלאת קובץ",
|
||||
"Threads" : "שרשורים",
|
||||
"Replies" : "תגובות",
|
||||
"No description available" : "תיאור לא זמין",
|
||||
"Create category header" : "יצירת כותרת קטגוריות",
|
||||
"Edit category header" : "עריכת כותרת קטגוריות",
|
||||
@@ -108,26 +123,129 @@ OC.L10N.register(
|
||||
"The page you are looking for could not be found." : "העמוד שחיפשתם לא נמצא.",
|
||||
"Back" : "אחורה",
|
||||
"Go to home" : "חזרה אל דף הבית",
|
||||
"Pagination" : "דפדוף",
|
||||
"First page" : "דף ראשון",
|
||||
"Previous page" : "דף קודם",
|
||||
"Next page" : "דף הבא",
|
||||
"Last page" : "דף אחרון",
|
||||
"Go to page {page}" : "עבור לדף {page}",
|
||||
"Edited" : "נערך",
|
||||
"Quote reply" : "ציטוט בתגובה",
|
||||
"Edit" : "עריכה",
|
||||
"Delete" : "מחיקה",
|
||||
"View edit history" : "צפייה בהיסטוריית עריכה",
|
||||
"Are you sure you want to delete this post? This action cannot be undone." : "האם אתם בטוחים שתרצו למחוק את הפוסט הזה? הפעולה בלתי ניתנת להפיכה.",
|
||||
"Unread" : "לא נקרא",
|
||||
"Edit your reply …" : "ערכו את תגובתכם …",
|
||||
"Save" : "שמירה",
|
||||
"Are you sure you want to discard your changes?" : "האם אתם בטוחים שתרצו לבטל את השינויים שלכם?",
|
||||
"Edit history" : "היסטוריית עריכה",
|
||||
"Loading history …" : "טוען היסטוריה …",
|
||||
"This post has no edit history." : "לפוסט הזה אין היסטוריית עריכה.",
|
||||
"Current version" : "גרסה נוכחית",
|
||||
"Edited by" : "נערך על ידי",
|
||||
"Failed to load edit history" : "כשלון בטעינת היסטוריית עריכה",
|
||||
"Version {index}" : "גרסה {index}",
|
||||
"Add reaction" : "הוספת תגובון",
|
||||
"React with {emoji}" : "הגיבו עם {emoji}",
|
||||
"You reacted with {emoji}" : "הגבת עם {emoji}",
|
||||
"_You and %n other reacted with {emoji}_::_You and %n others reacted with {emoji}_" : ["את\\ה ועוד %n אחר הגבתם עם {emoji}","את\\ה ועוד %n אחרים הגבתם עם {emoji}","את\\ה ועוד %n אחרים הגבתם עם {emoji}"],
|
||||
"_%n person reacted with {emoji}_::_%n people reacted with {emoji}_" : ["אדם אחד הגיב עם {emoji}","%n אנשים הגיבו עם {emoji}","%n אנשים הגיבו עם {emoji}"],
|
||||
"Write your reply …" : "כתבו את תגובתכם …",
|
||||
"Submit reply" : "שליחת תגובה",
|
||||
"Are you sure you want to discard your reply?" : "אתם בטוחים שברצונכם לבטל את התגובה?",
|
||||
"In thread" : "בנושא",
|
||||
"Thread unavailable" : "נושא לא זמין",
|
||||
"Pinned thread" : "נושא מודבק",
|
||||
"Locked thread" : "נושא נעול",
|
||||
"Uncategorized" : "ללא קטגוריה",
|
||||
"_%n reply_::_%n replies_" : ["תגובה אחת","%n תגובות","%n תגובות"],
|
||||
"_%n view_::_%n views_" : ["צפייה אחת","%n צפיות","%n צפיות"],
|
||||
"Views" : "תצוגות",
|
||||
"Title" : "כותרת",
|
||||
"Enter thread title …" : "כתבו כותרת לנושא …",
|
||||
"Write your thread content …" : "כתבו תוכן לנושא שלכם …",
|
||||
"Create thread" : "צרו נושא",
|
||||
"Are you sure you want to discard this thread?" : "אתם בטוחים שברצונכם לבטל את הנושא?",
|
||||
"Saving draft …" : "הטיוטה נשמרת…",
|
||||
"Draft saved" : "הטיוטה נשמרה",
|
||||
"Unsaved changes" : "שינויים שלא נשמרו",
|
||||
"Back to home" : "חזרה לדף בית",
|
||||
"Refresh" : "רענון",
|
||||
"Your bookmarked threads" : "הנושאים בסימניה שלכם",
|
||||
"Error loading bookmarks" : "שגיאה בטעינת סימניות",
|
||||
"No bookmarks yet" : "ללא סימניות עדיין",
|
||||
"Bookmark threads to quickly find them later." : "סמנו נושאים כדי למצוא אותם מהר מאוחר יותר",
|
||||
"Retry" : "ניסיון חוזר",
|
||||
"An unexpected error occurred" : "קרתה שגיאה בלתי צפוייה",
|
||||
"Failed to load bookmarks" : "כשלון בטעינת סימניות",
|
||||
"No categories yet" : "ללא קטגוריות עדיין",
|
||||
"Categories will appear here once they are created." : "קטגוריות יופיעו כאן ברגע שייוצרו",
|
||||
"No categories in this section" : "אין קטגוריות באיזור הזה",
|
||||
"Category not found" : "קטגוריה לא נמצאה",
|
||||
"The category you are looking for does not exist or has been removed." : "הקטגוריה שאתם מחפשים לא קיימת או הוסרה.",
|
||||
"Back to categories" : "חזרה אל קטגוריות",
|
||||
"New thread" : "נושא חדש",
|
||||
"Error loading category" : "שגיאה בטעינת קטגוריות",
|
||||
"No threads yet" : "ללא נושאים עדיין",
|
||||
"Be the first to start a discussion in this category." : "היו הראשונים שיוצרים דיון בקטגוריה זאת.",
|
||||
"No category ID or slug provided" : "מזהה קטגוריה לא סופק ",
|
||||
"Failed to load threads" : "שגיאה בטעינת נושאים",
|
||||
"Create New Thread" : "צרו נושא חדש",
|
||||
"In {category}" : "בתוך {category}",
|
||||
"Creating thread …" : "יוצר נושא …",
|
||||
"Thread created" : "נושא נוצר",
|
||||
"Failed to create thread" : "כשלון ביצירת נושא",
|
||||
"No category specified" : "קטגוריה לא סופקה",
|
||||
"Error" : "שגיאה",
|
||||
"First activity" : "פעילות ראשונה",
|
||||
"Threads ({count})" : "נושאים ({count})",
|
||||
"Replies ({count})" : "תגובות ({count})",
|
||||
"No threads" : "ללא נושאים",
|
||||
"This user has not created any threads yet" : "משתמש זה לא יצר נושאים עדיין",
|
||||
"No replies" : "ללא תגובות",
|
||||
"This user has not written any replies yet" : "משתמש זה לא כתב תגובות עדיין",
|
||||
"Failed to load user profile" : "כשלון בטעינת פרופיל משתמש",
|
||||
"Enter search query …" : "הכניסו שאילתת חיפוש …",
|
||||
"Search in threads" : "חפשו בנושאים",
|
||||
"Search in replies" : "חפשו בתגובות",
|
||||
"Syntax help" : "עזרה בתחביר",
|
||||
"Search syntax" : "תחביר חיפוש",
|
||||
"Match exact phrase" : "התאם מלל מדוייק",
|
||||
"Both terms required" : "שני הערכים הם חובה",
|
||||
"Either term matches" : "ערך אחד או אחר מתאים",
|
||||
"Group conditions with parentheses" : "קבצו תנאים בעזרת סוגריים",
|
||||
"Exclude term from results" : "השמיטו מתוצאות החיפוש",
|
||||
"Searching …" : "מתבצע חיפוש…",
|
||||
"Search Error" : "שגיאה בחיפוש",
|
||||
"Enter a search query" : "הכניסו שאילתת חיפוש",
|
||||
"Use the search box above to find threads and replies" : "השתמשו בתיבת החיפוש למעלה כדי למצוא נושאים ותגובות",
|
||||
"No results found" : "לא נמצאו תוצאות",
|
||||
"Try different keywords or check your syntax" : "נסו מילות מפתח אחרות או בדקו שוב את תחביר החיפוש שלכם",
|
||||
"_%n thread found_::_%n threads found_" : ["נמצא נושא אחד","נמצאו %n נושאים","נמצאו %n נושאים"],
|
||||
"_%n reply found_::_%n replies found_" : ["נמצאה תגובה אחת","נמצאו %n תגובות","נמצאו %n תגובות"],
|
||||
"Please enter a search query" : "נא להכניס שאילתת חיפוש",
|
||||
"Please select at least one search scope" : "נא בחרו לפחות איזור חיפוש אחד",
|
||||
"Failed to search" : "כשלון בחיפוש",
|
||||
"Thread not found" : "נושא לא נמצא",
|
||||
"The thread you are looking for does not exist or has been removed." : "הנושא שאתם מנסים לפתוח לא קיים או הוסר",
|
||||
"Back to {category}" : "חזרה אל {category}",
|
||||
"Reply" : "תגובה",
|
||||
"Error loading thread" : "שגיאה בטעינת נושא",
|
||||
"No replies yet" : "ללא תגובות עדיין",
|
||||
"Be the first to reply in this thread." : "היו הראשונים שיגיבו לנושא זה",
|
||||
"by" : "מאת",
|
||||
"This thread is locked. Only moderators can add replies." : "הנושא הזה נעול. רק מנהלים יכולים להוסיף תגובות.",
|
||||
"You must be signed in to reply to this thread." : "אתם חייבים להיות מחוברים כדי להגיב לנושא זה.",
|
||||
"Sign in to reply" : "התחברו כדי להגיב",
|
||||
"Lock thread" : "נעילת נושא",
|
||||
"Unlock thread" : "ביטול נעילת נושא",
|
||||
"Pin thread" : "הדבקת נושא",
|
||||
"Unpin thread" : "ביטול הדבקת נושא",
|
||||
"Thread locked" : "הנושא ננעל",
|
||||
"Thread unlocked" : "בוטלה נעילת הנושא",
|
||||
"Thread pinned" : "הנושא הודבק",
|
||||
"Thread unpinned" : "בוטלה הדבקת הנושא",
|
||||
"Subscribe" : "הרשמה",
|
||||
"Bookmark" : "סימנייה",
|
||||
"Edit title" : "עריכת כותרת",
|
||||
|
||||
128
l10n/he.json
128
l10n/he.json
@@ -1,12 +1,12 @@
|
||||
{ "translations": {
|
||||
"Admin" : "מנהל",
|
||||
"Administrator role with full permissions" : "תפקיד מנהל עם הרשאות מלאות",
|
||||
"Admin" : "אדמין",
|
||||
"Administrator role with full permissions" : "תפקיד אדמין עם הרשאות מלאות",
|
||||
"Moderator" : "מפקח",
|
||||
"Moderator role with elevated permissions" : "תפקיד מפקח עם הרשאות מוגבהות",
|
||||
"User" : "משתמש",
|
||||
"Default user role with basic permissions" : "תפקיד משתמש רגיל עם הרשאות בסיסיות",
|
||||
"Default user role with basic permissions" : "תפקיד משתמש ברירת מחדל עם הרשאות בסיסיות",
|
||||
"Guest" : "אורח",
|
||||
"Guest role for unauthenticated users with read-only access" : "תפקיד אורח עם הרשאות גישה לקריאה בלבד",
|
||||
"Guest role for unauthenticated users with read-only access" : "תפקיד אורח למשתמשים לא-מחוברים עם הרשאות גישה לקריאה בלבד",
|
||||
"General" : "כללי",
|
||||
"General discussion categories" : "קטגוריות לדיונים כלליים",
|
||||
"General discussions" : "דיונים כלליים",
|
||||
@@ -18,24 +18,29 @@
|
||||
"Hidden content" : "תוכן מוסתר",
|
||||
"Spoilers" : "ספוילרים",
|
||||
"Attachment" : "קובץ מצורף",
|
||||
"Welcome to Nextcloud Forums" : "ברוכים הבאים לפורום Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "ברוכים הבאים לפורום של Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "זהו פורום מונחה-קהילה מובנה ישירות ל-Nextcloud שלך. כאן ניתן להתדיין בנושאים שונים, לחלוק רעיונות ולשתף פעולה עם משתמשים אחרים.",
|
||||
"Features:" : "יכולות:",
|
||||
"Create and reply to threads" : "יצירה והגבה לשרשורים",
|
||||
"Organize discussions by categories" : "אירגון דיונים על-פי קטגוריות",
|
||||
"Use BBCode for rich text formatting" : "השתמשו ב-BBCode לפורמט טקסט עשיר",
|
||||
"Attach files from your Nextcloud storage" : "צירוף קבצים מאחסון ה-Nextcloud שלך",
|
||||
"React to posts" : "תגובות לשרשורים",
|
||||
"Track read/unread threads" : "מעקב אחר שרשורים שנקראו\\לא נקראו",
|
||||
"BBCode examples:" : "דוגמאות BBCode:",
|
||||
"Bold text" : "טקסט בולט",
|
||||
"Use %1$stext%2$s" : "השתמשו ב%1$sטקסט%2$s",
|
||||
"Italic text" : "טקסט מוטה",
|
||||
"Underlined text" : "טקסט בקו תחתון",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "הרגישו חופשי להתחיל שרשור חדש או להגיב לשרשורים קיימים. כתיבה מהנה!",
|
||||
"Welcome to Nextcloud Forums" : "ברוכים הבאים לפורום Nextcloud",
|
||||
"Forum" : "פורום",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["תגובה {count} חדשה ב-{thread}","{count} תגובות חדשות ב-{thread}","{count} תגובות חדשות ב-{thread}"],
|
||||
"{user} mentioned you in {thread}" : "אוזכרת על ידי {user}בנושא {thread}",
|
||||
"Welcome to the forum!" : "ברוכים הבאים לפורום!",
|
||||
"Deleted user" : "משתמש מחוק",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "פורום מונחה-קהילה מובנה ישירות להתקנת ה Nextcloud שלכם.",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "צרו דיונים, חלקו רעיונות ושתפו פעולה עם משתמשים ישירות ב-Nextcloud.\n\n**⚠️ הודעה לגבי שלב פיתוח מוקדם:**\nהיישום הזה נמצא בשלבי פיתוח מוקדמים. בעוד היישום שמיש, אתם עלולים להיתקל בבאגים או יכולות לא שלמות. נא לדווח בעיות ב-GitHub ולשקול לגבות את המידע שלכם באופן תכוף.\n\n**יכולות מפתח:**\n- **דיונים מונחי נושאים** - צרו והגיבו לנושאי דיון\n- **ארגון קטגוריות** - צרו מבנה לפורום שלכם עם קטגוריות וכותרות הניתנות להתאמה\n- **פורמט טקסט עשיר** - השתמשו ב-BBCode לפרמוט של פוסטים עם הדגשות, הטיות, קישורים, תמונות, קטעי קוד ועוד\n- **צירוף קבצים** - צרפו קבצים מתוך ה-Nextcloud שלכם לפוסטים\n- **תגובונים לפוסטים** - הגיבו לפוסטים עם תגובוני אמוג'י\n- **מעקב נקרא\\לא נקרא** - עקבו אחרי סטטוס הקריאה של פוסטים\n- **חיפוש** - מצאו דיונים מהר בעזרת חיפוש מובנה\n- **פרופילי משתמשים** - צפו בהיסטוריה וסטטיסטיקות של משתמשים\n- **הרשאות מבוססי תפקיד** - שלטו בגישה ומודרציה עם תפקידים גמישים\n- **גישה לאורחים**: גישת רשות ציבורית למשתמשים לא מחוברים עם הרשאות ניתנות להתאמה\n- **כלי ניהול** - נהלו קטגוריות, תפקידים, BBCode והגדרות פורום\n- **כלי מודרציה** - נעלו, הדביקו, ונהלו נושאים ותגובות\n\n**מושלם עבור:**\n- דיוני צוותים ושיתוף פעולה\n- פורום קהילתי\n- ערוצי תמיכה\n- ניהול ידע\n- דיוני פרוייקטים\n- תקשורת פנימית\n\nהפורום מתממשק באופן ישיר ל-Nextcloud שלכם, ומשתמש במשתמשים קיימים וקבוצות קיימות לניהול גישה והתחברות.",
|
||||
"Loading …" : "בטעינה…",
|
||||
"Search" : "חיפוש",
|
||||
"Home" : "בית",
|
||||
@@ -46,6 +51,7 @@
|
||||
"Users" : "משתמשים",
|
||||
"Roles" : "תפקידים",
|
||||
"Categories" : "קטגוריות",
|
||||
"BBCodes" : "BBCodes",
|
||||
"Expand" : "הרחבה",
|
||||
"Collapse" : "קיווץ",
|
||||
"{bStart}Please note:{bEnd} Attached files will be visible to anyone in the forum, regardless of the file's sharing settings." : "{bStart}נא לשים לב:{bEnd} קבצים מצורפים יהיו זמינים לכל מי שנמצא בפורום, ללא התחשבות בהגדרות השיתוף של הקובץ.",
|
||||
@@ -71,8 +77,16 @@
|
||||
"List" : "רשימה",
|
||||
"List item within a list" : "פריט רשימה בתוך רשימה",
|
||||
"List item within a list (alias)" : "פריט רשימה בתוך רשימה (חלופה)",
|
||||
"BBCode help" : "עזרה עם BBCode",
|
||||
"Built-in BBCodes" : "BBCode מובנים",
|
||||
"These BBCodes are available by default." : "ה-BBCode הללו זמינים כברירת מחדל.",
|
||||
"Custom BBCodes" : "BBCode מותאמים אישית",
|
||||
"These BBCodes are custom to this forum and configured by administrators." : "ה-BBCodes הללו מותאמים לפורום הזה וניתנים להגדרה על ידי צוות הניהול.",
|
||||
"Example" : "דוגמה",
|
||||
"Replacement" : "החלפה",
|
||||
"Loading custom BBCodes …" : "טוען BBCodes מותאמים …",
|
||||
"No custom BBCodes configured." : "אין BBCodes מותאמים מוגדרים.",
|
||||
"Failed to load custom BBCodes" : "כשלון בטעינת BBCodes מותאמים",
|
||||
"Insert emoji" : "הכנסת אימוג'י",
|
||||
"Pick file from Nextcloud" : "בחירת קובץ מ-Nextcloud",
|
||||
"Upload file to Nextcloud" : "העלאת קובץ ל-Nextcloud",
|
||||
@@ -82,6 +96,7 @@
|
||||
"Pick a file to attach" : "בחירת קובץ לצירוף",
|
||||
"Failed to upload file" : "כשלון בהעלאת קובץ",
|
||||
"Threads" : "שרשורים",
|
||||
"Replies" : "תגובות",
|
||||
"No description available" : "תיאור לא זמין",
|
||||
"Create category header" : "יצירת כותרת קטגוריות",
|
||||
"Edit category header" : "עריכת כותרת קטגוריות",
|
||||
@@ -106,26 +121,129 @@
|
||||
"The page you are looking for could not be found." : "העמוד שחיפשתם לא נמצא.",
|
||||
"Back" : "אחורה",
|
||||
"Go to home" : "חזרה אל דף הבית",
|
||||
"Pagination" : "דפדוף",
|
||||
"First page" : "דף ראשון",
|
||||
"Previous page" : "דף קודם",
|
||||
"Next page" : "דף הבא",
|
||||
"Last page" : "דף אחרון",
|
||||
"Go to page {page}" : "עבור לדף {page}",
|
||||
"Edited" : "נערך",
|
||||
"Quote reply" : "ציטוט בתגובה",
|
||||
"Edit" : "עריכה",
|
||||
"Delete" : "מחיקה",
|
||||
"View edit history" : "צפייה בהיסטוריית עריכה",
|
||||
"Are you sure you want to delete this post? This action cannot be undone." : "האם אתם בטוחים שתרצו למחוק את הפוסט הזה? הפעולה בלתי ניתנת להפיכה.",
|
||||
"Unread" : "לא נקרא",
|
||||
"Edit your reply …" : "ערכו את תגובתכם …",
|
||||
"Save" : "שמירה",
|
||||
"Are you sure you want to discard your changes?" : "האם אתם בטוחים שתרצו לבטל את השינויים שלכם?",
|
||||
"Edit history" : "היסטוריית עריכה",
|
||||
"Loading history …" : "טוען היסטוריה …",
|
||||
"This post has no edit history." : "לפוסט הזה אין היסטוריית עריכה.",
|
||||
"Current version" : "גרסה נוכחית",
|
||||
"Edited by" : "נערך על ידי",
|
||||
"Failed to load edit history" : "כשלון בטעינת היסטוריית עריכה",
|
||||
"Version {index}" : "גרסה {index}",
|
||||
"Add reaction" : "הוספת תגובון",
|
||||
"React with {emoji}" : "הגיבו עם {emoji}",
|
||||
"You reacted with {emoji}" : "הגבת עם {emoji}",
|
||||
"_You and %n other reacted with {emoji}_::_You and %n others reacted with {emoji}_" : ["את\\ה ועוד %n אחר הגבתם עם {emoji}","את\\ה ועוד %n אחרים הגבתם עם {emoji}","את\\ה ועוד %n אחרים הגבתם עם {emoji}"],
|
||||
"_%n person reacted with {emoji}_::_%n people reacted with {emoji}_" : ["אדם אחד הגיב עם {emoji}","%n אנשים הגיבו עם {emoji}","%n אנשים הגיבו עם {emoji}"],
|
||||
"Write your reply …" : "כתבו את תגובתכם …",
|
||||
"Submit reply" : "שליחת תגובה",
|
||||
"Are you sure you want to discard your reply?" : "אתם בטוחים שברצונכם לבטל את התגובה?",
|
||||
"In thread" : "בנושא",
|
||||
"Thread unavailable" : "נושא לא זמין",
|
||||
"Pinned thread" : "נושא מודבק",
|
||||
"Locked thread" : "נושא נעול",
|
||||
"Uncategorized" : "ללא קטגוריה",
|
||||
"_%n reply_::_%n replies_" : ["תגובה אחת","%n תגובות","%n תגובות"],
|
||||
"_%n view_::_%n views_" : ["צפייה אחת","%n צפיות","%n צפיות"],
|
||||
"Views" : "תצוגות",
|
||||
"Title" : "כותרת",
|
||||
"Enter thread title …" : "כתבו כותרת לנושא …",
|
||||
"Write your thread content …" : "כתבו תוכן לנושא שלכם …",
|
||||
"Create thread" : "צרו נושא",
|
||||
"Are you sure you want to discard this thread?" : "אתם בטוחים שברצונכם לבטל את הנושא?",
|
||||
"Saving draft …" : "הטיוטה נשמרת…",
|
||||
"Draft saved" : "הטיוטה נשמרה",
|
||||
"Unsaved changes" : "שינויים שלא נשמרו",
|
||||
"Back to home" : "חזרה לדף בית",
|
||||
"Refresh" : "רענון",
|
||||
"Your bookmarked threads" : "הנושאים בסימניה שלכם",
|
||||
"Error loading bookmarks" : "שגיאה בטעינת סימניות",
|
||||
"No bookmarks yet" : "ללא סימניות עדיין",
|
||||
"Bookmark threads to quickly find them later." : "סמנו נושאים כדי למצוא אותם מהר מאוחר יותר",
|
||||
"Retry" : "ניסיון חוזר",
|
||||
"An unexpected error occurred" : "קרתה שגיאה בלתי צפוייה",
|
||||
"Failed to load bookmarks" : "כשלון בטעינת סימניות",
|
||||
"No categories yet" : "ללא קטגוריות עדיין",
|
||||
"Categories will appear here once they are created." : "קטגוריות יופיעו כאן ברגע שייוצרו",
|
||||
"No categories in this section" : "אין קטגוריות באיזור הזה",
|
||||
"Category not found" : "קטגוריה לא נמצאה",
|
||||
"The category you are looking for does not exist or has been removed." : "הקטגוריה שאתם מחפשים לא קיימת או הוסרה.",
|
||||
"Back to categories" : "חזרה אל קטגוריות",
|
||||
"New thread" : "נושא חדש",
|
||||
"Error loading category" : "שגיאה בטעינת קטגוריות",
|
||||
"No threads yet" : "ללא נושאים עדיין",
|
||||
"Be the first to start a discussion in this category." : "היו הראשונים שיוצרים דיון בקטגוריה זאת.",
|
||||
"No category ID or slug provided" : "מזהה קטגוריה לא סופק ",
|
||||
"Failed to load threads" : "שגיאה בטעינת נושאים",
|
||||
"Create New Thread" : "צרו נושא חדש",
|
||||
"In {category}" : "בתוך {category}",
|
||||
"Creating thread …" : "יוצר נושא …",
|
||||
"Thread created" : "נושא נוצר",
|
||||
"Failed to create thread" : "כשלון ביצירת נושא",
|
||||
"No category specified" : "קטגוריה לא סופקה",
|
||||
"Error" : "שגיאה",
|
||||
"First activity" : "פעילות ראשונה",
|
||||
"Threads ({count})" : "נושאים ({count})",
|
||||
"Replies ({count})" : "תגובות ({count})",
|
||||
"No threads" : "ללא נושאים",
|
||||
"This user has not created any threads yet" : "משתמש זה לא יצר נושאים עדיין",
|
||||
"No replies" : "ללא תגובות",
|
||||
"This user has not written any replies yet" : "משתמש זה לא כתב תגובות עדיין",
|
||||
"Failed to load user profile" : "כשלון בטעינת פרופיל משתמש",
|
||||
"Enter search query …" : "הכניסו שאילתת חיפוש …",
|
||||
"Search in threads" : "חפשו בנושאים",
|
||||
"Search in replies" : "חפשו בתגובות",
|
||||
"Syntax help" : "עזרה בתחביר",
|
||||
"Search syntax" : "תחביר חיפוש",
|
||||
"Match exact phrase" : "התאם מלל מדוייק",
|
||||
"Both terms required" : "שני הערכים הם חובה",
|
||||
"Either term matches" : "ערך אחד או אחר מתאים",
|
||||
"Group conditions with parentheses" : "קבצו תנאים בעזרת סוגריים",
|
||||
"Exclude term from results" : "השמיטו מתוצאות החיפוש",
|
||||
"Searching …" : "מתבצע חיפוש…",
|
||||
"Search Error" : "שגיאה בחיפוש",
|
||||
"Enter a search query" : "הכניסו שאילתת חיפוש",
|
||||
"Use the search box above to find threads and replies" : "השתמשו בתיבת החיפוש למעלה כדי למצוא נושאים ותגובות",
|
||||
"No results found" : "לא נמצאו תוצאות",
|
||||
"Try different keywords or check your syntax" : "נסו מילות מפתח אחרות או בדקו שוב את תחביר החיפוש שלכם",
|
||||
"_%n thread found_::_%n threads found_" : ["נמצא נושא אחד","נמצאו %n נושאים","נמצאו %n נושאים"],
|
||||
"_%n reply found_::_%n replies found_" : ["נמצאה תגובה אחת","נמצאו %n תגובות","נמצאו %n תגובות"],
|
||||
"Please enter a search query" : "נא להכניס שאילתת חיפוש",
|
||||
"Please select at least one search scope" : "נא בחרו לפחות איזור חיפוש אחד",
|
||||
"Failed to search" : "כשלון בחיפוש",
|
||||
"Thread not found" : "נושא לא נמצא",
|
||||
"The thread you are looking for does not exist or has been removed." : "הנושא שאתם מנסים לפתוח לא קיים או הוסר",
|
||||
"Back to {category}" : "חזרה אל {category}",
|
||||
"Reply" : "תגובה",
|
||||
"Error loading thread" : "שגיאה בטעינת נושא",
|
||||
"No replies yet" : "ללא תגובות עדיין",
|
||||
"Be the first to reply in this thread." : "היו הראשונים שיגיבו לנושא זה",
|
||||
"by" : "מאת",
|
||||
"This thread is locked. Only moderators can add replies." : "הנושא הזה נעול. רק מנהלים יכולים להוסיף תגובות.",
|
||||
"You must be signed in to reply to this thread." : "אתם חייבים להיות מחוברים כדי להגיב לנושא זה.",
|
||||
"Sign in to reply" : "התחברו כדי להגיב",
|
||||
"Lock thread" : "נעילת נושא",
|
||||
"Unlock thread" : "ביטול נעילת נושא",
|
||||
"Pin thread" : "הדבקת נושא",
|
||||
"Unpin thread" : "ביטול הדבקת נושא",
|
||||
"Thread locked" : "הנושא ננעל",
|
||||
"Thread unlocked" : "בוטלה נעילת הנושא",
|
||||
"Thread pinned" : "הנושא הודבק",
|
||||
"Thread unpinned" : "בוטלה הדבקת הנושא",
|
||||
"Subscribe" : "הרשמה",
|
||||
"Bookmark" : "סימנייה",
|
||||
"Edit title" : "עריכת כותרת",
|
||||
|
||||
18
l10n/id.js
18
l10n/id.js
@@ -2,9 +2,11 @@ OC.L10N.register(
|
||||
"forum",
|
||||
{
|
||||
"Admin" : "Admin",
|
||||
"Moderator" : "Moderator",
|
||||
"User" : "Pengguna",
|
||||
"Guest" : "Tamu",
|
||||
"General" : "Umum",
|
||||
"Support" : "Dukung",
|
||||
"Forum" : "Forum",
|
||||
"Loading …" : "Memuat …",
|
||||
"Search" : "Cari",
|
||||
@@ -18,37 +20,50 @@ OC.L10N.register(
|
||||
"Hello world!" : "Halo dunia!",
|
||||
"Code" : "Kode",
|
||||
"Font size" : "Ukuran font",
|
||||
"Align left" : "Rata kiri",
|
||||
"Align center" : "Rata tengah",
|
||||
"Align right" : "Rata kanan",
|
||||
"List" : "Daftar",
|
||||
"Upload failed" : "Gagal mengunggah",
|
||||
"Close" : "Tutup",
|
||||
"No description available" : "Tidak ada deskripsi yang tersedia",
|
||||
"Sort order" : "Urutkan",
|
||||
"Cancel" : "Cancel",
|
||||
"Create" : "Buat",
|
||||
"Update" : "Perbarui",
|
||||
"Move" : "Pindah",
|
||||
"Page not found" : "Halaman tidak ditemukan",
|
||||
"Back" : "Kembali",
|
||||
"Edit" : "Sunting",
|
||||
"Delete" : "Hapus",
|
||||
"Save" : "Simpan",
|
||||
"Current version" : "Versi saat ini",
|
||||
"Version {index}" : "Versi {index}",
|
||||
"Uncategorized" : "Tidak terkategori",
|
||||
"Views" : "Tampilan",
|
||||
"Title" : "Judul",
|
||||
"Unsaved changes" : "Perubahan belum disimpan",
|
||||
"Back to home" : "Kembali ke beranda",
|
||||
"Refresh" : "Muat ulang",
|
||||
"Retry" : "Ulangi",
|
||||
"Error" : "Galat",
|
||||
"Searching …" : "Mencari …",
|
||||
"Back to {category}" : "Kembali ke {category}",
|
||||
"by" : "oleh",
|
||||
"Subscribe" : "Berlangganan",
|
||||
"Edit title" : "Edit judul",
|
||||
"Preferences" : "Preferensi",
|
||||
"Notifications" : "Notifikasi",
|
||||
"Files" : "File",
|
||||
"Signature" : "Tanda tangan",
|
||||
"Enable" : "Aktifkan",
|
||||
"Disable" : "Nonaktifkan",
|
||||
"Description" : "Deskrisi",
|
||||
"Enabled" : "Diaktifkan",
|
||||
"Name" : "Nama",
|
||||
"Enter category name" : "Masukkan nama kategori",
|
||||
"New" : "Baru",
|
||||
"Last 7 days" : "7 hari terakhir",
|
||||
"Appearance" : "Tampilan",
|
||||
"Access control" : "Kontol akses",
|
||||
"Settings saved" : "Setelan tersimpan",
|
||||
@@ -59,7 +74,10 @@ OC.L10N.register(
|
||||
"ID" : "ID",
|
||||
"Created" : "Dibuat",
|
||||
"Actions" : "Tindakan",
|
||||
"No description" : "Tanpa deskripsi",
|
||||
"No users found" : "Tidak ada pengguna yang ditemukan",
|
||||
"Status" : "Status",
|
||||
"Active" : "Aktif",
|
||||
"Deleted" : "Dihapus"
|
||||
},
|
||||
"nplurals=1; plural=0;");
|
||||
|
||||
18
l10n/id.json
18
l10n/id.json
@@ -1,8 +1,10 @@
|
||||
{ "translations": {
|
||||
"Admin" : "Admin",
|
||||
"Moderator" : "Moderator",
|
||||
"User" : "Pengguna",
|
||||
"Guest" : "Tamu",
|
||||
"General" : "Umum",
|
||||
"Support" : "Dukung",
|
||||
"Forum" : "Forum",
|
||||
"Loading …" : "Memuat …",
|
||||
"Search" : "Cari",
|
||||
@@ -16,37 +18,50 @@
|
||||
"Hello world!" : "Halo dunia!",
|
||||
"Code" : "Kode",
|
||||
"Font size" : "Ukuran font",
|
||||
"Align left" : "Rata kiri",
|
||||
"Align center" : "Rata tengah",
|
||||
"Align right" : "Rata kanan",
|
||||
"List" : "Daftar",
|
||||
"Upload failed" : "Gagal mengunggah",
|
||||
"Close" : "Tutup",
|
||||
"No description available" : "Tidak ada deskripsi yang tersedia",
|
||||
"Sort order" : "Urutkan",
|
||||
"Cancel" : "Cancel",
|
||||
"Create" : "Buat",
|
||||
"Update" : "Perbarui",
|
||||
"Move" : "Pindah",
|
||||
"Page not found" : "Halaman tidak ditemukan",
|
||||
"Back" : "Kembali",
|
||||
"Edit" : "Sunting",
|
||||
"Delete" : "Hapus",
|
||||
"Save" : "Simpan",
|
||||
"Current version" : "Versi saat ini",
|
||||
"Version {index}" : "Versi {index}",
|
||||
"Uncategorized" : "Tidak terkategori",
|
||||
"Views" : "Tampilan",
|
||||
"Title" : "Judul",
|
||||
"Unsaved changes" : "Perubahan belum disimpan",
|
||||
"Back to home" : "Kembali ke beranda",
|
||||
"Refresh" : "Muat ulang",
|
||||
"Retry" : "Ulangi",
|
||||
"Error" : "Galat",
|
||||
"Searching …" : "Mencari …",
|
||||
"Back to {category}" : "Kembali ke {category}",
|
||||
"by" : "oleh",
|
||||
"Subscribe" : "Berlangganan",
|
||||
"Edit title" : "Edit judul",
|
||||
"Preferences" : "Preferensi",
|
||||
"Notifications" : "Notifikasi",
|
||||
"Files" : "File",
|
||||
"Signature" : "Tanda tangan",
|
||||
"Enable" : "Aktifkan",
|
||||
"Disable" : "Nonaktifkan",
|
||||
"Description" : "Deskrisi",
|
||||
"Enabled" : "Diaktifkan",
|
||||
"Name" : "Nama",
|
||||
"Enter category name" : "Masukkan nama kategori",
|
||||
"New" : "Baru",
|
||||
"Last 7 days" : "7 hari terakhir",
|
||||
"Appearance" : "Tampilan",
|
||||
"Access control" : "Kontol akses",
|
||||
"Settings saved" : "Setelan tersimpan",
|
||||
@@ -57,7 +72,10 @@
|
||||
"ID" : "ID",
|
||||
"Created" : "Dibuat",
|
||||
"Actions" : "Tindakan",
|
||||
"No description" : "Tanpa deskripsi",
|
||||
"No users found" : "Tidak ada pengguna yang ditemukan",
|
||||
"Status" : "Status",
|
||||
"Active" : "Aktif",
|
||||
"Deleted" : "Dihapus"
|
||||
},"pluralForm" :"nplurals=1; plural=0;"
|
||||
}
|
||||
@@ -19,6 +19,7 @@ OC.L10N.register(
|
||||
"Collapse" : "접기",
|
||||
"Hello world!" : "Hello world!",
|
||||
"Code" : "코드",
|
||||
"Quote" : "ㅇ",
|
||||
"Font size" : "글꼴 크기",
|
||||
"List" : "목록",
|
||||
"Insert emoji" : "이모지 삽입",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"Collapse" : "접기",
|
||||
"Hello world!" : "Hello world!",
|
||||
"Code" : "코드",
|
||||
"Quote" : "ㅇ",
|
||||
"Font size" : "글꼴 크기",
|
||||
"List" : "목록",
|
||||
"Insert emoji" : "이모지 삽입",
|
||||
|
||||
@@ -16,6 +16,7 @@ OC.L10N.register(
|
||||
"Dashboard" : "Skydelis",
|
||||
"Users" : "Naudotojai",
|
||||
"Categories" : "Kategorijos",
|
||||
"Expand" : "Išskleisti",
|
||||
"Collapse" : "Suskleisti",
|
||||
"Hello world!" : "Sveikas, pasauli!",
|
||||
"Code" : "Kodas",
|
||||
@@ -56,6 +57,7 @@ OC.L10N.register(
|
||||
"Edit title" : "Taisyti pavadinimą",
|
||||
"Move thread" : "Perkelti giją",
|
||||
"Preferences" : "Nuostatos",
|
||||
"Notifications" : "Pranešimai",
|
||||
"Files" : "Failai",
|
||||
"Browse" : "Naršyti",
|
||||
"Signature" : "Parašas",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"Dashboard" : "Skydelis",
|
||||
"Users" : "Naudotojai",
|
||||
"Categories" : "Kategorijos",
|
||||
"Expand" : "Išskleisti",
|
||||
"Collapse" : "Suskleisti",
|
||||
"Hello world!" : "Sveikas, pasauli!",
|
||||
"Code" : "Kodas",
|
||||
@@ -54,6 +55,7 @@
|
||||
"Edit title" : "Taisyti pavadinimą",
|
||||
"Move thread" : "Perkelti giją",
|
||||
"Preferences" : "Nuostatos",
|
||||
"Notifications" : "Pranešimai",
|
||||
"Files" : "Failai",
|
||||
"Browse" : "Naršyti",
|
||||
"Signature" : "Parašas",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Conteúdo oculto",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Anexo",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo ao Nextcloud Fórums",
|
||||
"Welcome to the Nextcloud Forums!" : "Bem-vindo ao Nextcloud Fórums!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Este é um fórum comunitário integrado à sua instância Nextcloud. Aqui você pode discutir tópicos, compartilhar ideias e colaborar com outros usuários.",
|
||||
"Features:" : "Características:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Texto em itálico",
|
||||
"Underlined text" : "Texto sublinhado",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Sinta-se à vontade para iniciar uma nova discussão ou responder a fios existentes. Boas postagens!",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo ao Nextcloud Fórums",
|
||||
"Forum" : "Fórum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nova resposta em {thread}","{count} de novas respostas em {thread}","{count} novas respostas em {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mencionou você em {thread}",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "Configure como você recebe notificações",
|
||||
"Auto-subscribe to threads I create" : "Inscreva-me automaticamente nos fios que eu criar",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Quando ativado, você receberá automaticamente notificações sobre respostas aos fios que criar",
|
||||
"Auto-subscribe to threads I reply to" : "Inscrever-se automaticamente nos fios aos quais respondo",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Quando ativado, você receberá automaticamente notificações sobre novas respostas nos fios em que você respondeu.",
|
||||
"Files" : "Arquivos",
|
||||
"Configure file upload settings" : "Configurar as definições de envio de arquivos",
|
||||
"Upload directory" : "Diretório de upload",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Conteúdo oculto",
|
||||
"Spoilers" : "Spoilers",
|
||||
"Attachment" : "Anexo",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo ao Nextcloud Fórums",
|
||||
"Welcome to the Nextcloud Forums!" : "Bem-vindo ao Nextcloud Fórums!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Este é um fórum comunitário integrado à sua instância Nextcloud. Aqui você pode discutir tópicos, compartilhar ideias e colaborar com outros usuários.",
|
||||
"Features:" : "Características:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Texto em itálico",
|
||||
"Underlined text" : "Texto sublinhado",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Sinta-se à vontade para iniciar uma nova discussão ou responder a fios existentes. Boas postagens!",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo ao Nextcloud Fórums",
|
||||
"Forum" : "Fórum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} nova resposta em {thread}","{count} de novas respostas em {thread}","{count} novas respostas em {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} mencionou você em {thread}",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "Configure como você recebe notificações",
|
||||
"Auto-subscribe to threads I create" : "Inscreva-me automaticamente nos fios que eu criar",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Quando ativado, você receberá automaticamente notificações sobre respostas aos fios que criar",
|
||||
"Auto-subscribe to threads I reply to" : "Inscrever-se automaticamente nos fios aos quais respondo",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Quando ativado, você receberá automaticamente notificações sobre novas respostas nos fios em que você respondeu.",
|
||||
"Files" : "Arquivos",
|
||||
"Configure file upload settings" : "Configurar as definições de envio de arquivos",
|
||||
"Upload directory" : "Diretório de upload",
|
||||
|
||||
@@ -20,10 +20,10 @@ OC.L10N.register(
|
||||
"Hidden content" : "Conteúdo oculto",
|
||||
"Spoilers" : "Contém spoilers",
|
||||
"Attachment" : "Anexo",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo aos Fóruns Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Bem-vindo aos Fóruns do Nextcloud!",
|
||||
"Bold text" : "Texto a negrito",
|
||||
"Underlined text" : "Texto sublinhado",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo aos Fóruns Nextcloud",
|
||||
"Forum" : "Fórum",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Crie discussões, partilhe ideias e colabore com a sua comunidade diretamente no Nextcloud.\n\n**⚠️ Aviso de Desenvolvimento Inicial:**\nEsta aplicação está em fase inicial de desenvolvimento. Embora funcional, pode encontrar bugs ou funcionalidades incompletas. Por favor, comunique quaisquer problemas no GitHub e considere fazer cópias de segurança dos seus dados regularmente.\n\n**Principais características:**\n- **Discussões sobre tópicos** - Crie e responda a tópicos de discussão organizados\n- **Organização por categorias** - Estruture o seu fórum com categorias e cabeçalhos personalizáveis\n- **Formatação de texto avançada** - Utilize o BBCode para formatar as publicações com negrito, itálico, links, imagens, blocos de código e muito mais\n- **Anexos de ficheiros** - Anexe ficheiros do seu armazenamento Nextcloud às publicações\n- **Reações às publicações** - Reagir às publicações com emojis\n- **Controlo de leitura/não leitura** - Acompanhe quais os tópicos que já leu\n- **Pesquisa** - Encontre discussões rapidamente com a pesquisa integrada\n- **Perfis de utilizador** - Visualize o histórico de publicações e as estatísticas do utilizador\n- **Permissões baseadas em funções** - Controle o acesso e a moderação com funções flexíveis\n- **Acesso de convidado**: Acesso público opcional para utilizadores não autenticados com permissões configuráveis\n- **Ferramentas de administração** - Gerir categorias, funções, BBCodes e definições do fórum\n- **Ferramentas de moderação** - Fixar, bloquear e gerir tópicos e publicações\n\n**Ideal para:**\n- Discussões e colaboração em equipa\n- Fóruns da comunidade\n- Canais de suporte\n- Bases de conhecimento\n- Discussões sobre projetos\n- Comunicação interna\n\nO fórum integra-se perfeitamente na sua instância do Nextcloud, utilizando os seus utilizadores e grupos existentes para autenticação e controlo de acesso.",
|
||||
"Search" : "Pesquisa sobre",
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
"Hidden content" : "Conteúdo oculto",
|
||||
"Spoilers" : "Contém spoilers",
|
||||
"Attachment" : "Anexo",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo aos Fóruns Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Bem-vindo aos Fóruns do Nextcloud!",
|
||||
"Bold text" : "Texto a negrito",
|
||||
"Underlined text" : "Texto sublinhado",
|
||||
"Welcome to Nextcloud Forums" : "Bem-vindo aos Fóruns Nextcloud",
|
||||
"Forum" : "Fórum",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Crie discussões, partilhe ideias e colabore com a sua comunidade diretamente no Nextcloud.\n\n**⚠️ Aviso de Desenvolvimento Inicial:**\nEsta aplicação está em fase inicial de desenvolvimento. Embora funcional, pode encontrar bugs ou funcionalidades incompletas. Por favor, comunique quaisquer problemas no GitHub e considere fazer cópias de segurança dos seus dados regularmente.\n\n**Principais características:**\n- **Discussões sobre tópicos** - Crie e responda a tópicos de discussão organizados\n- **Organização por categorias** - Estruture o seu fórum com categorias e cabeçalhos personalizáveis\n- **Formatação de texto avançada** - Utilize o BBCode para formatar as publicações com negrito, itálico, links, imagens, blocos de código e muito mais\n- **Anexos de ficheiros** - Anexe ficheiros do seu armazenamento Nextcloud às publicações\n- **Reações às publicações** - Reagir às publicações com emojis\n- **Controlo de leitura/não leitura** - Acompanhe quais os tópicos que já leu\n- **Pesquisa** - Encontre discussões rapidamente com a pesquisa integrada\n- **Perfis de utilizador** - Visualize o histórico de publicações e as estatísticas do utilizador\n- **Permissões baseadas em funções** - Controle o acesso e a moderação com funções flexíveis\n- **Acesso de convidado**: Acesso público opcional para utilizadores não autenticados com permissões configuráveis\n- **Ferramentas de administração** - Gerir categorias, funções, BBCodes e definições do fórum\n- **Ferramentas de moderação** - Fixar, bloquear e gerir tópicos e publicações\n\n**Ideal para:**\n- Discussões e colaboração em equipa\n- Fóruns da comunidade\n- Canais de suporte\n- Bases de conhecimento\n- Discussões sobre projetos\n- Comunicação interna\n\nO fórum integra-se perfeitamente na sua instância do Nextcloud, utilizando os seus utilizadores e grupos existentes para autenticação e controlo de acesso.",
|
||||
"Search" : "Pesquisa sobre",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Скрытый контент",
|
||||
"Spoilers" : "Спойлеры",
|
||||
"Attachment" : "Вложение",
|
||||
"Welcome to Nextcloud Forums" : "Добро пожаловать на форумы Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Добро пожаловать на форумы Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Это форум, созданный сообществом и встроенный прямо в ваш экземпляр Nextcloud. Здесь вы можете обсуждать темы, делиться идеями и сотрудничать с другими пользователями.",
|
||||
"Features:" : "Функции:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Курсивный текст",
|
||||
"Underlined text" : "Подчеркнутый текст",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Не стесняйтесь начинать новое обсуждение или отвечать на уже существующие. Удачной публикации!",
|
||||
"Welcome to Nextcloud Forums" : "Добро пожаловать на форумы Nextcloud",
|
||||
"Forum" : "Форум",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} новый ответ в {thread}","{count} новых ответов в {thread}","{count} новых ответов в {thread}","{count} новых ответов в {thread}"],
|
||||
"Welcome to the forum!" : "Добро пожаловать на форум!",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Скрытый контент",
|
||||
"Spoilers" : "Спойлеры",
|
||||
"Attachment" : "Вложение",
|
||||
"Welcome to Nextcloud Forums" : "Добро пожаловать на форумы Nextcloud",
|
||||
"Welcome to the Nextcloud Forums!" : "Добро пожаловать на форумы Nextcloud!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Это форум, созданный сообществом и встроенный прямо в ваш экземпляр Nextcloud. Здесь вы можете обсуждать темы, делиться идеями и сотрудничать с другими пользователями.",
|
||||
"Features:" : "Функции:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Курсивный текст",
|
||||
"Underlined text" : "Подчеркнутый текст",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Не стесняйтесь начинать новое обсуждение или отвечать на уже существующие. Удачной публикации!",
|
||||
"Welcome to Nextcloud Forums" : "Добро пожаловать на форумы Nextcloud",
|
||||
"Forum" : "Форум",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} новый ответ в {thread}","{count} новых ответов в {thread}","{count} новых ответов в {thread}","{count} новых ответов в {thread}"],
|
||||
"Welcome to the forum!" : "Добро пожаловать на форум!",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Maudhui yaliyofichika",
|
||||
"Spoilers" : "Waharibifu",
|
||||
"Attachment" : "Kiambatisho",
|
||||
"Welcome to Nextcloud Forums" : "Karibu kwenye jukwaa la Nextcloud ",
|
||||
"Welcome to the Nextcloud Forums!" : "Karibu kwenye majukwaa ya Nextcloud! ",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Hili ni jukwaa linaloendeshwa na jamii lililojengwa ndani ya mfano wako wa Nextcloud. Hapa unaweza kujadili mada, kushiriki mawazo na kushirikiana na watumiaji wengine.",
|
||||
"Features:" : "Sifa:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Maandishi ya italiki",
|
||||
"Underlined text" : "Maandishi yaliyopigiwa mstari",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Jisikie huru kuanzisha mjadala mpya au kujibu mazungumzo yaliyopo. Furaha ya kuchapisha!",
|
||||
"Welcome to Nextcloud Forums" : "Karibu kwenye jukwaa la Nextcloud ",
|
||||
"Forum" : "Jukwaa",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} new reply in {thread}","{count} majibu mapya ndani {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} amekutaja katika {thread}",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Maudhui yaliyofichika",
|
||||
"Spoilers" : "Waharibifu",
|
||||
"Attachment" : "Kiambatisho",
|
||||
"Welcome to Nextcloud Forums" : "Karibu kwenye jukwaa la Nextcloud ",
|
||||
"Welcome to the Nextcloud Forums!" : "Karibu kwenye majukwaa ya Nextcloud! ",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Hili ni jukwaa linaloendeshwa na jamii lililojengwa ndani ya mfano wako wa Nextcloud. Hapa unaweza kujadili mada, kushiriki mawazo na kushirikiana na watumiaji wengine.",
|
||||
"Features:" : "Sifa:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Maandishi ya italiki",
|
||||
"Underlined text" : "Maandishi yaliyopigiwa mstari",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Jisikie huru kuanzisha mjadala mpya au kujibu mazungumzo yaliyopo. Furaha ya kuchapisha!",
|
||||
"Welcome to Nextcloud Forums" : "Karibu kwenye jukwaa la Nextcloud ",
|
||||
"Forum" : "Jukwaa",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{count} new reply in {thread}","{count} majibu mapya ndani {thread}"],
|
||||
"{user} mentioned you in {thread}" : "{user} amekutaja katika {thread}",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "Gizli içerik",
|
||||
"Spoilers" : "Alıntılar",
|
||||
"Attachment" : "Ek dosya",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud forumuna hoş geldiniz",
|
||||
"Welcome to the Nextcloud Forums!" : "Nextcloud forumuna hoş geldiniz!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Bu, doğrudan Nextcloud kopyanızda bulunan topluluk odaklı bir forumdur. Burada konuları tartışabilir, fikirleri paylaşabilir ve diğer kullanıcılarla iş birliği yapabilirsiniz.",
|
||||
"Features:" : "Özellikler:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "Yatık yazı",
|
||||
"Underlined text" : "Altı çizili yazı",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Yeni bir tartışma başlatmaktan veya var olan konulara yanıt vermekten çekinmeyin. Mutlu yazışmalar!",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud forumuna hoş geldiniz",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} konusunda {count} yeni yanıt","{thread} konusunda {count} yeni yanıt"],
|
||||
"{user} mentioned you in {thread}" : "{user} sizi {thread} konusunda andı",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "Bildirimleri nasıl almak istediğinizi ayarlayın",
|
||||
"Auto-subscribe to threads I create" : "Oluşturduğum konulara otomatik olarak abone olayım",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Açıldığında, oluşturduğunuz konulara verilen yanıtlar için bildirimleri otomatik olarak alırsınız",
|
||||
"Auto-subscribe to threads I reply to" : "Yanıtladığım konulara otomatik olarak abone olayım",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Açıldığında, yanıtladığınız konulara verilen yanıtlar için bildirimleri otomatik olarak alırsınız",
|
||||
"Files" : "Dosyalar",
|
||||
"Configure file upload settings" : "Dosya yükleme ayarlarını yapılandırın",
|
||||
"Upload directory" : "Yükleme klasörü",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "Gizli içerik",
|
||||
"Spoilers" : "Alıntılar",
|
||||
"Attachment" : "Ek dosya",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud forumuna hoş geldiniz",
|
||||
"Welcome to the Nextcloud Forums!" : "Nextcloud forumuna hoş geldiniz!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "Bu, doğrudan Nextcloud kopyanızda bulunan topluluk odaklı bir forumdur. Burada konuları tartışabilir, fikirleri paylaşabilir ve diğer kullanıcılarla iş birliği yapabilirsiniz.",
|
||||
"Features:" : "Özellikler:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "Yatık yazı",
|
||||
"Underlined text" : "Altı çizili yazı",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "Yeni bir tartışma başlatmaktan veya var olan konulara yanıt vermekten çekinmeyin. Mutlu yazışmalar!",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud forumuna hoş geldiniz",
|
||||
"Forum" : "Forum",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} konusunda {count} yeni yanıt","{thread} konusunda {count} yeni yanıt"],
|
||||
"{user} mentioned you in {thread}" : "{user} sizi {thread} konusunda andı",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "Bildirimleri nasıl almak istediğinizi ayarlayın",
|
||||
"Auto-subscribe to threads I create" : "Oluşturduğum konulara otomatik olarak abone olayım",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "Açıldığında, oluşturduğunuz konulara verilen yanıtlar için bildirimleri otomatik olarak alırsınız",
|
||||
"Auto-subscribe to threads I reply to" : "Yanıtladığım konulara otomatik olarak abone olayım",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "Açıldığında, yanıtladığınız konulara verilen yanıtlar için bildirimleri otomatik olarak alırsınız",
|
||||
"Files" : "Dosyalar",
|
||||
"Configure file upload settings" : "Dosya yükleme ayarlarını yapılandırın",
|
||||
"Upload directory" : "Yükleme klasörü",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "يوشۇرۇن مەزمۇن",
|
||||
"Spoilers" : "بۇزغۇنچىلار",
|
||||
"Attachment" : "قوشۇمچە",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud مۇنبىرىگە خۇش كەپسىز",
|
||||
"Welcome to the Nextcloud Forums!" : "Nextcloud مۇنبىرىگە خۇش كەپسىز!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "بۇ Nextcloud ئۈلگىڭىزگە قۇرۇلغان مەھەللە باشلامچىلىق قىلغان مۇنبەر. بۇ يەردە سىز تېمىلارنى مۇزاكىرە قىلالايسىز، پىكىر ئورتاقلىشالايسىز ۋە باشقا ئىشلەتكۈچىلەر بىلەن ھەمكارلىشالايسىز.",
|
||||
"Features:" : "ئالاھىدىلىكلىرى:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "قىيسىق تېكىست",
|
||||
"Underlined text" : "ئاستى سىزىقلىق تېكىست",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "يېڭى مۇنازىرە باشلىسىڭىز ياكى مەۋجۇت تېمىلارغا جاۋاب قايتۇرسىڭىز بولىدۇ. خەيرلىك يوللاڭ!",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud مۇنبىرىگە خۇش كەپسىز",
|
||||
"Forum" : "مۇنبەر",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} دا {count} يېڭى جاۋاپ","{thread} دا {count} يېڭى جاۋاپ"],
|
||||
"{user} mentioned you in {thread}" : "{user} سىزنى {thread} دا تىلغا ئالدى",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "يوشۇرۇن مەزمۇن",
|
||||
"Spoilers" : "بۇزغۇنچىلار",
|
||||
"Attachment" : "قوشۇمچە",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud مۇنبىرىگە خۇش كەپسىز",
|
||||
"Welcome to the Nextcloud Forums!" : "Nextcloud مۇنبىرىگە خۇش كەپسىز!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "بۇ Nextcloud ئۈلگىڭىزگە قۇرۇلغان مەھەللە باشلامچىلىق قىلغان مۇنبەر. بۇ يەردە سىز تېمىلارنى مۇزاكىرە قىلالايسىز، پىكىر ئورتاقلىشالايسىز ۋە باشقا ئىشلەتكۈچىلەر بىلەن ھەمكارلىشالايسىز.",
|
||||
"Features:" : "ئالاھىدىلىكلىرى:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "قىيسىق تېكىست",
|
||||
"Underlined text" : "ئاستى سىزىقلىق تېكىست",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "يېڭى مۇنازىرە باشلىسىڭىز ياكى مەۋجۇت تېمىلارغا جاۋاب قايتۇرسىڭىز بولىدۇ. خەيرلىك يوللاڭ!",
|
||||
"Welcome to Nextcloud Forums" : "Nextcloud مۇنبىرىگە خۇش كەپسىز",
|
||||
"Forum" : "مۇنبەر",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} دا {count} يېڭى جاۋاپ","{thread} دا {count} يېڭى جاۋاپ"],
|
||||
"{user} mentioned you in {thread}" : "{user} سىزنى {thread} دا تىلغا ئالدى",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "隱藏的內容",
|
||||
"Spoilers" : "劇透",
|
||||
"Attachment" : "附件",
|
||||
"Welcome to Nextcloud Forums" : "歡迎來到 Nextcloud 論壇",
|
||||
"Welcome to the Nextcloud Forums!" : "歡迎來到 Nextcloud 論壇!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "這是一個直接建置在你 Nextcloud 實例之中的社群主導論壇。你可以在這裡討論各種主題、分享意見,並與其他用戶協作。",
|
||||
"Features:" : "功能:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "斜體文字",
|
||||
"Underlined text" : "下面畫線的文字",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "歡迎隨時開啟新討論或回覆現有主題,玩得開心!",
|
||||
"Welcome to Nextcloud Forums" : "歡迎來到 Nextcloud 論壇",
|
||||
"Forum" : "論壇",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} 中有 {count} 個新回覆"],
|
||||
"{user} mentioned you in {thread}" : "{user} 在 {thread} 中提及您",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "設定你接收通知的方式",
|
||||
"Auto-subscribe to threads I create" : "自動訂閱由我建立的主題",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,你會自動收到針對你所建立主題之回覆通知。",
|
||||
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
|
||||
"Files" : "檔案",
|
||||
"Configure file upload settings" : "配置檔案上傳設定",
|
||||
"Upload directory" : "上載目錄",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "隱藏的內容",
|
||||
"Spoilers" : "劇透",
|
||||
"Attachment" : "附件",
|
||||
"Welcome to Nextcloud Forums" : "歡迎來到 Nextcloud 論壇",
|
||||
"Welcome to the Nextcloud Forums!" : "歡迎來到 Nextcloud 論壇!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "這是一個直接建置在你 Nextcloud 實例之中的社群主導論壇。你可以在這裡討論各種主題、分享意見,並與其他用戶協作。",
|
||||
"Features:" : "功能:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "斜體文字",
|
||||
"Underlined text" : "下面畫線的文字",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "歡迎隨時開啟新討論或回覆現有主題,玩得開心!",
|
||||
"Welcome to Nextcloud Forums" : "歡迎來到 Nextcloud 論壇",
|
||||
"Forum" : "論壇",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} 中有 {count} 個新回覆"],
|
||||
"{user} mentioned you in {thread}" : "{user} 在 {thread} 中提及您",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "設定你接收通知的方式",
|
||||
"Auto-subscribe to threads I create" : "自動訂閱由我建立的主題",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,你會自動收到針對你所建立主題之回覆通知。",
|
||||
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
|
||||
"Files" : "檔案",
|
||||
"Configure file upload settings" : "配置檔案上傳設定",
|
||||
"Upload directory" : "上載目錄",
|
||||
|
||||
@@ -20,7 +20,6 @@ OC.L10N.register(
|
||||
"Hidden content" : "隱藏內容",
|
||||
"Spoilers" : "劇透",
|
||||
"Attachment" : "附件",
|
||||
"Welcome to Nextcloud Forums" : "歡迎使用 Nextcloud 論壇",
|
||||
"Welcome to the Nextcloud Forums!" : "歡迎使用 Nextcloud 論壇!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "這是內建於您的 Nextcloud 站台中的社群驅動論壇。在此您可以討論主題、分享想法,並與其他使用者協作。",
|
||||
"Features:" : "功能:",
|
||||
@@ -36,6 +35,7 @@ OC.L10N.register(
|
||||
"Italic text" : "斜體文字",
|
||||
"Underlined text" : "有底線的文字",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "歡迎隨時開啟新討論或回覆現有主題。祝您發文愉快!",
|
||||
"Welcome to Nextcloud Forums" : "歡迎使用 Nextcloud 論壇",
|
||||
"Forum" : "論壇",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} 中有 {count} 個新回覆"],
|
||||
"{user} mentioned you in {thread}" : "{user} 在 {thread} 中提及您",
|
||||
@@ -282,6 +282,8 @@ OC.L10N.register(
|
||||
"Configure how you receive notifications" : "設定您要如何接收通知",
|
||||
"Auto-subscribe to threads I create" : "自動訂閱我建立的討論串",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,您會自動收到您所建立的討論串回覆的通知",
|
||||
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
|
||||
"Files" : "檔案",
|
||||
"Configure file upload settings" : "設定檔案上傳設定",
|
||||
"Upload directory" : "上傳目錄",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"Hidden content" : "隱藏內容",
|
||||
"Spoilers" : "劇透",
|
||||
"Attachment" : "附件",
|
||||
"Welcome to Nextcloud Forums" : "歡迎使用 Nextcloud 論壇",
|
||||
"Welcome to the Nextcloud Forums!" : "歡迎使用 Nextcloud 論壇!",
|
||||
"This is a community-driven forum built right into your Nextcloud instance. Here you can discuss topics, share ideas and collaborate with other users." : "這是內建於您的 Nextcloud 站台中的社群驅動論壇。在此您可以討論主題、分享想法,並與其他使用者協作。",
|
||||
"Features:" : "功能:",
|
||||
@@ -34,6 +33,7 @@
|
||||
"Italic text" : "斜體文字",
|
||||
"Underlined text" : "有底線的文字",
|
||||
"Feel free to start a new discussion or reply to existing threads. Happy posting!" : "歡迎隨時開啟新討論或回覆現有主題。祝您發文愉快!",
|
||||
"Welcome to Nextcloud Forums" : "歡迎使用 Nextcloud 論壇",
|
||||
"Forum" : "論壇",
|
||||
"_{count} new reply in {thread}_::_{count} new replies in {thread}_" : ["{thread} 中有 {count} 個新回覆"],
|
||||
"{user} mentioned you in {thread}" : "{user} 在 {thread} 中提及您",
|
||||
@@ -280,6 +280,8 @@
|
||||
"Configure how you receive notifications" : "設定您要如何接收通知",
|
||||
"Auto-subscribe to threads I create" : "自動訂閱我建立的討論串",
|
||||
"When enabled, you will automatically receive notifications for replies to threads you create" : "啟用後,您會自動收到您所建立的討論串回覆的通知",
|
||||
"Auto-subscribe to threads I reply to" : "自動訂閱我回覆的討論串",
|
||||
"When enabled, you will automatically receive notifications for new replies in threads you have replied to" : "啟用後,您將會自動收到您回覆過的討論串中的新回覆通知",
|
||||
"Files" : "檔案",
|
||||
"Configure file upload settings" : "設定檔案上傳設定",
|
||||
"Upload directory" : "上傳目錄",
|
||||
|
||||
@@ -87,7 +87,8 @@ class RepairSeeds extends Command {
|
||||
}
|
||||
};
|
||||
|
||||
SeedHelper::seedAll($migrationOutput);
|
||||
// Pass throwOnError=true so users get proper error feedback
|
||||
SeedHelper::seedAll($migrationOutput, true);
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln('<info>Forum data repair/seed completed successfully!</info>');
|
||||
|
||||
@@ -11,9 +11,12 @@ use OCA\Forum\Attribute\RequirePermission;
|
||||
use OCA\Forum\Db\CategoryMapper;
|
||||
use OCA\Forum\Db\ForumUserMapper;
|
||||
use OCA\Forum\Db\PostMapper;
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use OCA\Forum\Migration\SeedHelper;
|
||||
use OCA\Forum\Service\AdminSettingsService;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -23,6 +26,7 @@ use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserManager;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Migration\IOutput;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AdminController extends OCSController {
|
||||
@@ -36,6 +40,8 @@ class AdminController extends OCSController {
|
||||
private PostMapper $postMapper,
|
||||
private CategoryMapper $categoryMapper,
|
||||
private UserRoleMapper $userRoleMapper,
|
||||
private RoleMapper $roleMapper,
|
||||
private UserRoleService $userRoleService,
|
||||
private IUserManager $userManager,
|
||||
private IUserSession $userSession,
|
||||
private AdminSettingsService $settingsService,
|
||||
@@ -228,4 +234,202 @@ class AdminController extends OCSController {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the repair seeds command to restore default forum data
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool, message: string}, array{}>
|
||||
*
|
||||
* 200: Seeds repaired successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canAccessAdminTools')]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/admin/repair-seeds')]
|
||||
public function repairSeeds(): DataResponse {
|
||||
try {
|
||||
$messages = [];
|
||||
$migrationOutput = new class($messages) implements IOutput {
|
||||
/** @var array<string> */
|
||||
private array $messages;
|
||||
|
||||
public function __construct(array &$messages) {
|
||||
$this->messages = &$messages;
|
||||
}
|
||||
|
||||
public function info($message): void {
|
||||
$this->messages[] = $message;
|
||||
}
|
||||
|
||||
public function warning($message): void {
|
||||
$this->messages[] = '[Warning] ' . $message;
|
||||
}
|
||||
|
||||
public function debug($message): void {
|
||||
$this->messages[] = '[Debug] ' . $message;
|
||||
}
|
||||
|
||||
public function startProgress($max = 0): void {
|
||||
}
|
||||
|
||||
public function advance($step = 1, $description = ''): void {
|
||||
}
|
||||
|
||||
public function finishProgress(): void {
|
||||
}
|
||||
};
|
||||
|
||||
SeedHelper::seedAll($migrationOutput, true);
|
||||
|
||||
$this->logger->info('Forum repair seeds completed successfully');
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'message' => implode("\n", $messages),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error running repair seeds: ' . $e->getMessage());
|
||||
return new DataResponse([
|
||||
'success' => false,
|
||||
'message' => 'Failed to repair seeds: ' . $e->getMessage(),
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available roles
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{roles: list<array<string, mixed>>}, array{}>
|
||||
*
|
||||
* 200: Roles list returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canAccessAdminTools')]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/admin/roles')]
|
||||
public function getRoles(): DataResponse {
|
||||
try {
|
||||
$roles = $this->roleMapper->findAll();
|
||||
$rolesData = array_map(fn ($role) => [
|
||||
'id' => $role->getId(),
|
||||
'name' => $role->getName(),
|
||||
'roleType' => $role->getRoleType(),
|
||||
], $roles);
|
||||
return new DataResponse(['roles' => $rolesData]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching roles: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch roles'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a role to a user
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param int $roleId The role ID to assign
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool, message: string}, array{}>
|
||||
*
|
||||
* 200: Role assigned successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canAccessAdminTools')]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/admin/users/{userId}/roles')]
|
||||
public function assignRole(string $userId, int $roleId): DataResponse {
|
||||
try {
|
||||
// Check if user exists
|
||||
$user = $this->userManager->get($userId);
|
||||
if ($user === null) {
|
||||
return new DataResponse([
|
||||
'success' => false,
|
||||
'message' => "User '$userId' does not exist.",
|
||||
], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if role exists
|
||||
try {
|
||||
$role = $this->roleMapper->find($roleId);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
|
||||
return new DataResponse([
|
||||
'success' => false,
|
||||
'message' => "Role with ID '$roleId' does not exist.",
|
||||
], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if user already has this role
|
||||
if ($this->userRoleService->hasRole($userId, $roleId)) {
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'message' => "User '$userId' already has the role '{$role->getName()}'.",
|
||||
]);
|
||||
}
|
||||
|
||||
// Assign the role
|
||||
$this->userRoleService->assignRole($userId, $roleId, skipIfExists: false);
|
||||
$this->logger->info("Assigned role '{$role->getName()}' to user '$userId'");
|
||||
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'message' => "Successfully assigned role '{$role->getName()}' to user '$userId'.",
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error assigning role: ' . $e->getMessage());
|
||||
return new DataResponse([
|
||||
'success' => false,
|
||||
'message' => 'Failed to assign role: ' . $e->getMessage(),
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a role from a user
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param int $roleId The role ID to remove
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool, message: string}, array{}>
|
||||
*
|
||||
* 200: Role removed successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canAccessAdminTools')]
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/admin/users/{userId}/roles/{roleId}')]
|
||||
public function removeRole(string $userId, int $roleId): DataResponse {
|
||||
try {
|
||||
// Check if user exists
|
||||
$user = $this->userManager->get($userId);
|
||||
if ($user === null) {
|
||||
return new DataResponse([
|
||||
'success' => false,
|
||||
'message' => "User '$userId' does not exist.",
|
||||
], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if role exists
|
||||
try {
|
||||
$role = $this->roleMapper->find($roleId);
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
|
||||
return new DataResponse([
|
||||
'success' => false,
|
||||
'message' => "Role with ID '$roleId' does not exist.",
|
||||
], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Remove the role
|
||||
$removed = $this->userRoleService->removeRole($userId, $roleId);
|
||||
if (!$removed) {
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'message' => "User '$userId' does not have the role '{$role->getName()}'.",
|
||||
]);
|
||||
}
|
||||
|
||||
$this->logger->info("Removed role '{$role->getName()}' from user '$userId'");
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'message' => "Successfully removed role '{$role->getName()}' from user '$userId'.",
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error removing role: ' . $e->getMessage());
|
||||
return new DataResponse([
|
||||
'success' => false,
|
||||
'message' => 'Failed to remove role: ' . $e->getMessage(),
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ class CategoryController extends OCSController {
|
||||
* Update a category
|
||||
*
|
||||
* @param int $id Category ID
|
||||
* @param int|null $headerId Category header ID
|
||||
* @param string|null $name Category name
|
||||
* @param string|null $description Category description
|
||||
* @param string|null $slug Category slug
|
||||
@@ -203,10 +204,13 @@ class CategoryController extends OCSController {
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canEditCategories')]
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/categories/{id}')]
|
||||
public function update(int $id, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null): DataResponse {
|
||||
public function update(int $id, ?int $headerId = null, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null): DataResponse {
|
||||
try {
|
||||
$category = $this->categoryMapper->find($id);
|
||||
|
||||
if ($headerId !== null) {
|
||||
$category->setHeaderId($headerId);
|
||||
}
|
||||
if ($name !== null) {
|
||||
$category->setName($name);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ use OCA\Forum\Db\PostMapper;
|
||||
use OCA\Forum\Db\ReactionMapper;
|
||||
use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCA\Forum\Service\BBCodeService;
|
||||
use OCA\Forum\Service\NotificationService;
|
||||
use OCA\Forum\Service\PermissionService;
|
||||
use OCA\Forum\Service\PostEnrichmentService;
|
||||
use OCA\Forum\Service\PostHistoryService;
|
||||
use OCA\Forum\Service\UserPreferencesService;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
@@ -49,6 +51,8 @@ class PostController extends OCSController {
|
||||
private PostEnrichmentService $postEnrichmentService,
|
||||
private PostHistoryService $postHistoryService,
|
||||
private UserService $userService,
|
||||
private UserPreferencesService $userPreferencesService,
|
||||
private ThreadSubscriptionMapper $threadSubscriptionMapper,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
@@ -400,6 +404,21 @@ class PostController extends OCSController {
|
||||
// Don't fail the request if mention notification sending fails
|
||||
}
|
||||
|
||||
// Auto-subscribe the user to the thread if preference is enabled and not already subscribed
|
||||
try {
|
||||
$autoSubscribe = $this->userPreferencesService->getPreference(
|
||||
$user->getUID(),
|
||||
UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS
|
||||
);
|
||||
|
||||
if ($autoSubscribe && !$this->threadSubscriptionMapper->isUserSubscribed($user->getUID(), $threadId)) {
|
||||
$this->threadSubscriptionMapper->subscribe($user->getUID(), $threadId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to auto-subscribe user to thread: ' . $e->getMessage());
|
||||
// Don't fail the request if auto-subscribe fails
|
||||
}
|
||||
|
||||
return new DataResponse($this->postEnrichmentService->enrichPost($createdPost), Http::STATUS_CREATED);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error creating post: ' . $e->getMessage());
|
||||
|
||||
@@ -31,7 +31,6 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setUpdatedAt(int $updatedAt)
|
||||
*/
|
||||
class ForumUser extends Entity implements JsonSerializable {
|
||||
public $id;
|
||||
protected string $userId = '';
|
||||
protected int $postCount = 0;
|
||||
protected int $threadCount = 0;
|
||||
|
||||
@@ -17,34 +17,116 @@ class SeedHelper {
|
||||
* Each function checks its own state and returns early if already seeded
|
||||
*
|
||||
* @param \OCP\Migration\IOutput|null $output Optional output for console messages
|
||||
* @param bool $throwOnError If true, throws exceptions on failure. If false (default), logs errors and continues.
|
||||
* Set to false when called from migrations to avoid PostgreSQL transaction abort issues.
|
||||
*/
|
||||
public static function seedAll($output = null): void {
|
||||
public static function seedAll($output = null, bool $throwOnError = false): void {
|
||||
$logger = \OC::$server->get(\Psr\Log\LoggerInterface::class);
|
||||
$db = \OC::$server->get(\OCP\IDBConnection::class);
|
||||
$logger->info('Forum seeding: Starting data seed/repair');
|
||||
|
||||
if ($output) {
|
||||
$output->info('Forum: Starting data seed/repair...');
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
// Ensure forum_users table exists (handle rename from forum_user_stats if needed)
|
||||
self::ensureForumUsersTable($output);
|
||||
// This is critical and should fail early if it cannot be done
|
||||
try {
|
||||
self::ensureForumUsersTable($output);
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = 'ensureForumUsersTable: ' . $e->getMessage();
|
||||
$logger->error('Forum seeding: Failed to ensure forum_users table', ['exception' => $e->getMessage()]);
|
||||
if ($output) {
|
||||
$output->warning(' Failed to ensure forum_users table: ' . $e->getMessage());
|
||||
}
|
||||
// Try to recover connection state for PostgreSQL
|
||||
self::recoverConnectionState($db, $logger);
|
||||
}
|
||||
|
||||
// Each function checks its own state and returns early if already seeded
|
||||
// They run independently so one failure doesn't block others
|
||||
self::seedDefaultRoles($output);
|
||||
self::seedCategoryHeaders($output);
|
||||
self::seedDefaultCategories($output);
|
||||
self::seedCategoryPermissions($output);
|
||||
self::seedGuestRolePermissions($output);
|
||||
self::seedDefaultBBCodes($output);
|
||||
self::assignUserRoles($output);
|
||||
self::seedWelcomeThread($output);
|
||||
// They run independently so one failure does not block others
|
||||
// This is especially important for PostgreSQL where a failed query aborts the transaction
|
||||
$seedOperations = [
|
||||
'seedDefaultRoles' => fn () => self::seedDefaultRoles($output),
|
||||
'seedCategoryHeaders' => fn () => self::seedCategoryHeaders($output),
|
||||
'seedDefaultCategories' => fn () => self::seedDefaultCategories($output),
|
||||
'seedCategoryPermissions' => fn () => self::seedCategoryPermissions($output),
|
||||
'seedGuestRolePermissions' => fn () => self::seedGuestRolePermissions($output),
|
||||
'seedDefaultBBCodes' => fn () => self::seedDefaultBBCodes($output),
|
||||
'assignUserRoles' => fn () => self::assignUserRoles($output),
|
||||
'seedWelcomeThread' => fn () => self::seedWelcomeThread($output),
|
||||
];
|
||||
|
||||
$logger->info('Forum seeding: Completed data seed/repair');
|
||||
|
||||
if ($output) {
|
||||
$output->info('Forum: Data seed/repair completed');
|
||||
foreach ($seedOperations as $name => $operation) {
|
||||
try {
|
||||
// Before each operation, ensure connection is in a clean state
|
||||
self::recoverConnectionState($db, $logger);
|
||||
$operation();
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "$name: " . $e->getMessage();
|
||||
$logger->error("Forum seeding: $name failed", ['exception' => $e->getMessage()]);
|
||||
// Try to recover connection state for next operation (especially important for PostgreSQL)
|
||||
self::recoverConnectionState($db, $logger);
|
||||
// Continue with other operations - don't let one failure block others
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$errorSummary = 'Some seeding operations failed: ' . implode('; ', $errors);
|
||||
$logger->warning('Forum seeding: Completed with errors', ['errors' => $errors]);
|
||||
|
||||
if ($output) {
|
||||
$output->warning('Forum: Data seed/repair completed with errors. Run "occ forum:repair-seeds" to retry failed operations.');
|
||||
}
|
||||
|
||||
if ($throwOnError) {
|
||||
throw new \RuntimeException($errorSummary);
|
||||
}
|
||||
} else {
|
||||
$logger->info('Forum seeding: Completed data seed/repair successfully');
|
||||
|
||||
if ($output) {
|
||||
$output->info('Forum: Data seed/repair completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover database connection state after an error
|
||||
* On PostgreSQL, a failed query aborts the entire transaction, and subsequent queries fail.
|
||||
* This method attempts to rollback any open transactions to restore a usable connection state.
|
||||
*
|
||||
* @param \OCP\IDBConnection $db Database connection
|
||||
* @param \Psr\Log\LoggerInterface $logger Logger instance
|
||||
*/
|
||||
private static function recoverConnectionState(\OCP\IDBConnection $db, \Psr\Log\LoggerInterface $logger): void {
|
||||
try {
|
||||
// If we're in a transaction, try to roll back to recover the connection
|
||||
while ($db->inTransaction()) {
|
||||
try {
|
||||
$db->rollBack();
|
||||
$logger->debug('Forum seeding: Rolled back transaction to recover connection state');
|
||||
} catch (\Exception $e) {
|
||||
// If rollback fails, the connection might be in an unrecoverable state
|
||||
$logger->warning('Forum seeding: Failed to rollback transaction during recovery', ['exception' => $e->getMessage()]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignore errors when checking transaction state
|
||||
$logger->debug('Forum seeding: Error checking transaction state', ['exception' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper for ensureForumUsersTable for use in migrations
|
||||
*
|
||||
* @param \OCP\Migration\IOutput|null $output Optional output for console messages
|
||||
*/
|
||||
public static function ensureForumUsersTablePublic($output = null): void {
|
||||
self::ensureForumUsersTable($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +198,8 @@ class SeedHelper {
|
||||
|
||||
/**
|
||||
* Create the forum_users table from scratch
|
||||
* This mirrors the schema from Version1 + Version8 migrations
|
||||
* This mirrors the final schema from Version1 + Version2 + Version8 migrations
|
||||
* (id as primary key, user_id as unique, includes signature column)
|
||||
*/
|
||||
private static function createForumUsersTable(\OCP\IDBConnection $db): void {
|
||||
$platform = $db->getDatabasePlatform();
|
||||
@@ -129,6 +212,7 @@ class SeedHelper {
|
||||
// MySQL and MariaDB both extend MySQLPlatform
|
||||
$db->executeStatement("
|
||||
CREATE TABLE `{$tableName}` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` VARCHAR(64) NOT NULL,
|
||||
`post_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`thread_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
@@ -137,14 +221,17 @@ class SeedHelper {
|
||||
`signature` TEXT DEFAULT NULL,
|
||||
`created_at` INT UNSIGNED NOT NULL,
|
||||
`updated_at` INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`user_id`),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `forum_users_user_id_uniq` (`user_id`),
|
||||
INDEX `forum_users_post_count_idx` (`post_count`),
|
||||
INDEX `forum_users_thread_count_idx` (`thread_count`),
|
||||
INDEX `forum_users_deleted_at_idx` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
|
||||
");
|
||||
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
$db->executeStatement("
|
||||
CREATE TABLE \"{$tableName}\" (
|
||||
\"id\" BIGSERIAL PRIMARY KEY,
|
||||
\"user_id\" VARCHAR(64) NOT NULL,
|
||||
\"post_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
\"thread_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -152,16 +239,18 @@ class SeedHelper {
|
||||
\"deleted_at\" INTEGER DEFAULT NULL,
|
||||
\"signature\" TEXT DEFAULT NULL,
|
||||
\"created_at\" INTEGER NOT NULL,
|
||||
\"updated_at\" INTEGER NOT NULL,
|
||||
PRIMARY KEY (\"user_id\")
|
||||
\"updated_at\" INTEGER NOT NULL
|
||||
)
|
||||
");
|
||||
$db->executeStatement("CREATE UNIQUE INDEX \"forum_users_user_id_uniq\" ON \"{$tableName}\" (\"user_id\")");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_post_count_idx\" ON \"{$tableName}\" (\"post_count\")");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_thread_count_idx\" ON \"{$tableName}\" (\"thread_count\")");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_deleted_at_idx\" ON \"{$tableName}\" (\"deleted_at\")");
|
||||
} else {
|
||||
// SQLite (and any other platform as fallback)
|
||||
$db->executeStatement("
|
||||
CREATE TABLE \"{$tableName}\" (
|
||||
\"id\" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
\"user_id\" VARCHAR(64) NOT NULL,
|
||||
\"post_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
\"thread_count\" INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -169,11 +258,12 @@ class SeedHelper {
|
||||
\"deleted_at\" INTEGER DEFAULT NULL,
|
||||
\"signature\" TEXT DEFAULT NULL,
|
||||
\"created_at\" INTEGER NOT NULL,
|
||||
\"updated_at\" INTEGER NOT NULL,
|
||||
PRIMARY KEY (\"user_id\")
|
||||
\"updated_at\" INTEGER NOT NULL
|
||||
)
|
||||
");
|
||||
$db->executeStatement("CREATE UNIQUE INDEX \"forum_users_user_id_uniq\" ON \"{$tableName}\" (\"user_id\")");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_post_count_idx\" ON \"{$tableName}\" (\"post_count\")");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_thread_count_idx\" ON \"{$tableName}\" (\"thread_count\")");
|
||||
$db->executeStatement("CREATE INDEX \"forum_users_deleted_at_idx\" ON \"{$tableName}\" (\"deleted_at\")");
|
||||
}
|
||||
}
|
||||
@@ -224,23 +314,24 @@ class SeedHelper {
|
||||
$timestamp = time();
|
||||
|
||||
try {
|
||||
// Get existing role IDs to check what needs to be created
|
||||
// Get existing roles by role_type (not hardcoded IDs) to check what needs to be created
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
$qb->select('role_type')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter([
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
->where($qb->expr()->in('role_type', $qb->createNamedParameter([
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_ADMIN,
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR,
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT,
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_GUEST,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY)));
|
||||
$result = $qb->executeQuery();
|
||||
$existingRoles = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
|
||||
$existingIds = array_map(fn ($role) => (int)$role['id'], $existingRoles);
|
||||
// Use array_unique to handle duplicates (shouldn't happen after cleanup migration, but be defensive)
|
||||
$existingTypes = array_unique(array_map(fn ($role) => $role['role_type'], $existingRoles));
|
||||
|
||||
if (count($existingIds) === 4) {
|
||||
if (count($existingTypes) === 4) {
|
||||
$logger->info('Forum seeding: Default roles already exist, skipping');
|
||||
if ($output) {
|
||||
$output->info(' ✓ Default roles already exist');
|
||||
@@ -252,12 +343,13 @@ class SeedHelper {
|
||||
$output->info(' → Creating default roles...');
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
// Note: We don't use explicit transactions here to avoid PostgreSQL transaction abort cascade.
|
||||
// Each INSERT is independent and idempotent, so partial success is acceptable.
|
||||
$rolesCreated = 0;
|
||||
|
||||
// Define roles with their expected IDs and characteristics
|
||||
// Define roles by role_type (not hardcoded IDs)
|
||||
$rolesToCreate = [
|
||||
1 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_ADMIN => [
|
||||
'name' => $l->t('Admin'),
|
||||
'description' => $l->t('Administrator role with full permissions'),
|
||||
'can_access_admin_tools' => true,
|
||||
@@ -266,7 +358,7 @@ class SeedHelper {
|
||||
'is_system_role' => true,
|
||||
'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_ADMIN,
|
||||
],
|
||||
2 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR => [
|
||||
'name' => $l->t('Moderator'),
|
||||
'description' => $l->t('Moderator role with elevated permissions'),
|
||||
'can_access_admin_tools' => true,
|
||||
@@ -275,7 +367,7 @@ class SeedHelper {
|
||||
'is_system_role' => true,
|
||||
'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR,
|
||||
],
|
||||
3 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT => [
|
||||
'name' => $l->t('User'),
|
||||
'description' => $l->t('Default user role with basic permissions'),
|
||||
'can_access_admin_tools' => false,
|
||||
@@ -284,7 +376,7 @@ class SeedHelper {
|
||||
'is_system_role' => true,
|
||||
'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT,
|
||||
],
|
||||
4 => [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_GUEST => [
|
||||
'name' => $l->t('Guest'),
|
||||
'description' => $l->t('Guest role for unauthenticated users with read-only access'),
|
||||
'can_access_admin_tools' => false,
|
||||
@@ -295,33 +387,34 @@ class SeedHelper {
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rolesToCreate as $roleId => $roleData) {
|
||||
if (!in_array($roleId, $existingIds)) {
|
||||
// Note: We cannot force auto-increment IDs in a portable way
|
||||
// This assumes roles are created in order during initial migration
|
||||
// If roles are missing, they will be created with next available IDs
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_roles')
|
||||
->values([
|
||||
'name' => $qb->createNamedParameter($roleData['name']),
|
||||
'description' => $qb->createNamedParameter($roleData['description']),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter($roleData['can_access_admin_tools'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_roles' => $qb->createNamedParameter($roleData['can_edit_roles'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_categories' => $qb->createNamedParameter($roleData['can_edit_categories'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_system_role' => $qb->createNamedParameter($roleData['is_system_role'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'role_type' => $qb->createNamedParameter($roleData['role_type'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$rolesCreated++;
|
||||
$logger->warning("Forum seeding: Created role ID expected to be $roleId, but actual ID may differ due to database state");
|
||||
foreach ($rolesToCreate as $roleType => $roleData) {
|
||||
if (!in_array($roleType, $existingTypes)) {
|
||||
try {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_roles')
|
||||
->values([
|
||||
'name' => $qb->createNamedParameter($roleData['name']),
|
||||
'description' => $qb->createNamedParameter($roleData['description']),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter($roleData['can_access_admin_tools'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_roles' => $qb->createNamedParameter($roleData['can_edit_roles'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_categories' => $qb->createNamedParameter($roleData['can_edit_categories'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_system_role' => $qb->createNamedParameter($roleData['is_system_role'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'role_type' => $qb->createNamedParameter($roleData['role_type'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$rolesCreated++;
|
||||
$logger->info("Forum seeding: Created role with type '$roleType'");
|
||||
} catch (\Exception $e) {
|
||||
// Log but continue - other roles might succeed
|
||||
$logger->warning("Forum seeding: Failed to create role '$roleType': " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
|
||||
// Validate that critical roles can be found by role_type after creation
|
||||
$roleMapper = \OC::$server->get(\OCA\Forum\Db\RoleMapper::class);
|
||||
// Note: We query directly instead of using RoleMapper to avoid MultipleObjectsReturnedException
|
||||
// if duplicates somehow exist (the cleanup migration should have removed them, but be defensive)
|
||||
$criticalRoles = [
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_GUEST => 'Guest',
|
||||
\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT => 'Default User',
|
||||
@@ -329,10 +422,18 @@ class SeedHelper {
|
||||
];
|
||||
|
||||
foreach ($criticalRoles as $roleType => $roleName) {
|
||||
try {
|
||||
$role = $roleMapper->findByRoleType($roleType);
|
||||
$logger->info("Forum seeding: Validated $roleName role (ID {$role->getId()}, type: $roleType)");
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter($roleType, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)))
|
||||
->setMaxResults(1);
|
||||
$result = $qb->executeQuery();
|
||||
$role = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if ($role) {
|
||||
$logger->info("Forum seeding: Validated $roleName role (ID {$role['id']}, type: $roleType)");
|
||||
} else {
|
||||
$logger->error("Forum seeding: CRITICAL - $roleName role not found after creation. This will break functionality.");
|
||||
if ($output) {
|
||||
$output->warning(" ✗ CRITICAL: $roleName role not found - forum may not function correctly");
|
||||
@@ -345,9 +446,6 @@ class SeedHelper {
|
||||
$output->info(" ✓ Created $rolesCreated default roles (Admin, Moderator, User, Guest)");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
$logger->error('Forum seeding: Failed to create default roles', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
@@ -440,7 +538,7 @@ class SeedHelper {
|
||||
$userAccessibleCategories = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
|
||||
$db->beginTransaction();
|
||||
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
|
||||
$categoriesGranted = 0;
|
||||
foreach ($userAccessibleCategories as $categoryRow) {
|
||||
$categoryId = (int)$categoryRow['category_id'];
|
||||
@@ -456,30 +554,31 @@ class SeedHelper {
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$permExists) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($guestRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
$categoriesGranted++;
|
||||
try {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($guestRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
$categoriesGranted++;
|
||||
} catch (\Exception $e) {
|
||||
// Log but continue - other categories might succeed
|
||||
$logger->warning("Forum seeding: Failed to set guest permission for category $categoryId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
$logger->info('Forum seeding: Set guest role view-only permissions for ' . $categoriesGranted . ' categories (matching User role access)');
|
||||
if ($output) {
|
||||
$output->info(' ✓ Set guest role view-only permissions for ' . $categoriesGranted . ' categories');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
$logger->error('Forum seeding: Failed to set guest role permissions', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
@@ -523,8 +622,7 @@ class SeedHelper {
|
||||
$output->info(' → Creating category headers...');
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
|
||||
// Note: No explicit transaction - single INSERT auto-commits
|
||||
// Create "General" category header
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_cat_headers')
|
||||
@@ -536,15 +634,11 @@ class SeedHelper {
|
||||
])
|
||||
->executeStatement();
|
||||
|
||||
$db->commit();
|
||||
$logger->info('Forum seeding: Created category headers');
|
||||
if ($output) {
|
||||
$output->info(' ✓ Created category headers');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
$logger->error('Forum seeding: Failed to create category headers', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
@@ -590,7 +684,7 @@ class SeedHelper {
|
||||
}
|
||||
|
||||
$headerId = (int)$header['id'];
|
||||
$db->beginTransaction();
|
||||
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
|
||||
$categoriesCreated = 0;
|
||||
|
||||
// Check if "General Discussions" category exists
|
||||
@@ -603,22 +697,26 @@ class SeedHelper {
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$exists) {
|
||||
// Create "General Discussions" category
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_categories')
|
||||
->values([
|
||||
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'name' => $qb->createNamedParameter($l->t('General discussions')),
|
||||
'description' => $qb->createNamedParameter($l->t('A place for general conversations and discussions')),
|
||||
'slug' => $qb->createNamedParameter('general-discussions'),
|
||||
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$categoriesCreated++;
|
||||
try {
|
||||
// Create "General Discussions" category
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_categories')
|
||||
->values([
|
||||
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'name' => $qb->createNamedParameter($l->t('General discussions')),
|
||||
'description' => $qb->createNamedParameter($l->t('A place for general conversations and discussions')),
|
||||
'slug' => $qb->createNamedParameter('general-discussions'),
|
||||
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$categoriesCreated++;
|
||||
} catch (\Exception $e) {
|
||||
$logger->warning('Forum seeding: Failed to create General Discussions category: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if "Support" category exists
|
||||
@@ -631,33 +729,33 @@ class SeedHelper {
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$exists) {
|
||||
// Create "Support" category
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_categories')
|
||||
->values([
|
||||
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'name' => $qb->createNamedParameter($l->t('Support')),
|
||||
'description' => $qb->createNamedParameter($l->t('Ask questions about the forum, provide feedback or report issues.')),
|
||||
'slug' => $qb->createNamedParameter('support'),
|
||||
'sort_order' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$categoriesCreated++;
|
||||
try {
|
||||
// Create "Support" category
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_categories')
|
||||
->values([
|
||||
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'name' => $qb->createNamedParameter($l->t('Support')),
|
||||
'description' => $qb->createNamedParameter($l->t('Ask questions about the forum, provide feedback or report issues.')),
|
||||
'slug' => $qb->createNamedParameter('support'),
|
||||
'sort_order' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$categoriesCreated++;
|
||||
} catch (\Exception $e) {
|
||||
$logger->warning('Forum seeding: Failed to create Support category: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
$logger->info("Forum seeding: Created $categoriesCreated default categories");
|
||||
if ($output) {
|
||||
$output->info(" ✓ Created $categoriesCreated default categories (General Discussions, Support)");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
$logger->error('Forum seeding: Failed to create default categories', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
@@ -694,19 +792,25 @@ class SeedHelper {
|
||||
throw new \RuntimeException('Cannot create category permissions: categories must be created first');
|
||||
}
|
||||
|
||||
// Check if roles exist
|
||||
// Find Moderator role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter([
|
||||
2,
|
||||
3,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$roles = $result->fetchAll();
|
||||
$moderatorRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (count($roles) < 2) {
|
||||
// Find User (default) role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$userRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$moderatorRole || !$userRole) {
|
||||
$logger->error('Forum seeding: Not all required roles exist, cannot create permissions');
|
||||
if ($output) {
|
||||
$output->warning(' ✗ Required roles do not exist, cannot create permissions');
|
||||
@@ -714,14 +818,17 @@ class SeedHelper {
|
||||
throw new \RuntimeException('Cannot create category permissions: roles must be created first');
|
||||
}
|
||||
|
||||
$moderatorRoleId = (int)$moderatorRole['id'];
|
||||
$userRoleId = (int)$userRole['id'];
|
||||
|
||||
if ($output) {
|
||||
$output->info(' → Creating category permissions...');
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
|
||||
$permissionsCreated = 0;
|
||||
|
||||
// Role IDs: ROLE_MODERATOR, ROLE_USER (Admin has implicit permissions)
|
||||
// Create permissions for Moderator and User roles (Admin has implicit permissions)
|
||||
foreach ($categories as $category) {
|
||||
$categoryId = (int)$category['id'];
|
||||
|
||||
@@ -730,24 +837,28 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_category_perms')
|
||||
->where($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(2, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($moderatorRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$exists = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$exists) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter(2, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
$permissionsCreated++;
|
||||
try {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($moderatorRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
$permissionsCreated++;
|
||||
} catch (\Exception $e) {
|
||||
$logger->warning("Forum seeding: Failed to create moderator permission for category $categoryId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check and create User role permissions
|
||||
@@ -755,36 +866,36 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_category_perms')
|
||||
->where($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(3, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$exists = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$exists) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter(3, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
$permissionsCreated++;
|
||||
try {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_category_perms')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
$permissionsCreated++;
|
||||
} catch (\Exception $e) {
|
||||
$logger->warning("Forum seeding: Failed to create user permission for category $categoryId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
$logger->info("Forum seeding: Created $permissionsCreated category permissions");
|
||||
if ($output) {
|
||||
$output->info(" ✓ Created $permissionsCreated category permissions for " . count($categories) . ' categories');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
$logger->error('Forum seeding: Failed to create category permissions', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
@@ -811,8 +922,7 @@ class SeedHelper {
|
||||
$output->info(' → Creating default BBCodes...');
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
|
||||
// Note: No explicit transaction - each INSERT auto-commits to avoid PostgreSQL transaction abort cascade
|
||||
$bbcodes = [
|
||||
[
|
||||
'tag' => 'icode',
|
||||
@@ -855,33 +965,33 @@ class SeedHelper {
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$exists) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_bbcodes')
|
||||
->values([
|
||||
'tag' => $qb->createNamedParameter($bbcode['tag']),
|
||||
'replacement' => $qb->createNamedParameter($bbcode['replacement']),
|
||||
'example' => $qb->createNamedParameter($bbcode['example']),
|
||||
'description' => $qb->createNamedParameter($bbcode['description']),
|
||||
'enabled' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'parse_inner' => $qb->createNamedParameter($bbcode['parse_inner'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_builtin' => $qb->createNamedParameter($bbcode['is_builtin'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'special_handler' => $qb->createNamedParameter($bbcode['special_handler']),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$bbcodesCreated++;
|
||||
try {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_bbcodes')
|
||||
->values([
|
||||
'tag' => $qb->createNamedParameter($bbcode['tag']),
|
||||
'replacement' => $qb->createNamedParameter($bbcode['replacement']),
|
||||
'example' => $qb->createNamedParameter($bbcode['example']),
|
||||
'description' => $qb->createNamedParameter($bbcode['description']),
|
||||
'enabled' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'parse_inner' => $qb->createNamedParameter($bbcode['parse_inner'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_builtin' => $qb->createNamedParameter($bbcode['is_builtin'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'special_handler' => $qb->createNamedParameter($bbcode['special_handler']),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$bbcodesCreated++;
|
||||
} catch (\Exception $e) {
|
||||
$logger->warning("Forum seeding: Failed to create BBCode '{$bbcode['tag']}': " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
$logger->info("Forum seeding: Created $bbcodesCreated default BBCodes");
|
||||
if ($output) {
|
||||
$output->info(" ✓ Created $bbcodesCreated default BBCodes (icode, spoiler, attachment)");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
$logger->error('Forum seeding: Failed to create default BBCodes', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
@@ -905,19 +1015,25 @@ class SeedHelper {
|
||||
$timestamp = time();
|
||||
|
||||
try {
|
||||
// Check if roles exist before assigning
|
||||
// Find Admin role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter([
|
||||
1,
|
||||
3,
|
||||
], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_ADMIN, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$roles = $result->fetchAll();
|
||||
$adminRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (count($roles) < 2) {
|
||||
// Find User (default) role by role_type (not hardcoded ID)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter(\OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR)));
|
||||
$result = $qb->executeQuery();
|
||||
$userRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$adminRole || !$userRole) {
|
||||
$logger->error('Forum seeding: Required roles do not exist, cannot assign user roles');
|
||||
if ($output) {
|
||||
$output->warning(' ✗ Required roles do not exist, cannot assign user roles');
|
||||
@@ -925,6 +1041,9 @@ class SeedHelper {
|
||||
throw new \RuntimeException('Cannot assign user roles: roles must be created first');
|
||||
}
|
||||
|
||||
$adminRoleId = (int)$adminRole['id'];
|
||||
$userRoleId = (int)$userRole['id'];
|
||||
|
||||
if ($output) {
|
||||
$output->info(' → Assigning roles to users...');
|
||||
}
|
||||
@@ -932,7 +1051,7 @@ class SeedHelper {
|
||||
// Assign roles to all users
|
||||
$usersProcessed = 0;
|
||||
$usersSkipped = 0;
|
||||
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $groupManager, $logger, $output, &$usersProcessed, &$usersSkipped) {
|
||||
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $groupManager, $logger, $output, &$usersProcessed, &$usersSkipped, $adminRoleId, $userRoleId) {
|
||||
try {
|
||||
$userId = $user->getUID();
|
||||
$isAdmin = $groupManager->isAdmin($userId);
|
||||
@@ -942,7 +1061,7 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_user_roles')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(3, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$hasUserRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -953,7 +1072,7 @@ class SeedHelper {
|
||||
$qb->insert('forum_user_roles')
|
||||
->values([
|
||||
'user_id' => $qb->createNamedParameter($userId),
|
||||
'role_id' => $qb->createNamedParameter(3, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
@@ -965,7 +1084,7 @@ class SeedHelper {
|
||||
$qb->select('id')
|
||||
->from('forum_user_roles')
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||
$result = $qb->executeQuery();
|
||||
$hasAdminRole = $result->fetch();
|
||||
$result->closeCursor();
|
||||
@@ -976,7 +1095,7 @@ class SeedHelper {
|
||||
$qb->insert('forum_user_roles')
|
||||
->values([
|
||||
'user_id' => $qb->createNamedParameter($userId),
|
||||
'role_id' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
@@ -1027,6 +1146,9 @@ class SeedHelper {
|
||||
$logger = \OC::$server->get(\Psr\Log\LoggerInterface::class);
|
||||
$timestamp = time();
|
||||
|
||||
// Recover connection state before starting (important for PostgreSQL)
|
||||
self::recoverConnectionState($db, $logger);
|
||||
|
||||
try {
|
||||
// Check if welcome thread already exists
|
||||
$qb = $db->getQueryBuilder();
|
||||
@@ -1078,6 +1200,27 @@ class SeedHelper {
|
||||
}
|
||||
});
|
||||
|
||||
// Prepare welcome post content
|
||||
$welcomeContent = $l->t('Welcome to the Nextcloud Forums!') . "\n\n"
|
||||
. $l->t('This is a community-driven forum built right into your Nextcloud instance. '
|
||||
. 'Here you can discuss topics, share ideas and collaborate with other users.') . "\n\n"
|
||||
. '[b]' . $l->t('Features:') . "[/b]\n"
|
||||
. "[list]\n"
|
||||
. '[*]' . $l->t('Create and reply to threads') . "\n"
|
||||
. '[*]' . $l->t('Organize discussions by categories') . "\n"
|
||||
. '[*]' . $l->t('Use BBCode for rich text formatting') . "\n"
|
||||
. '[*]' . $l->t('Attach files from your Nextcloud storage') . "\n"
|
||||
. '[*]' . $l->t('React to posts') . "\n"
|
||||
. '[*]' . $l->t('Track read/unread threads') . "\n\n"
|
||||
. "[/list]\n"
|
||||
. '[b]' . $l->t('BBCode examples:') . "[/b]\n"
|
||||
. "[list]\n"
|
||||
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use %1$stext%2$s', ['[icode][b]', '[/b][/icode]']) . "\n"
|
||||
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use %1$stext%2$s', ['[icode][i]', '[/i][/icode]']) . "\n"
|
||||
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use %1$stext%2$s', ['[icode][u]', '[/u][/icode]']) . "\n\n"
|
||||
. "[/list]\n"
|
||||
. $l->t('Feel free to start a new discussion or reply to existing threads. Happy posting!');
|
||||
|
||||
$db->beginTransaction();
|
||||
|
||||
// Create welcome thread
|
||||
@@ -1100,39 +1243,7 @@ class SeedHelper {
|
||||
->executeStatement();
|
||||
$threadId = $qb->getLastInsertId();
|
||||
|
||||
// Create welcome post
|
||||
$welcomeContent = $l->t('Welcome to the Nextcloud Forums!') . "\n\n"
|
||||
. $l->t('This is a community-driven forum built right into your Nextcloud instance. '
|
||||
. 'Here you can discuss topics, share ideas and collaborate with other users.') . "\n\n"
|
||||
. '[b]' . $l->t('Features:') . "[/b]\n"
|
||||
. "[list]\n"
|
||||
. '[*]' . $l->t('Create and reply to threads') . "\n"
|
||||
. '[*]' . $l->t('Organize discussions by categories') . "\n"
|
||||
. '[*]' . $l->t('Use BBCode for rich text formatting') . "\n"
|
||||
. '[*]' . $l->t('Attach files from your Nextcloud storage') . "\n"
|
||||
. '[*]' . $l->t('React to posts') . "\n"
|
||||
. '[*]' . $l->t('Track read/unread threads') . "\n\n"
|
||||
. "[/list]\n"
|
||||
. '[b]' . $l->t('BBCode examples:') . "[/b]\n"
|
||||
. "[list]\n"
|
||||
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use %1$stext%2$s', ['[icode][b]', '[/b][/icode]']) . "\n"
|
||||
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use %1$stext%2$s', ['[icode][i]', '[/i][/icode]']) . "\n"
|
||||
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use %1$stext%2$s', ['[icode][u]', '[/u][/icode]']) . "\n\n"
|
||||
. "[/list]\n"
|
||||
. $l->t('Feel free to start a new discussion or reply to existing threads. Happy posting!');
|
||||
|
||||
// Check if slug column still exists (for backwards compatibility with old migrations)
|
||||
// Use a query to check column existence since schema introspection APIs vary
|
||||
$hasSlugColumn = true;
|
||||
try {
|
||||
$checkQb = $db->getQueryBuilder();
|
||||
$checkQb->select('slug')->from('forum_posts')->setMaxResults(1);
|
||||
$checkQb->executeQuery()->closeCursor();
|
||||
} catch (\Exception $e) {
|
||||
$hasSlugColumn = false;
|
||||
}
|
||||
|
||||
// Build post values - slug is optional (removed in Version8)
|
||||
// Build post values (slug column was removed in Version8)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$postValues = [
|
||||
'thread_id' => $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
@@ -1144,9 +1255,6 @@ class SeedHelper {
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
];
|
||||
if ($hasSlugColumn) {
|
||||
$postValues['slug'] = $qb->createNamedParameter('welcome-to-nextcloud-forums-1');
|
||||
}
|
||||
|
||||
$qb->insert('forum_posts')
|
||||
->values($postValues)
|
||||
@@ -1231,8 +1339,13 @@ class SeedHelper {
|
||||
$output->info(' ✓ Created welcome thread');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
// Try to rollback if we're in a transaction - important for PostgreSQL recovery
|
||||
try {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
} catch (\Exception $rollbackEx) {
|
||||
$logger->debug('Forum seeding: Failed to rollback after welcome thread error', ['exception' => $rollbackEx->getMessage()]);
|
||||
}
|
||||
$logger->error('Forum seeding: Failed to create welcome thread', [
|
||||
'exception' => $e->getMessage(),
|
||||
|
||||
@@ -15,10 +15,9 @@ use OCP\Migration\SimpleMigrationStep;
|
||||
/**
|
||||
* Version 13 Migration:
|
||||
* - Ensure forum_users table exists (fixes fresh installs where table was missing)
|
||||
* - Run seed to ensure all required data exists
|
||||
*
|
||||
* This migration consolidates seeding into a single point and fixes an issue where
|
||||
* the forum_users table was not created on fresh installations.
|
||||
* Note: The seedAll() call was moved to Version14 migration to fix an issue where
|
||||
* the seeding used hardcoded role IDs that may not exist during upgrades.
|
||||
*/
|
||||
class Version13Date20251231000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
@@ -27,7 +26,7 @@ class Version13Date20251231000000 extends SimpleMigrationStep {
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// SeedHelper ensures forum_users table exists and seeds all required data
|
||||
SeedHelper::seedAll($output);
|
||||
// SeedHelper ensures forum_users table exists (table creation only, seeding moved to Version14)
|
||||
SeedHelper::ensureForumUsersTablePublic($output);
|
||||
}
|
||||
}
|
||||
|
||||
31
lib/Migration/Version14Date20260101000000.php
Normal file
31
lib/Migration/Version14Date20260101000000.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 14 Migration:
|
||||
* - Originally ran seed to ensure all required data exists
|
||||
* - Seeding moved to Version15 which first cleans up duplicate roles
|
||||
*
|
||||
* This migration is now a no-op but kept for migration history.
|
||||
*/
|
||||
class Version14Date20260101000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// No-op: Seeding moved to Version15 which first cleans up duplicate roles
|
||||
}
|
||||
}
|
||||
168
lib/Migration/Version15Date20260103000000.php
Normal file
168
lib/Migration/Version15Date20260103000000.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Version 15 Migration:
|
||||
* - Clean up duplicate roles that may exist from partial installations
|
||||
* - Add unique constraint on role_type to prevent future duplicates
|
||||
* - Re-run seeding to ensure all required data exists
|
||||
*/
|
||||
class Version15Date20260103000000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up duplicate roles before schema changes
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
$output->info('Forum: Checking for duplicate roles...');
|
||||
$this->cleanupDuplicateRoles($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return ISchemaWrapper|null
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
// Add unique index on role_type to prevent future duplicates
|
||||
if ($schema->hasTable('forum_roles')) {
|
||||
$table = $schema->getTable('forum_roles');
|
||||
|
||||
// Check if the unique index already exists
|
||||
$hasUniqueIndex = false;
|
||||
foreach ($table->getIndexes() as $index) {
|
||||
if ($index->getColumns() === ['role_type'] && $index->isUnique()) {
|
||||
$hasUniqueIndex = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasUniqueIndex) {
|
||||
$output->info('Forum: Adding unique constraint on role_type...');
|
||||
$table->addUniqueIndex(['role_type'], 'forum_roles_role_type_uniq');
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// No-op: Seeding moved to Version16 which removes the incorrect unique constraint first
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate roles, keeping only the first one of each type
|
||||
*/
|
||||
private function cleanupDuplicateRoles(IOutput $output): void {
|
||||
$roleTypes = ['admin', 'moderator', 'default', 'guest'];
|
||||
$duplicatesRemoved = 0;
|
||||
|
||||
foreach ($roleTypes as $roleType) {
|
||||
// Find all roles of this type, ordered by ID
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_roles')
|
||||
->where($qb->expr()->eq('role_type', $qb->createNamedParameter($roleType, IQueryBuilder::PARAM_STR)))
|
||||
->orderBy('id', 'ASC');
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
$roles = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
|
||||
if (count($roles) <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep the first one, delete the rest
|
||||
$keepId = (int)$roles[0]['id'];
|
||||
$deleteIds = array_map(fn ($r) => (int)$r['id'], array_slice($roles, 1));
|
||||
|
||||
$this->logger->info('Forum migration: Found ' . count($deleteIds) . " duplicate '$roleType' roles, keeping ID $keepId");
|
||||
$output->info(' Found ' . count($deleteIds) . " duplicate '$roleType' roles, keeping ID $keepId");
|
||||
|
||||
// Update user_roles to point to the kept role before deleting duplicates
|
||||
foreach ($deleteIds as $deleteId) {
|
||||
// Update forum_user_roles: reassign users from duplicate role to kept role
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('forum_user_roles')
|
||||
->set('role_id', $qb->createNamedParameter($keepId, IQueryBuilder::PARAM_INT))
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
try {
|
||||
$qb->executeStatement();
|
||||
} catch (\Exception $e) {
|
||||
// Might fail due to unique constraint if user already has the kept role - that's fine
|
||||
$this->logger->debug("Forum migration: Could not reassign user roles from $deleteId to $keepId: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Delete orphaned user_roles entries (users who already had the kept role)
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('forum_user_roles')
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
|
||||
// Update forum_category_perms: reassign permissions from duplicate role to kept role
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('forum_category_perms')
|
||||
->set('role_id', $qb->createNamedParameter($keepId, IQueryBuilder::PARAM_INT))
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
try {
|
||||
$qb->executeStatement();
|
||||
} catch (\Exception $e) {
|
||||
// Might fail due to unique constraint - that's fine
|
||||
$this->logger->debug("Forum migration: Could not reassign category perms from $deleteId to $keepId: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Delete orphaned category_perms entries
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('forum_category_perms')
|
||||
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($deleteId, IQueryBuilder::PARAM_INT)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
// Now delete the duplicate roles
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('forum_roles')
|
||||
->where($qb->expr()->in('id', $qb->createNamedParameter($deleteIds, IQueryBuilder::PARAM_INT_ARRAY)));
|
||||
$qb->executeStatement();
|
||||
|
||||
$duplicatesRemoved += count($deleteIds);
|
||||
}
|
||||
|
||||
if ($duplicatesRemoved > 0) {
|
||||
$output->info(" Removed $duplicatesRemoved duplicate roles");
|
||||
$this->logger->info("Forum migration: Removed $duplicatesRemoved duplicate roles");
|
||||
} else {
|
||||
$output->info(' No duplicate roles found');
|
||||
}
|
||||
}
|
||||
}
|
||||
49
lib/Migration/Version16Date20260117000000.php
Normal file
49
lib/Migration/Version16Date20260117000000.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 16 Migration:
|
||||
* - Remove unique constraint on role_type column to allow multiple custom roles
|
||||
*
|
||||
* The unique constraint was incorrectly added in Version15, which prevented
|
||||
* creating more than one custom role (since all custom roles have role_type='custom').
|
||||
*
|
||||
* Seeding is handled in Version17.
|
||||
*/
|
||||
class Version16Date20260117000000 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return ISchemaWrapper|null
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if ($schema->hasTable('forum_roles')) {
|
||||
$table = $schema->getTable('forum_roles');
|
||||
|
||||
// Remove the unique index on role_type if it exists
|
||||
// This was incorrectly added in Version15 and prevents creating multiple custom roles
|
||||
if ($table->hasIndex('forum_roles_role_type_uniq')) {
|
||||
$output->info('Forum: Removing unique constraint on role_type to allow multiple custom roles...');
|
||||
$table->dropIndex('forum_roles_role_type_uniq');
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
46
lib/Migration/Version17Date20260118000000.php
Normal file
46
lib/Migration/Version17Date20260118000000.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Version 17 Migration:
|
||||
* - Re-run seeding to ensure all required data exists
|
||||
*
|
||||
* Seeding is run after Version16 removes the incorrect unique constraint on role_type,
|
||||
* ensuring multiple custom roles can be created properly.
|
||||
*/
|
||||
class Version17Date20260118000000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// Re-run seeding to ensure all required data exists
|
||||
// Pass throwOnError=false to avoid PostgreSQL transaction abort issues
|
||||
// If seeding fails, users can run "occ forum:repair-seeds" to retry
|
||||
try {
|
||||
SeedHelper::seedAll($output, false);
|
||||
} catch (\Exception $e) {
|
||||
// This should not happen with throwOnError=false, but handle it gracefully
|
||||
$this->logger->error('Forum migration: Seeding failed unexpectedly', ['exception' => $e->getMessage()]);
|
||||
$output->warning('Forum: Seeding failed. Run "occ forum:repair-seeds" after enabling the app to complete setup.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,30 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
$table->addIndex(['name'], 'forum_roles_name_idx');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create forum_users table (formerly forum_user_stats)
|
||||
* Note: On fresh installs, this creates forum_users directly with the final schema.
|
||||
* For progressive installs where forum_user_stats already exists,
|
||||
* SeedHelper::ensureForumUsersTable() handles the rename.
|
||||
*
|
||||
* The table structure matches what Version2 transforms it to:
|
||||
* - id: auto-increment primary key
|
||||
* - user_id: unique string
|
||||
* - signature: added in Version8
|
||||
*/
|
||||
private function createUserStatsTable(ISchemaWrapper $schema): void {
|
||||
if ($schema->hasTable('forum_user_stats')) {
|
||||
// Skip if either table already exists (handles both fresh and progressive installs)
|
||||
if ($schema->hasTable('forum_users') || $schema->hasTable('forum_user_stats')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $schema->createTable('forum_user_stats');
|
||||
// Create forum_users directly with the final schema (matching Version2's transformation)
|
||||
$table = $schema->createTable('forum_users');
|
||||
$table->addColumn('id', 'bigint', [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('user_id', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 64,
|
||||
@@ -115,6 +133,10 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'unsigned' => true,
|
||||
'default' => null,
|
||||
]);
|
||||
$table->addColumn('signature', 'text', [
|
||||
'notnull' => false,
|
||||
'default' => null,
|
||||
]);
|
||||
$table->addColumn('created_at', 'integer', [
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
@@ -123,9 +145,11 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->setPrimaryKey(['user_id']);
|
||||
$table->addIndex(['post_count'], 'user_stats_post_count_idx');
|
||||
$table->addIndex(['deleted_at'], 'user_stats_deleted_at_idx');
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addUniqueIndex(['user_id'], 'forum_users_user_id_uniq');
|
||||
$table->addIndex(['post_count'], 'forum_users_post_count_idx');
|
||||
$table->addIndex(['thread_count'], 'forum_users_thread_count_idx');
|
||||
$table->addIndex(['deleted_at'], 'forum_users_deleted_at_idx');
|
||||
}
|
||||
|
||||
private function createForumUserRolesTable(ISchemaWrapper $schema): void {
|
||||
|
||||
@@ -67,18 +67,38 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
$table->addUniqueIndex(['user_id', 'thread_id'], 'thread_subs_uniq_idx');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix forum_user_stats or forum_users table structure
|
||||
* Handles both old table name (progressive installs) and new table name (fresh installs)
|
||||
*/
|
||||
private function fixForumUserStatsTable(ISchemaWrapper $schema): void {
|
||||
if (!$schema->hasTable('forum_user_stats')) {
|
||||
// Determine which table exists (handles both fresh and progressive installs)
|
||||
$tableName = null;
|
||||
if ($schema->hasTable('forum_user_stats')) {
|
||||
$tableName = 'forum_user_stats';
|
||||
} elseif ($schema->hasTable('forum_users')) {
|
||||
$tableName = 'forum_users';
|
||||
}
|
||||
|
||||
if ($tableName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('forum_user_stats');
|
||||
$table = $schema->getTable($tableName);
|
||||
|
||||
// Check if already fixed (has id column)
|
||||
// Note: On fresh installs, forum_users uses user_id as primary key (no id column needed)
|
||||
// This fix is only needed for progressive installs with old forum_user_stats structure
|
||||
if ($table->hasColumn('id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add id column to forum_user_stats (old structure)
|
||||
// forum_users created in Version1 uses user_id as primary key and doesn't need this fix
|
||||
if ($tableName !== 'forum_user_stats') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add id column as auto-increment
|
||||
$table->addColumn('id', 'bigint', [
|
||||
'autoincrement' => true,
|
||||
@@ -123,8 +143,7 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild user stats using the old table name (forum_user_stats)
|
||||
* This is needed because Version8 hasn't renamed the table yet
|
||||
* Rebuild user stats - handles both old (forum_user_stats) and new (forum_users) table names
|
||||
*/
|
||||
private function rebuildAllUserStatsLegacy(): array {
|
||||
// Get all user IDs from Nextcloud
|
||||
@@ -133,11 +152,22 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
$users[] = $user->getUID();
|
||||
});
|
||||
|
||||
// Determine which table to use
|
||||
$tableName = $this->getUserStatsTableName();
|
||||
if ($tableName === null) {
|
||||
// No table exists yet - this shouldn't happen but handle gracefully
|
||||
return [
|
||||
'users' => count($users),
|
||||
'updated' => 0,
|
||||
'created' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
$created = 0;
|
||||
|
||||
foreach ($users as $userId) {
|
||||
$wasCreated = $this->rebuildUserStatsLegacy($userId);
|
||||
$wasCreated = $this->rebuildUserStatsLegacy($userId, $tableName);
|
||||
if ($wasCreated) {
|
||||
$created++;
|
||||
} else {
|
||||
@@ -153,9 +183,36 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild stats for a single user using the old table name
|
||||
* Get the user stats table name (handles both old and new names)
|
||||
*/
|
||||
private function rebuildUserStatsLegacy(string $userId): bool {
|
||||
private function getUserStatsTableName(): ?string {
|
||||
// Check forum_users first (new name, for fresh installs)
|
||||
// Then check forum_user_stats (old name, for progressive installs)
|
||||
try {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('user_id')->from('forum_users')->setMaxResults(1);
|
||||
$qb->executeQuery()->closeCursor();
|
||||
return 'forum_users';
|
||||
} catch (\Exception $e) {
|
||||
// Table doesn't exist, try old name
|
||||
}
|
||||
|
||||
try {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('user_id')->from('forum_user_stats')->setMaxResults(1);
|
||||
$qb->executeQuery()->closeCursor();
|
||||
return 'forum_user_stats';
|
||||
} catch (\Exception $e) {
|
||||
// Neither table exists
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild stats for a single user
|
||||
*/
|
||||
private function rebuildUserStatsLegacy(string $userId, string $tableName): bool {
|
||||
// Count non-deleted threads created by this user
|
||||
$threadQb = $this->db->getQueryBuilder();
|
||||
$threadQb->select($threadQb->func()->count('*', 'count'))
|
||||
@@ -194,10 +251,10 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
$lastPostAt = $lastPostResult->fetchOne();
|
||||
$lastPostResult->closeCursor();
|
||||
|
||||
// Check if forum user record already exists (using OLD table name)
|
||||
// Check if forum user record already exists
|
||||
$checkQb = $this->db->getQueryBuilder();
|
||||
$checkQb->select('user_id')
|
||||
->from('forum_user_stats') // OLD table name!
|
||||
->from($tableName)
|
||||
->where($checkQb->expr()->eq('user_id', $checkQb->createNamedParameter($userId)));
|
||||
$checkResult = $checkQb->executeQuery();
|
||||
$exists = $checkResult->fetch();
|
||||
@@ -206,9 +263,9 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
$timestamp = time();
|
||||
|
||||
if ($exists) {
|
||||
// Update existing record (using OLD table name)
|
||||
// Update existing record
|
||||
$updateQb = $this->db->getQueryBuilder();
|
||||
$updateQb->update('forum_user_stats') // OLD table name!
|
||||
$updateQb->update($tableName)
|
||||
->set('thread_count', $updateQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT))
|
||||
->set('post_count', $updateQb->createNamedParameter($postCount, IQueryBuilder::PARAM_INT))
|
||||
->set('updated_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
|
||||
@@ -221,9 +278,9 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
$updateQb->executeStatement();
|
||||
return false;
|
||||
} else {
|
||||
// Create new record (using OLD table name)
|
||||
// Create new record
|
||||
$insertQb = $this->db->getQueryBuilder();
|
||||
$insertQb->insert('forum_user_stats') // OLD table name!
|
||||
$insertQb->insert($tableName)
|
||||
->values([
|
||||
'user_id' => $insertQb->createNamedParameter($userId),
|
||||
'thread_count' => $insertQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT),
|
||||
@@ -239,7 +296,7 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
} catch (\Exception $e) {
|
||||
// If insert fails (race condition), try updating instead
|
||||
$updateQb = $this->db->getQueryBuilder();
|
||||
$updateQb->update('forum_user_stats') // OLD table name!
|
||||
$updateQb->update($tableName)
|
||||
->set('thread_count', $updateQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT))
|
||||
->set('post_count', $updateQb->createNamedParameter($postCount, IQueryBuilder::PARAM_INT))
|
||||
->set('updated_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
|
||||
|
||||
@@ -43,9 +43,18 @@ class Version8Date20251128000000 extends SimpleMigrationStep {
|
||||
}
|
||||
}
|
||||
|
||||
// Add signature column to user stats
|
||||
if ($schema->hasTable('forum_user_stats')) {
|
||||
$table = $schema->getTable('forum_user_stats');
|
||||
// Add signature column to forum_users table (handles both old and new table names)
|
||||
// On fresh installs: forum_users is created with signature column in Version1
|
||||
// On progressive installs: forum_user_stats may still exist and needs signature added
|
||||
$userTableName = null;
|
||||
if ($schema->hasTable('forum_users')) {
|
||||
$userTableName = 'forum_users';
|
||||
} elseif ($schema->hasTable('forum_user_stats')) {
|
||||
$userTableName = 'forum_user_stats';
|
||||
}
|
||||
|
||||
if ($userTableName !== null) {
|
||||
$table = $schema->getTable($userTableName);
|
||||
|
||||
if (!$table->hasColumn('signature')) {
|
||||
$table->addColumn('signature', 'text', [
|
||||
|
||||
32
lib/Sections/AdminSection.php
Normal file
32
lib/Sections/AdminSection.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace OCA\Forum\Sections;
|
||||
|
||||
use OCA\Forum\AppInfo;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
class AdminSection implements IIconSection {
|
||||
public function __construct(
|
||||
private IL10N $l,
|
||||
private IURLGenerator $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath(AppInfo\Application::APP_ID, 'app-dark.svg');
|
||||
}
|
||||
|
||||
public function getID(): string {
|
||||
return AppInfo\Application::APP_ID;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->l->t('Forum');
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
return 80;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ class UserPreferencesService {
|
||||
/** Preference key for auto-subscribing to created threads */
|
||||
public const PREF_AUTO_SUBSCRIBE_CREATED_THREADS = 'auto_subscribe_created_threads';
|
||||
|
||||
/** Preference key for auto-subscribing to threads when replying */
|
||||
public const PREF_AUTO_SUBSCRIBE_REPLIED_THREADS = 'auto_subscribe_replied_threads';
|
||||
|
||||
/** Preference key for upload directory path */
|
||||
public const PREF_UPLOAD_DIRECTORY = 'upload_directory';
|
||||
|
||||
@@ -26,6 +29,7 @@ class UserPreferencesService {
|
||||
/** @var array<string, mixed> Default preference values */
|
||||
private const DEFAULTS = [
|
||||
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true,
|
||||
self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS => false,
|
||||
self::PREF_UPLOAD_DIRECTORY => 'Forum',
|
||||
self::PREF_SIGNATURE => '',
|
||||
];
|
||||
@@ -33,6 +37,7 @@ class UserPreferencesService {
|
||||
/** @var array<string> List of valid preference keys */
|
||||
private const VALID_KEYS = [
|
||||
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS,
|
||||
self::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS,
|
||||
self::PREF_UPLOAD_DIRECTORY,
|
||||
self::PREF_SIGNATURE,
|
||||
];
|
||||
|
||||
42
lib/Settings/AdminSettings.php
Normal file
42
lib/Settings/AdminSettings.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace OCA\Forum\Settings;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\Settings\ISettings;
|
||||
|
||||
class AdminSettings implements ISettings {
|
||||
public function __construct(
|
||||
private IAppConfig $config,
|
||||
private IL10N $l,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
public function getForm(): TemplateResponse {
|
||||
return new TemplateResponse(Application::APP_ID, 'settings', [
|
||||
'script' => Application::getViteEntryScript('admin.ts'),
|
||||
'style' => Application::getViteEntryScript('style.css'),
|
||||
], '');
|
||||
}
|
||||
|
||||
public function getSection(): string {
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int whether the form should be rather on the top or bottom of
|
||||
* the admin section. The forms are arranged in ascending order of the
|
||||
* priority values. It is required to return a value between 0 and 100.
|
||||
*
|
||||
* E.g.: 70
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
461
openapi.json
461
openapi.json
@@ -454,6 +454,460 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/admin/repair-seeds": {
|
||||
"post": {
|
||||
"operationId": "admin-repair-seeds",
|
||||
"summary": "Run the repair seeds command to restore default forum data",
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Seeds repaired successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"success",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/admin/roles": {
|
||||
"get": {
|
||||
"operationId": "admin-get-roles",
|
||||
"summary": "Get all available roles",
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Roles list returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"roles"
|
||||
],
|
||||
"properties": {
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles": {
|
||||
"post": {
|
||||
"operationId": "admin-assign-role",
|
||||
"summary": "Assign a role to a user",
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"roleId"
|
||||
],
|
||||
"properties": {
|
||||
"roleId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The role ID to assign"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "path",
|
||||
"description": "The user ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Role assigned successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"success",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles/{roleId}": {
|
||||
"delete": {
|
||||
"operationId": "admin-remove-role",
|
||||
"summary": "Remove a role from a user",
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "path",
|
||||
"description": "The user ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "roleId",
|
||||
"in": "path",
|
||||
"description": "The role ID to remove",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Role removed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"success",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/bbcodes": {
|
||||
"get": {
|
||||
"operationId": "bb_code-index",
|
||||
@@ -2729,6 +3183,13 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"headerId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Category header ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
|
||||
19
package.json
19
package.json
@@ -5,15 +5,18 @@
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.19.0",
|
||||
"pnpm": "^10.17.0"
|
||||
"pnpm": "^10.27.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"lint": "eslint src",
|
||||
"format": "eslint --fix src && prettier --write {vite.config.ts,src/,README.md}",
|
||||
"prepare": "husky",
|
||||
"gen": "simple-scaffold -c . -k"
|
||||
"gen": "simple-scaffold -c . -k",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @nextcloud/browserslist-config"
|
||||
@@ -25,7 +28,7 @@
|
||||
"@nextcloud/l10n": "^3.4.1",
|
||||
"@nextcloud/router": "^3.1.0",
|
||||
"@nextcloud/vite-config": "2.3.5",
|
||||
"@nextcloud/vue": "^9.3.1",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"vue": "^3.5.26",
|
||||
@@ -36,19 +39,23 @@
|
||||
"@nextcloud/browserslist-config": "^3.1.2",
|
||||
"@nextcloud/eslint-config": "^8.4.2",
|
||||
"@nextcloud/stylelint-config": "^3.1.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.39.2",
|
||||
"happy-dom": "^20.3.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-vue": "^1.1.6",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"sass": "^1.97.1",
|
||||
"sass-embedded": "^1.97.1",
|
||||
"sass": "^1.97.2",
|
||||
"sass-embedded": "^1.97.2",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vitest": "^4.0.17",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
|
||||
1614
pnpm-lock.yaml
generated
1614
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ module.exports = () => {
|
||||
component: {
|
||||
templates: ['gen/component'],
|
||||
output: 'src/components',
|
||||
subDir: false,
|
||||
},
|
||||
view: {
|
||||
templates: ['gen/view'],
|
||||
|
||||
277
src/AdminSettings.vue
Normal file
277
src/AdminSettings.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div id="forum-settings" class="section">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
|
||||
<NcSettingsSection :name="strings.repairSeedsHeader">
|
||||
<NcNoteCard type="info">
|
||||
{{ strings.repairSeedsHelp }}
|
||||
</NcNoteCard>
|
||||
|
||||
<div class="settings-section-content">
|
||||
<div class="repair-seeds-container">
|
||||
<NcButton :disabled="repairSeedsLoading" @click="runRepairSeeds">
|
||||
<template #icon>
|
||||
<WrenchIcon v-if="!repairSeedsLoading" :size="20" />
|
||||
<NcLoadingIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ strings.runRepairSeeds }}
|
||||
</NcButton>
|
||||
|
||||
<NcNoteCard v-if="repairSeedsResult" :type="repairSeedsSuccess ? 'success' : 'error'">
|
||||
<pre class="repair-seeds-output">{{ repairSeedsResult }}</pre>
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
</div>
|
||||
</NcSettingsSection>
|
||||
|
||||
<NcSettingsSection :name="strings.userRolesHeader">
|
||||
<NcNoteCard type="info">
|
||||
{{ strings.userRolesHelp }}
|
||||
</NcNoteCard>
|
||||
|
||||
<div class="settings-section-content">
|
||||
<div class="user-role-form">
|
||||
<div class="field-row">
|
||||
<div class="form-group">
|
||||
<label for="user-id">{{ strings.userIdLabel }}</label>
|
||||
<NcTextField
|
||||
id="user-id"
|
||||
v-model="userId"
|
||||
:placeholder="strings.userIdPlaceholder"
|
||||
:disabled="assignRoleLoading"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="role-select">{{ strings.roleLabel }}</label>
|
||||
<NcSelect
|
||||
input-id="role-select"
|
||||
v-model="selectedRole"
|
||||
:options="roleOptions"
|
||||
:placeholder="strings.rolePlaceholder"
|
||||
:disabled="assignRoleLoading || rolesLoading"
|
||||
:loading="rolesLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<NcButton
|
||||
variant="primary"
|
||||
:disabled="!canAssignRole || assignRoleLoading"
|
||||
@click="assignRole"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon v-if="!assignRoleLoading" :size="20" />
|
||||
<NcLoadingIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ strings.assignRole }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-if="assignRoleResult" :type="assignRoleSuccess ? 'success' : 'error'">
|
||||
<p>{{ assignRoleResult }}</p>
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
</div>
|
||||
</NcSettingsSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import WrenchIcon from '@icons/Wrench.vue'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'AdminSettings',
|
||||
components: {
|
||||
NcSettingsSection,
|
||||
NcButton,
|
||||
NcSelect,
|
||||
NcNoteCard,
|
||||
NcTextField,
|
||||
NcLoadingIcon,
|
||||
WrenchIcon,
|
||||
PlusIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Repair seeds
|
||||
repairSeedsLoading: false,
|
||||
repairSeedsResult: null,
|
||||
repairSeedsSuccess: false,
|
||||
|
||||
// User roles
|
||||
rolesLoading: true,
|
||||
roles: [],
|
||||
userId: '',
|
||||
selectedRole: null,
|
||||
assignRoleLoading: false,
|
||||
assignRoleResult: null,
|
||||
assignRoleSuccess: false,
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'Forum'),
|
||||
repairSeedsHeader: t('forum', 'Repair Seeds'),
|
||||
repairSeedsHelp: t(
|
||||
'forum',
|
||||
'Run the repair seeds command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists.',
|
||||
),
|
||||
runRepairSeeds: t('forum', 'Run Repair Seeds'),
|
||||
userRolesHeader: t('forum', 'User Roles'),
|
||||
userRolesHelp: t(
|
||||
'forum',
|
||||
'Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users.',
|
||||
),
|
||||
userIdLabel: t('forum', 'User ID'),
|
||||
userIdPlaceholder: t('forum', 'Enter user ID'),
|
||||
roleLabel: t('forum', 'Role'),
|
||||
rolePlaceholder: t('forum', 'Select a role'),
|
||||
assignRole: t('forum', 'Assign Role'),
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
roleOptions() {
|
||||
return this.roles.map((role) => ({
|
||||
id: role.id,
|
||||
label: role.name,
|
||||
}))
|
||||
},
|
||||
canAssignRole() {
|
||||
return this.userId.trim() !== '' && this.selectedRole !== null
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchRoles()
|
||||
},
|
||||
methods: {
|
||||
async fetchRoles() {
|
||||
try {
|
||||
this.rolesLoading = true
|
||||
const resp = await ocs.get('/admin/roles')
|
||||
this.roles = resp.data.roles
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch roles', e)
|
||||
} finally {
|
||||
this.rolesLoading = false
|
||||
}
|
||||
},
|
||||
async runRepairSeeds() {
|
||||
try {
|
||||
this.repairSeedsLoading = true
|
||||
this.repairSeedsResult = null
|
||||
const resp = await ocs.post('/admin/repair-seeds')
|
||||
this.repairSeedsSuccess = resp.data.success
|
||||
this.repairSeedsResult = resp.data.message
|
||||
if (resp.data.success) {
|
||||
await this.fetchRoles()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to run repair seeds', e)
|
||||
this.repairSeedsSuccess = false
|
||||
this.repairSeedsResult =
|
||||
e.response?.data?.message || t('forum', 'Failed to run repair seeds')
|
||||
} finally {
|
||||
this.repairSeedsLoading = false
|
||||
}
|
||||
},
|
||||
async assignRole() {
|
||||
if (!this.canAssignRole) return
|
||||
|
||||
try {
|
||||
this.assignRoleLoading = true
|
||||
this.assignRoleResult = null
|
||||
const resp = await ocs.post(
|
||||
`/admin/users/${encodeURIComponent(this.userId.trim())}/roles`,
|
||||
{
|
||||
roleId: this.selectedRole.id,
|
||||
},
|
||||
)
|
||||
this.assignRoleSuccess = resp.data.success
|
||||
this.assignRoleResult = resp.data.message
|
||||
if (resp.data.success) {
|
||||
// Clear form on success
|
||||
this.userId = ''
|
||||
this.selectedRole = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to assign role', e)
|
||||
this.assignRoleSuccess = false
|
||||
this.assignRoleResult = e.response?.data?.message || t('forum', 'Failed to assign role')
|
||||
} finally {
|
||||
this.assignRoleLoading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#forum-settings {
|
||||
h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.repair-seeds-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.repair-seeds-output {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-role-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.form-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
src/App.vue
12
src/App.vue
@@ -23,7 +23,7 @@ import { defineComponent } from 'vue'
|
||||
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppNavigation from '@/components/AppNavigation.vue'
|
||||
import AppNavigation from '@/components/AppNavigation'
|
||||
import { isDarkTheme } from '@nextcloud/vue/functions/isDarkTheme'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -126,10 +126,10 @@ export default defineComponent({
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Global styles for smooth scrolling
|
||||
html,
|
||||
body,
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
// Fix content width on mobile
|
||||
@media screen and (max-width: 768px) {
|
||||
#content-vue.app-forum {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
5
src/admin.ts
Normal file
5
src/admin.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminSettings from './AdminSettings.vue'
|
||||
import './style.scss'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
createApp(AdminSettings).mount('#forum-settings')
|
||||
145
src/components/AdminTable/AdminTable.test.ts
Normal file
145
src/components/AdminTable/AdminTable.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AdminTable from './AdminTable.vue'
|
||||
|
||||
describe('AdminTable', () => {
|
||||
const defaultColumns = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
]
|
||||
|
||||
const defaultRows = [
|
||||
{ id: 1, name: 'John Doe', email: 'john@example.com' },
|
||||
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
|
||||
]
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render column headers', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.find('.header-row').text()).toContain('Name')
|
||||
expect(wrapper.find('.header-row').text()).toContain('Email')
|
||||
})
|
||||
|
||||
it('should render data rows', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
const dataRows = wrapper.findAll('.data-row')
|
||||
expect(dataRows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render cell values', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.text()).toContain('John Doe')
|
||||
expect(wrapper.text()).toContain('john@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions column', () => {
|
||||
it('should not show actions column by default', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.find('.col-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show actions column when hasActions is true', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
|
||||
})
|
||||
expect(wrapper.findAll('.col-actions').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should use custom actions label', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: {
|
||||
columns: defaultColumns,
|
||||
rows: defaultRows,
|
||||
hasActions: true,
|
||||
actionsLabel: 'Operations',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.header-row').text()).toContain('Operations')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid style', () => {
|
||||
it('should compute grid template columns', () => {
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Name', width: '200px' },
|
||||
{ key: 'email', label: 'Email', minWidth: '150px' },
|
||||
]
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns, rows: defaultRows },
|
||||
})
|
||||
const grid = wrapper.find('.table-grid')
|
||||
expect(grid.attributes('style')).toContain('grid-template-columns')
|
||||
})
|
||||
})
|
||||
|
||||
describe('row key', () => {
|
||||
it('should use id as row key by default', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
})
|
||||
expect(wrapper.findAll('.data-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should use custom row key', () => {
|
||||
const rows = [
|
||||
{ customId: 'a', name: 'Test' },
|
||||
{ customId: 'b', name: 'Test 2' },
|
||||
]
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: [{ key: 'name', label: 'Name' }], rows, rowKey: 'customId' },
|
||||
})
|
||||
expect(wrapper.findAll('.data-row')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slots', () => {
|
||||
it('should render custom cell content via slot', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows },
|
||||
slots: {
|
||||
'cell-name': '<span class="custom-cell">Custom Name</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.findAll('.custom-cell').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render actions slot', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows, hasActions: true },
|
||||
slots: {
|
||||
actions: '<button class="action-btn">Edit</button>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.findAll('.action-btn').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('row class', () => {
|
||||
it('should apply string row class', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: { columns: defaultColumns, rows: defaultRows, rowClass: 'custom-row' },
|
||||
})
|
||||
expect(wrapper.findAll('.data-row.custom-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should apply function row class', () => {
|
||||
const wrapper = mount(AdminTable, {
|
||||
props: {
|
||||
columns: defaultColumns,
|
||||
rows: defaultRows,
|
||||
rowClass: (row: { id: number }) => (row.id === 1 ? 'first-row' : ''),
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.data-row.first-row').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/AdminTable/index.ts
Normal file
2
src/components/AdminTable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import AdminTable from './AdminTable.vue'
|
||||
export default AdminTable
|
||||
@@ -193,7 +193,7 @@ import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
|
||||
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import UserInfo from '@/components/UserInfo.vue'
|
||||
import UserInfo from '@/components/UserInfo'
|
||||
import HomeIcon from '@icons/Home.vue'
|
||||
import ForumIcon from '@icons/Forum.vue'
|
||||
import FolderIcon from '@icons/Folder.vue'
|
||||
2
src/components/AppNavigation/index.ts
Normal file
2
src/components/AppNavigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import AppNavigation from './AppNavigation.vue'
|
||||
export default AppNavigation
|
||||
61
src/components/AppToolbar/AppToolbar.test.ts
Normal file
61
src/components/AppToolbar/AppToolbar.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppToolbar from './AppToolbar.vue'
|
||||
|
||||
describe('AppToolbar', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render toolbar container', () => {
|
||||
const wrapper = mount(AppToolbar)
|
||||
expect(wrapper.find('.app-toolbar').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render left and right sections', () => {
|
||||
const wrapper = mount(AppToolbar)
|
||||
expect(wrapper.find('.toolbar-left').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-right').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slots', () => {
|
||||
it('should render left slot content', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
left: '<span class="left-content">Left Content</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.toolbar-left .left-content').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-left').text()).toBe('Left Content')
|
||||
})
|
||||
|
||||
it('should render right slot content', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
right: '<span class="right-content">Right Content</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.toolbar-right .right-content').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-right').text()).toBe('Right Content')
|
||||
})
|
||||
|
||||
it('should render both slots simultaneously', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
left: '<button>Action</button>',
|
||||
right: '<span>Status</span>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.toolbar-left button').exists()).toBe(true)
|
||||
expect(wrapper.find('.toolbar-right span').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should render multiple elements in a slot', () => {
|
||||
const wrapper = mount(AppToolbar, {
|
||||
slots: {
|
||||
left: '<button>One</button><button>Two</button><button>Three</button>',
|
||||
},
|
||||
})
|
||||
const buttons = wrapper.findAll('.toolbar-left button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/AppToolbar/index.ts
Normal file
2
src/components/AppToolbar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import AppToolbar from './AppToolbar.vue'
|
||||
export default AppToolbar
|
||||
@@ -9,6 +9,7 @@
|
||||
<BBCodeToolbar
|
||||
ref="toolbar"
|
||||
:textarea-ref="contenteditableElement"
|
||||
:model-value="modelValue"
|
||||
@insert="handleBBCodeInsert"
|
||||
/>
|
||||
<NcRichContenteditable
|
||||
@@ -42,7 +43,7 @@
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcRichContenteditable from '@nextcloud/vue/components/NcRichContenteditable'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import BBCodeToolbar from './BBCodeToolbar.vue'
|
||||
import BBCodeToolbar from '@/components/BBCodeToolbar'
|
||||
import UploadIcon from '@icons/Upload.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { ocs } from '@/axios'
|
||||
2
src/components/BBCodeEditor/index.ts
Normal file
2
src/components/BBCodeEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import BBCodeEditor from './BBCodeEditor.vue'
|
||||
export default BBCodeEditor
|
||||
419
src/components/BBCodeHelpDialog/BBCodeHelpDialog.test.ts
Normal file
419
src/components/BBCodeHelpDialog/BBCodeHelpDialog.test.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import type { BBCode } from '@/types'
|
||||
|
||||
// Mock axios - must use factory that doesn't reference external variables
|
||||
vi.mock('@/axios', () => ({
|
||||
ocs: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mock
|
||||
import { ocs } from '@/axios'
|
||||
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
|
||||
|
||||
const mockGet = vi.mocked(ocs.get)
|
||||
|
||||
describe('BBCodeHelpDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGet.mockResolvedValue({ data: [] } as never)
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(BBCodeHelpDialog, {
|
||||
props: {
|
||||
open: true,
|
||||
showCustom: true,
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the dialog when open', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders built-in BBCodes section', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-section').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Built-in BBCodes')
|
||||
})
|
||||
|
||||
it('renders all built-in BBCode tags', () => {
|
||||
const wrapper = createWrapper()
|
||||
const tags = wrapper.findAll('.bbcode-tag')
|
||||
|
||||
// Check for some expected built-in tags
|
||||
const tagTexts = tags.map((t) => t.text())
|
||||
expect(tagTexts).toContain('[b]')
|
||||
expect(tagTexts).toContain('[i]')
|
||||
expect(tagTexts).toContain('[code]')
|
||||
expect(tagTexts).toContain('[url]')
|
||||
expect(tagTexts).toContain('[img]')
|
||||
expect(tagTexts).toContain('[quote]')
|
||||
})
|
||||
|
||||
it('renders BBCode examples', () => {
|
||||
const wrapper = createWrapper()
|
||||
const examples = wrapper.findAll('.example-code')
|
||||
expect(examples.length).toBeGreaterThan(0)
|
||||
// Check for a specific example
|
||||
expect(wrapper.text()).toContain('[b]Hello world![/b]')
|
||||
})
|
||||
|
||||
it('renders custom BBCodes section when showCustom is true', () => {
|
||||
const wrapper = createWrapper({ showCustom: true })
|
||||
expect(wrapper.text()).toContain('Custom BBCodes')
|
||||
})
|
||||
|
||||
it('does not render custom BBCodes section when showCustom is false', () => {
|
||||
const wrapper = createWrapper({ showCustom: false })
|
||||
expect(wrapper.text()).not.toContain('Custom BBCodes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetching builtin DB codes', () => {
|
||||
it('fetches builtin codes when dialog opens', async () => {
|
||||
createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/bbcodes/builtin')
|
||||
})
|
||||
|
||||
it('displays builtin DB codes', async () => {
|
||||
const builtinCodes: BBCode[] = [
|
||||
{
|
||||
id: 1,
|
||||
tag: 'spoiler',
|
||||
replacement: '<span class="spoiler">{content}</span>',
|
||||
example: '[spoiler]Hidden text[/spoiler]',
|
||||
description: 'Spoiler text',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: true,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes/builtin') {
|
||||
return Promise.resolve({ data: builtinCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('[spoiler]')
|
||||
expect(wrapper.text()).toContain('Spoiler text')
|
||||
})
|
||||
|
||||
it('silently fails when builtin codes fetch fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes/builtin') {
|
||||
return Promise.reject(new Error('Network error'))
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
// Should not show error state for builtin codes
|
||||
expect(wrapper.find('.error-state').exists()).toBe(false)
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetching custom codes', () => {
|
||||
it('fetches custom codes when dialog opens with showCustom true', async () => {
|
||||
createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/bbcodes')
|
||||
})
|
||||
|
||||
it('does not fetch custom codes when showCustom is false', async () => {
|
||||
createWrapper({ open: true, showCustom: false })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGet).not.toHaveBeenCalledWith('/bbcodes')
|
||||
})
|
||||
|
||||
it('displays loading state while fetching custom codes', async () => {
|
||||
let resolvePromise: (value: unknown) => void
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Loading custom BBCodes')
|
||||
|
||||
resolvePromise!({ data: [] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.loading-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('displays custom codes after fetch', async () => {
|
||||
const customCodes: BBCode[] = [
|
||||
{
|
||||
id: 10,
|
||||
tag: 'highlight',
|
||||
replacement: '<mark>{content}</mark>',
|
||||
example: '[highlight]Important text[/highlight]',
|
||||
description: 'Highlight text',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.resolve({ data: customCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('[highlight]')
|
||||
expect(wrapper.text()).toContain('Highlight text')
|
||||
})
|
||||
|
||||
it('filters out disabled custom codes', async () => {
|
||||
const customCodes: BBCode[] = [
|
||||
{
|
||||
id: 10,
|
||||
tag: 'enabled',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[enabled]Text[/enabled]',
|
||||
description: 'Enabled code',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
tag: 'disabled',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[disabled]Text[/disabled]',
|
||||
description: 'Disabled code',
|
||||
enabled: false,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.resolve({ data: customCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('[enabled]')
|
||||
expect(wrapper.text()).not.toContain('[disabled]')
|
||||
})
|
||||
|
||||
it('displays empty state when no custom codes exist', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] } as never)
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.empty-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('No custom BBCodes configured')
|
||||
})
|
||||
|
||||
it('displays error state when fetch fails', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.reject(new Error('Network error'))
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.error-state').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Failed to load custom BBCodes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('caching', () => {
|
||||
it('does not refetch builtin codes if already loaded when reopening', async () => {
|
||||
// Mock returns data
|
||||
const builtinCodes: BBCode[] = [
|
||||
{
|
||||
id: 1,
|
||||
tag: 'test',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[test]Hello[/test]',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: true,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes/builtin') {
|
||||
return Promise.resolve({ data: builtinCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
// Close and reopen - since builtinDbCodes.length > 0, should not refetch
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes/builtin').length
|
||||
expect(newCallCount).toBe(1) // Should still be 1
|
||||
})
|
||||
|
||||
it('does not refetch custom codes if already loaded when reopening', async () => {
|
||||
// Mock returns data for custom codes
|
||||
const customCodes: BBCode[] = [
|
||||
{
|
||||
id: 10,
|
||||
tag: 'custom',
|
||||
replacement: '<span>{content}</span>',
|
||||
example: '[custom]Hello[/custom]',
|
||||
description: 'Custom',
|
||||
enabled: true,
|
||||
parseInner: true,
|
||||
isBuiltin: false,
|
||||
specialHandler: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/bbcodes') {
|
||||
return Promise.resolve({ data: customCodes }) as never
|
||||
}
|
||||
return Promise.resolve({ data: [] }) as never
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ open: true, showCustom: true })
|
||||
await flushPromises()
|
||||
|
||||
const callCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
// Close and reopen - since customCodes.length > 0, should not refetch
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
const newCallCount = mockGet.mock.calls.filter((c) => c[0] === '/bbcodes').length
|
||||
expect(newCallCount).toBe(1) // Should still be 1
|
||||
})
|
||||
})
|
||||
|
||||
describe('close event', () => {
|
||||
it('emits update:open event when dialog closes', async () => {
|
||||
const wrapper = createWrapper({ open: true })
|
||||
|
||||
;(wrapper.vm as unknown as { handleClose: (v: boolean) => void }).handleClose(false)
|
||||
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in codes content', () => {
|
||||
it('contains bold tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Font style bold')
|
||||
})
|
||||
|
||||
it('contains italic tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Font style italic')
|
||||
})
|
||||
|
||||
it('contains code tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[code]')
|
||||
expect(wrapper.text()).toContain('Code')
|
||||
})
|
||||
|
||||
it('contains email tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[email]')
|
||||
expect(wrapper.text()).toContain('Email (clickable)')
|
||||
})
|
||||
|
||||
it('contains url tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[url=http://example.com]')
|
||||
expect(wrapper.text()).toContain('URL (clickable)')
|
||||
})
|
||||
|
||||
it('contains image tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[img]')
|
||||
expect(wrapper.text()).toContain('Image (not clickable)')
|
||||
})
|
||||
|
||||
it('contains quote tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[quote]')
|
||||
expect(wrapper.text()).toContain('Quote')
|
||||
})
|
||||
|
||||
it('contains youtube tag example', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[youtube]')
|
||||
expect(wrapper.text()).toContain('Embedded YouTube video')
|
||||
})
|
||||
|
||||
it('contains list tags examples', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[list]')
|
||||
expect(wrapper.text()).toContain('List')
|
||||
})
|
||||
|
||||
it('contains alignment tag examples', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('[left]')
|
||||
expect(wrapper.text()).toContain('[center]')
|
||||
expect(wrapper.text()).toContain('[right]')
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/BBCodeHelpDialog/index.ts
Normal file
2
src/components/BBCodeHelpDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
|
||||
export default BBCodeHelpDialog
|
||||
447
src/components/BBCodeToolbar/BBCodeToolbar.test.ts
Normal file
447
src/components/BBCodeToolbar/BBCodeToolbar.test.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createIconMock, createComponentMock } from '@/test-utils'
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@icons/FormatBold.vue', () => createIconMock('FormatBoldIcon'))
|
||||
vi.mock('@icons/FormatItalic.vue', () => createIconMock('FormatItalicIcon'))
|
||||
vi.mock('@icons/FormatStrikethrough.vue', () => createIconMock('FormatStrikethroughIcon'))
|
||||
vi.mock('@icons/FormatUnderline.vue', () => createIconMock('FormatUnderlineIcon'))
|
||||
vi.mock('@icons/CodeTags.vue', () => createIconMock('CodeTagsIcon'))
|
||||
vi.mock('@icons/Email.vue', () => createIconMock('EmailIcon'))
|
||||
vi.mock('@icons/Link.vue', () => createIconMock('LinkIcon'))
|
||||
vi.mock('@icons/Image.vue', () => createIconMock('ImageIcon'))
|
||||
vi.mock('@icons/FormatQuoteClose.vue', () => createIconMock('FormatQuoteCloseIcon'))
|
||||
vi.mock('@icons/Youtube.vue', () => createIconMock('YoutubeIcon'))
|
||||
vi.mock('@icons/FormatFont.vue', () => createIconMock('FormatFontIcon'))
|
||||
vi.mock('@icons/FormatSize.vue', () => createIconMock('FormatSizeIcon'))
|
||||
vi.mock('@icons/FormatColorFill.vue', () => createIconMock('FormatColorFillIcon'))
|
||||
vi.mock('@icons/FormatAlignLeft.vue', () => createIconMock('FormatAlignLeftIcon'))
|
||||
vi.mock('@icons/FormatAlignCenter.vue', () => createIconMock('FormatAlignCenterIcon'))
|
||||
vi.mock('@icons/FormatAlignRight.vue', () => createIconMock('FormatAlignRightIcon'))
|
||||
vi.mock('@icons/EyeOff.vue', () => createIconMock('EyeOffIcon'))
|
||||
vi.mock('@icons/FormatListBulleted.vue', () => createIconMock('FormatListBulletedIcon'))
|
||||
vi.mock('@icons/Paperclip.vue', () => createIconMock('PaperclipIcon'))
|
||||
vi.mock('@icons/Upload.vue', () => createIconMock('UploadIcon'))
|
||||
vi.mock('@icons/Emoticon.vue', () => createIconMock('EmoticonIcon'))
|
||||
vi.mock('@icons/HelpCircle.vue', () => createIconMock('HelpCircleIcon'))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/components/LazyEmojiPicker', () =>
|
||||
createComponentMock('LazyEmojiPicker', {
|
||||
template: '<div class="emoji-picker-mock"><slot /></div>',
|
||||
props: [],
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/components/BBCodeHelpDialog', () =>
|
||||
createComponentMock('BBCodeHelpDialog', {
|
||||
template: '<div class="bbcode-help-dialog-mock" v-if="open" />',
|
||||
props: ['open'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mock Nextcloud dialogs
|
||||
vi.mock('@nextcloud/dialogs', () => ({
|
||||
getFilePickerBuilder: vi.fn(() => ({
|
||||
setMultiSelect: vi.fn().mockReturnThis(),
|
||||
setType: vi.fn().mockReturnThis(),
|
||||
build: vi.fn(() => ({
|
||||
pick: vi.fn(),
|
||||
})),
|
||||
})),
|
||||
FilePickerType: { TYPE_FILE: 1 },
|
||||
}))
|
||||
|
||||
// Mock Nextcloud auth
|
||||
vi.mock('@nextcloud/auth', () => ({
|
||||
getCurrentUser: vi.fn(() => ({ uid: 'testuser', displayName: 'Test User' })),
|
||||
}))
|
||||
|
||||
// Mock axios
|
||||
vi.mock('@/axios', () => ({
|
||||
ocs: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
webDav: {
|
||||
put: vi.fn(),
|
||||
request: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock NcActions and NcActionButton since they're complex
|
||||
vi.mock('@nextcloud/vue/components/NcActions', () => ({
|
||||
default: {
|
||||
name: 'NcActions',
|
||||
template: '<div class="nc-actions-mock"><slot /><slot name="icon" /></div>',
|
||||
props: ['ariaLabel'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
|
||||
default: {
|
||||
name: 'NcActionButton',
|
||||
template:
|
||||
'<button class="nc-action-button-mock" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
props: [],
|
||||
emits: ['click'],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@nextcloud/vue/components/NcProgressBar', () => ({
|
||||
default: {
|
||||
name: 'NcProgressBar',
|
||||
template: '<div class="nc-progress-bar-mock" :data-value="value" />',
|
||||
props: ['value', 'size'],
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import BBCodeToolbar from './BBCodeToolbar.vue'
|
||||
|
||||
describe('BBCodeToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('prompt', vi.fn())
|
||||
})
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(BBCodeToolbar, {
|
||||
props: {
|
||||
textareaRef: null,
|
||||
modelValue: '',
|
||||
...props,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the toolbar', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-toolbar').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders BBCode formatting buttons', () => {
|
||||
const wrapper = createWrapper()
|
||||
const buttons = wrapper.findAll('.bbcode-button')
|
||||
// Should have multiple BBCode buttons (bold, italic, etc.) + emoji + help
|
||||
expect(buttons.length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('renders help button', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.bbcode-help-button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders emoji picker trigger', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.emoji-picker-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders attachment actions', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('.nc-actions-mock').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bbcodeButtons computed', () => {
|
||||
it('includes bold button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'b')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes italic button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'i')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes underline button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'u')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes strikethrough button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 's')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes code button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'code')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes quote button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'quote')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes url button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'url')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes img button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'img')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes youtube button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'youtube')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes list button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'list')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes color button with hasValue', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bbcodeButtons: Array<{ tag: string; hasValue?: boolean }>
|
||||
}
|
||||
const colorButton = vm.bbcodeButtons.find((b) => b.tag === 'color')
|
||||
expect(colorButton).toBeDefined()
|
||||
expect(colorButton!.hasValue).toBe(true)
|
||||
})
|
||||
|
||||
it('includes spoiler button', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { bbcodeButtons: Array<{ tag: string }> }
|
||||
expect(vm.bbcodeButtons.some((b) => b.tag === 'spoiler')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('help dialog', () => {
|
||||
it('opens help dialog when help button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('.bbcode-help-button').trigger('click')
|
||||
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('closes help dialog when showHelp is set to false', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { showHelp: boolean }
|
||||
|
||||
vm.showHelp = true
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(true)
|
||||
|
||||
vm.showHelp = false
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.bbcode-help-dialog-mock').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertBBCode', () => {
|
||||
it('does nothing when textareaRef is null', async () => {
|
||||
const wrapper = createWrapper({ textareaRef: null })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: { tag: string; template: string }) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]' })
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('emits insert event with new text for simple BBCode', async () => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello world'
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 5
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: { tag: string; template: string; label: string }) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({ tag: 'b', template: '[b]{text}[/b]', label: 'Bold' })
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
|
||||
expect(emitted.text).toBe('[b]Hello[/b] world')
|
||||
})
|
||||
|
||||
it('prompts for value when button has hasValue', async () => {
|
||||
const mockPrompt = vi.fn().mockReturnValue('red')
|
||||
vi.stubGlobal('prompt', mockPrompt)
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello'
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 5
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: {
|
||||
tag: string
|
||||
template: string
|
||||
label: string
|
||||
hasValue: boolean
|
||||
placeholder: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({
|
||||
tag: 'color',
|
||||
template: '[color={value}]{text}[/color]',
|
||||
label: 'Color',
|
||||
hasValue: true,
|
||||
placeholder: 'red',
|
||||
})
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does nothing when prompt is cancelled for hasValue button', async () => {
|
||||
const mockPrompt = vi.fn().mockReturnValue(null)
|
||||
vi.stubGlobal('prompt', mockPrompt)
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello'
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 5
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: {
|
||||
tag: string
|
||||
template: string
|
||||
label: string
|
||||
hasValue: boolean
|
||||
placeholder: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({
|
||||
tag: 'color',
|
||||
template: '[color={value}]{text}[/color]',
|
||||
label: 'Color',
|
||||
hasValue: true,
|
||||
placeholder: 'red',
|
||||
})
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('prompts for content when no selection and promptForContent is true', async () => {
|
||||
const mockPrompt = vi.fn().mockReturnValue('http://example.com/image.png')
|
||||
vi.stubGlobal('prompt', mockPrompt)
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = ''
|
||||
textarea.selectionStart = 0
|
||||
textarea.selectionEnd = 0
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
insertBBCode: (button: {
|
||||
tag: string
|
||||
template: string
|
||||
label: string
|
||||
promptForContent: boolean
|
||||
contentPlaceholder: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
await vm.insertBBCode({
|
||||
tag: 'img',
|
||||
template: '[img]{text}[/img]',
|
||||
label: 'Image',
|
||||
promptForContent: true,
|
||||
contentPlaceholder: 'http://example.com/image.png',
|
||||
})
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleEmojiSelect', () => {
|
||||
it('emits insert event with emoji', async () => {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = 'Hello '
|
||||
textarea.selectionStart = 6
|
||||
textarea.selectionEnd = 6
|
||||
|
||||
const wrapper = createWrapper({ textareaRef: textarea })
|
||||
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
|
||||
|
||||
vm.handleEmojiSelect('😀')
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeTruthy()
|
||||
const emitted = wrapper.emitted('insert')![0]![0] as { text: string; cursorPos: number }
|
||||
expect(emitted.text).toBe('Hello 😀')
|
||||
expect(emitted.cursorPos).toBe(8) // After emoji
|
||||
})
|
||||
|
||||
it('does nothing when textareaRef is null', () => {
|
||||
const wrapper = createWrapper({ textareaRef: null })
|
||||
const vm = wrapper.vm as unknown as { handleEmojiSelect: (emoji: string) => void }
|
||||
|
||||
vm.handleEmojiSelect('😀')
|
||||
|
||||
expect(wrapper.emitted('insert')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload dialog', () => {
|
||||
it('initializes with upload dialog closed', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { uploadDialog: boolean }
|
||||
expect(vm.uploadDialog).toBe(false)
|
||||
})
|
||||
|
||||
it('closeUploadDialog resets upload state', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
uploadDialog: boolean
|
||||
uploadProgress: number
|
||||
uploadFileName: string
|
||||
uploadError: string | null
|
||||
closeUploadDialog: () => void
|
||||
}
|
||||
|
||||
vm.uploadDialog = true
|
||||
vm.uploadProgress = 50
|
||||
vm.uploadFileName = 'test.pdf'
|
||||
vm.uploadError = 'Some error'
|
||||
|
||||
vm.closeUploadDialog()
|
||||
|
||||
expect(vm.uploadDialog).toBe(false)
|
||||
expect(vm.uploadProgress).toBe(0)
|
||||
expect(vm.uploadFileName).toBe('')
|
||||
expect(vm.uploadError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('strings', () => {
|
||||
it('has correct translation keys', () => {
|
||||
const wrapper = createWrapper()
|
||||
const vm = wrapper.vm as unknown as { strings: Record<string, string> }
|
||||
|
||||
expect(vm.strings.helpLabel).toBe('BBCode help')
|
||||
expect(vm.strings.emojiLabel).toBe('Insert emoji')
|
||||
expect(vm.strings.attachmentLabel).toBe('Attachment')
|
||||
expect(vm.strings.pickFileLabel).toBe('Pick file from Nextcloud')
|
||||
expect(vm.strings.uploadFileLabel).toBe('Upload file to Nextcloud')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,14 @@ import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
|
||||
import LazyEmojiPicker from '@/components/LazyEmojiPicker'
|
||||
import { getFilePickerBuilder, FilePickerType } from '@nextcloud/dialogs'
|
||||
import {
|
||||
applyBBCodeTemplate,
|
||||
insertTextAtSelection,
|
||||
getEditorState,
|
||||
setCursorPosition,
|
||||
editorStateToSelection,
|
||||
extractRelativePathFromFilePicker,
|
||||
} from '@/utils/bbcode'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import FormatBoldIcon from '@icons/FormatBold.vue'
|
||||
@@ -122,7 +130,7 @@ import PaperclipIcon from '@icons/Paperclip.vue'
|
||||
import UploadIcon from '@icons/Upload.vue'
|
||||
import EmoticonIcon from '@icons/Emoticon.vue'
|
||||
import HelpCircleIcon from '@icons/HelpCircle.vue'
|
||||
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
|
||||
import BBCodeHelpDialog from '@/components/BBCodeHelpDialog'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { webDav, ocs } from '@/axios'
|
||||
|
||||
@@ -158,6 +166,10 @@ export default defineComponent({
|
||||
type: Object as PropType<HTMLTextAreaElement | HTMLElement | null>,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['insert'],
|
||||
data() {
|
||||
@@ -314,110 +326,6 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Check if the element is a textarea
|
||||
*/
|
||||
isTextarea(el: HTMLElement | HTMLTextAreaElement): el is HTMLTextAreaElement {
|
||||
return el.tagName === 'TEXTAREA'
|
||||
},
|
||||
|
||||
/**
|
||||
* Get text content and selection info from the editor element
|
||||
*/
|
||||
getEditorState(): { value: string; start: number; end: number; selectedText: string } | null {
|
||||
if (!this.textareaRef) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.isTextarea(this.textareaRef)) {
|
||||
const textarea = this.textareaRef
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
return {
|
||||
value: textarea.value,
|
||||
start,
|
||||
end,
|
||||
selectedText: textarea.value.substring(start, end),
|
||||
}
|
||||
} else {
|
||||
// Contenteditable element
|
||||
const el = this.textareaRef
|
||||
const text = el.innerText || ''
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return { value: text, start: text.length, end: text.length, selectedText: '' }
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
// Check if selection is within this element
|
||||
if (!el.contains(range.commonAncestorContainer)) {
|
||||
return { value: text, start: text.length, end: text.length, selectedText: '' }
|
||||
}
|
||||
|
||||
// Calculate start and end positions in the text
|
||||
const preCaretRange = range.cloneRange()
|
||||
preCaretRange.selectNodeContents(el)
|
||||
preCaretRange.setEnd(range.startContainer, range.startOffset)
|
||||
const start = preCaretRange.toString().length
|
||||
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset)
|
||||
const end = preCaretRange.toString().length
|
||||
|
||||
return {
|
||||
value: text,
|
||||
start,
|
||||
end,
|
||||
selectedText: range.toString(),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set cursor position in the editor element
|
||||
*/
|
||||
setCursorPosition(position: number): void {
|
||||
if (!this.textareaRef) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isTextarea(this.textareaRef)) {
|
||||
this.textareaRef.setSelectionRange(position, position)
|
||||
} else {
|
||||
// For contenteditable, we need to find the text node and set cursor
|
||||
const el = this.textareaRef
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
// Find the text node at the position
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null)
|
||||
let currentPos = 0
|
||||
let node: Node | null = walker.nextNode()
|
||||
|
||||
while (node) {
|
||||
const nodeLength = (node.textContent || '').length
|
||||
if (currentPos + nodeLength >= position) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, position - currentPos)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
return
|
||||
}
|
||||
currentPos += nodeLength
|
||||
node = walker.nextNode()
|
||||
}
|
||||
|
||||
// If we couldn't find the position, put cursor at end
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
},
|
||||
|
||||
async insertBBCode(button: BBCodeButton): Promise<void> {
|
||||
// If button has a custom handler, use it instead
|
||||
if (button.handler) {
|
||||
@@ -425,16 +333,13 @@ export default defineComponent({
|
||||
return
|
||||
}
|
||||
|
||||
const state = this.getEditorState()
|
||||
const state = getEditorState(this.textareaRef, this.modelValue)
|
||||
if (!state || !this.textareaRef) {
|
||||
return
|
||||
}
|
||||
|
||||
const { value, start, end, selectedText } = state
|
||||
const beforeText = value.substring(0, start)
|
||||
const afterText = value.substring(end)
|
||||
const { selectedText } = state
|
||||
|
||||
let insertText = ''
|
||||
let promptValue = ''
|
||||
let contentText = selectedText
|
||||
|
||||
@@ -457,28 +362,28 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the BBCode text
|
||||
insertText = button.template
|
||||
.replace('{value}', promptValue)
|
||||
.replace('{text}', contentText || button.placeholder || '')
|
||||
|
||||
// Calculate new cursor position
|
||||
const newText = beforeText + insertText + afterText
|
||||
const cursorPos = beforeText.length + insertText.length
|
||||
// Use the bbcode utility to apply the template
|
||||
const result = applyBBCodeTemplate(editorStateToSelection(state), {
|
||||
template: button.template,
|
||||
value: promptValue,
|
||||
fallbackText: contentText || button.placeholder || '',
|
||||
})
|
||||
|
||||
// Emit the insert event so the parent can update the model
|
||||
this.$emit('insert', {
|
||||
text: newText,
|
||||
cursorPos,
|
||||
text: result.text,
|
||||
cursorPos: result.cursorPosition,
|
||||
selectedText,
|
||||
})
|
||||
|
||||
// Focus and set cursor position after insertion
|
||||
// Use $nextTick + requestAnimationFrame to ensure DOM has fully updated
|
||||
const editorRef = this.textareaRef
|
||||
this.$nextTick(() => {
|
||||
if (this.textareaRef) {
|
||||
this.textareaRef.focus()
|
||||
this.setCursorPosition(cursorPos)
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, result.cursorPosition)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -499,46 +404,31 @@ export default defineComponent({
|
||||
return
|
||||
}
|
||||
|
||||
// Extract relative path from the full path
|
||||
// File picker returns: /username/files/path/to/file.pdf
|
||||
// We need: path/to/file.pdf (relative to user's files directory)
|
||||
let relativePath = path
|
||||
const fileId = extractRelativePathFromFilePicker(path)
|
||||
|
||||
// Remove the leading /username/files/ part
|
||||
const pathParts = path.split('/')
|
||||
if (pathParts.length >= 3 && pathParts[2] === 'files') {
|
||||
// Remove first 3 parts: ['', 'username', 'files']
|
||||
relativePath = pathParts.slice(3).join('/')
|
||||
}
|
||||
|
||||
const fileId = relativePath
|
||||
|
||||
const state = this.getEditorState()
|
||||
const state = getEditorState(this.textareaRef, this.modelValue)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
|
||||
const { value, start, end } = state
|
||||
const beforeText = value.substring(0, start)
|
||||
const afterText = value.substring(end)
|
||||
|
||||
const insertText = `[attachment]${fileId}[/attachment]`
|
||||
const newText = beforeText + insertText + afterText
|
||||
const cursorPos = beforeText.length + insertText.length
|
||||
// Use the bbcode utility to insert the attachment tag
|
||||
const result = insertTextAtSelection(
|
||||
editorStateToSelection(state),
|
||||
`[attachment]${fileId}[/attachment]`,
|
||||
)
|
||||
|
||||
// Emit the insert event so the parent can update the model
|
||||
this.$emit('insert', {
|
||||
text: newText,
|
||||
cursorPos,
|
||||
text: result.text,
|
||||
cursorPos: result.cursorPosition,
|
||||
selectedText: '',
|
||||
})
|
||||
|
||||
// Focus the editor after insertion
|
||||
const editorRef = this.textareaRef
|
||||
this.$nextTick(() => {
|
||||
if (this.textareaRef) {
|
||||
this.textareaRef.focus()
|
||||
this.setCursorPosition(cursorPos)
|
||||
}
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, result.cursorPosition)
|
||||
})
|
||||
} catch (error) {
|
||||
// Silently ignore if user canceled the dialog
|
||||
@@ -555,31 +445,26 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
handleEmojiSelect(emoji: string): void {
|
||||
const state = this.getEditorState()
|
||||
const state = getEditorState(this.textareaRef, this.modelValue)
|
||||
if (!state || !this.textareaRef) {
|
||||
return
|
||||
}
|
||||
|
||||
const { value, start, end } = state
|
||||
const beforeText = value.substring(0, start)
|
||||
const afterText = value.substring(end)
|
||||
|
||||
const newText = beforeText + emoji + afterText
|
||||
const cursorPos = beforeText.length + emoji.length
|
||||
// Use the bbcode utility to insert the emoji
|
||||
const result = insertTextAtSelection(editorStateToSelection(state), emoji)
|
||||
|
||||
// Emit the insert event so the parent can update the model
|
||||
this.$emit('insert', {
|
||||
text: newText,
|
||||
cursorPos,
|
||||
text: result.text,
|
||||
cursorPos: result.cursorPosition,
|
||||
selectedText: '',
|
||||
})
|
||||
|
||||
// Focus the editor after insertion
|
||||
const editorRef = this.textareaRef
|
||||
this.$nextTick(() => {
|
||||
if (this.textareaRef) {
|
||||
this.textareaRef.focus()
|
||||
this.setCursorPosition(cursorPos)
|
||||
}
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, result.cursorPosition)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -651,34 +536,33 @@ export default defineComponent({
|
||||
})
|
||||
|
||||
// Insert attachment BBCode
|
||||
const state = this.getEditorState()
|
||||
const state = getEditorState(this.textareaRef, this.modelValue)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
|
||||
const { value, start, end } = state
|
||||
const beforeText = value.substring(0, start)
|
||||
const afterText = value.substring(end)
|
||||
|
||||
// Use the bbcode utility to insert the attachment tag
|
||||
const filePath = `${uploadDirectory}/${file.name}`
|
||||
const insertText = `[attachment]${filePath}[/attachment]`
|
||||
const newText = beforeText + insertText + afterText
|
||||
const cursorPos = beforeText.length + insertText.length
|
||||
const result = insertTextAtSelection(
|
||||
editorStateToSelection(state),
|
||||
`[attachment]${filePath}[/attachment]`,
|
||||
)
|
||||
|
||||
// Emit the insert event
|
||||
this.$emit('insert', {
|
||||
text: newText,
|
||||
cursorPos,
|
||||
text: result.text,
|
||||
cursorPos: result.cursorPosition,
|
||||
selectedText: '',
|
||||
})
|
||||
|
||||
// Focus the editor after insertion
|
||||
this.$nextTick(() => {
|
||||
if (this.textareaRef) {
|
||||
this.textareaRef.focus()
|
||||
this.setCursorPosition(cursorPos)
|
||||
}
|
||||
})
|
||||
const editorRef = this.textareaRef
|
||||
if (editorRef) {
|
||||
this.$nextTick(() => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, result.cursorPosition)
|
||||
})
|
||||
}
|
||||
|
||||
// Close dialog on success
|
||||
this.uploadDialog = false
|
||||
2
src/components/BBCodeToolbar/index.ts
Normal file
2
src/components/BBCodeToolbar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import BBCodeToolbar from './BBCodeToolbar.vue'
|
||||
export default BBCodeToolbar
|
||||
97
src/components/CategoryCard/CategoryCard.test.ts
Normal file
97
src/components/CategoryCard/CategoryCard.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CategoryCard from './CategoryCard.vue'
|
||||
import { createMockCategory } from '@/test-mocks'
|
||||
|
||||
// Uses global mock for @nextcloud/l10n from test-setup.ts
|
||||
|
||||
describe('CategoryCard', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render category name', () => {
|
||||
const category = createMockCategory({ name: 'General Discussion' })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
expect(wrapper.find('.category-name').text()).toBe('General Discussion')
|
||||
})
|
||||
|
||||
it('should render category description', () => {
|
||||
const category = createMockCategory({ description: 'Talk about anything' })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
expect(wrapper.find('.category-description').text()).toBe('Talk about anything')
|
||||
})
|
||||
|
||||
it('should render placeholder when no description', () => {
|
||||
const category = createMockCategory({ description: null })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
expect(wrapper.find('.category-description').text()).toBe('No description available')
|
||||
expect(wrapper.find('.category-description').classes()).toContain('muted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stats', () => {
|
||||
it('should display thread count', () => {
|
||||
const category = createMockCategory({ threadCount: 25 })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[0]!.text()).toBe('25')
|
||||
})
|
||||
|
||||
it('should display post count', () => {
|
||||
const category = createMockCategory({ postCount: 150 })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[1]!.text()).toBe('150')
|
||||
})
|
||||
|
||||
it('should handle zero counts', () => {
|
||||
const category = createMockCategory({ threadCount: 0, postCount: 0 })
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[0]!.text()).toBe('0')
|
||||
expect(stats[1]!.text()).toBe('0')
|
||||
})
|
||||
|
||||
it('should handle undefined counts as zero', () => {
|
||||
const category = createMockCategory()
|
||||
// @ts-expect-error Testing undefined case
|
||||
category.threadCount = undefined
|
||||
// @ts-expect-error Testing undefined case
|
||||
category.postCount = undefined
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category },
|
||||
})
|
||||
const stats = wrapper.findAll('.stat-value')
|
||||
expect(stats[0]!.text()).toBe('0')
|
||||
expect(stats[1]!.text()).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('structure', () => {
|
||||
it('should have correct class', () => {
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category: createMockCategory() },
|
||||
})
|
||||
expect(wrapper.find('.category-card').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should have header with name and stats', () => {
|
||||
const wrapper = mount(CategoryCard, {
|
||||
props: { category: createMockCategory() },
|
||||
})
|
||||
expect(wrapper.find('.category-header').exists()).toBe(true)
|
||||
expect(wrapper.find('.category-name').exists()).toBe(true)
|
||||
expect(wrapper.find('.category-stats').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
2
src/components/CategoryCard/index.ts
Normal file
2
src/components/CategoryCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import CategoryCard from './CategoryCard.vue'
|
||||
export default CategoryCard
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user