Add office editor provider

This commit is contained in:
2026-09-14 18:08:20 -04:00
commit a4e8b147e3
14 changed files with 309 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
static/
vendor/
.env*
*.local
*.log
*.tsbuildinfo
.idea/
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderEditorOffice;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract;
class Module extends ModuleInstanceAbstract
{
public function handle(): string { return 'provider_editor_office'; }
public function label(): string { return 'Office Editor Provider'; }
public function author(): string { return 'Ktrix'; }
public function version(): string { return '0.0.1'; }
public function description(): string { return 'Provides read-only office document viewing through WOPI'; }
public function configure(ModuleContextInterface $context): void
{
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'ProviderEditorOffice', 'static/module.mjs');
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "provider_editor_office",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "provider_editor_office",
"version": "0.0.1",
"license": "AGPL-3.0-or-later"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "provider_editor_office",
"version": "0.0.1",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"test": "vitest run --config tests/js/vitest.config.ts",
"test:unit": "vitest run --config tests/js/vitest.config.ts",
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { createFetchWrapper } from '@KTXC'
export interface LaunchSession {
actionUrl: string
accessToken: string
accessTokenTtl: number
officeOrigin: string
mode: 'view'
}
const api = createFetchWrapper()
export const launch = (resource: string): Promise<LaunchSession> => api.post('/m/service_wopi/sessions', { resource })
export function validateSession(session: LaunchSession): void {
const action = new URL(session.actionUrl)
if (action.protocol !== 'https:' || action.origin !== session.officeOrigin || action.username || action.password
|| session.mode !== 'view' || !/^[a-f0-9]{64}$/.test(session.accessToken)
|| !Number.isSafeInteger(session.accessTokenTtl) || session.accessTokenTtl <= Date.now()) {
throw new Error('Invalid office session.')
}
}
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { useIntegrationStore } from '@KTXC'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { launch, validateSession, type LaunchSession } from '../api'
import { officeResource, type OfficeEntity } from '../resource'
const props = defineProps<{ entity: OfficeEntity; mime: string }>()
const integrations = useIntegrationStore()
const session = ref<LaunchSession | null>(null)
const error = ref('')
const busy = ref(false)
const form = ref<HTMLFormElement | null>(null)
const submitted = ref(false)
const target = `office-${crypto.randomUUID()}`
let generation = 0
let timeout: ReturnType<typeof setTimeout> | undefined
async function open() {
const current = ++generation
clearTimeout(timeout)
session.value = null
submitted.value = false
error.value = ''
busy.value = true
try {
if (!integrations.getItems('documents_editor_service').some(item => item.id === 'service_wopi.wopi')) {
error.value = 'The WOPI service is not available.'
return
}
const result = await launch(officeResource(props.entity))
if (current !== generation) return
validateSession(result)
session.value = result
await nextTick()
if (current !== generation) return
form.value?.submit()
// Remove the token-bearing form from the DOM after submission.
submitted.value = true
timeout = setTimeout(() => {
error.value = 'This viewing session has expired. Reopen the document to continue.'
session.value = null
}, Math.min(result.accessTokenTtl - Date.now(), 2147483647))
} catch {
if (current === generation) error.value = 'Could not open the document. It may be unavailable, unsupported, or too large.'
} finally {
if (current === generation) busy.value = false
}
}
watch(() => [props.entity.provider, props.entity.service, props.entity.collection, props.entity.identifier], open, { immediate: true })
onBeforeUnmount(() => { generation++; clearTimeout(timeout); session.value = null })
</script>
<template>
<section class="office-viewer">
<p class="office-status">Read-only — reopen the document to see changes made elsewhere.</p>
<p v-if="busy" role="status">Opening document…</p>
<div v-if="error" role="alert">
<p>{{ error }}</p>
<button type="button" @click="open">Reopen document</button>
</div>
<template v-if="session">
<iframe :name="target" title="Office document viewer" referrerpolicy="no-referrer" allow="fullscreen" />
<form v-if="!submitted" ref="form" method="post" :action="session.actionUrl" :target="target" hidden>
<input type="hidden" name="access_token" :value="session.accessToken">
<input type="hidden" name="access_token_ttl" :value="session.accessTokenTtl">
</form>
</template>
</section>
</template>
<style scoped>
.office-viewer { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: 8px; }
.office-status { margin: 0 0 8px; font-size: 0.875rem; }
iframe { flex: 1; width: 100%; min-height: 400px; border: 0; }
</style>
+9
View File
@@ -0,0 +1,9 @@
declare module '@KTXC' {
export function createFetchWrapper(): {
get<T = any>(url: string): Promise<T>
post<T = any>(url: string, data: unknown): Promise<T>
}
export function useIntegrationStore(): {
getItems(point: string): Array<{ id: string; moduleHandle?: string }>
}
}
+17
View File
@@ -0,0 +1,17 @@
const integrations = {
documents_editor: [{
id: 'office',
label: 'Office Viewer',
priority: 10,
meta: {
mimeTypes: [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
},
component: () => import('./components/OfficeViewer.vue'),
}],
}
export default integrations
+4
View File
@@ -0,0 +1,4 @@
import integrations from './integrations'
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { integrations }
+15
View File
@@ -0,0 +1,15 @@
export interface OfficeEntity {
provider: string
service: string
collection: string | number
identifier: string | number
}
/** Documents models expose local IDs; the backend requires the complete reference. */
export function officeResource(entity: OfficeEntity): string {
const parts = [entity.provider, entity.service, entity.collection, entity.identifier]
if (parts.some(part => part === null || part === undefined || String(part) === '')) {
throw new Error('The document has an incomplete storage reference.')
}
return parts.map(String).join(':')
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { include: ['tests/**/*.test.ts'], environment: 'node' },
})
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { officeResource } from '../src/resource'
describe('office document reference', () => {
it('includes the storage location alongside the local file ID', () => {
expect(officeResource({ provider: 'default', service: 'personal', collection: 'folder', identifier: 'file' }))
.toBe('default:personal:folder:file')
})
it('keeps equal file IDs in different folders distinct', () => {
const entity = { provider: 'default', service: 'personal', collection: 'one', identifier: 42 }
expect(officeResource(entity)).not.toBe(officeResource({ ...entity, collection: 'two' }))
})
it('rejects an incomplete location instead of sending a bare file ID', () => {
expect(() => officeResource({ provider: '', service: 'personal', collection: 'folder', identifier: 'file' })).toThrow()
})
})
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": []
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"]
}
+73
View File
@@ -0,0 +1,73 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-filename',
enforce: 'post',
generateBundle(_options, bundle) {
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
if (!cssFile) return
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
}
}
}
}
],
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src'),
'@DocumentsManager': path.resolve(__dirname, '../documents_manager/src'),
},
},
build: {
outDir: 'static',
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: [
'vue',
'vue-router',
'pinia',
'@KTXC',
/^@DocumentsManager\//,
],
output: {
paths: (id) => {
if (id === '@KTXC') return '/js/ktxc.mjs'
if (id.startsWith('@DocumentsManager/')) {
return '/modules/documents_manager/static/module.mjs'
}
return id
},
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) {
return 'office-editor-[hash].css'
}
return '[name]-[hash][extname]'
}
}
},
},
})