emsk/redmine-notifier

View on GitHub
app/index.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict';

(() => {
  const isMac = process.platform === 'darwin';
  const defaultFetchIntervalSec = 600;
  const notieDisplaySec = 1.5;
  const colorIconFilename64 = 'redmine_icon_color_64.png';
  const blackIconFilename24 = 'redmine_icon_black_24.png';
  const blackIconFilename24Notification = 'redmine_icon_black_24_notification.png';
  const colorIconFilename24 = 'redmine_icon_color_24.png';
  const colorIconFilename24Notification = 'redmine_icon_color_24_notification.png';
  const fetchMode = Object.freeze({time: 'TIME', date: 'DATE'});

  const {remote} = require('electron');
  const {app, dialog, shell, Menu, Tray} = remote;
  const fs = require('fs');
  const notie = require('notie');

  const appName = app.getName();
  const appCopyright = 'Copyright (c) 2015-2019 emsk';

  let appDir = `${__dirname}.unpacked`; // Production
  try {
    fs.statSync(appDir);
  } catch (err) {
    appDir = __dirname; // Development
  }

  let nodeNotifier = require('node-notifier');
  if (isMac) {
    nodeNotifier = new nodeNotifier.NotificationCenter({
      customPath: `${appDir}/custom/terminal-notifier.app/Contents/MacOS/terminal-notifier`
    });
  } else {
    nodeNotifier = new nodeNotifier.WindowsToaster({
      customPath: `${appDir}/custom/SnoreToast.exe`
    });
  }

  const appIconFilePath = isMac ? null : `${appDir}/images/${colorIconFilename64}`;

  let notifierScreen = null;

  /**
   * Class to check updated issues.
   */
  class RedmineNotifier {
    /**
     * Initialize the RedmineNotifier object.
     * @constructor
     * @param {number} index - Index of the object.
     */
    constructor(index) {
      this._newFlag = false;
      this._index = index;
      this._lastExecutionTime = null;
      this._settings = null;
      this._fetchTimer = null;
      this._fetchMode = null;
      this._mostRecentIssueId = null;
    }

    /**
     * Set flag of whether the object is new.
     * @param {boolean} newFlag - true if the object is new.
     * @return {Object} Current object.
     */
    setNewFlag(newFlag) {
      this._newFlag = newFlag;
      return this;
    }

    /**
     * Display the settings on the screen.
     * @return {Object} Current object.
     */
    displaySettings() {
      document.getElementById('url').value = this._settings.url;
      document.getElementById('api-key').value = this._settings.apiKey;
      document.getElementById('project-id').value = this._settings.projectId;
      document.getElementById('fetch-interval-sec').value = this._settings.fetchIntervalSec;
      return this;
    }

    /**
     * Get the settings from the screen.
     * @return {Object} Settings.
     */
    getPageSettings() {
      return {
        url: document.getElementById('url').value,
        apiKey: document.getElementById('api-key').value,
        projectId: document.getElementById('project-id').value,
        fetchIntervalSec: document.getElementById('fetch-interval-sec').value
      };
    }

    /**
     * Read the settings from the screen.
     * @return {Object} Current object.
     */
    readScreenSettings() {
      this._settings = this.getPageSettings();
      return this;
    }

    /**
     * Read the settings from the localStorage.
     * @return {Object} Current object.
     */
    readStoredSettings() {
      this._settings = {
        url: localStorage.getItem(`url${this._index}`),
        apiKey: localStorage.getItem(`apiKey${this._index}`),
        projectId: localStorage.getItem(`projectId${this._index}`),
        fetchIntervalSec: localStorage.getItem(`fetchIntervalSec${this._index}`)
      };

      return this;
    }

    /**
     * Get the setting from the localStorage.
     * @param {string} key - Setting key.
     * @return {string} Setting value.
     */
    getStoredSetting(key) {
      return localStorage.getItem(`${key}${this._index}`);
    }

    /**
     * Update the stored last execution time.
     * @return {Object} Current object.
     */
    updateLastExecutionTime() {
      this._lastExecutionTime = (new Date()).toISOString().replace(/\.\d+Z$/, 'Z');
      localStorage.setItem(`lastExecutionTime${this._index}`, this._lastExecutionTime);
      return this;
    }

    /**
     * Update the stored settings.
     * @return {Object} Current object.
     */
    updateSettings() {
      localStorage.setItem(`url${this._index}`, this._settings.url);
      localStorage.setItem(`apiKey${this._index}`, this._settings.apiKey);
      localStorage.setItem(`projectId${this._index}`, this._settings.projectId);
      localStorage.setItem(`fetchIntervalSec${this._index}`, this._settings.fetchIntervalSec);
      return this;
    }

    /**
     * Delete the settings.
     * @return {Object} Current object.
     */
    deleteStoredSettings() {
      localStorage.removeItem(`url${this._index}`);
      localStorage.removeItem(`apiKey${this._index}`);
      localStorage.removeItem(`projectId${this._index}`);
      localStorage.removeItem(`fetchIntervalSec${this._index}`);
      return this;
    }

    /**
     * Validate the settings.
     * @return {boolean} true if valid.
     */
    validateSettings() {
      if (this._settings.url && this._settings.apiKey) {
        return true;
      }
      notie.alert({
        type: 'error',
        text: 'Please enter required fields.',
        time: notieDisplaySec
      });
      return false;
    }

    /**
     * Initialize the fetch function.
     * @return {Object} Current object.
     */
    initFetch() {
      const intervalMsec = 1000 * (this._settings.fetchIntervalSec || defaultFetchIntervalSec);

      clearInterval(this._fetchTimer);

      this._fetchTimer = setInterval(() => {
        this.fetch(this._fetchMode || fetchMode.time);
      }, intervalMsec);

      return this;
    }

    /**
     * Fetch updated issues by using Redmine REST API.
     * @param {string} mode - Time or date.
     * @return {Object} Current object.
     */
    fetch(mode) {
      const xhr = new XMLHttpRequest();

      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          this.handleResponseFetch(mode, xhr.status, xhr.responseText);
        }
      };

      xhr.open('GET', `${this._settings.url}/issues.json${this.getRequestParams(mode, this._settings.projectId)}`);
      xhr.setRequestHeader('X-Redmine-API-Key', this._settings.apiKey);
      xhr.send();

      return this;
    }

    /**
     * Handle the response for the fetch.
     * @param {string} mode - Time or date.
     * @param {number} status - Response status.
     * @param {string} responseText - Response text.
     * @return {Object} Current object.
     */
    handleResponseFetch(mode, status, responseText) {
      if (mode === fetchMode.time) {
        if (status === 200) {
          const response = JSON.parse(responseText);
          this.notify(response.issues, this.isOverPage(response))
            .updateLastExecutionTime();
        } else if (status === 422) {
          // Retry with date mode if Redmine API doesn't accept time format
          this._fetchMode = fetchMode.date;
          this.fetch(fetchMode.date);
        }
      } else {
        if (status === 200) {
          const response = JSON.parse(responseText);
          this.notify(this.pickIssues(response.issues), this.isOverPage(response));
        }

        this.updateLastExecutionTime();
      }

      return this;
    }

    /**
     * Check whether issues over 1 page.
     * @param {Object} response - Response.
     * @return {boolean} true if over 1 page.
     */
    isOverPage(response) {
      return response.total_count > response.limit;
    }

    /**
     * Get issues which were updated after last execution time.
     * @param {string} responseIssues - Response issues.
     * @return {Object[]} Processed issues.
     */
    pickIssues(responseIssues) {
      const lastExecutionTime = new Date(this._lastExecutionTime).getTime();

      const issues = responseIssues.filter(issue => {
        const updatedTime = new Date(issue.updated_on).getTime();
        return updatedTime >= lastExecutionTime;
      });

      return issues;
    }

    /**
     * Test the connection to the Redmine.
     * @param {string} mode - Time or date.
     * @return {Object} Current object.
     */
    testConnection(mode) {
      const xhr = new XMLHttpRequest();
      const pageSettings = this.getPageSettings();

      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          this.handleResponseTestConnection(mode, xhr.status);
        }
      };

      xhr.open('GET', `${pageSettings.url}/issues.json${this.getRequestParams(mode, pageSettings.projectId)}`);
      xhr.setRequestHeader('X-Redmine-API-Key', pageSettings.apiKey);
      xhr.send();

      return this;
    }

    /**
     * Handle the response for the test connection.
     * @param {string} mode - Time or date.
     * @param {number} status - Response status.
     * @return {Object} Current object.
     */
    handleResponseTestConnection(mode, status) {
      if (status === 200) {
        notie.alert({
          type: 'success',
          text: 'Connection succeeded.',
          time: notieDisplaySec
        });
        return this;
      }

      // Retry with date mode if Redmine API doesn't accept time format
      if (mode === fetchMode.time && status === 422) {
        this.testConnection(fetchMode.date);
        return this;
      }

      notie.alert({
        type: 'error',
        text: 'Connection failed.',
        time: notieDisplaySec
      });

      return this;
    }

    /**
     * Get the request parameters.
     * @param {string} mode - Time or date.
     * @param {string} projectId - Project ID (a numeric value, not a project identifier).
     * @return {string} Request parameters.
     */
    getRequestParams(mode, projectId) {
      const params = [
        `updated_on=%3E%3D${this.getLastExecutionTime(mode)}`,
        'status_id=*',
        'sort=updated_on:desc',
        'limit=100'
      ];

      if (typeof projectId === 'string' && projectId !== '') {
        params.unshift(`project_id=${projectId}`);
      }

      return `?${params.join('&')}`;
    }

    /**
     * Get last execution time by mode.
     * @param {string} mode - Time or date.
     * @return {string} Last execution time.
     */
    getLastExecutionTime(mode) {
      if (mode === fetchMode.time) {
        return this._lastExecutionTime;
      }
      return this._lastExecutionTime.replace(/T.*/, ''); // Date
    }

    /**
     * Send the desktop notification.
     * @param {Object} issues - All of updated issues.
     * @param {boolean} isOverPage - true if over 1 page.
     * @return {Object} Current object.
     */
    notify(issues, isOverPage) {
      const issueCount = issues.length;

      if (issueCount === 0) {
        return this;
      }

      this._mostRecentIssueId = issues[0].id;
      notifierScreen.setNotificationIcon(this._index);

      // Display the latest issue's subject only
      nodeNotifier.notify({
        title: this.buildNotificationTitle(issueCount, isOverPage),
        message: issues[0].subject,
        wait: true
      });

      nodeNotifier.removeAllListeners();

      nodeNotifier.once('click', () => {
        shell.openExternal(`${this._settings.url}/issues/${this._mostRecentIssueId}`);
        notifierScreen.setNormalIcon();
        nodeNotifier.removeAllListeners();
      });

      nodeNotifier.once('timeout', () => {
        nodeNotifier.removeAllListeners();
      });

      return this;
    }

    /**
     * Build a notification title.
     * @param {number} issueCount - Count of issues.
     * @param {boolean} isOverPage - true if over 1 page.
     * @return {string} Notification title.
     */
    buildNotificationTitle(issueCount, isOverPage) {
      let title = `(${issueCount}`;
      if (isOverPage) {
        title += '+';
      }
      title += ') Redmine Notifier';

      return title;
    }
  }

  /**
   * Class to handle settings screen.
   */
  class RedmineNotifierScreen {
    /**
     * Initialize the RedmineNotifierScreen object.
     * @constructor
     */
    constructor() {
      this._notifiers = null;
      this._currentNotifierIndex = null;
      this._tray = null;
      this._contextMenu = null;
      this._mostRecentNotifierIndex = null;

      if (isMac) {
        this._iconFilePath = `${__dirname}/images/${blackIconFilename24}`;
        this._notificationIconFilePath = `${__dirname}/images/${blackIconFilename24Notification}`;
      } else {
        this._iconFilePath = `${__dirname}/images/${colorIconFilename24}`;
        this._notificationIconFilePath = `${__dirname}/images/${colorIconFilename24Notification}`;
      }
    }

    /**
     * Initialize the RedmineNotifier objects.
     * @param {RedmineNotifier[]} notifiers - RedmineNotifier objects.
     * @return {Object} Current object.
     */
    initNotifiers(notifiers) {
      this._notifiers = notifiers;
      this._currentNotifierIndex = Number(localStorage.getItem('lastDisplayedNotifierIndex'));

      const notifier = this._notifiers[this._currentNotifierIndex];
      notifier.displaySettings();

      return this;
    }

    /**
     * Initialize the application menu and context menu.
     * @return {Object} Current object.
     */
    initMenu() {
      const appMenu = Menu.buildFromTemplate([
        {
          label: 'Edit',
          submenu: [
            {role: 'undo'},
            {role: 'redo'},
            {role: 'cut'},
            {role: 'copy'},
            {role: 'paste'},
            {role: 'selectall'}
          ]
        }
      ]);

      let aboutMenuItem;
      if (isMac) {
        aboutMenuItem = {role: 'about'};
      } else {
        aboutMenuItem = {
          label: `About ${appName}`,
          click: () => {
            dialog.showMessageBox({
              title: `About ${appName}`,
              message: `${appName} ${app.getVersion()}`,
              detail: appCopyright,
              icon: appIconFilePath,
              buttons: []
            });
          }
        };
      }

      this._contextMenu = Menu.buildFromTemplate([
        {
          label: 'Open Most Recent Issue in Browser',
          click: () => {
            const notifier = this._notifiers[this._mostRecentNotifierIndex];
            shell.openExternal(`${notifier._settings.url}/issues/${notifier._mostRecentIssueId}`);
            notifierScreen.setNormalIcon();
          },
          enabled: false
        },
        {
          label: 'Preferences',
          click: () => {
            remote.getCurrentWindow().show();
          }
        },
        {
          type: 'separator'
        },
        aboutMenuItem,
        {
          type: 'separator'
        },
        {
          label: 'Quit',
          click: () => {
            const notifier = this._notifiers[this._currentNotifierIndex];
            if (!notifier._newFlag) {
              this.updateLastDisplayedNotifierIndex();
            }

            remote.app.quit();
          }
        }
      ]);

      Menu.setApplicationMenu(appMenu);

      this._tray = new Tray(this._iconFilePath);
      this._tray.setContextMenu(this._contextMenu);

      return this;
    }

    /**
     * Initialize the event listeners.
     * @return {Object} Current object.
     */
    initEventListener() {
      document.getElementById('save-button').addEventListener('click', () => {
        const notifier = this._notifiers[this._currentNotifierIndex];
        notifier.readScreenSettings();

        if (notifier.validateSettings()) {
          notifier.initFetch()
            .updateSettings();
          this.updateNotifierCount();

          if (notifier._newFlag) {
            this.updateLastDisplayedNotifierIndex();
            notifier.setNewFlag(false);
          }

          notie.alert({
            type: 'success',
            text: 'Settings have been saved.',
            time: notieDisplaySec
          });
        } else {
          notifier.readStoredSettings();
        }
      });

      document.getElementById('close-button').addEventListener('click', () => {
        const notifier = this._notifiers[this._currentNotifierIndex];
        notifier.readStoredSettings()
          .displaySettings();
        remote.getCurrentWindow().hide();
      });

      document.getElementById('test-connection-button').addEventListener('click', () => {
        const notifier = this._notifiers[this._currentNotifierIndex];
        notifier.testConnection(fetchMode.time);
      });

      document.getElementById('new-url-button').addEventListener('click', () => {
        const lastNotifier = this._notifiers[this._notifiers.length - 1];
        if (lastNotifier._settings.url === null) {
          return;
        }

        this._currentNotifierIndex = this._notifiers.length;
        this.addNotifier(this._currentNotifierIndex)
          .displaySettings();
      });

      document.getElementById('other-urls-button').addEventListener('click', () => {
        this.openURLMenu();

        if (this._notifiers.length === 0) {
          this.addNotifier(0);
        }
      });

      document.getElementById('delete-button').addEventListener('click', () => {
        notie.confirm({
          text: 'Are you sure you want to delete this setting?',
          cancelText: 'No',
          submitCallback: () => {
            this.deleteCurrentNotifierSettings()
              .resetAllSettings()
              .updateNotifierCount()
              .displaySettingsAfterDelete();

            notie.alert({
              type: 'success',
              text: 'Settings have been deleted.',
              time: notieDisplaySec
            });
          }
        });
      });

      return this;
    }

    /**
     * Display the default settings on the screen.
     * @return {Object} Current object.
     */
    displayDefaultSettings() {
      document.getElementById('default-fetch-interval-sec').innerHTML = defaultFetchIntervalSec;
      return this;
    }

    /**
     * Update the stored count of RedmineNotifier objects.
     * @return {Object} Current object.
     */
    updateNotifierCount() {
      localStorage.setItem('notifierCount', this._notifiers.length);
      return this;
    }

    /**
     * Update the stored index of last displayed RedmineNotifier object.
     * @return {Object} Current object.
     */
    updateLastDisplayedNotifierIndex() {
      localStorage.setItem('lastDisplayedNotifierIndex', this._currentNotifierIndex);
      return this;
    }

    /**
     * Add a RedmineNotifier object.
     * @param {number} index - Index of the object.
     * @return {Object} The RedmineNotifier object.
     */
    addNotifier(index) {
      const notifier = new RedmineNotifier(index);
      notifier.updateLastExecutionTime()
        .readStoredSettings()
        .setNewFlag(true);
      this._notifiers.push(notifier);
      return notifier;
    }

    /**
     * Select valid RedmineNotifier objects.
     * @return {RedmineNotifier[]} Valid RedmineNotifier objects.
     */
    selectValidNotifiers() {
      return this._notifiers.filter(notifier => {
        return notifier._settings.url !== null;
      });
    }

    /**
     * Open the URL menu.
     * @return {Object} Current object.
     */
    openURLMenu() {
      const choices = [];

      const notifiers = this.selectValidNotifiers();
      notifiers.forEach((notifier, index) => {
        choices.push({
          text: notifier.getStoredSetting('url'),
          handler: () => {
            this._currentNotifierIndex = index;
            this.updateLastDisplayedNotifierIndex();
            notifier.readStoredSettings()
              .displaySettings();

            this._notifiers = this.selectValidNotifiers();
          }
        });
      });

      notie.select({
        text: 'Stored URLs',
        choices
      });

      this.wrapURLMenuItems();

      return this;
    }

    /**
     * Wrap an HTMLElement around URL menu item elements.
     * @return {Object} Current object.
     */
    wrapURLMenuItems() {
      const selectContainer = document.createElement('div');
      selectContainer.className = 'notie-select-container';

      const selectChoices = Array.prototype.slice.call(document.getElementsByClassName('notie-select-choice'));
      const notieContainer = selectChoices[0].parentNode;

      selectChoices.forEach(choice => {
        selectContainer.appendChild(choice);
      });

      const {cancelButton} = notieContainer.getElementsByClassName('notie-background-neutral notie-button');
      notieContainer.insertBefore(selectContainer, cancelButton);

      return this;
    }

    /**
     * Delete the settings of current RedmineNotifier object.
     * @return {Object} Current object.
     */
    deleteCurrentNotifierSettings() {
      const notifier = this._notifiers[this._currentNotifierIndex];
      notifier.deleteStoredSettings()
        .readStoredSettings();
      return this;
    }

    /**
     * Reset all settings.
     * @return {Object} Current object.
     */
    resetAllSettings() {
      this._notifiers = this.selectValidNotifiers();

      localStorage.clear();

      this._notifiers.forEach((notifier, index) => {
        notifier._index = index;
        notifier.updateSettings();
      });

      return this;
    }

    /**
     * Display settings after deleting.
     * @return {Object} Current object.
     */
    displaySettingsAfterDelete() {
      if (this._notifiers.length === 0) {
        // Display the first settings
        this._currentNotifierIndex = 0;
        this.addNotifier(this._currentNotifierIndex);
      } else if (this._notifiers[this._currentNotifierIndex] === undefined) {
        // Display the previous settings
        this._currentNotifierIndex = this._currentNotifierIndex - 1;
      }

      const notifier = this._notifiers[this._currentNotifierIndex];
      notifier.displaySettings();

      return this;
    }

    /**
     * Set normal icon and disable "Open Most Recent Issue in Browser" in context menu.
     * @return {Object} Current object.
     */
    setNormalIcon() {
      this._tray.setImage(this._iconFilePath);
      this._contextMenu.items[0].enabled = false;
      this._mostRecentNotifierIndex = null;
      return this;
    }

    /**
     * Set notification icon and enable "Open Most Recent Issue in Browser" in context menu.
     * @param {number} index - Index of the most recent RedmineNotifier object.
     * @return {Object} Current object.
     */
    setNotificationIcon(index) {
      this._tray.setImage(this._notificationIconFilePath);
      this._contextMenu.items[0].enabled = true;
      this._mostRecentNotifierIndex = index;
      return this;
    }
  }

  /**
   * Check whether old settings exist.
   * @param {Object} oldSettings - Old settings.
   * @return {boolean} true if old settings exist.
   */
  const hasOldSettings = oldSettings => {
    for (const key in oldSettings) {
      if (Object.prototype.hasOwnProperty.call(oldSettings, key) && oldSettings[key] !== null) {
        return true;
      }
    }

    return false;
  };

  /**
   * Migrate the settings of Redmine Notifier 0.5.0 or 0.6.0.
   * @return {boolean} true if old settings are migrated.
   */
  const migrateOldSettings = () => {
    const oldSettings = {
      url: localStorage.getItem('url'),
      apiKey: localStorage.getItem('apiKey'),
      projectId: localStorage.getItem('projectId'),
      fetchIntervalSec: localStorage.getItem('fetchIntervalSec'),
      lastExecutionTime: localStorage.getItem('lastExecutionTime')
    };

    if (!hasOldSettings(oldSettings)) {
      return false;
    }

    // Copy to index 0
    const notifier = new RedmineNotifier(0);
    delete oldSettings.lastExecutionTime; // Strictly speaking, `lastExecutionTime` is not a setting
    notifier._settings = oldSettings;
    notifier.updateSettings();
    notifier.updateLastExecutionTime();

    localStorage.setItem('notifierCount', 1);
    localStorage.setItem('lastDisplayedNotifierIndex', 0);

    // Delete old settings
    localStorage.removeItem('url');
    localStorage.removeItem('apiKey');
    localStorage.removeItem('projectId');
    localStorage.removeItem('fetchIntervalSec');
    localStorage.removeItem('lastExecutionTime');

    return true;
  };

  window.addEventListener('load', () => {
    migrateOldSettings();

    notie.setOptions({
      classes: {
        selectChoice: 'notie-select-choice'
      }
    });

    const notifiers = [];
    const notifierCount = Number(localStorage.getItem('notifierCount'));

    for (let i = 0; i < notifierCount; i++) {
      const notifier = new RedmineNotifier(i);
      notifier.updateLastExecutionTime()
        .readStoredSettings();

      if (notifier.validateSettings()) {
        notifier.initFetch();
      }

      notifiers.push(notifier);
    }

    if (notifiers.length === 0) {
      const notifier = new RedmineNotifier(0);
      notifier.updateLastExecutionTime()
        .readStoredSettings();
      notifiers.push(notifier);
    }

    notifierScreen = new RedmineNotifierScreen();
    notifierScreen.initNotifiers(notifiers)
      .initMenu()
      .initEventListener()
      .displayDefaultSettings();
  });
})();