mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8c6c822c2 | ||
|
|
1f34c81ef4 | ||
| da0c77114a | |||
| f10d0ff9a9 | |||
| cdee82cb4f | |||
|
|
05cc4b6084 | ||
|
|
3aba13d5c2 | ||
| 8c6fb8ff80 | |||
|
|
59134fb19a | ||
| d5b8421ed9 | |||
| 60d7aa3399 | |||
|
|
6f7c696b34 | ||
|
|
06ff4e2ff9 | ||
|
|
e2a45acc59 | ||
| 98593e2eff | |||
|
|
9c2b6ac64d | ||
|
|
afb4140e6e | ||
|
|
f9896c0cdd | ||
| a472e03e98 | |||
|
|
f2e3d37bcd | ||
| a0c70d8320 |
9
.github/workflows/phpunit-incremental.yml
vendored
9
.github/workflows/phpunit-incremental.yml
vendored
@@ -15,9 +15,16 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
phpunit-incremental:
|
||||
phpunit-incremental-v0-14-0:
|
||||
uses: chenasraf/workflows/.github/workflows/nextcloud-phpunit-incremental.yml@nextcloud-latest
|
||||
secrets: inherit
|
||||
with:
|
||||
baseline-version: 'v0.14.0'
|
||||
validation-query: 'SELECT COUNT(*) FROM oc_forum_users'
|
||||
|
||||
phpunit-incremental-v0-22-8:
|
||||
uses: chenasraf/workflows/.github/workflows/nextcloud-phpunit-incremental.yml@nextcloud-latest
|
||||
secrets: inherit
|
||||
with:
|
||||
baseline-version: 'v0.22.8'
|
||||
validation-query: 'SELECT COUNT(*) FROM oc_forum_users'
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.22.3"}
|
||||
{".":"0.23.0"}
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## [0.23.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.22.8...v0.23.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* category read markers ([da0c771](https://github.com/chenasraf/nextcloud-forum/commit/da0c77114aa35674ca938870e0517da5073fedf7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **l10n:** Update translations from Transifex ([1f34c81](https://github.com/chenasraf/nextcloud-forum/commit/1f34c81ef42fa69d5f2a6846296785c283cafece))
|
||||
* **l10n:** Update translations from Transifex ([05cc4b6](https://github.com/chenasraf/nextcloud-forum/commit/05cc4b6084271f5ea0172e68d55e423b10250375))
|
||||
|
||||
## [0.22.8](https://github.com/chenasraf/nextcloud-forum/compare/v0.22.7...v0.22.8) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow moving threads ([8c6fb8f](https://github.com/chenasraf/nextcloud-forum/commit/8c6fb8ff8055d44307aa6094195728e7621144f3))
|
||||
|
||||
## [0.22.7](https://github.com/chenasraf/nextcloud-forum/compare/v0.22.6...v0.22.7) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **l10n:** Update translations from Transifex ([6f7c696](https://github.com/chenasraf/nextcloud-forum/commit/6f7c696b3420d81edd6f06e10a88374525cf6b6d))
|
||||
* **l10n:** Update translations from Transifex ([06ff4e2](https://github.com/chenasraf/nextcloud-forum/commit/06ff4e2ff9f913636b37b6dd244d4a9dd04082c8))
|
||||
* light/dark theme would not listen to user preferences ([60d7aa3](https://github.com/chenasraf/nextcloud-forum/commit/60d7aa3399285280af3f68bd081a8c8de145665b))
|
||||
* move thread dialog closing/progress ([d5b8421](https://github.com/chenasraf/nextcloud-forum/commit/d5b8421ed901970bdb8ef09183f370d549058cb8))
|
||||
|
||||
## [0.22.6](https://github.com/chenasraf/nextcloud-forum/compare/v0.22.5...v0.22.6) (2026-02-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **l10n:** Update translations from Transifex ([9c2b6ac](https://github.com/chenasraf/nextcloud-forum/commit/9c2b6ac64d762e45796efd5fe08581926a3fc3e2))
|
||||
* **l10n:** Update translations from Transifex ([afb4140](https://github.com/chenasraf/nextcloud-forum/commit/afb4140e6ecd3344d19a3173799a7ecf13c91f10))
|
||||
|
||||
## [0.22.5](https://github.com/chenasraf/nextcloud-forum/compare/v0.22.4...v0.22.5) (2026-02-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile menu popover ([a472e03](https://github.com/chenasraf/nextcloud-forum/commit/a472e03e984e0e146f872df3f91eb88b9834970d))
|
||||
|
||||
## [0.22.4](https://github.com/chenasraf/nextcloud-forum/compare/v0.22.3...v0.22.4) (2026-02-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** unmark floating-vue as external ([a0c70d8](https://github.com/chenasraf/nextcloud-forum/commit/a0c70d8320bd16e7768adda8489d6f73f5fcd148))
|
||||
|
||||
## [0.22.3](https://github.com/chenasraf/nextcloud-forum/compare/v0.22.2...v0.22.3) (2026-02-05)
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ This app is in early stages of development. While functional, you may encounter
|
||||
|
||||
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
|
||||
]]></description>
|
||||
<version>0.22.3</version>
|
||||
<version>0.23.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
|
||||
31
composer.lock
generated
31
composer.lock
generated
@@ -180,12 +180,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud-deps/ocp.git",
|
||||
"reference": "f49cc367ee1a0216b7783b1b7a7f23dace6dd7c5"
|
||||
"reference": "83cf79e2922fe1c969adeeac8dbb10e173a8d781"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/f49cc367ee1a0216b7783b1b7a7f23dace6dd7c5",
|
||||
"reference": "f49cc367ee1a0216b7783b1b7a7f23dace6dd7c5",
|
||||
"url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/83cf79e2922fe1c969adeeac8dbb10e173a8d781",
|
||||
"reference": "83cf79e2922fe1c969adeeac8dbb10e173a8d781",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -220,7 +220,7 @@
|
||||
"issues": "https://github.com/nextcloud-deps/ocp/issues",
|
||||
"source": "https://github.com/nextcloud-deps/ocp/tree/stable32"
|
||||
},
|
||||
"time": "2026-01-21T00:58:32+00:00"
|
||||
"time": "2026-02-06T01:05:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
@@ -1035,12 +1035,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Roave/SecurityAdvisories.git",
|
||||
"reference": "258cd5fdcb59c29f421927b2cf77f48de9458a98"
|
||||
"reference": "7f3e95c9ebf1b16e002dd2c913d30d962c2a6a16"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/258cd5fdcb59c29f421927b2cf77f48de9458a98",
|
||||
"reference": "258cd5fdcb59c29f421927b2cf77f48de9458a98",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/7f3e95c9ebf1b16e002dd2c913d30d962c2a6a16",
|
||||
"reference": "7f3e95c9ebf1b16e002dd2c913d30d962c2a6a16",
|
||||
"shasum": ""
|
||||
},
|
||||
"conflict": {
|
||||
@@ -1071,6 +1071,7 @@
|
||||
"amphp/artax": "<1.0.6|>=2,<2.0.6",
|
||||
"amphp/http": "<=1.7.2|>=2,<=2.1",
|
||||
"amphp/http-client": ">=4,<4.4",
|
||||
"amphp/http-server": ">=2.0.0.0-RC1-dev,<2.1.10|>=3.0.0.0-beta1,<3.4.4",
|
||||
"anchorcms/anchor-cms": "<=0.12.7",
|
||||
"andreapollastri/cipi": "<=3.1.15",
|
||||
"andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5",
|
||||
@@ -1147,6 +1148,7 @@
|
||||
"causal/oidc": "<4",
|
||||
"cecil/cecil": "<7.47.1",
|
||||
"centreon/centreon": "<22.10.15",
|
||||
"cesargb/laravel-magiclink": ">=2,<2.25.1",
|
||||
"cesnet/simplesamlphp-module-proxystatistics": "<3.1",
|
||||
"chriskacerguis/codeigniter-restserver": "<=2.7.1",
|
||||
"chrome-php/chrome": "<1.14",
|
||||
@@ -1181,9 +1183,10 @@
|
||||
"cosenary/instagram": "<=2.3",
|
||||
"couleurcitron/tarteaucitron-wp": "<0.3",
|
||||
"cpsit/typo3-mailqueue": "<0.4.3|>=0.5,<0.5.1",
|
||||
"craftcms/cms": "<=4.16.16|>=5,<=5.8.20",
|
||||
"craftcms/cms": "<4.17.0.0-beta1|>=5,<5.9.0.0-beta1",
|
||||
"craftcms/commerce": ">=4.0.0.0-RC1-dev,<=4.10|>=5,<=5.5.1",
|
||||
"craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1",
|
||||
"craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21",
|
||||
"croogo/croogo": "<=4.0.7",
|
||||
"cuyz/valinor": "<0.12",
|
||||
"czim/file-handling": "<1.5|>=2,<2.3",
|
||||
@@ -1336,6 +1339,7 @@
|
||||
"friendsoftypo3/mediace": ">=7.6.2,<7.6.5",
|
||||
"friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6",
|
||||
"froala/wysiwyg-editor": "<=4.3",
|
||||
"frosh/adminer-platform": "<2.2.1",
|
||||
"froxlor/froxlor": "<=2.2.5",
|
||||
"frozennode/administrator": "<=5.0.12",
|
||||
"fuel/core": "<1.8.1",
|
||||
@@ -1386,7 +1390,7 @@
|
||||
"ibexa/solr": ">=4.5,<4.5.4",
|
||||
"ibexa/user": ">=4,<4.4.3|>=5,<5.0.4",
|
||||
"icecoder/icecoder": "<=8.1",
|
||||
"idno/known": "<=1.3.1",
|
||||
"idno/known": "<=1.6.2",
|
||||
"ilicmiljan/secure-props": ">=1.2,<1.2.2",
|
||||
"illuminate/auth": "<5.5.10",
|
||||
"illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4",
|
||||
@@ -1520,7 +1524,7 @@
|
||||
"microsoft/microsoft-graph": ">=1.16,<1.109.1|>=2,<2.0.1",
|
||||
"microsoft/microsoft-graph-beta": "<2.0.1",
|
||||
"microsoft/microsoft-graph-core": "<2.0.2",
|
||||
"microweber/microweber": "<=2.0.19",
|
||||
"microweber/microweber": "<2.0.20",
|
||||
"mikehaertl/php-shellcommand": "<1.6.1",
|
||||
"mineadmin/mineadmin": "<=3.0.9",
|
||||
"miniorange/miniorange-saml": "<1.4.3",
|
||||
@@ -1640,6 +1644,7 @@
|
||||
"phpwhois/phpwhois": "<=4.2.5",
|
||||
"phpxmlrpc/extras": "<0.6.1",
|
||||
"phpxmlrpc/phpxmlrpc": "<4.9.2",
|
||||
"phraseanet/phraseanet": "==4.0.3",
|
||||
"pi/pi": "<=2.5",
|
||||
"pimcore/admin-ui-classic-bundle": "<=1.7.15|>=2.0.0.0-RC1-dev,<=2.2.2",
|
||||
"pimcore/customer-management-framework-bundle": "<4.2.1",
|
||||
@@ -1777,7 +1782,7 @@
|
||||
"starcitizentools/short-description": ">=4,<4.0.1",
|
||||
"starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1",
|
||||
"starcitizenwiki/embedvideo": "<=4",
|
||||
"statamic/cms": "<=5.22",
|
||||
"statamic/cms": "<5.73.6|>=6,<6.2.5",
|
||||
"stormpath/sdk": "<9.9.99",
|
||||
"studio-42/elfinder": "<=2.1.64",
|
||||
"studiomitte/friendlycaptcha": "<0.1.4",
|
||||
@@ -1916,7 +1921,7 @@
|
||||
"vertexvaar/falsftp": "<0.2.6",
|
||||
"villagedefrance/opencart-overclocked": "<=1.11.1",
|
||||
"vova07/yii2-fileapi-widget": "<0.1.9",
|
||||
"vrana/adminer": "<=4.8.1",
|
||||
"vrana/adminer": "<5.4.2",
|
||||
"vufind/vufind": ">=2,<9.1.1",
|
||||
"waldhacker/hcaptcha": "<2.1.2",
|
||||
"wallabag/tcpdf": "<6.2.22",
|
||||
@@ -2046,7 +2051,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T22:06:32+00:00"
|
||||
"time": "2026-02-13T23:11:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
@@ -127,6 +127,7 @@ OC.L10N.register(
|
||||
"Threads" : "Themen",
|
||||
"Replies" : "Antworten",
|
||||
"No description available" : "Keine Beschreibung verfügbar",
|
||||
"New activity" : "Neue Aktivität",
|
||||
"Create category header" : "Kategorieüberschrift erstellen",
|
||||
"Edit category header" : "Kategorieüberschrift bearbeiten",
|
||||
"Header name" : "Name der Überschrift",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"Threads" : "Themen",
|
||||
"Replies" : "Antworten",
|
||||
"No description available" : "Keine Beschreibung verfügbar",
|
||||
"New activity" : "Neue Aktivität",
|
||||
"Create category header" : "Kategorieüberschrift erstellen",
|
||||
"Edit category header" : "Kategorieüberschrift bearbeiten",
|
||||
"Header name" : "Name der Überschrift",
|
||||
|
||||
@@ -127,6 +127,7 @@ OC.L10N.register(
|
||||
"Threads" : "Themen",
|
||||
"Replies" : "Antworten",
|
||||
"No description available" : "Keine Beschreibung verfügbar",
|
||||
"New activity" : "Neue Aktivität",
|
||||
"Create category header" : "Kategorieüberschrift erstellen",
|
||||
"Edit category header" : "Kategorieüberschrift bearbeiten",
|
||||
"Header name" : "Name der Überschrift",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"Threads" : "Themen",
|
||||
"Replies" : "Antworten",
|
||||
"No description available" : "Keine Beschreibung verfügbar",
|
||||
"New activity" : "Neue Aktivität",
|
||||
"Create category header" : "Kategorieüberschrift erstellen",
|
||||
"Edit category header" : "Kategorieüberschrift bearbeiten",
|
||||
"Header name" : "Name der Überschrift",
|
||||
|
||||
@@ -127,6 +127,7 @@ OC.L10N.register(
|
||||
"Threads" : "Threads",
|
||||
"Replies" : "Replies",
|
||||
"No description available" : "No description available",
|
||||
"New activity" : "New activity",
|
||||
"Create category header" : "Create category header",
|
||||
"Edit category header" : "Edit category header",
|
||||
"Header name" : "Header name",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"Threads" : "Threads",
|
||||
"Replies" : "Replies",
|
||||
"No description available" : "No description available",
|
||||
"New activity" : "New activity",
|
||||
"Create category header" : "Create category header",
|
||||
"Edit category header" : "Edit category header",
|
||||
"Header name" : "Header name",
|
||||
|
||||
@@ -53,6 +53,7 @@ OC.L10N.register(
|
||||
"Unread" : "No leído",
|
||||
"Save" : "Guardar",
|
||||
"Current version" : "Versión actual",
|
||||
"Add reaction" : "Añadir reacción",
|
||||
"React with {emoji}" : "Reaccionar con {emoji}",
|
||||
"Uncategorized" : "Sin categoría",
|
||||
"_%n reply_::_%n replies_" : ["%n respuesta","%n respuestas","%n respuestas"],
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"Unread" : "No leído",
|
||||
"Save" : "Guardar",
|
||||
"Current version" : "Versión actual",
|
||||
"Add reaction" : "Añadir reacción",
|
||||
"React with {emoji}" : "Reaccionar con {emoji}",
|
||||
"Uncategorized" : "Sin categoría",
|
||||
"_%n reply_::_%n replies_" : ["%n respuesta","%n respuestas","%n respuestas"],
|
||||
|
||||
@@ -126,6 +126,7 @@ OC.L10N.register(
|
||||
"Threads" : "Jutulõngad",
|
||||
"Replies" : "Vastused",
|
||||
"No description available" : "Kirjeldust pole saadaval",
|
||||
"New activity" : "Uus tegevus",
|
||||
"Create category header" : "Lisa kategooria päis",
|
||||
"Edit category header" : "Muuda kategooria päist",
|
||||
"Header name" : "Kategooria päise nimi",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"Threads" : "Jutulõngad",
|
||||
"Replies" : "Vastused",
|
||||
"No description available" : "Kirjeldust pole saadaval",
|
||||
"New activity" : "Uus tegevus",
|
||||
"Create category header" : "Lisa kategooria päis",
|
||||
"Edit category header" : "Muuda kategooria päist",
|
||||
"Header name" : "Kategooria päise nimi",
|
||||
|
||||
@@ -6,6 +6,7 @@ OC.L10N.register(
|
||||
"Guest" : "Invité",
|
||||
"General" : "Général",
|
||||
"Support" : "Support",
|
||||
"Use BBCode for rich text formatting" : "Utilisez les BBCodes pour un formatage enrichi du texte",
|
||||
"Bold text" : "Texte en gras",
|
||||
"Underlined text" : "Texte souligné",
|
||||
"Forum" : "Forum",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"Guest" : "Invité",
|
||||
"General" : "Général",
|
||||
"Support" : "Support",
|
||||
"Use BBCode for rich text formatting" : "Utilisez les BBCodes pour un formatage enrichi du texte",
|
||||
"Bold text" : "Texte en gras",
|
||||
"Underlined text" : "Texte souligné",
|
||||
"Forum" : "Forum",
|
||||
|
||||
@@ -57,6 +57,9 @@ OC.L10N.register(
|
||||
"Deleted user" : "Úsáideoir scriosta",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "Fóram atá tiomáinte ag an bpobal atá tógtha isteach i do chás Nextcloud",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Cruthaigh plé, roinn smaointe agus comhoibrigh le do phobal go díreach i Nextcloud.\n\n**⚠️ Fógra Forbartha Luath:**\nTá an aip seo i gcéimeanna luatha forbartha. Cé go bhfuil sí feidhmiúil, d'fhéadfadh fabhtanna nó gnéithe neamhiomlána teacht ort. Tuairiscigh aon fhadhbanna ar GitHub agus smaoinigh ar chúltaca de do shonraí a dhéanamh go rialta.\n\n**Príomhghnéithe:**\n- **Plé bunaithe ar shnáitheanna** - Cruthaigh agus freagair snáitheanna plé eagraithe\n- **Eagrú Catagóirí** - Struchtúraigh do fhóram le catagóirí agus ceanntásca saincheaptha\n- **Formáidiú Téacs Saibhir** - Úsáid BBCode chun poist a fhormáidiú le cló trom, iodálach, naisc, íomhánna, bloic chód agus níos mó\n- **Ceangaltáin Chomhad** - Ceangail comhaid ó do stóras Nextcloud le poist\n- **Imoibrithe Poist** - Imoibriú le poist le himoibrithe emoji\n- **Rianú Léite/Gan Léite** - Coinnigh súil ar na snáitheanna atá léite agat\n- **Cuardaigh** - Aimsigh plé go tapa le cuardach ionsuite\n- **Próifílí Úsáideoirí** - Féach ar stair agus staitisticí post úsáideoirí\n- **Ceadanna Bunaithe ar Ról** - Rialú rochtana agus modhnóireachta le róil sholúbtha\n- **Rochtain Aoi**: Rochtain phoiblí roghnach d'úsáideoirí neamhúdaraithe le ceadanna inchumraithe\n- **Uirlisí Riaracháin** - Bainistigh catagóirí, róil, BBCóid agus socruithe fóraim\n- **Uirlisí Modhnóireachta** - Snáitheanna agus poist a phionáil, a ghlasáil agus a bhainistiú\n\n**Foirfe do:**\n- Plé foirne agus comhoibriú\n- Fóraim phobail\n- Bealaí tacaíochta\n- Bunachair eolais\n- Plé tionscadail\n- Cumarsáid inmheánach\n\nComhtháthaíonn an fóram go gan uaim le do chás Nextcloud, ag baint úsáide as d'úsáideoirí agus do ghrúpaí atá ann cheana féin le haghaidh fíordheimhnithe agus rialú rochtana.",
|
||||
"Repair Database Initial Data" : "Deisigh Sonraí Tosaigh Bunachar Sonraí",
|
||||
"Run the repair database initial data command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "Rith an t-ordú \"deisigh sonraí tosaigh bunachar sonraí\" chun sonraí réamhshocraithe an fhóraim (róil, catagóirí, ceadanna, BBCóid) a athbhunú. Tá sé sábháilte é seo a rith arís agus arís eile mar go scipeálfaidh sé sonraí atá ann cheana féin.",
|
||||
"Run Repair Database Initial Data" : "Rith Deisiúchán Bunachar Sonraí Tosaigh",
|
||||
"User Roles" : "Róil Úsáideora",
|
||||
"Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users." : "Sannadh róil fóraim d'úsáideoirí. Ligeann sé seo duit pribhléidí riaracháin nó modhnóra a dheonú d'úsáideoirí sonracha.",
|
||||
"User ID" : "ID Úsáideoir",
|
||||
@@ -65,6 +68,7 @@ OC.L10N.register(
|
||||
"Select a role" : "Roghnaigh ról",
|
||||
"Assign Role" : "Sannadh Ról",
|
||||
"Failed to fetch roles" : "Theip ar róil a fháil",
|
||||
"Failed to run repair database initial data" : "Theip ar shonraí tosaigh an bhunachair shonraí a dheisiú a rith",
|
||||
"Failed to assign role" : "Theip ar ról a shannadh",
|
||||
"Loading …" : "Á lódáil…",
|
||||
"Search" : "Cuardach",
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"Deleted user" : "Úsáideoir scriosta",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "Fóram atá tiomáinte ag an bpobal atá tógtha isteach i do chás Nextcloud",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "Cruthaigh plé, roinn smaointe agus comhoibrigh le do phobal go díreach i Nextcloud.\n\n**⚠️ Fógra Forbartha Luath:**\nTá an aip seo i gcéimeanna luatha forbartha. Cé go bhfuil sí feidhmiúil, d'fhéadfadh fabhtanna nó gnéithe neamhiomlána teacht ort. Tuairiscigh aon fhadhbanna ar GitHub agus smaoinigh ar chúltaca de do shonraí a dhéanamh go rialta.\n\n**Príomhghnéithe:**\n- **Plé bunaithe ar shnáitheanna** - Cruthaigh agus freagair snáitheanna plé eagraithe\n- **Eagrú Catagóirí** - Struchtúraigh do fhóram le catagóirí agus ceanntásca saincheaptha\n- **Formáidiú Téacs Saibhir** - Úsáid BBCode chun poist a fhormáidiú le cló trom, iodálach, naisc, íomhánna, bloic chód agus níos mó\n- **Ceangaltáin Chomhad** - Ceangail comhaid ó do stóras Nextcloud le poist\n- **Imoibrithe Poist** - Imoibriú le poist le himoibrithe emoji\n- **Rianú Léite/Gan Léite** - Coinnigh súil ar na snáitheanna atá léite agat\n- **Cuardaigh** - Aimsigh plé go tapa le cuardach ionsuite\n- **Próifílí Úsáideoirí** - Féach ar stair agus staitisticí post úsáideoirí\n- **Ceadanna Bunaithe ar Ról** - Rialú rochtana agus modhnóireachta le róil sholúbtha\n- **Rochtain Aoi**: Rochtain phoiblí roghnach d'úsáideoirí neamhúdaraithe le ceadanna inchumraithe\n- **Uirlisí Riaracháin** - Bainistigh catagóirí, róil, BBCóid agus socruithe fóraim\n- **Uirlisí Modhnóireachta** - Snáitheanna agus poist a phionáil, a ghlasáil agus a bhainistiú\n\n**Foirfe do:**\n- Plé foirne agus comhoibriú\n- Fóraim phobail\n- Bealaí tacaíochta\n- Bunachair eolais\n- Plé tionscadail\n- Cumarsáid inmheánach\n\nComhtháthaíonn an fóram go gan uaim le do chás Nextcloud, ag baint úsáide as d'úsáideoirí agus do ghrúpaí atá ann cheana féin le haghaidh fíordheimhnithe agus rialú rochtana.",
|
||||
"Repair Database Initial Data" : "Deisigh Sonraí Tosaigh Bunachar Sonraí",
|
||||
"Run the repair database initial data command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "Rith an t-ordú \"deisigh sonraí tosaigh bunachar sonraí\" chun sonraí réamhshocraithe an fhóraim (róil, catagóirí, ceadanna, BBCóid) a athbhunú. Tá sé sábháilte é seo a rith arís agus arís eile mar go scipeálfaidh sé sonraí atá ann cheana féin.",
|
||||
"Run Repair Database Initial Data" : "Rith Deisiúchán Bunachar Sonraí Tosaigh",
|
||||
"User Roles" : "Róil Úsáideora",
|
||||
"Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users." : "Sannadh róil fóraim d'úsáideoirí. Ligeann sé seo duit pribhléidí riaracháin nó modhnóra a dheonú d'úsáideoirí sonracha.",
|
||||
"User ID" : "ID Úsáideoir",
|
||||
@@ -63,6 +66,7 @@
|
||||
"Select a role" : "Roghnaigh ról",
|
||||
"Assign Role" : "Sannadh Ról",
|
||||
"Failed to fetch roles" : "Theip ar róil a fháil",
|
||||
"Failed to run repair database initial data" : "Theip ar shonraí tosaigh an bhunachair shonraí a dheisiú a rith",
|
||||
"Failed to assign role" : "Theip ar ról a shannadh",
|
||||
"Loading …" : "Á lódáil…",
|
||||
"Search" : "Cuardach",
|
||||
|
||||
@@ -127,6 +127,7 @@ OC.L10N.register(
|
||||
"Threads" : "Fíos",
|
||||
"Replies" : "Respostas",
|
||||
"No description available" : "Non hai ningunha descrición dispoñíbel",
|
||||
"New activity" : "Nova actividade",
|
||||
"Create category header" : "Crear a cabeceira de categoría",
|
||||
"Edit category header" : "Editar a cabeceira de categoría",
|
||||
"Header name" : "Nome da cabeceira",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"Threads" : "Fíos",
|
||||
"Replies" : "Respostas",
|
||||
"No description available" : "Non hai ningunha descrición dispoñíbel",
|
||||
"New activity" : "Nova actividade",
|
||||
"Create category header" : "Crear a cabeceira de categoría",
|
||||
"Edit category header" : "Editar a cabeceira de categoría",
|
||||
"Header name" : "Nome da cabeceira",
|
||||
|
||||
16
l10n/hr.js
16
l10n/hr.js
@@ -3,10 +3,14 @@ OC.L10N.register(
|
||||
{
|
||||
"Admin" : "Admin",
|
||||
"User" : "@string/user_icon",
|
||||
"Guest" : "Gost",
|
||||
"General" : "Općenito",
|
||||
"Support" : "Podrška",
|
||||
"Bold text" : "Podebljani tekst",
|
||||
"Underlined text" : "Podcrtani tekst",
|
||||
"Forum" : "Forum",
|
||||
"Deleted user" : "Izbrisan korisnik",
|
||||
"User ID" : "ID korisnika",
|
||||
"Role" : "Uloga",
|
||||
"Loading …" : "Učitavanje…",
|
||||
"Search" : "Traži",
|
||||
@@ -15,6 +19,7 @@ OC.L10N.register(
|
||||
"Dashboard" : "Nadzorna ploča",
|
||||
"Users" : "Korisnici",
|
||||
"Categories" : "Kategorije",
|
||||
"Expand" : "Proširi",
|
||||
"Collapse" : "Sakrij",
|
||||
"Hello world!" : "Pozdrav svijete!",
|
||||
"Code" : "Kod",
|
||||
@@ -23,6 +28,8 @@ OC.L10N.register(
|
||||
"List" : "Popis",
|
||||
"Insert emoji" : "Umetni emoji",
|
||||
"Close" : "Zatvori",
|
||||
"Failed to upload file" : "Nije uspjelo prenijeti datoteku",
|
||||
"Threads" : "Niti",
|
||||
"Cancel" : "Cancel",
|
||||
"Create" : "Stvori",
|
||||
"Update" : "Ažuriraj",
|
||||
@@ -31,9 +38,14 @@ OC.L10N.register(
|
||||
"Back" : "Natrag",
|
||||
"Edit" : "Uredi",
|
||||
"Delete" : "Izbriši",
|
||||
"Unread" : "Nepročitano",
|
||||
"Save" : "Spremi",
|
||||
"Current version" : "Trenutna verzija",
|
||||
"Version {index}" : "Verzija {index}",
|
||||
"Add reaction" : "Dodaj reakciju",
|
||||
"React with {emoji}" : "Reagiraj s {emoji}",
|
||||
"Uncategorized" : "Nekategorizirani",
|
||||
"_%n reply_::_%n replies_" : ["%n odgovor","%n odgovora","%n odgovora"],
|
||||
"Views" : "Prikazi",
|
||||
"Title" : "Naslov",
|
||||
"Saving draft …" : "Spremanje skice...",
|
||||
@@ -41,10 +53,12 @@ OC.L10N.register(
|
||||
"Unsaved changes" : "Nespremljene promjene",
|
||||
"Refresh" : "Osvježi",
|
||||
"Retry" : "Pokušaj ponovno",
|
||||
"In {category}" : "U {category}",
|
||||
"Error" : "Pogreška",
|
||||
"Searching …" : "Traženje…",
|
||||
"No results found" : "Nema rezultata",
|
||||
"Back to {category}" : "Natrag na {category}",
|
||||
"Reply" : "Odgovori",
|
||||
"by" : "od",
|
||||
"Subscribe" : "Preplata",
|
||||
"Subscribed" : "Pretplaćen",
|
||||
@@ -54,6 +68,7 @@ OC.L10N.register(
|
||||
"Preferences" : "Preferencije",
|
||||
"Notifications" : "Obavijesti",
|
||||
"Files" : "Datoteke",
|
||||
"Browse" : "Pregledaj",
|
||||
"Signature" : "Potpis",
|
||||
"Enable" : "Omogućite",
|
||||
"Disable" : "Onemogući",
|
||||
@@ -75,6 +90,7 @@ OC.L10N.register(
|
||||
"Category" : "Kategorija",
|
||||
"Allow" : "Dopusti",
|
||||
"ID" : "ID",
|
||||
"Created" : "Stvoreno",
|
||||
"Actions" : "Radnje",
|
||||
"No users found" : "Nije pronađen nijedan korisnik",
|
||||
"Joined" : "Pridružen",
|
||||
|
||||
16
l10n/hr.json
16
l10n/hr.json
@@ -1,10 +1,14 @@
|
||||
{ "translations": {
|
||||
"Admin" : "Admin",
|
||||
"User" : "@string/user_icon",
|
||||
"Guest" : "Gost",
|
||||
"General" : "Općenito",
|
||||
"Support" : "Podrška",
|
||||
"Bold text" : "Podebljani tekst",
|
||||
"Underlined text" : "Podcrtani tekst",
|
||||
"Forum" : "Forum",
|
||||
"Deleted user" : "Izbrisan korisnik",
|
||||
"User ID" : "ID korisnika",
|
||||
"Role" : "Uloga",
|
||||
"Loading …" : "Učitavanje…",
|
||||
"Search" : "Traži",
|
||||
@@ -13,6 +17,7 @@
|
||||
"Dashboard" : "Nadzorna ploča",
|
||||
"Users" : "Korisnici",
|
||||
"Categories" : "Kategorije",
|
||||
"Expand" : "Proširi",
|
||||
"Collapse" : "Sakrij",
|
||||
"Hello world!" : "Pozdrav svijete!",
|
||||
"Code" : "Kod",
|
||||
@@ -21,6 +26,8 @@
|
||||
"List" : "Popis",
|
||||
"Insert emoji" : "Umetni emoji",
|
||||
"Close" : "Zatvori",
|
||||
"Failed to upload file" : "Nije uspjelo prenijeti datoteku",
|
||||
"Threads" : "Niti",
|
||||
"Cancel" : "Cancel",
|
||||
"Create" : "Stvori",
|
||||
"Update" : "Ažuriraj",
|
||||
@@ -29,9 +36,14 @@
|
||||
"Back" : "Natrag",
|
||||
"Edit" : "Uredi",
|
||||
"Delete" : "Izbriši",
|
||||
"Unread" : "Nepročitano",
|
||||
"Save" : "Spremi",
|
||||
"Current version" : "Trenutna verzija",
|
||||
"Version {index}" : "Verzija {index}",
|
||||
"Add reaction" : "Dodaj reakciju",
|
||||
"React with {emoji}" : "Reagiraj s {emoji}",
|
||||
"Uncategorized" : "Nekategorizirani",
|
||||
"_%n reply_::_%n replies_" : ["%n odgovor","%n odgovora","%n odgovora"],
|
||||
"Views" : "Prikazi",
|
||||
"Title" : "Naslov",
|
||||
"Saving draft …" : "Spremanje skice...",
|
||||
@@ -39,10 +51,12 @@
|
||||
"Unsaved changes" : "Nespremljene promjene",
|
||||
"Refresh" : "Osvježi",
|
||||
"Retry" : "Pokušaj ponovno",
|
||||
"In {category}" : "U {category}",
|
||||
"Error" : "Pogreška",
|
||||
"Searching …" : "Traženje…",
|
||||
"No results found" : "Nema rezultata",
|
||||
"Back to {category}" : "Natrag na {category}",
|
||||
"Reply" : "Odgovori",
|
||||
"by" : "od",
|
||||
"Subscribe" : "Preplata",
|
||||
"Subscribed" : "Pretplaćen",
|
||||
@@ -52,6 +66,7 @@
|
||||
"Preferences" : "Preferencije",
|
||||
"Notifications" : "Obavijesti",
|
||||
"Files" : "Datoteke",
|
||||
"Browse" : "Pregledaj",
|
||||
"Signature" : "Potpis",
|
||||
"Enable" : "Omogućite",
|
||||
"Disable" : "Onemogući",
|
||||
@@ -73,6 +88,7 @@
|
||||
"Category" : "Kategorija",
|
||||
"Allow" : "Dopusti",
|
||||
"ID" : "ID",
|
||||
"Created" : "Stvoreno",
|
||||
"Actions" : "Radnje",
|
||||
"No users found" : "Nije pronađen nijedan korisnik",
|
||||
"Joined" : "Pridružen",
|
||||
|
||||
@@ -3,6 +3,7 @@ OC.L10N.register(
|
||||
{
|
||||
"Admin" : "Admin",
|
||||
"User" : "Utente",
|
||||
"Guest" : "Ospite",
|
||||
"General" : "Generale",
|
||||
"Support" : "Supporto",
|
||||
"Bold text" : "Grassetto",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{ "translations": {
|
||||
"Admin" : "Admin",
|
||||
"User" : "Utente",
|
||||
"Guest" : "Ospite",
|
||||
"General" : "Generale",
|
||||
"Support" : "Supporto",
|
||||
"Bold text" : "Grassetto",
|
||||
|
||||
@@ -447,6 +447,7 @@ OC.L10N.register(
|
||||
"Created" : "Imetengenezwa",
|
||||
"Actions" : "Matendo",
|
||||
"No description" : "No description",
|
||||
"No users found" : "Hakuna watumiaji waliopatikana",
|
||||
"Status" : "Wadhifa/hadhi/hali",
|
||||
"Active" : "Inayotumika",
|
||||
"Deleted" : "Vilivyofutwa"
|
||||
|
||||
@@ -445,6 +445,7 @@
|
||||
"Created" : "Imetengenezwa",
|
||||
"Actions" : "Matendo",
|
||||
"No description" : "No description",
|
||||
"No users found" : "Hakuna watumiaji waliopatikana",
|
||||
"Status" : "Wadhifa/hadhi/hali",
|
||||
"Active" : "Inayotumika",
|
||||
"Deleted" : "Vilivyofutwa"
|
||||
|
||||
@@ -127,6 +127,7 @@ OC.L10N.register(
|
||||
"Threads" : "討論串",
|
||||
"Replies" : "回覆",
|
||||
"No description available" : "沒有可用描述",
|
||||
"New activity" : "新活動",
|
||||
"Create category header" : "建立分類標題列",
|
||||
"Edit category header" : "編輯分類標題列",
|
||||
"Header name" : "標題列名稱",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"Threads" : "討論串",
|
||||
"Replies" : "回覆",
|
||||
"No description available" : "沒有可用描述",
|
||||
"New activity" : "新活動",
|
||||
"Create category header" : "建立分類標題列",
|
||||
"Edit category header" : "編輯分類標題列",
|
||||
"Header name" : "標題列名稱",
|
||||
|
||||
@@ -57,6 +57,9 @@ OC.L10N.register(
|
||||
"Deleted user" : "已刪除的使用者",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "一個內建於您的 Nextcloud 站台中的社群驅動論壇",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "直接在 Nextcloud 建立討論、分享點子及與您的社群協作。\n\n**⚠️ 早期開發警告:**\n此應用程式仍在早期開發階段。雖然可以運作,但您可能會遇到臭蟲或不完整的功能。請在 GitHub 回報問題,並考慮定期備份您的資料。\n\n**重要功能:**\n- **以主題為基礎的討論** - 建立並回覆有條理的討論主題\n- **整理分類** - 以自訂分類與標題整理論壇\n- **豐富文字格式** - 使用 BBCode 格式化您的貼文,可使用粗體、義式斜體、連結、影像、程式碼區塊等\n- **檔案附件** - 從您的 Nextcloud 儲存空間附加檔案到貼文中\n- **貼文反應** - 使用表情符號對貼文做出反應\n- **已讀/未讀追蹤** - 追蹤您已讀過哪些討論串\n- **搜尋** - 使用內建搜尋快速尋找討論\n- **使用者個人檔案** - 檢視使用者貼文歷史紀錄與統計資料\n- **以角色為基礎的權限** - 透過彈性的角色設定控制存取與管理權限\n- **訪客存取**:可選擇允許未登入訪客以自訂權限瀏覽\n- **管理員工具** - 管理分類、角色、BBCode 與論壇設定\n- **版主工具** - 釘選、鎖定與管理討論串及貼文\n\n**非常適合:**\n- 團隊討論與協作\n- 社群論壇\n- 支援頻道\n- 知識庫\n- 專案討論\n- 內部溝通\n\n論壇與您的 Nextcloud 站台無縫整合,使用您既有的使用者、群組作為驗證與存取控制。",
|
||||
"Repair Database Initial Data" : "修復資料庫初始資料",
|
||||
"Run the repair database initial data command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "執行修復資料庫初始資料指令以還原預設論壇資料(角色、分類、權限、BBCode)。此指令可安全地多次執行,因其會跳過已存在的資料。",
|
||||
"Run Repair Database Initial Data" : "執行修復資料庫初始資料",
|
||||
"User Roles" : "使用者角色",
|
||||
"Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users." : "為使用者指派論壇角色。此功能可讓您授予特定使用者管理員或版主權限。",
|
||||
"User ID" : "使用者 ID",
|
||||
@@ -65,6 +68,7 @@ OC.L10N.register(
|
||||
"Select a role" : "選取角色",
|
||||
"Assign Role" : "指派角色",
|
||||
"Failed to fetch roles" : "擷取角色失敗",
|
||||
"Failed to run repair database initial data" : "執行修復資料庫初始資料失敗",
|
||||
"Failed to assign role" : "指派角色失敗",
|
||||
"Loading …" : "正在載入……",
|
||||
"Search" : "搜尋",
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"Deleted user" : "已刪除的使用者",
|
||||
"A community-driven forum built right into your Nextcloud instance" : "一個內建於您的 Nextcloud 站台中的社群驅動論壇",
|
||||
"Create discussions, share ideas and collaborate with your community directly in Nextcloud.\n\n**⚠️ Early Development Notice:**\nThis app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.\n\n**Key features:**\n- **Thread-based Discussions** - Create and reply to organized discussion threads\n- **Category Organization** - Structure your forum with customizable categories and headers\n- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more\n- **File Attachments** - Attach files from your Nextcloud storage to posts\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Keep track of which threads you've read\n- **Search** - Find discussions quickly with built-in search\n- **User Profiles** - View user post history and statistics\n- **Role-Based Permissions** - Control access and moderation with flexible roles\n- **Guest Access**: Optional public access for unauthenticated users with configurable permissions\n- **Admin Tools** - Manage categories, roles, BBCodes and forum settings\n- **Moderation Tools** - Pin, lock and manage threads and posts\n\n**Perfect for:**\n- Team discussions and collaboration\n- Community forums\n- Support channels\n- Knowledge bases\n- Project discussions\n- Internal communication\n\nThe forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control." : "直接在 Nextcloud 建立討論、分享點子及與您的社群協作。\n\n**⚠️ 早期開發警告:**\n此應用程式仍在早期開發階段。雖然可以運作,但您可能會遇到臭蟲或不完整的功能。請在 GitHub 回報問題,並考慮定期備份您的資料。\n\n**重要功能:**\n- **以主題為基礎的討論** - 建立並回覆有條理的討論主題\n- **整理分類** - 以自訂分類與標題整理論壇\n- **豐富文字格式** - 使用 BBCode 格式化您的貼文,可使用粗體、義式斜體、連結、影像、程式碼區塊等\n- **檔案附件** - 從您的 Nextcloud 儲存空間附加檔案到貼文中\n- **貼文反應** - 使用表情符號對貼文做出反應\n- **已讀/未讀追蹤** - 追蹤您已讀過哪些討論串\n- **搜尋** - 使用內建搜尋快速尋找討論\n- **使用者個人檔案** - 檢視使用者貼文歷史紀錄與統計資料\n- **以角色為基礎的權限** - 透過彈性的角色設定控制存取與管理權限\n- **訪客存取**:可選擇允許未登入訪客以自訂權限瀏覽\n- **管理員工具** - 管理分類、角色、BBCode 與論壇設定\n- **版主工具** - 釘選、鎖定與管理討論串及貼文\n\n**非常適合:**\n- 團隊討論與協作\n- 社群論壇\n- 支援頻道\n- 知識庫\n- 專案討論\n- 內部溝通\n\n論壇與您的 Nextcloud 站台無縫整合,使用您既有的使用者、群組作為驗證與存取控制。",
|
||||
"Repair Database Initial Data" : "修復資料庫初始資料",
|
||||
"Run the repair database initial data command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "執行修復資料庫初始資料指令以還原預設論壇資料(角色、分類、權限、BBCode)。此指令可安全地多次執行,因其會跳過已存在的資料。",
|
||||
"Run Repair Database Initial Data" : "執行修復資料庫初始資料",
|
||||
"User Roles" : "使用者角色",
|
||||
"Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users." : "為使用者指派論壇角色。此功能可讓您授予特定使用者管理員或版主權限。",
|
||||
"User ID" : "使用者 ID",
|
||||
@@ -63,6 +66,7 @@
|
||||
"Select a role" : "選取角色",
|
||||
"Assign Role" : "指派角色",
|
||||
"Failed to fetch roles" : "擷取角色失敗",
|
||||
"Failed to run repair database initial data" : "執行修復資料庫初始資料失敗",
|
||||
"Failed to assign role" : "指派角色失敗",
|
||||
"Loading …" : "正在載入……",
|
||||
"Search" : "搜尋",
|
||||
|
||||
@@ -121,7 +121,7 @@ class BookmarkController extends OCSController {
|
||||
*
|
||||
* @param int $page Page number (1-indexed)
|
||||
* @param int $perPage Number of threads per page
|
||||
* @return DataResponse<Http::STATUS_OK, array{threads: list<array<string, mixed>>, pagination: array{page: int, perPage: int, total: int, totalPages: int}, readMarkers: array<string, array{threadId: int, lastReadPostId: int, readAt: int}>}, array{}>
|
||||
* @return DataResponse<Http::STATUS_OK, array{threads: list<array<string, mixed>>, pagination: array{page: int, perPage: int, total: int, totalPages: int}, readMarkers: array<string, array{entityId: int, lastReadPostId: int, readAt: int}>}, array{}>
|
||||
*
|
||||
* 200: Bookmarked threads returned with pagination and read markers
|
||||
*/
|
||||
@@ -197,8 +197,8 @@ class BookmarkController extends OCSController {
|
||||
$readMarkers = [];
|
||||
$markers = $this->readMarkerMapper->findByUserAndThreads($userId, $threadIds);
|
||||
foreach ($markers as $marker) {
|
||||
$readMarkers[$marker->getThreadId()] = [
|
||||
'threadId' => $marker->getThreadId(),
|
||||
$readMarkers[$marker->getEntityId()] = [
|
||||
'entityId' => $marker->getEntityId(),
|
||||
'lastReadPostId' => $marker->getLastReadPostId(),
|
||||
'readAt' => $marker->getReadAt(),
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ use OCA\Forum\Db\CategoryMapper;
|
||||
use OCA\Forum\Db\CategoryPerm;
|
||||
use OCA\Forum\Db\CategoryPermMapper;
|
||||
use OCA\Forum\Db\CatHeaderMapper;
|
||||
use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\Role;
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
@@ -35,6 +36,7 @@ class CategoryController extends OCSController {
|
||||
private CategoryMapper $categoryMapper,
|
||||
private CategoryPermMapper $categoryPermMapper,
|
||||
private ThreadMapper $threadMapper,
|
||||
private ReadMarkerMapper $readMarkerMapper,
|
||||
private RoleMapper $roleMapper,
|
||||
private IUserSession $userSession,
|
||||
private IGroupManager $groupManager,
|
||||
@@ -55,9 +57,20 @@ class CategoryController extends OCSController {
|
||||
#[ApiRoute(verb: 'GET', url: '/api/categories')]
|
||||
public function index(): DataResponse {
|
||||
try {
|
||||
// Fetch all headers and categories in just 2 queries
|
||||
// Fetch all headers, categories, and last activity timestamps
|
||||
$headers = $this->catHeaderMapper->findAll();
|
||||
$allCategories = $this->categoryMapper->findAll();
|
||||
$lastActivityMap = $this->threadMapper->getLastActivityByCategories();
|
||||
|
||||
// Fetch category read markers for authenticated users
|
||||
$readMarkerMap = [];
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user) {
|
||||
$markers = $this->readMarkerMapper->findCategoryMarkersByUserId($user->getUID());
|
||||
foreach ($markers as $marker) {
|
||||
$readMarkerMap[$marker->getEntityId()] = $marker->getReadAt();
|
||||
}
|
||||
}
|
||||
|
||||
// Group categories by header_id
|
||||
$categoriesByHeader = [];
|
||||
@@ -66,7 +79,10 @@ class CategoryController extends OCSController {
|
||||
if (!isset($categoriesByHeader[$headerId])) {
|
||||
$categoriesByHeader[$headerId] = [];
|
||||
}
|
||||
$categoriesByHeader[$headerId][] = $category->jsonSerialize();
|
||||
$categoryData = $category->jsonSerialize();
|
||||
$categoryData['lastActivityAt'] = $lastActivityMap[$category->getId()] ?? null;
|
||||
$categoryData['readAt'] = $readMarkerMap[$category->getId()] ?? null;
|
||||
$categoriesByHeader[$headerId][] = $categoryData;
|
||||
}
|
||||
|
||||
// Build result with nested categories
|
||||
|
||||
@@ -35,19 +35,34 @@ class ReadMarkerController extends OCSController {
|
||||
* Get read markers for multiple threads
|
||||
*
|
||||
* @param string $threadIds Array of thread IDs (comma-separated in query string)
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, array{threadId: int, lastReadPostId: int, readAt: int}>, array{}>
|
||||
* @param string $markerType Marker type ('thread' or 'category')
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, array{entityId: int, lastReadPostId: int|null, readAt: int}>, array{}>
|
||||
*
|
||||
* 200: Read markers returned (keyed by thread ID)
|
||||
* 200: Read markers returned (keyed by entity ID)
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/read-markers')]
|
||||
public function index(string $threadIds = ''): DataResponse {
|
||||
public function index(string $threadIds = '', string $markerType = 'thread'): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Category markers
|
||||
if ($markerType === 'category') {
|
||||
$markers = $this->readMarkerMapper->findCategoryMarkersByUserId($user->getUID());
|
||||
$result = [];
|
||||
foreach ($markers as $marker) {
|
||||
$result[$marker->getEntityId()] = [
|
||||
'entityId' => $marker->getEntityId(),
|
||||
'readAt' => $marker->getReadAt(),
|
||||
];
|
||||
}
|
||||
return new DataResponse($result);
|
||||
}
|
||||
|
||||
// Thread markers (default)
|
||||
// Parse thread IDs from query parameter
|
||||
$threadIdArray = [];
|
||||
if (!empty($threadIds)) {
|
||||
@@ -59,8 +74,8 @@ class ReadMarkerController extends OCSController {
|
||||
$markers = $this->readMarkerMapper->findByUserId($user->getUID());
|
||||
$result = [];
|
||||
foreach ($markers as $marker) {
|
||||
$result[$marker->getThreadId()] = [
|
||||
'threadId' => $marker->getThreadId(),
|
||||
$result[$marker->getEntityId()] = [
|
||||
'entityId' => $marker->getEntityId(),
|
||||
'lastReadPostId' => $marker->getLastReadPostId(),
|
||||
'readAt' => $marker->getReadAt(),
|
||||
];
|
||||
@@ -73,8 +88,8 @@ class ReadMarkerController extends OCSController {
|
||||
// Convert to associative array keyed by thread ID for easier frontend lookup
|
||||
$result = [];
|
||||
foreach ($markers as $marker) {
|
||||
$result[$marker->getThreadId()] = [
|
||||
'threadId' => $marker->getThreadId(),
|
||||
$result[$marker->getEntityId()] = [
|
||||
'entityId' => $marker->getEntityId(),
|
||||
'lastReadPostId' => $marker->getLastReadPostId(),
|
||||
'readAt' => $marker->getReadAt(),
|
||||
];
|
||||
@@ -115,23 +130,38 @@ class ReadMarkerController extends OCSController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a thread as read
|
||||
* Mark a thread or category as read
|
||||
*
|
||||
* @param int $threadId Thread ID
|
||||
* @param int $lastReadPostId Last read post ID
|
||||
* @param int|null $categoryId Category ID (if provided, creates a category marker instead)
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
|
||||
*
|
||||
* 200: Thread marked as read
|
||||
* 200: Marked as read
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/read-markers')]
|
||||
public function create(int $threadId, int $lastReadPostId): DataResponse {
|
||||
public function create(int $threadId = 0, int $lastReadPostId = 0, ?int $categoryId = null): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Category marker
|
||||
if ($categoryId !== null) {
|
||||
$marker = $this->readMarkerMapper->createOrUpdateCategoryMarker(
|
||||
$user->getUID(),
|
||||
$categoryId
|
||||
);
|
||||
return new DataResponse($marker->jsonSerialize());
|
||||
}
|
||||
|
||||
// Thread marker (default)
|
||||
if ($threadId === 0 || $lastReadPostId === 0) {
|
||||
return new DataResponse(['error' => 'threadId and lastReadPostId are required'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$marker = $this->readMarkerMapper->createOrUpdate(
|
||||
$user->getUID(),
|
||||
$threadId,
|
||||
@@ -152,8 +182,8 @@ class ReadMarkerController extends OCSController {
|
||||
|
||||
return new DataResponse($marker->jsonSerialize());
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error marking thread as read: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to mark thread as read'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
$this->logger->error('Error marking as read: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to mark as read'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,23 +16,30 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setId(int $value)
|
||||
* @method string getUserId()
|
||||
* @method void setUserId(string $value)
|
||||
* @method int getThreadId()
|
||||
* @method void setThreadId(int $value)
|
||||
* @method int getLastReadPostId()
|
||||
* @method void setLastReadPostId(int $value)
|
||||
* @method int getEntityId()
|
||||
* @method void setEntityId(int $value)
|
||||
* @method string getMarkerType()
|
||||
* @method void setMarkerType(string $value)
|
||||
* @method int|null getLastReadPostId()
|
||||
* @method void setLastReadPostId(?int $value)
|
||||
* @method int getReadAt()
|
||||
* @method void setReadAt(int $value)
|
||||
*/
|
||||
class ReadMarker extends Entity implements JsonSerializable {
|
||||
public const TYPE_THREAD = 'thread';
|
||||
public const TYPE_CATEGORY = 'category';
|
||||
|
||||
protected $userId;
|
||||
protected $threadId;
|
||||
protected $entityId;
|
||||
protected $markerType;
|
||||
protected $lastReadPostId;
|
||||
protected $readAt;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', 'integer');
|
||||
$this->addType('userId', 'string');
|
||||
$this->addType('threadId', 'integer');
|
||||
$this->addType('entityId', 'integer');
|
||||
$this->addType('markerType', 'string');
|
||||
$this->addType('lastReadPostId', 'integer');
|
||||
$this->addType('readAt', 'integer');
|
||||
}
|
||||
@@ -41,7 +48,8 @@ class ReadMarker extends Entity implements JsonSerializable {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'userId' => $this->getUserId(),
|
||||
'threadId' => $this->getThreadId(),
|
||||
'entityId' => $this->getEntityId(),
|
||||
'markerType' => $this->getMarkerType(),
|
||||
'lastReadPostId' => $this->getLastReadPostId(),
|
||||
'readAt' => $this->getReadAt(),
|
||||
];
|
||||
|
||||
@@ -52,7 +52,10 @@ class ReadMarkerMapper extends QBMapper {
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
|
||||
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('entity_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
@@ -67,6 +70,9 @@ class ReadMarkerMapper extends QBMapper {
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
@@ -91,7 +97,10 @@ class ReadMarkerMapper extends QBMapper {
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->in('thread_id', $qb->createNamedParameter($threadIds, IQueryBuilder::PARAM_INT_ARRAY))
|
||||
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->in('entity_id', $qb->createNamedParameter($threadIds, IQueryBuilder::PARAM_INT_ARRAY))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
@@ -119,13 +128,67 @@ class ReadMarkerMapper extends QBMapper {
|
||||
// Create new marker
|
||||
$marker = new ReadMarker();
|
||||
$marker->setUserId($userId);
|
||||
$marker->setThreadId($threadId);
|
||||
$marker->setEntityId($threadId);
|
||||
$marker->setMarkerType(ReadMarker::TYPE_THREAD);
|
||||
$marker->setLastReadPostId($lastReadPostId);
|
||||
$marker->setReadAt(time());
|
||||
return $this->insert($marker);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all category read markers for a user
|
||||
*
|
||||
* @return array<ReadMarker>
|
||||
*/
|
||||
public function findCategoryMarkersByUserId(string $userId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_CATEGORY, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a category read marker
|
||||
*/
|
||||
public function createOrUpdateCategoryMarker(string $userId, int $categoryId): ReadMarker {
|
||||
try {
|
||||
// Try to find existing marker
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_CATEGORY, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('entity_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
$marker = $this->findEntity($qb);
|
||||
|
||||
// Always update the timestamp
|
||||
$marker->setReadAt(time());
|
||||
return $this->update($marker);
|
||||
} catch (DoesNotExistException $e) {
|
||||
// Create new marker
|
||||
$marker = new ReadMarker();
|
||||
$marker->setUserId($userId);
|
||||
$marker->setEntityId($categoryId);
|
||||
$marker->setMarkerType(ReadMarker::TYPE_CATEGORY);
|
||||
$marker->setLastReadPostId(null);
|
||||
$marker->setReadAt(time());
|
||||
return $this->insert($marker);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ReadMarker>
|
||||
*/
|
||||
|
||||
@@ -220,6 +220,31 @@ class ThreadMapper extends QBMapper {
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last activity timestamp (max updated_at) per category
|
||||
*
|
||||
* @return array<int, int> categoryId => lastActivityTimestamp
|
||||
*/
|
||||
public function getLastActivityByCategories(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('category_id')
|
||||
->selectAlias($qb->func()->max('updated_at'), 'last_activity')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->isNull('deleted_at'))
|
||||
->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
|
||||
->groupBy('category_id');
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
$rows = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['category_id']] = (int)$row['last_activity'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find recent threads in specified categories
|
||||
*
|
||||
|
||||
96
lib/Migration/Version18Date20260214000000.php
Normal file
96
lib/Migration/Version18Date20260214000000.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 18 Migration:
|
||||
* - Make forum_read_markers polymorphic by adding entity_id and marker_type columns
|
||||
* - Make last_read_post_id nullable (category markers don't need it)
|
||||
* - Copy thread_id values to entity_id
|
||||
* - Drop old indexes
|
||||
*/
|
||||
class Version18Date20260214000000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return ISchemaWrapper|null
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('forum_read_markers')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('forum_read_markers');
|
||||
|
||||
// Add entity_id column (will be populated from thread_id in postSchemaChange)
|
||||
if (!$table->hasColumn('entity_id')) {
|
||||
$output->info('Forum: Adding entity_id column to forum_read_markers...');
|
||||
$table->addColumn('entity_id', 'bigint', [
|
||||
'notnull' => true,
|
||||
'unsigned' => true,
|
||||
'default' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// Add marker_type column
|
||||
if (!$table->hasColumn('marker_type')) {
|
||||
$output->info('Forum: Adding marker_type column to forum_read_markers...');
|
||||
$table->addColumn('marker_type', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 16,
|
||||
'default' => 'thread',
|
||||
]);
|
||||
}
|
||||
|
||||
// Make last_read_post_id nullable (category markers don't use it)
|
||||
$lastReadPostIdCol = $table->getColumn('last_read_post_id');
|
||||
$lastReadPostIdCol->setNotnull(false);
|
||||
$lastReadPostIdCol->setDefault(null);
|
||||
|
||||
// Drop old indexes
|
||||
if ($table->hasIndex('forum_read_mark_uniq_idx')) {
|
||||
$output->info('Forum: Dropping old unique index forum_read_mark_uniq_idx...');
|
||||
$table->dropIndex('forum_read_mark_uniq_idx');
|
||||
}
|
||||
if ($table->hasIndex('forum_read_mark_tid_idx')) {
|
||||
$output->info('Forum: Dropping old index forum_read_mark_tid_idx...');
|
||||
$table->dropIndex('forum_read_mark_tid_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy thread_id values to entity_id
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
$output->info('Forum: Copying thread_id values to entity_id...');
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('forum_read_markers')
|
||||
->set('entity_id', 'thread_id');
|
||||
$qb->executeStatement();
|
||||
|
||||
$output->info('Forum: thread_id values copied to entity_id successfully.');
|
||||
}
|
||||
}
|
||||
57
lib/Migration/Version19Date20260214000001.php
Normal file
57
lib/Migration/Version19Date20260214000001.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Version 19 Migration:
|
||||
* - Drop thread_id column (data already copied to entity_id in Version18)
|
||||
* - Add new indexes for polymorphic read markers
|
||||
*/
|
||||
class Version19Date20260214000001 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return ISchemaWrapper|null
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('forum_read_markers')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('forum_read_markers');
|
||||
|
||||
// Drop thread_id column
|
||||
if ($table->hasColumn('thread_id')) {
|
||||
$output->info('Forum: Dropping thread_id column from forum_read_markers...');
|
||||
$table->dropColumn('thread_id');
|
||||
}
|
||||
|
||||
// Add unique index on (user_id, marker_type, entity_id)
|
||||
if (!$table->hasIndex('forum_read_mark_uniq_idx')) {
|
||||
$output->info('Forum: Adding unique index forum_read_mark_uniq_idx...');
|
||||
$table->addUniqueIndex(['user_id', 'marker_type', 'entity_id'], 'forum_read_mark_uniq_idx');
|
||||
}
|
||||
|
||||
// Add index on entity_id
|
||||
if (!$table->hasIndex('forum_read_mark_eid_idx')) {
|
||||
$output->info('Forum: Adding index forum_read_mark_eid_idx...');
|
||||
$table->addIndex(['entity_id'], 'forum_read_mark_eid_idx');
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -1768,12 +1768,12 @@
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"threadId",
|
||||
"entityId",
|
||||
"lastReadPostId",
|
||||
"readAt"
|
||||
],
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"entityId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
@@ -6237,6 +6237,15 @@
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "markerType",
|
||||
"in": "query",
|
||||
"description": "Marker type ('thread' or 'category')",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "thread"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
@@ -6250,7 +6259,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Read markers returned (keyed by thread ID)",
|
||||
"description": "Read markers returned (keyed by entity ID)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -6274,18 +6283,19 @@
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"threadId",
|
||||
"entityId",
|
||||
"lastReadPostId",
|
||||
"readAt"
|
||||
],
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"entityId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"lastReadPostId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"readAt": {
|
||||
"type": "integer",
|
||||
@@ -6333,7 +6343,7 @@
|
||||
},
|
||||
"post": {
|
||||
"operationId": "read_marker-create",
|
||||
"summary": "Mark a thread as read",
|
||||
"summary": "Mark a thread or category as read",
|
||||
"tags": [
|
||||
"read_marker"
|
||||
],
|
||||
@@ -6346,25 +6356,30 @@
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"threadId",
|
||||
"lastReadPostId"
|
||||
],
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0,
|
||||
"description": "Thread ID"
|
||||
},
|
||||
"lastReadPostId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0,
|
||||
"description": "Last read post ID"
|
||||
},
|
||||
"categoryId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Category ID (if provided, creates a category marker instead)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6385,7 +6400,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Thread marked as read",
|
||||
"description": "Marked as read",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
|
||||
41
openapi.json
41
openapi.json
@@ -1768,12 +1768,12 @@
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"threadId",
|
||||
"entityId",
|
||||
"lastReadPostId",
|
||||
"readAt"
|
||||
],
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"entityId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
@@ -6237,6 +6237,15 @@
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "markerType",
|
||||
"in": "query",
|
||||
"description": "Marker type ('thread' or 'category')",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "thread"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
@@ -6250,7 +6259,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Read markers returned (keyed by thread ID)",
|
||||
"description": "Read markers returned (keyed by entity ID)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -6274,18 +6283,19 @@
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"threadId",
|
||||
"entityId",
|
||||
"lastReadPostId",
|
||||
"readAt"
|
||||
],
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"entityId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"lastReadPostId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"readAt": {
|
||||
"type": "integer",
|
||||
@@ -6333,7 +6343,7 @@
|
||||
},
|
||||
"post": {
|
||||
"operationId": "read_marker-create",
|
||||
"summary": "Mark a thread as read",
|
||||
"summary": "Mark a thread or category as read",
|
||||
"tags": [
|
||||
"read_marker"
|
||||
],
|
||||
@@ -6346,25 +6356,30 @@
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"threadId",
|
||||
"lastReadPostId"
|
||||
],
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0,
|
||||
"description": "Thread ID"
|
||||
},
|
||||
"lastReadPostId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0,
|
||||
"description": "Last read post ID"
|
||||
},
|
||||
"categoryId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Category ID (if provided, creates a category marker instead)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6385,7 +6400,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Thread marked as read",
|
||||
"description": "Marked as read",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
|
||||
12
package.json
12
package.json
@@ -24,14 +24,14 @@
|
||||
"dependencies": {
|
||||
"@nextcloud/auth": "^2.5.3",
|
||||
"@nextcloud/axios": "^2.5.2",
|
||||
"@nextcloud/dialogs": "^7.2.0",
|
||||
"@nextcloud/dialogs": "^7.3.0",
|
||||
"@nextcloud/l10n": "^3.4.1",
|
||||
"@nextcloud/router": "^3.1.0",
|
||||
"@nextcloud/vite-config": "2.3.5",
|
||||
"@nextcloud/vue": "^9.4.0",
|
||||
"@nextcloud/vue": "^9.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"vue": "^3.5.27",
|
||||
"vue": "^3.5.28",
|
||||
"vue-material-design-icons": "^5.3.1",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
@@ -39,12 +39,12 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@nextcloud/browserslist-config": "^3.1.2",
|
||||
"@nextcloud/eslint-config": "^8.4.2",
|
||||
"@nextcloud/stylelint-config": "^3.2.0",
|
||||
"@nextcloud/stylelint-config": "^3.2.1",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.39.2",
|
||||
"happy-dom": "^20.5.0",
|
||||
"happy-dom": "^20.6.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^2.8.8",
|
||||
@@ -53,7 +53,7 @@
|
||||
"sass": "^1.97.3",
|
||||
"sass-embedded": "^1.97.3",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
736
pnpm-lock.yaml
generated
736
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
13
src/App.vue
13
src/App.vue
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NcContent app-name="forum" :data-theme-dark="isDarkTheme">
|
||||
<NcContent id="content-forum" app-name="forum" :data-forum-dark="isDarkTheme">
|
||||
<!-- Left sidebar -->
|
||||
<AppNavigation />
|
||||
|
||||
@@ -24,10 +24,10 @@ import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppNavigation from '@/components/AppNavigation'
|
||||
import { isDarkTheme } from '@nextcloud/vue/functions/isDarkTheme'
|
||||
import { useIsDarkTheme } from '@nextcloud/vue/composables/useIsDarkTheme'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppUserWrapper',
|
||||
name: 'ForumApp',
|
||||
components: {
|
||||
NcContent,
|
||||
NcAppContent,
|
||||
@@ -38,9 +38,12 @@ export default defineComponent({
|
||||
provide() {
|
||||
return { 'NcContent:setHasAppNavigation': () => true }
|
||||
},
|
||||
setup() {
|
||||
const isDarkTheme = useIsDarkTheme()
|
||||
return { isDarkTheme }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDarkTheme,
|
||||
isRouterLoading: false,
|
||||
_removeBeforeEach: null as (() => void) | null,
|
||||
_removeAfterEach: null as (() => void) | null,
|
||||
@@ -128,7 +131,7 @@ export default defineComponent({
|
||||
<style lang="scss">
|
||||
// Fix content width on mobile
|
||||
@media screen and (max-width: 768px) {
|
||||
#content-vue.app-forum {
|
||||
#content-forum.app-forum {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="category-card">
|
||||
<div class="category-card" :class="{ unread: isUnread }">
|
||||
<div class="category-header">
|
||||
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
|
||||
<h4 class="category-name">{{ category.name }}</h4>
|
||||
<div class="category-stats">
|
||||
<span class="stat">
|
||||
@@ -31,6 +32,10 @@ export default defineComponent({
|
||||
type: Object as PropType<Category>,
|
||||
required: true,
|
||||
},
|
||||
isUnread: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -38,6 +43,7 @@ export default defineComponent({
|
||||
threads: (count: number) => t('forum', 'Threads'),
|
||||
replies: (count: number) => t('forum', 'Replies'),
|
||||
noDescription: t('forum', 'No description available'),
|
||||
unread: t('forum', 'New activity'),
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -57,11 +63,25 @@ export default defineComponent({
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
border-left: 4px solid var(--color-primary-element);
|
||||
background: var(--color-primary-element-light-hover);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.unread-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--color-primary-element);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -434,7 +434,7 @@ describe('MoveCategoryDialog', () => {
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
|
||||
it('does not close when moving', async () => {
|
||||
it('allows closing even when moving', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
@@ -459,8 +459,9 @@ describe('MoveCategoryDialog', () => {
|
||||
// Try to close
|
||||
vm.handleClose()
|
||||
|
||||
// Should not emit close event
|
||||
expect(wrapper.emitted('update:open')).toBeFalsy()
|
||||
// Should emit close event even while moving
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:open')![0]).toEqual([false])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -519,6 +520,37 @@ describe('MoveCategoryDialog', () => {
|
||||
expect(vm.selectedCategory).toBeNull()
|
||||
})
|
||||
|
||||
it('resets moving state when dialog reopens', async () => {
|
||||
mockCategoryHeaders.value = [
|
||||
createMockHeader({
|
||||
id: 1,
|
||||
name: 'Header',
|
||||
categories: [createMockCategory({ id: 20, name: 'Category' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const wrapper = createWrapper({ open: true, currentCategoryId: 10 })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedCategory: { id: number; name: string; isHeader?: boolean } | null
|
||||
handleMove: () => void
|
||||
moving: boolean
|
||||
}
|
||||
vm.selectedCategory = { id: 20, name: 'Category', isHeader: false }
|
||||
|
||||
// Start moving
|
||||
vm.handleMove()
|
||||
expect(vm.moving).toBe(true)
|
||||
|
||||
// Close and reopen
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.moving).toBe(false)
|
||||
})
|
||||
|
||||
it('refetches categories when dialog reopens', async () => {
|
||||
const wrapper = createWrapper({ open: true })
|
||||
await flushPromises()
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="handleClose" :disabled="moving">
|
||||
<NcButton @click="handleClose">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
@@ -168,6 +168,7 @@ export default defineComponent({
|
||||
immediate: true,
|
||||
handler(newValue) {
|
||||
if (newValue) {
|
||||
this.moving = false
|
||||
this.loadCategories()
|
||||
this.selectedCategory = null
|
||||
}
|
||||
@@ -189,9 +190,7 @@ export default defineComponent({
|
||||
},
|
||||
|
||||
handleClose() {
|
||||
if (!this.moving) {
|
||||
this.$emit('update:open', false)
|
||||
}
|
||||
this.$emit('update:open', false)
|
||||
},
|
||||
|
||||
handleMove() {
|
||||
|
||||
@@ -56,6 +56,22 @@ export function useCategories() {
|
||||
return fetchCategories(true, silent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a category as read in the local state
|
||||
* Updates the readAt timestamp so the category appears read without refetching
|
||||
*/
|
||||
const markCategoryAsRead = (categoryId: number): void => {
|
||||
for (const header of categoryHeaders.value) {
|
||||
if (!header.categories) continue
|
||||
for (const category of header.categories) {
|
||||
if (category.id === categoryId) {
|
||||
category.readAt = Math.floor(Date.now() / 1000)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached categories
|
||||
*/
|
||||
@@ -76,5 +92,6 @@ export function useCategories() {
|
||||
fetchCategories,
|
||||
refresh,
|
||||
clear,
|
||||
markCategoryAsRead,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Dark theme avatar - use --avatar-dark when parent has data-theme-dark="true"
|
||||
[data-theme-dark='true'] .mention-bubble__icon {
|
||||
// Dark theme avatar - use --avatar-dark when parent has data-forum-dark="true"
|
||||
[data-forum-dark='true'] .mention-bubble__icon {
|
||||
background-image: var(--avatar-dark);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface Category {
|
||||
postCount: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
lastActivityAt?: number | null
|
||||
readAt?: number | null
|
||||
}
|
||||
|
||||
export interface CategoryHeader {
|
||||
@@ -106,8 +108,9 @@ export interface BBCode {
|
||||
export interface ReadMarker {
|
||||
id: number
|
||||
userId: string
|
||||
threadId: number
|
||||
lastReadPostId: number
|
||||
entityId: number
|
||||
markerType: string
|
||||
lastReadPostId: number | null
|
||||
readAt: number
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ export default defineComponent({
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
readMarkers: Record<number, { threadId: number; lastReadPostId: number; readAt: number }>
|
||||
readMarkers: Record<number, { entityId: number; lastReadPostId: number; readAt: number }>
|
||||
}
|
||||
|
||||
const resp = await ocs.get<BookmarksResponse>('/bookmarks', {
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
v-for="category in header.categories"
|
||||
:key="category.id"
|
||||
:category="category"
|
||||
:is-unread="isCategoryUnread(category)"
|
||||
@click="navigateToCategory(category)"
|
||||
/>
|
||||
</div>
|
||||
@@ -69,6 +70,7 @@ import CategoryCard from '@/components/CategoryCard'
|
||||
import RefreshIcon from '@icons/Refresh.vue'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import type { Category } from '@/types'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
@@ -85,17 +87,21 @@ export default defineComponent({
|
||||
RefreshIcon,
|
||||
},
|
||||
setup() {
|
||||
const { categoryHeaders, loading, fetchCategories, refresh } = useCategories()
|
||||
const { categoryHeaders, loading, fetchCategories, refresh, markCategoryAsRead } =
|
||||
useCategories()
|
||||
const { settings, loading: settingsLoading, fetchPublicSettings } = usePublicSettings()
|
||||
const { userId } = useCurrentUser()
|
||||
|
||||
return {
|
||||
categoryHeaders,
|
||||
loading,
|
||||
fetchCategories,
|
||||
refreshCategories: refresh,
|
||||
markCategoryAsRead,
|
||||
publicSettings: settings,
|
||||
settingsLoading,
|
||||
fetchPublicSettings,
|
||||
userId,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -134,7 +140,24 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
isCategoryUnread(category: Category): boolean {
|
||||
if (this.userId === null) {
|
||||
return false
|
||||
}
|
||||
const lastActivity = category.lastActivityAt
|
||||
if (!lastActivity) {
|
||||
return false
|
||||
}
|
||||
if (category.readAt == null) {
|
||||
return true
|
||||
}
|
||||
return lastActivity > category.readAt
|
||||
},
|
||||
|
||||
navigateToCategory(category: Category) {
|
||||
if (this.userId !== null) {
|
||||
this.markCategoryAsRead(category.id)
|
||||
}
|
||||
this.$router.push(`/c/${category.slug}`)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -143,12 +143,14 @@ import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CategoryView',
|
||||
setup() {
|
||||
const { userId } = useCurrentUser()
|
||||
return { userId }
|
||||
const { markCategoryAsRead } = useCategories()
|
||||
return { userId, markCategoryAsReadLocal: markCategoryAsRead }
|
||||
},
|
||||
components: {
|
||||
NcButton,
|
||||
@@ -222,6 +224,8 @@ export default defineComponent({
|
||||
await this.fetchThreads()
|
||||
// Fetch read markers after threads are loaded
|
||||
await this.fetchReadMarkers()
|
||||
// Mark category as read for authenticated users
|
||||
this.markCategoryAsRead()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh', e)
|
||||
@@ -304,6 +308,19 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
async markCategoryAsRead() {
|
||||
if (this.userId === null || !this.category) {
|
||||
return
|
||||
}
|
||||
// Update shared state immediately so back navigation shows as read
|
||||
this.markCategoryAsReadLocal(this.category.id)
|
||||
try {
|
||||
await ocs.post('/read-markers', { categoryId: this.category.id })
|
||||
} catch (e) {
|
||||
console.debug('Failed to mark category as read', e)
|
||||
}
|
||||
},
|
||||
|
||||
async fetchReadMarkers() {
|
||||
try {
|
||||
// Guests don't have read markers
|
||||
@@ -317,7 +334,7 @@ export default defineComponent({
|
||||
|
||||
const threadIds = this.threads.map((t) => t.id).join(',')
|
||||
const resp = await ocs.get<
|
||||
Record<number, { threadId: number; lastReadPostId: number; readAt: number }>
|
||||
Record<number, { entityId: number; lastReadPostId: number; readAt: number }>
|
||||
>('/read-markers', {
|
||||
params: { threadIds },
|
||||
})
|
||||
|
||||
@@ -1074,38 +1074,33 @@ export default defineComponent({
|
||||
this.isSavingTitle = false
|
||||
}
|
||||
},
|
||||
},
|
||||
async handleMoveThread(categoryId: number): Promise<void> {
|
||||
if (!this.thread) return
|
||||
|
||||
try {
|
||||
const response = await ocs.put(`/threads/${this.thread.id}/move`, {
|
||||
categoryId,
|
||||
})
|
||||
async handleMoveThread(categoryId: number): Promise<void> {
|
||||
if (!this.thread) return
|
||||
|
||||
if (response.data) {
|
||||
showSuccess(this.strings.threadMoved)
|
||||
this.showMoveDialog = false
|
||||
try {
|
||||
const response = await ocs.put(`/threads/${this.thread.id}/move`, {
|
||||
categoryId,
|
||||
})
|
||||
|
||||
// Refresh the thread data to update category information and back link
|
||||
await this.refresh()
|
||||
if (response.data) {
|
||||
showSuccess(this.strings.threadMoved)
|
||||
this.showMoveDialog = false
|
||||
|
||||
// Reset the move dialog
|
||||
// Refresh the thread data to update category information and back link
|
||||
await this.refresh()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to move thread', e)
|
||||
showError(t('forum', 'Failed to move thread'))
|
||||
} finally {
|
||||
// Always reset the move dialog state
|
||||
const moveDialog = this.$refs.moveDialog as any
|
||||
if (moveDialog && typeof moveDialog.reset === 'function') {
|
||||
moveDialog.reset()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to move thread', e)
|
||||
showError(t('forum', 'Failed to move thread'))
|
||||
|
||||
// Reset moving state in dialog
|
||||
const moveDialog = this.$refs.moveDialog as any
|
||||
if (moveDialog && typeof moveDialog.reset === 'function') {
|
||||
moveDialog.reset()
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -391,7 +391,7 @@ class BookmarkControllerTest extends TestCase {
|
||||
$data = $response->getData();
|
||||
$this->assertArrayHasKey('readMarkers', $data);
|
||||
$this->assertArrayHasKey(1, $data['readMarkers']);
|
||||
$this->assertEquals(1, $data['readMarkers'][1]['threadId']);
|
||||
$this->assertEquals(1, $data['readMarkers'][1]['entityId']);
|
||||
$this->assertEquals(5, $data['readMarkers'][1]['lastReadPostId']);
|
||||
}
|
||||
|
||||
@@ -426,7 +426,8 @@ class BookmarkControllerTest extends TestCase {
|
||||
$marker = new ReadMarker();
|
||||
$marker->setId($id);
|
||||
$marker->setUserId($userId);
|
||||
$marker->setThreadId($threadId);
|
||||
$marker->setEntityId($threadId);
|
||||
$marker->setMarkerType(ReadMarker::TYPE_THREAD);
|
||||
$marker->setLastReadPostId($lastReadPostId);
|
||||
$marker->setReadAt(time());
|
||||
return $marker;
|
||||
|
||||
@@ -12,6 +12,7 @@ use OCA\Forum\Db\CategoryPerm;
|
||||
use OCA\Forum\Db\CategoryPermMapper;
|
||||
use OCA\Forum\Db\CatHeader;
|
||||
use OCA\Forum\Db\CatHeaderMapper;
|
||||
use OCA\Forum\Db\ReadMarkerMapper;
|
||||
use OCA\Forum\Db\Role;
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
@@ -36,6 +37,8 @@ class CategoryControllerTest extends TestCase {
|
||||
private CategoryPermMapper $categoryPermMapper;
|
||||
/** @var ThreadMapper&MockObject */
|
||||
private ThreadMapper $threadMapper;
|
||||
/** @var ReadMarkerMapper&MockObject */
|
||||
private ReadMarkerMapper $readMarkerMapper;
|
||||
/** @var RoleMapper&MockObject */
|
||||
private RoleMapper $roleMapper;
|
||||
/** @var IUserSession&MockObject */
|
||||
@@ -53,6 +56,7 @@ class CategoryControllerTest extends TestCase {
|
||||
$this->categoryMapper = $this->createMock(CategoryMapper::class);
|
||||
$this->categoryPermMapper = $this->createMock(CategoryPermMapper::class);
|
||||
$this->threadMapper = $this->createMock(ThreadMapper::class);
|
||||
$this->readMarkerMapper = $this->createMock(ReadMarkerMapper::class);
|
||||
$this->roleMapper = $this->createMock(RoleMapper::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->groupManager = $this->createMock(IGroupManager::class);
|
||||
@@ -65,6 +69,7 @@ class CategoryControllerTest extends TestCase {
|
||||
$this->categoryMapper,
|
||||
$this->categoryPermMapper,
|
||||
$this->threadMapper,
|
||||
$this->readMarkerMapper,
|
||||
$this->roleMapper,
|
||||
$this->userSession,
|
||||
$this->groupManager,
|
||||
|
||||
@@ -945,7 +945,8 @@ class PostControllerTest extends TestCase {
|
||||
// Mock read marker - last read post ID is 31
|
||||
$readMarker = new ReadMarker();
|
||||
$readMarker->setUserId('user1');
|
||||
$readMarker->setThreadId($threadId);
|
||||
$readMarker->setEntityId($threadId);
|
||||
$readMarker->setMarkerType(ReadMarker::TYPE_THREAD);
|
||||
$readMarker->setLastReadPostId(31);
|
||||
$readMarker->setReadAt(time());
|
||||
|
||||
@@ -1017,7 +1018,8 @@ class PostControllerTest extends TestCase {
|
||||
// Mock read marker - all posts read (last read = 50)
|
||||
$readMarker = new ReadMarker();
|
||||
$readMarker->setUserId('user1');
|
||||
$readMarker->setThreadId($threadId);
|
||||
$readMarker->setEntityId($threadId);
|
||||
$readMarker->setMarkerType(ReadMarker::TYPE_THREAD);
|
||||
$readMarker->setLastReadPostId(50);
|
||||
$readMarker->setReadAt(time());
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class ReadMarkerControllerTest extends TestCase {
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals($threadId, $data['threadId']);
|
||||
$this->assertEquals($threadId, $data['entityId']);
|
||||
$this->assertEquals(10, $data['lastReadPostId']);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ class ReadMarkerControllerTest extends TestCase {
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals($threadId, $data['threadId']);
|
||||
$this->assertEquals($threadId, $data['entityId']);
|
||||
$this->assertEquals($lastReadPostId, $data['lastReadPostId']);
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ class ReadMarkerControllerTest extends TestCase {
|
||||
|
||||
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
|
||||
$data = $response->getData();
|
||||
$this->assertEquals($threadId, $data['threadId']);
|
||||
$this->assertEquals($threadId, $data['entityId']);
|
||||
}
|
||||
|
||||
public function testCreateReturnsUnauthorizedWhenUserNotAuthenticated(): void {
|
||||
@@ -232,7 +232,8 @@ class ReadMarkerControllerTest extends TestCase {
|
||||
$marker = new ReadMarker();
|
||||
$marker->setId($id);
|
||||
$marker->setUserId($userId);
|
||||
$marker->setThreadId($threadId);
|
||||
$marker->setEntityId($threadId);
|
||||
$marker->setMarkerType(ReadMarker::TYPE_THREAD);
|
||||
$marker->setLastReadPostId($lastReadPostId);
|
||||
$marker->setReadAt(time());
|
||||
return $marker;
|
||||
|
||||
12
vendor-bin/cs-fixer/composer.lock
generated
12
vendor-bin/cs-fixer/composer.lock
generated
@@ -106,16 +106,16 @@
|
||||
},
|
||||
{
|
||||
"name": "php-cs-fixer/shim",
|
||||
"version": "v3.93.1",
|
||||
"version": "v3.94.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/shim.git",
|
||||
"reference": "3a9db22e8f01762fddd3a85b998053294c5a3629"
|
||||
"reference": "bf90113c2d4f349a639df42045d36e78ffc25c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/3a9db22e8f01762fddd3a85b998053294c5a3629",
|
||||
"reference": "3a9db22e8f01762fddd3a85b998053294c5a3629",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/bf90113c2d4f349a639df42045d36e78ffc25c9a",
|
||||
"reference": "bf90113c2d4f349a639df42045d36e78ffc25c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -152,9 +152,9 @@
|
||||
"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.93.1"
|
||||
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.94.0"
|
||||
},
|
||||
"time": "2026-01-28T23:51:14+00:00"
|
||||
"time": "2026-02-11T16:45:46+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
|
||||
8
vendor-bin/openapi-extractor/composer.lock
generated
8
vendor-bin/openapi-extractor/composer.lock
generated
@@ -86,12 +86,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud/openapi-extractor.git",
|
||||
"reference": "28b731347bb9a44e924bd940805ec8aa56e2e24b"
|
||||
"reference": "3902c560797eebe7c69be41be2cbd31619262971"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud/openapi-extractor/zipball/28b731347bb9a44e924bd940805ec8aa56e2e24b",
|
||||
"reference": "28b731347bb9a44e924bd940805ec8aa56e2e24b",
|
||||
"url": "https://api.github.com/repos/nextcloud/openapi-extractor/zipball/3902c560797eebe7c69be41be2cbd31619262971",
|
||||
"reference": "3902c560797eebe7c69be41be2cbd31619262971",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -142,7 +142,7 @@
|
||||
"source": "https://github.com/nextcloud/openapi-extractor/tree/main",
|
||||
"issues": "https://github.com/nextcloud/openapi-extractor/issues"
|
||||
},
|
||||
"time": "2026-02-02T09:39:22+00:00"
|
||||
"time": "2026-02-12T16:56:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
|
||||
20
vendor-bin/psalm/composer.lock
generated
20
vendor-bin/psalm/composer.lock
generated
@@ -423,29 +423,29 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/deprecations.git",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<=7.5 || >=13"
|
||||
"phpunit/phpunit": "<=7.5 || >=14"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^13",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.11",
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^14",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.30",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
|
||||
"psr/log": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
@@ -465,9 +465,9 @@
|
||||
"homepage": "https://www.doctrine-project.org/",
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.5"
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
|
||||
},
|
||||
"time": "2025-04-07T20:06:18+00:00"
|
||||
"time": "2026-02-07T07:09:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "felixfbecker/advanced-json-rpc",
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.22.3
|
||||
0.23.0
|
||||
|
||||
@@ -54,7 +54,6 @@ export default createAppConfig(
|
||||
manifest: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
external: ['floating-vue'],
|
||||
output: {
|
||||
entryFileNames: 'js/[name]-[hash].mjs',
|
||||
chunkFileNames: 'js/[name]-[hash].mjs',
|
||||
|
||||
Reference in New Issue
Block a user