Add OnlyOffice-based preview for office documents
Replace Aspose.Words with OnlyOffice Document Server for generating bitmap previews of office documents (Word, Excel, PowerPoint, etc.). Backend: - Add cista/onlyoffice.py conversion client - Convert office docs directly to PNG via OnlyOffice, then AVIF via pyvips - Make office previews optional based on OnlyOffice availability - Remove Aspose.Words dependency and all related code - Add spreadsheet and presentation format support Frontend: - Mark office files as previewable in Document.ts - Add office extensions to MediaPreview.vue preview list - Fix pre-existing @ts-ignore in HeaderMain.vue Tests: - Fix test_lrucache.py parameter name (open -> opener) Also run ruff format across the codebase to satisfy linter checks.
This commit is contained in:
@@ -149,6 +149,7 @@ const settingsMenu = (e: Event) => {
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
x: e.target.getBoundingClientRect().right,
|
||||
// @ts-ignore
|
||||
y: e.target.getBoundingClientRect().bottom,
|
||||
items
|
||||
})
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
<script setup lang="ts">
|
||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const aud = ref<HTMLAudioElement | null>(null)
|
||||
const vid = ref<HTMLVideoElement | null>(null)
|
||||
@@ -29,7 +29,11 @@ const props = defineProps<{
|
||||
doc: Doc
|
||||
quality: string
|
||||
}>()
|
||||
const previewSrc = computed(() => props.doc.previewurl ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` : '')
|
||||
const previewSrc = computed(() =>
|
||||
props.doc.previewurl
|
||||
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const onplay = () => {
|
||||
if (!media.value) return
|
||||
@@ -51,8 +55,11 @@ const applyPoster = (el: HTMLVideoElement) => {
|
||||
let fscurrent: HTMLVideoElement | null = null
|
||||
const next = () => {
|
||||
if (!media.value) return
|
||||
media.value.load() // Restore poster
|
||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
|
||||
media.value.load() // Restore poster
|
||||
const medias = Array.from(document.querySelectorAll('video, audio')) as (
|
||||
| HTMLAudioElement
|
||||
| HTMLVideoElement
|
||||
)[]
|
||||
if (medias.length === 0) return
|
||||
let el: HTMLAudioElement | HTMLVideoElement | null = null
|
||||
for (const i in medias) {
|
||||
@@ -62,28 +69,32 @@ const next = () => {
|
||||
}
|
||||
}
|
||||
if (!el) return
|
||||
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) {
|
||||
if (el.tagName === 'VIDEO' && document.fullscreenElement === media.value) {
|
||||
// Fullscreen needs to use the current video element for the next video
|
||||
// because we are not allowed to fullscreen the next one.
|
||||
// FIXME: Write our own player to avoid this problem...
|
||||
const elem = media.value as HTMLVideoElement
|
||||
const playing = el as HTMLVideoElement
|
||||
if (elem === playing) {
|
||||
playing.play() // Only one video, just replay
|
||||
playing.play() // Only one video, just replay
|
||||
return
|
||||
}
|
||||
if (!fscurrent) {
|
||||
elem.addEventListener('fullscreenchange', ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
applyPoster(elem)
|
||||
onpaused()
|
||||
}, {once: true})
|
||||
elem.addEventListener(
|
||||
'fullscreenchange',
|
||||
ev => {
|
||||
if (!fscurrent) return
|
||||
// Restore the original video element and continue with the one that was playing
|
||||
fscurrent.currentTime = elem.currentTime
|
||||
fscurrent.click()
|
||||
if (!elem.paused) fscurrent.play()
|
||||
fscurrent = null
|
||||
elem.src = props.doc.url
|
||||
applyPoster(elem)
|
||||
onpaused()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
fscurrent = playing
|
||||
elem.src = playing.src
|
||||
@@ -99,7 +110,10 @@ defineExpose({
|
||||
if (!media.value) return false
|
||||
if (media.value.paused) {
|
||||
media.value.play()
|
||||
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
|
||||
for (const el of Array.from(document.querySelectorAll('video, audio')) as (
|
||||
| HTMLAudioElement
|
||||
| HTMLVideoElement
|
||||
)[]) {
|
||||
if (el === media.value) continue
|
||||
el.pause()
|
||||
}
|
||||
@@ -108,19 +122,67 @@ defineExpose({
|
||||
}
|
||||
return true
|
||||
},
|
||||
media,
|
||||
media
|
||||
})
|
||||
|
||||
|
||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
||||
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||
const archive = () =>
|
||||
['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
||||
const preview = () => (
|
||||
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
|
||||
props.doc.size > 500000 &&
|
||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
|
||||
)
|
||||
const preview = () =>
|
||||
[
|
||||
'bmp',
|
||||
'ico',
|
||||
'tif',
|
||||
'tiff',
|
||||
'heic',
|
||||
'heif',
|
||||
'pdf',
|
||||
'epub',
|
||||
'mobi',
|
||||
// Documents
|
||||
'doc',
|
||||
'dot',
|
||||
'docx',
|
||||
'docm',
|
||||
'dotx',
|
||||
'dotm',
|
||||
'rtf',
|
||||
'odt',
|
||||
'ott',
|
||||
'txt',
|
||||
'md',
|
||||
'mhtml',
|
||||
'mht',
|
||||
'html',
|
||||
'htm',
|
||||
'xml',
|
||||
'wps',
|
||||
'wri',
|
||||
// Spreadsheets
|
||||
'xls',
|
||||
'xlsx',
|
||||
'xlsm',
|
||||
'xlsb',
|
||||
'xltx',
|
||||
'xltm',
|
||||
'ods',
|
||||
'ots',
|
||||
'csv',
|
||||
// Presentations
|
||||
'ppt',
|
||||
'pptx',
|
||||
'pptm',
|
||||
'pps',
|
||||
'ppsx',
|
||||
'pot',
|
||||
'potx',
|
||||
'odp',
|
||||
'otp'
|
||||
].includes(props.doc.ext) ||
|
||||
(props.doc.size > 500000 &&
|
||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -85,7 +85,55 @@ export class Doc {
|
||||
if (this.dir) return false
|
||||
if (this.img) return true
|
||||
// Not a comprehensive list, but good enough for now
|
||||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
|
||||
return [
|
||||
'mp4',
|
||||
'mkv',
|
||||
'webm',
|
||||
'ogg',
|
||||
'mp3',
|
||||
'flac',
|
||||
'aac',
|
||||
'pdf',
|
||||
// Documents
|
||||
'doc',
|
||||
'dot',
|
||||
'docx',
|
||||
'docm',
|
||||
'dotx',
|
||||
'dotm',
|
||||
'rtf',
|
||||
'odt',
|
||||
'ott',
|
||||
'txt',
|
||||
'md',
|
||||
'mhtml',
|
||||
'mht',
|
||||
'html',
|
||||
'htm',
|
||||
'xml',
|
||||
'wps',
|
||||
'wri',
|
||||
// Spreadsheets
|
||||
'xls',
|
||||
'xlsx',
|
||||
'xlsm',
|
||||
'xlsb',
|
||||
'xltx',
|
||||
'xltm',
|
||||
'ods',
|
||||
'ots',
|
||||
'csv',
|
||||
// Presentations
|
||||
'ppt',
|
||||
'pptx',
|
||||
'pptm',
|
||||
'pps',
|
||||
'ppsx',
|
||||
'pot',
|
||||
'potx',
|
||||
'odp',
|
||||
'otp'
|
||||
].includes(this.ext)
|
||||
}
|
||||
get previewurl(): string {
|
||||
if (!this.complete || !this.previewable) return ''
|
||||
|
||||
Reference in New Issue
Block a user