Compare commits

...

19 Commits

Author SHA1 Message Date
25f7bf5933 feat: allow category nesting 2026-04-02 10:32:14 +03:00
7418bdf868 fix: refresh roles after forum initialization 2026-04-02 01:49:07 +03:00
7b1f42587b feat: improve accessibility 2026-04-01 15:20:59 +03:00
github-actions[bot]
b4a3765dca chore(master): release 0.36.0 2026-04-01 11:43:16 +03:00
ca109dc7fc feat: allow reassigning guests to actual users 2026-04-01 09:03:19 +03:00
Nextcloud bot
a1d2791d1c fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-04-01 02:12:38 +00:00
Nextcloud bot
73acd9e9af fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-03-31 02:10:32 +00:00
d172641b28 chore: update screenshot 2026-03-30 00:53:14 +03:00
4549ccac95 chore: fix screenshot url 2026-03-30 00:39:46 +03:00
9da9c37420 refactor: clean up bbcode service 2026-03-29 13:32:13 +03:00
github-actions[bot]
ccd7f1d98d chore(master): release 0.35.0 2026-03-29 13:23:33 +03:00
53a8e3cc72 refactor: builtin bbcode overrides 2026-03-29 11:50:32 +03:00
3e7ccbb02a feat: audio attachment support
refactor: unify attachment mime type handlers
2026-03-29 11:43:23 +03:00
Nextcloud bot
58c25e4c64 fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-03-29 06:55:51 +00:00
github-actions[bot]
f177e281ed chore(master): release 0.34.2 2026-03-29 09:51:48 +03:00
beaae2a4b4 fix: attachments file permissions for guest access posts 2026-03-29 09:50:02 +03:00
github-actions[bot]
93d347c87a chore(master): release 0.34.1 2026-03-29 09:38:02 +03:00
fcf9cf614b fix: attachment video streaming 2026-03-29 01:43:16 +03:00
0d461f1373 fix: youtube embed CSP 2026-03-29 01:27:48 +03:00
72 changed files with 4933 additions and 454 deletions

View File

@@ -1 +1 @@
{".":"0.34.0"}
{".":"0.36.0"}

View File

