generated from Nodarx/template
Merge pull request 'Initial module import' (#1) from initial-module-import into main
Reviewed-on: #1
This commit was merged in pull request #1.
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