diff --git a/src/services/officeFiles.ts b/src/services/officeFiles.ts index 6e84e58..a10e092 100644 --- a/src/services/officeFiles.ts +++ b/src/services/officeFiles.ts @@ -6,11 +6,11 @@ import type { Node } from '@nextcloud/files' import { getClient, getDavNameSpaces, getDavProperties, getRootPath, resultToNode } from '@nextcloud/files/dav' -// TODO: This DAV SEARCH is unpaginated (depth: infinity). For users with very large -// file collections the full result set is transferred over the wire before we slice it. -// MAX_DISPLAY_FILES only guards the rendered list; it does not reduce network cost. -// A proper solution requires a server-side cursor/limit API. -export const MAX_DISPLAY_FILES = 200 +// The DAV SEARCH is ordered newest-first via and capped server-side to +// MAX_DISPLAY_FILES using Nextcloud's pagination headers (X-NC-Paginate), so we only +// transfer the most recently modified results. The X-NC-Paginate-Total response header +// reports the real match count, letting the UI tell whether more files exist. +export const MAX_DISPLAY_FILES = 500 function buildOfficeMimeSearch(mimes: string[]): string { const escapeXml = (s: string) => s.replace(/&/g, '&').replace(//g, '>') @@ -37,35 +37,62 @@ function buildOfficeMimeSearch(mimes: string[]): string { ${conditions} + + + + + + + + ` } +export interface OfficeFilesResult { + nodes: Node[] + /** Total number of matching files on the server, which may exceed nodes.length. */ + total: number +} + // Single flat cache for all office files. Safe because the sole caller (fetchAll) // always passes the full union of every creator's mimes. If a partial-mime caller // is ever added this must be keyed by the mimes set. -let cachedNodes: Node[] | null = null +let cachedResult: OfficeFilesResult | null = null -export async function getAllOfficeFiles(mimes: string[]): Promise { - if (cachedNodes) { - return cachedNodes +export async function getAllOfficeFiles(mimes: string[]): Promise { + if (cachedResult) { + return cachedResult } const client = getClient() const response = await client.search('/', { details: true, data: buildOfficeMimeSearch(mimes), - }) as { data: { results: object[] } } + headers: { + 'X-NC-Paginate': 'true', + 'X-NC-Paginate-Count': String(MAX_DISPLAY_FILES), + }, + }) as { data: { results: object[] }, headers: Record } - cachedNodes = response.data.results + const nodes = response.data.results .map(item => resultToNode(item as Parameters[0])) .filter(node => node.type === 'file') - return cachedNodes + // Header keys are lowercased by the webdav client. Falls back to the number of + // returned nodes on servers that do not support pagination. + const reportedTotal = Number.parseInt(response.headers['x-nc-paginate-total'] ?? '', 10) + + cachedResult = { + nodes, + total: Number.isNaN(reportedTotal) ? nodes.length : reportedTotal, + } + + return cachedResult } export function invalidateOfficeFilesCache(): void { - cachedNodes = null + cachedResult = null } export function filterByMimes(files: Node[], mimes: string[]): Node[] { diff --git a/src/views/OfficeOverview.vue b/src/views/OfficeOverview.vue index b02c339..ec1d010 100644 --- a/src/views/OfficeOverview.vue +++ b/src/views/OfficeOverview.vue @@ -32,7 +32,7 @@ import { } from '@mdi/js' import FileCard from '../components/FileCard.vue' import TemplateSection from '../components/TemplateSection.vue' -import { getAllOfficeFiles, filterByMimes, invalidateOfficeFilesCache, MAX_DISPLAY_FILES } from '../services/officeFiles.ts' +import { getAllOfficeFiles, filterByMimes, invalidateOfficeFilesCache } from '../services/officeFiles.ts' import { getTemplates, createFromTemplate } from '../services/templates.ts' import { getOverviewGridView, setOverviewGridView } from '../services/config.ts' import type { TemplateCreator, TemplateFile, CreatedFile, OcsErrorResponse } from '../services/templates.ts' @@ -69,6 +69,7 @@ const currentUid = getCurrentUser()?.uid ?? null const creators = ref([]) const activeCreator = ref(null) const allFiles = ref([]) +const totalFiles = ref(0) const loading = ref(false) const error = ref(null) const viewMode = ref(getOverviewGridView() ? 'grid' : 'list') @@ -122,8 +123,9 @@ const filteredFiles = computed(() => { }) }) -const files = computed(() => filteredFiles.value.slice(0, MAX_DISPLAY_FILES)) -const hasMoreFiles = computed(() => filteredFiles.value.length > MAX_DISPLAY_FILES) +const files = computed(() => filteredFiles.value) +// The server caps the result set, so more files may exist than were returned. +const hasMoreFiles = computed(() => totalFiles.value > allFiles.value.length) const activeCategoryName = computed(() => activeCreator.value ? categoryName(activeCreator.value) : '', @@ -250,17 +252,15 @@ async function fetchAll() { activeCreator.value = creators.value[0] ?? null if (creators.value.length > 0) { - // Union our full static set (ODF + OOXML) with whatever the creators - // actually advertise, so we never drop a mime the server supports. - const allMimes = [...new Set([ - ...ALL_OFFICE_MIMES, - ...creators.value.flatMap(c => c.mimetypes), - ])] - allFiles.value = await getAllOfficeFiles(allMimes) + const allMimes = creators.value.flatMap(c => c.mimetypes) + const result = await getAllOfficeFiles(allMimes) + allFiles.value = result.nodes + totalFiles.value = result.total } } catch { error.value = t('office', 'Failed to load files') allFiles.value = [] + totalFiles.value = 0 } finally { loading.value = false }