mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be51e8a1a5 | |||
| 53875b1eef | |||
| 0f9d5ea9a5 | |||
| 4708d8cf87 | |||
| 20a15b42d9 | |||
| 7a1853935e | |||
| 04ec7ffcf8 | |||
| c9a76e5cd9 | |||
| 94787052ef | |||
| e20bfdadab | |||
| 328b37be6e | |||
| c7f84d4a18 | |||
| d09987600b | |||
| dcdcde31ed | |||
| f66169288e | |||
| 7a17dbc524 | |||
| c1443014b5 | |||
| 4c2e47d86b | |||
| 8408402148 | |||
| 3d113f1f31 | |||
| 48b7679e3b | |||
| 5f0317b153 | |||
| 56dc0049b8 | |||
| 7519088e2b | |||
| 0f3be447fa | |||
| f73d902962 | |||
| 4a9ae9bfc6 | |||
| 37012590a1 | |||
| 00b80b817d | |||
| 3472e95065 | |||
| 7fde88a158 | |||
| 5ebeb56636 | |||
| a66bcd4612 | |||
| 36d8ecd5bb | |||
| 257a12dfc4 | |||
| b67813fa34 | |||
| d6c6626bad | |||
| 9837fc4683 | |||
| a3b0582d2c |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Nextcloud Community Forum
|
||||
url: https://help.nextcloud.com
|
||||
|
||||
201
.github/workflows/phpunit-mysql.yml
vendored
Normal file
201
.github/workflows/phpunit-mysql.yml
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
# 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
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: PHPUnit MySQL
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: phpunit-mysql-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get supported server versions
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Build test matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
# Get server branches from version matrix
|
||||
BRANCHES='${{ steps.versions.outputs.branches }}'
|
||||
|
||||
# Build minimal matrix: min Nextcloud with PHP 8.2, max Nextcloud with PHP 8.3
|
||||
MATRIX=$(jq -nc \
|
||||
--argjson branches "$BRANCHES" \
|
||||
'{include: [{"php-versions": "8.2", "mysql-versions": "8.4", "server-versions": $branches[0]}, {"php-versions": "8.3", "mysql-versions": "8.4", "server-versions": $branches[-1]}]}'
|
||||
)
|
||||
|
||||
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
|
||||
echo "Generated matrix: $MATRIX"
|
||||
|
||||
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/**'
|
||||
- 'templates/**'
|
||||
- 'tests/**'
|
||||
- 'vendor/**'
|
||||
- 'vendor-bin/**'
|
||||
- '.php-cs-fixer.dist.php'
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
|
||||
phpunit-mysql:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: [changes, matrix]
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||
|
||||
name: MySQL ${{ matrix.mysql-versions }} PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }}
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: ghcr.io/nextcloud/continuous-integration-mysql-${{ matrix.mysql-versions }}:latest
|
||||
ports:
|
||||
- 4444:3306/tcp
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: rootpassword
|
||||
options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10
|
||||
|
||||
steps:
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: app-checkout
|
||||
|
||||
- name: Detect app ID from appinfo/info.xml
|
||||
run: |
|
||||
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-checkout/appinfo/info.xml | head -1)
|
||||
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
|
||||
echo "Detected app ID: $APP_ID"
|
||||
|
||||
- name: Checkout server
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
repository: nextcloud/server
|
||||
ref: ${{ matrix.server-versions }}
|
||||
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: apps/${{ env.APP_NAME }}
|
||||
|
||||
- name: Set up php ${{ matrix.php-versions }}
|
||||
uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
|
||||
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
|
||||
# Temporary workaround for missing pcntl_* in PHP 8.3
|
||||
ini-values: disable_functions=
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Enable ONLY_FULL_GROUP_BY MySQL option
|
||||
run: |
|
||||
echo "SET GLOBAL sql_mode=(SELECT CONCAT(@@sql_mode,',ONLY_FULL_GROUP_BY'));" | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword
|
||||
echo 'SELECT @@sql_mode;' | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword
|
||||
|
||||
- name: Check composer file existence
|
||||
id: check_composer
|
||||
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
|
||||
with:
|
||||
files: apps/${{ env.APP_NAME }}/composer.json
|
||||
|
||||
- name: Set up dependencies
|
||||
# Only run if phpunit config file exists
|
||||
if: steps.check_composer.outputs.files_exists == 'true'
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev --no-scripts
|
||||
composer i
|
||||
|
||||
- name: Set up Nextcloud
|
||||
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
|
||||
./occ app:enable --force ${{ env.APP_NAME }}
|
||||
|
||||
- name: Check PHPUnit script is defined
|
||||
id: check_phpunit
|
||||
continue-on-error: true
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer run --list | grep '^ test:unit ' | wc -l | grep 1
|
||||
|
||||
- name: PHPUnit
|
||||
# Only run if phpunit config file exists
|
||||
if: steps.check_phpunit.outcome == 'success'
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: composer run test:unit
|
||||
|
||||
- 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'
|
||||
run: |
|
||||
echo 'PHPUnit tests are not specified in composer.json scripts'
|
||||
exit 1
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, phpunit-mysql]
|
||||
|
||||
if: always()
|
||||
|
||||
name: phpunit-mysql-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-mysql.result != 'success' }}; then exit 1; fi
|
||||
216
.github/workflows/phpunit-pgsql.yml
vendored
Normal file
216
.github/workflows/phpunit-pgsql.yml
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
# 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
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: PHPUnit PostgreSQL
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: phpunit-pgsql-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get supported server versions
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Build test matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
# Get server branches from version matrix
|
||||
BRANCHES='${{ steps.versions.outputs.branches }}'
|
||||
|
||||
# Build minimal matrix: only latest Nextcloud with PHP 8.3
|
||||
MATRIX=$(jq -nc \
|
||||
--argjson branches "$BRANCHES" \
|
||||
'{include: [{"php-versions": "8.3", "server-versions": $branches[-1]}]}'
|
||||
)
|
||||
|
||||
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
|
||||
echo "Generated matrix: $MATRIX"
|
||||
|
||||
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/**'
|
||||
- 'templates/**'
|
||||
- 'tests/**'
|
||||
- 'vendor/**'
|
||||
- 'vendor-bin/**'
|
||||
- '.php-cs-fixer.dist.php'
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
|
||||
phpunit-pgsql:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: [changes, matrix]
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||
|
||||
name: PostgreSQL PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }}
|
||||
|
||||
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
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: app-checkout
|
||||
|
||||
- name: Detect app ID from appinfo/info.xml
|
||||
run: |
|
||||
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-checkout/appinfo/info.xml | head -1)
|
||||
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
|
||||
echo "Detected app ID: $APP_ID"
|
||||
|
||||
- name: Checkout server
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: true
|
||||
repository: nextcloud/server
|
||||
ref: ${{ matrix.server-versions }}
|
||||
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: apps/${{ env.APP_NAME }}
|
||||
|
||||
- name: Set up php ${{ matrix.php-versions }}
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
|
||||
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
|
||||
# Temporary workaround for missing pcntl_* in PHP 8.3
|
||||
ini-values: disable_functions=
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check composer file existence
|
||||
id: check_composer
|
||||
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
|
||||
with:
|
||||
files: apps/${{ env.APP_NAME }}/composer.json
|
||||
|
||||
- name: Set up dependencies
|
||||
# Only run if phpunit config file exists
|
||||
if: steps.check_composer.outputs.files_exists == 'true'
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev --no-scripts
|
||||
composer i
|
||||
|
||||
- name: Set up Nextcloud
|
||||
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
|
||||
./occ app:enable --force ${{ env.APP_NAME }}
|
||||
|
||||
- name: Check PHPUnit script is defined
|
||||
id: check_phpunit
|
||||
continue-on-error: true
|
||||
working-directory: apps/${{ env.APP_NAME }}
|
||||
run: |
|
||||
composer run --list | grep '^ test:unit ' | wc -l | grep 1
|
||||
|
||||
- name: PHPUnit
|
||||
# Only run if phpunit config file exists
|
||||
if: steps.check_phpunit.outcome == 'success'
|
||||
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 neither unit nor integration tests ran
|
||||
if: steps.check_phpunit.outcome == 'failure' && steps.check_integration.outcome == 'failure'
|
||||
run: |
|
||||
echo 'Neither PHPUnit nor PHPUnit integration tests are specified in composer.json scripts'
|
||||
exit 1
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, phpunit-pgsql]
|
||||
|
||||
if: always()
|
||||
|
||||
name: phpunit-pgsql-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-pgsql.result != 'success' }}; then exit 1; fi
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,7 +12,6 @@
|
||||
/js
|
||||
/css
|
||||
.DS_Store
|
||||
composer.lock
|
||||
build/
|
||||
tsconfig.app.tsbuildinfo
|
||||
.env
|
||||
|
||||
@@ -2,9 +2,16 @@ module.exports = {
|
||||
'*.{ts,vue}': ['eslint --fix'],
|
||||
'*.{scss,vue,ts,md}': ['prettier --write'],
|
||||
'*.json': (files) => {
|
||||
const filtered = files.filter(file => !file.includes('openapi.json'));
|
||||
return filtered.length > 0 ? `prettier --write ${filtered.join(' ')}` : [];
|
||||
const filtered = files.filter(file => !file.includes('openapi.json'))
|
||||
return filtered.length > 0 ? `prettier --write ${filtered.join(' ')}` : []
|
||||
},
|
||||
'*.php': (files) => {
|
||||
const nonGenFiles = files.filter(file => !file.includes('/gen/'))
|
||||
const commands = []
|
||||
if (nonGenFiles.length > 0) {
|
||||
commands.push('make php-cs-fixer', 'make test')
|
||||
}
|
||||
return commands
|
||||
},
|
||||
'*.php': [() => 'make php-cs-fixer'],
|
||||
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json'],
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.9.0"}
|
||||
{".":"0.10.0"}
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [0.10.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.9.2...v0.10.0) (2025-11-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Roles:** admin always has full permissions ([c9a76e5](https://github.com/chenasraf/nextcloud-forum/commit/c9a76e5cd97df0c82bb79825799eeba7ce66086e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **AdminDashboard:** exclude thread posts from post count ([53875b1](https://github.com/chenasraf/nextcloud-forum/commit/53875b1eefaddcbe8c4024c98834e44c3a01aeb0))
|
||||
* modal actions spacing ([0f9d5ea](https://github.com/chenasraf/nextcloud-forum/commit/0f9d5ea9a5a99dda9bb351ed98d91ae880ddf64f))
|
||||
* **Roles:** prevent deleting system roles on backend ([328b37b](https://github.com/chenasraf/nextcloud-forum/commit/328b37be6eec2b0001517ef74779565457de7213))
|
||||
* **UserEventListener:** add User role to newly created users ([c7f84d4](https://github.com/chenasraf/nextcloud-forum/commit/c7f84d4a186ab7832d5fa96c2143bef30ddf3a85))
|
||||
|
||||
## [0.9.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.9.1...v0.9.2) (2025-11-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **l10n:** bbcode help dialog strings ([d6c6626](https://github.com/chenasraf/nextcloud-forum/commit/d6c6626badf90636aae4f59fbe4765a5f786f5ab))
|
||||
* **l10n:** plural tokens + text alignment strings ([8408402](https://github.com/chenasraf/nextcloud-forum/commit/8408402148c9935b50df1f75915cffba7d76b043))
|
||||
* **l10n:** update translation source strings ([3d113f1](https://github.com/chenasraf/nextcloud-forum/commit/3d113f1f31887b7849753d07712a548392f574d6))
|
||||
* post counts in threads/categories ([257a12d](https://github.com/chenasraf/nextcloud-forum/commit/257a12dfc43d7784d7e63ab79a8545d80022251e))
|
||||
* **PostController:** exclude first posts from post_count fields ([a66bcd4](https://github.com/chenasraf/nextcloud-forum/commit/a66bcd4612a1889624e608e9e0207d7e09fc32df))
|
||||
* **SeedHelper:** subscribe author to welcome thread ([b67813f](https://github.com/chenasraf/nextcloud-forum/commit/b67813fa34746de8f999b16e609c6ee7a9458e9b))
|
||||
|
||||
## [0.9.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.9.0...v0.9.1) (2025-11-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **l10n:** fix welcome post & bbcode example strings ([a3b0582](https://github.com/chenasraf/nextcloud-forum/commit/a3b0582d2cce2c22cd5070f3c335a732c25c5e67))
|
||||
|
||||
## [0.9.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.8.2...v0.9.0) (2025-11-21)
|
||||
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -230,7 +230,7 @@ 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.local.xml; \
|
||||
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
|
||||
@@ -265,7 +265,7 @@ test-docker:
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "\x1b[33mRunning tests in container $$CONTAINER_ID for app $$APP_DIR\x1b[0m"; \
|
||||
docker exec $$CONTAINER_ID phpunit -c apps-shared/$$APP_DIR/tests/phpunit.xml
|
||||
docker exec $$CONTAINER_ID phpunit -c apps-shared/$$APP_DIR/tests/phpunit.docker.xml
|
||||
|
||||
# lint:
|
||||
# - Lint JS via pnpm and PHP via composer script "lint"
|
||||
|
||||
@@ -5,7 +5,9 @@ SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# Nextcloud Forum
|
||||
|
||||

|
||||
[](https://github.com/chenasraf/nextcloud-forum/releases/latest)
|
||||
[](https://github.com/chenasraf/nextcloud-forum/actions/workflows/phpunit-mysql.yml)
|
||||
[](https://github.com/chenasraf/nextcloud-forum/actions/workflows/phpunit-pgsql.yml)
|
||||
|
||||
A full-featured forum application for Nextcloud, allowing users to create discussion categories,
|
||||
threads, and posts within their Nextcloud instance.
|
||||
|
||||
@@ -13,7 +13,7 @@ Create discussions, share ideas, and collaborate with your community directly in
|
||||
**⚠️ Early Development Notice:**
|
||||
This 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.
|
||||
|
||||
**Key Features:**
|
||||
**Key features:**
|
||||
- **Thread-based Discussions** - Create and reply to organized discussion threads
|
||||
- **Category Organization** - Structure your forum with customizable categories and headers
|
||||
- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more
|
||||
@@ -26,7 +26,7 @@ This app is in early stages of development. While functional, you may encounter
|
||||
- **Admin Tools** - Manage categories, roles, BBCodes, and forum settings
|
||||
- **Moderation Tools** - Pin, lock, and manage threads and posts
|
||||
|
||||
**Perfect For:**
|
||||
**Perfect for:**
|
||||
- Team discussions and collaboration
|
||||
- Community forums
|
||||
- Support channels
|
||||
@@ -36,7 +36,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.9.0</version>
|
||||
<version>0.10.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
|
||||
3039
composer.lock
generated
Normal file
3039
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,7 @@ declare(strict_types=1);
|
||||
namespace OCA\Forum\Command;
|
||||
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Db\UserRole;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||
use OCP\IUserManager;
|
||||
@@ -21,7 +20,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
class SetRole extends Command {
|
||||
public function __construct(
|
||||
private RoleMapper $roleMapper,
|
||||
private UserRoleMapper $userRoleMapper,
|
||||
private UserRoleService $userRoleService,
|
||||
private IUserManager $userManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -70,22 +69,19 @@ class SetRole extends Command {
|
||||
}
|
||||
|
||||
// Check if user already has this role
|
||||
$userRoles = $this->userRoleMapper->findByUserId($username);
|
||||
foreach ($userRoles as $userRole) {
|
||||
if ($userRole->getRoleId() === $role->getId()) {
|
||||
$output->writeln("<comment>User '$username' already has the role '{$role->getName()}'.</comment>");
|
||||
return 0;
|
||||
}
|
||||
if ($this->userRoleService->hasRole($username, $role->getId())) {
|
||||
$output->writeln("<comment>User '$username' already has the role '{$role->getName()}'.</comment>");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Add the role to the user
|
||||
$userRole = new UserRole();
|
||||
$userRole->setUserId($username);
|
||||
$userRole->setRoleId($role->getId());
|
||||
$userRole->setCreatedAt(time());
|
||||
$this->userRoleMapper->insert($userRole);
|
||||
|
||||
$output->writeln("<info>Successfully assigned role '{$role->getName()}' to user '$username'.</info>");
|
||||
return 0;
|
||||
// Add the role to the user using the service
|
||||
try {
|
||||
$this->userRoleService->assignRole($username, $role->getId(), skipIfExists: false);
|
||||
$output->writeln("<info>Successfully assigned role '{$role->getName()}' to user '$username'.</info>");
|
||||
return 0;
|
||||
} catch (\Exception $ex) {
|
||||
$output->writeln("<error>Failed to assign role '{$role->getName()}' to user '$username': {$ex->getMessage()}</error>");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use OCA\Forum\Db\CategoryPermMapper;
|
||||
use OCA\Forum\Db\CatHeaderMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -384,7 +385,8 @@ class CategoryController extends OCSController {
|
||||
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}/permissions')]
|
||||
public function getPermissions(int $id): DataResponse {
|
||||
try {
|
||||
$permissions = $this->categoryPermMapper->findByCategoryId($id);
|
||||
// Exclude Admin role - it has hardcoded full access to all categories
|
||||
$permissions = $this->categoryPermMapper->findByCategoryIdExcludingAdmin($id);
|
||||
return new DataResponse(array_map(fn ($perm) => $perm->jsonSerialize(), $permissions));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching category permissions: ' . $e->getMessage());
|
||||
@@ -412,8 +414,13 @@ class CategoryController extends OCSController {
|
||||
// Delete existing permissions for this category
|
||||
$this->categoryPermMapper->deleteByCategoryId($id);
|
||||
|
||||
// Filter out Admin role - it has hardcoded full access
|
||||
$filteredPermissions = array_filter($permissions, fn ($perm)
|
||||
=> ($perm['roleId'] ?? null) !== UserRoleService::ROLE_ADMIN
|
||||
);
|
||||
|
||||
// Insert new permissions
|
||||
foreach ($permissions as $perm) {
|
||||
foreach ($filteredPermissions as $perm) {
|
||||
$categoryPerm = new CategoryPerm();
|
||||
$categoryPerm->setCategoryId($id);
|
||||
$categoryPerm->setRoleId($perm['roleId']);
|
||||
|
||||
@@ -383,7 +383,10 @@ class PostController extends OCSController {
|
||||
// Update thread post count and lastPostId
|
||||
try {
|
||||
$thread = $this->threadMapper->find($post->getThreadId());
|
||||
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
|
||||
// Only decrement post count for reply posts (not first posts)
|
||||
if (!$post->getIsFirstPost()) {
|
||||
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
|
||||
}
|
||||
$thread->setUpdatedAt(time());
|
||||
|
||||
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
|
||||
@@ -406,22 +409,25 @@ class PostController extends OCSController {
|
||||
|
||||
// Update user stats - decrement post count, and thread count if it's the first post
|
||||
try {
|
||||
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
|
||||
|
||||
// If this is the first post of a thread, also decrement thread count
|
||||
if ($post->getIsFirstPost()) {
|
||||
// First post: decrement thread count only
|
||||
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
|
||||
} else {
|
||||
// Reply post: decrement post count only
|
||||
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if stats update fails
|
||||
}
|
||||
|
||||
// Update category post count
|
||||
// Update category post count (only for reply posts, not first posts)
|
||||
try {
|
||||
$category = $this->categoryMapper->find($categoryId);
|
||||
$category->setPostCount(max(0, $category->getPostCount() - 1));
|
||||
$this->categoryMapper->update($category);
|
||||
if (!$post->getIsFirstPost()) {
|
||||
$category = $this->categoryMapper->find($categoryId);
|
||||
$category->setPostCount(max(0, $category->getPostCount() - 1));
|
||||
$this->categoryMapper->update($category);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if category update fails
|
||||
|
||||
@@ -11,6 +11,7 @@ use OCA\Forum\Attribute\RequirePermission;
|
||||
use OCA\Forum\Db\CategoryPerm;
|
||||
use OCA\Forum\Db\CategoryPermMapper;
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -163,14 +164,22 @@ class RoleController extends OCSController {
|
||||
if ($colorDark !== null) {
|
||||
$role->setColorDark($colorDark);
|
||||
}
|
||||
if ($canAccessAdminTools !== null) {
|
||||
$role->setCanAccessAdminTools($canAccessAdminTools);
|
||||
}
|
||||
if ($canEditRoles !== null) {
|
||||
$role->setCanEditRoles($canEditRoles);
|
||||
}
|
||||
if ($canEditCategories !== null) {
|
||||
$role->setCanEditCategories($canEditCategories);
|
||||
|
||||
// Admin role always has all permissions - cannot be changed
|
||||
if ($id === UserRoleService::ROLE_ADMIN) {
|
||||
$role->setCanAccessAdminTools(true);
|
||||
$role->setCanEditRoles(true);
|
||||
$role->setCanEditCategories(true);
|
||||
} else {
|
||||
if ($canAccessAdminTools !== null) {
|
||||
$role->setCanAccessAdminTools($canAccessAdminTools);
|
||||
}
|
||||
if ($canEditRoles !== null) {
|
||||
$role->setCanEditRoles($canEditRoles);
|
||||
}
|
||||
if ($canEditCategories !== null) {
|
||||
$role->setCanEditCategories($canEditCategories);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var \OCA\Forum\Db\Role */
|
||||
@@ -188,17 +197,22 @@ class RoleController extends OCSController {
|
||||
* Delete a role
|
||||
*
|
||||
* @param int $id Role ID
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
|
||||
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Role deleted
|
||||
* 403: Cannot delete system roles
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canEditRoles')]
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/roles/{id}')]
|
||||
public function destroy(int $id): DataResponse {
|
||||
try {
|
||||
// Prevent deleting system roles (Admin, Moderator, Member)
|
||||
if ($id == 3) {
|
||||
// Prevent deleting system roles (Admin, Moderator, User)
|
||||
if (in_array($id, [
|
||||
UserRoleService::ROLE_ADMIN,
|
||||
UserRoleService::ROLE_MODERATOR,
|
||||
UserRoleService::ROLE_USER,
|
||||
], true)) {
|
||||
return new DataResponse(['error' => 'System roles cannot be deleted'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
|
||||
@@ -257,23 +257,22 @@ class ThreadController extends OCSController {
|
||||
$createdPost = $this->postMapper->insert($post);
|
||||
|
||||
// Update thread with post count and last post
|
||||
$createdThread->setPostCount(1);
|
||||
// Note: post_count does NOT include the first post (is_first_post=true)
|
||||
$createdThread->setPostCount(0);
|
||||
$createdThread->setLastPostId($createdPost->getId());
|
||||
$this->threadMapper->update($createdThread);
|
||||
|
||||
// Update category counts (thread count and post count)
|
||||
// Update category counts (thread count only, first post doesn't count)
|
||||
try {
|
||||
$category = $this->categoryMapper->find($categoryId);
|
||||
$category->setThreadCount($category->getThreadCount() + 1);
|
||||
$category->setPostCount($category->getPostCount() + 1);
|
||||
$this->categoryMapper->update($category);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category counts: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Update user stats (post count and thread count, auto-creates stats if needed)
|
||||
// Update user stats (thread count only, first post doesn't count)
|
||||
try {
|
||||
$this->userStatsMapper->incrementPostCount($user->getUID());
|
||||
$this->userStatsMapper->incrementThreadCount($user->getUID());
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update user stats: ' . $e->getMessage());
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace OCA\Forum\Controller;
|
||||
|
||||
use OCA\Forum\Attribute\RequirePermission;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -23,6 +24,7 @@ class UserRoleController extends OCSController {
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private UserRoleMapper $userRoleMapper,
|
||||
private UserRoleService $userRoleService,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
@@ -75,22 +77,29 @@ class UserRoleController extends OCSController {
|
||||
*
|
||||
* @param string $userId Nextcloud user ID
|
||||
* @param int $roleId Role ID
|
||||
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
|
||||
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>|DataResponse<Http::STATUS_CONFLICT, array{error: string}, array{}>
|
||||
*
|
||||
* 201: Role assigned to user
|
||||
* 409: User already has this role
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[RequirePermission('canEditRoles')]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/user-roles')]
|
||||
public function create(string $userId, int $roleId): DataResponse {
|
||||
try {
|
||||
$userRole = new \OCA\Forum\Db\UserRole();
|
||||
$userRole->setUserId($userId);
|
||||
$userRole->setRoleId($roleId);
|
||||
$userRole->setCreatedAt(time());
|
||||
// Check if user already has the role
|
||||
if ($this->userRoleService->hasRole($userId, $roleId)) {
|
||||
return new DataResponse(['error' => 'User already has this role'], Http::STATUS_CONFLICT);
|
||||
}
|
||||
|
||||
// Assign the role using the service
|
||||
$createdUserRole = $this->userRoleService->assignRole($userId, $roleId, skipIfExists: false);
|
||||
|
||||
if ($createdUserRole === null) {
|
||||
// This shouldn't happen since we checked hasRole above, but handle it just in case
|
||||
return new DataResponse(['error' => 'Failed to assign role'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/** @var \OCA\Forum\Db\UserRole */
|
||||
$createdUserRole = $this->userRoleMapper->insert($userRole);
|
||||
return new DataResponse($createdUserRole->jsonSerialize(), Http::STATUS_CREATED);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error assigning role to user: ' . $e->getMessage());
|
||||
|
||||
@@ -8,6 +8,7 @@ declare(strict_types=1);
|
||||
namespace OCA\Forum\Db;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
@@ -67,6 +68,26 @@ class CategoryPermMapper extends QBMapper {
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find permissions for a category, excluding Admin role (which has implicit full access)
|
||||
*
|
||||
* @param int $categoryId Category ID
|
||||
* @return array<CategoryPerm>
|
||||
*/
|
||||
public function findByCategoryIdExcludingAdmin(int $categoryId): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->neq('role_id', $qb->createNamedParameter(UserRoleService::ROLE_ADMIN, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find permission for specific category and role
|
||||
*
|
||||
|
||||
@@ -165,6 +165,9 @@ class PostMapper extends QBMapper {
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace OCA\Forum\Listener;
|
||||
|
||||
use OCA\Forum\Db\UserStatsMapper;
|
||||
use OCA\Forum\Service\StatsService;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\User\Events\UserCreatedEvent;
|
||||
@@ -22,6 +23,7 @@ class UserEventListener implements IEventListener {
|
||||
public function __construct(
|
||||
private UserStatsMapper $userStatsMapper,
|
||||
private StatsService $statsService,
|
||||
private UserRoleService $userRoleService,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -47,6 +49,16 @@ class UserEventListener implements IEventListener {
|
||||
'exception' => $ex->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// Assign default user role to new user
|
||||
$this->userRoleService->assignRole($userId, UserRoleService::ROLE_USER, skipIfExists: true);
|
||||
$this->logger->info("Assigned default user role to new Nextcloud user: {$userId}");
|
||||
} catch (\Exception $ex) {
|
||||
$this->logger->error("Failed to assign default user role to new user: {$userId}", [
|
||||
'exception' => $ex->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleUserDeleted(UserDeletedEvent $event): void {
|
||||
|
||||
@@ -258,7 +258,7 @@ class SeedHelper {
|
||||
$qb->insert('forum_categories')
|
||||
->values([
|
||||
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'name' => $qb->createNamedParameter($l->t('General Discussions')),
|
||||
'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),
|
||||
@@ -472,7 +472,7 @@ class SeedHelper {
|
||||
[
|
||||
'tag' => 'icode',
|
||||
'replacement' => '<code>{content}</code>',
|
||||
'example' => '[icode]inline code[/icode]',
|
||||
'example' => $l->t('[icode]inline code[/icode]'),
|
||||
'description' => $l->t('Inline code'),
|
||||
'parse_inner' => false,
|
||||
'is_builtin' => true,
|
||||
@@ -481,7 +481,7 @@ class SeedHelper {
|
||||
[
|
||||
'tag' => 'spoiler',
|
||||
'replacement' => '<details><summary>{title}</summary>{content}</details>',
|
||||
'example' => '[spoiler="Spoiler Title"]Hidden content[/spoiler]',
|
||||
'example' => $l->t('[spoiler="%1$s"]%2$s[/spoiler]', ['Spoiler Title', 'Hidden content']),
|
||||
'description' => $l->t('Spoilers'),
|
||||
'parse_inner' => false,
|
||||
'is_builtin' => true,
|
||||
@@ -489,8 +489,8 @@ class SeedHelper {
|
||||
],
|
||||
[
|
||||
'tag' => 'attachment',
|
||||
'replacement' => '[attachment]/file/path.txt[/attachment]',
|
||||
'example' => '',
|
||||
'replacement' => '{content}',
|
||||
'example' => '[attachment]/file/path.txt[/attachment]',
|
||||
'description' => $l->t('Attachment'),
|
||||
'parse_inner' => false,
|
||||
'is_builtin' => true,
|
||||
@@ -719,7 +719,7 @@ class SeedHelper {
|
||||
'title' => $qb->createNamedParameter($l->t('Welcome to Nextcloud Forums')),
|
||||
'slug' => $qb->createNamedParameter('welcome-to-nextcloud-forums'),
|
||||
'view_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'post_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'last_post_id' => $qb->createNamedParameter(null, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_locked' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_pinned' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
@@ -745,9 +745,9 @@ class SeedHelper {
|
||||
. "[/list]\n"
|
||||
. '[b]' . $l->t('BBCode Examples:') . "[/b]\n"
|
||||
. "[list]\n"
|
||||
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use {codeStart}text{codeEnd}', ['{codeStart}' => '[icode][b]', '{codeEnd}' => '[/b][/icode]']) . "\n"
|
||||
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use {codeStart}text{codeEnd}', ['{codeStart}' => '[icode][i]', '{codeEnd}' => '[/i][/icode]']) . "\n"
|
||||
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use {codeStart}text{codeEnd}', ['{codeStart}' => '[icode][u]', '{codeEnd}' => '[/u][/icode]']) . "\n\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!');
|
||||
|
||||
@@ -783,6 +783,18 @@ class SeedHelper {
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->executeStatement();
|
||||
|
||||
// Subscribe the admin user to the welcome thread
|
||||
if ($db->tableExists('forum_thread_subs')) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_thread_subs')
|
||||
->values([
|
||||
'thread_id' => $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'user_id' => $qb->createNamedParameter($adminUserId),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
}
|
||||
|
||||
// Create user stats for the admin user
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_user_stats')
|
||||
|
||||
@@ -114,6 +114,62 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
$output->info(sprintf('Created %d new user stats', $result['created']));
|
||||
$output->info(sprintf('Updated %d existing user stats', $result['updated']));
|
||||
$output->info('User statistics created successfully!');
|
||||
|
||||
// Subscribe thread authors to their threads
|
||||
$this->subscribeAuthorsToThreads($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe all thread authors to their threads
|
||||
*/
|
||||
private function subscribeAuthorsToThreads(IOutput $output): void {
|
||||
$output->info('Subscribing thread authors to their threads...');
|
||||
|
||||
$db = \OC::$server->get(\OCP\IDBConnection::class);
|
||||
$timestamp = time();
|
||||
$subscribed = 0;
|
||||
|
||||
try {
|
||||
// Get all threads with their authors
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id', 'author_id')
|
||||
->from('forum_threads');
|
||||
$result = $qb->executeQuery();
|
||||
$threads = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
|
||||
foreach ($threads as $thread) {
|
||||
$threadId = (int)$thread['id'];
|
||||
$authorId = $thread['author_id'];
|
||||
|
||||
// Check if author is already subscribed
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('forum_thread_subs')
|
||||
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($authorId)));
|
||||
$result = $qb->executeQuery();
|
||||
$exists = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
if (!$exists) {
|
||||
// Subscribe the author to their thread
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_thread_subs')
|
||||
->values([
|
||||
'thread_id' => $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'user_id' => $qb->createNamedParameter($authorId),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
$subscribed++;
|
||||
}
|
||||
}
|
||||
|
||||
$output->info(sprintf('Subscribed %d thread authors to their threads', $subscribed));
|
||||
} catch (\Exception $e) {
|
||||
$output->warning('Failed to subscribe thread authors: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
64
lib/Migration/Version6Date20251122233018.php
Normal file
64
lib/Migration/Version6Date20251122233018.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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 OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Service\UserRoleService;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version6Date20251122233018 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
}
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return null|ISchemaWrapper
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
// TODO add migration logic
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// Remove Admin role permissions from categories
|
||||
// Admin role now has hardcoded full access to all categories
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete(Application::tableName('forum_category_perms'))
|
||||
->where(
|
||||
$qb->expr()->eq('role_id', $qb->createNamedParameter(UserRoleService::ROLE_ADMIN, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
$deletedCount = $qb->executeStatement();
|
||||
|
||||
$output->info("Removed $deletedCount Admin role permission entries from categories (Admin has hardcoded full access)");
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class Notifier implements INotifier {
|
||||
// Set the rich subject with thread title
|
||||
$notification->setRichSubject(
|
||||
$l->n(
|
||||
'New reply in {thread}',
|
||||
'{count} new reply in {thread}',
|
||||
'{count} new replies in {thread}',
|
||||
$postCount
|
||||
),
|
||||
|
||||
@@ -28,11 +28,34 @@ class PermissionService {
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has Admin role
|
||||
*
|
||||
* @param string $userId Nextcloud user ID
|
||||
* @return bool True if user has Admin role
|
||||
*/
|
||||
private function hasAdminRole(string $userId): bool {
|
||||
try {
|
||||
$userRoles = $this->userRoleMapper->findByUserId($userId);
|
||||
|
||||
foreach ($userRoles as $userRole) {
|
||||
if ($userRole->getRoleId() === UserRoleService::ROLE_ADMIN) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Error checking admin role for user $userId: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has Admin or Moderator role
|
||||
*
|
||||
* @param string $userId Nextcloud user ID
|
||||
* @return bool True if user has Admin (roleId 1) or Moderator (roleId 2) role
|
||||
* @return bool True if user has Admin or Moderator role
|
||||
*/
|
||||
public function hasAdminOrModeratorRole(string $userId): bool {
|
||||
try {
|
||||
@@ -40,8 +63,7 @@ class PermissionService {
|
||||
|
||||
foreach ($userRoles as $userRole) {
|
||||
$roleId = $userRole->getRoleId();
|
||||
// Admin role = 1, Moderator role = 2
|
||||
if ($roleId === 1 || $roleId === 2) {
|
||||
if ($roleId === UserRoleService::ROLE_ADMIN || $roleId === UserRoleService::ROLE_MODERATOR) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -125,6 +147,12 @@ class PermissionService {
|
||||
* @return bool True if user has the permission
|
||||
*/
|
||||
public function hasCategoryPermission(string $userId, int $categoryId, string $permission): bool {
|
||||
// Admin role has hardcoded full access to all categories
|
||||
if ($this->hasAdminRole($userId)) {
|
||||
$this->logger->debug("User $userId has Admin role - granting category permission '$permission' on category $categoryId");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$userRoles = $this->userRoleMapper->findByUserId($userId);
|
||||
|
||||
|
||||
@@ -207,14 +207,15 @@ class StatsService {
|
||||
$threadCount = (int)($threadResult->fetchOne() ?? 0);
|
||||
$threadResult->closeCursor();
|
||||
|
||||
// Count non-deleted posts in non-deleted threads in this category
|
||||
// Count non-deleted posts in non-deleted threads in this category (excluding first posts)
|
||||
$postQb = $this->db->getQueryBuilder();
|
||||
$postQb->select($postQb->func()->count('*', 'count'))
|
||||
->from('forum_posts', 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($postQb->expr()->eq('t.category_id', $postQb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($postQb->expr()->isNull('p.deleted_at'))
|
||||
->andWhere($postQb->expr()->isNull('t.deleted_at'));
|
||||
->andWhere($postQb->expr()->isNull('t.deleted_at'))
|
||||
->andWhere($postQb->expr()->eq('p.is_first_post', $postQb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)));
|
||||
$postResult = $postQb->executeQuery();
|
||||
$postCount = (int)($postResult->fetchOne() ?? 0);
|
||||
$postResult->closeCursor();
|
||||
@@ -266,12 +267,13 @@ class StatsService {
|
||||
* @return void
|
||||
*/
|
||||
public function rebuildThreadStats(int $threadId): void {
|
||||
// Count non-deleted posts in this thread
|
||||
// Count non-deleted posts in this thread (excluding first post)
|
||||
$postQb = $this->db->getQueryBuilder();
|
||||
$postQb->select($postQb->func()->count('*', 'count'))
|
||||
->from('forum_posts')
|
||||
->where($postQb->expr()->eq('thread_id', $postQb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($postQb->expr()->isNull('deleted_at'));
|
||||
->andWhere($postQb->expr()->isNull('deleted_at'))
|
||||
->andWhere($postQb->expr()->eq('is_first_post', $postQb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)));
|
||||
$postResult = $postQb->executeQuery();
|
||||
$postCount = (int)($postResult->fetchOne() ?? 0);
|
||||
$postResult->closeCursor();
|
||||
|
||||
141
lib/Service/UserRoleService.php
Normal file
141
lib/Service/UserRoleService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Service;
|
||||
|
||||
use OCA\Forum\Db\UserRole;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Service for managing user role assignments
|
||||
*/
|
||||
class UserRoleService {
|
||||
/** @var int Admin role ID */
|
||||
public const ROLE_ADMIN = 1;
|
||||
|
||||
/** @var int Moderator role ID */
|
||||
public const ROLE_MODERATOR = 2;
|
||||
|
||||
/** @var int User role ID */
|
||||
public const ROLE_USER = 3;
|
||||
|
||||
public function __construct(
|
||||
private UserRoleMapper $userRoleMapper,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a role to a user
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param int $roleId The role ID to assign
|
||||
* @param bool $skipIfExists If true, silently skip if user already has the role. If false, log a warning.
|
||||
* @return UserRole|null The created UserRole, or null if already exists and skipIfExists is true
|
||||
*/
|
||||
public function assignRole(string $userId, int $roleId, bool $skipIfExists = true): ?UserRole {
|
||||
// Check if user already has this role
|
||||
if ($this->hasRole($userId, $roleId)) {
|
||||
if ($skipIfExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->logger->warning('User {userId} already has role {roleId}', [
|
||||
'userId' => $userId,
|
||||
'roleId' => $roleId,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create and insert the new user role
|
||||
$userRole = new UserRole();
|
||||
$userRole->setUserId($userId);
|
||||
$userRole->setRoleId($roleId);
|
||||
$userRole->setCreatedAt(time());
|
||||
|
||||
try {
|
||||
$createdRole = $this->userRoleMapper->insert($userRole);
|
||||
$this->logger->info('Assigned role {roleId} to user {userId}', [
|
||||
'userId' => $userId,
|
||||
'roleId' => $roleId,
|
||||
]);
|
||||
return $createdRole;
|
||||
} catch (\Exception $ex) {
|
||||
$this->logger->error('Failed to assign role {roleId} to user {userId}: {error}', [
|
||||
'userId' => $userId,
|
||||
'roleId' => $roleId,
|
||||
'error' => $ex->getMessage(),
|
||||
]);
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific role
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param int $roleId The role ID to check
|
||||
* @return bool True if user has the role, false otherwise
|
||||
*/
|
||||
public function hasRole(string $userId, int $roleId): bool {
|
||||
$userRoles = $this->userRoleMapper->findByUserId($userId);
|
||||
foreach ($userRoles as $userRole) {
|
||||
if ($userRole->getRoleId() === $roleId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a role from a user
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param int $roleId The role ID to remove
|
||||
* @return bool True if role was removed, false if user didn't have the role
|
||||
*/
|
||||
public function removeRole(string $userId, int $roleId): bool {
|
||||
$userRoles = $this->userRoleMapper->findByUserId($userId);
|
||||
foreach ($userRoles as $userRole) {
|
||||
if ($userRole->getRoleId() === $roleId) {
|
||||
try {
|
||||
$this->userRoleMapper->delete($userRole);
|
||||
$this->logger->info('Removed role {roleId} from user {userId}', [
|
||||
'userId' => $userId,
|
||||
'roleId' => $roleId,
|
||||
]);
|
||||
return true;
|
||||
} catch (\Exception $ex) {
|
||||
$this->logger->error('Failed to remove role {roleId} from user {userId}: {error}', [
|
||||
'userId' => $userId,
|
||||
'roleId' => $roleId,
|
||||
'error' => $ex->getMessage(),
|
||||
]);
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug('User {userId} does not have role {roleId}, nothing to remove', [
|
||||
'userId' => $userId,
|
||||
'roleId' => $roleId,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all role IDs for a user
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @return array<int> Array of role IDs
|
||||
*/
|
||||
public function getUserRoleIds(string $userId): array {
|
||||
$userRoles = $this->userRoleMapper->findByUserId($userId);
|
||||
return array_map(fn ($userRole) => $userRole->getRoleId(), $userRoles);
|
||||
}
|
||||
}
|
||||
76
openapi.json
76
openapi.json
@@ -6195,6 +6195,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Cannot delete system roles",
|
||||
"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": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
@@ -8932,6 +8970,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "User already has this role",
|
||||
"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": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
|
||||
466
src/Settings.vue
466
src/Settings.vue
@@ -1,466 +0,0 @@
|
||||
<template>
|
||||
<div id="forum-content" class="section">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
|
||||
<!-- Information / quick start -->
|
||||
<NcAppSettingsSection :name="strings.infoTitle">
|
||||
<p v-html="strings.infoIntro"></p>
|
||||
|
||||
<ol class="ol">
|
||||
<li v-for="li in strings.gettingStartedList" :key="li" v-html="li"></li>
|
||||
</ol>
|
||||
|
||||
<NcNoteCard type="info">
|
||||
<p v-html="strings.tipsNote"></p>
|
||||
</NcNoteCard>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Live examples -->
|
||||
<NcAppSettingsSection :name="strings.examplesHeader">
|
||||
<section class="example-grid">
|
||||
<!-- v-model example -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ strings.nameInputHeader }}</h3>
|
||||
<NcTextField
|
||||
v-model="name"
|
||||
:label="strings.nameInputLabel"
|
||||
:placeholder="strings.nameInputPlaceholder"
|
||||
/>
|
||||
<p class="mt-8">
|
||||
{{ strings.livePreview }} <b>{{ greeting }}</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Select + computed example -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ strings.themeHeader }}</h3>
|
||||
<NcSelect
|
||||
v-model="themeLabel"
|
||||
:options="themeOptionsLabels"
|
||||
:input-label="strings.themeLabel"
|
||||
/>
|
||||
<p class="mt-8">
|
||||
{{ strings.themePreview }}
|
||||
<code>{{ activeTheme.value }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Counter + events example -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ strings.counterHeader }}</h3>
|
||||
<div class="row gap-8">
|
||||
<NcButton @click="decrement">{{ strings.minus }}</NcButton>
|
||||
<span class="counter">{{ counter }}</span>
|
||||
<NcButton @click="increment">{{ strings.plus }}</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Table + add/remove items example -->
|
||||
<NcAppSettingsSection :name="strings.itemsHeader">
|
||||
<div class="row align-start gap-16">
|
||||
<div style="max-width: 320px">
|
||||
<NcTextField
|
||||
v-model="newItem"
|
||||
:label="strings.newItemLabel"
|
||||
:placeholder="strings.newItemPlaceholder"
|
||||
trailing-button-icon="plus"
|
||||
:show-trailing-button="newItem.trim() !== ''"
|
||||
@trailing-button-click="addItem"
|
||||
/>
|
||||
</div>
|
||||
<NcButton @click="addItem" :disabled="newItem.trim() === ''">{{ strings.add }}</NcButton>
|
||||
<NcButton type="secondary" @click="clearItems" :disabled="items.length === 0">
|
||||
{{ strings.clear }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<table class="mt-16">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60%">{{ strings.tableItem }}</th>
|
||||
<th style="width: 40%">{{ strings.tableActions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in items" :key="item.id">
|
||||
<td>
|
||||
<input class="inline-input" :aria-label="strings.editItemAria" v-model="item.label" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="row gap-8">
|
||||
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
|
||||
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="2" class="muted">{{ strings.noItems }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Backend calls example -->
|
||||
<NcAppSettingsSection :name="strings.backendHeader">
|
||||
<form @submit.prevent @submit="save">
|
||||
<div class="row gap-16 align-center">
|
||||
<NcButton @click="fetchHello" :disabled="loading">{{ strings.fetchHello }}</NcButton>
|
||||
<NcButton :disabled="loading" @click="submit">{{ strings.save }}</NcButton>
|
||||
|
||||
<span>
|
||||
<span v-if="loading">{{ strings.loading }}</span>
|
||||
<span v-else-if="lastHelloAt">
|
||||
{{ strings.lastHelloAt }}
|
||||
<NcDateTime :timestamp="lastHelloAt.valueOf()" />
|
||||
</span>
|
||||
<span v-else class="muted">{{ strings.never }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<NcNoteCard v-if="serverMessage" type="success" class="mt-12">
|
||||
<p>
|
||||
{{ strings.serverSaid }} <code>{{ serverMessage }}</code>
|
||||
</p>
|
||||
</NcNoteCard>
|
||||
</NcAppSettingsSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'HelloWorld',
|
||||
components: {
|
||||
NcAppSettingsSection,
|
||||
NcButton,
|
||||
NcDateTime,
|
||||
NcNoteCard,
|
||||
NcSelect,
|
||||
NcTextField,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// UI state
|
||||
loading: false,
|
||||
|
||||
// Example: simple input
|
||||
name: '',
|
||||
|
||||
// Example: select with label <-> value mapping (like your intervals)
|
||||
themeLabel: null,
|
||||
themeOptions: [
|
||||
{ label: t('forum', 'Light'), value: 'light' },
|
||||
{ label: t('forum', 'Dark'), value: 'dark' },
|
||||
{
|
||||
label: n('forum', 'System (1 option)', 'System (%n options)', 2),
|
||||
value: 'system',
|
||||
},
|
||||
],
|
||||
|
||||
// Example: small counter
|
||||
counter: 0,
|
||||
|
||||
// Example: simple items table
|
||||
items: [],
|
||||
newItem: '',
|
||||
|
||||
// Example: tracking server interactions
|
||||
lastHelloAt: null,
|
||||
serverMessage: '',
|
||||
|
||||
// All user-visible strings go here
|
||||
strings: {
|
||||
// Titles / headers
|
||||
title: t('forum', 'Hello World — App Template'),
|
||||
infoTitle: t('forum', 'Information'),
|
||||
examplesHeader: t('forum', 'Quick Examples'),
|
||||
itemsHeader: t('forum', 'Editable List'),
|
||||
backendHeader: t('forum', 'Backend Calls'),
|
||||
|
||||
// Info
|
||||
infoIntro: t(
|
||||
'forum',
|
||||
'This view shows {bStart}small, focused examples{bEnd} for inputs, lists, selections, and backend calls.',
|
||||
{ bStart: '<b>', bEnd: '</b>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
|
||||
gettingStartedList: [
|
||||
t(
|
||||
'forum',
|
||||
'Import UI parts from {cStart}@nextcloud/vue{cEnd} and wire them with {cStart}v-model{cEnd}.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
t(
|
||||
'forum',
|
||||
'Use {cStart}axios{cEnd} for API calls; return OCS data as needed.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
t(
|
||||
'forum',
|
||||
'Keep user-facing text in a central {cStart}strings{cEnd} object with {cStart}t/n{cEnd}.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
],
|
||||
|
||||
tipsNote: t(
|
||||
'forum',
|
||||
'Pro tip: keep labels in {cStart}label{cEnd} and values in {cStart}value{cEnd} to simplify mapping.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
|
||||
// Name example
|
||||
nameInputHeader: t('forum', 'Chen Asraf'),
|
||||
nameInputLabel: t('forum', 'Name'),
|
||||
nameInputPlaceholder: t('forum', 'e.g. Ada Lovelace'),
|
||||
livePreview: t('forum', 'Live preview:'),
|
||||
|
||||
// Theme example
|
||||
themeHeader: t('forum', 'Theme'),
|
||||
themeLabel: t('forum', 'Choose a theme'),
|
||||
themePreview: t('forum', 'Active value:'),
|
||||
|
||||
// Counter example
|
||||
counterHeader: t('forum', 'Counter'),
|
||||
plus: t('forum', '+1'),
|
||||
minus: t('forum', '-1'),
|
||||
|
||||
// Items table
|
||||
newItemLabel: t('forum', 'New item'),
|
||||
newItemPlaceholder: t('forum', 'e.g. Hello item'),
|
||||
add: t('forum', 'Add'),
|
||||
clear: t('forum', 'Clear'),
|
||||
tableItem: t('forum', 'Item'),
|
||||
tableActions: t('forum', 'Actions'),
|
||||
editItemAria: t('forum', 'Edit item'),
|
||||
duplicate: t('forum', 'Duplicate'),
|
||||
remove: t('forum', 'Remove'),
|
||||
noItems: t('forum', 'No items yet'),
|
||||
|
||||
// Backend
|
||||
fetchHello: t('forum', 'Fetch Hello'),
|
||||
save: t('forum', 'Save'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
lastHelloAt: t('forum', 'Last hello at:'),
|
||||
never: t('forum', 'Never'),
|
||||
serverSaid: t('forum', 'Server said:'),
|
||||
},
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// Load initial data if you want
|
||||
this.fetchHello()
|
||||
},
|
||||
computed: {
|
||||
// Map selected theme label -> full option
|
||||
activeTheme() {
|
||||
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
|
||||
},
|
||||
// Convenience list for NcSelect (labels only)
|
||||
themeOptionsLabels() {
|
||||
return this.themeOptions.map((x) => x.label)
|
||||
},
|
||||
// Live greeting preview (reacts to "name")
|
||||
greeting() {
|
||||
return this.name.trim() ? `Hello, ${this.name.trim()}!` : 'Hello!'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Counter handlers
|
||||
increment() {
|
||||
this.counter++
|
||||
},
|
||||
decrement() {
|
||||
this.counter--
|
||||
},
|
||||
|
||||
// Items handlers
|
||||
addItem() {
|
||||
const label = this.newItem.trim()
|
||||
if (!label) return
|
||||
this.items.push({ id: cryptoRandom(), label })
|
||||
this.newItem = ''
|
||||
},
|
||||
duplicate(index) {
|
||||
const src = this.items[index]
|
||||
if (!src) return
|
||||
this.items.splice(index + 1, 0, { id: cryptoRandom(), label: src.label })
|
||||
},
|
||||
remove(index) {
|
||||
this.items.splice(index, 1)
|
||||
},
|
||||
clearItems() {
|
||||
this.items = []
|
||||
},
|
||||
|
||||
// Backend examples (adjust endpoints to your app’s routes)
|
||||
async fetchHello() {
|
||||
try {
|
||||
this.loading = true
|
||||
// Example GET -> /hello (expects: { ocs: { data: { message: string, at: string }}})
|
||||
const resp = await ocs.get('/hello')
|
||||
this.serverMessage = resp.data.message ?? '👋'
|
||||
// If backend returns ISO date strings, store a Date instance
|
||||
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch hello', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
try {
|
||||
this.loading = true
|
||||
// Example POST -> /hello (send minimal payload)
|
||||
const payload = {
|
||||
name: this.name.trim() || null,
|
||||
theme: this.activeTheme.value,
|
||||
items: this.items.map((x) => x.label),
|
||||
counter: this.counter,
|
||||
}
|
||||
const resp = await ocs.post('/hello', { data: payload })
|
||||
// Update preview/message
|
||||
if (resp.data.message) this.serverMessage = resp.data.message
|
||||
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
|
||||
} catch (e) {
|
||||
console.error('Failed to save hello', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Small helper for local IDs (no crypto dep) */
|
||||
function cryptoRandom() {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#forum-content {
|
||||
h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
|
||||
&.align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.gap-8 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&.gap-16 {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.example-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.counter {
|
||||
min-width: 3ch;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ol {
|
||||
padding-left: 2.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--color-border);
|
||||
margin-top: 8px;
|
||||
|
||||
tr:not(:last-child),
|
||||
thead tr {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody tr {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 6px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -242,10 +242,10 @@ export default defineComponent({
|
||||
searchLabel: t('forum', 'Search'),
|
||||
navHome: t('forum', 'Home'),
|
||||
navSearch: t('forum', 'Search'),
|
||||
navPreferences: t('forum', 'User Preferences'),
|
||||
navPreferences: t('forum', 'User preferences'),
|
||||
navAdmin: t('forum', 'Admin'),
|
||||
navAdminDashboard: t('forum', 'Dashboard'),
|
||||
navAdminSettings: t('forum', 'Forum Settings'),
|
||||
navAdminSettings: t('forum', 'Forum settings'),
|
||||
navAdminUsers: t('forum', 'Users'),
|
||||
navAdminRoles: t('forum', 'Roles'),
|
||||
navAdminCategories: t('forum', 'Categories'),
|
||||
|
||||
@@ -111,73 +111,156 @@ export default defineComponent({
|
||||
builtinDbCodes: [] as BBCode[],
|
||||
|
||||
builtInCodes: [
|
||||
{ tag: 'b', name: t('forum', 'Font style bold'), example: '[b]Hello world[/b]' },
|
||||
{ tag: 'i', name: t('forum', 'Font style italic'), example: '[i]Hello world[/i]' },
|
||||
{ tag: 's', name: t('forum', 'Font style struck through'), example: '[s]Hello world[/s]' },
|
||||
{ tag: 'u', name: t('forum', 'Font style underlined'), example: '[u]Hello world[/u]' },
|
||||
{ tag: 'code', name: t('forum', 'Code'), example: '[code]Hello world[/code]' },
|
||||
{
|
||||
tag: 'b',
|
||||
name: t('forum', 'Font style bold'),
|
||||
example: t('forum', '{bStart}Hello world{bEnd}', { bStart: '[b]', bEnd: '[/b]' }),
|
||||
},
|
||||
{
|
||||
tag: 'i',
|
||||
name: t('forum', 'Font style italic'),
|
||||
example: t('forum', '{iStart}Hello world{iEnd}', { iStart: '[i]', iEnd: '[/i]' }),
|
||||
},
|
||||
{
|
||||
tag: 's',
|
||||
name: t('forum', 'Font style struck through'),
|
||||
example: t('forum', '{sStart}Hello world{sEnd}', { sStart: '[s]', sEnd: '[/s]' }),
|
||||
},
|
||||
{
|
||||
tag: 'u',
|
||||
name: t('forum', 'Font style underlined'),
|
||||
example: t('forum', '{uStart}Hello world{uEnd}', { uStart: '[u]', uEnd: '[/u]' }),
|
||||
},
|
||||
{
|
||||
tag: 'code',
|
||||
name: t('forum', 'Code'),
|
||||
example: t('forum', '{codeStart}Hello world{codeEnd}', {
|
||||
codeStart: '[code]',
|
||||
codeEnd: '[/code]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'email',
|
||||
name: t('forum', 'Email (clickable)'),
|
||||
example: '[email]test@example.com[/email]',
|
||||
example: t('forum', '{emailStart}test@example.com{emailEnd}', {
|
||||
emailStart: '[email]',
|
||||
emailEnd: '[/email]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'url',
|
||||
name: t('forum', 'URL (clickable)'),
|
||||
example: '[url=http://example.com]Example.com[/url]',
|
||||
example: t('forum', '{urlStart}Example.com{urlEnd}', {
|
||||
urlStart: '[url=http://example.com]',
|
||||
urlEnd: '[/url]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'img',
|
||||
name: t('forum', 'Image (not clickable)'),
|
||||
example: '[img]http://example.com/example.png[/img]',
|
||||
example: t('forum', '{imgStart}http://example.com/example.png{imgEnd}', {
|
||||
imgStart: '[img]',
|
||||
imgEnd: '[/img]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'quote',
|
||||
name: t('forum', 'Quote'),
|
||||
example: t('forum', '{quoteStart}Hello world{quoteEnd}', {
|
||||
quoteStart: '[quote]',
|
||||
quoteEnd: '[/quote]',
|
||||
}),
|
||||
},
|
||||
{ tag: 'quote', name: t('forum', 'Quote'), example: '[quote]Hello world[/quote]' },
|
||||
{
|
||||
tag: 'youtube',
|
||||
name: t('forum', 'Embedded YouTube video'),
|
||||
example: '[youtube]a-video-id-123456[/youtube]',
|
||||
example: t('forum', '{youtubeStart}a-video-id-123456{youtubeEnd}', {
|
||||
youtubeStart: '[youtube]',
|
||||
youtubeEnd: '[/youtube]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'font',
|
||||
name: t('forum', 'Font (name)'),
|
||||
example: '[font=Arial]Hello world![/font]',
|
||||
example: t('forum', '{fontStart}Hello world!{fontEnd}', {
|
||||
fontStart: '[font=Arial]',
|
||||
fontEnd: '[/font]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'size',
|
||||
name: t('forum', 'Font size'),
|
||||
example: t('forum', '{sizeStart}Hello world!{sizeEnd}', {
|
||||
sizeStart: '[size=12]',
|
||||
sizeEnd: '[/size]',
|
||||
}),
|
||||
},
|
||||
{ tag: 'size', name: t('forum', 'Font size'), example: '[size=12]Hello world![/size]' },
|
||||
{
|
||||
tag: 'color',
|
||||
name: t('forum', 'Font color'),
|
||||
example: '[color=red]Hello world![/color]',
|
||||
example: t('forum', '{colorStart}Hello world!{colorEnd}', {
|
||||
colorStart: '[color=red]',
|
||||
colorEnd: '[/color]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'left',
|
||||
name: t('forum', 'Align left'),
|
||||
example: t('forum', '{leftStart}Hello world{leftEnd}', {
|
||||
leftStart: '[left]',
|
||||
leftEnd: '[/left]',
|
||||
}),
|
||||
},
|
||||
{ tag: 'left', name: t('forum', 'Text-align: left'), example: '[left]Hello world[/left]' },
|
||||
{
|
||||
tag: 'center',
|
||||
name: t('forum', 'Text-align: center'),
|
||||
example: '[center]Hello world[/center]',
|
||||
name: t('forum', 'Align center'),
|
||||
example: t('forum', '{centerStart}Hello world{centerEnd}', {
|
||||
centerStart: '[center]',
|
||||
centerEnd: '[/center]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'right',
|
||||
name: t('forum', 'Text-align: right'),
|
||||
example: '[right]Hello world[/right]',
|
||||
name: t('forum', 'Align right'),
|
||||
example: t('forum', '{rightStart}Hello world{rightEnd}', {
|
||||
rightStart: '[right]',
|
||||
rightEnd: '[/right]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'list',
|
||||
name: t('forum', 'List'),
|
||||
example: '[list][*]Hello world![li]Hello moon![/li][/list]',
|
||||
example: t(
|
||||
'forum',
|
||||
'{listStart}{item1Start}Hello world!{item2Start}Hello moon!{item2End}{listEnd}',
|
||||
{
|
||||
listStart: '[list]',
|
||||
item1Start: '[*]',
|
||||
item2Start: '[li]',
|
||||
item2End: '[/li]',
|
||||
listEnd: '[/list]',
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
tag: '*',
|
||||
name: t('forum', 'List item within a list'),
|
||||
example: '[*]Hello world!\\r\\n[*]Hello moon!',
|
||||
example: t('forum', '{itemStart}Hello world!\\r\\n{itemStart}Hello moon!', {
|
||||
itemStart: '[*]',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'li',
|
||||
name: t('forum', 'List item within a list (alias)'),
|
||||
example: '[li]Hello world!\\r\\n[/li][li]Hello moon![/li]',
|
||||
example: t('forum', '{liStart}Hello world!\\r\\n{liEnd}{liStart}Hello moon!{liEnd}', {
|
||||
liStart: '[li]',
|
||||
liEnd: '[/li]',
|
||||
}),
|
||||
},
|
||||
] as BuiltInCode[],
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'BBCode Help'),
|
||||
title: t('forum', 'BBCode help'),
|
||||
builtInTitle: t('forum', 'Built-in BBCodes'),
|
||||
builtInDescription: t('forum', 'These BBCodes are available by default.'),
|
||||
customTitle: t('forum', 'Custom BBCodes'),
|
||||
|
||||
@@ -107,7 +107,7 @@ export default defineComponent({
|
||||
return {
|
||||
showHelp: false,
|
||||
strings: {
|
||||
helpLabel: t('forum', 'BBCode Help'),
|
||||
helpLabel: t('forum', 'BBCode help'),
|
||||
emojiLabel: t('forum', 'Insert emoji'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export default defineComponent({
|
||||
isEditing: false,
|
||||
strings: {
|
||||
edited: t('forum', 'Edited'),
|
||||
reply: t('forum', 'Quote Reply'),
|
||||
reply: t('forum', 'Quote reply'),
|
||||
edit: t('forum', 'Edit'),
|
||||
delete: t('forum', 'Delete'),
|
||||
confirmDelete: t(
|
||||
|
||||
@@ -51,7 +51,7 @@ export default defineComponent({
|
||||
content: this.initialContent,
|
||||
submitting: false,
|
||||
strings: {
|
||||
placeholder: t('forum', 'Edit your post...'),
|
||||
placeholder: t('forum', 'Edit your post …'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
save: t('forum', 'Save'),
|
||||
confirmCancel: t('forum', 'Are you sure you want to discard your changes?'),
|
||||
|
||||
@@ -175,10 +175,8 @@ export default defineComponent({
|
||||
return t('forum', 'React with {emoji}', { emoji })
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return hasReacted
|
||||
? t('forum', 'You reacted with {emoji}', { emoji })
|
||||
: t('forum', '1 person reacted with {emoji}', { emoji })
|
||||
if (count === 1 && hasReacted) {
|
||||
return t('forum', 'You reacted with {emoji}', { emoji })
|
||||
}
|
||||
|
||||
return hasReacted
|
||||
|
||||
@@ -70,9 +70,9 @@ export default defineComponent({
|
||||
content: '',
|
||||
submitting: false,
|
||||
strings: {
|
||||
placeholder: t('forum', 'Write your reply...'),
|
||||
placeholder: t('forum', 'Write your reply …'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
submit: t('forum', 'Post Reply'),
|
||||
submit: t('forum', 'Post reply'),
|
||||
confirmCancel: t('forum', 'Are you sure you want to discard your reply?'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
<span class="stat-icon">
|
||||
<CommentIcon :size="20" />
|
||||
</span>
|
||||
<span class="stat-value">{{ (thread.postCount || 1) - 1 }}</span>
|
||||
<span class="stat-label">{{ strings.replies((thread.postCount || 1) - 1) }}</span>
|
||||
<span class="stat-value">{{ thread.postCount || 0 }}</span>
|
||||
<span class="stat-label">{{ strings.replies(thread.postCount || 0) }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-icon">
|
||||
|
||||
@@ -83,10 +83,10 @@ export default defineComponent({
|
||||
submitting: false,
|
||||
strings: {
|
||||
titleLabel: t('forum', 'Title'),
|
||||
titlePlaceholder: t('forum', 'Enter thread title...'),
|
||||
contentPlaceholder: t('forum', 'Write your first post...'),
|
||||
titlePlaceholder: t('forum', 'Enter thread title …'),
|
||||
contentPlaceholder: t('forum', 'Write your first post …'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
submit: t('forum', 'Create Thread'),
|
||||
submit: t('forum', 'Create thread'),
|
||||
confirmCancel: t('forum', 'Are you sure you want to discard this thread?'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { ocs } from '@/axios'
|
||||
import type { UserRole } from '@/types'
|
||||
import { SystemRole } from '@/constants'
|
||||
|
||||
const userRoles = ref<UserRole[]>([])
|
||||
const loading = ref<boolean>(false)
|
||||
@@ -30,13 +31,11 @@ export function useUserRole() {
|
||||
}
|
||||
|
||||
const isAdmin = computed<boolean>(() => {
|
||||
// Admin role has ID 1 (from migration)
|
||||
return userRoles.value.some((role) => role.roleId === 1)
|
||||
return userRoles.value.some((role) => role.roleId === SystemRole.ADMIN)
|
||||
})
|
||||
|
||||
const isModerator = computed<boolean>(() => {
|
||||
// Moderator role has ID 2 (from migration)
|
||||
return userRoles.value.some((role) => role.roleId === 2)
|
||||
return userRoles.value.some((role) => role.roleId === SystemRole.MODERATOR)
|
||||
})
|
||||
|
||||
const refresh = () => {
|
||||
|
||||
22
src/constants.ts
Normal file
22
src/constants.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
/**
|
||||
* System role IDs
|
||||
* These roles are created during app installation and cannot be deleted
|
||||
*/
|
||||
export const SystemRole = {
|
||||
/** Admin role ID */
|
||||
ADMIN: 1,
|
||||
/** Moderator role ID */
|
||||
MODERATOR: 2,
|
||||
/** User role ID */
|
||||
USER: 3,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Check if a role ID is a system role
|
||||
*/
|
||||
export function isSystemRole(roleId: number): boolean {
|
||||
return Object.values(SystemRole).includes(roleId)
|
||||
}
|
||||
@@ -112,3 +112,7 @@
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog__actions {
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default defineComponent({
|
||||
forumSubtitle: t('forum', 'Welcome to the forum'),
|
||||
strings: {
|
||||
refresh: t('forum', 'Refresh'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
emptyTitle: t('forum', 'No categories yet'),
|
||||
emptyDesc: t('forum', 'Categories will appear here once they are created.'),
|
||||
noCategories: t('forum', 'No categories in this section'),
|
||||
|
||||
@@ -148,10 +148,10 @@ export default defineComponent({
|
||||
offset: 0,
|
||||
|
||||
strings: {
|
||||
back: t('forum', 'Back to Categories'),
|
||||
back: t('forum', 'Back to categories'),
|
||||
refresh: t('forum', 'Refresh'),
|
||||
newThread: t('forum', 'New Thread'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
newThread: t('forum', 'New thread'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error loading category'),
|
||||
emptyTitle: t('forum', 'No threads yet'),
|
||||
emptyDesc: t('forum', 'Be the first to start a discussion in this category.'),
|
||||
|
||||
@@ -88,11 +88,11 @@ export default defineComponent({
|
||||
strings: {
|
||||
back: t('forum', 'Back'),
|
||||
title: t('forum', 'Create New Thread'),
|
||||
subtitle: (categoryName: string) => t('forum', 'in {category}', { category: categoryName }),
|
||||
loading: t('forum', 'Loading…'),
|
||||
subtitle: (categoryName: string) => t('forum', 'In {category}', { category: categoryName }),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error loading category'),
|
||||
creating: t('forum', 'Creating thread…'),
|
||||
success: t('forum', 'Thread created successfully'),
|
||||
creating: t('forum', 'Creating thread …'),
|
||||
success: t('forum', 'Thread created'),
|
||||
errorCreating: t('forum', 'Failed to create thread'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ export default defineComponent({
|
||||
strings: {
|
||||
back: t('forum', 'Back'),
|
||||
refresh: t('forum', 'Refresh'),
|
||||
loading: t('forum', 'Loading...'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error'),
|
||||
retry: t('forum', 'Retry'),
|
||||
firstPost: t('forum', 'First post'),
|
||||
@@ -214,7 +214,7 @@ export default defineComponent({
|
||||
noThreadsDesc: t('forum', 'This user has not created any threads yet'),
|
||||
noPosts: t('forum', 'No replies'),
|
||||
noPostsDesc: t('forum', 'This user has not posted any replies yet'),
|
||||
inThread: t('forum', 'in thread'),
|
||||
inThread: t('forum', 'In thread'),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -179,18 +179,18 @@ export default defineComponent({
|
||||
|
||||
strings: {
|
||||
searchTitle: t('forum', 'Search'),
|
||||
searchPlaceholder: t('forum', 'Enter search query...'),
|
||||
searchPlaceholder: t('forum', 'Enter search query …'),
|
||||
search: t('forum', 'Search'),
|
||||
searchThreads: t('forum', 'Search in Threads'),
|
||||
searchPosts: t('forum', 'Search in Posts'),
|
||||
syntaxHelp: t('forum', 'Syntax Help'),
|
||||
searchSyntax: t('forum', 'Search Syntax'),
|
||||
searchThreads: t('forum', 'Search in threads'),
|
||||
searchPosts: t('forum', 'Search in posts'),
|
||||
syntaxHelp: t('forum', 'Syntax help'),
|
||||
searchSyntax: t('forum', 'Search syntax'),
|
||||
helpExactPhrase: t('forum', 'Match exact phrase'),
|
||||
helpAnd: t('forum', 'Both terms required'),
|
||||
helpOr: t('forum', 'Either term matches'),
|
||||
helpGrouping: t('forum', 'Group conditions with parentheses'),
|
||||
helpExclude: t('forum', 'Exclude term from results'),
|
||||
searching: t('forum', 'Searching...'),
|
||||
searching: t('forum', 'Searching …'),
|
||||
errorTitle: t('forum', 'Search Error'),
|
||||
retry: t('forum', 'Retry'),
|
||||
emptyTitle: t('forum', 'Enter a search query'),
|
||||
|
||||
@@ -276,7 +276,7 @@ export default defineComponent({
|
||||
t('forum', 'Back to {category}', { category: categoryName }),
|
||||
refresh: t('forum', 'Refresh'),
|
||||
reply: t('forum', 'Reply'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error loading thread'),
|
||||
emptyPostsTitle: t('forum', 'No posts yet'),
|
||||
emptyPostsDesc: t('forum', 'Be the first to post in this thread.'),
|
||||
@@ -494,7 +494,7 @@ export default defineComponent({
|
||||
postCard.finishEdit()
|
||||
}
|
||||
|
||||
showSuccess(t('forum', 'Post updated successfully'))
|
||||
showSuccess(t('forum', 'Post updated'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to update post', e)
|
||||
@@ -523,7 +523,7 @@ export default defineComponent({
|
||||
)
|
||||
|
||||
if (response.data?.success && response.data.categorySlug) {
|
||||
showSuccess(t('forum', 'Thread deleted successfully'))
|
||||
showSuccess(t('forum', 'Thread deleted'))
|
||||
// Navigate to the category
|
||||
this.$router.push(`/c/${response.data.categorySlug}`)
|
||||
}
|
||||
@@ -537,7 +537,7 @@ export default defineComponent({
|
||||
this.posts.splice(index, 1)
|
||||
}
|
||||
|
||||
showSuccess(t('forum', 'Post deleted successfully'))
|
||||
showSuccess(t('forum', 'Post deleted'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete post', e)
|
||||
|
||||
@@ -121,7 +121,7 @@ export default defineComponent({
|
||||
title: t('forum', 'Preferences'),
|
||||
subtitle: t('forum', 'Customize your forum experience'),
|
||||
back: t('forum', 'Back'),
|
||||
loading: t('forum', 'Loading preferences…'),
|
||||
loading: t('forum', 'Loading preferences …'),
|
||||
errorTitle: t('forum', 'Error loading preferences'),
|
||||
retry: t('forum', 'Retry'),
|
||||
subscriptionsTitle: t('forum', 'Notifications'),
|
||||
@@ -133,7 +133,7 @@ export default defineComponent({
|
||||
),
|
||||
save: t('forum', 'Save'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
saveSuccess: t('forum', 'Preferences saved successfully'),
|
||||
saveSuccess: t('forum', 'Preferences saved'),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -332,10 +332,10 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'BBCode Management'),
|
||||
title: t('forum', 'BBCode management'),
|
||||
subtitle: t('forum', 'Manage custom BBCode tags for post formatting'),
|
||||
help: t('forum', 'BBCode Help'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error loading BBCodes'),
|
||||
retry: t('forum', 'Retry'),
|
||||
createBBCode: t('forum', 'Create BBCode'),
|
||||
@@ -361,7 +361,7 @@ export default defineComponent({
|
||||
tag: t('forum', 'Tag'),
|
||||
tagPlaceholder: t('forum', 'e.g., b, i, url, color'),
|
||||
tagHelp: t('forum', 'The BBCode tag name (without brackets)'),
|
||||
replacementLabel: t('forum', 'HTML Replacement'),
|
||||
replacementLabel: t('forum', 'HTML replacement'),
|
||||
replacementPlaceholder: t(
|
||||
'forum',
|
||||
'e.g., {strongStart}{content}{strongEnd}',
|
||||
@@ -381,7 +381,7 @@ export default defineComponent({
|
||||
description: t('forum', 'Description'),
|
||||
descriptionPlaceholder: t('forum', 'Brief description of what this BBCode does'),
|
||||
enabledLabel: t('forum', 'Enabled'),
|
||||
parseInnerLabel: t('forum', 'Parse Inner Content'),
|
||||
parseInnerLabel: t('forum', 'Parse inner content'),
|
||||
parseInnerHelp: t('forum', 'If enabled, BBCode tags inside this tag will also be parsed'),
|
||||
update: t('forum', 'Update'),
|
||||
create: t('forum', 'Create'),
|
||||
|
||||
@@ -207,6 +207,7 @@ import PencilIcon from '@icons/Pencil.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Category, CatHeader, Role } from '@/types'
|
||||
import { SystemRole } from '@/constants'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AdminCategoryEdit',
|
||||
@@ -252,19 +253,19 @@ export default defineComponent({
|
||||
|
||||
strings: {
|
||||
back: t('forum', 'Back'),
|
||||
createCategory: t('forum', 'Create Category'),
|
||||
editCategory: t('forum', 'Edit Category'),
|
||||
createCategory: t('forum', 'Create category'),
|
||||
editCategory: t('forum', 'Edit category'),
|
||||
subtitle: t('forum', 'Configure category details'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error loading category'),
|
||||
retry: t('forum', 'Retry'),
|
||||
basicInfo: t('forum', 'Basic Information'),
|
||||
categoryHeader: t('forum', 'Category Header'),
|
||||
basicInfo: t('forum', 'Basic information'),
|
||||
categoryHeader: t('forum', 'Category header'),
|
||||
selectHeader: t('forum', '-- Select a header --'),
|
||||
name: t('forum', 'Name'),
|
||||
namePlaceholder: t('forum', 'Enter category name'),
|
||||
slug: t('forum', 'Slug'),
|
||||
slugPlaceholder: t('forum', 'category-slug'),
|
||||
slugPlaceholder: 'category-slug',
|
||||
slugHelp: t('forum', 'URL-friendly identifier (e.g., "{slug}")', {
|
||||
slug: 'general-discussion',
|
||||
}),
|
||||
@@ -278,13 +279,13 @@ export default defineComponent({
|
||||
update: t('forum', 'Update'),
|
||||
newHeader: t('forum', 'New'),
|
||||
editHeader: t('forum', 'Edit'),
|
||||
createHeaderTitle: t('forum', 'Create Category Header'),
|
||||
editHeaderTitle: t('forum', 'Edit Category Header'),
|
||||
headerName: t('forum', 'Header Name'),
|
||||
createHeaderTitle: t('forum', 'Create category header'),
|
||||
editHeaderTitle: t('forum', 'Edit category header'),
|
||||
headerName: t('forum', 'Header name'),
|
||||
headerNamePlaceholder: t('forum', 'Enter header name'),
|
||||
headerDescription: t('forum', 'Header Description'),
|
||||
headerDescription: t('forum', 'Header description'),
|
||||
headerDescriptionPlaceholder: t('forum', 'Enter header description (optional)'),
|
||||
headerSortOrder: t('forum', 'Sort Order'),
|
||||
headerSortOrder: t('forum', 'Sort order'),
|
||||
permissions: t('forum', 'Permissions'),
|
||||
permissionsDescription: t(
|
||||
'forum',
|
||||
@@ -297,7 +298,7 @@ export default defineComponent({
|
||||
'forum',
|
||||
'Select roles that can moderate (edit/delete) content in this category',
|
||||
),
|
||||
selectRoles: t('forum', 'Select roles...'),
|
||||
selectRoles: t('forum', 'Select roles …'),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -322,10 +323,13 @@ export default defineComponent({
|
||||
}))
|
||||
},
|
||||
roleOptions(): Array<{ id: number; label: string }> {
|
||||
return this.roles.map((role) => ({
|
||||
id: role.id,
|
||||
label: role.name,
|
||||
}))
|
||||
// Filter out Admin role - it has implicit full access to all categories
|
||||
return this.roles
|
||||
.filter((role) => role.id !== SystemRole.ADMIN)
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
label: role.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -380,15 +384,15 @@ export default defineComponent({
|
||||
await this.loadPermissions()
|
||||
} else {
|
||||
// When creating a new category, prefill with default roles
|
||||
// View: Member (role ID 3)
|
||||
const memberRole = this.roles.find((r) => r.id === 3)
|
||||
// View: User role
|
||||
const memberRole = this.roles.find((r) => r.id === SystemRole.USER)
|
||||
if (memberRole) {
|
||||
this.selectedViewRoles = [{ id: memberRole.id, label: memberRole.name }]
|
||||
}
|
||||
|
||||
// Moderate: Admin (ID 1) and Moderator (ID 2)
|
||||
const adminRole = this.roles.find((r) => r.id === 1)
|
||||
const moderatorRole = this.roles.find((r) => r.id === 2)
|
||||
// Moderate: Admin and Moderator
|
||||
const adminRole = this.roles.find((r) => r.id === SystemRole.ADMIN)
|
||||
const moderatorRole = this.roles.find((r) => r.id === SystemRole.MODERATOR)
|
||||
this.selectedModerateRoles = []
|
||||
if (adminRole) {
|
||||
this.selectedModerateRoles.push({ id: adminRole.id, label: adminRole.name })
|
||||
|
||||
@@ -435,21 +435,21 @@ export default defineComponent({
|
||||
strings: {
|
||||
title: t('forum', 'Categories'),
|
||||
subtitle: t('forum', 'Manage forum categories and organization'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error loading categories'),
|
||||
retry: t('forum', 'Retry'),
|
||||
createCategory: t('forum', 'Create Category'),
|
||||
createCategory: t('forum', 'Create category'),
|
||||
edit: t('forum', 'Edit'),
|
||||
delete: t('forum', 'Delete'),
|
||||
noCategories: t('forum', 'No categories in this header'),
|
||||
deleteDialogTitle: t('forum', 'Delete Category'),
|
||||
deleteDialogTitle: t('forum', 'Delete category'),
|
||||
deleteConfirmMessage: (name: string) =>
|
||||
t('forum', `Are you sure you want to delete the category "{name}"?`, { name }),
|
||||
threadWarning: (count: number) =>
|
||||
n(
|
||||
'forum',
|
||||
'This category contains %d thread.',
|
||||
'This category contains %d threads.',
|
||||
'This category contains %n thread.',
|
||||
'This category contains %n threads.',
|
||||
count,
|
||||
),
|
||||
whatToDoWithThreads: t('forum', 'What should happen to the threads?'),
|
||||
@@ -459,18 +459,18 @@ export default defineComponent({
|
||||
selectTargetCategory: t('forum', 'Select target category'),
|
||||
selectCategory: t('forum', '-- Select a category --'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
deleteCategory: t('forum', 'Delete Category'),
|
||||
createHeader: t('forum', 'Create Header'),
|
||||
categoriesCount: (count: number) => n('forum', '%d category', '%d categories', count),
|
||||
threadsCount: (count: number) => n('forum', '%d thread', '%d threads', count),
|
||||
postsCount: (count: number) => n('forum', '%d post', '%d posts', count),
|
||||
createHeaderTitle: t('forum', 'Create Category Header'),
|
||||
editHeaderTitle: t('forum', 'Edit Category Header'),
|
||||
headerName: t('forum', 'Header Name'),
|
||||
deleteCategory: t('forum', 'Delete category'),
|
||||
createHeader: t('forum', 'Create header'),
|
||||
categoriesCount: (count: number) => n('forum', '%n category', '%n categories', count),
|
||||
threadsCount: (count: number) => n('forum', '%n thread', '%n threads', count),
|
||||
postsCount: (count: number) => n('forum', '%n post', '%n posts', count),
|
||||
createHeaderTitle: t('forum', 'Create category header'),
|
||||
editHeaderTitle: t('forum', 'Edit category header'),
|
||||
headerName: t('forum', 'Header name'),
|
||||
headerNamePlaceholder: t('forum', 'Enter header name'),
|
||||
headerDescription: t('forum', 'Header Description'),
|
||||
headerDescription: t('forum', 'Header description'),
|
||||
headerDescriptionPlaceholder: t('forum', 'Enter header description (optional)'),
|
||||
headerSortOrder: t('forum', 'Sort Order'),
|
||||
headerSortOrder: t('forum', 'Sort order'),
|
||||
sortOrderPlaceholder: t('forum', '0'),
|
||||
sortOrderHelp: t('forum', 'Lower numbers appear first'),
|
||||
update: t('forum', 'Update'),
|
||||
@@ -481,8 +481,8 @@ export default defineComponent({
|
||||
headerCategoryWarning: (count: number) =>
|
||||
n(
|
||||
'forum',
|
||||
'This header contains %d category.',
|
||||
'This header contains %d categories.',
|
||||
'This header contains %n category.',
|
||||
'This header contains %n categories.',
|
||||
count,
|
||||
),
|
||||
deleteHeaderHelp: t('forum', 'This action cannot be undone'),
|
||||
|
||||
@@ -231,24 +231,24 @@ export default defineComponent({
|
||||
error: null as string | null,
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'Admin Dashboard'),
|
||||
title: t('forum', 'Admin dashboard'),
|
||||
subtitle: t('forum', 'Overview of forum activity and statistics'),
|
||||
loading: t('forum', 'Loading statistics…'),
|
||||
loading: t('forum', 'Loading statistics …'),
|
||||
errorTitle: t('forum', 'Error loading dashboard'),
|
||||
retry: t('forum', 'Retry'),
|
||||
totals: t('forum', 'Total Statistics'),
|
||||
totalUsers: t('forum', 'Total Users'),
|
||||
totalThreads: t('forum', 'Total Threads'),
|
||||
totalPosts: t('forum', 'Total Posts'),
|
||||
totalCategories: t('forum', 'Total Categories'),
|
||||
totals: t('forum', 'Total statistics'),
|
||||
totalUsers: t('forum', 'Total users'),
|
||||
totalThreads: t('forum', 'Total threads'),
|
||||
totalPosts: t('forum', 'Total posts'),
|
||||
totalCategories: t('forum', 'Total categories'),
|
||||
recentActivity: t('forum', 'Recent Activity (Last 7 Days)'),
|
||||
newUsers: t('forum', 'New Users'),
|
||||
newThreads: t('forum', 'New Threads'),
|
||||
newPosts: t('forum', 'New Posts'),
|
||||
topContributors: t('forum', 'Top Contributors'),
|
||||
newUsers: t('forum', 'New users'),
|
||||
newThreads: t('forum', 'New threads'),
|
||||
newPosts: t('forum', 'New posts'),
|
||||
topContributors: t('forum', 'Top contributors'),
|
||||
noContributors: t('forum', 'No contributors yet'),
|
||||
last7Days: t('forum', 'Last 7 Days'),
|
||||
allTime: t('forum', 'All Time'),
|
||||
last7Days: t('forum', 'Last 7 days'),
|
||||
allTime: t('forum', 'All time'),
|
||||
threadsCount: (count: number) => n('forum', '%n thread', '%n threads', count),
|
||||
postsCount: (count: number) => n('forum', '%n post', '%n posts', count),
|
||||
},
|
||||
|
||||
@@ -121,22 +121,22 @@ export default defineComponent({
|
||||
} as Settings,
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'General Settings'),
|
||||
title: t('forum', 'General settings'),
|
||||
subtitle: t('forum', 'Configure general forum settings'),
|
||||
loading: t('forum', 'Loading settings…'),
|
||||
loading: t('forum', 'Loading settings …'),
|
||||
errorTitle: t('forum', 'Error loading settings'),
|
||||
retry: t('forum', 'Retry'),
|
||||
appearanceTitle: t('forum', 'Appearance'),
|
||||
appearanceDesc: t('forum', 'Customize how your forum looks to users'),
|
||||
forumTitle: t('forum', 'Forum Title'),
|
||||
forumTitle: t('forum', 'Forum title'),
|
||||
forumTitlePlaceholder: t('forum', 'Forum'),
|
||||
forumTitleHint: t('forum', 'Displayed at the top of the forum home page'),
|
||||
forumSubtitle: t('forum', 'Forum Subtitle'),
|
||||
forumSubtitle: t('forum', 'Forum subtitle'),
|
||||
forumSubtitlePlaceholder: t('forum', 'Welcome to the forum'),
|
||||
forumSubtitleHint: t('forum', 'A brief description shown below the title'),
|
||||
save: t('forum', 'Save'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
saveSuccess: t('forum', 'Settings saved successfully'),
|
||||
saveSuccess: t('forum', 'Settings saved'),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -242,7 +242,7 @@ export default defineComponent({
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
> p {
|
||||
>p {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -115,21 +115,21 @@
|
||||
|
||||
<div class="permissions-checkboxes">
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools" :disabled="isAdmin">
|
||||
<strong>{{ strings.canAccessAdminTools }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditRoles" :disabled="isAdmin">
|
||||
<strong>{{ strings.canEditRoles }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditCategories" :disabled="isAdmin">
|
||||
<strong>{{ strings.canEditCategories }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
@@ -223,6 +223,7 @@ import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Role, CategoryHeader } from '@/types'
|
||||
import { SystemRole, isSystemRole } from '@/constants'
|
||||
|
||||
interface CategoryPermission {
|
||||
canView: boolean
|
||||
@@ -265,13 +266,13 @@ export default defineComponent({
|
||||
|
||||
strings: {
|
||||
back: t('forum', 'Back'),
|
||||
createRole: t('forum', 'Create Role'),
|
||||
editRole: t('forum', 'Edit Role'),
|
||||
createRole: t('forum', 'Create role'),
|
||||
editRole: t('forum', 'Edit role'),
|
||||
subtitle: t('forum', 'Configure role permissions and category access'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
loading: t('forum', 'Loading …'),
|
||||
errorTitle: t('forum', 'Error loading role'),
|
||||
retry: t('forum', 'Retry'),
|
||||
basicInfo: t('forum', 'Basic Information'),
|
||||
basicInfo: t('forum', 'Basic information'),
|
||||
name: t('forum', 'Name'),
|
||||
description: t('forum', 'Description'),
|
||||
namePlaceholder: t('forum', 'Enter role name'),
|
||||
@@ -279,24 +280,24 @@ export default defineComponent({
|
||||
systemRoleNameWarning: t('forum', 'System role names cannot be changed'),
|
||||
colors: t('forum', 'Colors'),
|
||||
colorsDesc: t('forum', 'Set colors for this role badge'),
|
||||
colorLight: t('forum', 'Light Mode Color'),
|
||||
colorDark: t('forum', 'Dark Mode Color'),
|
||||
colorLightPlaceholder: t('forum', '#000000'),
|
||||
colorDarkPlaceholder: t('forum', '#ffffff'),
|
||||
colorLight: t('forum', 'Light mode color'),
|
||||
colorDark: t('forum', 'Dark mode color'),
|
||||
colorLightPlaceholder: '#000000',
|
||||
colorDarkPlaceholder: '#ffffff',
|
||||
reset: t('forum', 'Reset'),
|
||||
rolePermissions: t('forum', 'Role Permissions'),
|
||||
rolePermissionsDesc: t('forum', 'Set global permissions for this role'),
|
||||
canAccessAdminTools: t('forum', 'Can Access Admin Tools'),
|
||||
canAccessAdminToolsDesc: t('forum', 'Allow access to the admin dashboard and tools'),
|
||||
canEditRoles: t('forum', 'Can Edit Roles'),
|
||||
canEditRolesDesc: t('forum', 'Allow creating, editing, and deleting roles'),
|
||||
canEditCategories: t('forum', 'Can Edit Categories'),
|
||||
canEditCategoriesDesc: t('forum', 'Allow creating, editing, and deleting categories'),
|
||||
categoryPermissions: t('forum', 'Category Permissions'),
|
||||
canEditRoles: t('forum', 'Can edit roles'),
|
||||
canEditRolesDesc: t('forum', 'Allow creating, editing and deleting roles'),
|
||||
canEditCategories: t('forum', 'Can edit categories'),
|
||||
canEditCategoriesDesc: t('forum', 'Allow creating, editing and deleting categories'),
|
||||
categoryPermissions: t('forum', 'Category permissions'),
|
||||
categoryPermissionsDesc: t('forum', 'Set which categories this role can access'),
|
||||
category: t('forum', 'Category'),
|
||||
canView: t('forum', 'Can View'),
|
||||
canModerate: t('forum', 'Can Moderate'),
|
||||
canView: t('forum', 'Can view'),
|
||||
canModerate: t('forum', 'Can moderate'),
|
||||
allow: t('forum', 'Allow'),
|
||||
noCategories: t('forum', 'No categories available'),
|
||||
adminFullAccess: t('forum', 'Admin role has full access to all categories'),
|
||||
@@ -314,12 +315,12 @@ export default defineComponent({
|
||||
return this.$route.params.id ? parseInt(this.$route.params.id as string) : null
|
||||
},
|
||||
isSystemRole(): boolean {
|
||||
// System roles (Admin, Moderator, Member) - only name is locked
|
||||
return this.roleId !== null && this.roleId <= 3
|
||||
// System roles (Admin, Moderator, User) - only name is locked
|
||||
return this.roleId !== null && isSystemRole(this.roleId)
|
||||
},
|
||||
isAdmin(): boolean {
|
||||
// Admin role (ID 1) has full access to everything
|
||||
return this.roleId === 1
|
||||
// Admin role has full access to everything
|
||||
return this.roleId === SystemRole.ADMIN
|
||||
},
|
||||
canSubmit(): boolean {
|
||||
return this.formData.name.trim().length > 0
|
||||
@@ -398,6 +399,13 @@ export default defineComponent({
|
||||
this.formData.canEditRoles = role.canEditRoles || false
|
||||
this.formData.canEditCategories = role.canEditCategories || false
|
||||
|
||||
// Admin role always has all permissions
|
||||
if (this.isAdmin) {
|
||||
this.formData.canAccessAdminTools = true
|
||||
this.formData.canEditRoles = true
|
||||
this.formData.canEditCategories = true
|
||||
}
|
||||
|
||||
// If colors are different, mark dark as modified
|
||||
if (role.colorLight && role.colorDark && role.colorLight !== role.colorDark) {
|
||||
this.darkColorModified = true
|
||||
@@ -451,9 +459,9 @@ export default defineComponent({
|
||||
description: this.formData.description.trim() || null,
|
||||
colorLight: this.formData.colorLight || null,
|
||||
colorDark: this.formData.colorDark || null,
|
||||
canAccessAdminTools: this.formData.canAccessAdminTools,
|
||||
canEditRoles: this.formData.canEditRoles,
|
||||
canEditCategories: this.formData.canEditCategories,
|
||||
canAccessAdminTools: this.isAdmin ? true : this.formData.canAccessAdminTools,
|
||||
canEditRoles: this.isAdmin ? true : this.formData.canEditRoles,
|
||||
canEditCategories: this.isAdmin ? true : this.formData.canEditCategories,
|
||||
}
|
||||
|
||||
let roleId: number
|
||||
|
||||
@@ -117,6 +117,7 @@ import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Role } from '@/types'
|
||||
import { SystemRole, isSystemRole } from '@/constants'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AdminRoleList',
|
||||
@@ -141,16 +142,17 @@ export default defineComponent({
|
||||
loading: false,
|
||||
roles: [] as Role[],
|
||||
error: null as string | null,
|
||||
SystemRole, // Expose SystemRole constant to template
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'Role Management'),
|
||||
title: t('forum', 'Role management'),
|
||||
subtitle: t('forum', 'Create and manage forum roles and permissions'),
|
||||
loading: t('forum', 'Loading roles…'),
|
||||
loading: t('forum', 'Loading roles …'),
|
||||
errorTitle: t('forum', 'Error loading roles'),
|
||||
retry: t('forum', 'Retry'),
|
||||
emptyTitle: t('forum', 'No roles found'),
|
||||
emptyDesc: t('forum', 'Create your first role to get started'),
|
||||
createRole: t('forum', 'Create Role'),
|
||||
createRole: t('forum', 'Create role'),
|
||||
id: t('forum', 'ID'),
|
||||
name: t('forum', 'Name'),
|
||||
description: t('forum', 'Description'),
|
||||
@@ -198,10 +200,7 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
isSystemRole(roleId: number): boolean {
|
||||
// System roles (Admin, Moderator, Member) cannot be deleted
|
||||
return roleId <= 3
|
||||
},
|
||||
isSystemRole,
|
||||
|
||||
createRole(): void {
|
||||
this.$router.push('/admin/roles/create')
|
||||
|
||||
@@ -189,9 +189,9 @@ export default defineComponent({
|
||||
originalRoles: [] as number[],
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'User Management'),
|
||||
subtitle: t('forum', 'Manage forum users, roles, and permissions'),
|
||||
loading: t('forum', 'Loading users…'),
|
||||
title: t('forum', 'User management'),
|
||||
subtitle: t('forum', 'Manage forum users, roles and permissions'),
|
||||
loading: t('forum', 'Loading users …'),
|
||||
errorTitle: t('forum', 'Error loading users'),
|
||||
retry: t('forum', 'Retry'),
|
||||
emptyTitle: t('forum', 'No users found'),
|
||||
@@ -207,7 +207,7 @@ export default defineComponent({
|
||||
noRoles: t('forum', 'No roles'),
|
||||
selectRoles: t('forum', 'Select roles'),
|
||||
editRoles: t('forum', 'Edit roles'),
|
||||
editRolesTitle: t('forum', 'Edit User Roles'),
|
||||
editRolesTitle: t('forum', 'Edit user roles'),
|
||||
save: t('forum', 'Save'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
},
|
||||
|
||||
14
tests/phpunit.docker.xml
Normal file
14
tests/phpunit.docker.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
bootstrap="bootstrap.php"
|
||||
timeoutForSmallTests="900"
|
||||
timeoutForMediumTests="900"
|
||||
timeoutForLargeTests="900"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Forum Tests">
|
||||
<directory suffix="Test.php">.</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
bootstrap="bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Forum Tests">
|
||||
<directory suffix="Test.php">.</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">../appinfo</directory>
|
||||
<directory suffix=".php">../lib</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -1,14 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="bootstrap.php" timeoutForSmallTests="900" timeoutForMediumTests="900" timeoutForLargeTests="900" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
|
||||
<coverage>
|
||||
<include>
|
||||
<directory suffix=".php">../appinfo</directory>
|
||||
<directory suffix=".php">../lib</directory>
|
||||
</include>
|
||||
</coverage>
|
||||
<testsuites>
|
||||
<testsuite name="Forum Tests">
|
||||
<directory suffix="Test.php">.</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
bootstrap="bootstrap.php"
|
||||
timeoutForSmallTests="900"
|
||||
timeoutForMediumTests="900"
|
||||
timeoutForLargeTests="900"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Forum Tests">
|
||||
<directory suffix="Test.php">.</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">../appinfo</directory>
|
||||
<directory suffix=".php">../lib</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
|
||||
@@ -8,15 +8,19 @@ use OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Controller\CategoryController;
|
||||
use OCA\Forum\Db\Category;
|
||||
use OCA\Forum\Db\CategoryMapper;
|
||||
use OCA\Forum\Db\CategoryPerm;
|
||||
use OCA\Forum\Db\CategoryPermMapper;
|
||||
use OCA\Forum\Db\CatHeader;
|
||||
use OCA\Forum\Db\CatHeaderMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\UserRole;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -394,6 +398,268 @@ class CategoryControllerTest extends TestCase {
|
||||
$this->assertEquals(['error' => 'Target category not found'], $response->getData());
|
||||
}
|
||||
|
||||
public function testCheckPermissionReturnsTrue(): void {
|
||||
$categoryId = 1;
|
||||
$permission = 'canView';
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
// User is not an admin
|
||||
$this->groupManager->method('get')->with('admin')->willReturn(null);
|
||||
|
||||
// User has a role
|
||||
$userRole = new UserRole();
|
||||
$userRole->setId(1);
|
||||
$userRole->setUserId($userId);
|
||||
$userRole->setRoleId(1);
|
||||
|
||||
$this->userRoleMapper->expects($this->once())
|
||||
->method('findByUserId')
|
||||
->with($userId)
|
||||
->willReturn([$userRole]);
|
||||
|
||||
// Category permission allows viewing
|
||||
$categoryPerm = new CategoryPerm();
|
||||
$categoryPerm->setId(1);
|
||||
$categoryPerm->setCategoryId($categoryId);
|
||||
$categoryPerm->setRoleId(1);
|
||||
$categoryPerm->setCanView(true);
|
||||
$categoryPerm->setCanPost(false);
|
||||
$categoryPerm->setCanReply(false);
|
||||
$categoryPerm->setCanModerate(false);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByCategoryAndRoles')
|
||||
->with($categoryId, [1])
|
||||
->willReturn([$categoryPerm]);
|
||||
|
||||
$response = $this->controller->checkPermission($categoryId, $permission);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['hasPermission']);
|
||||
}
|
||||
|
||||
public function testCheckPermissionReturnsFalseWhenNoPermission(): void {
|
||||
$categoryId = 1;
|
||||
$permission = 'canModerate';
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->groupManager->method('get')->with('admin')->willReturn(null);
|
||||
|
||||
$userRole = new UserRole();
|
||||
$userRole->setId(1);
|
||||
$userRole->setUserId($userId);
|
||||
$userRole->setRoleId(1);
|
||||
|
||||
$this->userRoleMapper->expects($this->once())
|
||||
->method('findByUserId')
|
||||
->willReturn([$userRole]);
|
||||
|
||||
// Category permission does not allow moderating
|
||||
$categoryPerm = new CategoryPerm();
|
||||
$categoryPerm->setId(1);
|
||||
$categoryPerm->setCategoryId($categoryId);
|
||||
$categoryPerm->setRoleId(1);
|
||||
$categoryPerm->setCanView(true);
|
||||
$categoryPerm->setCanPost(false);
|
||||
$categoryPerm->setCanReply(false);
|
||||
$categoryPerm->setCanModerate(false);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByCategoryAndRoles')
|
||||
->with($categoryId, [1])
|
||||
->willReturn([$categoryPerm]);
|
||||
|
||||
$response = $this->controller->checkPermission($categoryId, $permission);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertFalse($data['hasPermission']);
|
||||
}
|
||||
|
||||
public function testCheckPermissionReturnsTrueForAdmin(): void {
|
||||
$categoryId = 1;
|
||||
$permission = 'canModerate';
|
||||
$userId = 'admin1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
// User is in admin group
|
||||
$adminGroup = $this->createMock(IGroup::class);
|
||||
$adminGroup->method('inGroup')->with($user)->willReturn(true);
|
||||
$this->groupManager->method('get')->with('admin')->willReturn($adminGroup);
|
||||
|
||||
$response = $this->controller->checkPermission($categoryId, $permission);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['hasPermission']);
|
||||
}
|
||||
|
||||
public function testGetPermissionsReturnsPermissionsSuccessfully(): void {
|
||||
$categoryId = 1;
|
||||
|
||||
// Note: Only non-admin roles (2, 3) are returned - Admin role is excluded
|
||||
$perm1 = new CategoryPerm();
|
||||
$perm1->setId(1);
|
||||
$perm1->setCategoryId($categoryId);
|
||||
$perm1->setRoleId(2);
|
||||
$perm1->setCanView(true);
|
||||
$perm1->setCanPost(true);
|
||||
$perm1->setCanReply(true);
|
||||
$perm1->setCanModerate(false);
|
||||
|
||||
$perm2 = new CategoryPerm();
|
||||
$perm2->setId(2);
|
||||
$perm2->setCategoryId($categoryId);
|
||||
$perm2->setRoleId(3);
|
||||
$perm2->setCanView(true);
|
||||
$perm2->setCanPost(false);
|
||||
$perm2->setCanReply(false);
|
||||
$perm2->setCanModerate(false);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByCategoryIdExcludingAdmin')
|
||||
->with($categoryId)
|
||||
->willReturn([$perm1, $perm2]);
|
||||
|
||||
$response = $this->controller->getPermissions($categoryId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(2, $data);
|
||||
$this->assertEquals(2, $data[0]['roleId']);
|
||||
$this->assertTrue($data[0]['canView']);
|
||||
$this->assertFalse($data[0]['canModerate']);
|
||||
}
|
||||
|
||||
public function testUpdatePermissionsSuccessfully(): void {
|
||||
$categoryId = 1;
|
||||
$permissions = [
|
||||
['roleId' => 2, 'canView' => true, 'canModerate' => false],
|
||||
['roleId' => 3, 'canView' => true, 'canModerate' => true],
|
||||
];
|
||||
|
||||
$category = $this->createCategory($categoryId, 1, 'Test Category');
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($categoryId)
|
||||
->willReturn($category);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('deleteByCategoryId')
|
||||
->with($categoryId);
|
||||
|
||||
$this->categoryPermMapper->expects($this->exactly(2))
|
||||
->method('insert')
|
||||
->willReturnCallback(function ($perm) {
|
||||
return $perm;
|
||||
});
|
||||
|
||||
$response = $this->controller->updatePermissions($categoryId, $permissions);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['success']);
|
||||
}
|
||||
|
||||
public function testUpdatePermissionsFiltersOutAdminRole(): void {
|
||||
$categoryId = 1;
|
||||
$permissions = [
|
||||
['roleId' => 1, 'canView' => true, 'canModerate' => true], // Admin - should be filtered
|
||||
['roleId' => 2, 'canView' => true, 'canModerate' => false],
|
||||
['roleId' => 3, 'canView' => true, 'canModerate' => false],
|
||||
];
|
||||
|
||||
$category = $this->createCategory($categoryId, 1, 'Test Category');
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($categoryId)
|
||||
->willReturn($category);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('deleteByCategoryId')
|
||||
->with($categoryId);
|
||||
|
||||
// Should only insert 2 permissions (Admin role ID 1 is filtered out)
|
||||
$this->categoryPermMapper->expects($this->exactly(2))
|
||||
->method('insert')
|
||||
->willReturnCallback(function ($perm) {
|
||||
// Verify that Admin role (ID 1) is never inserted
|
||||
$this->assertNotEquals(1, $perm->getRoleId());
|
||||
return $perm;
|
||||
});
|
||||
|
||||
$response = $this->controller->updatePermissions($categoryId, $permissions);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['success']);
|
||||
}
|
||||
|
||||
public function testUpdatePermissionsReturnsNotFoundWhenCategoryDoesNotExist(): void {
|
||||
$categoryId = 999;
|
||||
$permissions = [
|
||||
['roleId' => 1, 'canView' => true, 'canModerate' => false],
|
||||
];
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($categoryId)
|
||||
->willThrowException(new DoesNotExistException('Category not found'));
|
||||
|
||||
$response = $this->controller->updatePermissions($categoryId, $permissions);
|
||||
|
||||
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
|
||||
$this->assertEquals(['error' => 'Category not found'], $response->getData());
|
||||
}
|
||||
|
||||
public function testReorderUpdatesCategories(): void {
|
||||
$categories = [
|
||||
['id' => 1, 'sortOrder' => 2],
|
||||
['id' => 2, 'sortOrder' => 1],
|
||||
];
|
||||
|
||||
$category1 = $this->createCategory(1, 1, 'Category 1');
|
||||
$category2 = $this->createCategory(2, 1, 'Category 2');
|
||||
|
||||
$this->categoryMapper->expects($this->exactly(2))
|
||||
->method('find')
|
||||
->willReturnCallback(function ($id) use ($category1, $category2) {
|
||||
return $id === 1 ? $category1 : $category2;
|
||||
});
|
||||
|
||||
$this->categoryMapper->expects($this->exactly(2))
|
||||
->method('update')
|
||||
->willReturnCallback(function ($category) use ($categories) {
|
||||
if ($category->getId() === 1) {
|
||||
$this->assertEquals(2, $category->getSortOrder());
|
||||
} else {
|
||||
$this->assertEquals(1, $category->getSortOrder());
|
||||
}
|
||||
return $category;
|
||||
});
|
||||
|
||||
$response = $this->controller->reorder($categories);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['success']);
|
||||
}
|
||||
|
||||
private function createCatHeader(int $id, string $name): CatHeader {
|
||||
$header = new CatHeader();
|
||||
$header->setId($id);
|
||||
|
||||
@@ -545,4 +545,211 @@ class PostControllerTest extends TestCase {
|
||||
$reaction->setCreatedAt(time());
|
||||
return $reaction;
|
||||
}
|
||||
|
||||
public function testCreatePostIncrementsThreadPostCount(): void {
|
||||
$threadId = 1;
|
||||
$content = 'New reply post content';
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$thread = new Thread();
|
||||
$thread->setId($threadId);
|
||||
$thread->setCategoryId(1);
|
||||
$thread->setPostCount(5); // Thread has 5 replies
|
||||
|
||||
$category = new Category();
|
||||
$category->setId(1);
|
||||
$category->setPostCount(10); // Category has 10 total replies
|
||||
|
||||
$createdPost = $this->createMockPost(1, $threadId, $userId, $content);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->willReturn($createdPost);
|
||||
|
||||
$this->readMarkerMapper->method('createOrUpdate');
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($thread);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
// Thread post count should be incremented from 5 to 6
|
||||
$this->assertEquals(6, $updatedThread->getPostCount());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($category);
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedCategory) {
|
||||
// Category post count should be incremented from 10 to 11
|
||||
$this->assertEquals(11, $updatedCategory->getPostCount());
|
||||
return $updatedCategory;
|
||||
});
|
||||
|
||||
$this->userStatsMapper->expects($this->once())
|
||||
->method('incrementPostCount')
|
||||
->with($userId);
|
||||
|
||||
$this->notificationService->method('notifyThreadSubscribers');
|
||||
|
||||
$response = $this->controller->create($threadId, $content);
|
||||
|
||||
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testDestroyPostDecrementsThreadPostCount(): void {
|
||||
$postId = 1;
|
||||
$userId = 'user1';
|
||||
$post = $this->createMockPost($postId, 1, $userId, 'Test content');
|
||||
$post->setIsFirstPost(false); // Regular reply post
|
||||
|
||||
$thread = new Thread();
|
||||
$thread->setId(1);
|
||||
$thread->setCategoryId(1);
|
||||
$thread->setPostCount(5); // Thread has 5 replies
|
||||
$thread->setLastPostId($postId); // This post is the last post
|
||||
|
||||
$category = new Category();
|
||||
$category->setId(1);
|
||||
$category->setPostCount(10); // Category has 10 total replies
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->permissionService->method('getCategoryIdFromPost')->willReturn(1);
|
||||
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($post);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedPost) {
|
||||
$this->assertNotNull($updatedPost->getDeletedAt());
|
||||
return $updatedPost;
|
||||
});
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($thread);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
// Thread post count should be decremented from 5 to 4
|
||||
$this->assertEquals(4, $updatedThread->getPostCount());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
// Mock finding the last post (not the deleted one)
|
||||
$lastPost = $this->createMockPost(2, 1, $userId, 'Last post');
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('findLatestByThreadId')
|
||||
->with(1, $postId)
|
||||
->willReturn($lastPost);
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($category);
|
||||
|
||||
$this->categoryMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedCategory) {
|
||||
// Category post count should be decremented from 10 to 9
|
||||
$this->assertEquals(9, $updatedCategory->getPostCount());
|
||||
return $updatedCategory;
|
||||
});
|
||||
|
||||
$this->userStatsMapper->expects($this->once())
|
||||
->method('decrementPostCount')
|
||||
->with($userId);
|
||||
|
||||
$response = $this->controller->destroy($postId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testDestroyFirstPostDecrementsThreadCount(): void {
|
||||
$postId = 1;
|
||||
$userId = 'user1';
|
||||
$post = $this->createMockPost($postId, 1, $userId, 'First post content');
|
||||
$post->setIsFirstPost(true); // First post
|
||||
|
||||
$thread = new Thread();
|
||||
$thread->setId(1);
|
||||
$thread->setCategoryId(1);
|
||||
$thread->setPostCount(3); // Thread has 3 replies
|
||||
$thread->setLastPostId($postId); // This post is the last post
|
||||
|
||||
$category = new Category();
|
||||
$category->setId(1);
|
||||
$category->setPostCount(10); // Category has 10 total replies
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->permissionService->method('getCategoryIdFromPost')->willReturn(1);
|
||||
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($post);
|
||||
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedPost) {
|
||||
$this->assertNotNull($updatedPost->getDeletedAt());
|
||||
return $updatedPost;
|
||||
});
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->willReturn($thread);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
// Thread post count should stay at 3 (first posts don't count)
|
||||
$this->assertEquals(3, $updatedThread->getPostCount());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
$lastPost = $this->createMockPost(2, 1, $userId, 'Last post');
|
||||
$this->postMapper->expects($this->once())
|
||||
->method('findLatestByThreadId')
|
||||
->with(1, $postId)
|
||||
->willReturn($lastPost);
|
||||
|
||||
// Category mapper should not be called for first post deletion
|
||||
$this->categoryMapper->expects($this->never())
|
||||
->method('find');
|
||||
|
||||
$this->categoryMapper->expects($this->never())
|
||||
->method('update');
|
||||
|
||||
// First post deletion should decrement thread count, not post count
|
||||
$this->userStatsMapper->expects($this->once())
|
||||
->method('decrementThreadCount')
|
||||
->with($userId);
|
||||
|
||||
$this->userStatsMapper->expects($this->never())
|
||||
->method('decrementPostCount');
|
||||
|
||||
$response = $this->controller->destroy($postId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
433
tests/unit/Controller/RoleControllerTest.php
Normal file
433
tests/unit/Controller/RoleControllerTest.php
Normal file
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Tests\Controller;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Controller\RoleController;
|
||||
use OCA\Forum\Db\CategoryPermMapper;
|
||||
use OCA\Forum\Db\Role;
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\IRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RoleControllerTest extends TestCase {
|
||||
private RoleController $controller;
|
||||
private RoleMapper $roleMapper;
|
||||
private CategoryPermMapper $categoryPermMapper;
|
||||
private LoggerInterface $logger;
|
||||
private IRequest $request;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->roleMapper = $this->createMock(RoleMapper::class);
|
||||
$this->categoryPermMapper = $this->createMock(CategoryPermMapper::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->controller = new RoleController(
|
||||
Application::APP_ID,
|
||||
$this->request,
|
||||
$this->roleMapper,
|
||||
$this->categoryPermMapper,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
public function testUpdateAdminRoleEnforcesAllPermissions(): void {
|
||||
$adminRoleId = 1;
|
||||
$adminRole = $this->createRole($adminRoleId, 'Admin', true, true, true);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($adminRoleId)
|
||||
->willReturn($adminRole);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($role) use ($adminRoleId) {
|
||||
// Verify Admin role always has all permissions
|
||||
$this->assertEquals($adminRoleId, $role->getId());
|
||||
$this->assertTrue($role->getCanAccessAdminTools());
|
||||
$this->assertTrue($role->getCanEditRoles());
|
||||
$this->assertTrue($role->getCanEditCategories());
|
||||
return $role;
|
||||
});
|
||||
|
||||
// Try to update Admin role with permissions set to false - should be forced to true
|
||||
$response = $this->controller->update(
|
||||
$adminRoleId,
|
||||
'Admin',
|
||||
'Administrator role',
|
||||
'#ff0000',
|
||||
'#ff0000',
|
||||
false, // Try to disable - should be forced to true
|
||||
false, // Try to disable - should be forced to true
|
||||
false // Try to disable - should be forced to true
|
||||
);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['canAccessAdminTools']);
|
||||
$this->assertTrue($data['canEditRoles']);
|
||||
$this->assertTrue($data['canEditCategories']);
|
||||
}
|
||||
|
||||
public function testUpdateNonAdminRoleAllowsPermissionChanges(): void {
|
||||
$roleId = 2;
|
||||
$role = $this->createRole($roleId, 'Moderator', true, false, true);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($roleId)
|
||||
->willReturn($role);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($role) use ($roleId) {
|
||||
// Verify non-admin role can have permissions changed
|
||||
$this->assertEquals($roleId, $role->getId());
|
||||
$this->assertFalse($role->getCanAccessAdminTools());
|
||||
$this->assertTrue($role->getCanEditRoles());
|
||||
$this->assertFalse($role->getCanEditCategories());
|
||||
return $role;
|
||||
});
|
||||
|
||||
$response = $this->controller->update(
|
||||
$roleId,
|
||||
'Moderator',
|
||||
'Moderator role',
|
||||
null,
|
||||
null,
|
||||
false, // Changed from true
|
||||
true, // Kept true
|
||||
false // Changed from true
|
||||
);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
}
|
||||
|
||||
public function testDeleteAdminRoleReturnsForbidden(): void {
|
||||
$adminRoleId = 1;
|
||||
|
||||
// Should not even try to find the role - should reject immediately
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('find');
|
||||
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('delete');
|
||||
|
||||
$response = $this->controller->destroy($adminRoleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(['error' => 'System roles cannot be deleted'], $data);
|
||||
}
|
||||
|
||||
public function testDeleteModeratorRoleReturnsForbidden(): void {
|
||||
$moderatorRoleId = 2;
|
||||
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('find');
|
||||
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('delete');
|
||||
|
||||
$response = $this->controller->destroy($moderatorRoleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(['error' => 'System roles cannot be deleted'], $data);
|
||||
}
|
||||
|
||||
public function testDeleteUserRoleReturnsForbidden(): void {
|
||||
$userRoleId = 3;
|
||||
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('find');
|
||||
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('delete');
|
||||
|
||||
$response = $this->controller->destroy($userRoleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(['error' => 'System roles cannot be deleted'], $data);
|
||||
}
|
||||
|
||||
public function testDeleteCustomRoleSuccessfully(): void {
|
||||
$customRoleId = 4;
|
||||
$customRole = $this->createRole($customRoleId, 'Custom Role', false, false, false);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($customRoleId)
|
||||
->willReturn($customRole);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('deleteByRoleId')
|
||||
->with($customRoleId);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('delete')
|
||||
->with($customRole);
|
||||
|
||||
$response = $this->controller->destroy($customRoleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['success']);
|
||||
}
|
||||
|
||||
public function testDeleteNonExistentRoleReturnsNotFound(): void {
|
||||
$roleId = 999;
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($roleId)
|
||||
->willThrowException(new DoesNotExistException('Role not found'));
|
||||
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('delete');
|
||||
|
||||
$response = $this->controller->destroy($roleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(['error' => 'Role not found'], $data);
|
||||
}
|
||||
|
||||
public function testIndexReturnsAllRoles(): void {
|
||||
$role1 = $this->createRole(1, 'Admin', true, true, true);
|
||||
$role2 = $this->createRole(2, 'Moderator', true, false, true);
|
||||
$role3 = $this->createRole(3, 'User', false, false, false);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('findAll')
|
||||
->willReturn([$role1, $role2, $role3]);
|
||||
|
||||
$response = $this->controller->index();
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(3, $data);
|
||||
$this->assertEquals('Admin', $data[0]['name']);
|
||||
$this->assertEquals('Moderator', $data[1]['name']);
|
||||
$this->assertEquals('User', $data[2]['name']);
|
||||
}
|
||||
|
||||
public function testShowReturnsRoleSuccessfully(): void {
|
||||
$roleId = 2;
|
||||
$role = $this->createRole($roleId, 'Moderator', true, false, true);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($roleId)
|
||||
->willReturn($role);
|
||||
|
||||
$response = $this->controller->show($roleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals($roleId, $data['id']);
|
||||
$this->assertEquals('Moderator', $data['name']);
|
||||
$this->assertTrue($data['canAccessAdminTools']);
|
||||
$this->assertFalse($data['canEditRoles']);
|
||||
$this->assertTrue($data['canEditCategories']);
|
||||
}
|
||||
|
||||
public function testShowReturnsNotFoundWhenRoleDoesNotExist(): void {
|
||||
$roleId = 999;
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($roleId)
|
||||
->willThrowException(new DoesNotExistException('Role not found'));
|
||||
|
||||
$response = $this->controller->show($roleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(['error' => 'Role not found'], $data);
|
||||
}
|
||||
|
||||
public function testCreateRoleSuccessfully(): void {
|
||||
$name = 'Custom Role';
|
||||
$description = 'A custom role for special users';
|
||||
$colorLight = '#ff5722';
|
||||
$colorDark = '#d84315';
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->willReturnCallback(function ($role) use ($name, $description, $colorLight, $colorDark) {
|
||||
$this->assertEquals($name, $role->getName());
|
||||
$this->assertEquals($description, $role->getDescription());
|
||||
$this->assertEquals($colorLight, $role->getColorLight());
|
||||
$this->assertEquals($colorDark, $role->getColorDark());
|
||||
$this->assertTrue($role->getCanAccessAdminTools());
|
||||
$this->assertFalse($role->getCanEditRoles());
|
||||
$this->assertTrue($role->getCanEditCategories());
|
||||
|
||||
// Simulate DB setting ID
|
||||
$role->setId(4);
|
||||
return $role;
|
||||
});
|
||||
|
||||
$response = $this->controller->create(
|
||||
$name,
|
||||
$description,
|
||||
$colorLight,
|
||||
$colorDark,
|
||||
true, // canAccessAdminTools
|
||||
false, // canEditRoles
|
||||
true // canEditCategories
|
||||
);
|
||||
|
||||
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(4, $data['id']);
|
||||
$this->assertEquals($name, $data['name']);
|
||||
$this->assertEquals($description, $data['description']);
|
||||
}
|
||||
|
||||
public function testUpdateRoleReturnsNotFoundWhenRoleDoesNotExist(): void {
|
||||
$roleId = 999;
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($roleId)
|
||||
->willThrowException(new DoesNotExistException('Role not found'));
|
||||
|
||||
$this->roleMapper->expects($this->never())
|
||||
->method('update');
|
||||
|
||||
$response = $this->controller->update($roleId, 'New Name');
|
||||
|
||||
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(['error' => 'Role not found'], $data);
|
||||
}
|
||||
|
||||
public function testGetPermissionsReturnsPermissionsForRole(): void {
|
||||
$roleId = 2;
|
||||
|
||||
$perm1 = new \OCA\Forum\Db\CategoryPerm();
|
||||
$perm1->setId(1);
|
||||
$perm1->setCategoryId(1);
|
||||
$perm1->setRoleId($roleId);
|
||||
$perm1->setCanView(true);
|
||||
$perm1->setCanPost(true);
|
||||
$perm1->setCanReply(true);
|
||||
$perm1->setCanModerate(false);
|
||||
|
||||
$perm2 = new \OCA\Forum\Db\CategoryPerm();
|
||||
$perm2->setId(2);
|
||||
$perm2->setCategoryId(2);
|
||||
$perm2->setRoleId($roleId);
|
||||
$perm2->setCanView(true);
|
||||
$perm2->setCanPost(false);
|
||||
$perm2->setCanReply(false);
|
||||
$perm2->setCanModerate(true);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByRoleId')
|
||||
->with($roleId)
|
||||
->willReturn([$perm1, $perm2]);
|
||||
|
||||
$response = $this->controller->getPermissions($roleId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(2, $data);
|
||||
$this->assertEquals(1, $data[0]['categoryId']);
|
||||
$this->assertTrue($data[0]['canView']);
|
||||
$this->assertFalse($data[0]['canModerate']);
|
||||
$this->assertEquals(2, $data[1]['categoryId']);
|
||||
$this->assertTrue($data[1]['canModerate']);
|
||||
}
|
||||
|
||||
public function testUpdatePermissionsSuccessfully(): void {
|
||||
$roleId = 2;
|
||||
$permissions = [
|
||||
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
|
||||
['categoryId' => 2, 'canView' => true, 'canModerate' => true],
|
||||
];
|
||||
|
||||
$role = $this->createRole($roleId, 'Moderator', true, false, true);
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($roleId)
|
||||
->willReturn($role);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('deleteByRoleId')
|
||||
->with($roleId);
|
||||
|
||||
$this->categoryPermMapper->expects($this->exactly(2))
|
||||
->method('insert')
|
||||
->willReturnCallback(function ($perm) use ($roleId) {
|
||||
$this->assertEquals($roleId, $perm->getRoleId());
|
||||
// Verify canPost and canReply are set based on canView
|
||||
if ($perm->getCategoryId() === 1) {
|
||||
$this->assertTrue($perm->getCanView());
|
||||
$this->assertTrue($perm->getCanPost());
|
||||
$this->assertTrue($perm->getCanReply());
|
||||
$this->assertFalse($perm->getCanModerate());
|
||||
} else {
|
||||
$this->assertTrue($perm->getCanView());
|
||||
$this->assertTrue($perm->getCanPost());
|
||||
$this->assertTrue($perm->getCanReply());
|
||||
$this->assertTrue($perm->getCanModerate());
|
||||
}
|
||||
return $perm;
|
||||
});
|
||||
|
||||
$response = $this->controller->updatePermissions($roleId, $permissions);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['success']);
|
||||
}
|
||||
|
||||
public function testUpdatePermissionsReturnsNotFoundWhenRoleDoesNotExist(): void {
|
||||
$roleId = 999;
|
||||
$permissions = [
|
||||
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
|
||||
];
|
||||
|
||||
$this->roleMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($roleId)
|
||||
->willThrowException(new DoesNotExistException('Role not found'));
|
||||
|
||||
$this->categoryPermMapper->expects($this->never())
|
||||
->method('deleteByRoleId');
|
||||
|
||||
$this->categoryPermMapper->expects($this->never())
|
||||
->method('insert');
|
||||
|
||||
$response = $this->controller->updatePermissions($roleId, $permissions);
|
||||
|
||||
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals(['error' => 'Role not found'], $data);
|
||||
}
|
||||
|
||||
private function createRole(int $id, string $name, bool $canAccessAdminTools, bool $canEditRoles, bool $canEditCategories): Role {
|
||||
$role = new Role();
|
||||
$role->setId($id);
|
||||
$role->setName($name);
|
||||
$role->setCanAccessAdminTools($canAccessAdminTools);
|
||||
$role->setCanEditRoles($canEditRoles);
|
||||
$role->setCanEditCategories($canEditCategories);
|
||||
$role->setCreatedAt(time());
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
@@ -222,9 +222,11 @@ class ThreadControllerTest extends TestCase {
|
||||
$forumUser->setUserId($userId);
|
||||
$forumUser->setPostCount(10);
|
||||
|
||||
// Mock user stats increment methods (void methods, no return value)
|
||||
$this->userStatsMapper->method('incrementPostCount');
|
||||
$this->userStatsMapper->method('incrementThreadCount');
|
||||
// Mock user stats increment methods (first post doesn't count, only thread count increments)
|
||||
$this->userStatsMapper->expects($this->never())
|
||||
->method('incrementPostCount');
|
||||
$this->userStatsMapper->expects($this->once())
|
||||
->method('incrementThreadCount');
|
||||
|
||||
// Mock thread subscription
|
||||
$this->userPreferencesService->method('getPreference')->willReturn(false);
|
||||
@@ -261,7 +263,8 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($thread) {
|
||||
$this->assertEquals(1, $thread->getPostCount());
|
||||
// First post doesn't count, so postCount should be 0
|
||||
$this->assertEquals(0, $thread->getPostCount());
|
||||
return $thread;
|
||||
});
|
||||
|
||||
@@ -274,7 +277,8 @@ class ThreadControllerTest extends TestCase {
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedCategory) {
|
||||
$this->assertEquals(6, $updatedCategory->getThreadCount());
|
||||
$this->assertEquals(21, $updatedCategory->getPostCount());
|
||||
// First post doesn't count, so postCount stays at 20
|
||||
$this->assertEquals(20, $updatedCategory->getPostCount());
|
||||
return $updatedCategory;
|
||||
});
|
||||
|
||||
@@ -498,6 +502,137 @@ class ThreadControllerTest extends TestCase {
|
||||
$this->assertEquals(['error' => 'Thread not found'], $response->getData());
|
||||
}
|
||||
|
||||
public function testByAuthorReturnsThreadsSuccessfully(): void {
|
||||
$authorId = 'user1';
|
||||
$limit = 50;
|
||||
$offset = 0;
|
||||
|
||||
$thread1 = $this->createMockThread(1, 1, $authorId, 'Thread 1');
|
||||
$thread2 = $this->createMockThread(2, 1, $authorId, 'Thread 2');
|
||||
$threads = [$thread1, $thread2];
|
||||
|
||||
$enrichedAuthor = [
|
||||
'userId' => $authorId,
|
||||
'displayName' => 'Test User',
|
||||
];
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('findByAuthorId')
|
||||
->with($authorId, $limit, $offset)
|
||||
->willReturn($threads);
|
||||
|
||||
$this->userService->expects($this->once())
|
||||
->method('enrichUserData')
|
||||
->with($authorId)
|
||||
->willReturn($enrichedAuthor);
|
||||
|
||||
$this->threadEnrichmentService->expects($this->exactly(2))
|
||||
->method('enrichThread')
|
||||
->willReturnCallback(function ($thread, $author) {
|
||||
$data = $thread->jsonSerialize();
|
||||
$data['author'] = $author;
|
||||
return $data;
|
||||
});
|
||||
|
||||
$response = $this->controller->byAuthor($authorId, $limit, $offset);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(2, $data);
|
||||
$this->assertEquals(1, $data[0]['id']);
|
||||
$this->assertEquals(2, $data[1]['id']);
|
||||
$this->assertEquals($enrichedAuthor, $data[0]['author']);
|
||||
}
|
||||
|
||||
public function testSetLockedUpdatesThreadSuccessfully(): void {
|
||||
$threadId = 1;
|
||||
$thread = $this->createMockThread($threadId, 1, 'user1', 'Test Thread');
|
||||
$thread->setIsLocked(false);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($threadId)
|
||||
->willReturn($thread);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
$this->assertTrue($updatedThread->getIsLocked());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
$this->threadEnrichmentService->expects($this->once())
|
||||
->method('enrichThread')
|
||||
->willReturnCallback(function ($thread) {
|
||||
return $thread->jsonSerialize();
|
||||
});
|
||||
|
||||
$response = $this->controller->setLocked($threadId, true);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals($threadId, $data['id']);
|
||||
}
|
||||
|
||||
public function testSetLockedReturnsNotFoundWhenThreadDoesNotExist(): void {
|
||||
$threadId = 999;
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($threadId)
|
||||
->willThrowException(new DoesNotExistException('Thread not found'));
|
||||
|
||||
$response = $this->controller->setLocked($threadId, true);
|
||||
|
||||
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
|
||||
$this->assertEquals(['error' => 'Thread not found'], $response->getData());
|
||||
}
|
||||
|
||||
public function testSetPinnedUpdatesThreadSuccessfully(): void {
|
||||
$threadId = 1;
|
||||
$thread = $this->createMockThread($threadId, 1, 'user1', 'Test Thread');
|
||||
$thread->setIsPinned(false);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($threadId)
|
||||
->willReturn($thread);
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($updatedThread) {
|
||||
$this->assertTrue($updatedThread->getIsPinned());
|
||||
return $updatedThread;
|
||||
});
|
||||
|
||||
$this->threadEnrichmentService->expects($this->once())
|
||||
->method('enrichThread')
|
||||
->willReturnCallback(function ($thread) {
|
||||
return $thread->jsonSerialize();
|
||||
});
|
||||
|
||||
$response = $this->controller->setPinned($threadId, true);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals($threadId, $data['id']);
|
||||
}
|
||||
|
||||
public function testSetPinnedReturnsNotFoundWhenThreadDoesNotExist(): void {
|
||||
$threadId = 999;
|
||||
|
||||
$this->threadMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with($threadId)
|
||||
->willThrowException(new DoesNotExistException('Thread not found'));
|
||||
|
||||
$response = $this->controller->setPinned($threadId, true);
|
||||
|
||||
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
|
||||
$this->assertEquals(['error' => 'Thread not found'], $response->getData());
|
||||
}
|
||||
|
||||
private function createMockThread(int $id, int $categoryId, string $authorId, string $title): Thread {
|
||||
$thread = new Thread();
|
||||
$thread->setId($id);
|
||||
|
||||
222
tests/unit/Controller/ThreadSubscriptionControllerTest.php
Normal file
222
tests/unit/Controller/ThreadSubscriptionControllerTest.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Tests\Controller;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Controller\ThreadSubscriptionController;
|
||||
use OCA\Forum\Db\ThreadSubscription;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserSession;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ThreadSubscriptionControllerTest extends TestCase {
|
||||
private ThreadSubscriptionController $controller;
|
||||
private ThreadSubscriptionMapper $subscriptionMapper;
|
||||
private IUserSession $userSession;
|
||||
private LoggerInterface $logger;
|
||||
private IRequest $request;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->request = $this->createMock(IRequest::class);
|
||||
$this->subscriptionMapper = $this->createMock(ThreadSubscriptionMapper::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->controller = new ThreadSubscriptionController(
|
||||
Application::APP_ID,
|
||||
$this->request,
|
||||
$this->subscriptionMapper,
|
||||
$this->userSession,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
public function testSubscribeSuccessfully(): void {
|
||||
$threadId = 1;
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$subscription = $this->createMockSubscription(1, $userId, $threadId);
|
||||
|
||||
$this->subscriptionMapper->expects($this->once())
|
||||
->method('subscribe')
|
||||
->with($userId, $threadId)
|
||||
->willReturn($subscription);
|
||||
|
||||
$response = $this->controller->subscribe($threadId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['success']);
|
||||
$this->assertArrayHasKey('subscription', $data);
|
||||
$this->assertEquals($userId, $data['subscription']['userId']);
|
||||
$this->assertEquals($threadId, $data['subscription']['threadId']);
|
||||
}
|
||||
|
||||
public function testSubscribeReturnsUnauthorizedWhenUserNotAuthenticated(): void {
|
||||
$threadId = 1;
|
||||
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
|
||||
$response = $this->controller->subscribe($threadId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
|
||||
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
|
||||
}
|
||||
|
||||
public function testUnsubscribeSuccessfully(): void {
|
||||
$threadId = 1;
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->subscriptionMapper->expects($this->once())
|
||||
->method('unsubscribe')
|
||||
->with($userId, $threadId);
|
||||
|
||||
$response = $this->controller->unsubscribe($threadId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['success']);
|
||||
}
|
||||
|
||||
public function testUnsubscribeReturnsUnauthorizedWhenUserNotAuthenticated(): void {
|
||||
$threadId = 1;
|
||||
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
|
||||
$response = $this->controller->unsubscribe($threadId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
|
||||
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
|
||||
}
|
||||
|
||||
public function testIsSubscribedReturnsTrue(): void {
|
||||
$threadId = 1;
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->subscriptionMapper->expects($this->once())
|
||||
->method('isUserSubscribed')
|
||||
->with($userId, $threadId)
|
||||
->willReturn(true);
|
||||
|
||||
$response = $this->controller->isSubscribed($threadId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertTrue($data['isSubscribed']);
|
||||
}
|
||||
|
||||
public function testIsSubscribedReturnsFalse(): void {
|
||||
$threadId = 1;
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->subscriptionMapper->expects($this->once())
|
||||
->method('isUserSubscribed')
|
||||
->with($userId, $threadId)
|
||||
->willReturn(false);
|
||||
|
||||
$response = $this->controller->isSubscribed($threadId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertFalse($data['isSubscribed']);
|
||||
}
|
||||
|
||||
public function testIsSubscribedReturnsUnauthorizedWhenUserNotAuthenticated(): void {
|
||||
$threadId = 1;
|
||||
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
|
||||
$response = $this->controller->isSubscribed($threadId);
|
||||
|
||||
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
|
||||
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
|
||||
}
|
||||
|
||||
public function testGetUserSubscriptionsReturnsSubscriptionsSuccessfully(): void {
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$subscription1 = $this->createMockSubscription(1, $userId, 1);
|
||||
$subscription2 = $this->createMockSubscription(2, $userId, 2);
|
||||
$subscriptions = [$subscription1, $subscription2];
|
||||
|
||||
$this->subscriptionMapper->expects($this->once())
|
||||
->method('findByUserId')
|
||||
->with($userId)
|
||||
->willReturn($subscriptions);
|
||||
|
||||
$response = $this->controller->getUserSubscriptions();
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(2, $data);
|
||||
$this->assertEquals(1, $data[0]['id']);
|
||||
$this->assertEquals(2, $data[1]['id']);
|
||||
$this->assertEquals($userId, $data[0]['userId']);
|
||||
$this->assertEquals(1, $data[0]['threadId']);
|
||||
}
|
||||
|
||||
public function testGetUserSubscriptionsHandlesEmptySubscriptions(): void {
|
||||
$userId = 'user1';
|
||||
|
||||
$user = $this->createMock(IUser::class);
|
||||
$user->method('getUID')->willReturn($userId);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$this->subscriptionMapper->expects($this->once())
|
||||
->method('findByUserId')
|
||||
->with($userId)
|
||||
->willReturn([]);
|
||||
|
||||
$response = $this->controller->getUserSubscriptions();
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertIsArray($data);
|
||||
$this->assertCount(0, $data);
|
||||
}
|
||||
|
||||
public function testGetUserSubscriptionsReturnsUnauthorizedWhenUserNotAuthenticated(): void {
|
||||
$this->userSession->method('getUser')->willReturn(null);
|
||||
|
||||
$response = $this->controller->getUserSubscriptions();
|
||||
|
||||
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
|
||||
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
|
||||
}
|
||||
|
||||
private function createMockSubscription(int $id, string $userId, int $threadId): ThreadSubscription {
|
||||
$subscription = new ThreadSubscription();
|
||||
$subscription->setId($id);
|
||||
$subscription->setUserId($userId);
|
||||
$subscription->setThreadId($threadId);
|
||||
$subscription->setCreatedAt(time());
|
||||
return $subscription;
|
||||
}
|
||||
}
|
||||
@@ -161,17 +161,18 @@ class PermissionServiceTest extends TestCase {
|
||||
$categoryId = 1;
|
||||
$permission = 'canPost';
|
||||
|
||||
$userRole = $this->createUserRole(1, $userId, 1);
|
||||
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 1, true, true, true, false);
|
||||
// Using role ID 3 (User) instead of 1 (Admin) to test normal permission check
|
||||
$userRole = $this->createUserRole(1, $userId, 3);
|
||||
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 3, true, true, true, false);
|
||||
|
||||
$this->userRoleMapper->expects($this->once())
|
||||
$this->userRoleMapper->expects($this->exactly(2))
|
||||
->method('findByUserId')
|
||||
->with($userId)
|
||||
->willReturn([$userRole]);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByCategoryAndRole')
|
||||
->with($categoryId, 1)
|
||||
->with($categoryId, 3)
|
||||
->willReturn($categoryPerm);
|
||||
|
||||
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
|
||||
@@ -184,17 +185,18 @@ class PermissionServiceTest extends TestCase {
|
||||
$categoryId = 1;
|
||||
$permission = 'canModerate';
|
||||
|
||||
$userRole = $this->createUserRole(1, $userId, 1);
|
||||
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 1, true, true, true, false);
|
||||
// Using role ID 2 (Moderator) instead of 1 (Admin) to test normal permission check
|
||||
$userRole = $this->createUserRole(1, $userId, 2);
|
||||
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 2, true, true, true, false);
|
||||
|
||||
$this->userRoleMapper->expects($this->once())
|
||||
$this->userRoleMapper->expects($this->exactly(2))
|
||||
->method('findByUserId')
|
||||
->with($userId)
|
||||
->willReturn([$userRole]);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByCategoryAndRole')
|
||||
->with($categoryId, 1)
|
||||
->with($categoryId, 2)
|
||||
->willReturn($categoryPerm);
|
||||
|
||||
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
|
||||
@@ -207,6 +209,29 @@ class PermissionServiceTest extends TestCase {
|
||||
$categoryId = 1;
|
||||
$permission = 'canPost';
|
||||
|
||||
$userRole = $this->createUserRole(1, $userId, 3); // Non-admin role
|
||||
|
||||
$this->userRoleMapper->expects($this->exactly(2))
|
||||
->method('findByUserId')
|
||||
->with($userId)
|
||||
->willReturn([$userRole]);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByCategoryAndRole')
|
||||
->with($categoryId, 3)
|
||||
->willThrowException(new DoesNotExistException('Permission not found'));
|
||||
|
||||
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testHasCategoryPermissionReturnsTrueForAdminRoleRegardlessOfPermissions(): void {
|
||||
$userId = 'admin1';
|
||||
$categoryId = 1;
|
||||
$permission = 'canModerate';
|
||||
|
||||
// User has Admin role (ID 1)
|
||||
$userRole = $this->createUserRole(1, $userId, 1);
|
||||
|
||||
$this->userRoleMapper->expects($this->once())
|
||||
@@ -214,14 +239,36 @@ class PermissionServiceTest extends TestCase {
|
||||
->with($userId)
|
||||
->willReturn([$userRole]);
|
||||
|
||||
$this->categoryPermMapper->expects($this->once())
|
||||
->method('findByCategoryAndRole')
|
||||
->with($categoryId, 1)
|
||||
->willThrowException(new DoesNotExistException('Permission not found'));
|
||||
// Should not even check category permissions for Admin
|
||||
$this->categoryPermMapper->expects($this->never())
|
||||
->method('findByCategoryAndRole');
|
||||
|
||||
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testHasCategoryPermissionReturnsTrueForAdminEvenWithOtherRoles(): void {
|
||||
$userId = 'admin1';
|
||||
$categoryId = 1;
|
||||
$permission = 'canView';
|
||||
|
||||
// User has both Admin (ID 1) and User (ID 3) roles
|
||||
$userRole1 = $this->createUserRole(1, $userId, 1);
|
||||
$userRole2 = $this->createUserRole(2, $userId, 3);
|
||||
|
||||
$this->userRoleMapper->expects($this->once())
|
||||
->method('findByUserId')
|
||||
->with($userId)
|
||||
->willReturn([$userRole1, $userRole2]);
|
||||
|
||||
// Should not check category permissions for Admin
|
||||
$this->categoryPermMapper->expects($this->never())
|
||||
->method('findByCategoryAndRole');
|
||||
|
||||
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testGetCategoryIdFromThreadReturnsCorrectId(): void {
|
||||
|
||||
171
vendor-bin/cs-fixer/composer.lock
generated
Normal file
171
vendor-bin/cs-fixer/composer.lock
generated
Normal file
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "59bdbac023efd7059e30cfd98dc00b94",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "kubawerlos/php-cs-fixer-custom-fixers",
|
||||
"version": "v3.35.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git",
|
||||
"reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/2a35f80ae24ca77443a7af1599c3a3db1b6bd395",
|
||||
"reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-filter": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.87",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6.24 || ^10.5.51 || ^11.5.32"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpCsFixerCustomFixers\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Kuba Werłos",
|
||||
"email": "werlos@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A set of custom fixers for PHP CS Fixer",
|
||||
"support": {
|
||||
"issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues",
|
||||
"source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.35.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/kubawerlos",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-28T18:43:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nextcloud/coding-standard",
|
||||
"version": "v1.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud/coding-standard.git",
|
||||
"reference": "8e06808c1423e9208d63d1bd205b9a38bd400011"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/8e06808c1423e9208d63d1bd205b9a38bd400011",
|
||||
"reference": "8e06808c1423e9208d63d1bd205b9a38bd400011",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"kubawerlos/php-cs-fixer-custom-fixers": "^3.22",
|
||||
"php": "^8.0",
|
||||
"php-cs-fixer/shim": "^3.17"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nextcloud\\CodingStandard\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christoph Wurst",
|
||||
"email": "christoph@winzerhof-wurst.at"
|
||||
}
|
||||
],
|
||||
"description": "Nextcloud coding standards for the php cs fixer",
|
||||
"keywords": [
|
||||
"dev"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nextcloud/coding-standard/issues",
|
||||
"source": "https://github.com/nextcloud/coding-standard/tree/v1.4.0"
|
||||
},
|
||||
"time": "2025-06-19T12:27:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-cs-fixer/shim",
|
||||
"version": "v3.90.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/shim.git",
|
||||
"reference": "db628db551759424b1ba511aef3b0ff0550044f6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/db628db551759424b1ba511aef3b0ff0550044f6",
|
||||
"reference": "db628db551759424b1ba511aef3b0ff0550044f6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"replace": {
|
||||
"friendsofphp/php-cs-fixer": "self.version"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "For handling output formats in XML",
|
||||
"ext-mbstring": "For handling non-UTF8 characters."
|
||||
},
|
||||
"bin": [
|
||||
"php-cs-fixer",
|
||||
"php-cs-fixer.phar"
|
||||
],
|
||||
"type": "application",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Dariusz Rumiński",
|
||||
"email": "dariusz.ruminski@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A tool to automatically fix PHP code style",
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.90.0"
|
||||
},
|
||||
"time": "2025-11-20T15:15:37+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"platform-overrides": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
266
vendor-bin/openapi-extractor/composer.lock
generated
Normal file
266
vendor-bin/openapi-extractor/composer.lock
generated
Normal file
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "cc4604e4c3d0f17f3feec0fe94971ff6",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "adhocore/cli",
|
||||
"version": "v1.9.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/adhocore/php-cli.git",
|
||||
"reference": "474dc3d7ab139796be98b104d891476e3916b6f4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/adhocore/php-cli/zipball/474dc3d7ab139796be98b104d891476e3916b6f4",
|
||||
"reference": "474dc3d7ab139796be98b104d891476e3916b6f4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Ahc\\Cli\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jitendra Adhikari",
|
||||
"email": "jiten.adhikary@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Command line interface library for PHP",
|
||||
"keywords": [
|
||||
"argument-parser",
|
||||
"argv-parser",
|
||||
"cli",
|
||||
"cli-action",
|
||||
"cli-app",
|
||||
"cli-color",
|
||||
"cli-option",
|
||||
"cli-writer",
|
||||
"command",
|
||||
"console",
|
||||
"console-app",
|
||||
"php-cli",
|
||||
"php8",
|
||||
"stream-input",
|
||||
"stream-output"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/adhocore/php-cli/issues",
|
||||
"source": "https://github.com/adhocore/php-cli/tree/v1.9.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/ji10",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/adhocore",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-05-11T13:23:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nextcloud/openapi-extractor",
|
||||
"version": "dev-main",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud/openapi-extractor.git",
|
||||
"reference": "fc40e144aa0d7bdd388a20ded6dc7681f037a18e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud/openapi-extractor/zipball/fc40e144aa0d7bdd388a20ded6dc7681f037a18e",
|
||||
"reference": "fc40e144aa0d7bdd388a20ded6dc7681f037a18e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"adhocore/cli": "^1.7",
|
||||
"ext-simplexml": "*",
|
||||
"nikic/php-parser": "^5.0",
|
||||
"php": "^8.1",
|
||||
"phpstan/phpdoc-parser": "^2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"nextcloud/coding-standard": "^1.2",
|
||||
"nextcloud/ocp": "dev-master",
|
||||
"rector/rector": "^2.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"bin": [
|
||||
"bin/generate-spec",
|
||||
"bin/merge-specs"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OpenAPIExtractor\\": "src"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": [
|
||||
"find . -name \\*.php -not -path './tests/*' -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l"
|
||||
],
|
||||
"cs:check": [
|
||||
"php-cs-fixer fix --dry-run --diff"
|
||||
],
|
||||
"cs:fix": [
|
||||
"php-cs-fixer fix"
|
||||
],
|
||||
"test:unit": [
|
||||
"cd tests && ../bin/generate-spec"
|
||||
],
|
||||
"rector": [
|
||||
"rector && composer cs:fix"
|
||||
]
|
||||
},
|
||||
"license": [
|
||||
"AGPL-3.0-or-later"
|
||||
],
|
||||
"description": "A tool for extracting OpenAPI specifications from Nextcloud source code",
|
||||
"support": {
|
||||
"source": "https://github.com/nextcloud/openapi-extractor/tree/main",
|
||||
"issues": "https://github.com/nextcloud/openapi-extractor/issues"
|
||||
},
|
||||
"time": "2025-11-17T10:11:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.6.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||
"reference": "3a454ca033b9e06b63282ce19562e892747449bb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb",
|
||||
"reference": "3a454ca033b9e06b63282ce19562e892747449bb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"ircmaxell/php-yacc": "^0.0.7",
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"bin": [
|
||||
"bin/php-parse"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpParser\\": "lib/PhpParser"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nikita Popov"
|
||||
}
|
||||
],
|
||||
"description": "A PHP parser written in PHP",
|
||||
"keywords": [
|
||||
"parser",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2"
|
||||
},
|
||||
"time": "2025-10-21T19:32:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
|
||||
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/annotations": "^2.0",
|
||||
"nikic/php-parser": "^5.3.0",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"symfony/process": "^5.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PHPStan\\PhpDocParser\\": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
|
||||
},
|
||||
"time": "2025-08-30T15:50:23+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {
|
||||
"nextcloud/openapi-extractor": 20
|
||||
},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"platform-overrides": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
1691
vendor-bin/phpunit/composer.lock
generated
Normal file
1691
vendor-bin/phpunit/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
2127
vendor-bin/psalm/composer.lock
generated
Normal file
2127
vendor-bin/psalm/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
0.9.0
|
||||
0.10.0
|
||||
|
||||
Reference in New Issue
Block a user