refactor: improvemets

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-03-24 19:10:52 -04:00
parent b6d6bed2ee
commit da6a407445
16 changed files with 1063 additions and 254 deletions

View File

@@ -6,8 +6,10 @@ export { useFileManager } from './useFileManager'
export { useFileSelection } from './useFileSelection'
export { useFileUpload } from './useFileUpload'
export { useFileViewer } from './useFileViewer'
export { useFileEditor } from './useFileEditor'
export type { UseFileManagerOptions } from './useFileManager'
export type { UseFileSelectionOptions } from './useFileSelection'
export type { UseFileUploadOptions, FileUploadProgress } from './useFileUpload'
export type { UseFileViewerReturn } from './useFileViewer'
export type { UseFileEditorReturn } from './useFileEditor'

View File

@@ -0,0 +1,82 @@
/**
* useFileEditor — resolves which registered editor can handle a given MIME type.
*
* Other modules register editors at the `documents_file_editor` integration
* point by including an entry in their `integrations.ts`:
*
* ```ts
* documents_file_editor: [{
* id: 'my_editor',
* meta: { mimeTypes: ['text/plain'] },
* component: () => import('./components/MyEditor.vue'),
* }]
* ```
*
* Editor components receive the props:
* `entity: EntityObject`, `mime: string`,
* `readFile: (entityId: string) => Promise<string | null>`,
* `writeFile: (entityId: string, content: string) => Promise<number>`
*/
import { useIntegrationStore } from '@KTXC'
import type { EntityObject } from '@DocumentsManager/models/entity'
const INTEGRATION_POINT = 'documents_file_editor'
function mimeMatchesPattern(mime: string, pattern: string): boolean {
if (pattern.endsWith('/*')) {
return mime.startsWith(pattern.slice(0, -1))
}
return mime === pattern
}
function editorMatchesMime(
mime: string,
mimeTypes?: string[],
mimePatterns?: string[],
): boolean {
if (mimeTypes?.includes(mime)) return true
if (mimePatterns) {
for (const pattern of mimePatterns) {
if (mimeMatchesPattern(mime, pattern)) return true
}
}
return false
}
export function useFileEditor() {
const integrationStore = useIntegrationStore()
/**
* Returns the highest-priority registered editor that can handle `mime`,
* or `null` if none is found.
*/
function findEditor(mime: string) {
const editors = integrationStore.getItems(INTEGRATION_POINT)
for (const editor of editors) {
if (
editorMatchesMime(
mime,
editor.meta?.mimeTypes as string[] | undefined,
editor.meta?.mimePatterns as string[] | undefined,
)
) {
return editor
}
}
return null
}
/**
* Convenience: returns true if any registered editor can handle this entity.
*/
function canEdit(entity: EntityObject): boolean {
const mime = entity.properties.mime
if (!mime) return false
return findEditor(mime) !== null
}
return { findEditor, canEdit }
}
export type UseFileEditorReturn = ReturnType<typeof useFileEditor>

View File

