generated from Nodarx/template
Add office editor provider
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
static/
|
||||
vendor/
|
||||
.env*
|
||||
*.local
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.idea/
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+13
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.')
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Vendored
+9
@@ -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 }>
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
import integrations from './integrations'
|
||||
|
||||
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||
export { integrations }
|
||||
@@ -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(':')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ['tests/**/*.test.ts'], environment: 'node' },
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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]'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user