Add PolyGerrit-based screen for uploading images

Change-Id: Ifbabbd8e41a2b465bb4efbb267de2a1686919089
diff --git a/src/main/resources/static/gr-imagare-list-item.html b/src/main/resources/static/gr-imagare-list-item.html
new file mode 100644
index 0000000..2f66329
--- /dev/null
+++ b/src/main/resources/static/gr-imagare-list-item.html
@@ -0,0 +1,135 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<dom-module id="gr-imagare-list-item">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-subpage-styles"></style>
+    <style>
+      div.image-panel {
+        margin: 2em auto;
+        max-width: 50em;
+        height: 150px;
+      }
+
+      div.title {
+        float: left;
+        width: 30%;
+        height: 100%;
+      }
+
+      div.value {
+        text-align: center;
+        float: right;
+        width: 70%;
+        height: 100%;
+      }
+
+      div.imageName {
+        font-weight: bold;
+        padding: 1em;
+      }
+
+      a {
+        display: block;
+      }
+
+      .ellipsis {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        float: left;
+        width: 80%;
+      }
+
+      img {
+        width: 100%;
+        height: auto;
+        max-height: 100%;
+        max-width: 80%;
+        object-fit: contain;
+      }
+
+      #editButton {
+        box-shadow: none;
+      }
+    </style>
+    <div class="image-panel">
+      <div class="title">
+        <img id="thumbnail">
+      </div>
+      <div id="staging" class="value">
+        <div class="imageName">
+          <iron-input bind-value="{{imageName}}">
+            <input id="imageNameInput" value="{{imageName::input}}" type="text"
+                   disabled="[[!_editing]]" placeholder$="[[imageName]]">
+          </iron-input>
+          <gr-button id="editButton" on-click="_handleEditImage" hidden="[[_editing]]">
+            Edit
+          </gr-button>
+          <gr-button id="saveButton" on-click="_handleSaveName" hidden="[[!_editing]]">
+            Save
+          </gr-button>
+          <gr-button id="cancelRenameButton" on-click="_handleCancelRenameName"
+                     hidden="[[!_editing]]">
+            Cancel
+          </gr-button>
+        </div>
+        <div>
+          <section>
+            <gr-button id="uploadButton"
+                       on-click="_handleUploadImage"
+                       disabled="[[_editing]]">
+              Upload
+            </gr-button>
+            <gr-button id="cleanButton"
+                       on-click="_handleClearImage">
+              Clear
+            </gr-button>
+          </section>
+        </div>
+      </div>
+      <div id="uploading" class="value">
+        <div class="imageName">
+          [[imageName]]
+        </div>
+        <div>
+          <a class="ellipsis" href="[[imageUrl]]">[[imageUrl]]</a>
+          <gr-copy-clipboard has-tooltip button-title="Copy URL to Clipboard"
+                             hide-input text="[[imageUrl]]">
+          </gr-copy-clipboard>
+        </div>
+        <gr-button id="deleteButton"
+                   on-click="_openDeleteDialog">
+          Delete
+        </gr-button>
+        <gr-overlay id="deleteOverlay" with-backdrop>
+          <gr-dialog id="deleteDialog"
+                     class="confirmDialog"
+                     confirm-label="Delete"
+                     confirm-on-enter
+                     on-confirm="_handleDeleteImage">
+            <div class="header" slot="header">
+              Delete Image
+            </div>
+            <div class="main" slot="main">
+              Are you sure you want to delete '[[imageName]]'?
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      </div>
+    </div>
+  </template>
+  <script src="gr-imagare-list-item.js"></script>
+</dom-module>
diff --git a/src/main/resources/static/gr-imagare-list-item.js b/src/main/resources/static/gr-imagare-list-item.js
new file mode 100644
index 0000000..5f1cffe
--- /dev/null
+++ b/src/main/resources/static/gr-imagare-list-item.js
@@ -0,0 +1,107 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function () {
+  'use strict';
+
+  Polymer({
+    is: 'gr-imagare-list-item',
+
+    properties: {
+      imageUrl: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      imageName: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      imageData: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      uploaded: {
+        type: Boolean,
+        observer: '_uploadedChanged',
+        reflectToAttribute: true,
+      },
+      _originalImageName: String,
+      _editing: {
+        type: Boolean,
+        value: false,
+      },
+      _imageSrc: String,
+    },
+
+    attached() {
+      this._originalImageName = this.imageName;
+      this._setImage();
+    },
+
+    _handleCancelRenameName() {
+      this.imageName = this._originalImageName;
+      this._editing = false;
+    },
+
+    _handleClearImage() {
+      this.fire("clear");
+    },
+
+    _handleDeleteImage() {
+      this.fire("delete");
+    },
+
+    _handleEditImage() {
+      this._editing = true;
+    },
+
+    _handleSaveName() {
+      this._editing = false;
+
+      if (this._originalImageName === this.imageName) {
+        return;
+      }
+
+      let oldFileType = this._originalImageName.split('.').pop();
+      let newFileType = this.imageName.split('.').pop();
+      if (oldFileType !== newFileType) {
+        this.imageName += `.${oldFileType}`;
+      }
+
+      this.fire("editName", {oldName: this._originalImageName, newName: this.imageName});
+    },
+
+    _handleUploadImage() {
+      this.fire("upload");
+    },
+
+    _openDeleteDialog() {
+      this.$.deleteOverlay.open();
+    },
+
+    _setImage() {
+      if (this.uploaded) {
+        this.$.thumbnail.setAttribute('src', this.imageUrl);
+      } else {
+        this.$.thumbnail.setAttribute('src', this.imageData);
+      }
+    },
+
+    _uploadedChanged(uploaded) {
+      this.$.uploading.hidden = !uploaded;
+      this.$.staging.hidden = uploaded;
+      this._setImage();
+    },
+  });
+})();
diff --git a/src/main/resources/static/gr-imagare-upload.html b/src/main/resources/static/gr-imagare-upload.html
new file mode 100644
index 0000000..814ade2
--- /dev/null
+++ b/src/main/resources/static/gr-imagare-upload.html
@@ -0,0 +1,132 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import"
+      href="./gr-imagare-list-item.html">
+
+<dom-module id="gr-imagare-upload">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-subpage-styles"></style>
+    <style include="gr-form-styles"></style>
+    <style>
+      div.image-upload {
+        margin: 2em auto;
+        max-width: 50em;
+      }
+
+      h1#title {
+        margin-bottom: 1em;
+      }
+
+      div#dragDropArea {
+        padding: 2em 10em;
+        border: 2px dashed #ccc;
+        border-radius: 1em;
+        text-align: center;
+      }
+
+      div#dragDropArea>p {
+        font-weight: bold;
+        text-transform: uppercase;
+        padding: 0.25em;
+      }
+
+      div#dragDropArea>p.or {
+        color: #ccc;
+      }
+
+      input#imagareImagePathInput {
+        border: 0;
+        clip: rect(0, 0, 0, 0);
+        height: 1px;
+        overflow: hidden;
+        padding: 0;
+        position: absolute !important;
+        white-space: nowrap;
+        width: 1px;
+      }
+    </style>
+    <div class="image-upload">
+      <main class="gr-form-styles read-only">
+        <h1 id="title">Image Upload</h1>
+        <div id="loading"
+             class$="[[_computeLoadingClass(_loading)]]">
+          Loading...
+        </div>
+        <div id="form"
+             class$="[[_computeLoadingClass(_loading)]]">
+          <fieldset>
+            <h2>Settings</h2>
+            <section>
+              The user preferences for the image upload can be changed
+              <a href="[[_computeSettingsUrl()]]">here</a>.
+            </section>
+            <section>
+              <span class="title">Project</span>
+              <span class="value">
+                <gr-autocomplete id="imagareProjectInput" text="{{_imageProject}}"
+                                 value="{{_imageProject}}" query="[[_query]]">
+                  [[_defaultImageProject]]
+                </gr-autocomplete>
+              </span>
+            </section>
+          </fieldset>
+          <fieldset>
+            <h2>Image Selection</h2>
+            <section>
+              <div id="dragDropArea" contenteditable="true" on-paste="_handlePaste"
+                   on-drop="_handleDrop" on-keypress="_handleKeyPress">
+                <p>Drag and drop image here</p>
+                <p class="or">or</p>
+                <p>paste it here</p>
+                <p class="or">or</p>
+                <p>
+                  <iron-input>
+                    <input id="imagareImagePathInput"
+                           type="file"
+                           on-change="_handleImagePathChanged"
+                           slot="input"
+                           multiple>
+                  </iron-input>
+                  <label for="imagareImagePathInput">
+                    <gr-button>
+                      Browse
+                    </gr-button>
+                  </label>
+                </p>
+              </div>
+            </section>
+          </fieldset>
+          <fieldset id="imageListContainer" hidden>
+            <h2>Images</h2>
+            <fieldset id="imageList"></fieldset>
+            <section>
+              <gr-button id="uploadButton"
+                         on-click="_handleUploadAllImages"
+                         disabled="[[_allUploaded]]">
+                Upload All
+              </gr-button>
+              <gr-button id="cleanButton"
+                         on-click="_handleClearAllImages">
+                Clear List
+              </gr-button>
+            </section>
+          </fieldset>
+        </div>
+      </main>
+    </div>
+  </template>
+  <script src="gr-imagare-upload.js"></script>
+</dom-module>
diff --git a/src/main/resources/static/gr-imagare-upload.js b/src/main/resources/static/gr-imagare-upload.js
new file mode 100644
index 0000000..40150c1
--- /dev/null
+++ b/src/main/resources/static/gr-imagare-upload.js
@@ -0,0 +1,374 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the 'License');
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function () {
+  'use strict';
+
+  function preventDefaultFn(event) {
+    event.preventDefault();
+  }
+
+  Polymer({
+    is: 'gr-imagare-upload',
+
+    properties: {
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _allUploaded: {
+        type: Boolean,
+        value: false,
+      },
+      _imageProject: String,
+      _defaultImageProject: String,
+      _images: {
+        type: Map,
+        value: () => new Map(),
+      },
+      _stageImages: {
+        type: Boolean,
+        value: true,
+      },
+      _undefinedFileCounter: {
+        type: Number,
+        value: 0,
+      },
+      _query: {
+        type: Function,
+        value() {
+          return this._queryProjects.bind(this);
+        },
+      },
+    },
+
+    listeners: {
+      clear: '_handleClearImage',
+      delete: '_handleDeleteImage',
+      editName: '_handleEditImageName',
+      upload: '_handleUploadImage',
+    },
+
+    attached() {
+      this.fire('title-change', { title: 'Image Upload' });
+
+      window.addEventListener('dragover', preventDefaultFn, false);
+      window.addEventListener('drop', preventDefaultFn, false);
+      this.$.dragDropArea.addEventListener('paste', preventDefaultFn, false);
+
+      this._getUserPreferences();
+    },
+
+    detached() {
+      window.removeEventListener('dragover', preventDefaultFn, false);
+      window.removeEventListener('drop', preventDefaultFn, false);
+      this.$.dragDropArea.removeEventListener('paste', preventDefaultFn, false);
+    },
+
+    _computeFilenameWithCorrectType(filedata, filename) {
+      let realFiletype = filedata.slice(
+        filedata.indexOf('/') + 1,
+        filedata.indexOf(';'));
+
+      let givenFiletype;
+
+      if (filename.indexOf(".") !== -1) {
+        givenFiletype = filename.split('.').pop();
+      }
+
+      if (!givenFiletype || realFiletype !== givenFiletype) {
+        filename += `.${realFiletype}`;
+      }
+
+      return filename;
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _computeSettingsUrl() {
+      return `${location.origin}/settings#ImagarePreferences`;
+    },
+
+    _computeUploadAllDisabled() {
+      if (this._images) {
+        for (let value of this._images.values()) {
+          if (!value.uploaded) {
+            this._allUploaded = false;
+            return;
+          }
+        }
+      }
+
+      this._allUploaded = true;
+    },
+
+    _createImageObject(name, data, url, list_entry, uploaded, ref) {
+      return {
+        data: data,
+        list_entry: list_entry,
+        name: name,
+        ref: ref,
+        url: url,
+        uploaded: uploaded,
+      }
+    },
+
+    _createListEntry(name, data, url) {
+      let imagePanel = document.createElement('gr-imagare-list-item');
+      imagePanel.setAttribute("image-name", name);
+
+      if (data) {
+        imagePanel.setAttribute("image-data", data);
+      }
+
+      if (url) {
+        imagePanel.setAttribute("image-url", url);
+        imagePanel.uploaded = true;
+      } else {
+        imagePanel.uploaded = false;
+      }
+
+      this.$.imageList.appendChild(imagePanel);
+
+      return imagePanel;
+    },
+
+    _deleteImage(image) {
+      this.plugin.restApi('/projects')
+        .delete(`/${this._imageProject}/imagare~images/${image.ref}`)
+        .then(() => {
+          image.list_entry.remove();
+          this._images.delete(image.name);
+          if (!this.$.imageList.hasChildNodes()) {
+            this.$.imageListContainer.hidden = true;
+          }
+        }).catch(response => {
+          this.fire('show-error', { message: response });
+        });
+    },
+
+    _extractImageRef(url) {
+      return url.split('/').slice(-2)[0];
+    },
+
+    _getUserPreferences() {
+      this.plugin.restApi('/accounts/self/')
+        .get(`imagare~preference`)
+        .then(config => {
+          if (!config) {
+            return;
+          }
+
+          this._defaultImageProject = config.default_project;
+          this._imageProject = config.default_project;
+          this._stageImages = config.stage;
+          this._loading = false;
+        });
+    },
+
+    _handleClearAllImages() {
+      while (this.$.imageList.firstChild) {
+        this.$.imageList.removeChild(this.$.imageList.firstChild);
+      }
+      this.$.imageListContainer.hidden = true;
+
+      this._images.clear()
+    },
+
+    _handleClearImage(event) {
+      event.stopPropagation();
+      this._images.delete(event.target.imageName);
+      event.target.remove();
+      if (!this.$.imageList.hasChildNodes()) {
+        this.$.imageListContainer.hidden = true;
+      }
+    },
+
+    _handleDeleteImage(event) {
+      event.stopPropagation();
+      this._deleteImage(this._images.get(event.target.imageName));
+    },
+
+    _handleDrop(event) {
+      event.preventDefault();
+      event.stopPropagation();
+
+      for (let file of event.dataTransfer.files) {
+        if (!file.type.match('image/.*')) {
+          this.fire('show-error', { message: `No image file: ${file.name}` });
+        }
+        let fr = new FileReader();
+        fr.file = file;
+        fr.onload = fileLoadEvent => this._handleFileLoadEvent(
+          fr.file.name, fileLoadEvent);
+        fr.readAsDataURL(file);
+      }
+    },
+
+    _handleEditImageName(event) {
+      event.stopPropagation();
+      let editedImage = this._images.get(event.detail.oldName);
+      if (this._images.has(event.detail.newName)) {
+        this.fire('show-error', { message: 'An image with the same name was already staged.' });
+        editedImage.list_entry.setAttribute("image-name", event.detail.oldName);
+      } else {
+        editedImage.name = event.detail.newName;
+        this._images.set(editedImage.name, editedImage);
+        this._images.delete(event.detail.oldName);
+      }
+    },
+
+    _handleFileLoadEvent(filename, event) {
+      let correctedFilename = this._computeFilenameWithCorrectType(
+        event.target.result, filename);
+      if (this._stageImages) {
+        this._stageImage(correctedFilename, event.target.result);
+      } else {
+        let image = this._createImageObject(correctedFilename, event.target.result);
+        this._images.set(correctedFilename, image);
+        this._uploadImage(image);
+      }
+    },
+
+    _handleKeyPress(event) {
+      let ctrlDown = event.ctrlKey || event.metaKey;
+      if (!ctrlDown) {
+        event.preventDefault();
+        event.stopPropagation();
+      }
+    },
+
+    _handleImagePathChanged(event) {
+      for (let file of event.target.files) {
+        let fr = new FileReader();
+        fr.file = file;
+        fr.onload = fileLoadEvent => this._handleFileLoadEvent(
+          fr.file.name, fileLoadEvent);
+        fr.readAsDataURL(file);
+      }
+
+      event.target.value = '';
+    },
+
+    _handlePaste(event) {
+      let clipboardData = event.clipboardData || event.originalEvent.clipboardData;
+      let items = clipboardData.items;
+      if (JSON.stringify(items)) {
+        let blob;
+        for (let item of items) {
+          if (item.type.indexOf("image") === 0) {
+            blob = item.getAsFile();
+          }
+        }
+        if (blob) {
+          let fr = new FileReader();
+          fr.onload = fileLoadEvent => {
+            let filename = `undefined-${this._undefinedFileCounter}`;
+            this._undefinedFileCounter++;
+            this._handleFileLoadEvent(filename, fileLoadEvent);
+          };
+          fr.readAsDataURL(blob);
+        } else {
+          event.preventDefault();
+          this.fire('show-error', { message: `No image file` });
+        }
+      }
+    },
+
+    _handleUploadAllImages() {
+      for (let image of this._images.values()) {
+        this._uploadImage(image);
+      }
+    },
+
+    _handleUploadImage(event) {
+      event.stopPropagation();
+      let image = this._createImageObject(
+        event.target.imageName,
+        event.target.imageData,
+        null,
+        event.target);
+      this._images.set(image.name, image);
+      this._uploadImage(image);
+    },
+
+    _queryProjects(input) {
+      let query;
+      if (!input || input === this._defaultImageProject) {
+        query = '';
+      } else {
+        query = `?prefix=${input}`;
+      }
+
+      return this.plugin.restApi('/a/projects/').get(query)
+        .then(response => {
+          const projects = [];
+          for (const key in response) {
+            if (!response.hasOwnProperty(key)) { continue; }
+            projects.push({
+              name: key,
+              value: decodeURIComponent(response[key].id),
+            });
+          }
+          return projects;
+        });
+    },
+
+    _stageImage(name, data) {
+      if (this._images.has(name)) {
+        let fileName = name.slice(0, name.lastIndexOf('.'));
+        let fileExtension = name.slice(name.lastIndexOf('.'));
+        name = `${fileName}-${this._undefinedFileCounter}${fileExtension}`;
+        this._undefinedFileCounter++;
+      }
+      let imagePanel = this._createListEntry(name, data, null);
+      this._images.set(name, this._createImageObject(name, data, null, imagePanel));
+      this.$.imageListContainer.hidden = false;
+      this._computeUploadAllDisabled();
+    },
+
+    _uploadImage(image) {
+      if (image && image.uploaded) {
+        return;
+      }
+
+      this.plugin.restApi('/projects')
+        .post(`/${this._imageProject}/imagare~images`, {
+          image_data: image.data,
+          file_name: image.name,
+        })
+        .then(response => {
+          if (!image.list_entry) {
+            image.list_entry = this._createListEntry(image.name, image.data, response.url);
+          } else {
+            image.list_entry.setAttribute("image-url", response.url);
+            image.list_entry.uploaded = true;
+          }
+
+          this._images.set(
+            image.name,
+            this._createImageObject(
+              image.name, image.data, response.url, image.list_entry, true,
+              this._extractImageRef(response.url)));
+
+          this.$.imageListContainer.hidden = false;
+          this._computeUploadAllDisabled();
+        }).catch(response => {
+          this.fire('show-error', { message: response });
+        });
+    }
+  });
+})();
diff --git a/src/main/resources/static/imagare.html b/src/main/resources/static/imagare.html
index 42ea7ae..a06d629 100644
--- a/src/main/resources/static/imagare.html
+++ b/src/main/resources/static/imagare.html
@@ -16,12 +16,18 @@
 -->
 
 <link rel="import" href="./gr-imagare-inline.html">
+<link rel="import" href="./gr-imagare-upload.html">
 
 <dom-module id="imagare">
   <script>
     Gerrit.install(plugin => {
       if (!window.Polymer) { return; }
 
+      plugin.restApi('/config/server/').get('imagare~config').then(config => {
+        if (config && config.enable_image_server) {
+          plugin.screen('upload', 'gr-imagare-upload');
+        }
+      });
       plugin.registerCustomComponent('change-view-integration', 'gr-imagare-inline');
     });
   </script>