@@ -1,5 +1,45 @@
# Changelog
## [0.36.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.35.0...v0.36.0) (2026-04-01)
### Features
* allow reassigning guests to actual users ([ca109dc](https://github.com/chenasraf/nextcloud-forum/commit/ca109dc7fc7f05d861f163b4dd050cb459352626))
### Bug Fixes
* **l10n:** Update translations from Transifex ([a1d2791](https://github.com/chenasraf/nextcloud-forum/commit/a1d2791d1ca7201255ccd1a9879d5cf9df29bb20))
* **l10n:** Update translations from Transifex ([73acd9e](https://github.com/chenasraf/nextcloud-forum/commit/73acd9e9af7bf9032eeb5d4eaf6a4a8c7bf60209))
## [0.35.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.34.2...v0.35.0) (2026-03-29)
### Features
* audio attachment support ([3e7ccbb](https://github.com/chenasraf/nextcloud-forum/commit/3e7ccbb02ac9831d3e69e434e1723825a69880d5))
### Bug Fixes
* **l10n:** Update translations from Transifex ([58c25e4](https://github.com/chenasraf/nextcloud-forum/commit/58c25e4c6443df4f99d5192759116ea8112fbc66))
## [0.34.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.34.1...v0.34.2) (2026-03-29)
### Bug Fixes
* attachments file permissions for guest access posts ([beaae2a](https://github.com/chenasraf/nextcloud-forum/commit/beaae2a4b4ec061b50904221198ea6847ad7254a))
## [0.34.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.34.0...v0.34.1) (2026-03-28)
### Bug Fixes
* attachment video streaming ([fcf9cf6](https://github.com/chenasraf/nextcloud-forum/commit/fcf9cf614bcd3ae41b61ba780264b2c74bda671a))
* youtube embed CSP ([0d461f1](https://github.com/chenasraf/nextcloud-forum/commit/0d461f1373022642c6160ab5e10d459f9ece4b93))
## [0.34.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.33.0...v0.34.0) (2026-03-28)

View File

@@ -43,7 +43,7 @@ Create discussions, share ideas, and collaborate with your community directly in
The forum integrates seamlessly with your Nextcloud instance, using your existing accounts and teams for authentication and access control.
]]></description>
<version>0.34.0</version>
<version>0.36.0</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>
@@ -56,7 +56,7 @@ The forum integrates seamlessly with your Nextcloud instance, using your existin
<website>https://github.com/chenasraf/nextcloud-forum</website>
<bugs>https://github.com/chenasraf/nextcloud-forum/issues</bugs>
<repository>https://github.com/chenasraf/nextcloud-forum</repository>
<screenshot>https://raw.githubusercontent.com/chenasraf/nextcloud-forum/master/screenshots/screenshot-01.png</screenshot>
<screenshot>https://raw.githubusercontent.com/chenasraf/nextcloud-forum/refs/head/master/screenshots/screenshot-01.png</screenshot>
<donation>https://ko-fi.com/casraf</donation>
<donation type="paypal"><![CDATA[https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22&currency_code=ILS&source=url]]></donation>
<dependencies>

View File

@@ -57,8 +57,15 @@ OC.L10N.register(
"Welcome to the forum!" : "Welcome to the forum!",
"Deleted user" : "Deleted user",
"A community-driven forum built right into your Nextcloud instance" : "A community-driven forum built right into your Nextcloud instance",
"Create discussions, share ideas, and collaborate with your community directly in Nextcloud.\n\n**Key features:**\n- **Threaded Discussions** - Create and reply to organized discussion threads with pagination\n- **Category Organization** - Structure your forum with customizable categories, headers, colors, and drag-and-drop reordering\n- **Rich Text Formatting** - BBCode formatting with built-in and custom tags, toolbar with overflow menu\n- **File Attachments** - Attach files from your Nextcloud storage or upload via drag-and-drop\n- **Notifications** - Subscribe to threads and get notified on replies and @mentions\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Track unread posts at thread and category level\n- **Bookmarks** - Save threads for quick access\n- **Search** - Advanced search with boolean operators and category filtering\n- **User Profiles** - View post history, statistics, and role badges\n- **Roles and Teams** - Fine-grained permissions per role or Nextcloud Team, per category\n- **Guest Access** - Optional public access for unauthenticated visitors with configurable permissions\n- **Edit History** - View post revision history with configurable visibility and per-account privacy controls\n- **Reusable Templates** - Save and insert frequently used content snippets\n- **Signatures** - BBCode-formatted signatures on posts\n- **Thread Drafts** - Auto-saved drafts per category\n- **Dashboard Widgets** - Recent activity, top threads, and top categories on the Nextcloud dashboard\n- **Direct Post Links** - Link directly to a specific post within a thread\n- **Moderation Tools** - Pin, lock, hide, and move threads; review and restore deleted content\n- **Management Tools** - Manage categories, roles, BBCodes, and forum settings with granular permissions\n- **Server Administration** - Repair seeds, rebuild statistics, and assign roles from the Nextcloud admin panel\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 accounts and teams for authentication and access control." : "Create discussions, share ideas, and collaborate with your community directly in Nextcloud.\n\n**Key features:**\n- **Threaded Discussions** - Create and reply to organized discussion threads with pagination\n- **Category Organization** - Structure your forum with customizable categories, headers, colors, and drag-and-drop reordering\n- **Rich Text Formatting** - BBCode formatting with built-in and custom tags, toolbar with overflow menu\n- **File Attachments** - Attach files from your Nextcloud storage or upload via drag-and-drop\n- **Notifications** - Subscribe to threads and get notified on replies and @mentions\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Track unread posts at thread and category level\n- **Bookmarks** - Save threads for quick access\n- **Search** - Advanced search with boolean operators and category filtering\n- **User Profiles** - View post history, statistics, and role badges\n- **Roles and Teams** - Fine-grained permissions per role or Nextcloud Team, per category\n- **Guest Access** - Optional public access for unauthenticated visitors with configurable permissions\n- **Edit History** - View post revision history with configurable visibility and per-account privacy controls\n- **Reusable Templates** - Save and insert frequently used content snippets\n- **Signatures** - BBCode-formatted signatures on posts\n- **Thread Drafts** - Auto-saved drafts per category\n- **Dashboard Widgets** - Recent activity, top threads, and top categories on the Nextcloud dashboard\n- **Direct Post Links** - Link directly to a specific post within a thread\n- **Moderation Tools** - Pin, lock, hide, and move threads; review and restore deleted content\n- **Management Tools** - Manage categories, roles, BBCodes, and forum settings with granular permissions\n- **Server Administration** - Repair seeds, rebuild statistics, and assign roles from the Nextcloud admin panel\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 accounts and teams for authentication and access control.",
"Forum server administration" : "Forum server administration",
"Database Initial Data" : "Database Initial Data",
"Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists.",
"Repair Database Initial Data" : "Repair Database Initial Data",
"Rebuild Statistics" : "Rebuild Statistics",
"Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync." : "Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync.",
"User Roles" : "User Roles",
"Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts." : "Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts.",
"User ID" : "User ID",
"Enter user ID" : "Enter user ID",
"Role" : "Role",
@@ -70,6 +77,7 @@ OC.L10N.register(
"Home" : "Home",
"Bookmarks" : "Bookmarks",
"User preferences" : "User preferences",
"Management" : "Management",
"Dashboard" : "Dashboard",
"Forum settings" : "Forum settings",
"Users" : "Users",
@@ -161,8 +169,14 @@ OC.L10N.register(
"The forum has not been set up yet. Please contact an administration member to complete the setup." : "The forum has not been set up yet. Please contact an administration member to complete the setup.",
"Deleted" : "Deleted",
"Restore" : "Restore",
"Error loading content" : "Error loading content",
"Retry" : "Retry",
"No deleted content" : "No deleted content",
"There is no deleted content to review." : "There is no deleted content to review.",
"Deleted reply" : "Deleted reply",
"In thread" : "In thread",
"Restore reply" : "Restore reply",
"Restore thread" : "Restore thread",
"Move thread to category" : "Move thread to category",
"Select the category to move this thread to:" : "Select the category to move this thread to:",
"Select a category …" : "Select a category …",
@@ -365,6 +379,9 @@ OC.L10N.register(
"You can use BBCode formatting in your signature" : "You can use BBCode formatting in your signature",
"Enter your signature …" : "Enter your signature …",
"Privacy" : "Privacy",
"Control the visibility of your activity" : "Control the visibility of your activity",
"Hide my edit history from other accounts" : "Hide my edit history from other accounts",
"When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history." : "When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history.",
"Failed to save preferences" : "Failed to save preferences",
"Select upload directory" : "Select upload directory",
"BBCode management" : "BBCode management",
@@ -447,6 +464,7 @@ OC.L10N.register(
"Select target header" : "Select target header",
"Move up" : "Move up",
"Move down" : "Move down",
"Management dashboard" : "Management dashboard",
"Overview of forum activity and statistics" : "Overview of forum activity and statistics",
"Loading statistics …" : "Loading statistics …",
"Error loading dashboard" : "Error loading dashboard",
@@ -474,9 +492,21 @@ OC.L10N.register(
"Manage who can access the forum" : "Manage who can access the forum",
"Allow guest access" : "Allow guest access",
"When enabled, unauthenticated users can view forum content in read-only mode" : "When enabled, unauthenticated users can view forum content in read-only mode",
"Control who can view the edit history of posts" : "Control who can view the edit history of posts",
"Allow all accounts to view edit history" : "Allow all accounts to view edit history",
"When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history." : "When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history.",
"Allow accounts to hide their own edit history" : "Allow accounts to hide their own edit history",
"When enabled, accounts can choose to hide their edit history from other accounts in their preferences." : "When enabled, accounts can choose to hide their edit history from other accounts in their preferences.",
"Posts" : "Posts",
"Configure posting features" : "Configure posting features",
"Enable signatures" : "Enable signatures",
"When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts." : "When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts.",
"Settings saved" : "Settings saved",
"Failed to save settings" : "Failed to save settings",
"Review and restore deleted content" : "Review and restore deleted content",
"Deleted threads" : "Deleted threads",
"Deleted replies" : "Deleted replies",
"Search deleted content …" : "Search deleted content …",
"Newest first" : "Newest first",
"Oldest first" : "Oldest first",
"Create role" : "Create role",
@@ -493,12 +523,21 @@ OC.L10N.register(
"Reset" : "Reset",
"Role permissions" : "Role permissions",
"Set global permissions for this role" : "Set global permissions for this role",
"Dashboard and forum settings" : "Dashboard and forum settings",
"Allow access to the management dashboard and forum settings" : "Allow access to the management dashboard and forum settings",
"Account management" : "Account management",
"Allow viewing accounts and assigning roles" : "Allow viewing accounts and assigning roles",
"Roles and teams management" : "Roles and teams management",
"Allow creating, editing and deleting roles and team permissions" : "Allow creating, editing and deleting roles and team permissions",
"Category management" : "Category management",
"Allow creating, editing and deleting categories" : "Allow creating, editing and deleting categories",
"Allow creating, editing and deleting custom BBCodes" : "Allow creating, editing and deleting custom BBCodes",
"Allow access to the moderation page to review and restore deleted content" : "Allow access to the moderation page to review and restore deleted content",
"Category permissions" : "Category permissions",
"Set which categories this role can access" : "Set which categories this role can access",
"Admin role must have all permissions enabled" : "Admin role must have all permissions enabled",
"Admin role has full access to all categories" : "Admin role has full access to all categories",
"Guest role cannot have management permissions" : "Guest role cannot have management permissions",
"Guest role cannot moderate categories" : "Guest role cannot moderate categories",
"You can control which categories guests can view using the checkboxes below." : "You can control which categories guests can view using the checkboxes below.",
"Guest access is currently disabled" : "Guest access is currently disabled",

View File

@@ -55,8 +55,15 @@
"Welcome to the forum!" : "Welcome to the forum!",
"Deleted user" : "Deleted user",
"A community-driven forum built right into your Nextcloud instance" : "A community-driven forum built right into your Nextcloud instance",
"Create discussions, share ideas, and collaborate with your community directly in Nextcloud.\n\n**Key features:**\n- **Threaded Discussions** - Create and reply to organized discussion threads with pagination\n- **Category Organization** - Structure your forum with customizable categories, headers, colors, and drag-and-drop reordering\n- **Rich Text Formatting** - BBCode formatting with built-in and custom tags, toolbar with overflow menu\n- **File Attachments** - Attach files from your Nextcloud storage or upload via drag-and-drop\n- **Notifications** - Subscribe to threads and get notified on replies and @mentions\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Track unread posts at thread and category level\n- **Bookmarks** - Save threads for quick access\n- **Search** - Advanced search with boolean operators and category filtering\n- **User Profiles** - View post history, statistics, and role badges\n- **Roles and Teams** - Fine-grained permissions per role or Nextcloud Team, per category\n- **Guest Access** - Optional public access for unauthenticated visitors with configurable permissions\n- **Edit History** - View post revision history with configurable visibility and per-account privacy controls\n- **Reusable Templates** - Save and insert frequently used content snippets\n- **Signatures** - BBCode-formatted signatures on posts\n- **Thread Drafts** - Auto-saved drafts per category\n- **Dashboard Widgets** - Recent activity, top threads, and top categories on the Nextcloud dashboard\n- **Direct Post Links** - Link directly to a specific post within a thread\n- **Moderation Tools** - Pin, lock, hide, and move threads; review and restore deleted content\n- **Management Tools** - Manage categories, roles, BBCodes, and forum settings with granular permissions\n- **Server Administration** - Repair seeds, rebuild statistics, and assign roles from the Nextcloud admin panel\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 accounts and teams for authentication and access control." : "Create discussions, share ideas, and collaborate with your community directly in Nextcloud.\n\n**Key features:**\n- **Threaded Discussions** - Create and reply to organized discussion threads with pagination\n- **Category Organization** - Structure your forum with customizable categories, headers, colors, and drag-and-drop reordering\n- **Rich Text Formatting** - BBCode formatting with built-in and custom tags, toolbar with overflow menu\n- **File Attachments** - Attach files from your Nextcloud storage or upload via drag-and-drop\n- **Notifications** - Subscribe to threads and get notified on replies and @mentions\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Track unread posts at thread and category level\n- **Bookmarks** - Save threads for quick access\n- **Search** - Advanced search with boolean operators and category filtering\n- **User Profiles** - View post history, statistics, and role badges\n- **Roles and Teams** - Fine-grained permissions per role or Nextcloud Team, per category\n- **Guest Access** - Optional public access for unauthenticated visitors with configurable permissions\n- **Edit History** - View post revision history with configurable visibility and per-account privacy controls\n- **Reusable Templates** - Save and insert frequently used content snippets\n- **Signatures** - BBCode-formatted signatures on posts\n- **Thread Drafts** - Auto-saved drafts per category\n- **Dashboard Widgets** - Recent activity, top threads, and top categories on the Nextcloud dashboard\n- **Direct Post Links** - Link directly to a specific post within a thread\n- **Moderation Tools** - Pin, lock, hide, and move threads; review and restore deleted content\n- **Management Tools** - Manage categories, roles, BBCodes, and forum settings with granular permissions\n- **Server Administration** - Repair seeds, rebuild statistics, and assign roles from the Nextcloud admin panel\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 accounts and teams for authentication and access control.",
"Forum server administration" : "Forum server administration",
"Database Initial Data" : "Database Initial Data",
"Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists.",
"Repair Database Initial Data" : "Repair Database Initial Data",
"Rebuild Statistics" : "Rebuild Statistics",
"Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync." : "Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync.",
"User Roles" : "User Roles",
"Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts." : "Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts.",
"User ID" : "User ID",
"Enter user ID" : "Enter user ID",
"Role" : "Role",
@@ -68,6 +75,7 @@
"Home" : "Home",
"Bookmarks" : "Bookmarks",
"User preferences" : "User preferences",
"Management" : "Management",
"Dashboard" : "Dashboard",
"Forum settings" : "Forum settings",
"Users" : "Users",
@@ -159,8 +167,14 @@
"The forum has not been set up yet. Please contact an administration member to complete the setup." : "The forum has not been set up yet. Please contact an administration member to complete the setup.",
"Deleted" : "Deleted",
"Restore" : "Restore",
"Error loading content" : "Error loading content",
"Retry" : "Retry",
"No deleted content" : "No deleted content",
"There is no deleted content to review." : "There is no deleted content to review.",
"Deleted reply" : "Deleted reply",
"In thread" : "In thread",
"Restore reply" : "Restore reply",
"Restore thread" : "Restore thread",
"Move thread to category" : "Move thread to category",
"Select the category to move this thread to:" : "Select the category to move this thread to:",
"Select a category …" : "Select a category …",
@@ -363,6 +377,9 @@
"You can use BBCode formatting in your signature" : "You can use BBCode formatting in your signature",
"Enter your signature …" : "Enter your signature …",
"Privacy" : "Privacy",
"Control the visibility of your activity" : "Control the visibility of your activity",
"Hide my edit history from other accounts" : "Hide my edit history from other accounts",
"When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history." : "When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history.",
"Failed to save preferences" : "Failed to save preferences",
"Select upload directory" : "Select upload directory",
"BBCode management" : "BBCode management",
@@ -445,6 +462,7 @@
"Select target header" : "Select target header",
"Move up" : "Move up",
"Move down" : "Move down",
"Management dashboard" : "Management dashboard",
"Overview of forum activity and statistics" : "Overview of forum activity and statistics",
"Loading statistics …" : "Loading statistics …",
"Error loading dashboard" : "Error loading dashboard",
@@ -472,9 +490,21 @@
"Manage who can access the forum" : "Manage who can access the forum",
"Allow guest access" : "Allow guest access",
"When enabled, unauthenticated users can view forum content in read-only mode" : "When enabled, unauthenticated users can view forum content in read-only mode",
"Control who can view the edit history of posts" : "Control who can view the edit history of posts",
"Allow all accounts to view edit history" : "Allow all accounts to view edit history",
"When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history." : "When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history.",
"Allow accounts to hide their own edit history" : "Allow accounts to hide their own edit history",
"When enabled, accounts can choose to hide their edit history from other accounts in their preferences." : "When enabled, accounts can choose to hide their edit history from other accounts in their preferences.",
"Posts" : "Posts",
"Configure posting features" : "Configure posting features",
"Enable signatures" : "Enable signatures",
"When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts." : "When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts.",
"Settings saved" : "Settings saved",
"Failed to save settings" : "Failed to save settings",
"Review and restore deleted content" : "Review and restore deleted content",
"Deleted threads" : "Deleted threads",
"Deleted replies" : "Deleted replies",
"Search deleted content …" : "Search deleted content …",
"Newest first" : "Newest first",
"Oldest first" : "Oldest first",
"Create role" : "Create role",
@@ -491,12 +521,21 @@
"Reset" : "Reset",
"Role permissions" : "Role permissions",
"Set global permissions for this role" : "Set global permissions for this role",
"Dashboard and forum settings" : "Dashboard and forum settings",
"Allow access to the management dashboard and forum settings" : "Allow access to the management dashboard and forum settings",
"Account management" : "Account management",
"Allow viewing accounts and assigning roles" : "Allow viewing accounts and assigning roles",
"Roles and teams management" : "Roles and teams management",
"Allow creating, editing and deleting roles and team permissions" : "Allow creating, editing and deleting roles and team permissions",
"Category management" : "Category management",
"Allow creating, editing and deleting categories" : "Allow creating, editing and deleting categories",
"Allow creating, editing and deleting custom BBCodes" : "Allow creating, editing and deleting custom BBCodes",
"Allow access to the moderation page to review and restore deleted content" : "Allow access to the moderation page to review and restore deleted content",
"Category permissions" : "Category permissions",
"Set which categories this role can access" : "Set which categories this role can access",
"Admin role must have all permissions enabled" : "Admin role must have all permissions enabled",
"Admin role has full access to all categories" : "Admin role has full access to all categories",
"Guest role cannot have management permissions" : "Guest role cannot have management permissions",
"Guest role cannot moderate categories" : "Guest role cannot moderate categories",
"You can control which categories guests can view using the checkboxes below." : "You can control which categories guests can view using the checkboxes below.",
"Guest access is currently disabled" : "Guest access is currently disabled",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -62,6 +62,8 @@ OC.L10N.register(
"Name" : "Pavadinimas",
"Template name" : "Šablono pavadinimas",
"Content" : "Turinys",
"Both" : "Abu",
"Insert" : "Įterpti",
"Views" : "Rodiniai",
"Title" : "Pavadinimas",
"Saving draft …" : "Įrašomas juodraštis…",

View File

@@ -60,6 +60,8 @@
"Name" : "Pavadinimas",
"Template name" : "Šablono pavadinimas",
"Content" : "Turinys",
"Both" : "Abu",
"Insert" : "Įterpti",
"Views" : "Rodiniai",
"Title" : "Pavadinimas",
"Saving draft …" : "Įrašomas juodraštis…",

View File

@@ -42,6 +42,9 @@ OC.L10N.register(
"Deleted" : "Verwijderd",
"Restore" : "Herstellen",
"Retry" : "Opnieuw",
"Deleted reply" : "Verwijderd antwoord",
"Restore reply" : "Herstel antwoord",
"Restore thread" : "Herstel draad",
"Move" : "Verplaatsen",
"Page not found" : "Pagina niet gevonden",
"Back" : "Terug",
@@ -99,11 +102,13 @@ OC.L10N.register(
"Preview" : "Voorbeeld",
"Move up" : "Verplaats naar boven",
"Move down" : "Lager zetten",
"Management dashboard" : "Management dashboard",
"Last 7 days" : "Laatste 7 dagen",
"All time" : "Altijd",
"General settings" : "Algemene instellingen",
"Appearance" : "Uiterlijk",
"Access control" : "Toegangscontrole",
"Posts" : "Posts",
"Settings saved" : "Instellingen opgeslagen",
"Failed to save settings" : "Instellingen konden niet worden opgeslagen",
"Newest first" : "Nieuwste eerst",

View File

@@ -40,6 +40,9 @@
"Deleted" : "Verwijderd",
"Restore" : "Herstellen",
"Retry" : "Opnieuw",
"Deleted reply" : "Verwijderd antwoord",
"Restore reply" : "Herstel antwoord",
"Restore thread" : "Herstel draad",
"Move" : "Verplaatsen",
"Page not found" : "Pagina niet gevonden",
"Back" : "Terug",
@@ -97,11 +100,13 @@
"Preview" : "Voorbeeld",
"Move up" : "Verplaats naar boven",
"Move down" : "Lager zetten",
"Management dashboard" : "Management dashboard",
"Last 7 days" : "Laatste 7 dagen",
"All time" : "Altijd",
"General settings" : "Algemene instellingen",
"Appearance" : "Uiterlijk",
"Access control" : "Toegangscontrole",
"Posts" : "Posts",
"Settings saved" : "Instellingen opgeslagen",
"Failed to save settings" : "Instellingen konden niet worden opgeslagen",
"Newest first" : "Nieuwste eerst",

View File

@@ -59,8 +59,12 @@ OC.L10N.register(
"A community-driven forum built right into your Nextcloud instance" : "Um fórum comunitário integrado diretamente na sua instância Nextcloud",
"Forum server administration" : "Administração do servidor do fórum",
"Database Initial Data" : "Dados iniciais do banco de dados",
"Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "Restaurar os dados padrão do fórum (funções, categorias, permissões, códigos BBCode). É seguro executar essa operação várias vezes, pois ela ignorará os dados que já existem.",
"Repair Database Initial Data" : "Reparo dos dados iniciais do banco de dados",
"Rebuild Statistics" : "Recriar estatísticas",
"Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync." : "Recalcule todas as estatísticas do fórum, incluindo o número de mensagens por conta, o número de fios e os contadores de categorias. Use esta opção se as estatísticas parecerem incorretas ou desatualizadas.",
"User Roles" : "Funções de usuário",
"Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts." : "Atribua funções do fórum às contas. Isso permite que você conceda privilégios de administrador ou moderador a contas específicas.",
"User ID" : "ID do Usuário",
"Enter user ID" : "Inserir ID do usuário",
"Role" : "Função",
@@ -489,9 +493,16 @@ OC.L10N.register(
"When enabled, unauthenticated users can view forum content in read-only mode" : "Quando ativada, esta opção permite que usuários não autenticados visualizem o conteúdo do fórum em modo somente leitura",
"Control who can view the edit history of posts" : "Controle quem pode ver o histórico de edições das publicações",
"Allow all accounts to view edit history" : "Permitir que todas as contas visualizem o histórico de edições",
"When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history." : "Quando ativada, qualquer conta pode visualizar o histórico de edições de qualquer postagem. Quando desativada, apenas os autores das postagens podem visualizar seu próprio histórico de edições. A equipe administrativa e os moderadores sempre podem visualizar o histórico de edições.",
"Allow accounts to hide their own edit history" : "Permitir que as contas ocultem seu próprio histórico de edições",
"When enabled, accounts can choose to hide their edit history from other accounts in their preferences." : "Quando essa opção estiver ativada, as contas poderão optar por ocultar seu histórico de edições de outras contas nas suas preferências.",
"Posts" : "Postagens",
"Configure posting features" : "Configurar recursos de postagem",
"Enable signatures" : "Ativar assinaturas",
"When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts." : "Quando ativada, as contas podem definir uma assinatura nas suas preferências, que aparecerá na parte inferior das suas postagens.",
"Settings saved" : "Configurações salvas",
"Failed to save settings" : "Falha ao salvar configurações",
"Review and restore deleted content" : "Revisar e recuperar conteúdo excluído",
"Deleted threads" : "Fios excluídos",
"Deleted replies" : "Respostas excluídas",
"Search deleted content …" : "Pesquisar conteúdo excluído …",
@@ -512,12 +523,20 @@ OC.L10N.register(
"Role permissions" : "Permissões da função",
"Set global permissions for this role" : "Definir permissões globais para esta função",
"Dashboard and forum settings" : "Configurações do painel e fórum",
"Allow access to the management dashboard and forum settings" : "Permitir acesso ao painel de gerenciamento e às configurações do fórum",
"Account management" : "Gerenciamento de contas",
"Allow viewing accounts and assigning roles" : "Permitir a visualização de contas e a atribuição de funções",
"Roles and teams management" : "Gerenciamento de funções e equipes",
"Allow creating, editing and deleting roles and team permissions" : "Permitir a criação, edição e exclusão de funções e permissões de equipe",
"Category management" : "Gerenciamento de categorias",
"Allow creating, editing and deleting categories" : "Permitir a criação, edição e exclusão de categorias",
"Allow creating, editing and deleting custom BBCodes" : "Permitir a criação, edição e exclusão de BBCodes personalizados",
"Allow access to the moderation page to review and restore deleted content" : "Permitir acesso à página de moderação para revisar e restaurar conteúdos excluídos",
"Category permissions" : "Permissões de categoria",
"Set which categories this role can access" : "Defina quais categorias esta função pode acessar",
"Admin role must have all permissions enabled" : "A função de administrador deve ter todas as permissões ativadas",
"Admin role has full access to all categories" : "A função de administrador tem acesso total a todas as categorias",
"Guest role cannot have management permissions" : "A função de convidado não pode ter permissões de administração",
"Guest role cannot moderate categories" : "A função de convidado não pode moderar categorias",
"You can control which categories guests can view using the checkboxes below." : "Você pode controlar quais categorias os convidados podem visualizar usando as caixas de seleção abaixo.",
"Guest access is currently disabled" : "O acesso de convidados está desativado no momento",

View File

@@ -57,8 +57,12 @@
"A community-driven forum built right into your Nextcloud instance" : "Um fórum comunitário integrado diretamente na sua instância Nextcloud",
"Forum server administration" : "Administração do servidor do fórum",
"Database Initial Data" : "Dados iniciais do banco de dados",
"Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "Restaurar os dados padrão do fórum (funções, categorias, permissões, códigos BBCode). É seguro executar essa operação várias vezes, pois ela ignorará os dados que já existem.",
"Repair Database Initial Data" : "Reparo dos dados iniciais do banco de dados",
"Rebuild Statistics" : "Recriar estatísticas",
"Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync." : "Recalcule todas as estatísticas do fórum, incluindo o número de mensagens por conta, o número de fios e os contadores de categorias. Use esta opção se as estatísticas parecerem incorretas ou desatualizadas.",
"User Roles" : "Funções de usuário",
"Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts." : "Atribua funções do fórum às contas. Isso permite que você conceda privilégios de administrador ou moderador a contas específicas.",
"User ID" : "ID do Usuário",
"Enter user ID" : "Inserir ID do usuário",
"Role" : "Função",
@@ -487,9 +491,16 @@
"When enabled, unauthenticated users can view forum content in read-only mode" : "Quando ativada, esta opção permite que usuários não autenticados visualizem o conteúdo do fórum em modo somente leitura",
"Control who can view the edit history of posts" : "Controle quem pode ver o histórico de edições das publicações",
"Allow all accounts to view edit history" : "Permitir que todas as contas visualizem o histórico de edições",
"When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history." : "Quando ativada, qualquer conta pode visualizar o histórico de edições de qualquer postagem. Quando desativada, apenas os autores das postagens podem visualizar seu próprio histórico de edições. A equipe administrativa e os moderadores sempre podem visualizar o histórico de edições.",
"Allow accounts to hide their own edit history" : "Permitir que as contas ocultem seu próprio histórico de edições",
"When enabled, accounts can choose to hide their edit history from other accounts in their preferences." : "Quando essa opção estiver ativada, as contas poderão optar por ocultar seu histórico de edições de outras contas nas suas preferências.",
"Posts" : "Postagens",
"Configure posting features" : "Configurar recursos de postagem",
"Enable signatures" : "Ativar assinaturas",
"When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts." : "Quando ativada, as contas podem definir uma assinatura nas suas preferências, que aparecerá na parte inferior das suas postagens.",
"Settings saved" : "Configurações salvas",
"Failed to save settings" : "Falha ao salvar configurações",
"Review and restore deleted content" : "Revisar e recuperar conteúdo excluído",
"Deleted threads" : "Fios excluídos",
"Deleted replies" : "Respostas excluídas",
"Search deleted content …" : "Pesquisar conteúdo excluído …",
@@ -510,12 +521,20 @@
"Role permissions" : "Permissões da função",
"Set global permissions for this role" : "Definir permissões globais para esta função",
"Dashboard and forum settings" : "Configurações do painel e fórum",
"Allow access to the management dashboard and forum settings" : "Permitir acesso ao painel de gerenciamento e às configurações do fórum",
"Account management" : "Gerenciamento de contas",
"Allow viewing accounts and assigning roles" : "Permitir a visualização de contas e a atribuição de funções",
"Roles and teams management" : "Gerenciamento de funções e equipes",
"Allow creating, editing and deleting roles and team permissions" : "Permitir a criação, edição e exclusão de funções e permissões de equipe",
"Category management" : "Gerenciamento de categorias",
"Allow creating, editing and deleting categories" : "Permitir a criação, edição e exclusão de categorias",
"Allow creating, editing and deleting custom BBCodes" : "Permitir a criação, edição e exclusão de BBCodes personalizados",
"Allow access to the moderation page to review and restore deleted content" : "Permitir acesso à página de moderação para revisar e restaurar conteúdos excluídos",
"Category permissions" : "Permissões de categoria",
"Set which categories this role can access" : "Defina quais categorias esta função pode acessar",
"Admin role must have all permissions enabled" : "A função de administrador deve ter todas as permissões ativadas",
"Admin role has full access to all categories" : "A função de administrador tem acesso total a todas as categorias",
"Guest role cannot have management permissions" : "A função de convidado não pode ter permissões de administração",
"Guest role cannot moderate categories" : "A função de convidado não pode moderar categorias",
"You can control which categories guests can view using the checkboxes below." : "Você pode controlar quais categorias os convidados podem visualizar usando as caixas de seleção abaixo.",
"Guest access is currently disabled" : "O acesso de convidados está desativado no momento",

View File

@@ -57,8 +57,15 @@ OC.L10N.register(
"Welcome to the forum!" : "歡迎來到論壇!",
"Deleted user" : "已刪除的用戶",
"A community-driven forum built right into your Nextcloud instance" : "直接建置在你 Nextcloud 實例中的社群主導論壇",
"Create discussions, share ideas, and collaborate with your community directly in Nextcloud.\n\n**Key features:**\n- **Threaded Discussions** - Create and reply to organized discussion threads with pagination\n- **Category Organization** - Structure your forum with customizable categories, headers, colors, and drag-and-drop reordering\n- **Rich Text Formatting** - BBCode formatting with built-in and custom tags, toolbar with overflow menu\n- **File Attachments** - Attach files from your Nextcloud storage or upload via drag-and-drop\n- **Notifications** - Subscribe to threads and get notified on replies and @mentions\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Track unread posts at thread and category level\n- **Bookmarks** - Save threads for quick access\n- **Search** - Advanced search with boolean operators and category filtering\n- **User Profiles** - View post history, statistics, and role badges\n- **Roles and Teams** - Fine-grained permissions per role or Nextcloud Team, per category\n- **Guest Access** - Optional public access for unauthenticated visitors with configurable permissions\n- **Edit History** - View post revision history with configurable visibility and per-account privacy controls\n- **Reusable Templates** - Save and insert frequently used content snippets\n- **Signatures** - BBCode-formatted signatures on posts\n- **Thread Drafts** - Auto-saved drafts per category\n- **Dashboard Widgets** - Recent activity, top threads, and top categories on the Nextcloud dashboard\n- **Direct Post Links** - Link directly to a specific post within a thread\n- **Moderation Tools** - Pin, lock, hide, and move threads; review and restore deleted content\n- **Management Tools** - Manage categories, roles, BBCodes, and forum settings with granular permissions\n- **Server Administration** - Repair seeds, rebuild statistics, and assign roles from the Nextcloud admin panel\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 accounts and teams for authentication and access control." : "建立討論、分享點子、直接在 Nextcloud 上與您的社群協作。\n\n**重要功能:**\n- **討論串** - 建立並回覆具有分頁功能的討論串\n- **分類管理** - 透過可自訂的分類、標題、顏色以及拖放重新排序功能,來規劃您的論壇結構\n- **富文字格式設定** - 支援內建與自訂標籤的 BBCode 格式化功能,以及附帶溢出選單的工具列\n- **檔案附件** - 從您的 Nextcloud 儲存空間附加檔案,或透過拖放上傳\n- **通知** - 訂閱討論串,並在有人回覆或@提及時收到通知\n- **貼文反應** - 使用表情符號對貼文做出反應\n- **已讀/未讀追蹤** - 在討論串與分類層級追蹤未讀文章\n- **書籤** - 儲存討論串以供快速存取\n- **搜尋** - 使用布林運算子與分類篩選功能進行進階搜尋\n- **使用者個人資料** - 檢視貼文歷史紀錄、統計資料與角色徽章\n- **角色與團隊** - 依據角色或 Nextcloud 團隊、以及各分類進行細粒度權限設定\n- **訪客存取** - 選擇性公開存取功能,適用於未經身份驗證的訪客,並可自訂權限\n- **編輯歷史紀錄** - 檢視文章修訂歷史紀錄,並可自訂能見度及針對每個帳號的隱私權控管\n- **可重複使用的範本** - 儲存並插入常用內容片段\n- **簽名** - 貼文中使用 BBCode 格式的簽名\n- **討論串草稿** - 根據分類自動儲存草稿\n- **儀表板小工具** - Nextcloud 儀表板上的近期活動、熱門討論串與熱門分類\n- **直接貼文連結** - 直接連結至討論串中的特定貼文\n- **審核工具** - 釘選、鎖定、隱藏、移動討論串,審閱與還原刪除的內容\n- **管理工具** - 使用細粒度權限管理分類、角色、BBCode、論壇設定\n- **伺服器管理** - 從 Nextcloud 管理面板修復種子、重建統計資料、指派角色\n\n**適用於:**\n- 團隊討論與協作\n- 社群論壇\n- 支援管道\n- 知識庫\n- 專案討論\n- 內部溝通\n\n此論壇能與您的 Nextcloud 站台無縫整合,並利用您現有的帳號與團隊進行驗證與存取控制。",
"Forum server administration" : "論壇伺服器管理",
"Database Initial Data" : "資料庫初始資料",
"Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "還原論壇預設資料角色、分類、權限、BBCode。此操作可安全地重複執行因為系統會跳過已存在的資料。",
"Repair Database Initial Data" : "修復資料庫初始資料",
"Rebuild Statistics" : "重建統計資料",
"Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync." : "重新計算所有論壇統計資料,包括帳號發文數、討論串數及分類計數器。若統計資料顯示不正確或未同步,請使用此功能。",
"User Roles" : "用戶角色",
"Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts." : "為帳號指派論壇角色。此功能可讓您授予特定帳號管理員或版主權限。",
"User ID" : "用戶 ID",
"Enter user ID" : "輸入用戶 ID",
"Role" : "角色",
@@ -70,6 +77,7 @@ OC.L10N.register(
"Home" : "主頁",
"Bookmarks" : "書籤",
"User preferences" : "用戶偏愛設定",
"Management" : "管理",
"Dashboard" : "儀表板",
"Forum settings" : "論壇設定",
"Users" : "用戶",
@@ -161,8 +169,14 @@ OC.L10N.register(
"The forum has not been set up yet. Please contact an administration member to complete the setup." : "論壇尚未設定完成。請聯絡管理員以完成設定。",
"Deleted" : "已刪除",
"Restore" : "還原",
"Error loading content" : "載入內容時發生錯誤",
"Retry" : "重試",
"No deleted content" : "沒有已刪除的內容",
"There is no deleted content to review." : "沒有待審閱的已刪除內容。",
"Deleted reply" : "已刪除的回覆",
"In thread" : "所在主題",
"Restore reply" : "還原回覆",
"Restore thread" : "還原討論串",
"Move thread to category" : "將討論串移至分類",
"Select the category to move this thread to:" : "選擇要將此討論串移至的分類:",
"Select a category …" : "選擇分類…",
@@ -365,6 +379,9 @@ OC.L10N.register(
"You can use BBCode formatting in your signature" : "您可以使用 BBCode 格式化您的簽名",
"Enter your signature …" : "輸入您的簽名 …",
"Privacy" : "私隱",
"Control the visibility of your activity" : "控制您活動的能見度",
"Hide my edit history from other accounts" : "對其他帳號隱藏我的編輯歷史紀錄",
"When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history." : "啟用時,其他帳號無法檢視您貼文的編輯歷史紀錄。管理員與版主總是能檢視編輯歷史紀錄。",
"Failed to save preferences" : "儲存偏好設定失敗",
"Select upload directory" : "選擇上載目錄",
"BBCode management" : "BBCode 管理",
@@ -447,6 +464,7 @@ OC.L10N.register(
"Select target header" : "選擇目標標題列",
"Move up" : "向上移動",
"Move down" : "向下移動",
"Management dashboard" : "管理儀表板",
"Overview of forum activity and statistics" : "檢視論壇活動與統計總覽",
"Loading statistics …" : "正在載入統計資料 …",
"Error loading dashboard" : "載入控制台時發生錯誤",
@@ -474,9 +492,21 @@ OC.L10N.register(
"Manage who can access the forum" : "管理哪些人可以存取論壇。",
"Allow guest access" : "允許訪客存取",
"When enabled, unauthenticated users can view forum content in read-only mode" : "啟用後,未登入用戶亦可在唯讀模式下檢視論壇內容。",
"Control who can view the edit history of posts" : "控制誰可以檢視貼文的編輯歷史紀錄",
"Allow all accounts to view edit history" : "允許所有帳號檢視編輯歷史紀錄",
"When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history." : "啟用時,任何帳號都能檢視任何貼文的編輯歷史紀錄。停用時,僅貼文擁有者可以檢視他們自己的編輯歷史紀錄。管理員與版主總是可以檢視編輯歷史紀錄。",
"Allow accounts to hide their own edit history" : "允許帳號隱藏他們的編輯歷史紀錄",
"When enabled, accounts can choose to hide their edit history from other accounts in their preferences." : "啟用時,帳號可以在偏好設定中選擇對其他帳號隱藏他們的編輯歷史紀錄。",
"Posts" : "帖文",
"Configure posting features" : "設定貼文功能",
"Enable signatures" : "啟用簽名",
"When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts." : "啟用時,帳號可以在他們的偏好設定中設定簽名,簽名會出現在他們貼文的底部。",
"Settings saved" : "設定已保存",
"Failed to save settings" : "設定儲存失敗",
"Review and restore deleted content" : "審閱並還原已刪除的內容",
"Deleted threads" : "已刪除的討論串",
"Deleted replies" : "已刪除的回覆",
"Search deleted content …" : "搜尋已刪除的內容……",
"Newest first" : "最新先",
"Oldest first" : "最舊先",
"Create role" : "建立角色",
@@ -493,12 +523,21 @@ OC.L10N.register(
"Reset" : "重設",
"Role permissions" : "角色權限",
"Set global permissions for this role" : "設定此角色的全域權限。",
"Dashboard and forum settings" : "儀表板與論壇設定",
"Allow access to the management dashboard and forum settings" : "允許存取管理儀表板與論壇設定",
"Account management" : "帳戶管理",
"Allow viewing accounts and assigning roles" : "允許檢視帳號與指派角色",
"Roles and teams management" : "角色與團隊管理",
"Allow creating, editing and deleting roles and team permissions" : "允許建立、編輯與刪除角色及團隊權限",
"Category management" : "分類管理",
"Allow creating, editing and deleting categories" : "允許建立、編輯及刪除分類。",
"Allow creating, editing and deleting custom BBCodes" : "允許建立、編輯與刪除自訂 BBCode",
"Allow access to the moderation page to review and restore deleted content" : "允許存取審核頁面以審閱並還原已刪除的內容",
"Category permissions" : "分類權限",
"Set which categories this role can access" : "設定此角色可存取哪些分類。",
"Admin role must have all permissions enabled" : "管理員角色必須啟用所有權限。",
"Admin role has full access to all categories" : "管理員角色對所有分類擁有完整存取權。",
"Guest role cannot have management permissions" : "訪客角色不能擁有管理權限",
"Guest role cannot moderate categories" : "訪客角色不能管理分類。",
"You can control which categories guests can view using the checkboxes below." : "你可使用下方的核取方格控制訪客能查看哪些分類。",
"Guest access is currently disabled" : "目前已停用訪客存取。",

View File

@@ -55,8 +55,15 @@
"Welcome to the forum!" : "歡迎來到論壇!",
"Deleted user" : "已刪除的用戶",
"A community-driven forum built right into your Nextcloud instance" : "直接建置在你 Nextcloud 實例中的社群主導論壇",
"Create discussions, share ideas, and collaborate with your community directly in Nextcloud.\n\n**Key features:**\n- **Threaded Discussions** - Create and reply to organized discussion threads with pagination\n- **Category Organization** - Structure your forum with customizable categories, headers, colors, and drag-and-drop reordering\n- **Rich Text Formatting** - BBCode formatting with built-in and custom tags, toolbar with overflow menu\n- **File Attachments** - Attach files from your Nextcloud storage or upload via drag-and-drop\n- **Notifications** - Subscribe to threads and get notified on replies and @mentions\n- **Post Reactions** - React to posts with emoji reactions\n- **Read/Unread Tracking** - Track unread posts at thread and category level\n- **Bookmarks** - Save threads for quick access\n- **Search** - Advanced search with boolean operators and category filtering\n- **User Profiles** - View post history, statistics, and role badges\n- **Roles and Teams** - Fine-grained permissions per role or Nextcloud Team, per category\n- **Guest Access** - Optional public access for unauthenticated visitors with configurable permissions\n- **Edit History** - View post revision history with configurable visibility and per-account privacy controls\n- **Reusable Templates** - Save and insert frequently used content snippets\n- **Signatures** - BBCode-formatted signatures on posts\n- **Thread Drafts** - Auto-saved drafts per category\n- **Dashboard Widgets** - Recent activity, top threads, and top categories on the Nextcloud dashboard\n- **Direct Post Links** - Link directly to a specific post within a thread\n- **Moderation Tools** - Pin, lock, hide, and move threads; review and restore deleted content\n- **Management Tools** - Manage categories, roles, BBCodes, and forum settings with granular permissions\n- **Server Administration** - Repair seeds, rebuild statistics, and assign roles from the Nextcloud admin panel\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 accounts and teams for authentication and access control." : "建立討論、分享點子、直接在 Nextcloud 上與您的社群協作。\n\n**重要功能:**\n- **討論串** - 建立並回覆具有分頁功能的討論串\n- **分類管理** - 透過可自訂的分類、標題、顏色以及拖放重新排序功能,來規劃您的論壇結構\n- **富文字格式設定** - 支援內建與自訂標籤的 BBCode 格式化功能,以及附帶溢出選單的工具列\n- **檔案附件** - 從您的 Nextcloud 儲存空間附加檔案,或透過拖放上傳\n- **通知** - 訂閱討論串,並在有人回覆或@提及時收到通知\n- **貼文反應** - 使用表情符號對貼文做出反應\n- **已讀/未讀追蹤** - 在討論串與分類層級追蹤未讀文章\n- **書籤** - 儲存討論串以供快速存取\n- **搜尋** - 使用布林運算子與分類篩選功能進行進階搜尋\n- **使用者個人資料** - 檢視貼文歷史紀錄、統計資料與角色徽章\n- **角色與團隊** - 依據角色或 Nextcloud 團隊、以及各分類進行細粒度權限設定\n- **訪客存取** - 選擇性公開存取功能,適用於未經身份驗證的訪客,並可自訂權限\n- **編輯歷史紀錄** - 檢視文章修訂歷史紀錄,並可自訂能見度及針對每個帳號的隱私權控管\n- **可重複使用的範本** - 儲存並插入常用內容片段\n- **簽名** - 貼文中使用 BBCode 格式的簽名\n- **討論串草稿** - 根據分類自動儲存草稿\n- **儀表板小工具** - Nextcloud 儀表板上的近期活動、熱門討論串與熱門分類\n- **直接貼文連結** - 直接連結至討論串中的特定貼文\n- **審核工具** - 釘選、鎖定、隱藏、移動討論串,審閱與還原刪除的內容\n- **管理工具** - 使用細粒度權限管理分類、角色、BBCode、論壇設定\n- **伺服器管理** - 從 Nextcloud 管理面板修復種子、重建統計資料、指派角色\n\n**適用於:**\n- 團隊討論與協作\n- 社群論壇\n- 支援管道\n- 知識庫\n- 專案討論\n- 內部溝通\n\n此論壇能與您的 Nextcloud 站台無縫整合,並利用您現有的帳號與團隊進行驗證與存取控制。",
"Forum server administration" : "論壇伺服器管理",
"Database Initial Data" : "資料庫初始資料",
"Restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists." : "還原論壇預設資料角色、分類、權限、BBCode。此操作可安全地重複執行因為系統會跳過已存在的資料。",
"Repair Database Initial Data" : "修復資料庫初始資料",
"Rebuild Statistics" : "重建統計資料",
"Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync." : "重新計算所有論壇統計資料,包括帳號發文數、討論串數及分類計數器。若統計資料顯示不正確或未同步,請使用此功能。",
"User Roles" : "用戶角色",
"Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts." : "為帳號指派論壇角色。此功能可讓您授予特定帳號管理員或版主權限。",
"User ID" : "用戶 ID",
"Enter user ID" : "輸入用戶 ID",
"Role" : "角色",
@@ -68,6 +75,7 @@
"Home" : "主頁",
"Bookmarks" : "書籤",
"User preferences" : "用戶偏愛設定",
"Management" : "管理",
"Dashboard" : "儀表板",
"Forum settings" : "論壇設定",
"Users" : "用戶",
@@ -159,8 +167,14 @@
"The forum has not been set up yet. Please contact an administration member to complete the setup." : "論壇尚未設定完成。請聯絡管理員以完成設定。",
"Deleted" : "已刪除",
"Restore" : "還原",
"Error loading content" : "載入內容時發生錯誤",
"Retry" : "重試",
"No deleted content" : "沒有已刪除的內容",
"There is no deleted content to review." : "沒有待審閱的已刪除內容。",
"Deleted reply" : "已刪除的回覆",
"In thread" : "所在主題",
"Restore reply" : "還原回覆",
"Restore thread" : "還原討論串",
"Move thread to category" : "將討論串移至分類",
"Select the category to move this thread to:" : "選擇要將此討論串移至的分類:",
"Select a category …" : "選擇分類…",
@@ -363,6 +377,9 @@
"You can use BBCode formatting in your signature" : "您可以使用 BBCode 格式化您的簽名",
"Enter your signature …" : "輸入您的簽名 …",
"Privacy" : "私隱",
"Control the visibility of your activity" : "控制您活動的能見度",
"Hide my edit history from other accounts" : "對其他帳號隱藏我的編輯歷史紀錄",
"When enabled, other accounts cannot view the edit history of your posts. Administration and moderators can always view edit history." : "啟用時,其他帳號無法檢視您貼文的編輯歷史紀錄。管理員與版主總是能檢視編輯歷史紀錄。",
"Failed to save preferences" : "儲存偏好設定失敗",
"Select upload directory" : "選擇上載目錄",
"BBCode management" : "BBCode 管理",
@@ -445,6 +462,7 @@
"Select target header" : "選擇目標標題列",
"Move up" : "向上移動",
"Move down" : "向下移動",
"Management dashboard" : "管理儀表板",
"Overview of forum activity and statistics" : "檢視論壇活動與統計總覽",
"Loading statistics …" : "正在載入統計資料 …",
"Error loading dashboard" : "載入控制台時發生錯誤",
@@ -472,9 +490,21 @@
"Manage who can access the forum" : "管理哪些人可以存取論壇。",
"Allow guest access" : "允許訪客存取",
"When enabled, unauthenticated users can view forum content in read-only mode" : "啟用後,未登入用戶亦可在唯讀模式下檢視論壇內容。",
"Control who can view the edit history of posts" : "控制誰可以檢視貼文的編輯歷史紀錄",
"Allow all accounts to view edit history" : "允許所有帳號檢視編輯歷史紀錄",
"When enabled, any account can view the edit history of any post. When disabled, only post owners can view their own edit history. Administration and moderators can always view edit history." : "啟用時,任何帳號都能檢視任何貼文的編輯歷史紀錄。停用時,僅貼文擁有者可以檢視他們自己的編輯歷史紀錄。管理員與版主總是可以檢視編輯歷史紀錄。",
"Allow accounts to hide their own edit history" : "允許帳號隱藏他們的編輯歷史紀錄",
"When enabled, accounts can choose to hide their edit history from other accounts in their preferences." : "啟用時,帳號可以在偏好設定中選擇對其他帳號隱藏他們的編輯歷史紀錄。",
"Posts" : "帖文",
"Configure posting features" : "設定貼文功能",
"Enable signatures" : "啟用簽名",
"When enabled, accounts can set a signature in their preferences that appears at the bottom of their posts." : "啟用時,帳號可以在他們的偏好設定中設定簽名,簽名會出現在他們貼文的底部。",
"Settings saved" : "設定已保存",
"Failed to save settings" : "設定儲存失敗",
"Review and restore deleted content" : "審閱並還原已刪除的內容",
"Deleted threads" : "已刪除的討論串",
"Deleted replies" : "已刪除的回覆",
"Search deleted content …" : "搜尋已刪除的內容……",
"Newest first" : "最新先",
"Oldest first" : "最舊先",
"Create role" : "建立角色",
@@ -491,12 +521,21 @@
"Reset" : "重設",
"Role permissions" : "角色權限",
"Set global permissions for this role" : "設定此角色的全域權限。",
"Dashboard and forum settings" : "儀表板與論壇設定",
"Allow access to the management dashboard and forum settings" : "允許存取管理儀表板與論壇設定",
"Account management" : "帳戶管理",
"Allow viewing accounts and assigning roles" : "允許檢視帳號與指派角色",
"Roles and teams management" : "角色與團隊管理",
"Allow creating, editing and deleting roles and team permissions" : "允許建立、編輯與刪除角色及團隊權限",
"Category management" : "分類管理",
"Allow creating, editing and deleting categories" : "允許建立、編輯及刪除分類。",
"Allow creating, editing and deleting custom BBCodes" : "允許建立、編輯與刪除自訂 BBCode",
"Allow access to the moderation page to review and restore deleted content" : "允許存取審核頁面以審閱並還原已刪除的內容",
"Category permissions" : "分類權限",
"Set which categories this role can access" : "設定此角色可存取哪些分類。",
"Admin role must have all permissions enabled" : "管理員角色必須啟用所有權限。",
"Admin role has full access to all categories" : "管理員角色對所有分類擁有完整存取權。",
"Guest role cannot have management permissions" : "訪客角色不能擁有管理權限",
"Guest role cannot moderate categories" : "訪客角色不能管理分類。",
"You can control which categories guests can view using the checkboxes below." : "你可使用下方的核取方格控制訪客能查看哪些分類。",
"Guest access is currently disabled" : "目前已停用訪客存取。",

View File

@@ -14,6 +14,7 @@ use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\GuestService;
use OCA\Forum\Service\UserRoleService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
@@ -41,6 +42,7 @@ class AdminController extends OCSController {
private IUserManager $userManager,
private IUserSession $userSession,
private AdminSettingsService $settingsService,
private GuestService $guestService,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
@@ -380,4 +382,58 @@ class AdminController extends OCSController {
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Reassign all posts and threads from a guest to a registered user
*
* @param string $guestAuthorId The guest author ID (format: "guest:<token>")
* @param string $targetUserId The target Nextcloud user ID to assign posts to
* @return DataResponse<Http::STATUS_OK, array{success: bool, postsReassigned: int, threadsReassigned: int}, array{}>
*
* 200: Posts reassigned successfully
*/
#[NoAdminRequired]
#[RequirePermission('canManageUsers')]
#[ApiRoute(verb: 'POST', url: '/api/admin/guests/reassign')]
public function reassignGuestPosts(string $guestAuthorId, string $targetUserId): DataResponse {
try {
// Validate guest author ID format
if (!GuestService::isGuestAuthor($guestAuthorId)) {
return new DataResponse(['error' => 'Invalid guest author ID format'], Http::STATUS_BAD_REQUEST);
}
// Validate target user exists
$targetUser = $this->userManager->get($targetUserId);
if ($targetUser === null) {
return new DataResponse(['error' => 'Target user does not exist'], Http::STATUS_NOT_FOUND);
}
// Count posts before reassignment for forum user stats
$postCounts = $this->postMapper->countByAuthorId($guestAuthorId);
// Reassign posts, threads, and last_reply_author_id references
$postsReassigned = $this->postMapper->reassignAuthor($guestAuthorId, $targetUserId);
$threadsReassigned = $this->threadMapper->reassignAuthor($guestAuthorId, $targetUserId);
$this->threadMapper->reassignLastReplyAuthor($guestAuthorId, $targetUserId);
// Update the target user's forum user stats
if ($postCounts['replies'] > 0) {
$this->forumUserMapper->incrementPostCount($targetUserId, $postCounts['replies']);
}
if ($postCounts['threads'] > 0) {
$this->forumUserMapper->incrementThreadCount($targetUserId, $postCounts['threads']);
}
$this->logger->info("Reassigned {$postsReassigned} posts and {$threadsReassigned} threads from guest '{$guestAuthorId}' to user '{$targetUserId}'");
return new DataResponse([
'success' => true,
'postsReassigned' => $postsReassigned,
'threadsReassigned' => $threadsReassigned,
]);
} catch (\Exception $e) {
$this->logger->error('Error reassigning guest posts: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to reassign guest posts'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -78,13 +78,25 @@ class CategoryController extends OCSController {
}
}
// Group accessible categories by header_id
// Build a lookup map for resolving effective header IDs
$allCatsById = [];
foreach ($allCategories as $category) {
$allCatsById[$category->getId()] = $category;
}
// Group accessible categories by effective header_id
// Child categories inherit the header from their root ancestor
$categoriesByHeader = [];
foreach ($allCategories as $category) {
if (!in_array($category->getId(), $accessibleCategoryIds, true)) {
continue;
}
$headerId = $category->getHeaderId();
// Walk up the parent chain to find the effective header
$current = $category;
while ($current->getParentId() !== null && isset($allCatsById[$current->getParentId()])) {
$current = $allCatsById[$current->getParentId()];
}
$headerId = $current->getHeaderId();
if (!isset($categoriesByHeader[$headerId])) {
$categoriesByHeader[$headerId] = [];
}
@@ -190,13 +202,15 @@ class CategoryController extends OCSController {
/**
* Create a new category
*
* @param int $headerId Category header ID
* @param int|null $headerId Category header ID (required for top-level categories)
* @param string $name Category name
* @param string $slug Category slug
* @param string|null $description Category description
* @param int $sortOrder Sort order
* @param string|null $color Category color (hex, e.g. #dc2626)
* @param string|null $textColor Text color mode ('light' or 'dark')
* @param int|null $parentId Parent category ID (null for top-level categories)
* @param bool $hideChildrenOnCard Whether to hide child categories on the parent card
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
*
* 201: Category created
@@ -204,16 +218,32 @@ class CategoryController extends OCSController {
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'POST', url: '/api/categories')]
public function create(int $headerId, string $name, string $slug, ?string $description = null, int $sortOrder = 0, ?string $color = null, ?string $textColor = null): DataResponse {
public function create(?int $headerId = null, string $name = '', string $slug = '', ?string $description = null, int $sortOrder = 0, ?string $color = null, ?string $textColor = null, ?int $parentId = null, bool $hideChildrenOnCard = false): DataResponse {
try {
// Validate: either headerId (top-level) or parentId (child) must be set
if ($parentId !== null) {
// Validate parent exists
try {
$this->categoryMapper->find($parentId);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Parent category not found'], Http::STATUS_NOT_FOUND);
}
// Child categories don't have their own header
$headerId = null;
} elseif ($headerId === null) {
return new DataResponse(['error' => 'Either headerId or parentId must be provided'], Http::STATUS_BAD_REQUEST);
}
$category = new \OCA\Forum\Db\Category();
$category->setHeaderId($headerId);
$category->setParentId($parentId);
$category->setName($name);
$category->setSlug($slug);
$category->setDescription($description);
$category->setSortOrder($sortOrder);
$category->setColor($color);
$category->setTextColor($textColor);
$category->setHideChildrenOnCard($hideChildrenOnCard);
$category->setThreadCount(0);
$category->setPostCount(0);
$category->setCreatedAt(time());
@@ -239,6 +269,8 @@ class CategoryController extends OCSController {
* @param int|null $sortOrder Sort order
* @param string|null $color Category color (hex, e.g. #dc2626)
* @param string|null $textColor Text color mode ('light' or 'dark')
* @param string|null $parentId Parent category ID ('__unset__' = not provided, null = top-level, int = child)
* @param bool|null $hideChildrenOnCard Whether to hide child categories on the parent card
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Category updated
@@ -246,13 +278,49 @@ class CategoryController extends OCSController {
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'PUT', url: '/api/categories/{id}')]
public function update(int $id, ?int $headerId = null, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null, ?string $color = '__unset__', ?string $textColor = '__unset__'): DataResponse {
public function update(int $id, ?int $headerId = null, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null, ?string $color = '__unset__', ?string $textColor = '__unset__', string|int|null $parentId = '__unset__', ?bool $hideChildrenOnCard = null): DataResponse {
try {
$category = $this->categoryMapper->find($id);
if ($headerId !== null) {
// Handle parentId changes
if ($parentId !== '__unset__') {
if ($parentId !== null) {
$parentIdInt = (int)$parentId;
// Validate parent exists
try {
$this->categoryMapper->find($parentIdInt);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Parent category not found'], Http::STATUS_NOT_FOUND);
}
// Prevent circular references: walk up from proposed parent
$current = $parentIdInt;
while ($current !== null) {
if ($current === $id) {
return new DataResponse(['error' => 'Cannot set a descendant as parent (circular reference)'], Http::STATUS_BAD_REQUEST);
}
try {
$parentCat = $this->categoryMapper->find($current);
$current = $parentCat->getParentId();
} catch (DoesNotExistException $e) {
break;
}
}
$category->setParentId($parentIdInt);
$category->setHeaderId(null);
} else {
// Moving to top-level: need a headerId
$category->setParentId(null);
if ($headerId !== null) {
$category->setHeaderId($headerId);
}
}
} elseif ($headerId !== null) {
$category->setHeaderId($headerId);
}
if ($name !== null) {
$category->setName($name);
}
@@ -271,6 +339,9 @@ class CategoryController extends OCSController {
if ($textColor !== '__unset__') {
$category->setTextColor($textColor);
}
if ($hideChildrenOnCard !== null) {
$category->setHideChildrenOnCard($hideChildrenOnCard);
}
$category->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Category */
@@ -324,6 +395,18 @@ class CategoryController extends OCSController {
try {
$category = $this->categoryMapper->find($id);
// Re-parent children: move direct children to this category's parent
$children = $this->categoryMapper->findByParentId($id);
foreach ($children as $child) {
$child->setParentId($category->getParentId());
// If deleted category was top-level, children become top-level under the same header
if ($category->getParentId() === null) {
$child->setHeaderId($category->getHeaderId());
}
$child->setUpdatedAt(time());
$this->categoryMapper->update($child);
}
$threadsAffected = 0;
// Handle threads migration or soft-delete

View File

@@ -8,14 +8,17 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Service\PermissionService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\Route;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IRequest;
@@ -26,20 +29,57 @@ class FileController extends Controller {
string $appName,
IRequest $request,
private PostMapper $postMapper,
private PermissionService $permissionService,
private IRootFolder $rootFolder,
private IAppConfig $config,
private LoggerInterface $logger,
private ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Check if the current user can view a post's attachments.
* For authenticated users, checks category canView permission.
* For guests, also checks that guest access is globally enabled.
* Returns a 403 response if denied, or null if access is allowed.
*/
private function checkFileAccess(int $postId): ?DataResponse {
// Guests: check global guest access first
if ($this->userId === null) {
$guestAccessEnabled = $this->config->getAppValueBool('allow_guest_access', false, true);
if (!$guestAccessEnabled) {
return new DataResponse(['error' => 'Authentication required'], Http::STATUS_FORBIDDEN);
}
}
// Check canView on the post's category (works for both guests and authenticated users)
try {
$categoryId = $this->permissionService->getCategoryIdFromPost($postId);
if (!$this->permissionService->hasCategoryPermission($this->userId, $categoryId, 'canView')) {
return new DataResponse(['error' => 'Access denied'], Http::STATUS_FORBIDDEN);
}
} catch (\Exception $e) {
// If we can't determine the category, deny access for safety
return new DataResponse(['error' => 'Access denied'], Http::STATUS_FORBIDDEN);
}
return null;
}
/**
* Download a BBCode attachment file
*/
#[PublicPage]
#[NoAdminRequired]
#[NoCSRFRequired]
#[Route(type: Route::TYPE_FRONTPAGE, verb: 'GET', url: '/api/posts/{postId}/files')]
public function download(int $postId, string $filePath): FileDisplayResponse|DataResponse {
$denied = $this->checkFileAccess($postId);
if ($denied) {
return $denied;
}
try {
$post = $this->postMapper->find($postId);
@@ -60,7 +100,14 @@ class FileController extends Controller {
return new DataResponse(['error' => 'Invalid file'], Http::STATUS_BAD_REQUEST);
}
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
$mimeType = $file->getMimeType();
// Support Range requests for media files (video/audio) to enable seeking
if (str_starts_with($mimeType, 'video/') || str_starts_with($mimeType, 'audio/')) {
return $this->serveWithRangeSupport($file);
}
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $mimeType]);
$response->addHeader('Cache-Control', 'public, max-age=3600');
return $response;
@@ -77,10 +124,16 @@ class FileController extends Controller {
/**
* Get preview for a BBCode attachment
*/
#[PublicPage]
#[NoAdminRequired]
#[NoCSRFRequired]
#[Route(type: Route::TYPE_FRONTPAGE, verb: 'GET', url: '/api/posts/{postId}/preview')]
public function preview(int $postId, string $filePath, int $x = 1920, int $y = 1080): FileDisplayResponse|DataResponse {
$denied = $this->checkFileAccess($postId);
if ($denied) {
return $denied;
}
try {
$post = $this->postMapper->find($postId);
@@ -119,4 +172,74 @@ class FileController extends Controller {
return new DataResponse(['error' => 'Error loading preview'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Stream a media file with HTTP Range support for seeking
*
* Bypasses Nextcloud's response framework to stream directly,
* enabling proper range request handling for video/audio seeking.
*
* @return never
*/
private function serveWithRangeSupport(\OCP\Files\File $file): never {
$fileSize = $file->getSize();
$mimeType = $file->getMimeType();
$rangeHeader = $this->request->getHeader('Range');
$start = 0;
$end = $fileSize - 1;
$statusCode = Http::STATUS_OK;
if ($rangeHeader && preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = (int)$matches[1];
$end = $matches[2] !== '' ? (int)$matches[2] : $end;
$statusCode = Http::STATUS_PARTIAL_CONTENT;
}
$length = $end - $start + 1;
// Clear any previous output buffers
while (ob_get_level() > 0) {
ob_end_clean();
}
http_response_code($statusCode);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600');
header('Content-Disposition: inline; filename="' . basename($file->getName()) . '"');
if ($statusCode === Http::STATUS_PARTIAL_CONTENT) {
header("Content-Range: bytes $start-$end/$fileSize");
}
// Stream the file in chunks
$handle = $file->fopen('r');
if ($handle === false) {
http_response_code(500);
exit;
}
if ($start > 0) {
fseek($handle, $start);
}
$remaining = $length;
$chunkSize = 1024 * 1024; // 1MB chunks
while ($remaining > 0 && !feof($handle)) {
$readSize = min($chunkSize, $remaining);
$data = fread($handle, $readSize);
if ($data === false) {
break;
}
echo $data;
flush();
$remaining -= strlen($data);
}
fclose($handle);
exit;
}
}

View File

@@ -51,10 +51,12 @@ class PageController extends Controller {
$response = new PublicTemplateResponse(Application::APP_ID, 'app', $templateData);
}
// Allow loading images from external sources in forum posts
// Allow loading external resources in forum posts
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('*');
$csp->addAllowedMediaDomain('*');
$csp->addAllowedFrameDomain('https://www.youtube.com');
$csp->addAllowedFrameDomain('https://www.youtube-nocookie.com');
$response->setContentSecurityPolicy($csp);
return $response;

View File

@@ -16,6 +16,8 @@ use OCP\AppFramework\Db\Entity;
* @method void setId(int $value)
* @method int getHeaderId()
* @method void setHeaderId(int $value)
* @method int|null getParentId()
* @method void setParentId(?int $value)
* @method string getName()
* @method void setName(string $value)
* @method string|null getDescription()
@@ -32,6 +34,8 @@ use OCP\AppFramework\Db\Entity;
* @method void setColor(?string $value)
* @method string|null getTextColor()
* @method void setTextColor(?string $value)
* @method bool getHideChildrenOnCard()
* @method void setHideChildrenOnCard(bool $value)
* @method int getCreatedAt()
* @method void setCreatedAt(int $value)
* @method int getUpdatedAt()
@@ -39,12 +43,14 @@ use OCP\AppFramework\Db\Entity;
*/
class Category extends Entity implements JsonSerializable {
protected $headerId;
protected $parentId;
protected $name;
protected $description;
protected $slug;
protected $sortOrder;
protected $color;
protected $textColor;
protected $hideChildrenOnCard;
protected $threadCount;
protected $postCount;
protected $createdAt;
@@ -53,12 +59,14 @@ class Category extends Entity implements JsonSerializable {
public function __construct() {
$this->addType('id', 'integer');
$this->addType('headerId', 'integer');
$this->addType('parentId', 'integer');
$this->addType('name', 'string');
$this->addType('description', 'string');
$this->addType('slug', 'string');
$this->addType('sortOrder', 'integer');
$this->addType('color', 'string');
$this->addType('textColor', 'string');
$this->addType('hideChildrenOnCard', 'boolean');
$this->addType('threadCount', 'integer');
$this->addType('postCount', 'integer');
$this->addType('createdAt', 'integer');
@@ -69,12 +77,14 @@ class Category extends Entity implements JsonSerializable {
return [
'id' => $this->getId(),
'headerId' => $this->getHeaderId(),
'parentId' => $this->getParentId(),
'name' => $this->getName(),
'description' => $this->getDescription(),
'slug' => $this->getSlug(),
'sortOrder' => $this->getSortOrder(),
'color' => $this->getColor(),
'textColor' => $this->getTextColor(),
'hideChildrenOnCard' => (bool)$this->getHideChildrenOnCard(),
'threadCount' => $this->getThreadCount(),
'postCount' => $this->getPostCount(),
'createdAt' => $this->getCreatedAt(),

View File

@@ -119,6 +119,46 @@ class CategoryMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find direct children of a category
*
* @param int $parentId Parent category ID
* @return array<Category>
*/
public function findByParentId(int $parentId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq('parent_id', $qb->createNamedParameter($parentId, IQueryBuilder::PARAM_INT))
)
->orderBy('sort_order', 'ASC');
return $this->findEntities($qb);
}
/**
* Find all descendants of a category (iterative breadth-first)
*
* @param int $categoryId Root category ID
* @return array<Category> All descendants (not including the root)
*/
public function findChildren(int $categoryId): array {
$allChildren = [];
$queue = [$categoryId];
while (!empty($queue)) {
$currentId = array_shift($queue);
$children = $this->findByParentId($currentId);
foreach ($children as $child) {
$allChildren[] = $child;
$queue[] = $child->getId();
}
}
return $allChildren;
}
/**
* Move all categories from one header to another
*

View File

@@ -482,6 +482,64 @@ class PostMapper extends QBMapper {
return $count;
}
/**
* Reassign all posts from one author to another
*
* @param string $fromAuthorId Current author ID (e.g., "guest:abc123")
* @param string $toAuthorId New author ID (e.g., "john")
* @return int Number of posts updated
*/
public function reassignAuthor(string $fromAuthorId, string $toAuthorId): int {
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName())
->set('author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR))
->set('updated_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR)));
return $qb->executeStatement();
}
/**
* Count posts by author (including deleted)
*/
public function countByAuthorId(string $authorId): array {
$qb = $this->db->getQueryBuilder();
$qb->select(
$qb->func()->count('*', 'total'),
)
->from($this->getTableName())
->where($qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR)));
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
// Count first posts (threads) vs replies separately
$qb2 = $this->db->getQueryBuilder();
$qb2->select($qb2->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb2->expr()->eq('author_id', $qb2->createNamedParameter($authorId, IQueryBuilder::PARAM_STR)))
->andWhere($qb2->expr()->eq('is_first_post', $qb2->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb2->expr()->isNull('deleted_at'));
$result2 = $qb2->executeQuery();
$row2 = $result2->fetch();
$result2->closeCursor();
$qb3 = $this->db->getQueryBuilder();
$qb3->select($qb3->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb3->expr()->eq('author_id', $qb3->createNamedParameter($authorId, IQueryBuilder::PARAM_STR)))
->andWhere($qb3->expr()->eq('is_first_post', $qb3->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb3->expr()->isNull('deleted_at'));
$result3 = $qb3->executeQuery();
$row3 = $result3->fetch();
$result3->closeCursor();
return [
'total' => (int)($row['total'] ?? 0),
'threads' => (int)($row2['count'] ?? 0),
'replies' => (int)($row3['count'] ?? 0),
];
}
/**
* Find all posts for a thread, including deleted posts
*

View File

@@ -359,6 +359,37 @@ class ThreadMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Reassign all threads from one author to another
*
* @param string $fromAuthorId Current author ID (e.g., "guest:abc123")
* @param string $toAuthorId New author ID (e.g., "john")
* @return int Number of threads updated
*/
public function reassignAuthor(string $fromAuthorId, string $toAuthorId): int {
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName())
->set('author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR))
->set('updated_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR)));
return $qb->executeStatement();
}
/**
* Reassign last_reply_author_id from one author to another
*
* @param string $fromAuthorId Current author ID
* @param string $toAuthorId New author ID
* @return int Number of threads updated
*/
public function reassignLastReplyAuthor(string $fromAuthorId, string $toAuthorId): int {
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName())
->set('last_reply_author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR))
->where($qb->expr()->eq('last_reply_author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR)));
return $qb->executeStatement();
}
/**
* Find a thread by ID including soft-deleted threads
*

View File

@@ -0,0 +1,55 @@
<?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 29 Migration:
* - Add parent_id column to forum_categories for subcategory support
* - Add hide_children_on_card column to forum_categories
* - Make header_id nullable (child categories don't have their own header)
*/
class Version29Date20260402000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('forum_categories')) {
$table = $schema->getTable('forum_categories');
// Make header_id nullable - child categories inherit header from parent chain
if ($table->hasColumn('header_id')) {
$column = $table->getColumn('header_id');
$column->setNotnull(false);
$column->setDefault(null);
}
if (!$table->hasColumn('parent_id')) {
$table->addColumn('parent_id', 'integer', [
'notnull' => false,
'unsigned' => true,
'default' => null,
]);
$table->addIndex(['parent_id'], 'forum_cat_parent_id_idx');
}
if (!$table->hasColumn('hide_children_on_card')) {
$table->addColumn('hide_children_on_card', 'boolean', [
'notnull' => false,
'default' => false,
]);
}
}
return $schema;
}
}

View File

@@ -60,7 +60,7 @@ class BBCodeService {
// Handle based on the special handler type
$html = match ($handler) {
'attachment' => $this->renderAttachment($innerContent, $authorId, $postId),
default => htmlspecialchars($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
default => $this->esc($matches[0]),
};
$specialHandlerPlaceholders[$placeholder] = $html;
@@ -68,6 +68,17 @@ class BBCodeService {
}, $content);
}
// Preprocess builtin tag overrides (tags whose library rendering we replace)
$builtinOverridePlaceholders = [];
foreach ($this->getBuiltinOverrides() as $tag => $renderer) {
$pattern = '/\[' . preg_quote($tag, '/') . '\](.*?)\[\/' . preg_quote($tag, '/') . '\]/s';
$content = preg_replace_callback($pattern, function ($matches) use (&$builtinOverridePlaceholders, $renderer) {
$placeholder = '___BUILTIN_OVERRIDE_' . count($builtinOverridePlaceholders) . '___';
$builtinOverridePlaceholders[$placeholder] = $renderer(trim($matches[1]));
return $placeholder;
}, $content);
}
// Preprocess [code] blocks to prevent nl2br and trim whitespace
// The built-in [code] tag wraps content in <pre><code>, so we don't want <br/> tags inside
$codePlaceholders = [];
@@ -75,7 +86,7 @@ class BBCodeService {
$placeholder = '___CODE_BLOCK_' . count($codePlaceholders) . '___';
// Trim leading and trailing newlines, then HTML-escape
$innerContent = trim($matches[1], "\r\n");
$innerContent = htmlspecialchars($innerContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$innerContent = $this->esc($innerContent);
$codePlaceholders[$placeholder] = '<pre><code>' . $innerContent . '</code></pre>';
return $placeholder;
}, $content);
@@ -145,7 +156,7 @@ class BBCodeService {
}
// HTML-escape the inner content to prevent any HTML injection
$innerContent = htmlspecialchars($innerContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$innerContent = $this->esc($innerContent);
// Replace {content} with the escaped inner content
$html = str_replace('{content}', $innerContent, $replacement);
@@ -176,6 +187,11 @@ class BBCodeService {
$html = str_replace($placeholder, $replacement, $html);
}
// Replace builtin override placeholders
foreach ($builtinOverridePlaceholders as $placeholder => $replacement) {
$html = str_replace($placeholder, $replacement, $html);
}
// Replace code block placeholders (must be done before other placeholders to avoid double-escaping)
foreach ($codePlaceholders as $placeholder => $replacement) {
$html = str_replace($placeholder, $replacement, $html);
@@ -188,7 +204,7 @@ class BBCodeService {
// Restore disabled tags as literal text
foreach ($disabledPlaceholders as $placeholder => $original) {
$html = str_replace($placeholder, htmlspecialchars($original, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $html);
$html = str_replace($placeholder, $this->esc($original), $html);
}
// Parse @mentions and convert them to user profile links
@@ -198,7 +214,7 @@ class BBCodeService {
} catch (\Exception $e) {
$this->logger->error('BBCode parsing error: ' . $e->getMessage());
// Return escaped content as fallback
return htmlspecialchars($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $this->esc($content);
}
}
@@ -225,6 +241,11 @@ class BBCodeService {
// Create a new parser instance each time to ensure fresh state
$parser = new BBCodeParser();
// Ignore builtin tags that we override in preprocessing
foreach (array_keys($this->getBuiltinOverrides()) as $tag) {
$parser->ignoreTag($tag);
}
// Register custom BBCodes from database
foreach ($bbCodes as $bbCode) {
// Skip disabled tags (handled in preprocessing)
@@ -364,7 +385,6 @@ class BBCodeService {
* @return string The rendered HTML for the attachment
*/
private function renderAttachment(string $filePath, ?string $authorId, ?int $postId): string {
// Trim whitespace from file path
$filePath = trim($filePath);
if (empty($filePath)) {
@@ -372,111 +392,52 @@ class BBCodeService {
return '<span class="attachment-error">Invalid attachment</span>';
}
// If no author ID provided, we can't verify ownership
if (empty($authorId)) {
$this->logger->warning('Attachment rendering attempted without author ID: ' . $filePath);
return '<span class="attachment-error">Attachment unavailable</span>';
}
// If no post ID provided, we can't generate proxy URLs
if (empty($postId)) {
$this->logger->warning('Attachment rendering attempted without post ID: ' . $filePath);
return '<span class="attachment-error">Attachment unavailable</span>';
}
try {
// Get the user's folder
$userFolder = $this->rootFolder->getUserFolder($authorId);
$this->logger->debug('Attempting to load attachment', [
'filePath' => $filePath,
'authorId' => $authorId,
]);
// Get the file - path is relative to user's home directory
$file = $userFolder->get($filePath);
// Verify it's actually a file (not a folder)
if (!($file instanceof \OCP\Files\File)) {
$this->logger->warning('Attachment path is not a file: ' . $filePath);
return '<span class="attachment-error">Invalid attachment</span>';
}
// Get file metadata
$fileName = $file->getName();
$mimeType = $file->getMimeType();
$fileSize = $file->getSize();
$fileId = $file->getId();
$mimeCategory = explode('/', $mimeType)[0];
// Check if it's an image
if (str_starts_with($mimeType, 'image/')) {
// Generate preview URL for images using proxy endpoint
$previewUrl = $this->urlGenerator->linkToRouteAbsolute(
// Resolve URLs and metadata once
$ctx = [
'fileName' => $this->esc($file->getName()),
'mimeType' => $this->esc($mimeType),
'downloadUrl' => $this->esc($this->urlGenerator->linkToRouteAbsolute(
'forum.file.download',
['postId' => $postId, 'filePath' => $filePath]
)),
'previewUrl' => $this->esc($this->urlGenerator->linkToRouteAbsolute(
'forum.file.preview',
['postId' => $postId, 'filePath' => $filePath, 'x' => 1920, 'y' => 1080]
);
)),
'fileSize' => $this->formatFileSize($file->getSize()),
'iconClass' => $this->getFileIconClass($mimeType),
];
// Render as image
$escapedFileName = htmlspecialchars($fileName, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedUrl = htmlspecialchars($previewUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$html = match ($mimeCategory) {
'image' => $this->renderImageAttachment($ctx),
'video' => $this->renderVideoAttachment($ctx),
'audio' => $this->renderAudioAttachment($ctx),
default => $this->renderFileAttachment($ctx),
};
return sprintf(
'<div class="attachment attachment-image"><img src="%s" alt="%s" title="%s" loading="lazy" /></div>',
$escapedUrl,
$escapedFileName,
$escapedFileName
);
} elseif (str_starts_with($mimeType, 'video/')) {
// Generate download URL for video (used as source)
$videoUrl = $this->urlGenerator->linkToRouteAbsolute(
'forum.file.download',
['postId' => $postId, 'filePath' => $filePath]
);
$escapedFileName = htmlspecialchars($fileName, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedUrl = htmlspecialchars($videoUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedMimeType = htmlspecialchars($mimeType, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return sprintf(
'<div class="attachment attachment-video">'
. '<video controls preload="metadata" title="%s">'
. '<source src="%s" type="%s" />'
. '</video>'
. '</div>',
$escapedFileName,
$escapedUrl,
$escapedMimeType
);
} else {
// Generate download URL for non-image files using proxy endpoint
$downloadUrl = $this->urlGenerator->linkToRouteAbsolute(
'forum.file.download',
['postId' => $postId, 'filePath' => $filePath]
);
// Render as file link with icon
$escapedFileName = htmlspecialchars($fileName, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedUrl = htmlspecialchars($downloadUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$formattedSize = $this->formatFileSize($fileSize);
// Get appropriate icon for file type
$iconClass = $this->getFileIconClass($mimeType);
return sprintf(
'<div class="attachment attachment-file">'
. '<span class="attachment-icon %s"></span>'
. '<div class="attachment-info">'
. '<a href="%s" class="attachment-name" download="%s">%s</a>'
. '<span class="attachment-size">%s</span>'
. '</div>'
. '</div>',
$iconClass,
$escapedUrl,
$escapedFileName,
$escapedFileName,
$formattedSize
);
}
return $html;
} catch (NotFoundException $e) {
$this->logger->warning('Attachment file not found: ' . $filePath);
return '<span class="attachment-error">Attachment not found</span>';
@@ -486,6 +447,109 @@ class BBCodeService {
}
}
/**
* @param array{fileName: string, previewUrl: string} $ctx
*/
private function renderImageAttachment(array $ctx): string {
return sprintf(
'<div class="attachment attachment-image">'
. '<img src="%s" alt="%s" title="%s" loading="lazy" />'
. '</div>',
$ctx['previewUrl'],
$ctx['fileName'],
$ctx['fileName']
);
}
/**
* @param array{fileName: string, downloadUrl: string, mimeType: string} $ctx
*/
private function renderVideoAttachment(array $ctx): string {
return sprintf(
'<div class="attachment attachment-video">'
. '<video controls playsinline preload="metadata" title="%s">'
. '<source src="%s" type="%s" />'
. '</video>'
. '</div>',
$ctx['fileName'],
$ctx['downloadUrl'],
$ctx['mimeType']
);
}
/**
* @param array{fileName: string, downloadUrl: string, mimeType: string} $ctx
*/
private function renderAudioAttachment(array $ctx): string {
return sprintf(
'<div class="attachment attachment-audio">'
. '<audio controls preload="metadata" title="%s">'
. '<source src="%s" type="%s" />'
. '</audio>'
. '</div>',
$ctx['fileName'],
$ctx['downloadUrl'],
$ctx['mimeType']
);
}
/**
* @param array{fileName: string, downloadUrl: string, fileSize: string, iconClass: string} $ctx
*/
private function renderFileAttachment(array $ctx): string {
return sprintf(
'<div class="attachment attachment-file">'
. '<span class="attachment-icon %s"></span>'
. '<div class="attachment-info">'
. '<a href="%s" class="attachment-name" download="%s">%s</a>'
. '<span class="attachment-size">%s</span>'
. '</div>'
. '</div>',
$ctx['iconClass'],
$ctx['downloadUrl'],
$ctx['fileName'],
$ctx['fileName'],
$ctx['fileSize']
);
}
/**
* HTML-escape a string for safe use in attributes and content
*/
private function esc(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Returns a map of builtin tag names to renderer callables.
* Each renderer receives the raw inner content and returns HTML.
* These tags are ignored by the library parser and handled in preprocessing.
*
* To override another builtin tag, add an entry here — the preprocessing
* loop and parser ignoreTag loop will pick it up automatically.
*
* @return array<string, \Closure(string): string>
*/
private function getBuiltinOverrides(): array {
return [
'youtube' => fn (string $content): string => $this->renderYoutubeEmbed($content),
];
}
/**
* Render a YouTube embed from a video ID
*/
private function renderYoutubeEmbed(string $videoId): string {
$escapedId = $this->esc($videoId);
return '<div class="embed-video">'
. '<iframe class="youtube-player" width="560" height="315"'
. ' src="https://www.youtube.com/embed/' . $escapedId . '"'
. ' title="YouTube video player" frameborder="0"'
. ' allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"'
. ' referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
. '</div>';
}
/**
* Format file size in human-readable format
*
@@ -543,25 +607,28 @@ class BBCodeService {
$userId = $matches[1] !== '' ? $matches[1] : $matches[2];
// Check if the user exists
$user = $this->userManager->get($userId);
try {
$user = $this->userManager->get($userId);
} catch (\Exception $e) {
return $matches[0];
}
if ($user === null) {
// User doesn't exist, return original text
return $matches[0];
}
$displayName = $user->getDisplayName();
$escapedUserId = htmlspecialchars($userId, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedDisplayName = htmlspecialchars($displayName, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$displayName = $user->getDisplayName() ?? $userId;
$escapedUserId = $this->esc($userId);
$escapedDisplayName = $this->esc($displayName);
// Generate link to user profile in the forum app
$profileUrl = $this->urlGenerator->linkToRouteAbsolute('forum.page.index') . 'u/' . urlencode($userId);
$escapedUrl = htmlspecialchars($profileUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedUrl = $this->esc($profileUrl);
// Generate avatar URLs for both light and dark themes
$avatarUrlLight = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $userId, 'size' => 64]);
$avatarUrlDark = $avatarUrlLight . '/dark';
$escapedAvatarUrlLight = htmlspecialchars($avatarUrlLight, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedAvatarUrlDark = htmlspecialchars($avatarUrlDark, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$escapedAvatarUrlLight = $this->esc($avatarUrlLight);
$escapedAvatarUrlDark = $this->esc($avatarUrlDark);
return sprintf(
'<a href="%s" class="mention-bubble" data-user-id="%s">'

View File

@@ -266,13 +266,33 @@ class PermissionService {
// Get all categories
$categories = $this->categoryMapper->findAll();
// Check view permission for each category
// Check view permission for each category (own permission only)
foreach ($categories as $category) {
if ($this->hasCategoryPermission($userId, $category->getId(), 'canView')) {
$accessibleCategoryIds[] = $category->getId();
}
}
return $accessibleCategoryIds;
// Build parent map for ancestor chain checking
$parentMap = [];
foreach ($categories as $category) {
$parentMap[$category->getId()] = $category->getParentId();
}
// Prune categories whose ancestor chain is not fully accessible
$accessibleSet = array_flip($accessibleCategoryIds);
$accessibleCategoryIds = array_filter($accessibleCategoryIds, function ($catId) use ($parentMap, $accessibleSet) {
$current = $parentMap[$catId] ?? null;
while ($current !== null) {
if (!isset($accessibleSet[$current])) {
return false;
}
$current = $parentMap[$current] ?? null;
}
return true;
});
return array_values($accessibleCategoryIds);
} catch (\Exception $e) {
$this->logger->error('Error getting accessible categories: ' . $e->getMessage());
return [];

View File

@@ -825,6 +825,137 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/guests/reassign": {
"post": {
"operationId": "admin-reassign-guest-posts",
"summary": "Reassign all posts and threads from a guest to a registered user",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"guestAuthorId",
"targetUserId"
],
"properties": {
"guestAuthorId": {
"type": "string",
"description": "The guest author ID (format: \"guest:<token>\")"
},
"targetUserId": {
"type": "string",
"description": "The target Nextcloud user ID to assign posts to"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Posts reassigned successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success",
"postsReassigned",
"threadsReassigned"
],
"properties": {
"success": {
"type": "boolean"
},
"postsReassigned": {
"type": "integer",
"format": "int64"
},
"threadsReassigned": {
"type": "integer",
"format": "int64"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/bbcodes": {
"get": {
"operationId": "bb_code-index",
@@ -2924,28 +3055,27 @@
}
],
"requestBody": {
"required": true,
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"headerId",
"name",
"slug"
],
"properties": {
"headerId": {
"type": "integer",
"format": "int64",
"description": "Category header ID"
"nullable": true,
"default": null,
"description": "Category header ID (required for top-level categories)"
},
"name": {
"type": "string",
"default": "",
"description": "Category name"
},
"slug": {
"type": "string",
"default": "",
"description": "Category slug"
},
"description": {
@@ -2971,6 +3101,18 @@
"nullable": true,
"default": null,
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Parent category ID (null for top-level categories)"
},
"hideChildrenOnCard": {
"type": "boolean",
"default": false,
"description": "Whether to hide child categories on the parent card"
}
}
}
@@ -3293,6 +3435,18 @@
"nullable": true,
"default": "__unset__",
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "string",
"nullable": true,
"default": "__unset__",
"description": "Parent category ID ('__unset__' = not provided, null = top-level, int = child)"
},
"hideChildrenOnCard": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether to hide child categories on the parent card"
}
}
}

View File

@@ -825,6 +825,137 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/guests/reassign": {
"post": {
"operationId": "admin-reassign-guest-posts",
"summary": "Reassign all posts and threads from a guest to a registered user",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"guestAuthorId",
"targetUserId"
],
"properties": {
"guestAuthorId": {
"type": "string",
"description": "The guest author ID (format: \"guest:<token>\")"
},
"targetUserId": {
"type": "string",
"description": "The target Nextcloud user ID to assign posts to"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Posts reassigned successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success",
"postsReassigned",
"threadsReassigned"
],
"properties": {
"success": {
"type": "boolean"
},
"postsReassigned": {
"type": "integer",
"format": "int64"
},
"threadsReassigned": {
"type": "integer",
"format": "int64"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/bbcodes": {
"get": {
"operationId": "bb_code-index",
@@ -2924,28 +3055,27 @@
}
],
"requestBody": {
"required": true,
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"headerId",
"name",
"slug"
],
"properties": {
"headerId": {
"type": "integer",
"format": "int64",
"description": "Category header ID"
"nullable": true,
"default": null,
"description": "Category header ID (required for top-level categories)"
},
"name": {
"type": "string",
"default": "",
"description": "Category name"
},
"slug": {
"type": "string",
"default": "",
"description": "Category slug"
},
"description": {
@@ -2971,6 +3101,18 @@
"nullable": true,
"default": null,
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Parent category ID (null for top-level categories)"
},
"hideChildrenOnCard": {
"type": "boolean",
"default": false,
"description": "Whether to hide child categories on the parent card"
}
}
}
@@ -3293,6 +3435,18 @@
"nullable": true,
"default": "__unset__",
"description": "Text color mode ('light' or 'dark')"
},
"parentId": {
"type": "string",
"nullable": true,
"default": "__unset__",
"description": "Parent category ID ('__unset__' = not provided, null = top-level, int = child)"
},
"hideChildrenOnCard": {
"type": "boolean",
"nullable": true,
"default": null,
"description": "Whether to hide child categories on the parent card"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 374 KiB

View File

@@ -33,6 +33,7 @@ import InitializationScreen from '@/components/InitializationScreen.vue'
import { useIsDarkTheme } from '@nextcloud/vue/composables/useIsDarkTheme'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { useCategories } from '@/composables/useCategories'
import { useCurrentUser } from '@/composables/useCurrentUser'
export default defineComponent({
name: 'ForumApp',
@@ -51,7 +52,15 @@ export default defineComponent({
const isDarkTheme = useIsDarkTheme()
const { isInitialized, loaded: settingsLoaded, refresh } = usePublicSettings()
const { clear: clearCategories } = useCategories()
return { isDarkTheme, isInitialized, settingsLoaded, refreshSettings: refresh, clearCategories }
const { refresh: refreshCurrentUser } = useCurrentUser()
return {
isDarkTheme,
isInitialized,
settingsLoaded,
refreshSettings: refresh,
clearCategories,
refreshCurrentUser,
}
},
data() {
return {
@@ -63,7 +72,7 @@ export default defineComponent({
methods: {
async onInitialized() {
this.clearCategories()
await this.refreshSettings()
await Promise.all([this.refreshSettings(), this.refreshCurrentUser()])
},
},
created() {

View File

@@ -65,17 +65,13 @@
<!-- Categories under each header -->
<template v-if="isHeaderOpen(header.id)">
<NcAppNavigationItem
<NavCategoryItem
v-for="category in header.categories"
:key="`category-${category.id}`"
:name="category.name"
:to="{ path: `/c/${category.slug}` }"
:category="category"
:active="isCategoryActive(category)"
>
<template #icon>
<ForumIcon :size="20" />
</template>
</NcAppNavigationItem>
:active-category-ids="activeCategoryIds"
/>
</template>
</NcAppNavigationItem>
@@ -229,8 +225,8 @@ import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSear
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import UserInfo from '@/components/UserInfo'
import NavCategoryItem from './NavCategoryItem.vue'
import HomeIcon from '@icons/Home.vue'
import ForumIcon from '@icons/Forum.vue'
import FolderIcon from '@icons/Folder.vue'
import MagnifyIcon from '@icons/Magnify.vue'
import BookmarkIcon from '@icons/Bookmark.vue'
@@ -260,8 +256,8 @@ export default defineComponent({
NcActionButton,
NcLoadingIcon,
UserInfo,
NavCategoryItem,
HomeIcon,
ForumIcon,
FolderIcon,
MagnifyIcon,
BookmarkIcon,
@@ -277,7 +273,7 @@ export default defineComponent({
AccountCogIcon,
},
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
const { categoryHeaders, fetchCategories, getAllCategoriesFlat } = useCategories()
const { userId, displayName, fetchCurrentUser } = useCurrentUser()
const {
canAccessAdmin,
@@ -294,6 +290,7 @@ export default defineComponent({
return {
categoryHeaders,
fetchCategories,
getAllCategoriesFlat,
fetchCurrentUser,
userId,
displayName,
@@ -371,6 +368,18 @@ export default defineComponent({
this.isLoading = false
}
},
computed: {
activeCategoryIds(): Set<number> {
const ids = new Set<number>()
const allCats = this.getAllCategoriesFlat()
for (const cat of allCats) {
if (this.isCategoryActive(cat)) {
ids.add(cat.id)
}
}
return ids
},
},
methods: {
loadNavigationState(): void {
try {

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory } from '@/test-mocks'
// Mock NcAppNavigationItem to render children in a slot
vi.mock('@nextcloud/vue/components/NcAppNavigationItem', () =>
createComponentMock('NcAppNavigationItem', {
template: '<div class="nav-item-mock" :data-name="name"><slot name="icon" /><slot /></div>',
props: ['name', 'to', 'active'],
}),
)
vi.mock('@icons/Forum.vue', () => createIconMock('ForumIcon'))
import NavCategoryItem from './NavCategoryItem.vue'
describe('NavCategoryItem', () => {
it('should render the category name', () => {
const category = createMockCategory({ id: 1, name: 'General' })
const wrapper = mount(NavCategoryItem, {
props: { category, active: false, activeCategoryIds: new Set<number>() },
})
expect(wrapper.find('[data-name="General"]').exists()).toBe(true)
})
it('should render direct children', () => {
const child1 = createMockCategory({ id: 2, name: 'Child 1', parentId: 1, slug: 'child-1' })
const child2 = createMockCategory({ id: 3, name: 'Child 2', parentId: 1, slug: 'child-2' })
const parent = createMockCategory({
id: 1,
name: 'Parent',
children: [child1, child2],
})
const wrapper = mount(NavCategoryItem, {
props: { category: parent, active: false, activeCategoryIds: new Set<number>() },
})
const items = wrapper.findAll('.nav-item-mock')
// Parent + 2 children = 3 items
expect(items).toHaveLength(3)
expect(wrapper.find('[data-name="Child 1"]').exists()).toBe(true)
expect(wrapper.find('[data-name="Child 2"]').exists()).toBe(true)
})
it('should render grandchildren recursively', () => {
const grandchild = createMockCategory({
id: 3,
name: 'Grandchild',
parentId: 2,
slug: 'grandchild',
})
const child = createMockCategory({
id: 2,
name: 'Child',
parentId: 1,
slug: 'child',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
children: [child],
})
const wrapper = mount(NavCategoryItem, {
props: { category: parent, active: false, activeCategoryIds: new Set<number>() },
})
const items = wrapper.findAll('.nav-item-mock')
// Parent + child + grandchild = 3 items
expect(items).toHaveLength(3)
expect(wrapper.find('[data-name="Grandchild"]').exists()).toBe(true)
})
it('should not render children when there are none', () => {
const category = createMockCategory({ id: 1, name: 'Leaf', children: [] })
const wrapper = mount(NavCategoryItem, {
props: { category, active: false, activeCategoryIds: new Set<number>() },
})
const items = wrapper.findAll('.nav-item-mock')
expect(items).toHaveLength(1)
})
})

View File

@@ -0,0 +1,47 @@
<template>
<NcAppNavigationItem :name="category.name" :to="{ path: `/c/${category.slug}` }" :active="active">
<template #icon>
<ForumIcon :size="20" />
</template>
<!-- Recursive children -->
<template v-if="category.children && category.children.length > 0">
<NavCategoryItem
v-for="child in category.children"
:key="`category-${child.id}`"
:category="child"
:active="activeCategoryIds.has(child.id)"
:active-category-ids="activeCategoryIds"
/>
</template>
</NcAppNavigationItem>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import ForumIcon from '@icons/Forum.vue'
import type { Category } from '@/types'
export default defineComponent({
name: 'NavCategoryItem',
components: {
NcAppNavigationItem,
ForumIcon,
},
props: {
category: {
type: Object as PropType<Category>,
required: true,
},
active: {
type: Boolean,
default: false,
},
activeCategoryIds: {
type: Set as unknown as PropType<Set<number>>,
default: () => new Set(),
},
},
})
</script>

View File

@@ -3,12 +3,12 @@
<div class="bbcode-help">
<!-- Built-in BBCodes Section -->
<section class="bbcode-section">
<h3 class="section-title">{{ strings.builtInTitle }}</h3>
<h2 class="section-title">{{ strings.builtInTitle }}</h2>
<p class="section-description">{{ strings.builtInDescription }}</p>
<div class="bbcode-list">
<ul class="bbcode-list">
<!-- Library-provided BBCodes -->
<div v-for="code in builtInCodes" :key="code.tag" class="bbcode-item">
<li v-for="code in builtInCodes" :key="code.tag" class="bbcode-item">
<div class="bbcode-header">
<code class="bbcode-tag">[{{ code.tag }}]</code>
<span class="bbcode-name">{{ code.name }}</span>
@@ -17,10 +17,10 @@
<span class="example-label">{{ strings.example }}:</span>
<code class="example-code">{{ code.example }}</code>
</div>
</div>
</li>
<!-- Database builtin BBCodes -->
<div v-for="code in builtinDbCodes" :key="code.id" class="bbcode-item">
<li v-for="code in builtinDbCodes" :key="code.id" class="bbcode-item">
<div class="bbcode-header">
<code class="bbcode-tag">[{{ code.tag }}]</code>
<span v-if="code.description" class="bbcode-name">{{ code.description }}</span>
@@ -29,13 +29,13 @@
<span class="example-label">{{ strings.example }}:</span>
<code class="example-code">{{ code.example }}</code>
</div>
</div>
</div>
</li>
</ul>
</section>
<!-- Custom BBCodes Section -->
<section v-if="showCustom" class="bbcode-section">
<h3 class="section-title">{{ strings.customTitle }}</h3>
<h2 class="section-title">{{ strings.customTitle }}</h2>
<p class="section-description">{{ strings.customDescription }}</p>
<!-- Loading state -->
@@ -55,8 +55,8 @@
</div>
<!-- Custom codes list -->
<div v-else class="bbcode-list">
<div v-for="code in customCodes" :key="code.id" class="bbcode-item">
<ul v-else class="bbcode-list">
<li v-for="code in customCodes" :key="code.id" class="bbcode-item">
<div class="bbcode-header">
<code class="bbcode-tag">[{{ code.tag }}]</code>
<span v-if="code.description" class="bbcode-name">{{ code.description }}</span>
@@ -65,8 +65,8 @@
<span class="example-label">{{ strings.example }}:</span>
<code class="example-code" :v-html="code.example"></code>
</div>
</div>
</div>
</li>
</ul>
</section>
</div>
</NcDialog>
@@ -313,6 +313,9 @@ export default defineComponent({
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
padding: 0;
margin: 0;
}
.bbcode-item {

View File

@@ -110,7 +110,7 @@
@update:open="uploadDialog = $event"
size="small"
>
<div class="upload-progress">
<div class="upload-progress" aria-live="polite">
<p class="upload-filename">{{ uploadFileName }}</p>
<template v-if="uploadError">
<p class="upload-error-message">{{ uploadError }}</p>

View File

@@ -77,6 +77,53 @@ describe('CategoryCard', () => {
})
})
describe('children', () => {
it('should not render children section when no children', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory() },
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
it('should not render children section when children is empty', () => {
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children: [] },
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
it('should render child links when children provided', () => {
const children = [
createMockCategory({ id: 2, name: 'Child 1', slug: 'child-1' }),
createMockCategory({ id: 3, name: 'Child 2', slug: 'child-2' }),
]
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children },
global: {
stubs: {
'router-link': {
template: '<a class="child-link"><slot /></a>',
props: ['to'],
},
},
},
})
expect(wrapper.find('.category-children').exists()).toBe(true)
const links = wrapper.findAll('.child-link')
expect(links).toHaveLength(2)
expect(links[0]!.text()).toBe('Child 1')
expect(links[1]!.text()).toBe('Child 2')
})
it('should not render children when hideChildren is true', () => {
const children = [createMockCategory({ id: 2, name: 'Child 1', slug: 'child-1' })]
const wrapper = mount(CategoryCard, {
props: { category: createMockCategory(), children, hideChildren: true },
})
expect(wrapper.find('.category-children').exists()).toBe(false)
})
})
describe('structure', () => {
it('should have correct class', () => {
const wrapper = mount(CategoryCard, {

View File

@@ -3,9 +3,17 @@
class="category-card"
:class="{ unread: isUnread, colored: !!category.color }"
:style="cardStyle"
role="link"
tabindex="0"
>
<div class="category-header">
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<span
v-if="isUnread"
class="unread-indicator"
:title="strings.unread"
:aria-label="strings.unread"
role="img"
></span>
<h4 class="category-name">{{ category.name }}</h4>
<div class="category-stats">
<span class="stat">
@@ -21,6 +29,18 @@
</div>
<p v-if="category.description" class="category-description">{{ category.description }}</p>
<p v-else class="category-description muted">{{ strings.noDescription }}</p>
<!-- Child category links -->
<div v-if="!hideChildren && visibleChildren.length > 0" class="category-children">
<router-link
v-for="child in visibleChildren"
:key="child.id"
:to="`/c/${child.slug}`"
class="child-link"
@click.stop
>
{{ child.name }}
</router-link>
</div>
</div>
</template>
@@ -40,6 +60,14 @@ export default defineComponent({
type: Boolean,
default: false,
},
children: {
type: Array as PropType<Category[]>,
default: () => [],
},
hideChildren: {
type: Boolean,
default: false,
},
},
computed: {
cardStyle(): Record<string, string> {
@@ -53,6 +81,9 @@ export default defineComponent({
}
return style
},
visibleChildren(): Category[] {
return this.children
},
},
data() {
return {
@@ -101,6 +132,16 @@ export default defineComponent({
.category-description.muted {
color: var(--card-text-muted);
}
.child-link {
background: rgba(255, 255, 255, 0.15);
color: var(--card-text);
border-color: rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
}
&.unread:not(.colored) {
@@ -184,5 +225,35 @@ export default defineComponent({
font-style: italic;
}
}
.category-children {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--color-border);
}
.child-link {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
text-decoration: none;
background: var(--color-background-dark);
color: var(--color-main-text);
border: 1px solid var(--color-border);
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-primary-element);
}
}
}
</style>

View File

@@ -64,55 +64,60 @@
</div>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<!-- Category rows under this header (including subcategories) -->
<template v-for="row in flattenCategories(header.categories || [])" :key="row.category.id">
<div class="table-row" :class="{ 'subcategory-row': row.depth > 0 }">
<div class="col-category" :style="{ paddingLeft: `${row.depth * 24 + 16}px` }">
<span class="category-name">
<span v-if="row.depth > 0" class="subcategory-arrow"></span>
{{ row.category.name }}
</span>
<span v-if="row.category.description" class="category-desc muted">
{{ row.category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canView || false"
:disabled="disableView"
@update:model-value="updateCategoryView(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canView || false"
:disabled="disableView"
@update:model-value="updateCategoryView(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canPost || false"
:disabled="disablePost"
@update:model-value="updateCategoryPost(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canPost || false"
:disabled="disablePost"
@update:model-value="updateCategoryPost(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canReply || false"
:disabled="disableReply"
@update:model-value="updateCategoryReply(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canReply || false"
:disabled="disableReply"
@update:model-value="updateCategoryReply(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canModerate || false"
:disabled="disableModerate"
@update:model-value="updateCategoryModerate(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[row.category.id]?.canModerate || false"
:disabled="disableModerate"
@update:model-value="updateCategoryModerate(row.category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
</div>
</div>
</template>
</template>
</div>
<div v-else class="muted">{{ strings.noCategories }}</div>
@@ -124,7 +129,12 @@ import { defineComponent, type PropType } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import { t } from '@nextcloud/l10n'
import type { CategoryHeader } from '@/types'
import type { Category, CategoryHeader } from '@/types'
interface FlatCategoryRow {
category: Category
depth: number
}
export interface CategoryPermission {
canView: boolean
@@ -205,6 +215,24 @@ export default defineComponent({
}
},
methods: {
flattenCategories(categories: Category[], depth = 0): FlatCategoryRow[] {
const result: FlatCategoryRow[] = []
for (const cat of categories) {
result.push({ category: cat, depth })
if (cat.children && cat.children.length > 0) {
result.push(...this.flattenCategories(cat.children, depth + 1))
}
}
return result
},
/** Get all categories under a header (including nested subcategories) */
getAllCategoriesInHeader(headerId: number): Category[] {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return []
return this.flattenCategories(header.categories).map((row) => row.category)
},
ensurePermission(categoryId: number): CategoryPermission {
if (!this.permissions[categoryId]) {
this.permissions[categoryId] = {
@@ -221,13 +249,13 @@ export default defineComponent({
headerId: number,
key: keyof CategoryPermission,
): { checked: boolean; indeterminate: boolean } {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || header.categories.length === 0) {
const allCats = this.getAllCategoriesInHeader(headerId)
if (allCats.length === 0) {
return { checked: false, indeterminate: false }
}
const checkedCount = header.categories.filter((cat) => this.permissions[cat.id]?.[key]).length
const totalCount = header.categories.length
const checkedCount = allCats.filter((cat) => this.permissions[cat.id]?.[key]).length
const totalCount = allCats.length
if (checkedCount === 0) {
return { checked: false, indeterminate: false }
@@ -265,11 +293,11 @@ export default defineComponent({
},
toggleHeader(headerId: number, key: keyof CategoryPermission): void {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return
const allCats = this.getAllCategoriesInHeader(headerId)
if (allCats.length === 0) return
const newValue = !this.getHeaderState(headerId, key).checked
header.categories.forEach((cat) => {
allCats.forEach((cat) => {
this.ensurePermission(cat.id)[key] = newValue
})
this.$emit('update:permissions', this.permissions)
@@ -366,6 +394,15 @@ export default defineComponent({
background: var(--color-background-hover);
}
&.subcategory-row {
background: var(--color-background-dark);
}
.subcategory-arrow {
color: var(--color-text-maxcontrast);
margin-right: 4px;
}
.col-category {
display: flex;
flex-direction: column;

View File

@@ -8,12 +8,16 @@
@update:model-value="$emit('update:modelValue', $event)"
@submit="$emit('update:modelValue', $event)"
>
<NcButton>
<NcButton
:aria-label="modelValue ? strings.changeColor + ': ' + modelValue : strings.pickColor"
>
<template #icon>
<div
class="color-preview"
:class="{ empty: !modelValue }"
:style="modelValue ? { backgroundColor: modelValue } : {}"
role="img"
:aria-label="modelValue || strings.noColor"
/>
</template>
{{ modelValue || strings.pickColor }}
@@ -53,6 +57,8 @@ export default defineComponent({
return {
strings: {
pickColor: t('forum', 'Pick a color'),
changeColor: t('forum', 'Change color'),
noColor: t('forum', 'No color selected'),
},
}
},

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
},
}))
vi.mock('@nextcloud/dialogs', () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
}))
// Mock NcAvatar
vi.mock('@nextcloud/vue/components/NcAvatar', () =>
createComponentMock('NcAvatar', {
template: '<span class="nc-avatar-mock" :data-user="user" />',
props: ['user', 'size', 'showUserStatus'],
}),
)
// Import after mocks
import { ocs } from '@/axios'
import { showSuccess } from '@nextcloud/dialogs'
import GuestReassignDialog from './GuestReassignDialog.vue'
const mockGet = vi.mocked(ocs.get)
const mockPost = vi.mocked(ocs.post)
describe('GuestReassignDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: [] } as never)
mockPost.mockResolvedValue({
data: { success: true, postsReassigned: 3, threadsReassigned: 1 },
} as never)
})
const createWrapper = (props = {}) => {
return mount(GuestReassignDialog, {
props: {
open: true,
guestAuthorId: 'guest:abcdef1234567890abcdef1234567890',
guestDisplayName: 'BrightMountain42',
...props,
},
})
}
describe('rendering', () => {
it('renders dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('shows description text', () => {
const wrapper = createWrapper()
expect(wrapper.find('.description').exists()).toBe(true)
expect(wrapper.text()).toContain('All posts and threads by this guest will be reassigned')
})
it('shows user search input', () => {
const wrapper = createWrapper()
expect(wrapper.find('.user-search').exists()).toBe(true)
})
})
describe('user search', () => {
it('calls autocomplete API when searching', async () => {
vi.useFakeTimers()
const users = [
{ id: 'alice', label: 'Alice Smith' },
{ id: 'bob', label: 'Bob Jones' },
]
mockGet.mockResolvedValue({ data: users } as never)
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleSearch('ali')
vi.advanceTimersByTime(300)
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/users/autocomplete', {
params: { search: 'ali', limit: 10 },
})
vi.useRealTimers()
})
it('does not call API for empty search', async () => {
vi.useFakeTimers()
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleSearch('')
vi.advanceTimersByTime(300)
await flushPromises()
expect(mockGet).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('debounces search calls', async () => {
vi.useFakeTimers()
mockGet.mockResolvedValue({ data: [] } as never)
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleSearch('a')
vm.handleSearch('al')
vm.handleSearch('ali')
vi.advanceTimersByTime(300)
await flushPromises()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/users/autocomplete', {
params: { search: 'ali', limit: 10 },
})
vi.useRealTimers()
})
})
describe('confirm action', () => {
it('calls reassign API with correct parameters', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
// Simulate selecting a user
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await wrapper.vm.$nextTick()
await vm.handleConfirm()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith('/admin/guests/reassign', {
guestAuthorId: 'guest:abcdef1234567890abcdef1234567890',
targetUserId: 'alice',
})
})
it('shows success message after reassignment', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await vm.handleConfirm()
await flushPromises()
expect(showSuccess).toHaveBeenCalledWith('Guest posts reassigned successfully')
})
it('emits reassigned event on success', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await vm.handleConfirm()
await flushPromises()
expect(wrapper.emitted('reassigned')).toBeTruthy()
expect(wrapper.emitted('reassigned')![0]).toEqual([
{
guestAuthorId: 'guest:abcdef1234567890abcdef1234567890',
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
},
])
})
it('emits update:open false on success', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await vm.handleConfirm()
await flushPromises()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('does not call API when no user is selected', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = null
await vm.handleConfirm()
await flushPromises()
expect(mockPost).not.toHaveBeenCalled()
})
it('shows error message on API failure', async () => {
mockPost.mockRejectedValue({
response: { data: { error: 'Target user does not exist' } },
})
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'nonexistent', label: 'Nobody' }
await vm.handleConfirm()
await flushPromises()
expect(wrapper.find('.error-message').exists()).toBe(true)
expect(wrapper.find('.error-message').text()).toBe('Target user does not exist')
})
})
describe('reset on open', () => {
it('resets state when dialog reopens', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
// Set some state
vm.selectedUser = { id: 'alice', label: 'Alice' }
vm.error = 'Some error'
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(vm.selectedUser).toBeNull()
expect(vm.error).toBeNull()
})
})
describe('close event', () => {
it('emits update:open false when handleClose is called', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleClose()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
})

View File

@@ -0,0 +1,234 @@
<template>
<NcDialog :name="strings.title" :open="open" size="normal" @update:open="handleClose">
<div class="guest-reassign-dialog">
<p class="description">
{{ strings.description }}
</p>
<div class="user-search">
<NcSelect
v-model="selectedUser"
:options="userOptions"
:placeholder="strings.searchPlaceholder"
:input-label="strings.searchLabel"
:loading="searching"
:filterable="false"
label="label"
@search="handleSearch"
>
<template #option="option">
<div class="user-option">
<NcAvatar :user="option.id" :size="24" :show-user-status="false" />
<span class="user-option-label">{{ option.label }}</span>
<span class="user-option-id muted">@{{ option.id }}</span>
</div>
</template>
<template #selected-option="option">
<div class="user-option">
<NcAvatar :user="option.id" :size="20" :show-user-status="false" />
<span class="user-option-label">{{ option.label }}</span>
</div>
</template>
<template #no-options>
{{ searchQuery ? strings.noResults : strings.typeToSearch }}
</template>
</NcSelect>
</div>
<!-- Error message -->
<p v-if="error" class="error-message">{{ error }}</p>
</div>
<template #actions>
<NcButton @click="handleClose">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!selectedUser || submitting" @click="handleConfirm">
{{ submitting ? strings.reassigning : strings.confirm }}
</NcButton>
</template>
</NcDialog>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { showSuccess, showError } from '@nextcloud/dialogs'
interface UserOption {
id: string
label: string
}
export default defineComponent({
name: 'GuestReassignDialog',
components: {
NcDialog,
NcButton,
NcSelect,
NcAvatar,
},
props: {
open: {
type: Boolean,
default: false,
},
guestAuthorId: {
type: String,
default: '',
},
guestDisplayName: {
type: String,
default: '',
},
},
emits: ['update:open', 'reassigned'],
data() {
return {
selectedUser: null as UserOption | null,
userOptions: [] as UserOption[],
searching: false,
submitting: false,
error: null as string | null,
searchQuery: '',
searchTimeout: null as ReturnType<typeof setTimeout> | null,
strings: {
title: t('forum', 'Assign guest posts to account'),
description: t(
'forum',
'All posts and threads by this guest will be reassigned to the selected account.',
),
searchPlaceholder: t('forum', 'Search for an account …'),
searchLabel: t('forum', 'Account'),
noResults: t('forum', 'No accounts found'),
typeToSearch: t('forum', 'Type to search for an account'),
cancel: t('forum', 'Cancel'),
confirm: t('forum', 'Reassign'),
reassigning: t('forum', 'Reassigning …'),
successMessage: t('forum', 'Guest posts reassigned successfully'),
errorMessage: t('forum', 'Failed to reassign guest posts'),
},
}
},
watch: {
open(newVal: boolean) {
if (newVal) {
this.reset()
}
},
},
methods: {
reset() {
this.selectedUser = null
this.userOptions = []
this.error = null
this.searchQuery = ''
},
handleClose() {
this.$emit('update:open', false)
},
handleSearch(query: string) {
this.searchQuery = query
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
if (!query || query.length < 1) {
this.userOptions = []
return
}
this.searchTimeout = setTimeout(() => {
this.fetchUsers(query)
}, 300)
},
async fetchUsers(query: string) {
try {
this.searching = true
const response = await ocs.get<Array<{ id: string; label: string }>>(
'/users/autocomplete',
{
params: { search: query, limit: 10 },
},
)
this.userOptions = (response.data || []).map((u) => ({
id: u.id,
label: u.label,
}))
} catch (e) {
console.error('Error searching users:', e)
this.userOptions = []
} finally {
this.searching = false
}
},
async handleConfirm() {
if (!this.selectedUser || !this.guestAuthorId) {
return
}
try {
this.submitting = true
this.error = null
await ocs.post('/admin/guests/reassign', {
guestAuthorId: this.guestAuthorId,
targetUserId: this.selectedUser.id,
})
showSuccess(this.strings.successMessage)
this.$emit('reassigned', {
guestAuthorId: this.guestAuthorId,
targetUserId: this.selectedUser.id,
targetDisplayName: this.selectedUser.label,
})
this.handleClose()
} catch (e) {
console.error('Error reassigning guest posts:', e)
const errorData = (e as any)?.response?.data
this.error = errorData?.error || this.strings.errorMessage
} finally {
this.submitting = false
}
},
},
})
</script>
<style scoped lang="scss">
.guest-reassign-dialog {
padding: 8px 0;
.description {
margin-bottom: 16px;
color: var(--color-text-maxcontrast);
}
.user-search {
width: 100%;
}
.user-option {
display: flex;
align-items: center;
gap: 8px;
}
.user-option-id {
font-size: 0.85rem;
}
.error-message {
margin-top: 12px;
color: var(--color-error);
font-size: 0.9rem;
}
}
</style>

View File

@@ -0,0 +1,2 @@
import GuestReassignDialog from './GuestReassignDialog.vue'
export default GuestReassignDialog

View File

@@ -31,7 +31,7 @@
</template>
{{ initializing ? strings.initializingButton : strings.initializeButton }}
</NcButton>
<NcNoteCard v-if="errorMessage" type="error" class="init-note">
<NcNoteCard v-if="errorMessage" type="error" class="init-note" role="alert">
{{ errorMessage }}
</NcNoteCard>
</div>

View File

@@ -1,7 +1,7 @@
<template>
<div class="deleted-item-list">
<!-- Loading -->
<div v-if="loading" class="center mt-16">
<div v-if="loading" class="center mt-16" aria-live="polite" aria-busy="true">
<NcLoadingIcon :size="32" />
</div>
@@ -27,14 +27,17 @@
<!-- Items -->
<template v-else>
<div class="item-list">
<ul class="item-list">
<!-- Thread items: clickable to open preview -->
<div
<li
v-for="item in items"
:key="item.id"
class="deleted-item-wrapper"
:class="{ clickable: mode === 'threads' }"
:role="mode === 'threads' ? 'button' : undefined"
:tabindex="mode === 'threads' ? 0 : undefined"
@click="mode === 'threads' && $emit('view', item)"
@keydown.enter="mode === 'threads' && $emit('view', item)"
>
<div class="deleted-item-overlay">
<span class="deleted-badge">
@@ -67,8 +70,8 @@
</div>
<ThreadCard v-if="mode === 'threads'" :thread="item" />
<PostCard v-else :post="item" />
</div>
</div>
</li>
</ul>
<Pagination
v-if="maxPages > 1"
@@ -140,6 +143,8 @@ export default defineComponent({
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
list-style: none;
padding: 0;
}
.deleted-item-wrapper {

View File

@@ -211,7 +211,7 @@ describe('MoveCategoryDialog', () => {
}
const categoryOption = vm.categoryOptions.find((o) => !o.isHeader)
expect(categoryOption!.name).toBe(' Category') // Two spaces prefix
expect(categoryOption!.name).toBe('\u00A0\u00A0Category') // Non-breaking space prefix
})
it('excludes headers with no categories', async () => {

View File

@@ -84,6 +84,7 @@ interface CategoryOption {
id: number
name: string
isHeader?: boolean
$isDisabled?: boolean
}
export default defineComponent({
@@ -147,16 +148,11 @@ export default defineComponent({
id: -header.id, // Negative ID to distinguish from categories
name: header.name,
isHeader: true,
$isDisabled: true,
})
// Add categories under this header
for (const category of header.categories) {
options.push({
id: category.id,
name: ` ${category.name}`,
isHeader: false,
})
}
// Add categories under this header (recursively)
this.addCategoryOptions(header.categories, options, 1)
}
}
@@ -176,6 +172,20 @@ export default defineComponent({
},
},
methods: {
addCategoryOptions(categories: Category[], options: CategoryOption[], depth: number): void {
const indent = '\u00A0\u00A0'.repeat(depth)
for (const category of categories) {
options.push({
id: category.id,
name: `${indent}${category.name}`,
isHeader: false,
})
if (category.children && category.children.length > 0) {
this.addCategoryOptions(category.children, options, depth + 1)
}
}
},
async loadCategories() {
try {
this.loading = true

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockPost, createMockUser } from '@/test-mocks'
import { createMockPost, createMockRole, createMockUser } from '@/test-mocks'
import { useUserRole } from '@/composables/useUserRole'
import PostCard from './PostCard.vue'
// Mock icons
@@ -10,6 +11,7 @@ vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
vi.mock('@icons/LinkVariant.vue', () => createIconMock('LinkVariantIcon'))
vi.mock('@icons/AccountConvert.vue', () => createIconMock('AccountConvertIcon'))
// Mock components
vi.mock('@/components/UserInfo', () =>
@@ -40,6 +42,14 @@ vi.mock('@/components/PostHistoryDialog', () =>
}),
)
vi.mock('@/components/GuestReassignDialog', () =>
createComponentMock('GuestReassignDialog', {
template: '<div class="guest-reassign-dialog-mock" v-if="open" />',
props: ['open', 'guestAuthorId', 'guestDisplayName'],
emits: ['update:open', 'reassigned'],
}),
)
vi.mock('@nextcloud/dialogs', () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
@@ -72,6 +82,8 @@ describe('PostCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentUser.mockReturnValue({ uid: 'testuser', displayName: 'Test User' })
const { clear } = useUserRole()
clear()
})
describe('rendering', () => {
@@ -502,4 +514,99 @@ describe('PostCard', () => {
expect(buttons.some((b) => b.text().includes('Delete'))).toBe(false)
})
})
describe('guest reassignment', () => {
it('should show assign to account button for guest posts when user has canManageUsers', () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const guestAuthor = createMockUser({
userId: 'guest:abc123',
displayName: 'BrightMountain42',
isGuest: true,
})
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(true)
})
it('should not show assign to account button for non-guest posts', () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const author = createMockUser({ userId: 'alice', isGuest: false })
const post = createMockPost({ authorId: 'alice', author })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(false)
})
it('should not show assign to account button when user lacks canManageUsers', () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: false })])
const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true })
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(false)
})
it('should open reassign dialog when button is clicked', async () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true })
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.guest-reassign-dialog-mock').exists()).toBe(false)
const button = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Assign to account'))
await button?.trigger('click')
expect(wrapper.find('.guest-reassign-dialog-mock').exists()).toBe(true)
})
it('should emit reassigned event from dialog', async () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true })
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
const vm = wrapper.vm as InstanceType<typeof PostCard>
vm.handleReassigned({
guestAuthorId: 'guest:abc123',
targetUserId: 'alice',
targetDisplayName: 'Alice',
})
expect(wrapper.emitted('reassigned')).toBeTruthy()
expect(wrapper.emitted('reassigned')![0]).toEqual([
{
guestAuthorId: 'guest:abc123',
targetUserId: 'alice',
targetDisplayName: 'Alice',
},
])
})
})
})

View File

@@ -2,7 +2,13 @@
<div class="post-card" :class="{ 'first-post': isFirstPost, unread: isUnread }">
<div class="post-header">
<div class="author-info">
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<span
v-if="isUnread"
class="unread-indicator"
:title="strings.unread"
:aria-label="strings.unread"
role="img"
></span>
<UserInfo
:user-id="post.author?.userId || post.authorId"
:display-name="post.author?.displayName || post.authorId"
@@ -54,6 +60,12 @@
</template>
{{ strings.directLink }}
</NcActionButton>
<NcActionButton v-if="canReassignGuest" @click="handleReassignGuest">
<template #icon>
<AccountConvertIcon :size="20" />
</template>
{{ strings.assignToAccount }}
</NcActionButton>
</NcActions>
</div>
</div>
@@ -91,6 +103,15 @@
:post-id="post.id"
@update:open="showHistoryDialog = $event"
/>
<!-- Guest Reassign Dialog -->
<GuestReassignDialog
:open="showReassignDialog"
:guest-author-id="post.authorId"
:guest-display-name="post.author?.displayName || ''"
@update:open="showReassignDialog = $event"
@reassigned="handleReassigned"
/>
</div>
</template>
@@ -104,14 +125,17 @@ import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import HistoryIcon from '@icons/History.vue'
import LinkVariantIcon from '@icons/LinkVariant.vue'
import AccountConvertIcon from '@icons/AccountConvert.vue'
import UserInfo from '@/components/UserInfo'
import PostReactions from '@/components/PostReactions'
import PostEditForm from '@/components/PostEditForm'
import PostHistoryDialog from '@/components/PostHistoryDialog'
import GuestReassignDialog from '@/components/GuestReassignDialog'
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'
import { showSuccess } from '@nextcloud/dialogs'
import { useUserRole } from '@/composables/useUserRole'
import type { Post } from '@/types'
import type { ReactionGroup } from '@/composables/useReactions'
@@ -126,10 +150,12 @@ export default defineComponent({
DeleteIcon,
HistoryIcon,
LinkVariantIcon,
AccountConvertIcon,
UserInfo,
PostReactions,
PostEditForm,
PostHistoryDialog,
GuestReassignDialog,
},
props: {
post: {
@@ -157,14 +183,16 @@ export default defineComponent({
default: 1,
},
},
emits: ['reply', 'edit', 'delete', 'update'],
emits: ['reply', 'edit', 'delete', 'update', 'reassigned'],
setup() {
return {}
const { canManageUsers } = useUserRole()
return { canManageUsers }
},
data() {
return {
isEditing: false,
showHistoryDialog: false,
showReassignDialog: false,
strings: {
edited: t('forum', 'Edited'),
reply: t('forum', 'Quote reply'),
@@ -178,6 +206,7 @@ export default defineComponent({
unread: t('forum', 'Unread'),
directLink: t('forum', 'Direct link'),
directLinkCopied: t('forum', 'Direct link copied to clipboard'),
assignToAccount: t('forum', 'Assign to account'),
},
}
},
@@ -209,6 +238,9 @@ export default defineComponent({
hasSignature(): boolean {
return !!this.post.author?.signature
},
canReassignGuest(): boolean {
return this.canManageUsers && !!this.post.author?.isGuest
},
},
methods: {
closeActionsMenu() {
@@ -245,6 +277,19 @@ export default defineComponent({
this.showHistoryDialog = true
},
handleReassignGuest() {
this.closeActionsMenu()
this.showReassignDialog = true
},
handleReassigned(data: {
guestAuthorId: string
targetUserId: string
targetDisplayName: string
}) {
this.$emit('reassigned', data)
},
async handleDirectLink() {
this.closeActionsMenu()

View File

@@ -2,7 +2,7 @@
<NcDialog :name="strings.title" :open="open" @update:open="handleClose" size="large">
<div class="post-history-dialog">
<!-- Loading state -->
<div v-if="loading" class="loading-state">
<div v-if="loading" class="loading-state" aria-live="polite" aria-busy="true">
<NcLoadingIcon :size="32" />
<span class="loading-text">{{ strings.loading }}</span>
</div>

View File

@@ -7,6 +7,8 @@
class="reaction-button"
:class="{ reacted: isReacted(emoji), 'has-count': getCount(emoji) > 0 }"
:title="getReactionTooltip(emoji)"
:aria-label="getReactionTooltip(emoji)"
:aria-pressed="isReacted(emoji)"
@click="handleToggleReaction(emoji)"
>
<span class="emoji">{{ emoji }}</span>
@@ -15,7 +17,11 @@
<!-- Add custom reaction button -->
<LazyEmojiPicker @select="handleSelectEmoji" style="display: inline-block">
<button class="add-reaction-button" :title="strings.addReaction">
<button
class="add-reaction-button"
:title="strings.addReaction"
:aria-label="strings.addReaction"
>
<span class="icon">+</span>
</button>
</LazyEmojiPicker>

View File

@@ -1,5 +1,12 @@
<template>
<div class="search-post-result" :class="{ 'dark-theme': isDarkTheme }" @click="navigateToPost">
<div
class="search-post-result"
:class="{ 'dark-theme': isDarkTheme }"
role="link"
tabindex="0"
@click="navigateToPost"
@keydown.enter="navigateToPost"
>
<div class="result-header">
<div class="thread-context">
<span class="meta-label">{{ strings.inThread }}:</span>

View File

@@ -1,11 +1,30 @@
<template>
<div class="search-thread-result" :class="{ 'dark-theme': isDarkTheme }" @click="$emit('click')">
<div
class="search-thread-result"
:class="{ 'dark-theme': isDarkTheme }"
role="link"
tabindex="0"
@click="$emit('click')"
@keydown.enter="$emit('click')"
>
<div class="result-header">
<h4 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<span
v-if="thread.isPinned"
class="badge badge-pinned"
:title="strings.pinned"
:aria-label="strings.pinned"
role="img"
>
<PinIcon :size="16" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<span
v-if="thread.isLocked"
class="badge badge-locked"
:title="strings.locked"
:aria-label="strings.locked"
role="img"
>
<LockIcon :size="16" />
</span>
<span v-html="highlightedTitle"></span>
@@ -35,6 +54,7 @@
</span>
<a
v-if="thread.lastReply"
href="#"
class="meta-item last-reply"
@click.prevent.stop="$emit('navigate-last-reply', thread)"
>

View File

@@ -6,7 +6,13 @@
<div class="thread-main">
<div class="thread-header">
<div class="thread-title-row">
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<span
v-if="isUnread"
class="unread-indicator"
:title="strings.unread"
:aria-label="strings.unread"
role="img"
></span>
<h4 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="16" />

View File

@@ -0,0 +1,332 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createMockCategory } from '@/test-mocks'
import type { CategoryHeader } from '@/types'
// Mock the axios module before importing the composable
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
},
}))
import { useCategories } from '../useCategories'
import { ocs } from '@/axios'
describe('useCategories', () => {
beforeEach(() => {
const { clear } = useCategories()
clear()
vi.clearAllMocks()
})
describe('tree building', () => {
it('should build tree from flat categories', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child1 = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Child 1',
sortOrder: 0,
})
const child2 = createMockCategory({
id: 3,
headerId: null,
parentId: 1,
name: 'Child 2',
sortOrder: 1,
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child1, child2],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
// Top-level should only have parent
expect(categoryHeaders.value).toHaveLength(1)
expect(categoryHeaders.value[0]!.categories).toHaveLength(1)
expect(categoryHeaders.value[0]!.categories![0]!.name).toBe('Parent')
// Parent should have children
const parentCat = categoryHeaders.value[0]!.categories![0]!
expect(parentCat.children).toHaveLength(2)
expect(parentCat.children![0]!.name).toBe('Child 1')
expect(parentCat.children![1]!.name).toBe('Child 2')
})
it('should sort children by sortOrder', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child1 = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Second',
sortOrder: 2,
})
const child2 = createMockCategory({
id: 3,
headerId: null,
parentId: 1,
name: 'First',
sortOrder: 1,
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child1, child2],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
const parentCat = categoryHeaders.value[0]!.categories![0]!
expect(parentCat.children![0]!.name).toBe('First')
expect(parentCat.children![1]!.name).toBe('Second')
})
it('should handle categories with no children', async () => {
const cat = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Standalone' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [cat],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
expect(categoryHeaders.value[0]!.categories![0]!.children).toEqual([])
})
it('should build a 3-level deep tree (grandchildren)', async () => {
const grandparent = createMockCategory({
id: 1,
headerId: 1,
parentId: null,
name: 'Grandparent',
})
const parent = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Parent',
})
const child = createMockCategory({
id: 3,
headerId: null,
parentId: 2,
name: 'Child',
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [grandparent, parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, categoryHeaders } = useCategories()
await fetchCategories(true)
// Only grandparent at top level
expect(categoryHeaders.value[0]!.categories).toHaveLength(1)
const gp = categoryHeaders.value[0]!.categories![0]!
expect(gp.name).toBe('Grandparent')
// Parent nested under grandparent
expect(gp.children).toHaveLength(1)
expect(gp.children![0]!.name).toBe('Parent')
// Child nested under parent
expect(gp.children![0]!.children).toHaveLength(1)
expect(gp.children![0]!.children![0]!.name).toBe('Child')
})
})
describe('getAllCategoriesFlat', () => {
it('should return all categories including children', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child = createMockCategory({ id: 2, headerId: null, parentId: 1, name: 'Child' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, getAllCategoriesFlat } = useCategories()
await fetchCategories(true)
const flat = getAllCategoriesFlat()
expect(flat).toHaveLength(2)
expect(flat.map((c) => c.name)).toContain('Parent')
expect(flat.map((c) => c.name)).toContain('Child')
})
it('should include deeply nested categories', async () => {
const gp = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'GP' })
const p = createMockCategory({ id: 2, headerId: null, parentId: 1, name: 'P' })
const c = createMockCategory({ id: 3, headerId: null, parentId: 2, name: 'C' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [gp, p, c],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, getAllCategoriesFlat } = useCategories()
await fetchCategories(true)
const flat = getAllCategoriesFlat()
expect(flat).toHaveLength(3)
expect(flat.map((cat) => cat.name)).toEqual(['GP', 'P', 'C'])
})
})
describe('findCategoryInTree', () => {
it('should find a child category by ID', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child = createMockCategory({ id: 2, headerId: null, parentId: 1, name: 'Child' })
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, findCategoryInTree } = useCategories()
await fetchCategories(true)
const found = findCategoryInTree(2)
expect(found).not.toBeNull()
expect(found!.name).toBe('Child')
})
it('should return null for nonexistent ID', async () => {
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, findCategoryInTree } = useCategories()
await fetchCategories(true)
expect(findCategoryInTree(999)).toBeNull()
})
})
describe('markCategoryAsRead', () => {
it('should mark a child category as read', async () => {
const parent = createMockCategory({ id: 1, headerId: 1, parentId: null, name: 'Parent' })
const child = createMockCategory({
id: 2,
headerId: null,
parentId: 1,
name: 'Child',
readAt: null,
})
const mockResponse: CategoryHeader[] = [
{
id: 1,
name: 'Header',
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories: [parent, child],
},
]
vi.mocked(ocs.get).mockResolvedValueOnce({ data: mockResponse } as unknown as Promise<{
data: CategoryHeader[]
}>)
const { fetchCategories, markCategoryAsRead, findCategoryInTree } = useCategories()
await fetchCategories(true)
markCategoryAsRead(2)
const found = findCategoryInTree(2)
expect(found!.readAt).toBeDefined()
expect(found!.readAt).toBeGreaterThan(0)
})
})
})

View File

@@ -1,6 +1,6 @@
import { ref, type Ref } from 'vue'
import { ocs } from '@/axios'
import type { CategoryHeader } from '@/types'
import type { Category, CategoryHeader } from '@/types'
// Shared state - will persist across components
// The API returns an array of headers, each with a nested 'categories' array
@@ -9,6 +9,55 @@ const loading = ref<boolean>(false)
const error = ref<string | null>(null)
const loaded = ref<boolean>(false)
/**
* Build a category tree from flat category list.
* Moves child categories out of the top-level header.categories
* and nests them under their parent's children array.
*/
function buildCategoryTree(headers: CategoryHeader[]): CategoryHeader[] {
// Collect all categories into a flat map
const allCategories: Category[] = []
for (const header of headers) {
if (header.categories) {
for (const cat of header.categories) {
allCategories.push(cat)
}
}
}
const catMap = new Map<number, Category>()
for (const cat of allCategories) {
cat.children = []
catMap.set(cat.id, cat)
}
// Attach children to parents
const topLevelIds = new Set<number>()
for (const cat of allCategories) {
if (cat.parentId !== null && catMap.has(cat.parentId)) {
catMap.get(cat.parentId)!.children!.push(cat)
} else {
topLevelIds.add(cat.id)
}
}
// Sort children by sortOrder
for (const cat of allCategories) {
if (cat.children && cat.children.length > 1) {
cat.children.sort((a, b) => a.sortOrder - b.sortOrder)
}
}
// Rebuild header.categories to only contain top-level categories
for (const header of headers) {
if (header.categories) {
header.categories = header.categories.filter((cat) => topLevelIds.has(cat.id))
}
}
return headers
}
/**
* Composable for managing categories
* Provides shared state across components to avoid redundant API calls
@@ -33,7 +82,7 @@ export function useCategories() {
error.value = null
const response = await ocs.get<CategoryHeader[]>('/categories')
categoryHeaders.value = response.data || []
categoryHeaders.value = buildCategoryTree(response.data || [])
loaded.value = true
return categoryHeaders.value
@@ -61,15 +110,34 @@ export function useCategories() {
* Updates the readAt timestamp so the category appears read without refetching
*/
const markCategoryAsRead = (categoryId: number): void => {
const cat = findCategoryInTree(categoryId)
if (cat) {
cat.readAt = Math.floor(Date.now() / 1000)
}
}
/**
* Find a category by ID in the tree (searches recursively)
*/
const findCategoryInTree = (categoryId: number): Category | null => {
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
}
}
const found = findInChildren(header.categories, categoryId)
if (found) return found
}
return null
}
/**
* Get a flat list of all categories across all headers (includes children)
*/
const getAllCategoriesFlat = (): Category[] => {
const result: Category[] = []
for (const header of categoryHeaders.value) {
if (!header.categories) continue
collectFlat(header.categories, result)
}
return result
}
/**
@@ -93,5 +161,29 @@ export function useCategories() {
refresh,
clear,
markCategoryAsRead,
findCategoryInTree,
getAllCategoriesFlat,
}
}
/** Recursively search for a category by ID */
function findInChildren(categories: Category[], id: number): Category | null {
for (const cat of categories) {
if (cat.id === id) return cat
if (cat.children) {
const found = findInChildren(cat.children, id)
if (found) return found
}
}
return null
}
/** Recursively collect all categories into a flat array */
function collectFlat(categories: Category[], result: Category[]): void {
for (const cat of categories) {
result.push(cat)
if (cat.children) {
collectFlat(cat.children, result)
}
}
}

View File

@@ -78,6 +78,16 @@
}
}
&-audio {
display: block;
max-width: 100%;
audio {
width: 100%;
border-radius: 8px;
}
}
&-file {
display: flex;
align-items: center;

View File

@@ -121,10 +121,14 @@ export function createMockCategory(overrides: Partial<Category> = {}): Category
return {
id: 1,
headerId: 1,
parentId: null,
name: 'Test Category',
description: 'Test description',
slug: 'test-category',
sortOrder: 0,
color: null,
textColor: null,
hideChildrenOnCard: false,
threadCount: 10,
postCount: 50,
createdAt: Date.now(),

View File

@@ -5,19 +5,22 @@
export interface Category {
id: number
headerId: number
headerId: number | null
parentId: number | null
name: string
description: string | null
slug: string
sortOrder: number
color: string | null
textColor: 'light' | 'dark' | null
hideChildrenOnCard: boolean
threadCount: number
postCount: number
createdAt: number
updatedAt: number
lastActivityAt?: number | null
readAt?: number | null
children?: Category[]
}
export interface CategoryHeader {

View File

@@ -45,6 +45,8 @@
v-for="category in header.categories"
:key="category.id"
:category="category"
:children="category.children || []"
:hide-children="category.hideChildrenOnCard"
:is-unread="isCategoryUnread(category)"
@click="navigateToCategory(category)"
/>

View File

@@ -7,7 +7,7 @@
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
{{ backLabel }}
</NcButton>
</template>
@@ -43,6 +43,24 @@
class="mt-16"
/>
<!-- Subcategories section -->
<div
v-if="!loading && !error && childCategories.length > 0"
class="subcategories-section mt-16"
>
<h3 class="subcategories-title">{{ strings.subcategories }}</h3>
<div class="subcategories-grid">
<CategoryCard
v-for="child in childCategories"
:key="child.id"
:category="child"
:children="child.children || []"
:is-unread="isCategoryUnread(child)"
@click="navigateToChildCategory(child)"
/>
</div>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
@@ -141,6 +159,7 @@ import CategoryNotFound from '@/views/CategoryNotFound.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import MessagePlusIcon from '@icons/MessagePlus.vue'
import CategoryCard from '@/components/CategoryCard'
import type { Category, Thread } from '@/types'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
@@ -153,9 +172,14 @@ export default defineComponent({
name: 'CategoryView',
setup() {
const { userId } = useCurrentUser()
const { markCategoryAsRead } = useCategories()
const { markCategoryAsRead, findCategoryInTree } = useCategories()
const { checkCategoryPermission } = usePermissions()
return { userId, markCategoryAsReadLocal: markCategoryAsRead, checkCategoryPermission }
return {
userId,
markCategoryAsReadLocal: markCategoryAsRead,
findCategoryInTree,
checkCategoryPermission,
}
},
components: {
NcButton,
@@ -167,6 +191,7 @@ export default defineComponent({
ThreadCard,
Pagination,
CategoryNotFound,
CategoryCard,
ArrowLeftIcon,
RefreshIcon,
MessagePlusIcon,
@@ -193,6 +218,7 @@ export default defineComponent({
emptyTitle: t('forum', 'No threads yet'),
emptyDesc: t('forum', 'Be the first to start a discussion in this category.'),
retry: t('forum', 'Retry'),
subcategories: t('forum', 'Subcategories'),
},
}
},
@@ -203,9 +229,24 @@ export default defineComponent({
categorySlug(): string | null {
return (this.$route.params.slug as string) || null
},
backLabel(): string {
if (this.category?.parentId) {
const parent = this.findCategoryInTree(this.category.parentId)
if (parent) {
return t('forum', 'Back to {name}', { name: parent.name })
}
}
return t('forum', 'Back to categories')
},
sortedThreads(): Thread[] {
return this.threads
},
childCategories(): Category[] {
if (!this.category) return []
// Look up this category in the tree to get its children
const catInTree = this.findCategoryInTree(this.category.id)
return catInTree?.children || []
},
},
created() {
this.refresh()
@@ -389,8 +430,27 @@ export default defineComponent({
}
},
navigateToChildCategory(child: Category): void {
this.$router.push(`/c/${child.slug}`)
},
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
},
goBack(): void {
// Always navigate to home, not browser history
// Navigate to parent category if this is a child, otherwise home
if (this.category?.parentId) {
const parent = this.findCategoryInTree(this.category.parentId)
if (parent) {
this.$router.push(`/c/${parent.slug}`)
return
}
}
this.$router.push('/')
},
},
@@ -399,6 +459,21 @@ export default defineComponent({
<style scoped lang="scss">
.category-view {
.subcategories-section {
.subcategories-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-main-text);
}
.subcategories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
}
.threads-list {
display: flex;
flex-direction: column;

View File

@@ -11,6 +11,7 @@
v-model="searchQuery"
type="text"
:placeholder="strings.searchPlaceholder"
:aria-label="strings.searchTitle"
class="search-input"
@keydown.enter="performSearch"
/>
@@ -23,7 +24,7 @@
</div>
<!-- Search Options -->
<div class="search-options">
<fieldset class="search-options">
<NcCheckboxRadioSwitch v-model="searchThreads" @update:checked="onOptionsChange">
{{ strings.searchThreads }}
</NcCheckboxRadioSwitch>
@@ -37,7 +38,7 @@
</template>
{{ strings.syntaxHelp }}
</NcButton>
</div>
</fieldset>
<!-- Syntax Help -->
<div v-if="showSyntaxHelp" class="syntax-help">
@@ -53,7 +54,7 @@
</div>
<!-- Loading State -->
<div v-if="loading" class="center mt-16">
<div v-if="loading" class="center mt-16" aria-live="polite">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.searching }}</span>
</div>
@@ -95,7 +96,7 @@
</NcEmptyContent>
<!-- Results -->
<div v-else class="search-results mt-16">
<div v-else class="search-results mt-16" aria-live="polite">
<!-- Thread Results Section -->
<section v-if="searchThreads && threadResults.length > 0" class="results-section">
<h3 class="results-header">
@@ -328,6 +329,9 @@ export default defineComponent({
align-items: center;
gap: 16px;
flex-wrap: wrap;
border: none;
padding: 0;
margin: 0;
}
.syntax-help {

View File

@@ -209,6 +209,7 @@
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
@reassigned="handleReassigned"
/>
</section>
@@ -246,6 +247,7 @@
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
@reassigned="handleReassigned"
/>
</div>
@@ -874,6 +876,60 @@ export default defineComponent({
}
},
async handleReassigned(data: {
guestAuthorId: string
targetUserId: string
targetDisplayName: string
}): Promise<void> {
try {
// Fetch the target user's roles from the forum user endpoint
let roles: any[] = []
try {
const response = await ocs.get(`/users/${data.targetUserId}`)
roles = response.data?.roles || []
} catch {
// User may not have a forum profile yet - that is fine
}
const newAuthor = {
userId: data.targetUserId,
displayName: data.targetDisplayName,
isDeleted: false,
isGuest: false,
roles,
signature: null,
signatureRaw: null,
}
// Update first post if it belonged to this guest
if (this.firstPost && this.firstPost.authorId === data.guestAuthorId) {
this.firstPost = { ...this.firstPost, authorId: data.targetUserId, author: newAuthor }
}
// Update all replies that belonged to this guest
this.replies = this.replies.map((reply) => {
if (reply.authorId === data.guestAuthorId) {
return { ...reply, authorId: data.targetUserId, author: newAuthor }
}
return reply
})
// Update thread header if the thread author was this guest
if (this.thread && this.thread.authorId === data.guestAuthorId) {
this.thread.authorId = data.targetUserId
this.thread.author = newAuthor
}
// Update lastReplyAuthorId if it was this guest
if (this.thread && this.thread.lastReplyAuthorId === data.guestAuthorId) {
this.thread.lastReplyAuthorId = data.targetUserId
}
} catch (e) {
console.error('Failed to update posts after reassignment', e)
// Posts were reassigned on the backend; a refresh will show the correct state
}
},
replyToThread(): void {
// Redirect guests to login (only if they cannot reply)
if (this.userId === null && !this.canReply) {

View File

@@ -0,0 +1,391 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory, createMockRole } from '@/test-mocks'
import type { Category, CategoryHeader, Role } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}))
// Reactive state for categories
const mockCategoryHeaders = ref<CategoryHeader[]>([])
const mockFetchCategories = vi.fn().mockResolvedValue([])
const mockRefresh = vi.fn().mockResolvedValue([])
const mockGetAllFlat = vi.fn().mockReturnValue([])
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
categoryHeaders: mockCategoryHeaders,
fetchCategories: mockFetchCategories,
refresh: mockRefresh,
getAllCategoriesFlat: mockGetAllFlat,
}),
}))
// Mock NcCheckboxRadioSwitch (imports .css that Vitest can't handle)
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template: '<label class="nc-checkbox"><input type="checkbox" /><slot /></label>',
props: ['modelValue', 'disabled', 'value', 'type', 'name'],
emits: ['update:model-value'],
},
}))
// Mock icons
vi.mock('@icons/ArrowLeft.vue', () => createIconMock('ArrowLeftIcon'))
// Mock components
vi.mock('@/components/PageWrapper', () =>
createComponentMock('PageWrapper', {
template: '<div class="page-wrapper-mock"><slot name="toolbar" /><slot /></div>',
}),
)
vi.mock('@/components/AppToolbar', () =>
createComponentMock('AppToolbar', {
template: '<div class="app-toolbar-mock"><slot name="left" /></div>',
}),
)
vi.mock('@/components/PageHeader', () =>
createComponentMock('PageHeader', {
template: '<div class="page-header-mock" />',
props: ['title', 'subtitle'],
}),
)
vi.mock('@/components/FormSection', () =>
createComponentMock('FormSection', {
template: '<div class="form-section-mock"><slot /></div>',
props: ['title', 'subtitle'],
}),
)
vi.mock('@/components/CategoryCard', () =>
createComponentMock('CategoryCard', {
template: '<div class="category-card-mock" />',
props: ['category'],
}),
)
vi.mock('@/components/ColorPickerPreset', () =>
createComponentMock('ColorPickerPreset', {
template: '<div class="color-picker-mock" />',
props: ['modelValue', 'presets', 'label'],
emits: ['update:modelValue'],
}),
)
import AdminCategoryEdit from '../admin/AdminCategoryEdit.vue'
import { ocs } from '@/axios'
const mockOcsGet = vi.mocked(ocs.get)
const mockOcsPost = vi.mocked(ocs.post)
const mockOcsPut = vi.mocked(ocs.put)
function createHeader(id: number, name: string, categories: Category[] = []): CategoryHeader {
return { id, name, description: null, sortOrder: 0, createdAt: Date.now(), categories }
}
describe('AdminCategoryEdit', () => {
const mockRouter = { push: vi.fn() }
const defaultRoles: Role[] = [
createMockRole({ id: 1, name: 'Admin', roleType: 'admin', isSystemRole: true }),
createMockRole({ id: 2, name: 'Moderator', roleType: 'moderator', isSystemRole: true }),
createMockRole({ id: 3, name: 'Member', roleType: 'default', isSystemRole: true }),
createMockRole({ id: 4, name: 'Guest', roleType: 'guest', isSystemRole: true }),
]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockResponse = (data: unknown): Promise<any> => Promise.resolve({ data })
beforeEach(() => {
vi.clearAllMocks()
mockCategoryHeaders.value = []
mockFetchCategories.mockResolvedValue([])
mockGetAllFlat.mockReturnValue([])
})
const createWrapper = (routeParams: Record<string, string> = {}) =>
mount(AdminCategoryEdit, {
global: {
mocks: {
$router: mockRouter,
$route: { params: routeParams, path: '/admin/categories/create' },
},
},
})
const setupCreateMocks = () => {
mockOcsGet.mockImplementation((url: string) => {
if (url === '/roles') return mockResponse(defaultRoles)
if (url === '/teams') return mockResponse([])
return mockResponse(null)
})
}
const setupEditMocks = (category: Category) => {
mockOcsGet.mockImplementation((url: string) => {
if (url === '/roles') return mockResponse(defaultRoles)
if (url === '/teams') return mockResponse([])
if (url === `/categories/${category.id}`) return mockResponse(category)
if (url === `/categories/${category.id}/permissions`) return mockResponse([])
return mockResponse(null)
})
}
describe('parent dropdown', () => {
it('should include headers as parent options', async () => {
mockCategoryHeaders.value = [createHeader(1, 'General'), createHeader(2, 'Support')]
setupCreateMocks()
const wrapper = createWrapper()
await flushPromises()
type VM = { parentOptions: Array<{ id: string; label: string; type: string }> }
const vm = wrapper.vm as unknown as VM
const headerOptions = vm.parentOptions.filter((o) => o.type === 'header')
expect(headerOptions).toHaveLength(2)
expect(headerOptions[0]!.label).toBe('General')
expect(headerOptions[1]!.label).toBe('Support')
})
it('should include categories nested under headers', async () => {
const cat = createMockCategory({ id: 10, name: 'Announcements', slug: 'ann' })
mockCategoryHeaders.value = [createHeader(1, 'General', [cat])]
setupCreateMocks()
const wrapper = createWrapper()
await flushPromises()
type VM = { parentOptions: Array<{ id: string; label: string; type: string }> }
const vm = wrapper.vm as unknown as VM
const catOptions = vm.parentOptions.filter((o) => o.type === 'category')
expect(catOptions).toHaveLength(1)
expect(catOptions[0]!.id).toBe('category:10')
})
it('should exclude the current category and its descendants when editing', async () => {
const grandchild = createMockCategory({
id: 3,
name: 'GC',
parentId: 2,
slug: 'gc',
children: [],
})
const child = createMockCategory({
id: 2,
name: 'Child',
parentId: 1,
slug: 'child',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
slug: 'parent',
children: [child],
})
const sibling = createMockCategory({
id: 4,
name: 'Sibling',
slug: 'sibling',
children: [],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent, sibling])]
// getAllCategoriesFlat returns the flat list for descendant collection
mockGetAllFlat.mockReturnValue([parent, child, grandchild, sibling])
const editCategory = createMockCategory({
id: 1,
headerId: 1,
name: 'Parent',
slug: 'parent',
})
setupEditMocks(editCategory)
const wrapper = createWrapper({ id: '1' })
await flushPromises()
type VM = { parentOptions: Array<{ id: string; label: string; type: string }> }
const vm = wrapper.vm as unknown as VM
const catOptions = vm.parentOptions.filter((o) => o.type === 'category')
const catIds = catOptions.map((o) => o.id)
// Should exclude category 1 (self), 2 (child), 3 (grandchild)
expect(catIds).not.toContain('category:1')
expect(catIds).not.toContain('category:2')
expect(catIds).not.toContain('category:3')
// Should include sibling
expect(catIds).toContain('category:4')
})
})
describe('form submission', () => {
it('should send parentId when a category parent is selected', async () => {
const cat = createMockCategory({ id: 10, name: 'Parent Cat', slug: 'parent-cat' })
mockCategoryHeaders.value = [createHeader(1, 'H', [cat])]
setupCreateMocks()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { id: 99 } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedParent: { id: string; label: string; type: string } | null
formData: {
name: string
slug: string
parentId: number | null
headerId: number | null
}
submitForm: () => Promise<void>
}
vm.selectedParent = { id: 'category:10', label: 'Parent Cat', type: 'category' }
vm.formData.parentId = 10
vm.formData.headerId = null
vm.formData.name = 'New Child'
vm.formData.slug = 'new-child'
await wrapper.vm.$nextTick()
await vm.submitForm()
await flushPromises()
expect(mockOcsPost).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({
parentId: 10,
headerId: null,
name: 'New Child',
slug: 'new-child',
}),
)
})
it('should send headerId when a header parent is selected', async () => {
mockCategoryHeaders.value = [createHeader(1, 'General')]
setupCreateMocks()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { id: 99 } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedParent: { id: string; label: string; type: string } | null
formData: {
name: string
slug: string
parentId: number | null
headerId: number | null
}
submitForm: () => Promise<void>
}
vm.selectedParent = { id: 'header:1', label: 'General', type: 'header' }
vm.formData.headerId = 1
vm.formData.parentId = null
vm.formData.name = 'New Category'
vm.formData.slug = 'new-category'
await wrapper.vm.$nextTick()
await vm.submitForm()
await flushPromises()
expect(mockOcsPost).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({
headerId: 1,
parentId: null,
name: 'New Category',
}),
)
})
it('should send hideChildrenOnCard in the payload', async () => {
mockCategoryHeaders.value = [createHeader(1, 'H')]
setupCreateMocks()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { id: 99 } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
selectedParent: { id: string; label: string; type: string } | null
formData: {
name: string
slug: string
headerId: number | null
parentId: number | null
hideChildrenOnCard: boolean
}
submitForm: () => Promise<void>
}
vm.selectedParent = { id: 'header:1', label: 'H', type: 'header' }
vm.formData.headerId = 1
vm.formData.parentId = null
vm.formData.name = 'Test'
vm.formData.slug = 'test'
vm.formData.hideChildrenOnCard = true
await wrapper.vm.$nextTick()
await vm.submitForm()
await flushPromises()
expect(mockOcsPost).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ hideChildrenOnCard: true }),
)
})
it('should use PUT when editing an existing category', async () => {
const existingCat = createMockCategory({
id: 5,
headerId: 1,
name: 'Existing',
slug: 'existing',
})
mockCategoryHeaders.value = [createHeader(1, 'H', [existingCat])]
setupEditMocks(existingCat)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPut.mockResolvedValue({ data: existingCat } as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { success: true } } as any)
const wrapper = createWrapper({ id: '5' })
await flushPromises()
const vm = wrapper.vm as unknown as { submitForm: () => Promise<void> }
await vm.submitForm()
await flushPromises()
expect(mockOcsPut).toHaveBeenCalledWith('/categories/5', expect.any(Object))
})
})
describe('navigation', () => {
it('should navigate back to category list on cancel', async () => {
mockCategoryHeaders.value = []
setupCreateMocks()
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as { goBack: () => void }
vm.goBack()
expect(mockRouter.push).toHaveBeenCalledWith('/admin/categories')
})
})
})

View File

@@ -0,0 +1,332 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockCategory } from '@/test-mocks'
import type { Category, CategoryHeader } from '@/types'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
// Reactive state for categoryHeaders so tests can control it
const mockCategoryHeaders = ref<CategoryHeader[]>([])
const mockRefresh = vi.fn().mockResolvedValue([])
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
categoryHeaders: mockCategoryHeaders,
loading: computed(() => false),
error: computed(() => null),
refresh: mockRefresh,
}),
}))
// Mock NcCheckboxRadioSwitch (imports .css that Vitest can't handle)
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template: '<label class="nc-checkbox"><input type="checkbox" /><slot /></label>',
props: ['modelValue', 'disabled', 'value', 'type', 'name'],
emits: ['update:model-value'],
},
}))
// Mock icons
vi.mock('@icons/Plus.vue', () => createIconMock('PlusIcon'))
vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@icons/ChevronUp.vue', () => createIconMock('ChevronUpIcon'))
vi.mock('@icons/ChevronDown.vue', () => createIconMock('ChevronDownIcon'))
vi.mock('@icons/Information.vue', () => createIconMock('InformationIcon'))
// Mock components
vi.mock('@/components/PageWrapper', () =>
createComponentMock('PageWrapper', {
template: '<div class="page-wrapper-mock"><slot name="toolbar" /><slot /></div>',
}),
)
vi.mock('@/components/PageHeader', () =>
createComponentMock('PageHeader', {
template: '<div class="page-header-mock" />',
props: ['title', 'subtitle'],
}),
)
vi.mock('@/components/AppToolbar', () =>
createComponentMock('AppToolbar', {
template: '<div class="app-toolbar-mock"><slot name="right" /></div>',
}),
)
vi.mock('@/components/HeaderEditDialog', () =>
createComponentMock('HeaderEditDialog', {
template: '<div class="header-edit-dialog-mock" />',
props: ['open', 'headerId', 'name', 'description', 'sortOrder'],
emits: ['update:open', 'saved'],
}),
)
import AdminCategoryList from '../admin/AdminCategoryList.vue'
import { ocs } from '@/axios'
const mockOcsPost = vi.mocked(ocs.post)
function createHeader(id: number, name: string, categories: Category[] = []): CategoryHeader {
return {
id,
name,
description: null,
sortOrder: 0,
createdAt: Date.now(),
categories,
}
}
describe('AdminCategoryList', () => {
const mockRouter = { push: vi.fn() }
beforeEach(() => {
vi.clearAllMocks()
mockCategoryHeaders.value = []
})
const createWrapper = () =>
mount(AdminCategoryList, {
global: { mocks: { $router: mockRouter, $route: { path: '/admin/categories' } } },
})
describe('rendering categories', () => {
it('should render top-level categories', async () => {
const cat = createMockCategory({ id: 1, name: 'General', slug: 'general' })
mockCategoryHeaders.value = [createHeader(1, 'Main', [cat])]
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('General')
expect(wrapper.text()).toContain('general')
})
it('should render subcategories with indentation', async () => {
const child = createMockCategory({
id: 2,
name: 'Sub Category',
parentId: 1,
slug: 'sub',
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
slug: 'parent',
children: [child],
})
mockCategoryHeaders.value = [createHeader(1, 'Main', [parent])]
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Parent')
expect(wrapper.text()).toContain('Sub Category')
// Subcategory row should have deeper indentation
const rows = wrapper.findAll('.category-row')
expect(rows.length).toBeGreaterThanOrEqual(2)
const subRow = rows.find((r) => r.text().includes('Sub Category'))
expect(subRow).toBeDefined()
expect(subRow!.classes()).toContain('subcategory-row')
})
it('should render grandchildren (3 levels)', async () => {
const grandchild = createMockCategory({
id: 3,
name: 'Grandchild',
parentId: 2,
slug: 'grandchild',
children: [],
})
const child = createMockCategory({
id: 2,
name: 'Child',
parentId: 1,
slug: 'child',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'Parent',
slug: 'parent',
children: [child],
})
mockCategoryHeaders.value = [createHeader(1, 'Main', [parent])]
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Parent')
expect(wrapper.text()).toContain('Child')
expect(wrapper.text()).toContain('Grandchild')
})
})
describe('flattenCategoriesWithContext', () => {
it('should flatten tree with correct depth info', async () => {
const grandchild = createMockCategory({
id: 3,
name: 'GC',
parentId: 2,
slug: 'gc',
children: [],
})
const child = createMockCategory({
id: 2,
name: 'C',
parentId: 1,
slug: 'c',
children: [grandchild],
})
const parent = createMockCategory({
id: 1,
name: 'P',
slug: 'p',
children: [child],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
flattenCategoriesWithContext: (
cats: Category[],
headerId: number,
) => Array<{ category: Category; depth: number; index: number; siblings: Category[] }>
}
const rows = vm.flattenCategoriesWithContext([parent], 1)
expect(rows).toHaveLength(3)
expect(rows[0]!.category.name).toBe('P')
expect(rows[0]!.depth).toBe(0)
expect(rows[1]!.category.name).toBe('C')
expect(rows[1]!.depth).toBe(1)
expect(rows[2]!.category.name).toBe('GC')
expect(rows[2]!.depth).toBe(2)
})
it('should provide correct sibling references', async () => {
const child1 = createMockCategory({
id: 2,
name: 'C1',
parentId: 1,
slug: 'c1',
children: [],
})
const child2 = createMockCategory({
id: 3,
name: 'C2',
parentId: 1,
slug: 'c2',
children: [],
})
const parent = createMockCategory({
id: 1,
name: 'P',
slug: 'p',
children: [child1, child2],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
flattenCategoriesWithContext: (
cats: Category[],
headerId: number,
) => Array<{ category: Category; depth: number; index: number; siblings: Category[] }>
}
const rows = vm.flattenCategoriesWithContext([parent], 1)
// Parent row: siblings is the top-level array
expect(rows[0]!.siblings).toHaveLength(1)
// Child rows: siblings is parent.children
expect(rows[1]!.siblings).toHaveLength(2)
expect(rows[1]!.index).toBe(0)
expect(rows[2]!.siblings).toHaveLength(2)
expect(rows[2]!.index).toBe(1)
})
})
describe('reorder', () => {
it('should call reorder API when sorting siblings', async () => {
const child1 = createMockCategory({
id: 2,
name: 'C1',
parentId: 1,
slug: 'c1',
children: [],
})
const child2 = createMockCategory({
id: 3,
name: 'C2',
parentId: 1,
slug: 'c2',
children: [],
})
const parent = createMockCategory({
id: 1,
name: 'P',
slug: 'p',
children: [child1, child2],
})
mockCategoryHeaders.value = [createHeader(1, 'H', [parent])]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockOcsPost.mockResolvedValue({ data: { success: true } } as any)
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as {
reorderSiblings: (siblings: Category[], index: number, amount: number) => Promise<void>
}
await vm.reorderSiblings(parent.children!, 0, 1)
expect(mockOcsPost).toHaveBeenCalledWith('/categories/reorder', {
categories: expect.arrayContaining([
expect.objectContaining({ id: 3, sortOrder: 0 }),
expect.objectContaining({ id: 2, sortOrder: 1 }),
]),
})
})
})
describe('navigation', () => {
it('should navigate to edit page when clicking edit', async () => {
const cat = createMockCategory({ id: 5, name: 'Test', slug: 'test' })
mockCategoryHeaders.value = [createHeader(1, 'H', [cat])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as { editCategory: (id: number) => void }
vm.editCategory(5)
expect(mockRouter.push).toHaveBeenCalledWith('/admin/categories/5/edit')
})
it('should navigate to create page', async () => {
mockCategoryHeaders.value = [createHeader(1, 'H', [])]
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as unknown as { createCategory: () => void }
vm.createCategory()
expect(mockRouter.push).toHaveBeenCalledWith('/admin/categories/create')
})
})
})

View File

@@ -24,6 +24,7 @@ vi.mock('@/composables/useCurrentUser', () => ({
vi.mock('@/composables/useCategories', () => ({
useCategories: () => ({
markCategoryAsRead: vi.fn(),
findCategoryInTree: vi.fn().mockReturnValue(null),
}),
}))
@@ -82,6 +83,14 @@ vi.mock('@/views/CategoryNotFound.vue', () =>
}),
)
vi.mock('@/components/CategoryCard', () =>
createComponentMock('CategoryCard', {
template: '<div class="category-card-mock">{{ category.name }}</div>',
props: ['category', 'children', 'hideChildren', 'isUnread'],
emits: ['click'],
}),
)
import CategoryView from '../CategoryView.vue'
import { ocs } from '@/axios'

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockThread, createMockPost } from '@/test-mocks'
import { createMockThread, createMockPost, createMockUser } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
@@ -82,7 +82,7 @@ vi.mock('@/components/PostCard', () =>
template:
'<div class="post-card-mock" :data-can-reply="canReply" :data-can-moderate="canModerateCategory" />',
props: ['post', 'isFirstPost', 'isUnread', 'canModerateCategory', 'canReply'],
emits: ['reply', 'update', 'delete'],
emits: ['reply', 'update', 'delete', 'reassigned'],
}),
)
@@ -342,4 +342,243 @@ describe('ThreadView', () => {
expect(wrapper.text()).toContain('You do not have permission to reply in this category.')
})
})
describe('guest reassignment', () => {
const guestAuthorId = 'guest:abcdef1234567890abcdef1234567890'
const guestAuthor = createMockUser({
userId: guestAuthorId,
displayName: 'BrightMountain42',
isGuest: true,
})
it('updates first post author in-place after reassignment', async () => {
const guestFirstPost = createMockPost({
id: 1,
authorId: guestAuthorId,
author: guestAuthor,
content: '<p>Guest post</p>',
})
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: guestFirstPost,
replies: [],
pagination: {
page: 1,
perPage: 20,
total: 0,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return mockGetResponse({ data: { userId: 'alice', roles: [{ id: 1, name: 'Default' }] } })
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
expect(vm.firstPost!.authorId).toBe('alice')
expect(vm.firstPost!.author!.displayName).toBe('Alice Smith')
expect(vm.firstPost!.author!.isGuest).toBe(false)
})
it('updates replies author in-place after reassignment', async () => {
const guestReply = createMockPost({ id: 10, authorId: guestAuthorId, author: guestAuthor })
const otherReply = createMockPost({
id: 11,
authorId: 'bob',
author: createMockUser({ userId: 'bob' }),
})
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [guestReply, otherReply],
pagination: {
page: 1,
perPage: 20,
total: 2,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return mockGetResponse({ data: { userId: 'alice', roles: [] } })
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
// Guest reply should be updated
expect(vm.replies[0]!.authorId).toBe('alice')
expect(vm.replies[0]!.author!.displayName).toBe('Alice Smith')
expect(vm.replies[0]!.author!.isGuest).toBe(false)
// Other reply should be unchanged
expect(vm.replies[1]!.authorId).toBe('bob')
})
it('updates thread header author after reassignment', async () => {
mockThread.value = createMockThread({
id: 1,
categoryId: 5,
authorId: guestAuthorId,
author: guestAuthor,
})
mockFetchThread.mockResolvedValue(mockThread.value)
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [],
pagination: {
page: 1,
perPage: 20,
total: 0,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return mockGetResponse({ data: { userId: 'alice', roles: [] } })
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
expect(vm.thread!.authorId).toBe('alice')
expect(vm.thread!.author!.displayName).toBe('Alice Smith')
})
it('updates thread lastReplyAuthorId after reassignment', async () => {
mockThread.value = createMockThread({
id: 1,
categoryId: 5,
lastReplyAuthorId: guestAuthorId,
})
mockFetchThread.mockResolvedValue(mockThread.value)
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [],
pagination: {
page: 1,
perPage: 20,
total: 0,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
expect(vm.thread!.lastReplyAuthorId).toBe('alice')
})
it('handles user fetch failure gracefully', async () => {
const guestReply = createMockPost({ id: 10, authorId: guestAuthorId, author: guestAuthor })
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [guestReply],
pagination: {
page: 1,
perPage: 20,
total: 1,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return Promise.reject(new Error('Not found'))
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
// Should still update with empty roles
expect(vm.replies[0]!.authorId).toBe('alice')
expect(vm.replies[0]!.author!.displayName).toBe('Alice Smith')
expect(vm.replies[0]!.author!.roles).toEqual([])
})
})
})

View File

@@ -42,28 +42,16 @@
<FormSection :title="strings.basicInfo">
<div class="form-grid">
<div class="form-group">
<label>{{ strings.categoryHeader }} *</label>
<label>{{ strings.parent }} *</label>
<div class="header-select-row">
<NcSelect
v-model="selectedHeader"
:options="headerOptions"
:placeholder="strings.selectHeader"
v-model="selectedParent"
:options="parentOptions"
:placeholder="strings.selectParent"
label="label"
track-by="id"
class="header-select"
/>
<NcButton @click="createNewHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newHeader }}
</NcButton>
<NcButton v-if="selectedHeader" @click="editHeader">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editHeader }}
</NcButton>
</div>
</div>
@@ -140,6 +128,12 @@
</NcCheckboxRadioSwitch>
</div>
</div>
<div class="hide-children-group">
<NcCheckboxRadioSwitch v-model="formData.hideChildrenOnCard">
{{ strings.hideChildrenOnCard }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.hideChildrenOnCardHelp }}</p>
</div>
</div>
<div class="design-preview">
<label>{{ strings.preview }}</label>
@@ -224,17 +218,6 @@
</NcButton>
</div>
</div>
<!-- Header Edit/Create Dialog -->
<HeaderEditDialog
:open="headerDialog.show"
:header-id="headerDialog.id"
:name="headerDialog.name"
:description="headerDialog.description"
:sort-order="headerDialog.sortOrder"
@update:open="headerDialog.show = $event"
@saved="handleHeaderSaved"
/>
</div>
</PageWrapper>
</template>
@@ -246,32 +229,29 @@ import AppToolbar from '@/components/AppToolbar'
import FormSection from '@/components/FormSection'
import CategoryCard from '@/components/CategoryCard'
import ColorPickerPreset from '@/components/ColorPickerPreset'
import HeaderEditDialog from '@/components/HeaderEditDialog'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import PlusIcon from '@icons/Plus.vue'
import PencilIcon from '@icons/Pencil.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { isAdminRole, isModeratorRole, isDefaultRole, isGuestRole } from '@/constants'
import { useCategories } from '@/composables/useCategories'
import type { Category, CategoryPerm, CatHeader, Role, Team } from '@/types'
import PageHeader from '@/components/PageHeader'
type PermTarget = { id: string; label: string; type: 'role' | 'team' }
type ParentOption = { id: string; label: string; type: 'header' | 'category' }
export default defineComponent({
name: 'AdminCategoryEdit',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcDialog,
NcEmptyContent,
NcLoadingIcon,
NcSelect,
@@ -282,17 +262,21 @@ export default defineComponent({
FormSection,
CategoryCard,
ColorPickerPreset,
HeaderEditDialog,
ArrowLeftIcon,
PlusIcon,
PencilIcon,
PageHeader,
},
setup() {
const { categoryHeaders, fetchCategories, refresh: refreshCategories } = useCategories()
const {
categoryHeaders,
fetchCategories,
refresh: refreshCategories,
getAllCategoriesFlat,
} = useCategories()
return {
categoryHeaders,
fetchCategories,
refreshCategories,
getAllCategoriesFlat,
}
},
data() {
@@ -302,7 +286,7 @@ export default defineComponent({
error: null as string | null,
headers: [] as CatHeader[],
roles: [] as Role[],
selectedHeader: null as { id: number; label: string } | null,
selectedParent: null as ParentOption | null,
teams: [] as Team[],
selectedViewTargets: [] as PermTarget[],
selectedPostTargets: [] as PermTarget[],
@@ -310,21 +294,16 @@ export default defineComponent({
selectedModerateTargets: [] as PermTarget[],
formData: {
headerId: null as number | null,
parentId: null as number | null,
name: '',
slug: '',
description: '',
sortOrder: 0,
color: null as string | null,
textColor: 'dark' as 'light' | 'dark',
hideChildrenOnCard: false,
},
slugManuallyEdited: false,
headerDialog: {
show: false,
id: null as number | null,
name: '',
description: '',
sortOrder: 0,
},
strings: {
back: t('forum', 'Back'),
@@ -335,8 +314,8 @@ export default defineComponent({
errorTitle: t('forum', 'Error loading category'),
retry: t('forum', 'Retry'),
basicInfo: t('forum', 'Basic information'),
categoryHeader: t('forum', 'Category header'),
selectHeader: t('forum', '-- Select a header --'),
parent: t('forum', 'Parent'),
selectParent: t('forum', '-- Select a parent --'),
name: t('forum', 'Name'),
namePlaceholder: t('forum', 'Enter category name'),
slug: t('forum', 'Slug'),
@@ -353,8 +332,6 @@ export default defineComponent({
cancel: t('forum', 'Cancel'),
create: t('forum', 'Create'),
update: t('forum', 'Update'),
newHeader: t('forum', 'New'),
editHeader: t('forum', 'Edit'),
permissions: t('forum', 'Permissions'),
permissionsDescription: t(
'forum',
@@ -388,6 +365,11 @@ export default defineComponent({
darkText: t('forum', 'Dark text'),
lightText: t('forum', 'Light text'),
preview: t('forum', 'Preview'),
hideChildrenOnCard: t('forum', 'Hide subcategories on category card'),
hideChildrenOnCardHelp: t(
'forum',
"When enabled, child categories will not appear as links on this category's card on the home page",
),
},
}
},
@@ -400,16 +382,33 @@ export default defineComponent({
},
canSubmit(): boolean {
return (
this.selectedHeader !== null &&
this.selectedParent !== null &&
this.formData.name.trim().length > 0 &&
this.formData.slug.trim().length > 0
)
},
headerOptions(): Array<{ id: number; label: string }> {
return this.headers.map((header) => ({
id: header.id,
label: header.name,
}))
parentOptions(): ParentOption[] {
const options: ParentOption[] = []
// Get the set of descendant IDs to exclude (prevent circular refs)
const excludeIds = new Set<number>()
if (this.categoryId !== null) {
excludeIds.add(this.categoryId)
this.collectDescendantIds(this.categoryId, excludeIds)
}
for (const header of this.categoryHeaders) {
// Add header as a parent option
options.push({
id: `header:${header.id}`,
label: header.name,
type: 'header',
})
// Add categories nested under this header (with indentation)
if (header.categories) {
this.addCategoryOptions(header.categories, options, excludeIds, 1)
}
}
return options
},
teamOptions(): PermTarget[] {
return this.teams.map((team) => ({
@@ -479,12 +478,14 @@ export default defineComponent({
return {
id: 0,
headerId: 0,
parentId: null,
name: this.formData.name || this.strings.namePlaceholder,
description: this.formData.description || this.strings.descriptionPlaceholder,
slug: '',
sortOrder: 0,
color: this.formData.color,
textColor: this.formData.color ? this.formData.textColor : null,
hideChildrenOnCard: false,
threadCount: 0,
postCount: 0,
createdAt: 0,
@@ -493,14 +494,33 @@ export default defineComponent({
},
},
watch: {
selectedHeader(newVal: { id: number; label: string } | null) {
this.formData.headerId = newVal?.id || null
selectedParent(newVal: ParentOption | null) {
if (!newVal) {
this.formData.headerId = null
this.formData.parentId = null
return
}
if (newVal.type === 'header') {
this.formData.headerId = parseInt(newVal.id.split(':')[1])
this.formData.parentId = null
} else {
this.formData.parentId = parseInt(newVal.id.split(':')[1])
this.formData.headerId = null
}
// When creating a new category, auto-set sort order based on category count in the header
// When creating a new category, auto-set sort order based on sibling count
if (!this.isEditing && newVal) {
const header = this.categoryHeaders.find((h) => h.id === newVal.id)
const categoryCount = header?.categories?.length || 0
this.formData.sortOrder = categoryCount
if (newVal.type === 'header') {
const header = this.categoryHeaders.find(
(h) => h.id === parseInt(newVal.id.split(':')[1]),
)
this.formData.sortOrder = header?.categories?.length || 0
} else {
const allCats = this.getAllCategoriesFlat()
const parentId = parseInt(newVal.id.split(':')[1])
const siblings = allCats.filter((c) => c.parentId === parentId)
this.formData.sortOrder = siblings.length
}
}
},
'formData.name'(newVal: string) {
@@ -523,6 +543,38 @@ export default defineComponent({
this.refresh()
},
methods: {
/** Recursively add category options with indentation */
addCategoryOptions(
categories: Category[],
options: ParentOption[],
excludeIds: Set<number>,
depth: number,
): void {
for (const cat of categories) {
if (!excludeIds.has(cat.id)) {
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth)
options.push({
id: `category:${cat.id}`,
label: `${indent}${cat.name}`,
type: 'category',
})
}
if (cat.children && cat.children.length > 0) {
this.addCategoryOptions(cat.children, options, excludeIds, depth + 1)
}
}
},
/** Collect all descendant IDs of a category */
collectDescendantIds(categoryId: number, result: Set<number>): void {
const allCats = this.getAllCategoriesFlat()
const children = allCats.filter((c) => c.parentId === categoryId)
for (const child of children) {
result.add(child.id)
this.collectDescendantIds(child.id, result)
}
},
toKebabCase(str: string): string {
return str
.trim()
@@ -602,22 +654,40 @@ export default defineComponent({
const category = categoryResponse.data
this.formData.headerId = category.headerId
this.formData.parentId = category.parentId
this.formData.name = category.name
this.formData.slug = category.slug
this.formData.description = category.description || ''
this.formData.sortOrder = category.sortOrder
this.formData.color = category.color || null
this.formData.textColor = category.textColor || 'dark'
this.formData.hideChildrenOnCard = category.hideChildrenOnCard || false
// When editing, don't track manual slug edits (slug is pre-populated from DB)
this.slugManuallyEdited = false
// Set selectedHeader based on headerId
const header = this.headers.find((h) => h.id === category.headerId)
if (header) {
this.selectedHeader = {
id: header.id,
label: header.name,
// Set selectedParent based on parentId or headerId
if (category.parentId !== null) {
// Find the parent category in the tree
const allCats = this.getAllCategoriesFlat()
const parentCat = allCats.find((c) => c.id === category.parentId)
if (parentCat) {
// Find depth for indentation
const option = this.parentOptions.find((o) => o.id === `category:${category.parentId}`)
this.selectedParent = option || {
id: `category:${parentCat.id}`,
label: parentCat.name,
type: 'category',
}
}
} else if (category.headerId !== null) {
const header = this.headers.find((h) => h.id === category.headerId)
if (header) {
this.selectedParent = {
id: `header:${header.id}`,
label: header.name,
type: 'header',
}
}
}
},
@@ -687,14 +757,23 @@ export default defineComponent({
try {
this.submitting = true
const categoryData = {
headerId: this.formData.headerId!,
const categoryData: Record<string, unknown> = {
name: this.formData.name.trim(),
slug: this.formData.slug.trim(),
description: this.formData.description.trim() || null,
sortOrder: this.formData.sortOrder,
color: this.formData.color || null,
textColor: this.formData.color ? this.formData.textColor : null,
hideChildrenOnCard: this.formData.hideChildrenOnCard,
}
// Set parent based on selection type
if (this.formData.parentId !== null) {
categoryData.parentId = this.formData.parentId
categoryData.headerId = null
} else {
categoryData.headerId = this.formData.headerId
categoryData.parentId = null
}
let categoryId: number
@@ -800,50 +879,6 @@ export default defineComponent({
goBack(): void {
this.$router.push('/admin/categories')
},
createNewHeader(): void {
this.headerDialog.show = true
this.headerDialog.id = null
this.headerDialog.name = ''
this.headerDialog.description = ''
// Set sort order to the count of headers (will be last)
this.headerDialog.sortOrder = this.categoryHeaders.length
},
editHeader(): void {
if (!this.selectedHeader) return
const header = this.categoryHeaders.find((h) => h.id === this.selectedHeader?.id)
if (!header) return
this.headerDialog.show = true
this.headerDialog.id = header.id
this.headerDialog.name = header.name
this.headerDialog.description = header.description || ''
this.headerDialog.sortOrder = header.sortOrder || 0
},
async handleHeaderSaved(savedHeader: CatHeader): Promise<void> {
// Update in local headers array
const index = this.headers.findIndex((h) => h.id === savedHeader.id)
if (index !== -1) {
this.headers[index] = savedHeader
} else {
// Add to local headers array if new
this.headers.push(savedHeader)
}
// Auto-select the new/updated header
this.selectedHeader = {
id: savedHeader.id,
label: savedHeader.name,
}
// Refresh sidebar categories
await this.refreshCategories()
this.headerDialog.show = false
},
},
})
</script>
@@ -928,6 +963,17 @@ export default defineComponent({
}
}
.hide-children-group {
display: flex;
flex-direction: column;
gap: 4px;
.help-text {
font-size: 0.85rem;
margin-top: 2px;
}
}
.design-preview {
display: flex;
flex-direction: column;

View File

@@ -98,63 +98,71 @@
</div>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div
v-for="(category, index) in header.categories"
:key="category.id"
class="category-row"
<template
v-for="row in flattenCategoriesWithContext(header.categories, header.id)"
:key="row.category.id"
>
<div class="category-sort-buttons">
<NcButton
v-if="index > 0"
variant="tertiary"
@click="moveCategoryUp(header.id, index)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="index < header.categories.length - 1"
variant="tertiary"
@click="moveCategoryDown(header.id, index)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">
{{ category.description }}
<div
class="category-row"
:class="{ 'subcategory-row': row.depth > 0 }"
:style="{ paddingLeft: `${16 + row.depth * 32}px` }"
>
<div class="category-sort-buttons">
<NcButton
v-if="row.index > 0"
variant="tertiary"
@click="reorderSiblings(row.siblings, row.index, -1)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="row.index < row.siblings.length - 1"
variant="tertiary"
@click="reorderSiblings(row.siblings, row.index, 1)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>{{ strings.threadsCount(category.threadCount || 0) }}</span>
<span></span>
<span>{{ strings.postsCount(category.postCount || 0) }}</span>
<div class="category-info">
<div class="category-name" :class="{ 'subcategory-indicator': row.depth > 0 }">
<span v-if="row.depth > 0" class="subcategory-arrow"></span>
{{ row.category.name }}
</div>
<div v-if="row.category.description" class="category-desc muted">
{{ row.category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ row.category.slug }}</span>
<span></span>
<span>{{ strings.threadsCount(row.category.threadCount || 0) }}</span>
<span></span>
<span>{{ strings.postsCount(row.category.postCount || 0) }}</span>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(row.category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(row.category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</template>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
@@ -462,16 +470,24 @@ export default defineComponent({
computed: {
targetCategoryOptions(): Array<{ id: number; label: string; disabled?: boolean }> {
const options: Array<{ id: number; label: string; disabled?: boolean }> = []
const deletingId = this.deleteDialog.category?.id
const addCats = (categories: Category[], prefix: string) => {
for (const cat of categories) {
options.push({
id: cat.id,
label: `${prefix} / ${cat.name}`,
disabled: cat.id === deletingId,
})
if (cat.children && cat.children.length > 0) {
addCats(cat.children, `${prefix} / ${cat.name}`)
}
}
}
this.categoryHeaders.forEach((header) => {
if (header.categories) {
header.categories.forEach((cat) => {
options.push({
id: cat.id,
label: `${header.name} / ${cat.name}`,
disabled: cat.id === this.deleteDialog.category?.id,
})
})
addCats(header.categories, header.name)
}
})
@@ -652,53 +668,64 @@ export default defineComponent({
}
},
async moveCategoryUp(headerId: number, index: number): Promise<void> {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || index <= 0) return
// Update sort orders on backend
await this.updateCategorySortOrders(headerId, index, -1)
/**
* Flatten a category tree into rows with depth, index, and sibling info
* for rendering and reordering at any nesting level.
*/
flattenCategoriesWithContext(
categories: Category[],
_headerId: number,
depth = 0,
): Array<{
category: Category
depth: number
index: number
siblings: Category[]
}> {
const rows: Array<{
category: Category
depth: number
index: number
siblings: Category[]
}> = []
for (let i = 0; i < categories.length; i++) {
const cat = categories[i]!
rows.push({ category: cat, depth, index: i, siblings: categories })
if (cat.children && cat.children.length > 0) {
rows.push(...this.flattenCategoriesWithContext(cat.children, _headerId, depth + 1))
}
}
return rows
},
async moveCategoryDown(headerId: number, index: number): Promise<void> {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || index >= header.categories.length - 1) return
// Update sort orders on backend
await this.updateCategorySortOrders(headerId, index, 1)
},
async updateCategorySortOrders(headerId: number, index: number, amount: number): Promise<void> {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return
// Swap positions locally
const temp = header.categories[index]
const swapTarget = header.categories[index + amount]
/**
* Reorder siblings by swapping two adjacent items and persisting to backend.
*/
async reorderSiblings(siblings: Category[], index: number, amount: number): Promise<void> {
const temp = siblings[index]
const swapTarget = siblings[index + amount]
if (!temp || !swapTarget) return
header.categories[index] = swapTarget
header.categories[index + amount] = temp
// Swap locally
siblings[index] = swapTarget
siblings[index + amount] = temp
try {
// Build array of category IDs in their current order
const sortOrders = header.categories.map((category, idx) => ({
id: category.id,
const sortOrders = siblings.map((cat, idx) => ({
id: cat.id,
sortOrder: idx,
}))
await ocs.post('/categories/reorder', { categories: sortOrders })
// Refresh sidebar categories silently
await this.refreshCategories(true)
} catch (e) {
console.error('Failed to update category sort orders', e)
// Revert the swap on error
const revertTemp = header.categories[index + amount]
const revertCurrent = header.categories[index]
// Revert
const revertTemp = siblings[index + amount]
const revertCurrent = siblings[index]
if (revertTemp && revertCurrent) {
header.categories[index + amount] = revertCurrent
header.categories[index] = revertTemp
siblings[index + amount] = revertCurrent
siblings[index] = revertTemp
}
}
},
@@ -820,6 +847,21 @@ export default defineComponent({
gap: 8px;
}
}
.subcategory-row {
background: var(--color-background-dark);
}
.subcategory-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.subcategory-arrow {
color: var(--color-text-maxcontrast);
font-size: 1.1rem;
}
}
.no-categories {

View File

@@ -12,8 +12,10 @@ use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\GuestService;
use OCA\Forum\Service\UserRoleService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
@@ -45,6 +47,8 @@ class AdminControllerTest extends TestCase {
private IUserSession $userSession;
/** @var AdminSettingsService&MockObject */
private AdminSettingsService $settingsService;
/** @var GuestService&MockObject */
private GuestService $guestService;
/** @var LoggerInterface&MockObject */
private LoggerInterface $logger;
/** @var IRequest&MockObject */
@@ -62,6 +66,7 @@ class AdminControllerTest extends TestCase {
$this->userManager = $this->createMock(IUserManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->settingsService = $this->createMock(AdminSettingsService::class);
$this->guestService = $this->createMock(GuestService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new AdminController(
@@ -77,6 +82,7 @@ class AdminControllerTest extends TestCase {
$this->userManager,
$this->userSession,
$this->settingsService,
$this->guestService,
$this->logger
);
}
@@ -260,4 +266,114 @@ class AdminControllerTest extends TestCase {
$this->assertEquals(3, $contributor['postCount']);
$this->assertEquals(1, $contributor['threadCount']);
}
// ── Guest reassignment tests ─────────────────────────────────────
public function testReassignGuestPostsSuccess(): void {
$guestAuthorId = 'guest:abcdef1234567890abcdef1234567890';
$targetUserId = 'alice';
$targetUser = $this->createMock(IUser::class);
$this->userManager->expects($this->once())
->method('get')
->with($targetUserId)
->willReturn($targetUser);
$this->postMapper->expects($this->once())
->method('countByAuthorId')
->with($guestAuthorId)
->willReturn(['total' => 5, 'threads' => 1, 'replies' => 4]);
$this->postMapper->expects($this->once())
->method('reassignAuthor')
->with($guestAuthorId, $targetUserId)
->willReturn(5);
$this->threadMapper->expects($this->once())
->method('reassignAuthor')
->with($guestAuthorId, $targetUserId)
->willReturn(1);
$this->threadMapper->expects($this->once())
->method('reassignLastReplyAuthor')
->with($guestAuthorId, $targetUserId);
$this->forumUserMapper->expects($this->once())
->method('incrementPostCount')
->with($targetUserId, 4);
$this->forumUserMapper->expects($this->once())
->method('incrementThreadCount')
->with($targetUserId, 1);
$response = $this->controller->reassignGuestPosts($guestAuthorId, $targetUserId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
$this->assertEquals(5, $data['postsReassigned']);
$this->assertEquals(1, $data['threadsReassigned']);
}
public function testReassignGuestPostsRejectsInvalidGuestId(): void {
$response = $this->controller->reassignGuestPosts('not-a-guest-id', 'alice');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$this->assertEquals('Invalid guest author ID format', $response->getData()['error']);
}
public function testReassignGuestPostsRejectsNonexistentTargetUser(): void {
$this->userManager->expects($this->once())
->method('get')
->with('nonexistent')
->willReturn(null);
$response = $this->controller->reassignGuestPosts(
'guest:abcdef1234567890abcdef1234567890',
'nonexistent'
);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals('Target user does not exist', $response->getData()['error']);
}
public function testReassignGuestPostsDoesNotIncrementZeroCounts(): void {
$guestAuthorId = 'guest:abcdef1234567890abcdef1234567890';
$targetUserId = 'alice';
$targetUser = $this->createMock(IUser::class);
$this->userManager->method('get')->willReturn($targetUser);
$this->postMapper->method('countByAuthorId')
->willReturn(['total' => 0, 'threads' => 0, 'replies' => 0]);
$this->postMapper->method('reassignAuthor')->willReturn(0);
$this->threadMapper->method('reassignAuthor')->willReturn(0);
$this->threadMapper->method('reassignLastReplyAuthor');
$this->forumUserMapper->expects($this->never())->method('incrementPostCount');
$this->forumUserMapper->expects($this->never())->method('incrementThreadCount');
$response = $this->controller->reassignGuestPosts($guestAuthorId, $targetUserId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$this->assertTrue($response->getData()['success']);
}
public function testReassignGuestPostsHandlesException(): void {
$guestAuthorId = 'guest:abcdef1234567890abcdef1234567890';
$targetUser = $this->createMock(IUser::class);
$this->userManager->method('get')->willReturn($targetUser);
$this->postMapper->method('countByAuthorId')
->willThrowException(new \Exception('DB error'));
$this->logger->expects($this->once())
->method('error')
->with($this->stringContains('Error reassigning guest posts'));
$response = $this->controller->reassignGuestPosts($guestAuthorId, 'alice');
$this->assertEquals(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
}
}

View File

@@ -1015,6 +1015,226 @@ class CategoryControllerTest extends TestCase {
$this->assertTrue($data['success']);
}
// ====== Subcategory Tests ======
public function testCreateCategoryWithParentId(): void {
$parentCategory = $this->createCategory(1, 1, 'Parent Category');
$createdChild = $this->createCategory(2, 1, 'Child Category', 1);
$this->categoryMapper->expects($this->once())
->method('find')
->with(1)
->willReturn($parentCategory);
$this->categoryMapper->expects($this->once())
->method('insert')
->willReturnCallback(function ($category) use ($createdChild) {
// Child categories should have null headerId and parentId set
$this->assertNull($category->getHeaderId());
$this->assertEquals(1, $category->getParentId());
return $createdChild;
});
$response = $this->controller->create(null, 'Child Category', 'child-category', null, 0, null, null, 1);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$data = $response->getData();
$this->assertEquals(1, $data['parentId']);
}
public function testCreateCategoryWithParentIdNotFound(): void {
$this->categoryMapper->expects($this->once())
->method('find')
->with(999)
->willThrowException(new DoesNotExistException('Not found'));
$response = $this->controller->create(null, 'Child', 'child', null, 0, null, null, 999);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
}
public function testCreateCategoryRequiresHeaderOrParent(): void {
$response = $this->controller->create(null, 'Orphan', 'orphan');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
}
public function testUpdateCategorySetParentId(): void {
$category = $this->createCategory(1, 1, 'Category');
$parentCategory = $this->createCategory(2, 1, 'Parent');
$this->categoryMapper->method('find')
->willReturnMap([
[1, $category],
[2, $parentCategory],
]);
$this->categoryMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($cat) {
$this->assertEquals(2, $cat->getParentId());
$this->assertNull($cat->getHeaderId());
return $cat;
});
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '2');
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testUpdateCategoryPreventCircularReferenceDirectChild(): void {
$parentCategory = $this->createCategory(1, 1, 'Parent');
$childCategory = $this->createCategory(2, 1, 'Child', 1);
$this->categoryMapper->method('find')
->willReturnMap([
[1, $parentCategory],
[2, $childCategory],
]);
// Try to set parent (1) as child of its own child (2)
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '2');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$data = $response->getData();
$this->assertStringContainsString('circular', $data['error']);
}
public function testUpdateCategoryPreventCircularReferenceDeeperDescendant(): void {
$grandparent = $this->createCategory(1, 1, 'Grandparent');
$parent = $this->createCategory(2, 1, 'Parent', 1);
$child = $this->createCategory(3, 1, 'Child', 2);
$this->categoryMapper->method('find')
->willReturnMap([
[1, $grandparent],
[2, $parent],
[3, $child],
]);
// Try to set grandparent (1) as child of its grandchild (3)
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '3');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$data = $response->getData();
$this->assertStringContainsString('circular', $data['error']);
}
public function testUpdateCategoryPreventSelfAsParent(): void {
$category = $this->createCategory(1, 1, 'Category');
$this->categoryMapper->method('find')
->willReturnMap([
[1, $category],
]);
// Try to set category as its own parent
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '1');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$data = $response->getData();
$this->assertStringContainsString('circular', $data['error']);
}
public function testUpdateCategorySetHideChildrenOnCard(): void {
$category = $this->createCategory(1, 1, 'Category');
$this->categoryMapper->expects($this->once())
->method('find')
->with(1)
->willReturn($category);
$this->categoryMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($cat) {
$this->assertTrue($cat->getHideChildrenOnCard());
return $cat;
});
$response = $this->controller->update(1, null, null, null, null, null, '__unset__', '__unset__', '__unset__', true);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testDestroyReparentsChildren(): void {
$parent = $this->createCategory(1, 1, 'Parent');
$child1 = $this->createCategory(2, 1, 'Child 1', 1);
$child2 = $this->createCategory(3, 1, 'Child 2', 1);
$this->categoryMapper->expects($this->once())
->method('find')
->with(1)
->willReturn($parent);
$this->categoryMapper->expects($this->once())
->method('findByParentId')
->with(1)
->willReturn([$child1, $child2]);
// Children should be re-parented to parent's parent (null = top-level)
$updateCount = 0;
$this->categoryMapper->method('update')
->willReturnCallback(function ($cat) use (&$updateCount) {
$updateCount++;
$this->assertNull($cat->getParentId());
$this->assertEquals(1, $cat->getHeaderId());
return $cat;
});
$this->threadMapper->method('softDeleteByCategoryId')->willReturn(0);
$this->categoryMapper->expects($this->once())->method('delete')->with($parent);
$response = $this->controller->destroy(1);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$this->assertEquals(2, $updateCount);
}
public function testIndexGroupsChildCategoriesUnderParentHeader(): void {
$header = $this->createCatHeader(1, 'General');
$parent = $this->createCategory(1, 1, 'Parent');
$child = $this->createCategory(2, 1, 'Child', 1);
// Child has null headerId but should be grouped under header 1
$this->catHeaderMapper->method('findAll')->willReturn([$header]);
$this->categoryMapper->method('findAll')->willReturn([$parent, $child]);
$this->threadMapper->method('getLastActivityByCategories')->willReturn([]);
$response = $this->controller->index();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertCount(1, $data); // One header
$this->assertCount(2, $data[0]['categories']); // Both parent and child under it
// Verify child has parentId set
$childData = array_values(array_filter($data[0]['categories'], fn ($c) => $c['id'] === 2));
$this->assertNotEmpty($childData);
$this->assertEquals(1, $childData[0]['parentId']);
}
public function testCategoryEntityJsonIncludesSubcategoryFields(): void {
$category = new Category();
$category->setId(1);
$category->setHeaderId(1);
$category->setParentId(5);
$category->setName('Test');
$category->setSlug('test');
$category->setSortOrder(0);
$category->setHideChildrenOnCard(true);
$category->setThreadCount(0);
$category->setPostCount(0);
$category->setCreatedAt(time());
$category->setUpdatedAt(time());
$json = $category->jsonSerialize();
$this->assertArrayHasKey('parentId', $json);
$this->assertEquals(5, $json['parentId']);
$this->assertArrayHasKey('hideChildrenOnCard', $json);
$this->assertTrue($json['hideChildrenOnCard']);
}
private function createCatHeader(int $id, string $name): CatHeader {
$header = new CatHeader();
$header->setId($id);
@@ -1024,14 +1244,16 @@ class CategoryControllerTest extends TestCase {
return $header;
}
private function createCategory(int $id, int $headerId, string $name): Category {
private function createCategory(int $id, int $headerId, string $name, ?int $parentId = null): Category {
$category = new Category();
$category->setId($id);
$category->setHeaderId($headerId);
$category->setHeaderId($parentId !== null ? null : $headerId);
$category->setParentId($parentId);
$category->setName($name);
$category->setSlug("category-$id");
$category->setDescription(null);
$category->setSortOrder(0);
$category->setHideChildrenOnCard(false);
$category->setThreadCount(0);
$category->setPostCount(0);
$category->setCreatedAt(time());

View File

@@ -252,6 +252,311 @@ class BBCodeServiceTest extends TestCase {
$this->assertStringContainsString('Red text', $result);
}
public function testParseYoutubeTagGeneratesCorrectIframe(): void {
$content = '[youtube]dQw4w9WgXcQ[/youtube]';
$result = $this->service->parse($content, []);
$this->assertStringContainsString('<iframe', $result);
$this->assertStringContainsString('src="https://www.youtube.com/embed/dQw4w9WgXcQ"', $result);
$this->assertStringContainsString('allowfullscreen', $result);
$this->assertStringContainsString('class="youtube-player"', $result);
$this->assertStringContainsString('allow="accelerometer', $result);
}
public function testParseYoutubeTagEscapesVideoId(): void {
$content = '[youtube]<script>alert("xss")</script>[/youtube]';
$result = $this->service->parse($content, []);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testParseMultipleYoutubeTags(): void {
$content = 'First: [youtube]abc123[/youtube] Second: [youtube]def456[/youtube]';
$result = $this->service->parse($content, []);
$this->assertStringContainsString('embed/abc123', $result);
$this->assertStringContainsString('embed/def456', $result);
$this->assertEquals(2, substr_count($result, '<iframe'));
}
public function testParseVideoAttachmentRendersVideoTag(): void {
$bbCode = $this->createAttachmentBBCode();
$file = $this->createMock(\OCP\Files\File::class);
$file->method('getName')->willReturn('video.mp4');
$file->method('getMimeType')->willReturn('video/mp4');
$file->method('getSize')->willReturn(1024 * 1024);
$file->method('getId')->willReturn(42);
$userFolder = $this->createMock(\OCP\Files\Folder::class);
$userFolder->method('get')->willReturn($file);
$this->rootFolder->method('getUserFolder')->willReturn($userFolder);
$this->urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/download');
$content = '[attachment]Forum/video.mp4[/attachment]';
$result = $this->service->parse($content, [$bbCode], 'alice', 1);
$this->assertStringContainsString('<video', $result);
$this->assertStringContainsString('controls', $result);
$this->assertStringContainsString('playsinline', $result);
$this->assertStringContainsString('<source', $result);
$this->assertStringContainsString('type="video/mp4"', $result);
$this->assertStringContainsString('class="attachment attachment-video"', $result);
}
public function testParseImageAttachmentRendersImgTag(): void {
$bbCode = $this->createAttachmentBBCode();
$file = $this->createMock(\OCP\Files\File::class);
$file->method('getName')->willReturn('photo.jpg');
$file->method('getMimeType')->willReturn('image/jpeg');
$file->method('getSize')->willReturn(512 * 1024);
$file->method('getId')->willReturn(43);
$userFolder = $this->createMock(\OCP\Files\Folder::class);
$userFolder->method('get')->willReturn($file);
$this->rootFolder->method('getUserFolder')->willReturn($userFolder);
$this->urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/preview');
$content = '[attachment]Forum/photo.jpg[/attachment]';
$result = $this->service->parse($content, [$bbCode], 'alice', 1);
$this->assertStringContainsString('<img', $result);
$this->assertStringContainsString('class="attachment attachment-image"', $result);
$this->assertStringNotContainsString('<video', $result);
}
public function testParseAudioAttachmentRendersAudioTag(): void {
$bbCode = $this->createAttachmentBBCode();
$file = $this->createMock(\OCP\Files\File::class);
$file->method('getName')->willReturn('podcast.mp3');
$file->method('getMimeType')->willReturn('audio/mpeg');
$file->method('getSize')->willReturn(5 * 1024 * 1024);
$file->method('getId')->willReturn(44);
$userFolder = $this->createMock(\OCP\Files\Folder::class);
$userFolder->method('get')->willReturn($file);
$this->rootFolder->method('getUserFolder')->willReturn($userFolder);
$this->urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/download');
$content = '[attachment]Forum/podcast.mp3[/attachment]';
$result = $this->service->parse($content, [$bbCode], 'alice', 1);
$this->assertStringContainsString('<audio', $result);
$this->assertStringContainsString('controls', $result);
$this->assertStringContainsString('<source', $result);
$this->assertStringContainsString('type="audio/mpeg"', $result);
$this->assertStringContainsString('class="attachment attachment-audio"', $result);
$this->assertStringNotContainsString('<video', $result);
$this->assertStringNotContainsString('<img', $result);
}
public function testParseGenericFileAttachmentRendersDownloadLink(): void {
$bbCode = $this->createAttachmentBBCode();
$file = $this->createMock(\OCP\Files\File::class);
$file->method('getName')->willReturn('document.pdf');
$file->method('getMimeType')->willReturn('application/pdf');
$file->method('getSize')->willReturn(2 * 1024 * 1024);
$file->method('getId')->willReturn(45);
$userFolder = $this->createMock(\OCP\Files\Folder::class);
$userFolder->method('get')->willReturn($file);
$this->rootFolder->method('getUserFolder')->willReturn($userFolder);
$this->urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/download');
$content = '[attachment]Forum/document.pdf[/attachment]';
$result = $this->service->parse($content, [$bbCode], 'alice', 1);
$this->assertStringContainsString('class="attachment attachment-file"', $result);
$this->assertStringContainsString('download="document.pdf"', $result);
$this->assertStringContainsString('attachment-size', $result);
$this->assertStringNotContainsString('<video', $result);
$this->assertStringNotContainsString('<audio', $result);
$this->assertStringNotContainsString('<img', $result);
}
public function testAttachmentTypeDispatchSelectsCorrectRenderer(): void {
$bbCode = $this->createAttachmentBBCode();
$types = [
['image/png', 'attachment-image', '<img'],
['video/webm', 'attachment-video', '<video'],
['audio/ogg', 'attachment-audio', '<audio'],
['application/zip', 'attachment-file', 'attachment-name'],
];
foreach ($types as [$mimeType, $expectedClass, $expectedElement]) {
$file = $this->createMock(\OCP\Files\File::class);
$file->method('getName')->willReturn('file.ext');
$file->method('getMimeType')->willReturn($mimeType);
$file->method('getSize')->willReturn(1024);
$file->method('getId')->willReturn(1);
$userFolder = $this->createMock(\OCP\Files\Folder::class);
$userFolder->method('get')->willReturn($file);
$rootFolder = $this->createMock(\OCP\Files\IRootFolder::class);
$rootFolder->method('getUserFolder')->willReturn($userFolder);
$urlGenerator = $this->createMock(\OCP\IURLGenerator::class);
$urlGenerator->method('linkToRouteAbsolute')->willReturn('https://example.com/file');
$service = new BBCodeService(
$this->bbCodeMapper,
$this->logger,
$rootFolder,
$urlGenerator,
$this->userManager,
);
$result = $service->parse('[attachment]test[/attachment]', [$bbCode], 'user', 1);
$this->assertStringContainsString($expectedClass, $result, "Failed for mime: $mimeType");
$this->assertStringContainsString($expectedElement, $result, "Missing element for mime: $mimeType");
}
}
public function testBuiltinOverrideYoutubeReplacesBrokenLibraryOutput(): void {
// The library generates malformed HTML for youtube — our override should produce clean output
$content = '[youtube]testVideoId[/youtube]';
$result = $this->service->parse($content, []);
// Should not contain the library's broken backslash in width attribute
$this->assertStringNotContainsString('\\', $result);
// Should contain our clean iframe
$this->assertStringContainsString('www.youtube.com/embed/testVideoId', $result);
$this->assertStringContainsString('allowfullscreen', $result);
}
public function testYoutubeEmbedWithSurroundingContent(): void {
$content = 'Check this video: [youtube]abc123[/youtube] and this text after.';
$result = $this->service->parse($content, []);
$this->assertStringContainsString('Check this video:', $result);
$this->assertStringContainsString('embed/abc123', $result);
$this->assertStringContainsString('and this text after.', $result);
}
// ── XSS injection tests ─────────────────────────────────────
public function testXssInBoldTagContentIsEscaped(): void {
$content = '[b]<img src=x onerror=alert(1)>[/b]';
$result = $this->service->parse($content, []);
// Library HTML-escapes content — the tag is rendered as text, not executable HTML
$this->assertStringContainsString('&lt;img', $result);
$this->assertStringNotContainsString('<img src=x', $result);
}
public function testXssInItalicTagContentIsEscaped(): void {
$content = '[i]<svg onload=alert(1)>[/i]';
$result = $this->service->parse($content, []);
$this->assertStringContainsString('&lt;svg', $result);
$this->assertStringNotContainsString('<svg', $result);
}
public function testXssInUrlTagContentIsEscaped(): void {
$content = '[url=https://example.com]<script>alert(1)</script>[/url]';
$result = $this->service->parse($content, []);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testXssInCodeTagContentIsEscaped(): void {
$content = '[code]<script>alert("xss")</script>[/code]';
$result = $this->service->parse($content, []);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testXssInQuoteTagContentIsEscaped(): void {
$content = '[quote]<iframe src="javascript:alert(1)"></iframe>[/quote]';
$result = $this->service->parse($content, []);
// Content is HTML-escaped inside blockquote
$this->assertStringContainsString('&lt;iframe', $result);
$this->assertStringNotContainsString('<iframe', $result);
}
public function testXssInSpoilerTagContentIsEscaped(): void {
$content = '[spoiler]<script>document.cookie</script>[/spoiler]';
$result = $this->service->parse($content, []);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testXssInYoutubeTagVideoIdIsEscaped(): void {
$content = '[youtube]" onload="alert(1)" data-x="[/youtube]';
$result = $this->service->parse($content, []);
// Quotes are escaped via htmlspecialchars, preventing attribute breakout
$this->assertStringContainsString('&quot;', $result);
// The onload is inside the src attribute value (escaped), not a real attribute
$this->assertStringNotContainsString('" onload="', $result);
}
public function testXssInYoutubeTagScriptIsEscaped(): void {
$content = '[youtube]<script>alert(1)</script>[/youtube]';
$result = $this->service->parse($content, []);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testXssInCustomTagContentIsEscaped(): void {
$customTag = $this->createBBCode('highlight', '<mark>{content}</mark>', true, true);
$content = '[highlight]<img src=x onerror=alert(1)>[/highlight]';
$result = $this->service->parse($content, [$customTag]);
// Library escapes content inside custom tags
$this->assertStringContainsString('&lt;img', $result);
$this->assertStringNotContainsString('<img src=x', $result);
}
public function testXssInCustomTagNoParseInnerIsEscaped(): void {
$customTag = $this->createBBCode('raw', '<pre>{content}</pre>', true, false);
$content = '[raw]<script>alert("xss")</script>[/raw]';
$result = $this->service->parse($content, [$customTag]);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testXssDangerousProtocolsBlockedInCustomUrlParam(): void {
$customTag = $this->createBBCode('link', '<a href="{url}">{content}</a>', true, true);
$protocols = [
'javascript:alert(1)',
'vbscript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'file:///etc/passwd',
];
foreach ($protocols as $protocol) {
$content = "[link=$protocol]click[/link]";
$result = $this->service->parse($content, [$customTag]);
$this->assertStringContainsString('href=""', $result, "Dangerous protocol not blocked: $protocol");
}
}
public function testXssCssInjectionInColorParamIsStripped(): void {
$colorCode = $this->createBBCode('customcolor', '<span style="color:{color}">{content}</span>', true, true);
$content = '[customcolor=red;font-weight:bold]text[/customcolor]';
$result = $this->service->parse($content, [$colorCode]);
$this->assertStringNotContainsString('font-weight:bold', $result);
}
private function createBBCode(string $tag, string $replacement, bool $enabled, bool $parseInner): BBCode {
$bbCode = new BBCode();
$bbCode->setTag($tag);
@@ -260,4 +565,14 @@ class BBCodeServiceTest extends TestCase {
$bbCode->setParseInner($parseInner);
return $bbCode;
}
private function createAttachmentBBCode(): BBCode {
$bbCode = new BBCode();
$bbCode->setTag('attachment');
$bbCode->setReplacement('');
$bbCode->setEnabled(true);
$bbCode->setParseInner(false);
$bbCode->setSpecialHandler('attachment');
return $bbCode;
}
}

View File

@@ -1 +1 @@
0.34.0
0.36.0