[FEATURE] Store icons in localStorage 21/56721/7
authorAndreas Fernandez <a.fernandez@scripting-base.de>
Wed, 18 Apr 2018 08:03:59 +0000 (10:03 +0200)
committerFrank Naegler <frank.naegler@typo3.org>
Thu, 19 Apr 2018 13:01:43 +0000 (15:01 +0200)
The icons that get requested by the Icon API on JavaScript side are not
stored in the client's localStorage.
To have a proper invalidation, a hash of the IconRegistry is built and
stored in the localStorage, too. If the hash changes, all icons in the
localStorage get flushed.

To achieve this, the Storage/Client module is extended to allow removing
values by a given prefix.

Resolves: #84780
Releases: master
Change-Id: Ic2137b05530201a8a94a7ea6c28ae1a012206221
Reviewed-on: https://review.typo3.org/56721
Reviewed-by: Mathias Schreiber <mathias.schreiber@typo3.com>
Tested-by: Mathias Schreiber <mathias.schreiber@typo3.com>
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Kay Strobach <typo3@kay-strobach.de>
Tested-by: Kay Strobach <typo3@kay-strobach.de>
Reviewed-by: Frank Naegler <frank.naegler@typo3.org>
Tested-by: Frank Naegler <frank.naegler@typo3.org>
typo3/sysext/backend/Configuration/Backend/AjaxRoutes.php
typo3/sysext/backend/Resources/Private/TypeScript/Icons.ts
typo3/sysext/backend/Resources/Private/TypeScript/Storage/Client.ts
typo3/sysext/backend/Resources/Public/JavaScript/Icons.js
typo3/sysext/backend/Resources/Public/JavaScript/Storage/Client.js
typo3/sysext/backend/Tests/JavaScript/IconsTest.js
typo3/sysext/core/Classes/Controller/IconController.php
typo3/sysext/core/Classes/Imaging/IconRegistry.php
typo3/sysext/core/Documentation/Changelog/master/Feature-84780-RemoveEntriesInLocalStorageByKeyPrefix.rst [new file with mode: 0644]
typo3/sysext/core/Documentation/Changelog/master/Feature-84780-StoreIconsFetchedByTheIconAPIInLocalStorage.rst [new file with mode: 0644]

index 002a36a..bfe2e5e 100644 (file)
@@ -234,6 +234,12 @@ return [
         'target' => \TYPO3\CMS\Core\Controller\IconController::class . '::getIcon'
     ],
 