@@ -3,7 +3,7 @@
* Provides reactive access to file manager state and actions
*/
import { computed, ref } from 'vue'
import { computed, ref, unref } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { useProvidersStore } from '@DocumentsManager/stores/providersStore'
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
@@ -17,8 +17,8 @@ import { EntityObject } from '@DocumentsManager/models/entity'
const TRANSFER_BASE_URL = '/m/documents_manager'
export interface UseFileManagerOptions {
providerId: string
serviceId: string
providerId: string | Ref<string> | ComputedRef<string>
serviceId: string | Ref<string> | ComputedRef<string>
autoFetch?: boolean
}
@@ -29,6 +29,9 @@ export function useFileManager(options: UseFileManagerOptions) {
const { providerId, serviceId, autoFetch = false } = options
const currentProviderId = () => unref(providerId)
const currentServiceId = () => unref(serviceId)
// Current location (folder being viewed)
const currentLocation: Ref<string> = ref(ROOT_ID)
@@ -37,21 +40,21 @@ export function useFileManager(options: UseFileManagerOptions) {
const error = computed(() => nodesStore.error)
// Provider and service
const provider = computed(() => providersStore.provider(providerId))
const service = computed(() => servicesStore.service(providerId, serviceId))
const provider = computed(() => providersStore.provider(currentProviderId()))
const service = computed(() => servicesStore.service(currentProviderId(), currentServiceId()))
const rootId = computed(() => ROOT_ID)
// Current children
const currentChildren = computed(() =>
nodesStore.getChildren(providerId, serviceId, currentLocation.value)
nodesStore.getChildren(currentProviderId(), currentServiceId(), currentLocation.value)
)
const currentCollections: ComputedRef<CollectionObject[]> = computed(() =>
nodesStore.getChildCollections(providerId, serviceId, currentLocation.value)
nodesStore.getChildCollections(currentProviderId(), currentServiceId(), currentLocation.value)
)
const currentEntities: ComputedRef<EntityObject[]> = computed(() =>
nodesStore.getChildEntities(providerId, serviceId, currentLocation.value)
nodesStore.getChildEntities(currentProviderId(), currentServiceId(), currentLocation.value)
)
// Breadcrumb path
@@ -59,7 +62,7 @@ export function useFileManager(options: UseFileManagerOptions) {
if (currentLocation.value === ROOT_ID) {
return []
}
return nodesStore.getPath(providerId, serviceId, currentLocation.value)
return nodesStore.getPath(currentProviderId(), currentServiceId(), currentLocation.value)
})
// Is at root?
@@ -76,7 +79,7 @@ export function useFileManager(options: UseFileManagerOptions) {
if (currentLocation.value === ROOT_ID) {
return
}
const currentNode = nodesStore.getNode(providerId, serviceId, currentLocation.value)
const currentNode = nodesStore.getNode(currentProviderId(), currentServiceId(), currentLocation.value)
if (currentNode) {
await navigateTo(currentNode.collection ? String(currentNode.collection) : ROOT_ID)
}
@@ -94,8 +97,8 @@ export function useFileManager(options: UseFileManagerOptions) {
range?: ListRange
) => {
await nodesStore.fetchNodes(
providerId,
serviceId,
currentProviderId(),
currentServiceId(),
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
filter,
sort,
@@ -106,8 +109,8 @@ export function useFileManager(options: UseFileManagerOptions) {
// Create a new folder
const createFolder = async (label: string): Promise<CollectionObject> => {
return await nodesStore.createCollection(
providerId,
serviceId,
currentProviderId(),
currentServiceId(),
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
{ label, owner: '' }
)
@@ -129,8 +132,8 @@ export function useFileManager(options: UseFileManagerOptions) {
}
return await nodesStore.createEntity(
providerId,
serviceId,
currentProviderId(),
currentServiceId(),
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
properties
)
@@ -138,13 +141,13 @@ export function useFileManager(options: UseFileManagerOptions) {
// Rename a node
const renameNode = async (nodeId: string, newLabel: string) => {
const node = nodesStore.getNode(providerId, serviceId, nodeId)
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
if (!node) {
throw new Error('Node not found')
}
if (node instanceof CollectionObject) {
return await nodesStore.updateCollection(providerId, serviceId, nodeId, {
return await nodesStore.updateCollection(currentProviderId(), currentServiceId(), nodeId, {
label: newLabel,
owner: node.properties.owner,
})
@@ -158,53 +161,53 @@ export function useFileManager(options: UseFileManagerOptions) {
format: node.properties.format,
encoding: node.properties.encoding,
}
return await nodesStore.updateEntity(providerId, serviceId, node.collection, nodeId, properties)
return await nodesStore.updateEntity(currentProviderId(), currentServiceId(), node.collection, nodeId, properties)
}
}
// Delete a node
const deleteNode = async (nodeId: string): Promise<boolean> => {
const node = nodesStore.getNode(providerId, serviceId, nodeId)
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
if (!node) {
throw new Error('Node not found')
}
if (node instanceof CollectionObject) {
return await nodesStore.deleteCollection(providerId, serviceId, nodeId)
return await nodesStore.deleteCollection(currentProviderId(), currentServiceId(), nodeId)
} else {
return await nodesStore.deleteEntity(providerId, serviceId, node.collection, nodeId)
return await nodesStore.deleteEntity(currentProviderId(), currentServiceId(), node.collection, nodeId)
}
}
// Read file content
const readFile = async (entityId: string): Promise<string | null> => {
const node = nodesStore.getNode(providerId, serviceId, entityId)
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.readEntity(providerId, serviceId, node.collection || ROOT_ID, entityId)
return await nodesStore.readEntity(currentProviderId(), currentServiceId(), node.collection || ROOT_ID, entityId)
}
// Write file content
const writeFile = async (entityId: string, content: string): Promise<number> => {
const node = nodesStore.getNode(providerId, serviceId, entityId)
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.writeEntity(providerId, serviceId, node.collection, entityId, content)
return await nodesStore.writeEntity(currentProviderId(), currentServiceId(), node.collection, entityId, content)
}
// Get a URL suitable for inline viewing (img src / video src)
const getEntityUrl = (entityId: string, collectionId?: string | null): string => {
const collection = collectionId ?? currentLocation.value
return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
}
// Download a single file
const downloadEntity = (entityId: string, collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value
// Use path parameters: /download/entity/{provider}/{service}/{collection}/{identifier}
const url = `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
const url = `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
// Trigger download by opening URL (browser handles it)
window.open(url, '_blank')
@@ -212,7 +215,7 @@ export function useFileManager(options: UseFileManagerOptions) {
const downloadCollection = (collectionId: string): void => {
// Use path parameters: /download/collection/{provider}/{service}/{identifier}
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collectionId)}`
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collectionId)}`
window.open(url, '_blank')
}
@@ -220,8 +223,8 @@ export function useFileManager(options: UseFileManagerOptions) {
const downloadArchive = (ids: string[], name: string = 'download', collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value
const params = new URLSearchParams({
provider: providerId,
service: serviceId,
provider: currentProviderId(),
service: currentServiceId(),
})
ids.forEach(id => params.append('ids[]', id))
if (name) {
@@ -239,7 +242,7 @@ export function useFileManager(options: UseFileManagerOptions) {
// Initialize - fetch providers, services, and initial nodes if autoFetch
const initialize = async () => {
await providersStore.list()
await servicesStore.list({ [providerId]: true })
await servicesStore.list({ [currentProviderId()]: true })
if (autoFetch) {
await refresh()
}

View File

@@ -4,7 +4,7 @@
* Supports individual files and entire folder uploads with path preservation
*/
import { ref, computed } from 'vue'
import { ref, computed, unref } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { useNodesStore, ROOT_ID } from '@DocumentsManager/stores/nodesStore'
import { EntityObject } from '@DocumentsManager/models/entity'
@@ -25,9 +25,19 @@ export interface FileWithPath {
relativePath: string
}
interface NormalizedUploadItem {
file: File
relativePath?: string
}
export interface AddUploadsWithPathsOptions {
batchSize?: number
onProgress?: (processed: number, total: number) => void
}
export interface UseFileUploadOptions {
providerId: string
serviceId: string
providerId: string | Ref<string> | ComputedRef<string>
serviceId: string | Ref<string> | ComputedRef<string>
collectionId?: string | null
maxFileSize?: number
allowedTypes?: string[]
@@ -44,6 +54,9 @@ export function useFileUpload(options: UseFileUploadOptions) {
allowedTypes
} = options
const currentProviderId = () => unref(providerId)
const currentServiceId = () => unref(serviceId)
const uploads: Ref<Map<string, FileUploadProgress>> = ref(new Map())
const isUploading = ref(false)
@@ -113,25 +126,69 @@ export function useFileUpload(options: UseFileUploadOptions) {
return `${pathPart}-${file.size}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
const createUploadProgress = (file: File, relativePath?: string): FileUploadProgress => {
const error = validateFile(file)
return {
file,
progress: 0,
status: error ? 'error' : 'pending',
error: error || undefined,
relativePath,
}
}
const yieldToBrowser = async (): Promise<void> => {
await new Promise<void>(resolve => {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => resolve())
return
}
setTimeout(resolve, 0)
})
}
const normalizeUploadsWithPaths = (
filesOrList: FileList | File[] | FileWithPath[]
): NormalizedUploadItem[] => {
if (filesOrList instanceof FileList) {
return Array.from(filesOrList, file => ({
file,
relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
}))
}
if (filesOrList.length === 0) {
return []
}
if (filesOrList[0] instanceof File) {
return (filesOrList as File[]).map(file => ({
file,
relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
}))
}
return (filesOrList as FileWithPath[]).map(({ file, relativePath }) => ({ file, relativePath }))
}
// Add files to upload queue
const addFiles = (files: FileList | File[]): FileUploadProgress[] => {
const added: FileUploadProgress[] = []
const nextUploads = new Map(uploads.value)
for (const file of files) {
const error = validateFile(file)
const progress = createUploadProgress(file)
const uploadId = generateUploadId(file)
const progress: FileUploadProgress = {
file,
progress: 0,
status: error ? 'error' : 'pending',
error: error || undefined
}
uploads.value.set(uploadId, progress)
nextUploads.set(uploadId, progress)
added.push(progress)
}
uploads.value = nextUploads
return added
}
@@ -140,47 +197,55 @@ export function useFileUpload(options: UseFileUploadOptions) {
filesOrList: FileList | File[] | FileWithPath[]
): FileUploadProgress[] => {
const added: FileUploadProgress[] = []
// Handle FileList from webkitdirectory input
if (filesOrList instanceof FileList || (Array.isArray(filesOrList) && filesOrList[0] instanceof File && !('relativePath' in filesOrList[0]))) {
const fileList = filesOrList as FileList | File[]
for (const file of fileList) {
// webkitRelativePath is set on files from folder input
const relativePath = (file as any).webkitRelativePath || file.name
const error = validateFile(file)
const nextUploads = new Map(uploads.value)
for (const { file, relativePath } of normalizeUploadsWithPaths(filesOrList)) {
const progress = createUploadProgress(file, relativePath)
const uploadId = generateUploadId(file, relativePath)
nextUploads.set(uploadId, progress)
added.push(progress)
}
uploads.value = nextUploads
return added
}
const addFilesWithPathsBatched = async (
filesOrList: FileList | File[] | FileWithPath[],
options: AddUploadsWithPathsOptions = {}
): Promise<FileUploadProgress[]> => {
const items = normalizeUploadsWithPaths(filesOrList)
const total = items.length
const batchSize = Math.max(1, options.batchSize ?? 250)
const added: FileUploadProgress[] = []
options.onProgress?.(0, total)
for (let start = 0; start < total; start += batchSize) {
const batch = items.slice(start, start + batchSize)
const nextUploads = new Map(uploads.value)
for (const { file, relativePath } of batch) {
const progress = createUploadProgress(file, relativePath)
const uploadId = generateUploadId(file, relativePath)
const progress: FileUploadProgress = {
file,
progress: 0,
status: error ? 'error' : 'pending',
error: error || undefined,
relativePath
}
uploads.value.set(uploadId, progress)
nextUploads.set(uploadId, progress)
added.push(progress)
}
} else {
// Handle FileWithPath array (from drag & drop folder processing)
const filesWithPaths = filesOrList as FileWithPath[]
for (const { file, relativePath } of filesWithPaths) {
const error = validateFile(file)
const uploadId = generateUploadId(file, relativePath)
const progress: FileUploadProgress = {
file,
progress: 0,
status: error ? 'error' : 'pending',
error: error || undefined,
relativePath
}
uploads.value.set(uploadId, progress)
added.push(progress)
uploads.value = nextUploads
const processed = Math.min(start + batch.length, total)
options.onProgress?.(processed, total)
if (processed < total) {
await yieldToBrowser()
}
}
return added
}
@@ -228,8 +293,8 @@ export function useFileUpload(options: UseFileUploadOptions) {
try {
// Create the folder
const collection = await nodesStore.createCollection(
providerId,
serviceId,
currentProviderId(),
currentServiceId(),
parentId,
{ label: folderName, owner: '' }
)
@@ -277,8 +342,8 @@ export function useFileUpload(options: UseFileUploadOptions) {
// Create the entity
const entity = await nodesStore.createEntity(
providerId,
serviceId,
currentProviderId(),
currentServiceId(),
targetCollection || ROOT_ID,
{
'@type': 'documents.properties',
@@ -295,8 +360,8 @@ export function useFileUpload(options: UseFileUploadOptions) {
// Write the content
await nodesStore.writeEntity(
providerId,
serviceId,
currentProviderId(),
currentServiceId(),
targetCollection || ROOT_ID,
String(entity.identifier),
content
@@ -417,6 +482,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
validateFile,
addFiles,
addFilesWithPaths,
addFilesWithPathsBatched,
uploadFile,
uploadAll,
removeUpload,