+
+
diff --git a/zedd-app/src/main.ts b/zedd-app/src/main.ts
new file mode 100644
index 0000000..8818492
--- /dev/null
+++ b/zedd-app/src/main.ts
@@ -0,0 +1,118 @@
+import { app, ipcMain, BrowserWindow } from 'electron'
+import { homedir } from 'os'
+import * as path from 'path'
+
+declare global {
+ namespace NodeJS {
+ interface Global {
+ isDev: boolean
+ appUserModelId: string
+ }
+ }
+}
+
+global.isDev = process.argv.includes('--dev')
+
+global.appUserModelId = global.isDev ? process.execPath : 'com.squirrel.zedd.zedd-app'
+app.setAppUserModelId(global.appUserModelId)
+app.allowRendererProcessReuse = false
+
+process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = '1'
+
+// Handle creating/removing shortcuts on Windows when installing/uninstalling.
+if (require('electron-squirrel-startup')) {
+ // eslint-disable-line global-require
+ app.quit()
+}
+if (!app.requestSingleInstanceLock()) {
+ app.quit()
+}
+
+app.on('second-instance', (_event, _commandLine, _workingDirectory) => {
+ // Someone tried to run a second instance, we should focus our window.
+ if (mainWindow) {
+ if (mainWindow.isMinimized()) mainWindow.restore()
+ mainWindow.focus()
+ }
+})
+
+// Keep a global reference of the window object, if you don't, the window will
+// be closed automatically when the JavaScript object is garbage collected.
+let mainWindow: BrowserWindow | undefined
+let userQuit: boolean = false
+const createWindow = () => {
+ if (global.isDev) {
+ BrowserWindow.addDevToolsExtension(
+ path.join(
+ homedir(),
+ // react-devtools
+ 'AppData/Local/Google/Chrome/User Data/Default/Extensions/fmkadmapgofadopljbjfkapdkoienihi/4.5.0_0',
+ ),
+ )
+ }
+
+ // Create the browser window.
+ mainWindow = new BrowserWindow({
+ width: 800,
+ height: 600,
+ frame: false,
+ webPreferences: {
+ nodeIntegration: true,
+ webSecurity: false,
+ },
+ })
+
+ mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY)
+
+ if (global.isDev) {
+ // Open the DevTools.
+ mainWindow.webContents.openDevTools()
+ }
+
+ // This must be done here, because registered callbacks in the renderer
+ // process are async and preventDefault is ignored
+ mainWindow.on('close', (e) => {
+ if (!userQuit) {
+ e.preventDefault()
+ }
+ })
+ // mainWindow.on('minimize', () => mainWindow!.hide())
+
+ // Emitted when the window is closed.
+ mainWindow.on('closed', () => {
+ // Dereference the window object, usually you would store windows
+ // in an array if your app supports multi windows, this is the time
+ // when you should delete the corresponding element.
+ mainWindow = undefined
+ })
+}
+
+ipcMain.on('user-quit', () => {
+ userQuit = true
+ app.quit()
+})
+
+// This method will be called when Electron has finished
+// initialization and is ready to create browser windows.
+// Some APIs can only be used after this event occurs.
+app.on('ready', createWindow)
+
+// Quit when all windows are closed.
+app.on('window-all-closed', () => {
+ // On OS X it is common for applications and their menu bar
+ // to stay active until the user quits explicitly with Cmd + Q
+ if (process.platform !== 'darwin') {
+ app.quit()
+ }
+})
+
+app.on('activate', () => {
+ // On OS X it's common to re-create a window in the app when the
+ // dock icon is clicked and there are no other windows open.
+ if (null === mainWindow) {
+ createWindow()
+ }
+})
+
+// In this file you can include the rest of your app's specific main process
+// code. You can also put them in separate files and import them here.
diff --git a/zedd-app/src/plJiraConnector.ts b/zedd-app/src/plJiraConnector.ts
new file mode 100644
index 0000000..6eff68f
--- /dev/null
+++ b/zedd-app/src/plJiraConnector.ts
@@ -0,0 +1,160 @@
+import { compareDesc, differenceInMinutes } from 'date-fns'
+import JiraClient from 'jira-connector'
+// @ts-ignore
+import * as request from 'request'
+
+import { Task } from './AppState'
+import { ClarityTask } from './ClarityState'
+import './index.css'
+import { clarityState } from './renderer'
+import { ZeddSettings } from './ZeddSettings'
+
+// Initialize
+const jar = request.jar()
+let jiraConfig: ZeddSettings['cgJira']
+let jira: JiraClient
+
+export function initJiraClient(jc: ZeddSettings['cgJira']) {
+ jiraConfig = jc
+ const url = new URL(jc.url)
+ jira = new JiraClient({
+ host: url.host,
+ protocol: url.protocol,
+ port: url.port ? +url.port : undefined,
+ path_prefix: url.pathname,
+ basic_auth: {
+ username: jc.username,
+ password: Buffer.from(jc.password, 'base64').toString('utf8'),
+ },
+ cookie_jar: jar,
+ })
+}
+
+let lastJiraCall: Date | undefined = undefined
+
+const externalJiraField = 'customfield_10216'
+const clarityTaskField = 'customfield_10301'
+
+const jiraConnectorErrorToMessage = (x: any) => {
+ console.error(x)
+ const { body, request } = JSON.parse(x)
+ throw new Error(request.method + ' ' + request.uri.href + ' returned ' + body)
+}
+
+export const checkCgJira = (config: ZeddSettings['cgJira']) => {
+ return new Promise((resolve, reject) =>
+ request(
+ {
+ method: 'GET',
+ jar,
+ url: config.url,
+ auth: {
+ username: config.username,
+ password: Buffer.from(config.password, 'base64').toString('utf8'),
+ },
+ },
+ (err: any, response: any) => {
+ if (err) {
+ reject(err)
+ } else if (response.statusCode >= 400) {
+ console.error(response)
+ reject(
+ new Error(
+ response.request.method +
+ ' ' +
+ response.request.uri.href +
+ ' returned ' +
+ response.statusCode +
+ ' ' +
+ response.statusMessage,
+ ),
+ )
+ } else {
+ resolve(response)
+ }
+ },
+ ),
+ )
+}
+
+const callWithJsessionCookie = async
(cb: () => Promise) => {
+ if (!lastJiraCall || differenceInMinutes(new Date(), lastJiraCall) > 10) {
+ await checkCgJira(jiraConfig)
+ lastJiraCall = new Date()
+ }
+
+ return cb()
+}
+
+export const getTasksFromAssignedJiraIssues = (clarityTasks: ClarityTask[]): Promise =>
+ callWithJsessionCookie(async () => {
+ const result = await jira.search
+ .search({
+ jql: jiraConfig.currentIssuesJql,
+ })
+ .catch(jiraConnectorErrorToMessage)
+ console.log(result)
+
+ return Promise.all(result.issues.map((i) => issueInfoToTask(clarityTasks, i)))
+ })
+
+export const getTasksForSearchString = async (s: string): Promise =>
+ callWithJsessionCookie(async () => {
+ const sClean = s.trim().replace('"', '\\\\"')
+ const orKeyMatch = sClean.match(/^[a-z]{1,6}-\d+$/i) ? ` OR key = "${sClean}"` : ''
+ const jql = `(text ~ "${sClean}*"${orKeyMatch}) AND resolution = Unresolved ORDER BY updated DESC`
+ console.log('searching ' + jql)
+ const result = await jira.search
+ .search({
+ jql,
+ })
+ .catch(jiraConnectorErrorToMessage)
+ return Promise.all(result.issues.map((i) => issueInfoToTask(clarityState.tasks, i)))
+ })
+
+const issueInfoToTask = async (clarityTasks: ClarityTask[], i: any): Promise => {
+ if (i.fields.parent) {
+ const result = await callWithJsessionCookie(() =>
+ jira.search
+ .search({
+ jql: `key=${i.fields.parent.key}`,
+ })
+ .catch(jiraConnectorErrorToMessage),
+ )
+ return issueInfoToTask(clarityTasks, result.issues[0])
+ }
+
+ const externalKey = i.fields[externalJiraField]
+ const clarityTaskFieldValue = i.fields[clarityTaskField]?.[0]?.trim()
+ let clarityTaskId: number | undefined
+ if (clarityTaskFieldValue) {
+ clarityTaskId = clarityTasks
+ .filter((t) => t.name === clarityTaskFieldValue)
+ .sort((a, b) => compareDesc(a.start, b.start))[0]?.intId
+ if (!clarityTaskId) {
+ console.warn(
+ "No clarity-account found for JIRA Clarity-Task Field '" + clarityTaskFieldValue + "'",
+ )
+ }
+ } else if (externalKey) {
+ clarityTaskId = clarityTasks
+ .filter((t) => t.name.includes(externalKey))
+ .sort((a, b) => compareDesc(a.start, b.start))[0]?.intId
+ if (!clarityTaskId) {
+ console.warn("No clarity-account found for exernal JIRA-Key '" + externalKey + "'")
+ }
+ }
+ console.log(
+ 'resolved ',
+ i.fields.summary,
+ clarityTaskId,
+ clarityTasks.find((t) => t.intId === clarityTaskId),
+ )
+ return new Task(
+ (externalKey ? externalKey + '/' : '') +
+ i.key +
+ ': ' +
+ i.fields.summary.replace(new RegExp('^' + externalKey + ':? ?'), ''),
+ clarityTaskId,
+ )
+}
diff --git a/zedd-app/src/renderer.ts b/zedd-app/src/renderer.ts
new file mode 100644
index 0000000..5ad2422
--- /dev/null
+++ b/zedd-app/src/renderer.ts
@@ -0,0 +1,356 @@
+import { parse as dateParse } from 'date-fns'
+import { ipcRenderer, remote, BrowserWindow, MenuItemConstructorOptions, Rectangle } from 'electron'
+// @ts-ignore
+import { ToastNotification } from 'electron-windows-notifications'
+import { promises as fsp } from 'fs'
+import { autorun, computed } from 'mobx'
+import * as path from 'path'
+import * as React from 'react'
+import * as ReactDOM from 'react-dom'
+
+import { dateFormatString, format, AppState, TimeSlice } from './AppState'
+import { ClarityState } from './ClarityState'
+import { AppGui } from './components/AppGui'
+import './index.css'
+import {
+ getTasksForSearchString,
+ getTasksFromAssignedJiraIssues,
+ initJiraClient,
+ checkCgJira,
+} from './plJiraConnector'
+import toastTemplate from './toast-template.xml'
+import {
+ fileExists,
+ formatMinutes as formatMinutesBT,
+ formatMinutesHHmm,
+ mkdirIfNotExists,
+ round,
+} from './util'
+import { ZeddSettings } from './ZeddSettings'
+import { floor } from '../out/zedd-app-win32-x64/resources/app/src/util'
+
+const { Tray, Menu, getCurrentWindow, app, shell, screen: electronScreen, powerMonitor } = remote
+const currentWindow = getCurrentWindow()
+const saveDir = path.join(app.getPath('home'), 'zedd')
+
+const clarityDir = path.join(saveDir, 'clarity')
+
+const userConfigFile = path.join(saveDir, 'zeddconfig.json')
+
+const d = (...x: any[]) => console.log('renderer.ts', ...x)
+
+function showNotification(
+ title: string,
+ text: string,
+ [button1Text, button1Args]: [string, string],
+ [button2Text, button2Args]: [string, string],
+ cb: (notification: any, args: { arguments: string }) => void,
+) {
+ const notification = new ToastNotification({
+ template: toastTemplate,
+ strings: [title, text, button1Text, button1Args, button2Text, button2Args],
+ })
+ notification.on('activated', cb)
+ notification.show()
+}
+
+function quit() {
+ ipcRenderer.send('user-quit')
+}
+
+const menuItems = [
+ {
+ label: 'Open Config Dir',
+ click: () => shell.showItemInFolder(userConfigFile),
+ },
+ { label: 'Edit Settings', click: () => (state.settingsDialogOpen = true) },
+
+ { label: 'Github', click: () => shell.openExternal('https://github.com/NaridaL/zedd') },
+ { label: 'Open Dev', click: () => getCurrentWindow().webContents.openDevTools() },
+ { label: 'Reload Config', click: () => getCurrentWindow().reload() },
+ { label: 'Quit', click: () => quit() },
+]
+let config: ZeddSettings
+let state: AppState
+let cleanup: () => void
+const saveWindowBounds = ({ sender }: { sender: BrowserWindow }) => {
+ if (state && !state.hoverMode) {
+ if (sender.isMaximized()) {
+ state.bounds.maximized = true
+ } else {
+ state.bounds.maximized = false
+ state.bounds.normal = sender.getBounds()
+ }
+ }
+ if (state && state.hoverMode) {
+ state.bounds.hover = sender.getBounds()
+ }
+}
+
+async function setup() {
+ const currentWindowEvents: [string, Function][] = []
+ try {
+ state = await AppState.loadFromDir(path.join(saveDir, 'data'))
+ d(state)
+ d('Cleaning save dir')
+ const deletedFileCount = await AppState.cleanSaveDir(path.join(saveDir, 'data'))
+ d(`Deleted ${deletedFileCount} files.`)
+ } catch (e) {
+ console.error(e)
+ console.error('Could not load state from ' + path.join(saveDir, 'data'))
+ state = new AppState()
+ }
+ state.startInterval()
+ state.config = config
+ state.idleSliceNotificationCallback = (when) => {
+ console.log('You were away ' + format(when.start) + ' - ' + format(when.end))
+ showNotification(
+ 'You were away ' + format(when.start) + ' - ' + format(when.end),
+ 'Close to discard or choose what to assign.',
+ [
+ state.currentTask.name.substring(0),
+ format(when.start) + ' - ' + format(when.end) + ' ' + state.currentTask.name,
+ ],
+ ['Other', 'other'],
+ (_, wargs) => {
+ if ('other' === wargs.arguments) {
+ } else {
+ const [, startString, endString, taskName] = wargs.arguments.match(
+ /(.{16}) - (.{16}) (.*)/,
+ )!
+
+ const now = new Date()
+ state.addSlice(
+ new TimeSlice(
+ dateParse(startString, dateFormatString, now),
+ dateParse(endString, dateFormatString, now),
+ state.getTaskForName(taskName),
+ ),
+ )
+ }
+ },
+ )
+ }
+
+ const boundsContained = (outer: Rectangle, inner: Rectangle, margin = 0) =>
+ outer.x - inner.x <= margin &&
+ outer.y - inner.y <= margin &&
+ inner.x + inner.width - (outer.x + outer.width) <= margin &&
+ inner.y + inner.height - (outer.y + outer.height) <= margin
+
+ console.log(electronScreen.getPrimaryDisplay().bounds, state.bounds)
+
+ const saveInterval = setInterval(
+ () => AppState.saveToDir(state, path.join(saveDir, 'data')),
+ 10 * 1000,
+ )
+
+ const lastActionInterval = setInterval(
+ () => (state.lastAction = powerMonitor.getSystemIdleTime()),
+ 1000,
+ )
+
+ currentWindowEvents.push([
+ 'close',
+ (e: Electron.Event) => {
+ if (config.keepHovering) {
+ state.hoverMode = true
+ } else {
+ currentWindow.hide()
+ }
+ },
+ ])
+
+ const hoverModeOff = () => (state.hoverMode = false)
+ const restoreUnmaximizedBoundsIfNotHoverMode = () =>
+ !state.hoverMode && currentWindow.setBounds(state.bounds.normal)
+
+ currentWindowEvents.push(['resize', saveWindowBounds])
+ currentWindowEvents.push(['maximize', saveWindowBounds])
+ currentWindowEvents.push(['maximize', hoverModeOff])
+ currentWindowEvents.push(['unmaximize', restoreUnmaximizedBoundsIfNotHoverMode])
+ currentWindowEvents.push(['move', saveWindowBounds])
+ const currentIconImage = computed(() => {
+ const NUMBER_OF_SAMPLES = 12
+ if (state.timingInProgess) {
+ return (
+ app.getAppPath() +
+ '/' +
+ 'icons/progress' +
+ floor((state.getDayProgress(new Date()) % 1) * NUMBER_OF_SAMPLES) +
+ '.ico'
+ )
+ } else {
+ return app.getAppPath() + '/' + 'icons/paused.ico'
+ }
+ })
+ const tray = new Tray(currentIconImage.get())
+ tray.on('double-click', () => {
+ getCurrentWindow().show()
+ })
+
+ const cleanupTrayMenuAutorun = autorun(() => {
+ tray.setContextMenu(
+ Menu.buildFromTemplate([
+ // Quit first, so it is the furthest from the mouse
+ {
+ label: 'Quit',
+ type: 'normal',
+ click: () => quit(),
+ },
+
+ { type: 'separator' },
+
+ ...state.getSuggestedTasks().map(
+ (t): MenuItemConstructorOptions => ({
+ label: t.name,
+ type: 'checkbox',
+ checked: state.currentTask === t,
+ click: (x) => (state.currentTask = state.getTaskForName(x.label)),
+ }),
+ ),
+
+ { type: 'separator' },
+
+ {
+ label: state.timingInProgess ? '■ Stop Timing' : '▶️ Start Timing',
+ type: 'normal',
+ click: () => state.toggleTimingInProgress(),
+ },
+ ]),
+ )
+ })
+ const cleanupIconAutorun = autorun(() => {
+ tray.setImage(currentIconImage.get())
+ currentWindow.setIcon(currentIconImage.get())
+ })
+ const cleanupTrayTooltipAutorun = autorun(() => {
+ const workedTime = formatMinutesHHmm(state.getDayWorkedMinutes(new Date()))
+ const timingInfo =
+ state.timingInProgess && state.currentTask
+ ? '▶️ Currently Timing: ' +
+ state.currentTask.name +
+ ' ' +
+ formatMinutesBT(state.getTaskMinutes(state.currentTask)) +
+ 'BT'
+ : '■ Not Timing'
+ tray.setToolTip(workedTime + ' ' + timingInfo)
+ document.title = workedTime + ' ' + timingInfo
+ })
+
+ let hoverModeTimer: NodeJS.Timeout | undefined
+ currentWindowEvents.push(
+ [
+ 'blur',
+ () =>
+ config.keepHovering &&
+ !state.hoverMode &&
+ (hoverModeTimer = setTimeout(() => (d('uhm'), (state.hoverMode = true)), 15_000)),
+ ],
+ ['focus', () => hoverModeTimer && clearTimeout(hoverModeTimer)],
+ )
+
+ const cleanupHoverModeAutorun = autorun(() => {
+ currentWindow.setSkipTaskbar(state.hoverMode)
+ currentWindow.setAlwaysOnTop(state.hoverMode)
+ // currentWindow.resizable = !state.hoverMode
+ console.log('currentWindow.resizable', currentWindow.resizable)
+ // console.log('showing:', state.hoverMode, !currentWindow.isVisible)
+ // state.hoverMode && !currentWindow.isVisible && currentWindow.show()
+ if (state.hoverMode) {
+ currentWindow.isMaximized && currentWindow.unmaximize()
+ currentWindow.setBounds({
+ ...state.bounds.hover,
+ height: 32,
+ width: Math.min(800, state.bounds.hover.width),
+ })
+ } else {
+ currentWindow.setBounds(state.bounds.normal)
+ if (state.bounds.maximized) {
+ currentWindow.maximize()
+ }
+ }
+ })
+
+ currentWindowEvents.forEach(([x, y]) => currentWindow.on(x as any, y))
+
+ return () => {
+ clearInterval(saveInterval)
+ clearInterval(lastActionInterval)
+ cleanupIconAutorun()
+ cleanupTrayMenuAutorun()
+ cleanupTrayTooltipAutorun()
+ state.cleanup()
+ tray.destroy()
+ cleanupHoverModeAutorun()
+ currentWindowEvents.forEach(([x, y]) => currentWindow.removeListener(x as any, y))
+ }
+}
+
+export const clarityState = new ClarityState(clarityDir)
+
+function renderDom() {
+ ReactDOM.render(
+ React.createElement(AppGui, {
+ state,
+ checkCgJira,
+ clarityState,
+ menuItems,
+ getTasksForSearchString: (s) =>
+ getTasksForSearchString(s).then((ts) =>
+ ts.filter((t) => !state.tasks.some((t2) => t2.name === t.name)),
+ ),
+ }),
+ document.getElementById('react-root'),
+ )
+}
+
+async function main() {
+ await mkdirIfNotExists(saveDir)
+
+ config = (await fileExists(userConfigFile))
+ ? await ZeddSettings.readFromFile(userConfigFile)
+ : new ZeddSettings(userConfigFile)
+
+ d('clarityDir=' + clarityDir)
+ clarityState.init()
+ clarityState.nikuLink = config.nikuLink
+
+ // await sleep(5000);
+ // importAndSaveClarityTasks();
+ try {
+ await clarityState.loadStateFromFile()
+ } catch (e) {
+ console.error('Could not load clarity tasks')
+ console.error(e)
+ }
+
+ try {
+ initJiraClient(config.cgJira)
+ } catch (e) {
+ console.error('Could not init JiraClient')
+ console.error(e)
+ }
+ getTasksFromAssignedJiraIssues(clarityState.tasks)
+ .then((e) => (state.assignedIssueTasks = e.map((t) => state.normalizeTask(t))))
+ .catch((err) => state.errors.push(err.message))
+
+ cleanup = await setup()
+ // window.addEventListener('beforeunload', cleanup)
+
+ renderDom()
+}
+main()
+
+if (module.hot) {
+ module.hot.accept('./components/AppGui', () => {
+ renderDom()
+ })
+ module.hot.accept('./AppState', async () => {
+ cleanup()
+ cleanup = await setup()
+ renderDom()
+ })
+ module.hot.dispose(() => cleanup())
+ module.hot.accept()
+}
diff --git a/zedd-app/src/toast-template.xml b/zedd-app/src/toast-template.xml
new file mode 100644
index 0000000..86290a2
--- /dev/null
+++ b/zedd-app/src/toast-template.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ %s
+ %s
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/zedd-app/src/typings.d.ts b/zedd-app/src/typings.d.ts
new file mode 100644
index 0000000..28c2fc6
--- /dev/null
+++ b/zedd-app/src/typings.d.ts
@@ -0,0 +1,4 @@
+declare const MAIN_WINDOW_WEBPACK_ENTRY: string
+declare module '*.xml' {
+ export default string
+}
diff --git a/zedd-app/src/util.test.ts b/zedd-app/src/util.test.ts
new file mode 100644
index 0000000..9b70d48
--- /dev/null
+++ b/zedd-app/src/util.test.ts
@@ -0,0 +1,28 @@
+import * as assert from 'assert'
+import { parseISO } from 'date-fns'
+import { splitIntervalIntoCalendarDays } from './util'
+
+describe('splitIntervalIntoCalendarDays', () => {
+ it('works', () => {
+ assert.deepStrictEqual(
+ splitIntervalIntoCalendarDays({
+ start: parseISO('2019-10-20T11:35'),
+ end: parseISO('2019-10-22T10:00'),
+ }),
+ [
+ {
+ start: parseISO('2019-10-20T11:35'),
+ end: parseISO('2019-10-21T00:00'),
+ },
+ {
+ start: parseISO('2019-10-21T00:00'),
+ end: parseISO('2019-10-22T00:00'),
+ },
+ {
+ start: parseISO('2019-10-22T00:00'),
+ end: parseISO('2019-10-22T10:00'),
+ },
+ ],
+ )
+ })
+})
diff --git a/zedd-app/src/util.ts b/zedd-app/src/util.ts
new file mode 100644
index 0000000..0b96bae
--- /dev/null
+++ b/zedd-app/src/util.ts
@@ -0,0 +1,189 @@
+import * as chroma from 'chroma.ts'
+import {
+ addDays,
+ addMinutes,
+ addSeconds,
+ compareDesc,
+ endOfMonth,
+ format as formatDate,
+ isBefore,
+ isEqual,
+ lastDayOfISOWeek,
+ parse as parseDate,
+ roundToNearestMinutes,
+ startOfDay,
+ startOfISOWeek,
+ startOfMinute,
+ startOfMonth,
+} from 'date-fns'
+import { promises as fsp, PathLike } from 'fs'
+import * as fs from 'fs'
+import { promisify } from 'util'
+
+export const FILE_DATE_FORMAT = "yyyyMMdd'T'HHmm"
+
+export const fileExists = promisify(fs.exists)
+
+/**
+ *
+ * @param dir
+ * @param regex The first capturing group must match a Date in the format FILE_DATE_FORMAT.
+ */
+export const getLatestFileInDir = async (dir: PathLike, regex: RegExp): Promise<[string, Date]> => {
+ const filesWithDate = await readFilesWithDate(dir, regex)
+ if (0 === filesWithDate.length) {
+ throw new Error(
+ `Could not find file matching ${regex} in ${dir}. files=${filesWithDate.map(([f]) => f)}`,
+ )
+ }
+ return filesWithDate[0]
+}
+export const readFilesWithDate = async (
+ dir: PathLike,
+ regex: RegExp,
+): Promise<[string, Date][]> => {
+ const files = await fsp.readdir(dir)
+ return files
+ .filter((f) => regex.test(f))
+ .map((f) => [f, parseDate(regex.exec(f)![1], FILE_DATE_FORMAT, new Date())] as [string, Date])
+ .sort(([_bFile, aDate], [_aFile, bDate]) => compareDesc(aDate, bDate))
+}
+
+/**
+ * Sorts the files in a directory descendingly by the parsed date. Calls the callback on the first
+ * file. If an error is thrown, the callback is called on the second file, etc...
+ *
+ * @param dir Directory from which to read/parse the date from files.
+ * @param regex Regex matching the filenames to consider. The first capturing group must match a
+ * Date in the format FILE_DATE_FORMAT.
+ * @param cb The callback called with file and date.
+ */
+export const tryWithFilesInDir = async (
+ dir: PathLike,
+ regex: RegExp,
+ cb: (f: string, date: Date) => Promise,
+): Promise => {
+ const filesWithDate = await readFilesWithDate(dir, regex)
+ for (const [f, date] of filesWithDate) {
+ try {
+ return await cb(f, date)
+ } catch (e) {
+ console.warn('Error calling callback with', f, e)
+ }
+ }
+ throw new Error(
+ `Tried ${filesWithDate.length} files. None returned a value. See log for exceptions.`,
+ )
+}
+
+export const { abs, floor, ceil, round, imul, min } = Math
+
+export async function mkdirIfNotExists(
+ dir: PathLike,
+ options?: number | string | fs.MakeDirectoryOptions | null,
+) {
+ const dirExists = await fileExists(dir)
+ if (!dirExists) {
+ await fsp.mkdir(dir, options)
+ }
+ return !dirExists
+}
+
+export const startOfNextMinute = (d: Date | number) => startOfMinute(addMinutes(d, 1))
+
+export const startOfNextDay = (d: Date | number) => startOfDay(addDays(d, 1))
+
+export function splitIntervalIntoCalendarDays(interval: Interval): Interval[] {
+ const result = []
+ let { start, end } = interval
+ while (end > startOfNextDay(start)) {
+ result.push({ start: start, end: startOfNextDay(start) })
+ start = startOfNextDay(start)
+ }
+ result.push({ start, end })
+ return result
+}
+export const sum = (nums: number[]) => nums.reduce((a, b) => a + b, 0)
+
+export const formatMinutesHHmm = (mins: number) => {
+ return ((mins / 60) | 0) + ':' + ('' + (mins % 60)).padStart(2, '0')
+}
+export const formatMinutes = (mins: number) => {
+ return (mins / (60 * 8)).toLocaleString('de-DE', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+}
+
+export const isoWeekInterval = (d: Date | number) => ({
+ start: startOfISOWeek(d),
+ end: lastDayOfISOWeek(d),
+})
+
+export const toggle = (arr: any[], value: any) => {
+ const idx = arr.indexOf(value)
+ if (idx !== -1) {
+ arr.splice(idx, 1)
+ } else {
+ arr.push(value)
+ }
+}
+
+export const intRange = (startIncl: number, endExcl: number) =>
+ Array(endExcl - startIncl)
+ .fill(undefined)
+ .map((_, i) => startIncl + i)
+
+export const monthInterval = (d: Date | number) => ({ start: startOfMonth(d), end: endOfMonth(d) })
+
+/**
+ * Given an array `input`, returns a new array such that for all pairs (s, t) of the
+ * new array, cmp(s, t) == false
+ */
+export const uniqCustom = (input: T[], equals: (s: T, t: T) => boolean): T[] => {
+ const result: T[] = []
+ for (const s of input) {
+ if (!result.some((t) => equals(s, t))) {
+ result.push(s)
+ }
+ }
+ return result
+}
+
+export const businessWeekInterval = (d: Date | number) => ({
+ start: startOfISOWeek(d),
+ end: addDays(lastDayOfISOWeek(d), -2),
+})
+
+export const getDayInterval = (date: Date) => {
+ const start = startOfDay(date)
+ return { start, end: addDays(start, 1) }
+}
+
+export const isBeforeOrEqual = (d: Date, d2: Date) => isBefore(d, d2) || isEqual(d, d2)
+
+export const ilog = (x: T, ...more: any[]) => (console.log(x, ...more), x)
+
+export const roundDownToQuarterHour = (date: Date) => {
+ return roundToNearestMinutes(addSeconds(date, -7.5 * 60), { nearestTo: 15 })
+}
+
+export const isoDay = (date: Date) => {
+ return formatDate(date, 'yyyy-MM-dd')
+}
+
+export function omap(x: { [P in K]: T }, f: (t: T) => M) {
+ const result: { [P in K]: M } = {} as any
+ for (const k of Object.keys(x)) result[k as K] = f(x[k as K])
+ return result
+}
+
+export const stringHash = (str: string) => {
+ let h: number = 0
+ for (let i = 0; i < str.length; i++) h = (imul(31, h) + str.charCodeAt(i)) | 0
+ return h
+}
+
+export const stringHashColor = (str: string) => {
+ return chroma.num(stringHash(str) & 0xffffff)
+}
diff --git a/zedd-app/tsconfig.json b/zedd-app/tsconfig.json
new file mode 100644
index 0000000..11f7c76
--- /dev/null
+++ b/zedd-app/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "removeComments": false,
+ "preserveConstEnums": true,
+ "sourceMap": true,
+ "declaration": true,
+ "noImplicitAny": true,
+ "noImplicitReturns": true,
+ "suppressImplicitAnyIndexErrors": true,
+ "strictNullChecks": true,
+ "noUnusedLocals": true,
+ "noImplicitThis": true,
+ "allowJs": true,
+ "noUnusedParameters": true,
+ "importHelpers": true,
+ "noEmitHelpers": true,
+ "module": "esnext",
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "noEmit": true,
+ "moduleResolution": "node",
+ "pretty": true,
+ "target": "es2019",
+ "jsx": "react",
+ "allowSyntheticDefaultImports": true,
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true
+ },
+ "formatCodeOptions": {
+ "indentSize": 2,
+ "tabSize": 2
+ }
+}
\ No newline at end of file
diff --git a/zedd-app/tslint.json b/zedd-app/tslint.json
new file mode 100644
index 0000000..f13f46f
--- /dev/null
+++ b/zedd-app/tslint.json
@@ -0,0 +1,36 @@
+{
+ "defaultSeverity": "error",
+ "extends": ["tslint-config-prettier"],
+ "jsRules": {},
+ "rules": {
+ "no-var-keyword": true,
+ "yoda": true,
+ "triple-equals": true,
+ "member-access": true,
+ "member-ordering": [
+ true,
+ {
+ "order": "fields-first"
+ }
+ ],
+ "prefer-const": [
+ true,
+ {
+ "destructuring": "all"
+ }
+ ],
+ "no-unused-expression": true,
+ "no-unnecessary-qualifier": true,
+ "no-null-keyword": true,
+ "no-unnecessary-type-assertion": true,
+ "no-string-throw": true,
+ "ordered-imports": [
+ true,
+ {
+ "import-sources-order": "lowercase-last",
+ "named-imports-order": "lowercase-first"
+ }
+ ]
+ },
+ "rulesDirectory": []
+}
diff --git a/zedd-app/webpack.main.config.js b/zedd-app/webpack.main.config.js
new file mode 100644
index 0000000..7dd447a
--- /dev/null
+++ b/zedd-app/webpack.main.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ /**
+ * This is the main entry point for your application, it's the first file
+ * that runs in the main process.
+ */
+ entry: "./src/main.ts",
+ // Put your normal webpack config below here
+ module: {
+ rules: require("./webpack.rules")
+ }
+};
diff --git a/zedd-app/webpack.renderer.config.js b/zedd-app/webpack.renderer.config.js
new file mode 100644
index 0000000..c94615a
--- /dev/null
+++ b/zedd-app/webpack.renderer.config.js
@@ -0,0 +1,37 @@
+const rules = require('./webpack.rules')
+
+rules.push({
+ test: /\.css$/,
+ use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
+})
+module.exports = {
+ // Put your normal webpack config below here
+ resolve: {
+ // Add `.ts` and `.tsx` as a resolvable extension.
+ extensions: ['.ts', '.tsx', '.js', '.xml'],
+ },
+ module: {
+ rules,
+ },
+ externals: [
+ function(context, request, callback) {
+ if (
+ [
+ 'bindings',
+ // 'mobx-react',
+ // 'mobx',
+ // 'react-dom',
+ // 'react',
+ 'selenium-webdriver',
+ 'selenium-webdriver/chrome',
+ 'electron-windows-notifications',
+ 'zedd-win32',
+ ].includes(request) ||
+ /^\w:\\.*/.test(request)
+ ) {
+ return callback(null, 'commonjs ' + request)
+ }
+ callback()
+ },
+ ],
+}
diff --git a/zedd-app/webpack.rules.js b/zedd-app/webpack.rules.js
new file mode 100644
index 0000000..f0a727c
--- /dev/null
+++ b/zedd-app/webpack.rules.js
@@ -0,0 +1,62 @@
+module.exports = [
+ {
+ test: /\.(txt|xml)$/i,
+ use: 'raw-loader',
+ },
+ // Add support for native node modules
+ {
+ test: /\.node$/,
+ use: 'node-loader',
+ },
+ {
+ test: /\.(m?js|node|selenium-webdriver)$/,
+ parser: { amd: false },
+ use: {
+ loader: '@marshallofsound/webpack-asset-relocator-loader',
+ options: {
+ outputAssetBase: 'native_modules',
+ },
+ },
+ },
+ {
+ test: /\.tsx?$/,
+ exclude: /(node_modules|.webpack)/,
+ loaders: [
+ // use babel-plugin-import to convert import {...} from '@material-ui/icons'
+ // to default icons. REMOVING THIS WILL LEAD TO LONG REBUILD TIMES!
+ // see https://material-ui.com/guides/minimizing-bundle-size/
+ {
+ loader: 'babel-loader',
+ options: {
+ presets: [],
+ plugins: [
+ [
+ 'babel-plugin-import',
+ {
+ libraryName: '@material-ui/core',
+ libraryDirectory: 'esm',
+ camel2DashComponentName: false,
+ },
+ 'core',
+ ],
+ [
+ 'babel-plugin-import',
+ {
+ libraryName: '@material-ui/icons',
+ libraryDirectory: 'esm',
+ camel2DashComponentName: false,
+ },
+ 'icons',
+ ],
+ ],
+ },
+ },
+ {
+ loader: 'ts-loader',
+ options: {
+ transpileOnly: true,
+ },
+ },
+ ],
+ },
+]
diff --git a/zedd-clarity/.gitignore b/zedd-clarity/.gitignore
new file mode 100644
index 0000000..9209ef5
--- /dev/null
+++ b/zedd-clarity/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+out
diff --git a/zedd-clarity/package-lock.json b/zedd-clarity/package-lock.json
new file mode 100644
index 0000000..59136a3
--- /dev/null
+++ b/zedd-clarity/package-lock.json
@@ -0,0 +1,633 @@
+{
+ "name": "zedd-clarity",
+ "version": "1.0.0",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "@fast-csv/format": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.1.2.tgz",
+ "integrity": "sha512-ERW7QuIEYa/eJArjzEKWe8Sdj9aNBLDjMfZw+EpcIb9CFOMz7SNZl8ElWOjcUU8Gj088TerBSGvUoCZN0SoXig==",
+ "requires": {
+ "lodash.escaperegexp": "^4.1.2",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isequal": "^4.5.0",
+ "lodash.isfunction": "^3.0.9",
+ "lodash.isnil": "^4.0.0"
+ }
+ },
+ "@fast-csv/parse": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.1.2.tgz",
+ "integrity": "sha512-GmgHo1EHB5/M2v3tAj83l77PxGCxxYvx0s/PoWM/fp5PrS8ECtX0THKsuJ0jO3+BkpaHhX3/UZFOBnfcEw1FkQ==",
+ "requires": {
+ "lodash.escaperegexp": "^4.1.2",
+ "lodash.groupby": "^4.6.0",
+ "lodash.isfunction": "^3.0.9",
+ "lodash.isnil": "^4.0.0",
+ "lodash.isundefined": "^3.0.1",
+ "lodash.uniq": "^4.5.0"
+ }
+ },
+ "@types/deep-equal": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz",
+ "integrity": "sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg=="
+ },
+ "@types/lodash": {
+ "version": "4.14.149",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
+ "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ=="
+ },
+ "@types/node": {
+ "version": "12.12.34",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.34.tgz",
+ "integrity": "sha512-BneGN0J9ke24lBRn44hVHNeDlrXRYF+VRp0HbSUNnEZahXGAysHZIqnf/hER6aabdBgzM4YOV4jrR8gj4Zfi0g=="
+ },
+ "@types/selenium-webdriver": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.0.9.tgz",
+ "integrity": "sha512-HopIwBE7GUXsscmt/J0DhnFXLSmO04AfxT6b8HAprknwka7pqEWquWDMXxCjd+NUHK9MkCe1SDKKsMiNmCItbQ=="
+ },
+ "arg": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
+ },
+ "balanced-match": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "buffer-from": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
+ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+ },
+ "date-fns": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.11.1.tgz",
+ "integrity": "sha512-3RdUoinZ43URd2MJcquzBbDQo+J87cSzB8NkXdZiN5ia1UNyep0oCyitfiL88+R7clGTeq/RniXAc16gWyAu1w=="
+ },
+ "deep-equal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.1.tgz",
+ "integrity": "sha512-7Et6r6XfNW61CPPCIYfm1YPGSmh6+CliYeL4km7GWJcpX5LTAflGF8drLLR+MZX+2P3NZfAfSduutBbSWqER4g==",
+ "requires": {
+ "es-abstract": "^1.16.3",
+ "es-get-iterator": "^1.0.1",
+ "is-arguments": "^1.0.4",
+ "is-date-object": "^1.0.1",
+ "is-regex": "^1.0.4",
+ "isarray": "^2.0.5",
+ "object-is": "^1.0.1",
+ "object-keys": "^1.1.1",
+ "regexp.prototype.flags": "^1.2.0",
+ "side-channel": "^1.0.1",
+ "which-boxed-primitive": "^1.0.1",
+ "which-collection": "^1.0.0"
+ }
+ },
+ "define-properties": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+ "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+ "requires": {
+ "object-keys": "^1.0.12"
+ }
+ },
+ "diff": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="
+ },
+ "es-abstract": {
+ "version": "1.17.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
+ "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
+ "requires": {
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.1",
+ "is-callable": "^1.1.5",
+ "is-regex": "^1.0.5",
+ "object-inspect": "^1.7.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.0",
+ "string.prototype.trimleft": "^2.1.1",
+ "string.prototype.trimright": "^2.1.1"
+ }
+ },
+ "es-get-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.0.tgz",
+ "integrity": "sha512-UfrmHuWQlNMTs35e1ypnvikg6jCz3SK8v8ImvmDsh36fCVUR1MqoFDiyn0/k52C8NqO3YsO8Oe0azeesNuqSsQ==",
+ "requires": {
+ "es-abstract": "^1.17.4",
+ "has-symbols": "^1.0.1",
+ "is-arguments": "^1.0.4",
+ "is-map": "^2.0.1",
+ "is-set": "^2.0.1",
+ "is-string": "^1.0.5",
+ "isarray": "^2.0.5"
+ }
+ },
+ "es-to-primitive": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+ "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "requires": {
+ "is-callable": "^1.1.4",
+ "is-date-object": "^1.0.1",
+ "is-symbol": "^1.0.2"
+ }
+ },
+ "fast-csv": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.1.3.tgz",
+ "integrity": "sha512-W2ifln3p+/Fv2tPRQq5Et8BdRb6/f4EhncmRMZHWlvZHr/s9uMe7ZVDtwD1kskwKDmTLsq63hPcJDtO4NYZr9w==",
+ "requires": {
+ "@fast-csv/format": "^4.1.2",
+ "@fast-csv/parse": "^4.1.2",
+ "@types/node": "^12.12.17"
+ }
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+ },
+ "glob": {
+ "version": "7.1.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+ "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
+ "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg=="
+ },
+ "immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "is-arguments": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz",
+ "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA=="
+ },
+ "is-bigint": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.0.tgz",
+ "integrity": "sha512-t5mGUXC/xRheCK431ylNiSkGGpBp8bHENBcENTkDT6ppwPzEVxNGZRvgvmOEfbWkFhA7D2GEuE2mmQTr78sl2g=="
+ },
+ "is-boolean-object": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.0.1.tgz",
+ "integrity": "sha512-TqZuVwa/sppcrhUCAYkGBk7w0yxfQQnxq28fjkO53tnK9FQXmdwz2JS5+GjsWQ6RByES1K40nI+yDic5c9/aAQ=="
+ },
+ "is-callable": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
+ "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
+ },
+ "is-date-object": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz",
+ "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g=="
+ },
+ "is-map": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.1.tgz",
+ "integrity": "sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw=="
+ },
+ "is-number-object": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz",
+ "integrity": "sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw=="
+ },
+ "is-regex": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
+ "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "is-set": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.1.tgz",
+ "integrity": "sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA=="
+ },
+ "is-string": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz",
+ "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ=="
+ },
+ "is-symbol": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
+ "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
+ "requires": {
+ "has-symbols": "^1.0.1"
+ }
+ },
+ "is-weakmap": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz",
+ "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA=="
+ },
+ "is-weakset": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.1.tgz",
+ "integrity": "sha512-pi4vhbhVHGLxohUw7PhGsueT4vRGFoXhP7+RGN0jKIv9+8PWYCQTqtADngrxOm2g46hoH0+g8uZZBzMrvVGDmw=="
+ },
+ "isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
+ },
+ "jszip": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.2.2.tgz",
+ "integrity": "sha512-NmKajvAFQpbg3taXQXr/ccS2wcucR1AZ+NtyWp2Nq7HHVsXhcJFR8p0Baf32C2yVvBylFWVeKf+WI2AnvlPhpA==",
+ "requires": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "set-immediate-shim": "~1.0.1"
+ }
+ },
+ "lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "requires": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "lodash": {
+ "version": "4.17.15",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
+ "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+ },
+ "lodash.escaperegexp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
+ "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c="
+ },
+ "lodash.groupby": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
+ "integrity": "sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E="
+ },
+ "lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY="
+ },
+ "lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
+ },
+ "lodash.isfunction": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
+ "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="
+ },
+ "lodash.isnil": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
+ "integrity": "sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw="
+ },
+ "lodash.isundefined": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
+ "integrity": "sha1-I+89lTVWUgOmbO/VuDD4SJEa+0g="
+ },
+ "lodash.uniq": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+ "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
+ },
+ "make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
+ },
+ "minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "object-inspect": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
+ "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw=="
+ },
+ "object-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.2.tgz",
+ "integrity": "sha512-Epah+btZd5wrrfjkJZq1AOB9O6OxUQto45hzFd7lXGrpHPGE0W1k+426yrZV+k6NJOzLNNW/nVsmZdIWsAqoOQ=="
+ },
+ "object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
+ },
+ "object.assign": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
+ "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
+ "requires": {
+ "define-properties": "^1.1.2",
+ "function-bind": "^1.1.1",
+ "has-symbols": "^1.0.0",
+ "object-keys": "^1.0.11"
+ }
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "os-tmpdir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+ "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
+ },
+ "pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+ }
+ }
+ },
+ "regexp.prototype.flags": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz",
+ "integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==",
+ "requires": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.0-next.1"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "selenium-webdriver": {
+ "version": "4.0.0-alpha.7",
+ "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.0.0-alpha.7.tgz",
+ "integrity": "sha512-D4qnTsyTr91jT8f7MfN+OwY0IlU5+5FmlO5xlgRUV6hDEV8JyYx2NerdTEqDDkNq7RZDYc4VoPALk8l578RBHw==",
+ "requires": {
+ "jszip": "^3.2.2",
+ "rimraf": "^2.7.1",
+ "tmp": "0.0.30"
+ }
+ },
+ "set-immediate-shim": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
+ "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
+ },
+ "side-channel": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.2.tgz",
+ "integrity": "sha512-7rL9YlPHg7Ancea1S96Pa8/QWb4BtXL/TZvS6B8XFetGBeuhAsfmUspK6DokBeZ64+Kj9TCNRD/30pVz1BvQNA==",
+ "requires": {
+ "es-abstract": "^1.17.0-next.1",
+ "object-inspect": "^1.7.0"
+ }
+ },
+ "sleep-promise": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/sleep-promise/-/sleep-promise-8.0.1.tgz",
+ "integrity": "sha1-jXlaJ+ojlT32tSuRCB5eImZZk8U="
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+ },
+ "source-map-support": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz",
+ "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==",
+ "requires": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "string.prototype.trimend": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.0.tgz",
+ "integrity": "sha512-EEJnGqa/xNfIg05SxiPSqRS7S9qwDhYts1TSLR1BQfYUfPe1stofgGKvwERK9+9yf+PpfBMlpBaCHucXGPQfUA==",
+ "requires": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "string.prototype.trimleft": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
+ "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
+ "requires": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5",
+ "string.prototype.trimstart": "^1.0.0"
+ }
+ },
+ "string.prototype.trimright": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
+ "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
+ "requires": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5",
+ "string.prototype.trimend": "^1.0.0"
+ }
+ },
+ "string.prototype.trimstart": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.0.tgz",
+ "integrity": "sha512-iCP8g01NFYiiBOnwG1Xc3WZLyoo+RuBymwIlWncShXDDJYWN6DbnM3odslBJdgCdRlq94B5s63NWAZlcn2CS4w==",
+ "requires": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "tmp": {
+ "version": "0.0.30",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.30.tgz",
+ "integrity": "sha1-ckGdSovn1s51FI/YsyTlk6cRwu0=",
+ "requires": {
+ "os-tmpdir": "~1.0.1"
+ }
+ },
+ "ts-node": {
+ "version": "8.8.1",
+ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.8.1.tgz",
+ "integrity": "sha512-10DE9ONho06QORKAaCBpPiFCdW+tZJuY/84tyypGtl6r+/C7Asq0dhqbRZURuUlLQtZxxDvT8eoj8cGW0ha6Bg==",
+ "requires": {
+ "arg": "^4.1.0",
+ "diff": "^4.0.1",
+ "make-error": "^1.1.1",
+ "source-map-support": "^0.5.6",
+ "yn": "3.1.1"
+ }
+ },
+ "typescript": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
+ "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w=="
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+ },
+ "which-boxed-primitive": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.1.tgz",
+ "integrity": "sha512-7BT4TwISdDGBgaemWU0N0OU7FeAEJ9Oo2P1PHRm/FCWoEi2VLWC9b6xvxAA3C/NMpxg3HXVgi0sMmGbNUbNepQ==",
+ "requires": {
+ "is-bigint": "^1.0.0",
+ "is-boolean-object": "^1.0.0",
+ "is-number-object": "^1.0.3",
+ "is-string": "^1.0.4",
+ "is-symbol": "^1.0.2"
+ }
+ },
+ "which-collection": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz",
+ "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==",
+ "requires": {
+ "is-map": "^2.0.1",
+ "is-set": "^2.0.1",
+ "is-weakmap": "^2.0.1",
+ "is-weakset": "^2.0.1"
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+ },
+ "yn": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="
+ }
+ }
+}
diff --git a/zedd-clarity/package.json b/zedd-clarity/package.json
new file mode 100644
index 0000000..3185820
--- /dev/null
+++ b/zedd-clarity/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "zedd-clarity",
+ "version": "1.0.0",
+ "description": "",
+ "main": "out/index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "build": "tsc",
+ "watch": "tsc --watch"
+ },
+ "types": "out/index.d.ts",
+ "keywords": [],
+ "author": {
+ "name": "Adrian Leonhard",
+ "email": "adrianleonhard@gmail.com"
+ },
+ "license": "UNLICENSED",
+ "private": true,
+ "prettier": {
+ "semi": false,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "singleQuote": true,
+ "proseWrap": "always"
+ },
+ "dependencies": {
+ "@types/deep-equal": "^1.0.1",
+ "@types/lodash": "^4.14.149",
+ "@types/node": "^12.12.21",
+ "@types/selenium-webdriver": "^4.0.9",
+ "date-fns": "^2.11.1",
+ "deep-equal": "^2.0.1",
+ "fast-csv": "^4.1.3",
+ "lodash": "^4.17.15",
+ "selenium-webdriver": "^4.0.0-alpha.7",
+ "sleep-promise": "^8.0.1",
+ "ts-node": "^8.8.1",
+ "typescript": "^3.8.3"
+ },
+ "devDependencies": {}
+}
diff --git a/zedd-clarity/src/index.ts b/zedd-clarity/src/index.ts
new file mode 100644
index 0000000..e607746
--- /dev/null
+++ b/zedd-clarity/src/index.ts
@@ -0,0 +1,713 @@
+import {
+ By,
+ until,
+ Builder as WebDriverBuilder,
+ Capabilities,
+ WebDriver,
+ WebElement,
+ Key,
+ WebElementPromise,
+} from 'selenium-webdriver'
+import * as path from 'path'
+import * as url from 'url'
+import * as chrome from 'selenium-webdriver/chrome'
+import partition from 'lodash/partition'
+import uniqBy from 'lodash/uniqBy'
+import sleep from 'sleep-promise'
+import * as fs from 'fs'
+import * as csv from 'fast-csv'
+import { promises as fsp } from 'fs'
+import deepEqual from 'deep-equal'
+import 'selenium-webdriver/lib/atoms/get-attribute'
+import 'selenium-webdriver/lib/atoms/is-displayed'
+import { homedir } from 'os'
+import {
+ min as dateMin,
+ format,
+ parse as parseDate,
+ isWithinInterval,
+ isSameDay,
+ parseISO,
+ compareAsc,
+ differenceInCalendarDays,
+} from 'date-fns'
+import { de } from 'date-fns/locale'
+import uniq from 'lodash/uniq'
+
+export class NikuUrlInvalidError extends Error {
+ constructor(url: string) {
+ super(`url ${JSON.stringify(url)} is not valid`)
+ }
+}
+
+const logSleep = async (ms: number) => {
+ console.warn('sleeping for ' + ((ms / 1000) | 0) + 's')
+ await sleep(ms)
+}
+
+function checkNikuUrl(urlToCheck: any) {
+ if (!urlToCheck) {
+ throw new NikuUrlInvalidError(urlToCheck)
+ }
+ const urlParts = url.parse(urlToCheck)
+ if (!urlParts.protocol || !urlParts.host || !urlParts.path) {
+ throw new NikuUrlInvalidError(urlToCheck)
+ }
+}
+
+async function makeContext(headless: boolean, downloadDir?: string) {
+ d('making context headless=' + headless)
+ const chromeOptions = new chrome.Options().addArguments('--no-sandbox')
+ if (downloadDir) {
+ await fsp.mkdir(downloadDir, { recursive: true })
+ chromeOptions.setUserPreferences({ 'download.default_directory': downloadDir })
+ }
+ d(`download.default_directory="${downloadDir}"`)
+
+ if (headless) {
+ chromeOptions.headless()
+ }
+ const driver = new WebDriverBuilder()
+ .setChromeOptions(chromeOptions)
+ .withCapabilities(Capabilities.chrome())
+ .build()
+
+ await driver.manage().setTimeouts({ implicit: 5000 })
+
+ return wrapDriver(driver)
+}
+function wrapDriver(driver: WebDriver) {
+ function $$(css: string): Promise
+ function $$(root: WebElement, css: string): Promise
+ function $$(a: WebElement | string, css?: string) {
+ if ('string' === typeof a) {
+ return driver.findElements(By.css(a))
+ } else {
+ return a.findElements(By.css(css!))
+ }
+ }
+ function $(css: string): WebElementPromise
+ function $(root: WebElement | undefined, css: string): WebElementPromise
+ function $(a: WebElement | string | undefined, css?: string) {
+ if ('string' === typeof a) {
+ return driver.findElement(By.css(a))
+ } else {
+ return (a || driver).findElement(By.css(css!))
+ }
+ }
+ return [$, $$, driver] as [typeof $, typeof $$, WebDriver]
+}
+type Context = ReturnType
+
+export async function getProjectInfo(
+ nikuLink: string,
+ excludeProject?: (projectName: string) => boolean,
+ headless: boolean = false,
+ downloadDir: string = __dirname + '/downloads',
+): Promise {
+ return withErrorHandling('getProjectInfo', nikuLink, headless, downloadDir, ctx =>
+ getProjectInfoInternal(nikuLink, ctx, downloadDir, excludeProject),
+ )
+}
+const pageLoad = async ([$, $$, driver]: Context) => {
+ // await driver.wait(until.elementIsVisible(await $("#ppm_header_wait")))
+ await sleep(200) // give it time to show the loading icon
+ await driver.wait(until.elementLocated({ css: '#ppm_header_wait' }))
+ await driver.wait(until.elementIsNotVisible(await $('#ppm_header_wait')))
+ await sleep(1000)
+}
+
+function escapeRegExp(string: string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
+}
+
+const urlHashQueryParam = (url: string, p: string) =>
+ new URL(new URL(url).hash.replace(/^#/, 'http://example.com?')).searchParams.get(p)
+
+export interface Project {
+ name: string
+ intId: number
+ tasks: Task[]
+}
+export interface Task {
+ sortNo: number
+ name: string
+ strId?: string
+ projectName: string
+ intId: number
+ start: Date
+ end: Date
+ openForTimeEntry: boolean
+}
+async function forceGetSSO(ctx: Context, url: string) {
+ const [$, $$, driver] = ctx
+ do {
+ await driver.get(url)
+ await pageLoad(ctx)
+ } while (url != (await driver.getCurrentUrl()))
+}
+async function getProjects(ctx: Context, nikuLink: string): Promise {
+ const [$, $$, driver] = ctx
+ d('getting projects')
+ await forceGetSSO(ctx, nikuLink + '#action:mainnav.work&classCode=project')
+ await driver.wait(until.elementLocated({ css: '.ppm_workspace_title' }))
+ await pageLoad(ctx)
+
+ d('locating project links')
+ const as = await $$('a[href^="#action:projmgr.projectDefaultTab"]')
+ d(`located ${as.length} project links`)
+
+ return Promise.all(
+ as.map(
+ async a =>
+ ({
+ name: await a.getText(),
+ intId: +(await a.getAttribute('href')).replace(
+ new RegExp('^' + escapeRegExp(nikuLink) + '#action:projmgr\\.projectDefaultTab&id='),
+ '',
+ ),
+ } as Project),
+ ),
+ )
+}
+
+async function getProjectTasks(
+ nikuLink: string,
+ ctx: Context,
+ { name: pName, intId: projectIntId }: Project,
+ openForTimeEntry: 'yes' | 'no' | 'all',
+ downloadDir: string,
+): Promise {
+ const [$, $$, driver] = ctx
+ const tasks: Task[] = []
+ d('get tasks for project ' + pName + ' ' + projectIntId)
+ const headerWaitIcon = await $('#ppm_header_wait')
+ await driver.get(nikuLink + '#action:projmgr.keyTaskList&id=' + projectIntId)
+ await driver.wait(until.elementIsVisible(await $('#ppm_header_wait')))
+ await pageLoad(ctx)
+ await $('[name=is_open_te]').sendKeys({ all: 'Alle', no: 'Nein', yes: 'Ja' }[openForTimeEntry])
+ await $('[name=filter]').click()
+ await pageLoad(ctx)
+ // const ersteSeiteButton = await $$(
+ // "#grid-content-projmgr\\.odfKeyTaskList .ppm_pagination *[title='Erste Seite']",
+ // ).then(bs => bs[0])
+ // if (ersteSeiteButton) {
+ // d('clicking ersteSeiteButton')
+ // await ersteSeiteButton.click()
+ // await pageLoad(ctx)
+ // }
+ d(' emptying download dir')
+ for (const file of await fsp.readdir(downloadDir)) {
+ await fsp.unlink(path.join(downloadDir, file))
+ }
+ d(' clicking on CSV export')
+ await $('.ppm_portlet_header_btn[title=Optionen]').click()
+ await $('[alt="In CSV exportieren"]').click()
+ d(' waiting for new file in downloadDir')
+ let files
+ do {
+ await sleep(1000)
+ files = await fsp.readdir(downloadDir)
+ } while (files.length == 0)
+ const csvPath = path.join(downloadDir, files[0])
+ const ilog = (x: any) => (console.log(x), x)
+ const magic = fs
+ .createReadStream(csvPath)
+ .pipe(
+ csv.parse({
+ headers: (headersFromCsv: csv.ParserHeaderArray) => {
+ if (headersFromCsv.filter(h => h === 'Aufgabe').length === 2) {
+ const indexFirstAufgabe = headersFromCsv.indexOf('Aufgabe')
+ headersFromCsv[indexFirstAufgabe] = 'AufgabeJaNein'
+ }
+ return headersFromCsv
+ },
+ }),
+ )
+ .on('data', (row: { [header: string]: string }) => {
+ if (row['AufgabeJaNein'] === 'Nein') return
+ if (row['Für Zeiteintrag geöffnet'] === 'Nein') return
+ const [_, intIdStr, name] = /id=(\d+).*"(.*)"\)/.exec(row['Aufgabe'])!
+ tasks.push({
+ sortNo: +row['PSP-Sortierung'],
+ name: name,
+ strId: row['ID'],
+ intId: +intIdStr,
+ projectName: pName,
+ start: parseDate(row['Anfang'], 'dd.MM.yy', new Date()),
+ end: parseDate(row['Ende'], 'dd.MM.yy', new Date()),
+ openForTimeEntry: row['Für Zeiteintrag geöffnet'] == 'Ja',
+ })
+ })
+ .on('error', console.error)
+ await new Promise(resolve => magic.on('end', resolve))
+ await fsp.unlink(csvPath)
+ d(` found ${tasks.length} tasks`)
+ return tasks
+}
+function getPagination(ctx: Context, where?: WebElement) {
+ const [$, $$, driver] = ctx
+ return $(where, '.ppm_pagination_display_of')
+ .then(d => d && d.getText())
+ .then(t => {
+ const [_, fromStr, toStr, ofStr] = t.match(/(\d+)\s*-\s+(\d+)\s*von\s*(\d+) angezeigt/)!
+ return {
+ from: +fromStr,
+ to: +toStr,
+ of: +ofStr,
+ }
+ })
+}
+
+function hasClass(e: WebElement, c: string) {
+ return e.getAttribute('class').then(classString => classString.split('\\s+').includes(c))
+}
+
+async function getProjectInfoInternal(
+ nikuLink: string,
+ ctx: Context,
+ downloadDir: string,
+ excludeProject: (projectName: string) => boolean = () => false,
+) {
+ const [$, $$, driver] = ctx
+
+ const projects = (await getProjects(ctx, nikuLink)).filter(p => !excludeProject(p.name))
+
+ const tasks: Task[] = []
+ for (const project of projects) {
+ project.tasks = await getProjectTasks(nikuLink, ctx, project, 'yes', downloadDir)
+ }
+
+ return projects
+}
+
+async function addTasks(
+ ctx: Context,
+ tasks: Pick[],
+) {
+ const [$, $$, driver] = ctx
+
+ const as = await $$('#portlet-table-timeadmin\\.editTimesheet tbody td[column="9"] a')
+ const addedIds = await Promise.all(
+ as.map(a => a.getAttribute('href').then(href => +urlHashQueryParam(href, 'id')!)),
+ )
+ d('already have tasks with ids ' + addedIds)
+ tasks = tasks.filter(t => !addedIds.includes(t.intId))
+ d(`adding ${tasks.length} tasks...`)
+ if (tasks.length == 0) {
+ return
+ }
+
+ await $(`button[onclick*="'timeadmin.timesheetAddTask'"]`).click()
+ await pageLoad(ctx)
+ await $('select[name=ff_assigned]').sendKeys('Alle')
+ await $('select[name=ff_task_status]').sendKeys('Alle')
+ for (let i = 0; i < tasks.length; i++) {
+ const taskIdInput = await $('input[name=ff_task_id]')
+ const taskNameInput = await $('input[name=ff_task_name]')
+ const projectNameInput = await $('input[name=ff_project_name]')
+ // const projectIdInput = await $('input[name=ff_project_id]')
+ const applyFilterButton = await $('button[name=applyFilter]')
+ const task = tasks[i]
+ await projectNameInput.sendKeys(Key.chord(Key.CONTROL, 'a'), task.projectName)
+ if (task.strId) {
+ await taskIdInput.sendKeys(Key.chord(Key.CONTROL, 'a'), task.strId)
+ await taskNameInput.sendKeys(Key.chord(Key.CONTROL, 'a'), Key.BACK_SPACE)
+ } else {
+ await taskIdInput.sendKeys(Key.chord(Key.CONTROL, 'a'), Key.BACK_SPACE)
+ await taskNameInput.sendKeys(Key.chord(Key.CONTROL, 'a'), task.name)
+ }
+ await applyFilterButton.click()
+ await pageLoad(ctx)
+ const taskRows = await $$(
+ '#ppm-portlet-grid-content-timeadmin\\.selectTimesheetTask tbody table tbody tr',
+ )
+ d(`found ${taskRows.length} taskRows`)
+ for (const tr of taskRows) {
+ const taskRowTaskId = +(await $(tr, 'input[name=selitem_id]').getAttribute('value'))
+ d('taskRowTaskId', taskRowTaskId)
+ if (taskRowTaskId === task.intId) {
+ await $(tr, 'input[name=selitem]').click()
+ break
+ }
+ }
+
+ // select thingy
+ if (i == tasks.length - 1) {
+ await $(`.ppm_button_bar button[onclick*="'timeadmin.addTimesheetTask'"]`).click()
+ } else {
+ await $(`.ppm_button_bar button[onclick*="'timeadmin.addTimesheetTaskMore'"]`).click()
+ }
+ await pageLoad(ctx)
+ }
+ // await sleep(30000)
+}
+
+interface WorkEntry {
+ projectName: string
+ taskName: string
+ taskIntId: number
+ hours: number
+ comment?: string
+}
+export type ClarityExportFormat = {
+ [day: string]: WorkEntry[]
+}
+
+async function exportToClarity(
+ ctx: Context,
+ whatt: ClarityExportFormat,
+ submitTimesheets: boolean,
+ nikuLink: string,
+): Promise {
+ const [$, $$, driver] = ctx
+ type ThenArg = T extends Promise ? U : T
+
+ let what = Object.keys(whatt).map((dateString: string) => ({
+ day: parseISO(dateString),
+ work: whatt[dateString],
+ }))
+
+ async function getRowInfos(editMode: boolean) {
+ const trs = await $$(
+ '#portlet-table-timeadmin\\.editTimesheet div.ppm_gridcontent ' +
+ '> table > tbody > tr:not(:first-child):not(:last-child)',
+ )
+ d(`found ${trs.length} trs`)
+ return Promise.all(
+ trs.map(async tr => {
+ const projectNameTd = await $(tr, `td[column="${editMode ? 8 : 7}"]`)
+ const rowNum = await projectNameTd.getAttribute('rownum')
+ const projectName = await $(projectNameTd, 'a').getText()
+ const taskNameA = await $(tr, `td[column="${editMode ? 9 : 8}"] a`)
+ const taskName = await taskNameA.getText()
+ const taskIntId = await taskNameA
+ .getAttribute('href')
+ .then(href => +urlHashQueryParam(href, 'id')!)
+ const hasComments = await hasClass(
+ await $(tr, `td[column="${editMode ? 7 : 6}"] img`),
+ 'caui-ndeNotes',
+ )
+ return { rowNum, projectName, taskName, taskIntId, tr, hasComments }
+ }),
+ )
+ }
+
+ type RowInfo = ThenArg>[number]
+
+ async function openCommentsDialogAndGetComments(rowInfo: RowInfo) {
+ d(" comments aren't empty/empty, opening dialog")
+ await $(rowInfo.tr, 'td > a#notes').click()
+ await pageLoad(ctx)
+
+ const comments = await Promise.all(
+ (
+ await $$('#ppm-portlet-grid-content-timeadmin\\.notesBrowser .ppm_gridcontent tbody tr')
+ ).map(async tr => {
+ const checkbox = await $(tr, 'input[type=checkbox]')
+ const content = await $(tr, 'td[column="6"]').getText()
+ return { checkbox, content }
+ }),
+ )
+
+ return comments
+ }
+
+ async function correctComments(
+ relevant: typeof what,
+ rowInfo: ThenArg>[number],
+ ) {
+ d('fixing comments for ' + rowInfo.taskName)
+ const targetComments: { [dayString: string]: string } = {}
+ for (const what of relevant) {
+ const dayStr = format(what.day, 'EEEEEE', { locale: de }).toUpperCase()
+ const comment = (what.work.find(s => s.taskIntId == rowInfo.taskIntId) || {}).comment || ''
+ targetComments[dayStr] = comment
+ }
+
+ d(targetComments, rowInfo)
+
+ if (!Object.values(targetComments).some(x => x) && !rowInfo.hasComments) {
+ // don't need to do anything
+ return
+ }
+ d(" comments aren't empty/empty, opening dialog")
+ await $(rowInfo.tr, 'td[column="7"] a').click()
+ await pageLoad(ctx)
+
+ const comments = await Promise.all(
+ (
+ await $$('#ppm-portlet-grid-content-timeadmin\\.notesBrowser .ppm_gridcontent tbody tr')
+ ).map(async tr => {
+ const checkbox = await $(tr, 'input[type=checkbox]')
+ const content = await $(tr, 'td[column="6"]').getText()
+ return { checkbox, content }
+ }),
+ )
+
+ // delete wrong comments
+ const [delComments, keepComments] = partition(comments, c => {
+ const x = Object.keys(targetComments).find(dayString =>
+ new RegExp('^\\[' + dayString + '\\]', 'i').test(c.content),
+ )
+ return x && (!targetComments[x] || x + ': ' + targetComments[x] != c.content)
+ })
+ if (delComments.length != 0) {
+ await Promise.all(
+ delComments.map(c => {
+ d(' selecting for deletion ' + c.content)
+ return c.checkbox.click()
+ }),
+ )
+
+ d(' clicking delete')
+ await $(
+ `#ppm-portlet-grid-content-timeadmin\\.notesBrowser button[onclick*="'timeadmin.deleteItemsConfirmPopup'"]`,
+ ).click()
+ await pageLoad(ctx)
+
+ d(' confirming')
+ await $(
+ `#ppm-portlet-grid-content-timeadmin\\.deleteItemsConfirmPopup button[onclick*="'timeadmin.deleteNotes'"]`,
+ ).click()
+ await pageLoad(ctx)
+ }
+
+ // add missing comments
+ for (const dayString of Object.keys(targetComments)) {
+ if (targetComments[dayString]) {
+ const comment = '[' + dayString + '] ' + targetComments[dayString]
+ if (!keepComments.some(c => c.content == comment)) {
+ const noteInput = await $('#portlet-timeadmin\\.notesBrowser textarea[name=note]')
+ await noteInput.sendKeys(comment)
+ const catInput = await $('#portlet-timeadmin\\.notesBrowser input[name=category]')
+ await catInput.sendKeys('BOT')
+ d(' adding comment ' + comment)
+ await $(
+ `#portlet-timeadmin\\.notesBrowser button[onclick*="'timeadmin.addNote'"]`,
+ ).click()
+
+ await pageLoad(ctx)
+ }
+ }
+ }
+
+ await $('#portlet-timeadmin\\.notesBrowser button[onclick="closeWindow();"]').click()
+ }
+
+ async function exportTimesheet(timesheetStartDate: Date, rowInfos: RowInfo[], days: Date[]) {
+ d('exporting timesheet starting at ' + formatDayYYYY(timesheetStartDate))
+ d('for days ' + days)
+ const daysInfo: { day: Date; work: WorkEntry[] }[] = days.map(d => ({ day: d, work: [] }))
+ for (const rowInfo of rowInfos) {
+ let comments: string[] = []
+ if (rowInfo.hasComments) {
+ comments = await openCommentsDialogAndGetComments(rowInfo).then(cs =>
+ cs.map(c => c.content),
+ )
+ await $('#portlet-timeadmin\\.notesBrowser button[onclick="closeWindow();"]').click()
+ }
+
+ for (const day of days) {
+ const dayStr = format(day, 'EEEEEE', { locale: de })
+ const dayComment = comments.find(c => c.startsWith(dayStr + ': '))
+ const columnIndex = 13 + differenceInCalendarDays(day, timesheetStartDate)
+ const hoursStr = await $(rowInfo.tr, `td[column="${columnIndex}"]`).getText()
+ const hours = +hoursStr.replace(',', '.')
+ if (hours != 0)
+ daysInfo
+ .find(di => di.day == day)!
+ .work.push({
+ taskIntId: rowInfo.taskIntId,
+ taskName: rowInfo.taskName,
+ projectName: rowInfo.projectName,
+ hours,
+ comment: dayComment && dayComment.substring(4),
+ })
+ }
+ }
+ return daysInfo
+ }
+
+ function formatDayYYYY(d: Date) {
+ return format(d, 'dd.MM.yyyy')
+ }
+
+ interface What {
+ day: Date
+ work: WorkEntry[]
+ }
+
+ function normalizeWhatArray(ws: What[]) {
+ ws.sort((a, b) => compareAsc(a.day, b.day))
+ for (const w of ws) {
+ w.work.sort((a, b) => a.taskIntId - b.taskIntId)
+ for (const y of w.work) {
+ y.comment = y.comment || undefined
+ }
+ }
+ }
+
+ function timeSheetDataEqual(a: What[], b: What[]) {
+ normalizeWhatArray(a)
+ normalizeWhatArray(b)
+ return deepEqual(a, b)
+ }
+
+ // DEFINITIONS END
+
+ d('submitTimesheets = ' + submitTimesheets)
+
+ while (what.length) {
+ d(`${what.length} days left to submit`)
+ await forceGetSSO(ctx, nikuLink + '#action:timeadmin.timesheetBrowserReturn')
+
+ const minDate = dateMin(what.map(w => w.day))
+ d(`minDate is ${formatDayYYYY(minDate)}`)
+ await $('input[name=ff_date_type][value=userdefined]').click()
+ await $('input[name=ff_from_date]').sendKeys(
+ Key.chord(Key.CONTROL, 'a'),
+ formatDayYYYY(minDate),
+ )
+ await $('select[name=ff_status]').sendKeys(Key.chord(Key.CONTROL, 'a'))
+ await $('button[name=applyFilter]').click()
+ await pageLoad(ctx)
+
+ // click on the first timesheet
+ await $('#manageTimesheet').click()
+ await pageLoad(ctx)
+ const txt = await $('select[name=timeperiod] > option[selected=true]').getText()
+ const [start, end] = txt
+ .split(' - ')
+ .map(ds => parseDate(ds, 'dd.MM.yy', Date.now(), { locale: de }))
+ d('start ' + start)
+ d('end ' + end)
+ d('in timesheet ' + format(start, 'EEEEEE dd.MM') + ' - ' + format(end, 'EEEEEE dd.MM'))
+
+ const [relevant, others] = partition(what, w => isWithinInterval(w.day, { start, end }))
+
+ const saveTimesheetExitButton = (
+ await $$(`button[onclick*="'timeadmin.saveTimesheetExit','status=2'"]`)
+ )[0]
+ const createAdjustmentTimesheetButton = (
+ await $$(`button[onclick*="'timeadmin.createAdjustmentTimesheet'"]`)
+ )[0]
+ if (saveTimesheetExitButton || createAdjustmentTimesheetButton) {
+ d('reading timesheet data...')
+ const data = await exportTimesheet(
+ start,
+ await getRowInfos(false),
+ relevant.map(w => w.day),
+ )
+ d(' done')
+ if (timeSheetDataEqual(data, relevant)) {
+ d('timesheet data is already correct')
+ what = others
+ continue
+ } else {
+ if (saveTimesheetExitButton) {
+ d('sende Zeitformular zurück')
+ await saveTimesheetExitButton.click()
+ await pageLoad(ctx)
+ continue
+ }
+ if (createAdjustmentTimesheetButton) {
+ d('passe Zeitformular an')
+ await createAdjustmentTimesheetButton.click()
+ await pageLoad(ctx)
+ }
+ }
+ }
+
+ await addTasks(
+ ctx,
+ uniqBy(
+ relevant.flatMap(w => w.work),
+ t => t.taskIntId,
+ ).map(({ taskName, taskIntId, projectName }) => ({
+ name: taskName,
+ intId: taskIntId,
+ projectName,
+ })),
+ )
+ const rowInfos = await getRowInfos(true)
+ for (const ri of rowInfos) {
+ await correctComments(relevant, ri)
+ }
+ // d('' + eachDayOfInterval({ start, end }))
+ d('' + relevant.map(w => w.day))
+ for (const day of uniq(relevant.map(w => w.day)).sort(compareAsc)) {
+ d(' filling out ' + format(day, 'EEEEEE dd.MM'))
+ const daySlices = (relevant.find(w => isSameDay(w.day, day)) || { work: [] }).work
+ await Promise.all(
+ rowInfos.map(async rowInfo => {
+ const hours = daySlices
+ .filter(s => s.taskIntId === rowInfo.taskIntId)
+ .map(s => s.hours)
+ .reduce((a, b) => a + b, 0)
+ .toLocaleString('de-DE', { maximumFractionDigits: 2 })
+ .replace('.', ',')
+ d(` setting ${hours} hours for task ${rowInfo.taskIntId} ${rowInfo.taskName}`)
+ await $(
+ rowInfo.tr,
+ `input[alt^="${format(day, 'EEEEEE, dd.MM', { locale: de })}"]`,
+ ).sendKeys(Key.chord(Key.CONTROL, 'a'), hours)
+ }),
+ )
+ }
+
+ if (submitTimesheets) {
+ d(' submitting timesheet')
+ await $(`button[onclick*="'timeadmin.saveTimesheetExit','status=1'"]`).click()
+ } else {
+ d(' saving timesheet')
+ await $(`button[onclick="submitForm('page','timeadmin.saveTimesheet');"]`).click()
+ }
+ await pageLoad(ctx)
+ what = others
+ }
+ await logSleep(5000)
+}
+
+function d(...x: any) {
+ console.log('zedd-clarity', ...x)
+}
+
+export async function fillClarity(
+ nikuLink: string,
+ data: ClarityExportFormat,
+ submitTimesheets: boolean,
+ headless: boolean = false,
+ downloadDir?: string,
+): Promise {
+ return withErrorHandling('fillClarity', nikuLink, headless, downloadDir, ctx =>
+ exportToClarity(ctx, data, submitTimesheets, nikuLink),
+ )
+}
+export async function withErrorHandling(
+ name: string,
+ nikuLink: string,
+ headless: boolean,
+ downloadDir: string | undefined,
+ cb: (ctx: Context) => Promise,
+): Promise {
+ checkNikuUrl(nikuLink)
+ const ctx = await makeContext(headless, downloadDir)
+ try {
+ return await cb(ctx)
+ } catch (err) {
+ console.error(err)
+ const ssDir = path.join(homedir(), 'zedd')
+ await fsp.mkdir(ssDir, { recursive: true })
+ const ssFile = path.join(ssDir, name + '_' + format(new Date(), 'yyyy-MM-dd_HHmm') + '.png')
+ console.warn('Saving screenshot to', ssFile)
+ await fsp.writeFile(ssFile, await ctx[2].takeScreenshot(), 'base64')
+ const htmlFile = path.join(ssDir, name + '_' + format(new Date(), 'yyyy-MM-dd_HHmm') + '.html')
+ console.warn('Saving HTML to', htmlFile)
+ await fsp.writeFile(htmlFile, await ctx[2].getPageSource(), 'utf8')
+ await logSleep(30_000)
+ throw err
+ } finally {
+ await ctx[2].quit()
+ }
+}
diff --git a/zedd-clarity/tsconfig.json b/zedd-clarity/tsconfig.json
new file mode 100644
index 0000000..ba8371f
--- /dev/null
+++ b/zedd-clarity/tsconfig.json
@@ -0,0 +1,58 @@
+{
+ "compilerOptions": {
+ /* Basic Options */
+ // "incremental": true, /* Enable incremental compilation */
+ "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
+ "module": "es2015" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
+ // "lib": [], /* Specify library files to be included in the compilation. */
+ // "allowJs": true, /* Allow javascript files to be compiled. */
+ // "checkJs": true, /* Report errors in .js files. */
+ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+ "declaration": true /* Generates corresponding '.d.ts' file. */,
+ "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
+ "sourceMap": true /* Generates corresponding '.map' file. */,
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ "outDir": "./out" /* Redirect output structure to the directory. */,
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "composite": true, /* Enable project compilation */
+ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
+ // "removeComments": true, /* Do not emit comments to output. */
+ // "noEmit": true, /* Do not emit outputs. */
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true /* Enable all strict type-checking options. */,
+ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* Enable strict null checks. */
+ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
+ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ /* Module Resolution Options */
+ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ /* Source Map Options */
+ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ }
+}
\ No newline at end of file
diff --git a/zedd-win32/.gitignore b/zedd-win32/.gitignore
new file mode 100644
index 0000000..12820bb
--- /dev/null
+++ b/zedd-win32/.gitignore
@@ -0,0 +1,17 @@
+lib-cov
+*.seed
+*.log
+*.csv
+*.dat
+*.out
+*.pid
+*.gz
+
+pids
+logs
+results
+lib/binding
+*.tgz
+npm-debug.log
+node_modules
+build
\ No newline at end of file
diff --git a/zedd-win32/.npmignore b/zedd-win32/.npmignore
new file mode 100644
index 0000000..cf68f30
--- /dev/null
+++ b/zedd-win32/.npmignore
@@ -0,0 +1,9 @@
+node_modules
+lib/binding
+build
+test
+*.tgz
+npm-debug.log
+.npmignore
+.gitignore
+.travis.yml
\ No newline at end of file
diff --git a/zedd-win32/LICENSE b/zedd-win32/LICENSE
new file mode 100644
index 0000000..1149654
--- /dev/null
+++ b/zedd-win32/LICENSE
@@ -0,0 +1,23 @@
+Copyright (c) 2014, Dane Springmeyer
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/zedd-win32/README.md b/zedd-win32/README.md
new file mode 100644
index 0000000..e38ae14
--- /dev/null
+++ b/zedd-win32/README.md
@@ -0,0 +1,40 @@
+node-addon
+==================
+
+[](https://travis-ci.org/springmeyer/node-addon)
+
+Sample application of a Node C++ addon packaged with [node-pre-gyp](https://github.com/mapbox/node-pre-gyp).
+
+If you are interested in learning how to write C++ addons see the [official guide](http://nodejs.org/api/addons.html#addons_hello_world).
+
+This repo is intended as starter code for your own C++ module - feel free to copy and modify. The docs below are meant to be a template for how you might document your module once packaged with `node-pre-gyp`.
+
+## Depends
+
+- Node.js 0.10.x, 0.12.x, 4, or 5
+
+## Install
+
+Install from binary:
+
+ npm install
+
+Install from source:
+
+ npm install --build-from-source
+
+## Developing
+
+The [node-pre-gyp](https://github.com/mapbox/node-pre-gyp#usage) tool is used to handle building from source and packaging.
+
+Simply run:
+
+ ./node_modules/.bin/node-pre-gyp build
+
+### Packaging
+
+ ./node_modules/.bin/node-pre-gyp build package
+
+### Publishing
+
+ ./node_modules/.bin/node-pre-gyp publish
diff --git a/zedd-win32/bin/win32-x64-75/zedd-win32.node b/zedd-win32/bin/win32-x64-75/zedd-win32.node
new file mode 100644
index 0000000..cd6432a
Binary files /dev/null and b/zedd-win32/bin/win32-x64-75/zedd-win32.node differ
diff --git a/zedd-win32/binding.gyp b/zedd-win32/binding.gyp
new file mode 100644
index 0000000..6ffecc4
--- /dev/null
+++ b/zedd-win32/binding.gyp
@@ -0,0 +1,14 @@
+{
+ "targets": [
+ {
+ "target_name": "zedd-win32",
+ "cflags!": [ "-fno-exceptions" ],
+ "cflags_cc!": [ "-fno-exceptions" ],
+ "sources": [ "zedd-win32.cpp" ],
+ "include_dirs": [
+ "
+#include
+
+#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers
+#include
+
+
+
+Napi::Number GetMillisSinceLastUserInput(const Napi::CallbackInfo& info) {
+ Napi::Env env = info.Env();
+
+
+ LASTINPUTINFO lii;
+ lii.cbSize = sizeof(LASTINPUTINFO);
+ BOOL result = GetLastInputInfo(&lii);
+
+ DWORDLONG lastUserInputTick = lii.dwTime;
+ DWORDLONG tickCount = GetTickCount();
+ if (tickCount < lastUserInputTick) {
+ // this means that tickCount overflowed, but lastUserInputTick didn't.
+ tickCount += MAXDWORD;
+ }
+
+ std::string x = std::to_string(lii.dwTime);
+
+ return Napi::Number::New(env, tickCount - lastUserInputTick);
+}
+
+struct SpeichereFensterParams {
+ const Napi::Env env;
+ std::vector& objects;
+ HWND foregroundWindow;
+
+ SpeichereFensterParams(
+ const Napi::Env env,
+ std::vector& objects,
+ const HWND foregroundWindow)
+ : env(env)
+ , objects(objects)
+ , foregroundWindow(foregroundWindow) {}
+};
+
+std::string ProcessIdToName(DWORD processId) {
+ std::string ret;
+ HANDLE handle = OpenProcess(
+ PROCESS_QUERY_LIMITED_INFORMATION,
+ FALSE,
+ processId /* This is the PID, you can find one from windows task manager */
+ );
+ if (handle)
+ {
+ DWORD buffSize = 1024;
+ CHAR buffer[1024];
+ if (QueryFullProcessImageNameA(handle, 0, buffer, &buffSize))
+ {
+ ret = buffer;
+ }
+ else
+ {
+ printf("Error GetModuleBaseNameA : %lu", GetLastError());
+ }
+ CloseHandle(handle);
+ }
+ else
+ {
+ printf("Error OpenProcess : %lu", GetLastError());
+ }
+ return ret;
+}
+
+Napi::String GetWindowProcessImageName(const Napi::Env &env, HWND hWnd) {
+ DWORD processId;
+ DWORD threadId = GetWindowThreadProcessId(hWnd, &processId);
+
+ std::string processName = ProcessIdToName(processId);
+ return Napi::String::New(env, processName);
+}
+
+Napi::String GetWindowPlacementAsNapiString(const Napi::Env &env, HWND hWnd) {
+ WINDOWPLACEMENT windowPlacement;
+ windowPlacement.length = sizeof(WINDOWPLACEMENT);
+
+ GetWindowPlacement(hWnd, &windowPlacement);
+
+ if (windowPlacement.showCmd == SW_SHOWMAXIMIZED) {
+ return Napi::String::New(env, "maximized");
+ }
+ if (windowPlacement.showCmd == SW_SHOWNORMAL) {
+ return Napi::String::New(env, "normal");
+ }
+ if (windowPlacement.showCmd == SW_SHOWMINIMIZED) {
+ return Napi::String::New(env, "minimized");
+ }
+ return Napi::String::New(env, "ERROR!");
+}
+
+BOOL CALLBACK GetWindowInfosEnumCallback(HWND hWnd, LPARAM lParam) {
+ SpeichereFensterParams& params =
+ *reinterpret_cast(lParam);
+
+ const DWORD TITLE_SIZE = 1024;
+ WCHAR windowTitle[TITLE_SIZE];
+
+ GetWindowTextW(hWnd, windowTitle, TITLE_SIZE);
+ int length = ::GetWindowTextLength(hWnd);
+ if (!IsWindowVisible(hWnd) || length == 0) {
+ return TRUE;
+ }
+
+ Napi::Object obj = Napi::Object::New(params.env);
+ obj["name"] = Napi::String::New(params.env, (char16_t *)windowTitle);
+ obj["foreground"] = Napi::Boolean::New(params.env, params.foregroundWindow == hWnd);
+ obj["placement"] = GetWindowPlacementAsNapiString(params.env, hWnd);
+ obj["processName"] = GetWindowProcessImageName(params.env, hWnd);
+
+ // Retrieve the pointer passed into this callback, and re-'type' it.
+ // The only way for a C API to pass arbitrary data is by means of a void*.
+ params.objects.push_back(obj);
+
+ return TRUE;
+}
+
+Napi::Array vectorToNapiArray(Napi::Env env, std::vector& vec) {
+ Napi::Array array = Napi::Array::New(env, vec.size());
+
+ for (int i = 0; i < vec.size(); i++) {
+ array.Set((uint32_t)i, vec[i]);
+ }
+
+ return array;
+}
+
+Napi::Array GetWindowInfos(const Napi::CallbackInfo& info) {
+ std::vector objects;
+
+ SpeichereFensterParams params = SpeichereFensterParams(info.Env(), objects, GetForegroundWindow());
+
+ EnumWindows(GetWindowInfosEnumCallback, reinterpret_cast(¶ms));
+
+ return vectorToNapiArray(info.Env(), objects);
+}
+
+Napi::Object Init(Napi::Env env, Napi::Object exports) {
+ exports.Set("getMillisSinceLastUserInput",
+ Napi::Function::New(env, GetMillisSinceLastUserInput));
+ exports.Set("getWindowInfos", Napi::Function::New(env, GetWindowInfos));
+ return exports;
+}
+
+NODE_API_MODULE(hello, Init)
\ No newline at end of file