function ViewController()

in public/explorer.js [287:777]


function ViewController($scope, SharedService) {
  DEBUG.log('ViewController init');
  window.viewScope = $scope; // for debugging
  $scope.view = {
      bucket: null, prefix: null, settings: null, objectCount: 0, keys_selected: [],
  };
  $scope.stop = false;

  // Delegated event handler for S3 object/folder clicks. This is delegated
  // because the object/folder rows are added dynamically and we do not want
  // to have to assign click handlers to each and every row.
  $tb.on('click', 'a', (e) => {
      const { currentTarget: target } = e;
      e.preventDefault();
      DEBUG.log('target href:', target.href);
      DEBUG.log('target dataset:', JSON.stringify(target.dataset));

      if (target.dataset.s3 === 'folder') {
          // User has clicked on a folder so navigate into that folder
          SharedService.changeViewPrefix(target.dataset.s3key);
      } else if ($scope.view.settings.auth === 'anon') {
          // Unauthenticated user has clicked on an object so download it
          // in new window/tab
          window.open(target.href, '_blank');
      } else {
          // Authenticated user has clicked on an object so create pre-signed
          // URL and download it in new window/tab
          const s3 = new AWS.S3();
          const params = {
              Bucket: $scope.view.settings.bucket, Key: target.dataset.s3key, Expires: 15,
          };
          DEBUG.log('params:', params);
          s3.getSignedUrl('getObject', params, (err, url) => {
              if (err) {
                  DEBUG.log('err:', err);
                  SharedService.showError(params, err);
              } else {
                  DEBUG.log('url:', url);
                  window.open(url, '_blank');
              }
          });
      }
      return false;
  });

  // Delegated event handler for breadcrumb clicks.
  $bc.on('click', 'a', (e) => {
      DEBUG.log('breadcrumb li click');
      e.preventDefault();
      const { currentTarget: target } = e;
      DEBUG.log('target dataset:', JSON.stringify(target.dataset));
      SharedService.changeViewPrefix(target.dataset.prefix);
      return false;
  });

  $scope.$on('broadcastChangeSettings', (e, args) => {
      DEBUG.log('ViewController', 'broadcast change settings:', args.settings);
      $scope.view.objectCount = 0;
      $scope.view.settings = args.settings;
      $scope.refresh();
  });

  $scope.$on('broadcastChangePrefix', (e, args) => {
      DEBUG.log('ViewController', 'broadcast change prefix args:', args);
      $scope.$apply(() => {
          // Create breadcrumbs from current path (S3 bucket plus folder hierarchy)
          $scope.folder2breadcrumbs($scope.view.settings.bucket, args.viewprefix || args.prefix);

          if (args.viewprefix !== undefined && args.viewprefix !== null) {
              // In bucket-level view we already have the data so we just need to
              // filter it on prefix.
              $.fn.dataTableExt.afnFiltering.length = 0;

              $.fn.dataTableExt.afnFiltering.push(
                  // Filter function returns true to include item in view
                  (_o, d, _i) => d[1] !== args.viewprefix && d[1].startsWith(args.viewprefix),
              );

              // Re-draw the table
              $tb.DataTable().draw();
          } else {
              // In folder-level view, we actually need to query the data for the
              // the newly-selected folder.
              $.fn.dataTableExt.afnFiltering.length = 0;
              $scope.view.settings.prefix = args.prefix;
              $scope.refresh();
          }
      });
  });

  $scope.$on('broadcastViewRefresh', () => {
      DEBUG.log('ViewController', 'broadcast view refresh');
      $scope.$apply(() => {
          $scope.refresh();
      });
  });

  $scope.renderObject = (data, _type, full) => {
      // DEBUG.log('renderObject:', JSON.stringify(full));
      const hrefv = object2hrefvirt($scope.view.settings.bucket, data);

      function buildAnchor(s3key, href, text, download) {
          const a = $('<a>');
          a.attr({ 'data-s3key': s3key });
          a.attr({ href });
          if (download) {
              a.attr({ 'data-s3': 'object' });
              a.attr({ download });
          } else {
              a.attr({ 'data-s3': 'folder' });
          }
          a.text(text);
          return a.prop('outerHTML');
      }

      function render(d, href, text, download) {
          if (download) {
              return buildAnchor(d, href, text, download);
          }

          return buildAnchor(d, href, text);
      }

      if (full.CommonPrefix) {
          if ($scope.view.settings.prefix) {
              return render(data, hrefv, prefix2folder(data));
          }

          return render(data, hrefv, data);
      }

      return render(data, hrefv, fullpath2filename(data), fullpath2filename(data));
  };

  $scope.renderFolder = (data, _type, full) => (full.CommonPrefix ? '' : fullpath2pathname(data, true));

  $scope.progresscb = (objects, folders) => {
      DEBUG.log('ViewController', 'Progress cb objects:', objects);
      DEBUG.log('ViewController', 'Progress cb folders:', folders);
      $scope.$apply(() => {
          $scope.view.objectCount += objects + folders;
      });
  };

  $scope.refresh = () => {
      DEBUG.log('refresh');
      if ($scope.running()) {
          DEBUG.log('running, stop');
          $scope.listobjectsstop();
      } else {
          DEBUG.log('refresh', $scope.view.settings);
          $scope.view.objectCount = 0;
          $scope.folder2breadcrumbs(
              $scope.view.settings.bucket,
              SharedService.getViewPrefix(),
          );
          $scope.listobjects(
              $scope.view.settings.bucket,
              $scope.view.settings.prefix,
              $scope.view.settings.delimiter,
          );
      }
  };

  $scope.upload = () => {
      DEBUG.log('Add files');
      $('#addedFiles').trigger('click');
  };

  $scope.trash = () => {
      DEBUG.log('Trash:', $scope.view.keys_selected);
      if ($scope.view.keys_selected.length > 0) {
          SharedService.trashObjects($scope.view.settings.bucket, $scope.view.keys_selected);
      }
  };

  $scope.running = () => $bl.hasClass('fa-spin');

  $scope.folder2breadcrumbs = (bucket, prefix) => {
      DEBUG.log('Breadcrumbs bucket:', bucket);
      DEBUG.log('Breadcrumbs prefix:', prefix);

      // Empty the current breadcrumb list
      $('#breadcrumb li').remove();

      // This array will contain the needed prefixes for each folder level.
      const prefixes = [''];
      let buildprefix = '';

      if (prefix) {
          prefixes.push(...prefix.replace(/\/$/g, '').split('/'));
      }

      // Add bucket followed by prefix segments to make breadcrumbs
      for (let ii = 0; ii < prefixes.length; ii++) {
          let li;

          // Bucket
          if (ii === 0) {
              const a1 = $('<a>').attr('href', '#').text(bucket);
              li = $('<li>').append(a1);
          // Followed by n - 1 intermediate folders
          } else if (ii < prefixes.length - 1) {
              const a2 = $('<a>').attr('href', '#').text(prefixes[ii]);
              li = $('<li>').append(a2);
          // Followed by current folder
          } else {
              li = $('<li>').text(prefixes[ii]);
          }

          // Accumulate prefix
          if (ii) {
              buildprefix = `${buildprefix}${prefixes[ii]}/`;
          }

          // Save prefix & bucket data for later click handler
          li.children('a').attr('data-prefix', buildprefix).attr('data-bucket', bucket);

          // Add to breadcrumbs
          $bc.append(li);
      }

      // Make last breadcrumb active
      $('#breadcrumb li:last').addClass('active');
  };

  $scope.listobjectsstop = (stop) => {
      DEBUG.log('ViewController', 'listobjectsstop:', stop || true);
      $scope.stop = stop || true;
  };

  // This is the listObjects callback
  $scope.listobjectscb = (err, data) => {
      DEBUG.log('Enter listobjectscb');
      if (err) {
          DEBUG.log('Error:', JSON.stringify(err));
          DEBUG.log('Error:', err.stack);
          $bl.removeClass('fa-spin');
          const params = { bucket: $scope.view.bucket, prefix: $scope.view.prefix };
          SharedService.showError(params, err);
      } else {
          let marker;

          // Store marker before filtering data. Note that Marker is the
          // previous request marker, not the marker to use on the next call
          // to listObject. For the one to use on the next invocation you
          // need to use NextMarker or retrieve the key of the last item.
          if (data.IsTruncated) {
              if (data.NextMarker) {
                  marker = data.NextMarker;
              } else if (data.Contents.length > 0) {
                  marker = data.Contents[data.Contents.length - 1].Key;
              }
          }

          const count = { objects: 0, folders: 0 };

          // NOTE: folders are returned in CommonPrefixes if delimiter is
          // supplied on the listObjects call and in Contents if delimiter
          // is not supplied on the listObjects call, so we may need to
          // source our DataTable folders from Contents or CommonPrefixes.
          // DEBUG.log("Contents", data.Contents);
          $.each(data.Contents, (index, value) => {
              if (value.Key === data.Prefix) {
                  // ignore this folder
              } else if (isfolder(value.Key)) {
                  $tb.DataTable().row.add({
                      CommonPrefix: true, Key: value.Key, StorageClass: null,
                  });
                  count.folders++;
              } else {
                  $tb.DataTable().row.add(value);
                  count.objects++;
              }
          });

          // Add folders to the datatable. Note that folder entries in the
          // DataTable will have different content to object entries and the
          // folders can be identified by CommonPrefix=true.
          // DEBUG.log("CommonPrefixes:", data.CommonPrefixes);
          $.each(data.CommonPrefixes, (index, value) => {
              $tb.DataTable().rows.add([
                  { CommonPrefix: true, Key: value.Prefix, StorageClass: null },
              ]);
              count.objects++;
          });

          // Re-draw the table
          $tb.DataTable().draw();

          // Make progress callback to report objects read so far
          $scope.progresscb(count.objects, count.folders);

          const params = {
              Bucket: data.Name, Prefix: data.Prefix, Delimiter: data.Delimiter, Marker: marker,
          };

          // DEBUG.log("AWS.config:", JSON.stringify(AWS.config));

          if ($scope.stop) {
              DEBUG.log('Bucket', data.Name, 'stopped');
              $bl.removeClass('fa-spin');
          } else if (data.IsTruncated) {
              DEBUG.log('Bucket', data.Name, 'truncated');
              const s3 = new AWS.S3(AWS.config);
              if (AWS.config.credentials && AWS.config.credentials.accessKeyId) {
                  DEBUG.log('Make S3 authenticated call to listObjects');
                  s3.listObjects(params, $scope.listobjectscb);
              } else {
                  DEBUG.log('Make S3 unauthenticated call to listObjects');
                  s3.makeUnauthenticatedRequest('listObjects', params, $scope.listobjectscb);
              }
          } else {
              DEBUG.log('Bucket', data.Name, 'listing complete');
              $bl.removeClass('fa-spin');
          }
      }
  };

  // Start the spinner, clear the table, make an S3 listObjects request
  $scope.listobjects = (Bucket, Prefix, Delimiter, Marker) => {
      DEBUG.log('Enter listobjects');

      // If this is the initial listObjects
      if (!Marker) {
          // Checked on each event cycle to stop list prematurely
          $scope.stop = false;

          // Start spinner and clear table
          $scope.view.keys_selected = [];
          $bl.addClass('fa-spin');
          $tb.DataTable().clear();
          $tb.DataTable().column(s3ExplorerColumns.folder).visible(!Delimiter);
      }

      const s3 = new AWS.S3(AWS.config);
      const params = {
          Bucket, Prefix, Delimiter, Marker,
      };

      // DEBUG.log("AWS.config:", JSON.stringify(AWS.config));

      // Now make S3 listObjects call(s)
      if (AWS.config.credentials && AWS.config.credentials.accessKeyId) {
          DEBUG.log('Make S3 authenticated call to listObjects, params:', params);
          s3.listObjects(params, $scope.listobjectscb);
      } else {
          DEBUG.log('Make S3 unauthenticated call to listObjects, params:', params);
          s3.makeUnauthenticatedRequest('listObjects', params, $scope.listobjectscb);
      }
  };

  this.isfolder = path => path.endsWith('/');

  // Individual render functions so that we can control how column data appears
  this.renderSelect = (data, type, _full) => {
      if (type === 'display') {
          return '<span class="text-center"><input type="checkbox"></span>';
      }

      return '';
  };

  this.renderObject = (data, type, full) => {
      if (type === 'display') {
          return $scope.renderObject(data, type, full);
      }

      return data;
  };

  this.renderFolder = (data, type, full) => $scope.renderFolder(data, type, full);

  this.renderLastModified = (data, _type, _full) => {
      if (data) {
          return moment(data).fromNow();
      }

      return '';
  };

  this.renderTimestamp = (data, _type, _full) => {
      if (data) {
          return moment(data).local().format('YYYY-MM-DD HH:mm:ss');
      }

      return '';
  };

  this.renderStorageClass = (data, _type, _full) => {
      if (data) {
          return mapStorage[data];
      }

      return '';
  };

  // Object sizes are displayed in nicer format e.g. 1.2 MB but are otherwise
  // handled as simple number of bytes e.g. for sorting purposes
  this.dataSize = (source, type, _val) => {
      if (source.Size) {
          return (type === 'display') ? bytesToSize(source.Size) : source.Size;
      }

      return '';
  };

  // Initial DataTable settings (must only do this one time)
  $tb.DataTable({
      iDisplayLength: 25,
      order: [[2, 'asc'], [1, 'asc']],
      aoColumnDefs: [
          {
              aTargets: [0], mData: null, mRender: this.renderSelect, sClass: 'text-center', sWidth: '20px', bSortable: false,
          },
          {
              aTargets: [1], mData: 'Key', mRender: this.renderObject, sType: 'key',
          },
          {
              aTargets: [2], mData: 'Key', mRender: this.renderFolder,
          },
          {
              aTargets: [3], mData: 'LastModified', mRender: this.renderLastModified,
          },
          {
              aTargets: [4], mData: 'LastModified', mRender: this.renderTimestamp,
          },
          {
              aTargets: [5], mData: 'StorageClass', mRender: this.renderStorageClass,
          },
          {
              aTargets: [6], mData: this.dataSize,
          },
      ],
  });

  // Custom ascending sort for Key column so folders appear before objects
  $.fn.dataTableExt.oSort['key-asc'] = (a, b) => {
      const x = (isfolder(a) ? `0-${a}` : `1-${a}`).toLowerCase();
      const y = (isfolder(b) ? `0-${b}` : `1-${b}`).toLowerCase();
      if (x < y) return -1;
      if (x > y) return 1;
      return 0;
  };

  // Custom descending sort for Key column so folders appear before objects
  $.fn.dataTableExt.oSort['key-desc'] = (a, b) => {
      const x = (isfolder(a) ? `1-${a}` : `0-${a}`).toLowerCase();
      const y = (isfolder(b) ? `1-${b}` : `0-${b}`).toLowerCase();
      if (x < y) return 1;
      if (x > y) return -1;
      return 0;
  };

  // Handle click on selection checkbox
  $('#s3objects-table tbody').on('click', 'input[type="checkbox"]', (e1) => {
      const checkbox = e1.currentTarget;
      const $row = $(checkbox).closest('tr');
      const data = $tb.DataTable().row($row).data();
      let index = -1;

      // Prevent click event from propagating to parent
      e1.stopPropagation();

      // Find matching key in currently checked rows
      index = $scope.view.keys_selected.findIndex(e2 => e2.Key === data.Key);

      // Remove or add checked row as appropriate
      if (checkbox.checked && index === -1) {
          $scope.view.keys_selected.push(data);
      } else if (!checkbox.checked && index !== -1) {
          $scope.view.keys_selected.splice(index, 1);
      }

      $scope.$apply(() => {
          // Doing this to force Angular to update models
          DEBUG.log('Selected rows:', $scope.view.keys_selected);
      });

      if (checkbox.checked) {
          $row.addClass('selected');
      } else {
          $row.removeClass('selected');
      }
  });

  // Handle click on table cells
  $('#s3objects-table tbody').on('click', 'td', (e) => {
      $(e.currentTarget).parent().find('input[type="checkbox"]').trigger('click');
  });
}