33 * SPDX-License-Identifier: AGPL-3.0-or-later
44 */
55
6- import type { Folder , Node } from '@nextcloud/files'
6+ import type { IFolder , INode , NodeData } from '@nextcloud/files'
77import type { FileSource , FilesState , FilesStore , RootOptions , RootsStore , Service } from '../types.ts'
88
99import { subscribe } from '@nextcloud/event-bus'
10+ import { File , Folder } from '@nextcloud/files'
1011import { defineStore } from 'pinia'
1112import Vue from 'vue'
1213import logger from '../logger.ts'
1314import { fetchNode } from '../services/WebdavClient.ts'
1415import { usePathsStore } from './paths.ts'
1516
17+ const DB_NAME = 'nextcloud_files_store'
18+ const DB_VERSION = 1
19+ const STORE_FILES = 'files'
20+ const STORE_ROOTS = 'roots'
21+
22+ /**
23+ * Lazily opened IndexedDB connection, shared across all store instances.
24+ * IndexedDB can store significantly more data than localStorage and supports
25+ * storing millions of individual file records efficiently.
26+ */
27+ const dbPromise : Promise < IDBDatabase > = new Promise ( ( resolve , reject ) => {
28+ if ( typeof indexedDB === 'undefined' ) {
29+ reject ( new Error ( 'IndexedDB is not available' ) )
30+ return
31+ }
32+ const request = indexedDB . open ( DB_NAME , DB_VERSION )
33+ request . onupgradeneeded = ( event ) => {
34+ const db = ( event . target as IDBOpenDBRequest ) . result
35+ if ( ! db . objectStoreNames . contains ( STORE_FILES ) ) {
36+ db . createObjectStore ( STORE_FILES )
37+ }
38+ if ( ! db . objectStoreNames . contains ( STORE_ROOTS ) ) {
39+ db . createObjectStore ( STORE_ROOTS )
40+ }
41+ }
42+ request . onsuccess = ( ) => resolve ( request . result )
43+ request . onerror = ( ) => reject ( request . error )
44+ } )
45+
46+ /**
47+ * Deserialize a stored JSON string back into a File or Folder instance.
48+ *
49+ * @param nodeJson JSON string of a serialized node
50+ */
51+ function deserializeNode ( nodeJson : string ) : INode | undefined {
52+ try {
53+ const node = JSON . parse ( nodeJson ) as [ NodeData , RegExp ]
54+ if ( node [ 0 ] . mime === 'httpd/unix-directory' ) {
55+ return new Folder ( ...node )
56+ }
57+ return new File ( ...node )
58+ } catch {
59+ return undefined
60+ }
61+ }
62+
63+ /**
64+ * Restore all entries from an IndexedDB object store into a keyed map of INode instances.
65+ *
66+ * @param db Open IDBDatabase
67+ * @param storeName Name of the object store to read
68+ */
69+ function restoreFromStore ( db : IDBDatabase , storeName : string ) : Promise < Record < string , INode > > {
70+ return new Promise ( ( resolve ) => {
71+ const result : Record < string , INode > = { }
72+ const tx = db . transaction ( storeName , 'readonly' )
73+ const objectStore = tx . objectStore ( storeName )
74+ const keysRequest = objectStore . getAllKeys ( )
75+ const valuesRequest = objectStore . getAll ( )
76+
77+ tx . oncomplete = ( ) => {
78+ const keys = keysRequest . result as string [ ]
79+ const values = valuesRequest . result as string [ ]
80+ keys . forEach ( ( key , index ) => {
81+ const raw = values [ index ]
82+ const node = raw !== undefined ? deserializeNode ( raw ) : undefined
83+ if ( node ) {
84+ result [ key ] = node
85+ }
86+ } )
87+ resolve ( result )
88+ }
89+ tx . onerror = ( ) => resolve ( result )
90+ } )
91+ }
92+
1693/**
1794 *
1895 * @param args
@@ -30,17 +107,17 @@ export function useFilesStore(...args) {
30107 *
31108 * @param state
32109 */
33- getNode : ( state ) => ( source : FileSource ) : Node | undefined => state . files [ source ] ,
110+ getNode : ( state ) => ( source : FileSource ) : INode | undefined => state . files [ source ] ,
34111
35112 /**
36113 * Get a list of files or folders by their IDs
37114 * Note: does not return undefined values
38115 *
39116 * @param state
40117 */
41- getNodes : ( state ) => ( sources : FileSource [ ] ) : Node [ ] => sources
118+ getNodes : ( state ) => ( sources : FileSource [ ] ) : INode [ ] => sources
42119 . map ( ( source ) => state . files [ source ] )
43- . filter ( Boolean ) ,
120+ . filter ( Boolean ) as INode [ ] ,
44121
45122 /**
46123 * Get files or folders by their file ID
@@ -49,14 +126,14 @@ export function useFilesStore(...args) {
49126 *
50127 * @param state
51128 */
52- getNodesById : ( state ) => ( fileId : number ) : Node [ ] => Object . values ( state . files ) . filter ( ( node ) => node . fileid === fileId ) ,
129+ getNodesById : ( state ) => ( fileId : string ) : INode [ ] => Object . values ( state . files ) . filter ( ( node ) => node . id === fileId ) ,
53130
54131 /**
55132 * Get the root folder of a service
56133 *
57134 * @param state
58135 */
59- getRoot : ( state ) => ( service : Service ) : Folder | undefined => state . roots [ service ] ,
136+ getRoot : ( state ) => ( service : Service ) : IFolder | undefined => state . roots [ service ] ,
60137 } ,
61138
62139 actions : {
@@ -67,17 +144,17 @@ export function useFilesStore(...args) {
67144 * @param path - The path relative within the service
68145 * @return The folder if found
69146 */
70- getDirectoryByPath ( service : string , path ?: string ) : Folder | undefined {
147+ getDirectoryByPath ( service : string , path ?: string ) : IFolder | undefined {
71148 const pathsStore = usePathsStore ( )
72- let folder : Folder | undefined
149+ let folder : IFolder | undefined
73150
74151 // Get the containing folder from path store
75152 if ( ! path || path === '/' ) {
76153 folder = this . getRoot ( service )
77154 } else {
78155 const source = pathsStore . getPath ( service , path )
79156 if ( source ) {
80- folder = this . getNode ( source ) as Folder | undefined
157+ folder = this . getNode ( source ) as IFolder | undefined
81158 }
82159 }
83160
@@ -91,20 +168,20 @@ export function useFilesStore(...args) {
91168 * @param path - The path relative within the service
92169 * @return Array of cached nodes within the path
93170 */
94- getNodesByPath ( service : string , path ?: string ) : Node [ ] {
171+ getNodesByPath ( service : string , path ?: string ) : INode [ ] {
95172 const folder = this . getDirectoryByPath ( service , path )
96173
97174 // If we found a cache entry and the cache entry was already loaded (has children) then use it
98- return ( folder ?. _children ?? [ ] )
175+ return ( folder ?. attributes . _children ?? [ ] )
99176 . map ( ( source : string ) => this . getNode ( source ) )
100177 . filter ( Boolean )
101178 } ,
102179
103- updateNodes ( nodes : Node [ ] ) {
180+ updateNodes ( nodes : INode [ ] ) {
104181 // Update the store all at once
105182 const files = nodes . reduce ( ( acc , node ) => {
106- if ( ! node . fileid ) {
107- logger . error ( 'Trying to update/set a node without fileid ' , { node } )
183+ if ( ! node . id ) {
184+ logger . error ( 'Trying to update/set a node without id ' , { node } )
108185 return acc
109186 }
110187
@@ -113,55 +190,87 @@ export function useFilesStore(...args) {
113190 } , { } as FilesStore )
114191
115192 Vue . set ( this , 'files' , { ...this . files , ...files } )
193+
194+ // Persist new/updated nodes individually to IndexedDB
195+ dbPromise . then ( ( db ) => {
196+ const tx = db . transaction ( STORE_FILES , 'readwrite' )
197+ const objectStore = tx . objectStore ( STORE_FILES )
198+ for ( const [ source , node ] of Object . entries ( files ) ) {
199+ objectStore . put ( node . toJSON ( ) , source )
200+ }
201+ } ) . catch ( ( e ) => logger . error ( 'Failed to persist nodes to IndexedDB' , { error : e } ) )
116202 } ,
117203
118- deleteNodes ( nodes : Node [ ] ) {
204+ deleteNodes ( nodes : INode [ ] ) {
119205 nodes . forEach ( ( node ) => {
120206 if ( node . source ) {
121207 Vue . delete ( this . files , node . source )
122208 }
123209 } )
210+
211+ // Remove deleted nodes from IndexedDB
212+ dbPromise . then ( ( db ) => {
213+ const tx = db . transaction ( STORE_FILES , 'readwrite' )
214+ const objectStore = tx . objectStore ( STORE_FILES )
215+ for ( const node of nodes ) {
216+ if ( node . source ) {
217+ objectStore . delete ( node . source )
218+ }
219+ }
220+ } ) . catch ( ( e ) => logger . error ( 'Failed to delete nodes from IndexedDB' , { error : e } ) )
124221 } ,
125222
126223 setRoot ( { service, root } : RootOptions ) {
127224 Vue . set ( this . roots , service , root )
225+
226+ // Persist the root folder to IndexedDB
227+ dbPromise . then ( ( db ) => {
228+ const tx = db . transaction ( STORE_ROOTS , 'readwrite' )
229+ tx . objectStore ( STORE_ROOTS ) . put ( root . toJSON ( ) , service )
230+ } ) . catch ( ( e ) => logger . error ( 'Failed to persist root to IndexedDB' , { error : e } ) )
128231 } ,
129232
130- onDeletedNode ( node : Node ) {
233+ onDeletedNode ( node : INode ) {
131234 this . deleteNodes ( [ node ] )
132235 } ,
133236
134- onCreatedNode ( node : Node ) {
237+ onCreatedNode ( node : INode ) {
135238 this . updateNodes ( [ node ] )
136239 } ,
137240
138- onMovedNode ( { node, oldSource } : { node : Node , oldSource : string } ) {
139- if ( ! node . fileid ) {
140- logger . error ( 'Trying to update/set a node without fileid ' , { node } )
241+ onMovedNode ( { node, oldSource } : { node : INode , oldSource : string } ) {
242+ if ( ! node . id ) {
243+ logger . error ( 'Trying to update/set a node without id ' , { node } )
141244 return
142245 }
143246
247+ // Remove the old source key from IndexedDB before writing the new one
248+ dbPromise . then ( ( db ) => {
249+ const tx = db . transaction ( STORE_FILES , 'readwrite' )
250+ tx . objectStore ( STORE_FILES ) . delete ( oldSource )
251+ } ) . catch ( ( e ) => logger . error ( 'Failed to delete moved node from IndexedDB' , { error : e } ) )
252+
144253 // Update the path of the node
145254 Vue . delete ( this . files , oldSource )
146255 this . updateNodes ( [ node ] )
147256 } ,
148257
149- async onUpdatedNode ( node : Node ) {
150- if ( ! node . fileid ) {
151- logger . error ( 'Trying to update/set a node without fileid ' , { node } )
258+ async onUpdatedNode ( node : INode ) {
259+ if ( ! node . id ) {
260+ logger . error ( 'Trying to update/set a node without id ' , { node } )
152261 return
153262 }
154263
155264 // If we have multiple nodes with the same file ID, we need to update all of them
156- const nodes = this . getNodesById ( node . fileid )
265+ const nodes = this . getNodesById ( node . id )
157266 if ( nodes . length > 1 ) {
158267 await Promise . all ( nodes . map ( ( node ) => fetchNode ( node . path ) ) ) . then ( this . updateNodes )
159- logger . debug ( nodes . length + ' nodes updated in store' , { fileid : node . fileid } )
268+ logger . debug ( nodes . length + ' nodes updated in store' , { id : node . id } )
160269 return
161270 }
162271
163272 // If we have only one node with the file ID, we can update it directly
164- if ( nodes . length === 1 && node . source === nodes [ 0 ] . source ) {
273+ if ( nodes . length === 1 && nodes [ 0 ] && node . source === nodes [ 0 ] . source ) {
165274 this . updateNodes ( [ node ] )
166275 return
167276 }
@@ -171,17 +280,27 @@ export function useFilesStore(...args) {
171280 } ,
172281
173282 // Handlers for legacy sidebar (no real nodes support)
174- onAddFavorite ( node : Node ) {
283+ onAddFavorite ( node : INode ) {
175284 const ourNode = this . getNode ( node . source )
176285 if ( ourNode ) {
177286 Vue . set ( ourNode . attributes , 'favorite' , 1 )
287+ // Persist the updated node to IndexedDB
288+ dbPromise . then ( ( db ) => {
289+ const tx = db . transaction ( STORE_FILES , 'readwrite' )
290+ tx . objectStore ( STORE_FILES ) . put ( ourNode . toJSON ( ) , ourNode . source )
291+ } ) . catch ( ( e ) => logger . error ( 'Failed to update favorite node in IndexedDB' , { error : e } ) )
178292 }
179293 } ,
180294
181- onRemoveFavorite ( node : Node ) {
295+ onRemoveFavorite ( node : INode ) {
182296 const ourNode = this . getNode ( node . source )
183297 if ( ourNode ) {
184298 Vue . set ( ourNode . attributes , 'favorite' , 0 )
299+ // Persist the updated node to IndexedDB
300+ dbPromise . then ( ( db ) => {
301+ const tx = db . transaction ( STORE_FILES , 'readwrite' )
302+ tx . objectStore ( STORE_FILES ) . put ( ourNode . toJSON ( ) , ourNode . source )
303+ } ) . catch ( ( e ) => logger . error ( 'Failed to update favorite node in IndexedDB' , { error : e } ) )
185304 }
186305 } ,
187306 } ,
@@ -198,6 +317,21 @@ export function useFilesStore(...args) {
198317 subscribe ( 'files:favorites:added' , fileStore . onAddFavorite )
199318 subscribe ( 'files:favorites:removed' , fileStore . onRemoveFavorite )
200319
320+ // Restore state from IndexedDB asynchronously.
321+ // Each node is stored as an individual record, so this scales to millions of files.
322+ dbPromise . then ( async ( db ) => {
323+ const [ files , roots ] = await Promise . all ( [
324+ restoreFromStore ( db , STORE_FILES ) ,
325+ restoreFromStore ( db , STORE_ROOTS ) ,
326+ ] )
327+ fileStore . $state . files = files as FilesStore
328+ fileStore . $state . roots = roots as RootsStore
329+ logger . info ( 'Restored files store from IndexedDB' , {
330+ files : Object . keys ( files ) . length ,
331+ roots : Object . keys ( roots ) . length ,
332+ } )
333+ } ) . catch ( ( e ) => logger . info ( 'Failed to restore files store from IndexedDB' , { error : e } ) )
334+
201335 fileStore . _initialized = true
202336 }
203337
0 commit comments