[verified] add Nextcloud file comment shortcut

This commit is contained in:
2026-09-19 08:09:45 +00:00
commit 1206e8d332
14 changed files with 7175 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# Korean comments action for Nextcloud 34
Minimal custom app that adds **댓글** to each eligible Files row action menu and opens that node's comments UI. On Nextcloud 34 with Activity enabled, comments are integrated into sidebar tab `activity`; if Activity is disabled, the app falls back to tab `comments`.
## Why these APIs
The installed Nextcloud 34.0.4 bundle uses `@nextcloud/files` 4.0.0. Its own Comments app implements the same flow with `registerFileAction()`, `getSidebar()`, `sidebar.open(node, tabId)`, and `sidebar.setActiveTab(tabId)`. The installed Activity app registers sidebar tab ID `activity`; Comments registers tab ID `comments` only when Activity integration is unavailable.
The PHP bootstrap listens for `OCA\Files\Event\LoadAdditionalScriptsEvent`, the supported event fired while Files is rendered, and loads the frontend with `OCP\Util::addInitScript()`.
## Build and automated checks
Use Node 24, matching the installed package engines:
```bash
npm ci
npm run typecheck
npm test
npm run build
```
The production entry is generated as `js/korean_comments_action-main.mjs`. `Util::addInitScript(Application::APP_ID, 'main')` resolves that app-prefixed asset.
## Deployment commands (not run during this investigation)
Copy the app directory to `/data/nextcloud/app/custom_apps/korean_comments_action`, retaining `appinfo/`, `lib/`, and generated `js/` (source/build metadata may also be retained), then run OCC in the Nextcloud PHP container as the web user:
```bash
php occ app:enable korean_comments_action
```
Disable/rollback with:
```bash
php occ app:disable korean_comments_action
```
## Manual acceptance test
1. Open **Files → Personal files**, then open a file row's `…` menu. Confirm one **댓글** item appears and is not an inline icon.
2. Click **댓글**. Confirm the details sidebar opens for that exact filename, the **Activity** tab is active, existing comments load, and the comment composer is visible.
3. On the same file, switch to another sidebar tab and click **댓글** again. Confirm it switches back to Activity without closing the sidebar.
4. With the sidebar still open, use **댓글** on another file. Confirm the sidebar header and comments belong to the second file.
5. Select multiple rows. Confirm the action is not offered. Confirm it is also absent for nodes outside the supported `/files/<user>` tree or with `Permission.NONE`.
6. Optional fallback check on a test instance: disable Activity while leaving Comments enabled, reload Files, and confirm **댓글** opens the standalone **Comments** tab.
## Primary references
- Files event: https://docs.nextcloud.com/server/latest/developer_manual/basics/events.html
- Files frontend API: https://nextcloud-libraries.github.io/nextcloud-files/
- `registerFileAction`: https://nextcloud-libraries.github.io/nextcloud-files/functions/index.registerFileAction.html
- `getSidebar`: https://nextcloud-libraries.github.io/nextcloud-files/functions/index.getSidebar.html
- Bootstrap API: https://docs.nextcloud.com/server/latest/developer_manual/app_development/bootstrap.html
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>korean_comments_action</id>
<name>Korean comments action</name>
<summary>Adds a 댓글 action to the Files row menu</summary>
<description>Adds a Korean-labeled file action that opens the Activity/comments sidebar.</description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>Hera (헤라)</author>
<namespace>KoreanCommentsAction</namespace>
<category>files</category>
<dependencies>
<nextcloud min-version="34" max-version="34"/>
</dependencies>
</info>
+1
View File
@@ -0,0 +1 @@
/* extracted by css-entry-points-plugin */
File diff suppressed because one or more lines are too long
+190
View File
@@ -0,0 +1,190 @@
SPDX-License-Identifier: (MPL-2.0 OR Apache-2.0)
SPDX-License-Identifier: AGPL-3.0-or-later
SPDX-License-Identifier: BlueOak-1.0.0
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-License-Identifier: ISC
SPDX-License-Identifier: MIT
SPDX-License-Identifier: undefined
SPDX-FileCopyrightText: Borewit
SPDX-FileCopyrightText: David Myers <hello@davidmyers.dev>
SPDX-FileCopyrightText: Dr.-Ing. Mario Heiderich, Cure53 <mario@cure53.de> (https://cure53.de/)
SPDX-FileCopyrightText: Evan You
SPDX-FileCopyrightText: Feross Aboukhadijeh
SPDX-FileCopyrightText: GitHub Inc.
SPDX-FileCopyrightText: Irakli Gozalishvili <rfobic@gmail.com> (http://jeditoolkit.com)
SPDX-FileCopyrightText: Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)
SPDX-FileCopyrightText: James Halliday
SPDX-FileCopyrightText: Jonas Schade <derzade@gmail.com>
SPDX-FileCopyrightText: Jordan Harbamd <ljharb@gmail.com>
SPDX-FileCopyrightText: Jordan Harband
SPDX-FileCopyrightText: Jordan Harband <ljharb@gmail.com>
SPDX-FileCopyrightText: Joyent
SPDX-FileCopyrightText: Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/)
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: Raynos <raynos2@gmail.com>
SPDX-FileCopyrightText: Sindre Sorhus
SPDX-FileCopyrightText: inherits developers
SPDX-FileCopyrightText: korean-comments-action developers
SPDX-FileCopyrightText: readable-stream developers
SPDX-FileCopyrightText: string_decoder developers
This file is generated from multiple sources. Included packages:
- @file-type/xml
- version: 0.4.4
- license: MIT
- @nextcloud/auth
- version: 2.6.0
- license: GPL-3.0-or-later
- @nextcloud/browser-storage
- version: 0.5.0
- license: GPL-3.0-or-later
- @nextcloud/event-bus
- version: 3.3.3
- license: GPL-3.0-or-later
- @nextcloud/files
- version: 4.0.0
- license: AGPL-3.0-or-later
- @nextcloud/initial-state
- version: 3.0.0
- license: GPL-3.0-or-later
- @nextcloud/l10n
- version: 3.4.1
- license: GPL-3.0-or-later
- @nextcloud/logger
- version: 3.0.3
- license: GPL-3.0-or-later
- available-typed-arrays
- version: 1.0.7
- license: MIT
- call-bind
- version: 1.0.9
- license: MIT
- call-bind-apply-helpers
- version: 1.0.2
- license: MIT
- call-bound
- version: 1.0.4
- license: MIT
- define-data-property
- version: 1.1.4
- license: MIT
- dompurify
- version: 3.4.15
- license: (MPL-2.0 OR Apache-2.0)
- dunder-proto
- version: 1.0.1
- license: MIT
- es-define-property
- version: 1.0.1
- license: MIT
- es-errors
- version: 1.3.0
- license: MIT
- es-object-atoms
- version: 1.1.2
- license: MIT
- events
- version: 3.3.0
- license: MIT
- for-each
- version: 0.3.5
- license: MIT
- function-bind
- version: 1.1.2
- license: MIT
- generator-function
- version: 2.0.1
- license: MIT
- get-intrinsic
- version: 1.3.0
- license: MIT
- get-proto
- version: 1.0.1
- license: MIT
- gopd
- version: 1.2.0
- license: MIT
- has-property-descriptors
- version: 1.0.2
- license: MIT
- has-symbols
- version: 1.1.0
- license: MIT
- has-tostringtag
- version: 1.0.2
- license: MIT
- hasown
- version: 2.0.4
- license: MIT
- inherits
- version: 2.0.4
- license: ISC
- is-arguments
- version: 1.2.0
- license: MIT
- is-callable
- version: 1.2.7
- license: MIT
- is-generator-function
- version: 1.1.2
- license: MIT
- is-regex
- version: 1.2.1
- license: MIT
- is-svg
- version: 6.1.0
- license: MIT
- is-typed-array
- version: 1.1.15
- license: MIT
- korean-comments-action
- version: 1.0.0
- license: undefined
- math-intrinsics
- version: 1.1.0
- license: MIT
- possible-typed-array-names
- version: 1.1.0
- license: MIT
- readable-stream
- version: 3.6.2
- license: MIT
- safe-buffer
- version: 5.2.1
- license: MIT
- safe-regex-test
- version: 1.1.0
- license: MIT
- sax
- version: 1.6.1
- license: BlueOak-1.0.0
- semver
- version: 7.8.5
- license: ISC
- set-function-length
- version: 1.2.2
- license: MIT
- stream-browserify
- version: 3.0.0
- license: MIT
- string_decoder
- version: 1.3.0
- license: MIT
- typescript-event-target
- version: 1.1.2
- license: MIT
- util
- version: 0.12.5
- license: MIT
- util-deprecate
- version: 1.0.2
- license: MIT
- vite
- version: 7.3.6
- license: MIT
- vite-plugin-node-polyfills
- version: 0.28.0
- license: MIT
- which-typed-array
- version: 1.1.24
- license: MIT
File diff suppressed because one or more lines are too long
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace OCA\KoreanCommentsAction\AppInfo;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\KoreanCommentsAction\Listener\LoadFilesScript;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
final class Application extends App implements IBootstrap {
public const APP_ID = 'korean_comments_action';
public function __construct() {
parent::__construct(self::APP_ID);
}
public function register(IRegistrationContext $context): void {
$context->registerEventListener(
LoadAdditionalScriptsEvent::class,
LoadFilesScript::class,
);
}
public function boot(IBootContext $context): void {
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace OCA\KoreanCommentsAction\Listener;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\KoreanCommentsAction\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
/** @template-implements IEventListener<LoadAdditionalScriptsEvent> */
final class LoadFilesScript implements IEventListener {
public function handle(Event $event): void {
if (!$event instanceof LoadAdditionalScriptsEvent) {
return;
}
Util::addInitScript(Application::APP_ID, 'main');
}
}
+6626
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "korean-comments-action",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@nextcloud/files": "4.0.0",
"@nextcloud/initial-state": "3.0.0"
},
"devDependencies": {
"@nextcloud/vite-config": "2.5.4",
"@types/node": "24.10.4",
"typescript": "6.0.3",
"vite": "7.3.6",
"vitest": "^4.1.11"
},
"engines": {
"node": "^24.0.0"
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { IFileAction } from '@nextcloud/files'
import { getSidebar, Permission, registerFileAction } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
const COMMENT_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 8H6V6h12v2m0 3H6V9h12v2m0 3H6v-2h12v2m4-10v18l-4-4H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2Z"/></svg>'
type ActionContext = Parameters<NonNullable<IFileAction['enabled']>>[0]
function commentsTab(context: ActionContext): 'activity' | 'comments' | undefined {
const [node] = context.nodes
if (!node) {
return undefined
}
const tabs = getSidebar().getTabs({
node,
folder: context.folder,
view: context.view,
})
const commentsUseActivity = loadState<boolean>('comments', 'activityEnabled', false)
if (commentsUseActivity && tabs.some(({ id }) => id === 'activity')) {
return 'activity'
}
if (tabs.some(({ id }) => id === 'comments')) {
return 'comments'
}
return undefined
}
export const action: IFileAction = {
id: 'korean-comments-action',
displayName: () => '댓글',
title: () => '댓글',
iconSvgInline: () => COMMENT_ICON,
enabled(context) {
const [node] = context.nodes
return context.nodes.length === 1
&& node !== undefined
&& node.root.startsWith('/files/')
&& node.permissions !== Permission.NONE
&& getSidebar().available
&& commentsTab(context) !== undefined
},
async exec(context) {
const [node] = context.nodes
const tabId = commentsTab(context)
if (!node || !tabId) {
return false
}
try {
const sidebar = getSidebar()
if (sidebar.isOpen && sidebar.node?.source === node.source) {
sidebar.setActiveTab(tabId)
} else {
sidebar.open(node, tabId)
}
return null
} catch (error) {
console.error('Could not open the comments sidebar', error)
return false
}
},
inline: () => false,
order: -45,
}
registerFileAction(action)
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getSidebar: vi.fn(),
registerFileAction: vi.fn(),
loadState: vi.fn(),
open: vi.fn(),
setActiveTab: vi.fn(),
getTabs: vi.fn(),
}))
vi.mock('@nextcloud/files', () => ({
getSidebar: mocks.getSidebar,
registerFileAction: mocks.registerFileAction,
Permission: { NONE: 0 },
}))
vi.mock('@nextcloud/initial-state', () => ({
loadState: mocks.loadState,
}))
import { action } from '../src/main'
const node = {
root: '/files/alice',
permissions: 1,
source: 'https://cloud.example/remote.php/dav/files/alice/report.pdf',
}
const context = {
nodes: [node],
folder: {},
view: {},
contents: [node],
}
beforeEach(() => {
mocks.open.mockReset()
mocks.setActiveTab.mockReset()
mocks.getTabs.mockReset()
mocks.loadState.mockReset()
mocks.loadState.mockReturnValue(true)
mocks.getTabs.mockReturnValue([{ id: 'activity' }])
mocks.getSidebar.mockReturnValue({
available: true,
isOpen: false,
node: undefined,
open: mocks.open,
setActiveTab: mocks.setActiveTab,
getTabs: mocks.getTabs,
})
})
describe('Korean comments file action', () => {
it('registers a non-inline 댓글 action', () => {
expect(mocks.registerFileAction).toHaveBeenCalledWith(action)
expect(action.displayName(context as never)).toBe('댓글')
expect(action.inline?.(context as never)).toBe(false)
})
it('opens the integrated Activity tab', async () => {
expect(action.enabled?.(context as never)).toBe(true)
expect(await action.exec(context as never)).toBeNull()
expect(mocks.open).toHaveBeenCalledWith(node, 'activity')
})
it('switches an already-open sidebar to Activity', async () => {
mocks.getSidebar.mockReturnValue({
available: true,
isOpen: true,
node,
open: mocks.open,
setActiveTab: mocks.setActiveTab,
getTabs: mocks.getTabs,
})
expect(await action.exec(context as never)).toBeNull()
expect(mocks.setActiveTab).toHaveBeenCalledWith('activity')
expect(mocks.open).not.toHaveBeenCalled()
})
it('falls back to the standalone Comments tab', async () => {
mocks.getTabs.mockReturnValue([{ id: 'comments' }])
expect(await action.exec(context as never)).toBeNull()
expect(mocks.open).toHaveBeenCalledWith(node, 'comments')
})
it('hides when Activity exists without Comments integration', () => {
mocks.loadState.mockReturnValue(false)
mocks.getTabs.mockReturnValue([{ id: 'activity' }])
expect(action.enabled?.(context as never)).toBe(false)
})
})
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { createAppConfig } from '@nextcloud/vite-config'
import { resolve } from 'node:path'
export default createAppConfig(
{
main: resolve('src/main.ts'),
},
{
createEmptyCSSEntryPoints: true,
},
)