diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9175db9..9c1903a 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -6,4 +6,16 @@ module.exports = { 'jsdoc/require-jsdoc': 'off', 'vue/first-attribute-linebreak': 'off', }, + overrides: [ + { + // @nextcloud/eslint-config uses @babel/eslint-parser for .vue files, + // which cannot parse TypeScript in - - diff --git a/src/components/FileCard.vue b/src/components/FileCard.vue new file mode 100644 index 0000000..744c60b --- /dev/null +++ b/src/components/FileCard.vue @@ -0,0 +1,91 @@ + + + + + + + diff --git a/src/components/TemplateSection.vue b/src/components/TemplateSection.vue new file mode 100644 index 0000000..d30b18c --- /dev/null +++ b/src/components/TemplateSection.vue @@ -0,0 +1,318 @@ + + + + + + + diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..4adefb6 --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import Editor from './views/Editor.vue' + +const app = createApp(Editor) +app.mount('#office-editor') diff --git a/src/file-actions.ts b/src/file-actions.ts new file mode 100644 index 0000000..422991c --- /dev/null +++ b/src/file-actions.ts @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { DefaultType, Permission, registerFileAction } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' + +const supportedMimes: string[] = loadState('office', 'supported-mimes', []) + +registerFileAction({ + id: 'office-open', + + displayName: () => 'Open in Office', + + iconSvgInline: () => '', + + default: DefaultType.DEFAULT, + + order: 10, + + enabled: ({ nodes }) => { + if (nodes.length !== 1) { + return false + } + const node = nodes[0] + if ((node.permissions & Permission.READ) === 0) { + return false + } + return supportedMimes.includes(node.mime ?? '') + }, + + exec: async ({ nodes }) => { + const node = nodes[0] + const fileId = node.fileid + + if (fileId === undefined) { + return false + } + + if (isPublicShare()) { + const token = getSharingToken() + if (!token) { + return false + } + window.location.href = generateUrl('/apps/office/open/share/{token}', { token }) + + '?fileId=' + encodeURIComponent(String(fileId)) + } else { + window.location.href = generateUrl('/apps/office/open') + '?fileId=' + encodeURIComponent(String(fileId)) + } + + return true + }, +}) diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..4e1d401 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,16 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare global { + interface Window { + OCA?: { + Viewer?: { + open(options: { path: string }): void + } + } + } +} + +export {} diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..4f97f4c --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const GRID_VIEW_KEY = 'office.overview.gridView' + +export function getOverviewGridView(): boolean { + return localStorage.getItem(GRID_VIEW_KEY) === 'true' +} + +export function setOverviewGridView(enabled: boolean): void { + localStorage.setItem(GRID_VIEW_KEY, String(enabled)) +} diff --git a/src/services/officeFiles.ts b/src/services/officeFiles.ts new file mode 100644 index 0000000..6e84e58 --- /dev/null +++ b/src/services/officeFiles.ts @@ -0,0 +1,73 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' +import { getClient, getDavNameSpaces, getDavProperties, getRootPath, resultToNode } from '@nextcloud/files/dav' + +// TODO: This DAV SEARCH is unpaginated (depth: infinity). For users with very large +// file collections the full result set is transferred over the wire before we slice it. +// MAX_DISPLAY_FILES only guards the rendered list; it does not reduce network cost. +// A proper solution requires a server-side cursor/limit API. +export const MAX_DISPLAY_FILES = 200 + +function buildOfficeMimeSearch(mimes: string[]): string { + const escapeXml = (s: string) => s.replace(/&/g, '&').replace(//g, '>') + const conditions = mimes + .map(mime => `\t\t\t\t${escapeXml(mime)}`) + .join('\n') + + return ` + + + + + ${getDavProperties()} + + + + + ${getRootPath()}/ + infinity + + + + +${conditions} + + + +` +} + +// Single flat cache for all office files. Safe because the sole caller (fetchAll) +// always passes the full union of every creator's mimes. If a partial-mime caller +// is ever added this must be keyed by the mimes set. +let cachedNodes: Node[] | null = null + +export async function getAllOfficeFiles(mimes: string[]): Promise { + if (cachedNodes) { + return cachedNodes + } + + const client = getClient() + const response = await client.search('/', { + details: true, + data: buildOfficeMimeSearch(mimes), + }) as { data: { results: object[] } } + + cachedNodes = response.data.results + .map(item => resultToNode(item as Parameters[0])) + .filter(node => node.type === 'file') + + return cachedNodes +} + +export function invalidateOfficeFilesCache(): void { + cachedNodes = null +} + +export function filterByMimes(files: Node[], mimes: string[]): Node[] { + return files.filter(file => mimes.includes(file.mime ?? '')) +} diff --git a/src/services/templates.ts b/src/services/templates.ts new file mode 100644 index 0000000..4f7c74d --- /dev/null +++ b/src/services/templates.ts @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +export interface TemplateFile { + fileid: number + basename: string + filename: string + templateId: string + templateType: string + hasPreview: boolean + previewUrl?: string +} + +export interface TemplateCreator { + app: string + label: string + extension: string + iconClass?: string + iconSvgInline?: string + mimetypes: string[] + templates: TemplateFile[] +} + +export interface CreatedFile { + fileid: number + basename: string + filename: string +} + +export interface OcsErrorResponse { + response?: { + data?: { + ocs?: { + meta?: { + message?: string + } + } + } + } +} + +export async function getTemplates(): Promise { + const response = await axios.get(generateOcsUrl('apps/files/api/v1/templates')) + return response.data.ocs.data +} + +export async function createFromTemplate( + filePath: string, + templatePath: string, + templateType: string, +): Promise { + const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/create'), { + filePath, + templatePath, + templateType, + }) + return response.data.ocs.data +} diff --git a/src/settings-admin.ts b/src/settings-admin.ts new file mode 100644 index 0000000..2aaaf30 --- /dev/null +++ b/src/settings-admin.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import AdminSettings from './settings/AdminSettings.vue' + +const app = createApp(AdminSettings) +app.mount('#office-settings-admin') diff --git a/src/settings/AdminSettings.vue b/src/settings/AdminSettings.vue new file mode 100644 index 0000000..409e2b4 --- /dev/null +++ b/src/settings/AdminSettings.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts new file mode 100644 index 0000000..af9fd94 --- /dev/null +++ b/src/shims-vue.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/src/views/Editor.vue b/src/views/Editor.vue new file mode 100644 index 0000000..0395863 --- /dev/null +++ b/src/views/Editor.vue @@ -0,0 +1,78 @@ + + +