Compare commits

...

6 Commits

Author SHA1 Message Date
407df1d423 chore(master): release 0.20.2 2026-01-09 00:53:18 +02:00
e2dcebc6ee fix: bbcode cursor positions after inserting 2026-01-08 10:30:25 +02:00
a905ce3b4c chore(master): release 0.20.1 2026-01-08 10:14:02 +02:00
c017bb3d09 fix: db seed migrations 2026-01-08 09:32:40 +02:00
Nextcloud bot
67c92c05a3 fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-01-08 02:01:13 +00:00
e94ca2dec1 chore(deps): update dependencies 2026-01-07 22:51:20 +02:00
16 changed files with 632 additions and 397 deletions

View File

@@ -1 +1 @@
{".":"0.20.0"}
{".":"0.20.2"}

View File

@@ -1,5 +1,20 @@
# Changelog
## [0.20.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.1...v0.20.2) (2026-01-08)
### Bug Fixes
* bbcode cursor positions after inserting ([e2dcebc](https://github.com/chenasraf/nextcloud-forum/commit/e2dcebc6ee6e4d017f7f26fc86e72e6734a1f757))
## [0.20.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.20.0...v0.20.1) (2026-01-08)
### Bug Fixes
* db seed migrations ([c017bb3](https://github.com/chenasraf/nextcloud-forum/commit/c017bb3d09a517c19e772420311c23a957f25cba))
* **l10n:** Update translations from Transifex ([67c92c0](https://github.com/chenasraf/nextcloud-forum/commit/67c92c05a3e7f58bbc05265087b763368653f7d3))
## [0.20.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.19.7...v0.20.0) (2026-01-07)

View File

@@ -37,7 +37,7 @@ This app is in early stages of development. While functional, you may encounter
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
]]></description>
<version>0.20.0</version>
<version>0.20.2</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>

13
composer.lock generated
View File

@@ -1035,12 +1035,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "5ba14c800ff89c74333c22d56ca1c1f35c424805"
"reference": "ebc5572f219ad85f60f20fcff71b98b5055c4f8e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/5ba14c800ff89c74333c22d56ca1c1f35c424805",
"reference": "5ba14c800ff89c74333c22d56ca1c1f35c424805",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ebc5572f219ad85f60f20fcff71b98b5055c4f8e",
"reference": "ebc5572f219ad85f60f20fcff71b98b5055c4f8e",
"shasum": ""
},
"conflict": {
@@ -1173,10 +1173,11 @@
"contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
"coreshop/core-shop": "<=4.1.7",
"corveda/phpsandbox": "<1.3.5",
"cosenary/instagram": "<=2.3",
"couleurcitron/tarteaucitron-wp": "<0.3",
"craftcms/cms": "<=4.16.5|>=5,<=5.8.6",
"craftcms/cms": "<=4.16.16|>=5,<=5.8.20",
"croogo/croogo": "<=4.0.7",
"cuyz/valinor": "<0.12",
"czim/file-handling": "<1.5|>=2,<2.3",
@@ -1681,7 +1682,7 @@
"rap2hpoutre/laravel-log-viewer": "<0.13",
"react/http": ">=0.7,<1.9",
"really-simple-plugins/complianz-gdpr": "<6.4.2",
"redaxo/source": "<5.20.1",
"redaxo/source": "<=5.20.1",
"remdex/livehelperchat": "<4.29",
"renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1",
"reportico-web/reportico": "<=8.1",
@@ -2034,7 +2035,7 @@
"type": "tidelift"
}
],
"time": "2026-01-02T22:05:49+00:00"
"time": "2026-01-07T20:06:51+00:00"
},
{
"name": "sebastian/cli-parser",

View File

@@ -2,11 +2,15 @@ OC.L10N.register(
"forum",
{
"Admin" : "Administrador",
"Administrator role with full permissions" : "Rol de administrador con permisos completos",
"Moderator" : "Moderador",
"Moderator role with elevated permissions" : "Rol de moderador con permisos elevados",
"User" : "Usuario",
"Default user role with basic permissions" : "Rol de usuario por defecto con permisos básicos",
"Guest" : "Invitado",
"Guest role for unauthenticated users with read-only access" : "Rol de invitado para usuarios sin autenticar con acceso de solo lectura",
"General" : "General",
"General discussion categories" : "Categorías de discusión general",
"Support" : "Soporte",
"Bold text" : "Texto en negrita",
"Underlined text" : "Texto subrayado",

View File

@@ -1,10 +1,14 @@
{ "translations": {
"Admin" : "Administrador",
"Administrator role with full permissions" : "Rol de administrador con permisos completos",
"Moderator" : "Moderador",
"Moderator role with elevated permissions" : "Rol de moderador con permisos elevados",
"User" : "Usuario",
"Default user role with basic permissions" : "Rol de usuario por defecto con permisos básicos",
"Guest" : "Invitado",
"Guest role for unauthenticated users with read-only access" : "Rol de invitado para usuarios sin autenticar con acceso de solo lectura",
"General" : "General",
"General discussion categories" : "Categorías de discusión general",
"Support" : "Soporte",
"Bold text" : "Texto en negrita",
"Underlined text" : "Texto subrayado",

View File

@@ -93,7 +93,7 @@ OC.L10N.register(
"Pick file from Nextcloud" : "Seleccionar un ficheiro en Nextcloud",
"Upload file to Nextcloud" : "Enviar un ficheiro a Nextcloud",
"Uploading file …" : "Enviando o ficheiro…",
"Upload failed" : "Produciuse algún fallo no envío",
"Upload failed" : "Produciuse un fallo no envío",
"Close" : "Pechar",
"Pick a file to attach" : "Escolla un ficheiro para anexar",
"Failed to upload file" : "Produciuse un fallo ao enviar o ficheiro",

View File

@@ -91,7 +91,7 @@
"Pick file from Nextcloud" : "Seleccionar un ficheiro en Nextcloud",
"Upload file to Nextcloud" : "Enviar un ficheiro a Nextcloud",
"Uploading file …" : "Enviando o ficheiro…",
"Upload failed" : "Produciuse algún fallo no envío",
"Upload failed" : "Produciuse un fallo no envío",
"Close" : "Pechar",
"Pick a file to attach" : "Escolla un ficheiro para anexar",
"Failed to upload file" : "Produciuse un fallo ao enviar o ficheiro",

View File

@@ -87,7 +87,8 @@ class RepairSeeds extends Command {
}
};
SeedHelper::seedAll($migrationOutput);
// Pass throwOnError=true so users get proper error feedback
SeedHelper::seedAll($migrationOutput, true);
$output->writeln('');
$output->writeln('<info>Forum data repair/seed completed successfully!</info>');

View File

@@ -17,8 +17,10 @@ class SeedHelper {
* Each function checks its own state and returns early if already seeded
*
* @param \OCP\Migration\IOutput|null $output Optional output for console messages
* @param bool $throwOnError If true, throws exceptions on failure. If false (default), logs errors and continues.
* Set to false when called from migrations to avoid PostgreSQL transaction abort issues.
*/
public static function seedAll($output = null): void {
public static function seedAll($output = null, bool $throwOnError = false): void {
$logger = \OC::$server->get(\Psr\Log\LoggerInterface::class);
$logger->info('Forum seeding: Starting data seed/repair');
@@ -26,24 +28,61 @@ class SeedHelper {
$output->info('Forum: Starting data seed/repair...');
}
$errors = [];
// Ensure forum_users table exists (handle rename from forum_user_stats if needed)
self::ensureForumUsersTable($output);
// This is critical and should fail early if it cannot be done
try {
self::ensureForumUsersTable($output);
} catch (\Exception $e) {
$errors[] = 'ensureForumUsersTable: ' . $e->getMessage();
$logger->error('Forum seeding: Failed to ensure forum_users table', ['exception' => $e->getMessage()]);
if ($output) {
$output->warning(' Failed to ensure forum_users table: ' . $e->getMessage());
}
}
// Each function checks its own state and returns early if already seeded
// They run independently so one failure doesn't block others
self::seedDefaultRoles($output);
self::seedCategoryHeaders($output);
self::seedDefaultCategories($output);
self::seedCategoryPermissions($output);
self::seedGuestRolePermissions($output);
self::seedDefaultBBCodes($output);
self::assignUserRoles($output);
self::seedWelcomeThread($output);
// They run independently so one failure does not block others
// This is especially important for PostgreSQL where a failed query aborts the transaction
$seedOperations = [
'seedDefaultRoles' => fn () => self::seedDefaultRoles($output),
'seedCategoryHeaders' => fn () => self::seedCategoryHeaders($output),
'seedDefaultCategories' => fn () => self::seedDefaultCategories($output),
'seedCategoryPermissions' => fn () => self::seedCategoryPermissions($output),
'seedGuestRolePermissions' => fn () => self::seedGuestRolePermissions($output),
'seedDefaultBBCodes' => fn () => self::seedDefaultBBCodes($output),
'assignUserRoles' => fn () => self::assignUserRoles($output),
'seedWelcomeThread' => fn () => self::seedWelcomeThread($output),
];
$logger->info('Forum seeding: Completed data seed/repair');
foreach ($seedOperations as $name => $operation) {
try {
$operation();
} catch (\Exception $e) {
$errors[] = "$name: " . $e->getMessage();
$logger->error("Forum seeding: $name failed", ['exception' => $e->getMessage()]);
// Continue with other operations - don't let one failure block others
}
}
if ($output) {
$output->info('Forum: Data seed/repair completed');
if (!empty($errors)) {
$errorSummary = 'Some seeding operations failed: ' . implode('; ', $errors);
$logger->warning('Forum seeding: Completed with errors', ['errors' => $errors]);
if ($output) {
$output->warning('Forum: Data seed/repair completed with errors. Run "occ forum:repair-seeds" to retry failed operations.');
}
if ($throwOnError) {
throw new \RuntimeException($errorSummary);
}
} else {
$logger->info('Forum seeding: Completed data seed/repair successfully');
if ($output) {
$output->info('Forum: Data seed/repair completed');
}
}
}

View File

@@ -76,7 +76,15 @@ class Version15Date20260103000000 extends SimpleMigrationStep {
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Re-run seeding to ensure all required data exists
SeedHelper::seedAll($output);
// Pass throwOnError=false to avoid PostgreSQL transaction abort issues
// If seeding fails, users can run "occ forum:repair-seeds" to retry
try {
SeedHelper::seedAll($output, false);
} catch (\Exception $e) {
// This should not happen with throwOnError=false, but handle it gracefully
$this->logger->error('Forum migration: Seeding failed unexpectedly', ['exception' => $e->getMessage()]);
$output->warning('Forum: Seeding failed. Run "occ forum:repair-seeds" after enabling the app to complete setup.');
}
}
/**

View File

@@ -43,16 +43,16 @@
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"happy-dom": "^20.0.11",
"happy-dom": "^20.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^2.8.8",
"prettier-plugin-vue": "^1.1.6",
"rollup-plugin-visualizer": "^6.0.5",
"sass": "^1.97.1",
"sass-embedded": "^1.97.1",
"sass": "^1.97.2",
"sass-embedded": "^1.97.2",
"typescript": "5.9.2",
"typescript-eslint": "^8.51.0",
"typescript-eslint": "^8.52.0",
"vite": "^6.4.1",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.0.16",

769
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,12 +54,12 @@ describe('bbcode utilities', () => {
expect(result.cursorPosition).toBe(18)
})
it('inserts template at cursor when no selection', () => {
it('inserts template at cursor when no selection, cursor between tags', () => {
const selection: TextSelection = { text: 'Hello world', start: 6, end: 6 }
const result = applyBBCodeTemplate(selection, { template: '[b]{text}[/b]' })
expect(result.text).toBe('Hello [b][/b]world')
expect(result.cursorPosition).toBe(13) // cursor after [/b]
expect(result.cursorPosition).toBe(9) // cursor between tags (after [b]) for immediate typing
})
it('uses fallback text when no selection', () => {
@@ -130,6 +130,83 @@ describe('bbcode utilities', () => {
expect(result.text).toBe('[size=]text[/size]')
})
describe('cursor positioning', () => {
it('places cursor between tags when no selection (simple tag)', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = applyBBCodeTemplate(selection, { template: '[b]{text}[/b]' })
expect(result.text).toBe('[b][/b]')
expect(result.cursorPosition).toBe(3) // right after [b]
})
it('places cursor between tags when no selection (tag with value)', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = applyBBCodeTemplate(selection, {
template: '[url={value}]{text}[/url]',
value: 'http://example.com',
})
expect(result.text).toBe('[url=http://example.com][/url]')
expect(result.cursorPosition).toBe(24) // right after the ]
})
it('places cursor between tags when no selection (color tag)', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = applyBBCodeTemplate(selection, {
template: '[color={value}]{text}[/color]',
value: '#ff0000',
})
expect(result.text).toBe('[color=#ff0000][/color]')
expect(result.cursorPosition).toBe(15) // right after [color=#ff0000]
})
it('places cursor after closing tag when selection exists', () => {
const selection: TextSelection = { text: 'hello', start: 0, end: 5 }
const result = applyBBCodeTemplate(selection, { template: '[b]{text}[/b]' })
expect(result.text).toBe('[b]hello[/b]')
expect(result.cursorPosition).toBe(12) // after [/b]
})
it('places cursor after closing tag when fallback text is used', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = applyBBCodeTemplate(selection, {
template: '[b]{text}[/b]',
fallbackText: 'bold',
})
expect(result.text).toBe('[b]bold[/b]')
expect(result.cursorPosition).toBe(11) // after [/b]
})
it('places cursor between tags for quote tag without selection', () => {
const selection: TextSelection = { text: 'Some text', start: 9, end: 9 }
const result = applyBBCodeTemplate(selection, { template: '[quote]{text}[/quote]' })
expect(result.text).toBe('Some text[quote][/quote]')
expect(result.cursorPosition).toBe(16) // right after [quote]
})
it('places cursor between tags for code tag without selection', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = applyBBCodeTemplate(selection, { template: '[code]{text}[/code]' })
expect(result.text).toBe('[code][/code]')
expect(result.cursorPosition).toBe(6) // right after [code]
})
it('handles template with newlines - cursor between tags', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = applyBBCodeTemplate(selection, {
template: '[list]\n[*]{text}\n[/list]',
})
expect(result.text).toBe('[list]\n[*]\n[/list]')
expect(result.cursorPosition).toBe(10) // right after [*]
})
})
})
describe('insertTextAtSelection', () => {
@@ -183,12 +260,12 @@ describe('bbcode utilities', () => {
expect(result.cursorPosition).toBe(18)
})
it('inserts empty tags when no selection', () => {
it('inserts empty tags when no selection, cursor between tags', () => {
const selection: TextSelection = { text: 'Hello', start: 5, end: 5 }
const result = wrapSelection(selection, '[i]', '[/i]')
expect(result.text).toBe('Hello[i][/i]')
expect(result.cursorPosition).toBe(12)
expect(result.cursorPosition).toBe(8) // cursor between tags (after [i]) for immediate typing
})
it('uses fallback text when no selection', () => {
@@ -419,12 +496,12 @@ describe('bbcode utilities', () => {
expect(result.text).toBe('Use [code][b][/code] for bold')
})
it('handles empty string', () => {
it('handles empty string, cursor between tags', () => {
const selection: TextSelection = { text: '', start: 0, end: 0 }
const result = wrapSelection(selection, '[b]', '[/b]')
expect(result.text).toBe('[b][/b]')
expect(result.cursorPosition).toBe(7)
expect(result.cursorPosition).toBe(3) // cursor between tags (after [b]) for immediate typing
})
it('handles very long text', () => {

View File

@@ -74,6 +74,11 @@ export function getSelectedText(selection: TextSelection): string {
* 2. Replaces the selected text with the BBCode-wrapped version
* 3. Returns the new text and cursor position
*
* Cursor positioning:
* - With selected text or fallback text: cursor is placed after the closing tag
* - Without any content: cursor is placed between the opening and closing tags
* so the user can immediately start typing
*
* Template placeholders:
* - {text}: Replaced with selected text (or fallbackText if nothing selected)
* - {value}: Replaced with the provided value (for tags like [url=...], [color=...])
@@ -83,7 +88,7 @@ export function getSelectedText(selection: TextSelection): string {
* @returns The insertion result with new text and cursor position
*
* @example
* // Simple wrap with [b] tags
* // Simple wrap with [b] tags - cursor after closing tag
* applyBBCodeTemplate(
* { text: 'Hello world', start: 6, end: 11 },
* { template: '[b]{text}[/b]' }
@@ -91,6 +96,14 @@ export function getSelectedText(selection: TextSelection): string {
* // Returns: { text: 'Hello [b]world[/b]', cursorPosition: 18 }
*
* @example
* // No selection - cursor between tags for immediate typing
* applyBBCodeTemplate(
* { text: 'Hello ', start: 6, end: 6 },
* { template: '[b]{text}[/b]' }
* )
* // Returns: { text: 'Hello [b][/b]', cursorPosition: 9 }
*
* @example
* // URL with value
* applyBBCodeTemplate(
* { text: 'Check this', start: 6, end: 10 },
@@ -115,7 +128,27 @@ export function applyBBCodeTemplate(
.replace('{text}', contentText)
const newText = beforeText + insertText + afterText
const cursorPosition = beforeText.length + insertText.length
// Calculate cursor position:
// - If there's content (selected text or fallback), place cursor after the closing tag
// - If no content, place cursor between tags (at the {text} placeholder position)
// so user can immediately start typing
let cursorPosition: number
if (contentText) {
// Cursor after the entire inserted text (after closing tag)
cursorPosition = beforeText.length + insertText.length
} else {
// No content - find where {text} was in the template and place cursor there
const templateWithValue = template.template.replace('{value}', template.value || '')
const textPlaceholderIndex = templateWithValue.indexOf('{text}')
if (textPlaceholderIndex !== -1) {
// Place cursor where {text} placeholder was (between tags)
cursorPosition = beforeText.length + textPlaceholderIndex
} else {
// Fallback: place cursor at end of inserted text
cursorPosition = beforeText.length + insertText.length
}
}
return {
text: newText,

View File

@@ -1 +1 @@
0.20.0
0.20.2