Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.storage / SafeStorage.js
blobe1a55052cc6d94255015b50ebb4e1191ebee195a
1 'use strict';
2 const EXPIRY_PREFIX = '_EXPIRY_';
4 /**
5  * @classdesc
6  * A wrapper for the HTML5 Storage interface (`localStorage` or `sessionStorage`)
7  * that is safe to call in all browsers.
8  *
9  * The constructor is not publicly accessible. An instance can be accessed from
10  * {@link mw.storage} or {@link module:mediawiki.storage}.
11  *
12  * @class
13  * @param {Object|undefined} store The Storage instance to wrap around
14  * @hideconstructor
15  * @memberof module:mediawiki.storage
16  * @inner
17  */
18 function SafeStorage( store ) {
19         this.store = store;
21         // Purge expired items once per page session
22         if ( !window.QUnit ) {
23                 setTimeout( () => {
24                         this.clearExpired();
25                 }, 2000 );
26         }
29 /**
30  * Retrieve value from device storage.
31  *
32  * @param {string} key Key of item to retrieve
33  * @return {string|null|boolean} String value, null if no value exists, or false
34  *  if storage is not available.
35  */
36 SafeStorage.prototype.get = function ( key ) {
37         if ( this.isExpired( key ) ) {
38                 return null;
39         }
40         try {
41                 return this.store.getItem( key );
42         } catch ( e ) {}
43         return false;
46 /**
47  * Set a value in device storage.
48  *
49  * @param {string} key Key name to store under
50  * @param {string} value Value to be stored
51  * @param {number} [expiry] Number of seconds after which this item can be deleted
52  * @return {boolean} The value was set
53  */
54 SafeStorage.prototype.set = function ( key, value, expiry ) {
55         if ( key.slice( 0, EXPIRY_PREFIX.length ) === EXPIRY_PREFIX ) {
56                 throw new Error( 'Key can\'t have a prefix of ' + EXPIRY_PREFIX );
57         }
58         // Compare to `false` instead of checking falsiness to tolerate subclasses and mocks in
59         // extensions that weren't updated to add a return value to setExpires().
60         if ( this.setExpires( key, expiry ) === false ) {
61                 // If we failed to set the expiration time, don't try to set the value,
62                 // otherwise it might end up set with no expiration.
63                 return false;
64         }
65         try {
66                 this.store.setItem( key, value );
67                 return true;
68         } catch ( e ) {}
69         return false;
72 /**
73  * Remove a value from device storage.
74  *
75  * @param {string} key Key of item to remove
76  * @return {boolean} Whether the key was removed
77  */
78 SafeStorage.prototype.remove = function ( key ) {
79         try {
80                 this.store.removeItem( key );
81                 this.setExpires( key );
82                 return true;
83         } catch ( e ) {}
84         return false;
87 /**
88  * Retrieve JSON object from device storage.
89  *
90  * @param {string} key Key of item to retrieve
91  * @return {Object|null|boolean} Object, null if no value exists or value
92  *  is not JSON-parseable, or false if storage is not available.
93  */
94 SafeStorage.prototype.getObject = function ( key ) {
95         const json = this.get( key );
97         if ( json === false ) {
98                 return false;
99         }
101         try {
102                 return JSON.parse( json );
103         } catch ( e ) {}
105         return null;
109  * Set an object value in device storage by JSON encoding.
111  * @param {string} key Key name to store under
112  * @param {Object} value Object value to be stored
113  * @param {number} [expiry] Number of seconds after which this item can be deleted
114  * @return {boolean} The value was set
115  */
116 SafeStorage.prototype.setObject = function ( key, value, expiry ) {
117         let json;
118         try {
119                 json = JSON.stringify( value );
120                 return this.set( key, json, expiry );
121         } catch ( e ) {}
122         return false;
126  * Set the expiry time for an item in the store.
128  * @param {string} key Key name
129  * @param {number} [expiry] Number of seconds after which this item can be deleted,
130  *  omit to clear the expiry (either making the item never expire, or to clean up
131  *  when deleting a key).
132  * @return {boolean} The expiry was set (or cleared) [since 1.41]
133  */
134 SafeStorage.prototype.setExpires = function ( key, expiry ) {
135         if ( expiry ) {
136                 try {
137                         this.store.setItem(
138                                 EXPIRY_PREFIX + key,
139                                 Math.floor( Date.now() / 1000 ) + expiry
140                         );
141                         return true;
142                 } catch ( e ) {}
143         } else {
144                 try {
145                         this.store.removeItem( EXPIRY_PREFIX + key );
146                         return true;
147                 } catch ( e ) {}
148         }
149         return false;
152 // Minimum amount of time (in milliseconds) for an iteration involving localStorage access.
153 const MIN_WORK_TIME = 3;
156  * Clear any expired items from the store
158  * @private
159  * @return {jQuery.Promise} Resolves when items have been expired
160  */
161 SafeStorage.prototype.clearExpired = function () {
162         return this.getExpiryKeys().then( ( keys ) => $.Deferred( ( d ) => {
163                 const iterate = ( deadline ) => {
164                         while ( keys[ 0 ] !== undefined && deadline.timeRemaining() > MIN_WORK_TIME ) {
165                                 const key = keys.shift();
166                                 if ( this.isExpired( key ) ) {
167                                         this.remove( key );
168                                 }
169                         }
170                         if ( keys[ 0 ] !== undefined ) {
171                                 // Ran out of time with keys still to remove, continue later
172                                 mw.requestIdleCallback( iterate );
173                         } else {
174                                 return d.resolve();
175                         }
176                 };
177                 mw.requestIdleCallback( iterate );
178         } ) );
182  * Get all keys with expiry values
184  * @private
185  * @return {jQuery.Promise} Promise resolving with all the keys which have
186  *  expiry values (unprefixed), or as many could be retrieved in the allocated time.
187  */
188 SafeStorage.prototype.getExpiryKeys = function () {
189         const store = this.store;
190         return $.Deferred( ( d ) => {
191                 mw.requestIdleCallback( ( deadline ) => {
192                         const prefixLength = EXPIRY_PREFIX.length;
193                         const keys = [];
194                         let length = 0;
195                         try {
196                                 length = store.length;
197                         } catch ( e ) {}
199                         // Optimization: If time runs out, degrade to checking fewer keys.
200                         // We will get another chance during a future page view. Iterate forward
201                         // so that older keys are checked first and increase likelihood of recovering
202                         // from key exhaustion.
203                         //
204                         // We don't expect to have more keys than we can handle in 50ms long-task window.
205                         // But, we might still run out of time when other tasks run before this,
206                         // or when the device receives UI events (especially on low-end devices).
207                         for ( let i = 0; ( i < length && deadline.timeRemaining() > MIN_WORK_TIME ); i++ ) {
208                                 let key = null;
209                                 try {
210                                         key = store.key( i );
211                                 } catch ( e ) {}
212                                 if ( key !== null && key.slice( 0, prefixLength ) === EXPIRY_PREFIX ) {
213                                         keys.push( key.slice( prefixLength ) );
214                                 }
215                         }
216                         d.resolve( keys );
217                 } );
218         } ).promise();
222  * Check if a given key has expired
224  * @private
225  * @param {string} key Key name
226  * @return {boolean} Whether key is expired
227  */
228 SafeStorage.prototype.isExpired = function ( key ) {
229         let expiry;
230         try {
231                 expiry = this.store.getItem( EXPIRY_PREFIX + key );
232         } catch ( e ) {
233                 return false;
234         }
235         return !!expiry && expiry < Math.floor( Date.now() / 1000 );
238 module.exports = SafeStorage;