+    // Get icon cache identifier
+    'icons_cache' => [
+        'path' => '/icons/cache',
+        'target' => \TYPO3\CMS\Core\Controller\IconController::class . '::getCacheIdentifier'
+    ],
+
     // Encode typolink parts on demand
     'link_browser_encodetypolink' => [
         'path' => '/link-browser/encode-typolink',
index e9e4f07..8568963 100644 (file)
@@ -12,6 +12,7 @@
  */
 
 import * as $ from 'jquery';
+import ClientStorage = require('./Storage/Client');
 
 enum Sizes {
   small = 'small',
@@ -30,7 +31,7 @@ enum MarkupIdentifiers {
   inline = 'inline'
 }
 
-interface Cache {
+interface PromiseCache {
   [key: string]: JQueryPromise<any>;
 }
 
@@ -42,7 +43,7 @@ class Icons {
   public readonly sizes: any = Sizes;
   public readonly states: any = States;
   public readonly markupIdentifiers: any = MarkupIdentifiers;
-  private readonly cache: Cache = {};
+  private readonly promiseCache: PromiseCache = {};
 
   /**
    * Get the icon by its identifier
@@ -59,24 +60,7 @@ class Icons {
                  overlayIdentifier?: string,
                  state?: string,
                  markupIdentifier?: MarkupIdentifiers): JQueryPromise<any> {
-    return $.when(this.fetch(identifier, size, overlayIdentifier, state, markupIdentifier));
-  }
 
-  /**
-   * Performs the AJAX request to fetch the icon
-   *
-   * @param {string} identifier
-   * @param {Sizes} size
-   * @param {string} overlayIdentifier
-   * @param {string} state
-   * @param {MarkupIdentifiers} markupIdentifier
-   * @returns {JQueryPromise<any>}
-   */
-  public fetch(identifier: string,
-               size: Sizes,
-               overlayIdentifier: string,
-               state: string,
-               markupIdentifier: MarkupIdentifiers): JQueryPromise<any> {
     /**
      * Icon keys:
      *
@@ -90,22 +74,76 @@ class Icons {
     state = state || States.default;
     markupIdentifier = markupIdentifier || MarkupIdentifiers.default;
 
-    const icon = [identifier, size, overlayIdentifier, state, markupIdentifier];
-    const cacheIdentifier = icon.join('_');
+    const describedIcon = [identifier, size, overlayIdentifier, state, markupIdentifier];
+    const cacheIdentifier = describedIcon.join('_');
+
+    return $.when(this.getIconRegistryCache()).pipe((registryCacheIdentifier: string): any => {
+      if (!ClientStorage.isset('icon_registry_cache_identifier')
+        || ClientStorage.get('icon_registry_cache_identifier') !== registryCacheIdentifier
+      ) {
+        ClientStorage.unsetByPrefix('icon_');
+        ClientStorage.set('icon_registry_cache_identifier', registryCacheIdentifier);
+      }
+
+      return this.fetchFromLocal(cacheIdentifier).then(null, (): any => {
+        return this.fetchFromRemote(describedIcon, cacheIdentifier);
+      });
+    });
+  }
+
+  private getIconRegistryCache(): JQueryPromise<any> {
+    const promiseCacheIdentifier = 'icon_registry_cache_identifier';
+
+    if (!this.isPromiseCached(promiseCacheIdentifier)) {
+      this.putInPromiseCache(promiseCacheIdentifier, $.ajax({
+        url: TYPO3.settings.ajaxUrls.icons_cache,
+        success: (response: string): string => {
+          return response;
+        }
+      }));
+    }
+
+    return this.getFromPromiseCache(promiseCacheIdentifier);
+  }
 
-    if (!this.isCached(cacheIdentifier)) {
-      this.putInCache(cacheIdentifier, $.ajax({
+  /**
+   * Performs the AJAX request to fetch the icon
+   *
+   * @param {Array<string>} icon
+   * @param {string} cacheIdentifier
+   * @returns {JQueryPromise<any>}
+   */
+  private fetchFromRemote(icon: Array<string>, cacheIdentifier: string): JQueryPromise<any> {
+    if (!this.isPromiseCached(cacheIdentifier)) {
+      this.putInPromiseCache(cacheIdentifier, $.ajax({
         url: TYPO3.settings.ajaxUrls.icons,
         dataType: 'html',
         data: {
           icon: JSON.stringify(icon)
         },
         success: (markup: string) => {
+          ClientStorage.set('icon_' + cacheIdentifier, markup);
           return markup;
         }
-      }).promise());
+      }));
     }
-    return this.getFromCache(cacheIdentifier).done();
+    return this.getFromPromiseCache(cacheIdentifier);
+  }
+
+  /**
+   * Gets the icon from localStorage
+   * @param {string} cacheIdentifier
+   * @returns {JQueryPromise<any>}
+   */
+  private fetchFromLocal(cacheIdentifier: string): JQueryPromise<any> {
+    const deferred = $.Deferred();
+    if (ClientStorage.isset('icon_' + cacheIdentifier)) {
+      deferred.resolve(ClientStorage.get('icon_' + cacheIdentifier));
+    } else {
+      deferred.reject();
+    }
+
+    return deferred.promise();
   }
 
   /**
@@ -114,8 +152,8 @@ class Icons {
    * @param {string} cacheIdentifier
    * @returns {boolean}
    */
-  private isCached(cacheIdentifier: string): boolean {
-    return typeof this.cache[cacheIdentifier] !== 'undefined';
+  private isPromiseCached(cacheIdentifier: string): boolean {
+    return typeof this.promiseCache[cacheIdentifier] !== 'undefined';
   }
 
   /**
@@ -124,8 +162,8 @@ class Icons {
    * @param {string} cacheIdentifier
    * @returns {JQueryPromise<any>}
    */
-  private getFromCache(cacheIdentifier: string): JQueryPromise<any> {
-    return this.cache[cacheIdentifier];
+  private getFromPromiseCache(cacheIdentifier: string): JQueryPromise<any> {
+    return this.promiseCache[cacheIdentifier];
   }
 
   /**
@@ -134,34 +172,12 @@ class Icons {
    * @param {string} cacheIdentifier
    * @param {JQueryPromise<any>} markup
    */
-  private putInCache(cacheIdentifier: string, markup: JQueryPromise<any>): void {
-    this.cache[cacheIdentifier] = markup;
+  private putInPromiseCache(cacheIdentifier: string, markup: JQueryPromise<any>): void {
+    this.promiseCache[cacheIdentifier] = markup;
   }
 }
 
 let iconsObject: Icons;
-try {
-  // fetch from opening window
-  if (window.opener && window.opener.TYPO3 && window.opener.TYPO3.Icons) {
-    iconsObject = window.opener.TYPO3.Icons;
-  }
-
-  // fetch from parent
-  if (parent && parent.window.TYPO3 && parent.window.TYPO3.Icons) {
-    iconsObject = parent.window.TYPO3.Icons;
-  }
-
-  // fetch object from outer frame
-  if (top && top.TYPO3.Icons) {
-    iconsObject = top.TYPO3.Icons;
-  }
-} catch (e) {
-  // This only happens if the opener, parent or top is some other url (eg a local file)
-  // which loaded the current window. Then the browser's cross domain policy jumps in
-  // and raises an exception.
-  // For this case we are safe and we can create our global object below.
-}
-
 if (!iconsObject) {
   iconsObject = new Icons();
   TYPO3.Icons = iconsObject;
index 15c3419..9bf3d94 100644 (file)
  * @exports TYPO3/CMS/Backend/Storage/Client
  */
 class Client {
+  private keyPrefix: string = 't3-';
+
   /**
    * Simple localStorage wrapper, to get value from localStorage
    * @param {string} key
    * @returns {string}
    */
   public get = (key: string): string => {
-    return localStorage.getItem('t3-' + key);
+    return localStorage.getItem(this.keyPrefix + key);
   }
 
   /**
@@ -34,7 +36,7 @@ class Client {
    * @returns {string}
    */
   public set = (key: string, value: string): void => {
-    localStorage.setItem('t3-' + key, value);
+    localStorage.setItem(this.keyPrefix + key, value);
   }
 
   /**
@@ -43,7 +45,31 @@ class Client {
    * @param {string} key
    */
   public unset = (key: string): void => {
-    localStorage.removeItem('t3-' + key);
+    localStorage.removeItem(this.keyPrefix + key);
+  }
+
+  /**
+   * Removes values from localStorage by a specific prefix of the key
+   *
+   * @param {string} prefix
+   */
+  public unsetByPrefix = (prefix: string): void => {
+    prefix = this.keyPrefix + prefix;
+
+    const keysToDelete: Array<string> = [];
+    for (let i = 0; i < localStorage.length; ++i) {
+      if (localStorage.key(i).substring(0, prefix.length) === prefix) {
+        // Remove the global key prefix, as it gets prepended in unset again
+        const key = localStorage.key(i).substr(this.keyPrefix.length);
+
+        // We can't delete the key here as this interferes with the size of the localStorage
+        keysToDelete.push(key);
+      }
+    }
+
+    for (let key of keysToDelete) {
+      this.unset(key);
+    }
   }
 
   /**
index 4fefb92..b162f2a 100644 (file)
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-define(["require","exports","jquery"],function(e,t,n){"use strict";var o,i,r,a,s,c;(i=o||(o={})).small="small",i.default="default",i.large="large",i.overlay="overlay",(a=r||(r={})).default="default",a.disabled="disabled",(c=s||(s={})).default="default",c.inline="inline";var u,d=function(){function e(){this.sizes=o,this.states=r,this.markupIdentifiers=s,this.cache={}}return e.prototype.getIcon=function(e,t,o,i,r){return n.when(this.fetch(e,t,o,i,r))},e.prototype.fetch=function(e,t,i,a,c){var u=[e,t=t||o.default,i,a=a||r.default,c=c||s.default],d=u.join("_");return this.isCached(d)||this.putInCache(d,n.ajax({url:TYPO3.settings.ajaxUrls.icons,dataType:"html",data:{icon:JSON.stringify(u)},success:function(e){return e}}).promise()),this.getFromCache(d).done()},e.prototype.isCached=function(e){return void 0!==this.cache[e]},e.prototype.getFromCache=function(e){return this.cache[e]},e.prototype.putInCache=function(e,t){this.cache[e]=t},e}();try{window.opener&&window.opener.TYPO3&&window.opener.TYPO3.Icons&&(u=window.opener.TYPO3.Icons),parent&&parent.window.TYPO3&&parent.window.TYPO3.Icons&&(u=parent.window.TYPO3.Icons),top&&top.TYPO3.Icons&&(u=top.TYPO3.Icons)}catch(e){}return u||(u=new d,TYPO3.Icons=u),u});
\ No newline at end of file
+define(["require","exports","jquery","./Storage/Client"],function(e,t,i,r){"use strict";var n,o,s,c,a,u;(o=n||(n={})).small="small",o.default="default",o.large="large",o.overlay="overlay",(c=s||(s={})).default="default",c.disabled="disabled",(u=a||(a={})).default="default",u.inline="inline";var h,f=function(){function e(){this.sizes=n,this.states=s,this.markupIdentifiers=a,this.promiseCache={}}return e.prototype.getIcon=function(e,t,o,c,u){var h=this,f=[e,t=t||n.default,o,c=c||s.default,u=u||a.default],l=f.join("_");return i.when(this.getIconRegistryCache()).pipe(function(e){return r.isset("icon_registry_cache_identifier")&&r.get("icon_registry_cache_identifier")===e||(r.unsetByPrefix("icon_"),r.set("icon_registry_cache_identifier",e)),h.fetchFromLocal(l).then(null,function(){return h.fetchFromRemote(f,l)})})},e.prototype.getIconRegistryCache=function(){var e="icon_registry_cache_identifier";return this.isPromiseCached(e)||this.putInPromiseCache(e,i.ajax({url:TYPO3.settings.ajaxUrls.icons_cache,success:function(e){return e}})),this.getFromPromiseCache(e)},e.prototype.fetchFromRemote=function(e,t){return this.isPromiseCached(t)||this.putInPromiseCache(t,i.ajax({url:TYPO3.settings.ajaxUrls.icons,dataType:"html",data:{icon:JSON.stringify(e)},success:function(e){return r.set("icon_"+t,e),e}})),this.getFromPromiseCache(t)},e.prototype.fetchFromLocal=function(e){var t=i.Deferred();return r.isset("icon_"+e)?t.resolve(r.get("icon_"+e)):t.reject(),t.promise()},e.prototype.isPromiseCached=function(e){return void 0!==this.promiseCache[e]},e.prototype.getFromPromiseCache=function(e){return this.promiseCache[e]},e.prototype.putInPromiseCache=function(e,t){this.promiseCache[e]=t},e}();return h||(h=new f,TYPO3.Icons=h),h});
\ No newline at end of file
index bc10c9e..a0d7e98 100644 (file)
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-define(["require","exports"],function(t,e){"use strict";return new function(){var t=this;this.get=function(t){return localStorage.getItem("t3-"+t)},this.set=function(t,e){localStorage.setItem("t3-"+t,e)},this.unset=function(t){localStorage.removeItem("t3-"+t)},this.clear=function(){localStorage.clear()},this.isset=function(e){var n=t.get(e);return null!=n}}});
\ No newline at end of file
+var __values=this&&this.__values||function(e){var t="function"==typeof Symbol&&e[Symbol.iterator],r=0;return t?t.call(e):{next:function(){return e&&r>=e.length&&(e=void 0),{value:e&&e[r++],done:!e}}}};define(["require","exports"],function(e,t){"use strict";return new function(){var e=this;this.keyPrefix="t3-",this.get=function(t){return localStorage.getItem(e.keyPrefix+t)},this.set=function(t,r){localStorage.setItem(e.keyPrefix+t,r)},this.unset=function(t){localStorage.removeItem(e.keyPrefix+t)},this.unsetByPrefix=function(t){t=e.keyPrefix+t;for(var r,n,o=[],i=0;i<localStorage.length;++i)if(localStorage.key(i).substring(0,t.length)===t){var l=localStorage.key(i).substr(e.keyPrefix.length);o.push(l)}try{for(var a=__values(o),u=a.next();!u.done;u=a.next())l=u.value,e.unset(l)}catch(e){r={error:e}}finally{try{u&&!u.done&&(n=a.return)&&n.call(a)}finally{if(r)throw r.error}}},this.clear=function(){localStorage.clear()},this.isset=function(t){var r=e.get(t);return null!=r}}});
\ No newline at end of file
index cc0efde..f301a97 100644 (file)
@@ -45,29 +45,29 @@ define(['jquery', 'TYPO3/CMS/Backend/Icons'], function($, Icons) {
      */
     describe('tests for Icons::putInCache', function() {
       it('works for simply identifier and markup', function() {
-        Icons.putInCache('foo', 'bar');
-        expect(Icons.cache['foo']).toBe('bar');
+        Icons.putInPromiseCache('foo', 'bar');
+        expect(Icons.promiseCache['foo']).toBe('bar');
       });
     });
 
     /**
      * @test
      */
-    describe('tests for Icons::getFromCache', function() {
-      it('return undefined for uncached icon', function() {
-        expect(Icons.getFromCache('bar')).not.toBeDefined();
+    describe('tests for Icons::getFromPromiseCache', function() {
+      it('return undefined for uncached promise', function() {
+        expect(Icons.getFromPromiseCache('bar')).not.toBeDefined();
       });
     });
 
     /**
      * @test
      */
-    describe('tests for Icons::isCached', function() {
-      it('return true for cached icon', function() {
-        expect(Icons.isCached('foo')).toBe(true);
+    describe('tests for Icons::isPromiseCached', function() {
+      it('return true for cached promise', function() {
+        expect(Icons.isPromiseCached('foo')).toBe(true);
       });
-      it('return false for uncached icon', function() {
-        expect(Icons.isCached('bar')).toBe(false);
+      it('return false for uncached promise', function() {
+        expect(Icons.isPromiseCached('bar')).toBe(false);
       });
     });
   });
index acdc1eb..2f0630a 100644 (file)
@@ -19,15 +19,23 @@ use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use TYPO3\CMS\Core\Http\HtmlResponse;
 use TYPO3\CMS\Core\Imaging\IconFactory;
+use TYPO3\CMS\Core\Imaging\IconRegistry;
 use TYPO3\CMS\Core\Type\Icon\IconState;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
  * Controller for icon handling
+ *
+ * @internal
  */
 class IconController
 {
     /**
+     * @var IconRegistry
+     */
+    protected $iconRegistry;
+
+    /**
      * @var IconFactory
      */
     protected $iconFactory;
@@ -37,10 +45,20 @@ class IconController
      */
     public function __construct()
     {
+        $this->iconRegistry = GeneralUtility::makeInstance(IconRegistry::class);
         $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
     }
 
     /**
+     * @return ResponseInterface
+     * @internal
+     */
+    public function getCacheIdentifier(): ResponseInterface
+    {
+        return new HtmlResponse($this->iconRegistry->getCacheIdentifier());
+    }
+
+    /**
      * @param ServerRequestInterface $request
      * @return ResponseInterface
      * @internal
index 230dbfa..625bbf2 100644 (file)
@@ -763,6 +763,21 @@ class IconRegistry implements SingletonInterface
     }
 
     /**
+     * Calculates the cache identifier based on the current registry
+     *
+     * @return string
+     * @internal
+     */
+    public function getCacheIdentifier(): string
+    {
+        if (!$this->fullInitialized) {
+            $this->initialize();
+        }
+
+        return sha1(json_encode($this->icons));
+    }
+
+    /**
      * Load icons from TCA for each table and add them as "tcarecords-XX" to $this->icons
      */
     protected function registerTCAIcons()
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-84780-RemoveEntriesInLocalStorageByKeyPrefix.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-84780-RemoveEntriesInLocalStorageByKeyPrefix.rst
new file mode 100644 (file)
index 0000000..3c4655e
--- /dev/null
@@ -0,0 +1,31 @@
+.. include:: ../../Includes.txt
+
+==============================================================
+Feature: #84780 - Remove entries in localStorage by key prefix
+==============================================================
+
+See :issue:`84780`
+
+Description
+===========
+
+The localStorage wrapper :js:`TYPO3/CMS/Backend/Storage/Client` is now capable of removing entries in the localStorage
+by a specific key prefix.
+
+
+Impact
+======
+
+The method :js:`Client.unsetByPrefix()` takes the prefix as argument. As all keys are internally namespaced with a `t3-`
+prefix, this must be omitted in the requested prefix.
+
+Example:
+
+.. code-block:: javascript
+
+       function foo() {
+           // Removes any localStorage entry whose key starts with "t3-bar-"
+           Client.unsetByPrefix('bar-');
+       }
+
+.. index:: Backend, JavaScript, ext:backend
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-84780-StoreIconsFetchedByTheIconAPIInLocalStorage.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-84780-StoreIconsFetchedByTheIconAPIInLocalStorage.rst
new file mode 100644 (file)
index 0000000..d0d7c0c
--- /dev/null
@@ -0,0 +1,16 @@
+.. include:: ../../Includes.txt
+
+=====================================================================
+Feature: #84780 - Store icons fetched by the Icon API in localStorage
+=====================================================================
+
+See :issue:`84780`
+
+Description
+===========
+
+Icons that get fetched by the JavaScript-based Icon API are now stored in the localStorage of the client.
+A hash is calculated based on the state of the IconRegistry and stored in the localStorage as well to determine whether
+the icon markup need to get refetched. from the server
+
+.. index:: Backend, JavaScript, ext:backend