diff --git a/.gitignore b/.gitignore index 6fc9165..64d8e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ .vscode -test-data.txt -clipnotify \ No newline at end of file +.idea +# TODO remove clipnotify +clipnotify +docs/notes + +# virtualenv artifacts +.eggs +.venv* + +# runtime artifacts +**/__pycache__ + +# build artifacts +build +dist +*.spec diff --git a/Makefile b/Makefile deleted file mode 100644 index b643253..0000000 --- a/Makefile +++ /dev/null @@ -1,4 +0,0 @@ -setup: - rm -rf ./clipnotify - git clone https://github.com/cdown/clipnotify.git - make -C ./clipnotify diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..0511952 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/icon_flash.png b/assets/icon_flash.png new file mode 100644 index 0000000..0afdcd1 Binary files /dev/null and b/assets/icon_flash.png differ diff --git a/builder.sh b/builder.sh new file mode 100755 index 0000000..a63d1bb --- /dev/null +++ b/builder.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash + +# Usage examples: +# `install arm python3.10` or `install intel python3.10-intel64` +# `build arm` or `build intel` + +install() {(set -e + # Prepare virtualenv for given architecture + + arch=$1 # architecture name, `arm` | `intel` + pythonExec=$2 # python executable name, e.g. `python3.10` or `python3.10-intel64` + + _validateArchArg $arch + + if [ -z "$pythonExec" ]; then + _logError 'Python executable must be provided as argument (e.g. "python3.10" or "python3.10-intel64")' + exit 1 + fi + + _validateExecutableArchitecture $arch $pythonExec + + venvPath=".venv-$arch" + _log "Installing virtualenv to \"$venvPath\"" + + $pythonExec -m venv .venv-$arch --clear --copies + + source "$venvPath/bin/activate" + + pip install wheel + pip install -r requirements_macos.txt + + _log "Successfully installed virtualenv to \"$venvPath\"" +)} + +build() {(set -e + arch=$1 # architecture name, `arm` | `intel` + + _validateArchArg $arch + fullArchName=$(_getFullArchName $arch) + venvPath=".venv-$arch" + distPath="dist/$fullArchName" + + source "$venvPath/bin/activate" + + rm -rf ./$distPath ./build/ ./*.spec + + _log "Starting build for $fullArchName" + + $venvPath/bin/pyinstaller \ + --clean \ + --name "Statusbar Converter" \ + --onedir \ + --windowed \ + --add-data '../../assets:assets' \ + --add-data '../../config:config' \ + --distpath "$distPath" \ + --workpath "$distPath/build" \ + --specpath "$distPath" \ + --icon '../../assets/icon.png' \ + --target-arch "$fullArchName" \ + --osx-bundle-identifier 'com.mindaugasw.statusbar_converter' \ + start.py + + _log "Successfully built for $fullArchName in $distPath" + + _createZip $fullArchName +)} + +_createDmg() {(set -e + # No longer used. .dmg image triggers additional safety measures for unsigned apps: + # - Chrome warns about unsafe file when downloading. .zip avoids this problem + # - Still need to manually un-quarantine app with `xattr -d com.apple.quarantine path.app` + # For .dmg it's always needed for download. .zip seems to avoid quarantine + # after download but only on the same machine that it was built on + + # `create-dmg` in path is required + # `brew install create-dmg` + + arch=$1 # full architecture name, `arm64` | `x86_64` + + _log "Packing into dmg image for $arch" + + fileName="Statusbar_Converter_macOS_$arch.dmg" + + cd dist/$arch + + # create-dmg includes all files in the directory. So we copy only the needed stuff to a new directory + rm -rf dmg/ ./*.dmg + mkdir dmg + cp -r 'Statusbar Converter.app' 'dmg/Statusbar Converter.app' + + create-dmg \ + --volname 'Statusbar Converter' \ + --icon-size 80 \ + --text-size 14 \ + --icon 'Statusbar Converter.app' 190 0 \ + --app-drop-link 0 0 \ + --hide-extension 'Statusbar Converter.app' \ + $fileName \ + dmg/ + + rm -rf dmg/ + + _log "Successfully packed into dmg image \"$fileName\"" +)} + +_createZip() {(set -e + arch=$1 # full architecture name, `arm64` | `x86_64` + + cd "dist/$arch" + fileName="Statusbar_Converter_macOS_$arch.app.zip" + _log "Compressing into zip for $arch" + + zip -r "$fileName" "Statusbar Converter.app" + + _log "Successfully compressed into \"$fileName\"" +)} + +_validateExecutableArchitecture() {(set -e + arch=$1 # architecture name, `arm` | `intel` + pythonExec=$2 # python executable name, e.g. `python3.10` or `python3.10-intel64` + + _validateArchArg $arch + + platform=$($pythonExec -c 'import platform; print(platform.platform())') + needle=$(_getFullArchName $arch) + + if [[ "$platform" != *"$needle"* ]]; then + _logError "Requested architecture ($arch) does not match provided python executable ($platform)" + exit 1 + else + _log "Requested architecture ($arch) matches provided python executable ($platform)" + fi +)} + +_validateArchArg() {(set -e + # $1 - architecture name + + if [ "$1" != 'arm' ] && [ "$1" != 'intel' ]; then + _logError 'Argument must be either "arm" or "intel"' + exit 1 + fi +)} + +_getFullArchName() {(set -e + # $1 - architecture name + + if [ "$1" == 'arm' ]; then + printf 'arm64' + elif [ "$1" == 'intel' ]; then + printf 'x86_64' + else + _logError 'Argument must be either "arm" or "intel"' + exit 1 + fi +)} + +_log() {(set -e + yellowCode='\033[0;33m' + resetCode='\033[0m' + + printf "$yellowCode> $1$resetCode\n" +)} + +_logError() {(set -e + redCode='\033[0;31m' + resetCode='\033[0m' + + printf "$redCode\nERROR: $1$resetCode\n" +)} + +# This will call function name from the first argument +# See: https://stackoverflow.com/a/16159057/4110469 +"$@" diff --git a/config/config.app.yml b/config/config.app.yml new file mode 100644 index 0000000..d6916a1 --- /dev/null +++ b/config/config.app.yml @@ -0,0 +1,61 @@ +# If enabled, text in the statusbar will be cleared after copying anything else +clear_on_change: false + +# Automatically clear text in the status bar after this time, in seconds. +# 0 to disable automatic clearing. +clear_after_time: 300 + +# Parse text on highlighting it, without having to copy it. +# Supported only on Linux, ignored on macOS. # TODO +# Highlight detection may not work in some apps, in which case you can still copy the text to manually trigger timestamp update +update_on_highlight: true # TODO + +# If enabled, will shortly flash status bar icon on timestamp change +flash_icon_on_change: true + +# Formatting template for main text on the statusbar. +# Will use first template found where key is less than relative time difference +# in seconds. +# +# Templates must be ordered ascending by time difference in their key. +# +# Templates support all standard strftime() codes: +# https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes +# and these custom codes for relative time: +# - {ts} - unix timestamp. Milliseconds (if copied timestamp with milliseconds) will be separated with a dot for better readability. +# - {ts_ms} - unix timestamp. Milliseconds won't be separated to allow copying a valid timestamp. +# - {r_int} - relative time with integer number, e.g. '5 h ago'. +# - {r_float} - relative time with float number, e.g. '5.5 h ago'. +# TODO name this somehow better +format_icon: + # Up to 60 s, e.g. "1619295908 - 59 s ago" + 60: "{ts} - {r_int}" + # 1 min - 10 min, e.g. "1619295908 - 5 min ago" + 600: "{ts} - {r_int}" + # 10 min - 1 hour, e.g. "1619295908 - 29 min ago (15:10)" + 3600: "{ts} - {r_float} (%H:%M)" + # 1 hour - 1 day, e.g. "1619295908 - 5.5 h ago (12:00)" + 86400: "{ts} - {r_float} (%H:%M)" + # 1 day - 1 month, e.g. "1619295908 - 5.5 days ago (08-05 12:00)" + 2678400: "{ts} - {r_float} (%m-%d %H:%M)" + # 1 month - 1 year, e.g. "1619295908 - 9.2 months ago" + 31536000: "{ts} - {r_float} (%m-%d %H:%M)" + # 1 year - 75 years, e.g. "1619295908 - 5.8 years ago ('15)" + 2365200000: "{ts} - {r_float} ('%y)" + # Default if no other format matches. Must be last element in the list + default: "{ts} - {r_float}" + +# Formatting template to use for menu items under "Last timestamp". +# Supports the same formats as in "format_icon" configuration. +menu_items_last_timestamp: + Last timestamp: "{ts_ms}" + Last datetime: "%Y-%m-%d %H:%M:%S" + +# Formatting template to use for menu items under "Current timestamp". +# Supports the same formats as in "format_icon" configuration. +menu_items_current_timestamp: + Current timestamp: "{ts_ms}" + Current datetime: "%Y-%m-%d %H:%M:%S" + +# If enabled, will log additional info to the console +debug: false diff --git a/config/config.user.example.yml b/config/config.user.example.yml new file mode 100644 index 0000000..73fd58e --- /dev/null +++ b/config/config.user.example.yml @@ -0,0 +1,7 @@ +# Warning: user config will not be updated automatically and may break new app +# versions in the future. +# +# All supported configuration can be found at +# https://github.com/mindaugasw/statusbar-converter/tree/master/config/config.app.yml +# +# After editing configuration the application must be restarted. diff --git a/docs/README.md b/docs/README.md index 81a98e8..fae0679 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,13 +1,22 @@ -# Timestamp tray converter - -It's a small tool to easily convert timestamp to human-readable time. Just highlight the timestamp and it will automatically show as time in the tray. See demo below: - -![demo](/docs/demo.gif) - -## Usage -Currently works only on Linux. Requires Python and xsel package. -- `git clone https://github.com/mindaugasw/timestamp-tray-converter.git` -- `cd timestamp-tray-converter` -- `make` -- Optionally change app configuration in `timestamp-tray-converter.py` -- Run `python ./timestamp-tray-converter.py` or add it to your startup items +# Statusbar Converter + +A small tool to easily convert timestamp to human-readable time. Just copy the +timestamp and it will automatically show as time in the statusbar: + +![demo](/docs/demo-2.gif) + + +## Installation + +- Download the latest release from [GitHub](https://github.com/mindaugasw/statusbar-converter/releases) +- Extract zip +- On macOS you must manually remove quarantine attribute, because the app is not signed: + `xattr -d com.apple.quarantine /Applications/Statusbar\ Converter.app/` +- Start the app. A new icon will appear on the statusbar +- To automatically launch the app on boot, go to System Preferences, search for `Login items` and add the app +- _Tip:_ on macOS you can change icon order on the statusbar with Cmd-click + + +## Building locally + +[See documentation for building locally.](/docs/building.md) diff --git a/docs/building.md b/docs/building.md new file mode 100644 index 0000000..35373f6 --- /dev/null +++ b/docs/building.md @@ -0,0 +1,19 @@ +# Building locally + +Install `python3.10` + +> If you want to build `x86_64` architecture app on Apple Silicon, you need Python +> with `universal2` support from [python.org](https://www.python.org/downloads/release/python-31011/). +> Python from Homebrew will support only your current architecture. + +Create virtualenv with: +- `./builder.sh install arm python3.10` (`arm64` native) +- `./builder.sh install intel python3.10` (`x86_64` native) +- `./builder.sh install intel python3.10-intel64` (building `x86_64` on `arm64`) + +Run app: +- `source .venv-arm/bin/activate` +- `python start.py` + +Build distributable: +- `./builder.sh build arm` diff --git a/docs/demo-2.gif b/docs/demo-2.gif new file mode 100644 index 0000000..c7b10c9 Binary files /dev/null and b/docs/demo-2.gif differ diff --git a/requirements_macos.txt b/requirements_macos.txt new file mode 100644 index 0000000..487287d --- /dev/null +++ b/requirements_macos.txt @@ -0,0 +1,6 @@ +pasteboard==0.3.3 +PyYAML==6.0 +rumps==0.4.0 +pyinstaller +# Pillow is needed for pyinstaller to be able to convert .png image to .icns to allow to use as macOS file icon +Pillow diff --git a/src/Entity/Event.py b/src/Entity/Event.py new file mode 100644 index 0000000..71de43f --- /dev/null +++ b/src/Entity/Event.py @@ -0,0 +1,34 @@ +class Event(list): + """Event subscription. + + A list of callable objects. Calling an instance of this will cause a + call to each item in the list in ascending order by index. + + Source: https://stackoverflow.com/a/2022629/4110469 + + Example Usage: + >>> def f(x): + ... print 'f(%s)' % x + >>> def g(x): + ... print 'g(%s)' % x + >>> e = Event() + >>> e() + >>> e.append(f) + >>> e(123) + f(123) + >>> e.remove(f) + >>> e() + >>> e += (f, g) + >>> e(10) + f(10) + g(10) + >>> del e[0] + >>> e(2) + g(2) + """ + def __call__(self, *args, **kwargs): + for f in self: + f(*args, **kwargs) + + def __repr__(self): + return "Event(%s)" % list.__repr__(self) diff --git a/src/Entity/Timestamp.py b/src/Entity/Timestamp.py new file mode 100644 index 0000000..ed17df3 --- /dev/null +++ b/src/Entity/Timestamp.py @@ -0,0 +1,32 @@ +import time + + +class Timestamp: + seconds: int + milliseconds: int | None + + def __init__(self, seconds: int | None = None, milliseconds: int | None = None): + if seconds is None: + seconds = int(time.time()) + + self.seconds = seconds + self.milliseconds = milliseconds + + def __str__(self) -> str: + return self.getStringPretty() + + def getStringPretty(self) -> str: + """Get timestamp with milliseconds separated for better readability""" + + return self._getString('.') + + def getStringValid(self) -> str: + """Get valid timestamp, without milliseconds separation""" + + return self._getString('') + + def _getString(self, msSeparator: str) -> str: + if self.milliseconds is None: + return str(self.seconds) + else: + return '%d%s%03d' % (self.seconds, msSeparator, self.milliseconds) diff --git a/src/Service/AppLoop.py b/src/Service/AppLoop.py new file mode 100644 index 0000000..e5f7573 --- /dev/null +++ b/src/Service/AppLoop.py @@ -0,0 +1,21 @@ +import threading +import time +import src.events as events +from src.Service.ClipboardManager import ClipboardManager + + +class AppLoop: + LOOP_INTERVAL = 0.33 + + _clipboardManager: ClipboardManager + + def __init__(self, clipboardManager: ClipboardManager): + self._clipboardManager = clipboardManager + + def startLoop(self) -> None: + threading.Thread(target=self._processIteration).start() + + def _processIteration(self) -> None: + while True: + events.appLoopIteration() + time.sleep(self.LOOP_INTERVAL) diff --git a/src/Service/ClipboardManager.py b/src/Service/ClipboardManager.py new file mode 100644 index 0000000..e19d2d3 --- /dev/null +++ b/src/Service/ClipboardManager.py @@ -0,0 +1,11 @@ +from abc import ABCMeta, abstractmethod + + +class ClipboardManager(metaclass=ABCMeta): + @abstractmethod + def _checkClipboard(self) -> None: + pass + + @abstractmethod + def setClipboardContent(self, content: str) -> None: + pass diff --git a/src/Service/ClipboardManagerLinux.py b/src/Service/ClipboardManagerLinux.py new file mode 100644 index 0000000..8aec25f --- /dev/null +++ b/src/Service/ClipboardManagerLinux.py @@ -0,0 +1,6 @@ +from src.Service.ClipboardManager import ClipboardManager + + +class ClipboardManagerLinux(ClipboardManager): + def __init__(self): + raise Exception('Not implemented') diff --git a/src/Service/ClipboardManagerMacOs.py b/src/Service/ClipboardManagerMacOs.py new file mode 100644 index 0000000..7f1f5e6 --- /dev/null +++ b/src/Service/ClipboardManagerMacOs.py @@ -0,0 +1,46 @@ +import pasteboard +from src.Service.ClipboardManager import ClipboardManager +from src.Service.Debug import Debug +import src.events as events + + +class ClipboardManagerMacOs(ClipboardManager): + MAX_CONTENT_LENGTH = 1000 + + _pb: pasteboard.Pasteboard + _debug: Debug + _firstIteration = True + + def __init__(self, debug: Debug): + self._debug = debug + self._pb = pasteboard.Pasteboard() + + events.appLoopIteration.append(self._checkClipboard) + + def _checkClipboard(self) -> None: + content = self._pb.get_contents(type=pasteboard.String, diff=True) + + # On the first call Pasteboard will return content copied before + # opening the app, which we should not parse + if self._firstIteration: + self._firstIteration = False + + return + + # If content did not change between 2 polls, pb.get_contents() will + # return None + if content is None: + return + + # Avoid parsing huge texts to not impact performance + if len(content) > self.MAX_CONTENT_LENGTH: + self._debug.log('Too long clipboard content, skipping') + return + + events.clipboardChanged(content.strip()) + + def setClipboardContent(self, content: str) -> None: + try: + self._pb.set_contents(content) + except Exception as e: + raise Exception('Could not set clipboard content.\nOriginal exception: ' + str(e)) diff --git a/src/Service/ConfigFileManager.py b/src/Service/ConfigFileManager.py new file mode 100644 index 0000000..6161d58 --- /dev/null +++ b/src/Service/ConfigFileManager.py @@ -0,0 +1,44 @@ +import os +import shutil +from src.Service.FilesystemHelper import FilesystemHelper + + +class ConfigFileManager: + CONFIG_APP_PATH: str + CONFIG_USER_PATH: str + CONFIG_USER_EXAMPLE_PATH: str + + def __init__(self, filesystemHelper: FilesystemHelper): + self.CONFIG_APP_PATH = filesystemHelper.getConfigDir() + '/config.app.yml' + self.CONFIG_USER_EXAMPLE_PATH = filesystemHelper.getConfigDir() + '/config.user.example.yml' + self.CONFIG_USER_PATH = filesystemHelper.getUserDataDir() + '/config.user.yml' + + def getAppConfigContent(self) -> str: + # Debug service is not yet initialized, so we simply always print debug information + print(f'Loading app config from `{self.CONFIG_APP_PATH}` ... ', end='') + + with open(self.CONFIG_APP_PATH, 'r') as appConfigFile: + appConfigContent = appConfigFile.read() + print('done') + + return appConfigContent + + def getUserConfigContent(self) -> str: + if not self._userConfigExists(): + self._createUserConfig() + + print(f'Loading user config from `{self.CONFIG_USER_PATH}` ... ', end='') + + with open(self.CONFIG_USER_PATH, 'r') as userConfigFile: + userConfigContent = userConfigFile.read() + print('done') + + return userConfigContent + + def _userConfigExists(self) -> bool: + return os.path.isfile(self.CONFIG_USER_PATH) + + def _createUserConfig(self) -> None: + print(f'Creating user config at `{self.CONFIG_USER_PATH}` from `{self.CONFIG_USER_EXAMPLE_PATH}` ... ', end='') + shutil.copyfile(self.CONFIG_USER_EXAMPLE_PATH, self.CONFIG_USER_PATH) + print('done') diff --git a/src/Service/Configuration.py b/src/Service/Configuration.py new file mode 100644 index 0000000..d286cc7 --- /dev/null +++ b/src/Service/Configuration.py @@ -0,0 +1,61 @@ +import yaml +from src.Service.ConfigFileManager import ConfigFileManager + + +class Configuration: + # Config keys + CLEAR_ON_CHANGE = 'clear_on_change' + CLEAR_AFTER_TIME = 'clear_after_time' + FLASH_ICON_ON_CHANGE = 'flash_icon_on_change' + FORMAT_ICON = 'format_icon' + MENU_ITEMS_LAST_TIMESTAMP = 'menu_items_last_timestamp' + MENU_ITEMS_CURRENT_TIMESTAMP = 'menu_items_current_timestamp' + DEBUG = 'debug' + + _configFileManager: ConfigFileManager + _configApp: dict + """ + Default config of the application. + Located in the project directory, should never be changed by the user. + """ + _configUser: dict + """ + User overrides of app config. + Located in user temp files directory, can be changed by the user. + """ + _configInitialized = False + + def __init__(self, configFileManager: ConfigFileManager): + self._configFileManager = configFileManager + + def get(self, key: str): + self._initializeConfig() + + userValue = self._queryConfigDictionary(key, self._configUser) + + if userValue is not None: + return userValue + + return self._queryConfigDictionary(key, self._configApp) + + def _initializeConfig(self) -> None: + if self._configInitialized: + return + + self._configApp = yaml.load( + self._configFileManager.getAppConfigContent(), + yaml.Loader, + ) + + self._configUser = yaml.load( + self._configFileManager.getUserConfigContent(), + yaml.Loader, + ) + + self._configInitialized = True + + def _queryConfigDictionary(self, key: str, config: dict): + if config is None: + return None + + return config.get(key) diff --git a/src/Service/Debug.py b/src/Service/Debug.py new file mode 100644 index 0000000..4de76d2 --- /dev/null +++ b/src/Service/Debug.py @@ -0,0 +1,19 @@ +import time +from src.Service.Configuration import Configuration +import src.events as events + + +class Debug: + _debugEnabled: bool + + def __init__(self, config: Configuration): + self._debugEnabled = config.get(config.DEBUG) + + if self._debugEnabled: + events.clipboardChanged.append(lambda content: self.log('Clipboard changed: ' + str(content))) + events.timestampChanged.append(lambda timestamp: self.log('Timestamp detected: ' + str(timestamp))) + events.timestampClear.append(lambda: self.log('Timestamp cleared')) + + def log(self, content): + if self._debugEnabled: + print(time.strftime('%H:%M:%S:'), content) diff --git a/src/Service/FilesystemHelper.py b/src/Service/FilesystemHelper.py new file mode 100644 index 0000000..07ce56b --- /dev/null +++ b/src/Service/FilesystemHelper.py @@ -0,0 +1,22 @@ +import os +import rumps +from src.Service.StatusbarApp import StatusbarApp + + +class FilesystemHelper: + def __init__(self): + # Debug service is not yet initialized, so we simply always print debug information + print(f'Project dir: `{self._getProjectDir()}`') + print(f'User data dir: `{self.getUserDataDir()}`') + + def getAssetsDir(self) -> str: + return self._getProjectDir() + '/assets' + + def getConfigDir(self) -> str: + return self._getProjectDir() + '/config' + + def getUserDataDir(self) -> str: + return rumps.application_support(StatusbarApp.APP_NAME) + + def _getProjectDir(self) -> str: + return os.path.realpath(os.path.dirname(os.path.realpath(__file__)) + '/../..') diff --git a/src/Service/OSSwitch.py b/src/Service/OSSwitch.py new file mode 100644 index 0000000..6486062 --- /dev/null +++ b/src/Service/OSSwitch.py @@ -0,0 +1,20 @@ +import platform + + +class OSSwitch: + OS_MAC_OS = 'Darwin' + OS_LINUX = 'Linux' + + os: str + + def __init__(self): + self.os = platform.system() + + if not self.isMacOS() and not self.isLinux(): + raise Exception('Unsupported OS: ' + self.os) + + def isMacOS(self) -> bool: + return self.os == self.OS_MAC_OS + + def isLinux(self) -> bool: + return self.os == self.OS_LINUX diff --git a/src/Service/StatusbarApp.py b/src/Service/StatusbarApp.py new file mode 100644 index 0000000..619c841 --- /dev/null +++ b/src/Service/StatusbarApp.py @@ -0,0 +1,8 @@ +from abc import ABCMeta, abstractmethod + + +class StatusbarApp(metaclass=ABCMeta): + APP_NAME = 'Statusbar Converter' + + def createApp(self) -> None: + pass diff --git a/src/Service/StatusbarAppLinux.py b/src/Service/StatusbarAppLinux.py new file mode 100644 index 0000000..e486e59 --- /dev/null +++ b/src/Service/StatusbarAppLinux.py @@ -0,0 +1,6 @@ +from src.Service.StatusbarApp import StatusbarApp + + +class StatusbarAppLinux(StatusbarApp): + def __init__(self): + raise Exception('Not implemented') diff --git a/src/Service/StatusbarAppMacOs.py b/src/Service/StatusbarAppMacOs.py new file mode 100644 index 0000000..bc89bb8 --- /dev/null +++ b/src/Service/StatusbarAppMacOs.py @@ -0,0 +1,176 @@ +import os +import subprocess +import sys +import time +from rumps import App, MenuItem, rumps +import src.events as events +from src.Service.Debug import Debug +from src.Service.AppLoop import AppLoop +from src.Service.ClipboardManager import ClipboardManager +from src.Service.ConfigFileManager import ConfigFileManager +from src.Service.Configuration import Configuration +from src.Service.StatusbarApp import StatusbarApp +from src.Service.TimestampParser import TimestampParser +from src.Service.TimestampTextFormatter import TimestampTextFormatter +from src.Service.FilesystemHelper import FilesystemHelper +from src.Entity.Timestamp import Timestamp + + +class StatusbarAppMacOs(StatusbarApp): + WEBSITE = 'https://github.com/mindaugasw/statusbar-converter' + ICON_DEFAULT: str + ICON_FLASH: str + + _formatter: TimestampTextFormatter + _clipboard: ClipboardManager + _timestampParser: TimestampParser + _configFileManager: ConfigFileManager + _debug: Debug + _rumpsApp: App + + _menuItems: dict[str, MenuItem | None] + _menuTemplatesLastTimestamp: dict[str, str] + _menuTemplatesCurrentTimestamp: dict[str, str] + _flashIconOnChange: bool + _flashIconSetAt: float | None = None + + def __init__( + self, + formatter: TimestampTextFormatter, + clipboard: ClipboardManager, + timestampParser: TimestampParser, + config: Configuration, + configFileManager: ConfigFileManager, + filesystemHelper: FilesystemHelper, + debug: Debug + ): + self.ICON_DEFAULT = filesystemHelper.getAssetsDir() + '/icon.png' + self.ICON_FLASH = filesystemHelper.getAssetsDir() + '/icon_flash.png' + + self._formatter = formatter + self._clipboard = clipboard + self._timestampParser = timestampParser + self._configFileManager = configFileManager + self._debug = debug + + self._menuTemplatesLastTimestamp = config.get(config.MENU_ITEMS_LAST_TIMESTAMP) + self._menuTemplatesCurrentTimestamp = config.get(config.MENU_ITEMS_CURRENT_TIMESTAMP) + self._flashIconOnChange = config.get(config.FLASH_ICON_ON_CHANGE) + + events.timestampChanged.append(self._onTimestampChange) + events.timestampClear.append(self._onTimestampClear) + events.appLoopIteration.append(self._clearIconFlash) + + def createApp(self) -> None: + self._menuItems = self._createMenuItems() + self._rumpsApp = App( + StatusbarApp.APP_NAME, + None, + self.ICON_DEFAULT, + True, + self._menuItems.values(), + ) + self._rumpsApp.run() + + def _createMenuItems(self) -> dict[str, MenuItem | None]: + lastTimestamp = Timestamp() + menu: dict[str, MenuItem | None] = {} + + if len(self._menuTemplatesLastTimestamp) != 0: + menu.update({ + 'last_timestamp_label': MenuItem('Last timestamp - click to copy'), + }) + + for key, template in self._menuTemplatesLastTimestamp.items(): + menu.update({key: MenuItem( + self._formatter.format(lastTimestamp, template), + self._onMenuClickLastTime, + )}) + + menu.update({'separator_last_timestamp': None}) + + if len(self._menuTemplatesCurrentTimestamp) != 0: + menu.update({ + 'current_timestamp_label': MenuItem('Current timestamp - click to copy'), + }) + + for key, template in self._menuTemplatesCurrentTimestamp.items(): + menu.update({key: MenuItem(key, self._onMenuClickCurrentTime)}) + + menu.update({'separator_current_timestamp': None}) + + menu.update({ + 'clear_timestamp': MenuItem('Clear timestamp', self._onMenuClickClearTimestamp), + 'edit_config': MenuItem('Edit configuration', self._onMenuClickEditConfiguration), + 'check_for_updates': MenuItem('Check for updates'), # TODO implement check for updates + 'open_website': MenuItem('Open website', self._onMenuClickOpenWebsite), + 'restart': MenuItem('Restart application', self._onMenuClickRestart), + }) + + return menu + + def _onTimestampChange(self, timestamp: Timestamp) -> None: + title = self._formatter.formatForIcon(timestamp) + self._debug.log(f'Changing statusbar to: {title}') + self._rumpsApp.title = title + + for key, template in self._menuTemplatesLastTimestamp.items(): + self._menuItems[key].title = self._formatter.format(timestamp, template) + + if self._flashIconOnChange: + self._rumpsApp.icon = self.ICON_FLASH + self._flashIconSetAt = time.time() + + def _clearIconFlash(self) -> None: + if not self._flashIconSetAt: + return + + if (time.time() - self._flashIconSetAt) < (AppLoop.LOOP_INTERVAL / 2): + # After starting icon flash, the script would try to immediately + # turn it off in the same app loop iteration. So we ensure that at + # least some time has passed since flash start + return + + self._rumpsApp.icon = self.ICON_DEFAULT + self._flashIconSetAt = None + + def _onTimestampClear(self) -> None: + self._rumpsApp.title = None + + def _onMenuClickLastTime(self, item: MenuItem) -> None: + self._clipboard.setClipboardContent(item.title) + + def _onMenuClickCurrentTime(self, item: MenuItem) -> None: + template = self._menuTemplatesCurrentTimestamp[item.title] + text = self._formatter.format(Timestamp(), template) + + self._clipboard.setClipboardContent(text) + + def _onMenuClickClearTimestamp(self, item: MenuItem) -> None: + events.timestampClear() + + def _onMenuClickEditConfiguration(self, item: MenuItem) -> None: + configFilePath = self._configFileManager.CONFIG_USER_PATH + + alertResult = rumps.alert( + title='Edit configuration', + message='Configuration can be edited in the file: \n' + f'{configFilePath}\n\n' + 'After editing, the application must be restarted.\n\n' + 'All supported configuration can be found at:\n' + 'https://github.com/mindaugasw/timestamp-statusbar-converter/blob/master/config.app.yml', + ok='Open in default editor', + cancel='Close', + icon_path=self.ICON_FLASH, + ) + + if alertResult == 1: + subprocess.Popen(['open', configFilePath]) + + def _onMenuClickOpenWebsite(self, item: MenuItem) -> None: + subprocess.Popen(['open', self.WEBSITE]) + # TODO use xdg-open on Linux + # https://stackoverflow.com/a/4217323/4110469 + + def _onMenuClickRestart(self, item: MenuItem) -> None: + os.execl(sys.executable, '-m src.main', *sys.argv) diff --git a/src/Service/TimestampParser.py b/src/Service/TimestampParser.py new file mode 100644 index 0000000..3ae0ce0 --- /dev/null +++ b/src/Service/TimestampParser.py @@ -0,0 +1,93 @@ +import re +import time +import src.events as events +from src.Service import Configuration +from src.Service.Debug import Debug +from src.Entity.Timestamp import Timestamp + + +class TimestampParser: + REGEX_PATTERN = '^\\d{1,14}$' + MIN_VALUE = 100000000 # 1973-03-03 + MAX_VALUE = 9999999999 # 2286-11-20 + MILLIS_MIN_CHARACTERS = 12 + """ + If a number has between MILLIS_MIN_CHARACTERS and MILLIS_MAX_CHARACTERS digits, + it will be considered millisecond timestamp. Otherwise regular timestamp. + """ + MILLIS_MAX_CHARACTERS = 14 + + _debug: Debug + _clearOnChange: bool + _clearAfterTime: int + _timestampSetAt: int | None = None + + def __init__(self, config: Configuration, debug: Debug): + self._debug = debug + + self._clearOnChange = config.get(config.CLEAR_ON_CHANGE) + self._clearAfterTime = config.get(config.CLEAR_AFTER_TIME) + + events.clipboardChanged.append(self._processChangedClipboard) + events.timestampChanged.append(self._onTimestampChanged) + events.timestampClear.append(self._onTimestampClear) + events.appLoopIteration.append(self._clearTimestampAfterTime) + + def _processChangedClipboard(self, content: str) -> None: + timestamp = self._extractTimestamp(content) + + if timestamp is None: + if self._clearOnChange and self._timestampSetAt: + events.timestampClear() + + return + + events.timestampChanged(timestamp) + + def _extractTimestamp(self, content: str) -> Timestamp | None: + regexResult = re.match(self.REGEX_PATTERN, content) + + if regexResult is None: + return None + + try: + number = int(content) + except Exception as e: + self._debug.log( + f'Exception occurred while converting copied text to integer.\n' + f'Copied content: {content}\n' + f'Exception: {type(e)}\n' + f'Exception message: {str(e)}' + ) + + return None + + numberString = str(number) + + if self.MILLIS_MIN_CHARACTERS <= len(numberString) <= self.MILLIS_MAX_CHARACTERS: + seconds = int(numberString[:-3]) + milliseconds = int(numberString[-3:]) + else: + seconds = number + milliseconds = None + + if self.MIN_VALUE <= seconds <= self.MAX_VALUE: + return Timestamp(seconds, milliseconds) + + return None + + def _onTimestampChanged(self, timestamp: Timestamp) -> None: + self._timestampSetAt = int(time.time()) + + def _onTimestampClear(self) -> None: + self._timestampSetAt = None + + def _clearTimestampAfterTime(self) -> None: + if self._clearAfterTime <= 0 or not self._timestampSetAt: + return + + if int(time.time()) - self._timestampSetAt < self._clearAfterTime: + return + + self._debug.log('Auto clearing timestamp after timeout') + events.timestampClear() diff --git a/src/Service/TimestampTextFormatter.py b/src/Service/TimestampTextFormatter.py new file mode 100644 index 0000000..b303413 --- /dev/null +++ b/src/Service/TimestampTextFormatter.py @@ -0,0 +1,95 @@ +import datetime +import time +from src.Service.Configuration import Configuration +from src.Entity.Timestamp import Timestamp + + +class TimestampTextFormatter: + _iconFormats: dict[int, str] + + def __init__(self, config: Configuration): + self._iconFormats = config.get(config.FORMAT_ICON) + + def format(self, timestamp: Timestamp, template: str) -> str: + return self._formatInternal(timestamp, template, self._getRelativeTimeData(timestamp.seconds)) + + def formatForIcon(self, timestamp: Timestamp) -> str: + """Format timestamp for main icon, according to user config""" + + timeData = self._getRelativeTimeData(timestamp.seconds) + formatTemplate: str | None = None + + for key, template in self._iconFormats.items(): + if key == 'default' or int(key) > timeData['diff']: + formatTemplate = template + break + + if formatTemplate is None: + raise Exception('No suitable format found for timestamp: ' + str(timestamp)) + + return self._formatInternal(timestamp, formatTemplate, timeData) + + def _formatInternal(self, timestamp: Timestamp, template: str, relativeTimeData: dict) -> str: + """Format timestamp with relative time support + + Formatter supports all standard strftime() codes: + https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes + and these custom codes to for relative time: + - {ts} - unix timestamp. Milliseconds will be separated for better readability. + - {ts_ms} - unix timestamp. Milliseconds won't be separated to allow copying a valid timestamp. + - {r_int} - relative time with integer number, e.g. '5 h ago'. + - {r_float} - relative time with float number, e.g. '5.5 h ago'. + """ + + relativeFormatArguments = ( + '' if relativeTimeData['past'] else 'in ', + relativeTimeData['number'], + relativeTimeData['unit'], + ' ago' if relativeTimeData['past'] else '', + ) + + formatted = template.format( + ts=timestamp.getStringPretty(), + ts_ms=timestamp.getStringValid(), + r_int='%s%d %s%s' % relativeFormatArguments, + r_float='%s%.1f %s%s' % relativeFormatArguments, + ) + + dateTime = datetime.datetime.fromtimestamp(timestamp.seconds) + + return dateTime.strftime(formatted) + + def _getRelativeTimeData(self, timestamp: int) -> dict[str, float | str | bool]: + """ + @return: Dictionary with the following keys: + - diff: int, absolute difference in seconds between given timestamp and now + - number: float, relative time amount, e.g. 5.5 + - unit: str, relative time unit, e.g. 'd' + - past: bool, if timestamp is in the past or in the future + """ + + currentTimestamp = int(time.time()) + diff = abs(currentTimestamp - timestamp) + isPastTime = currentTimestamp >= timestamp + + data = {'diff': diff, 'past': isPastTime} + + if diff < 60: + # up to 60 seconds, return seconds + data.update({'number': float(diff), 'unit': 's'}) + elif diff < 3600: + # up to 60 minutes + data.update({'number': diff / 60.0, 'unit': 'min'}) + elif diff < 86400: + # up to 24 hours + data.update({'number': diff / 3600.0, 'unit': 'h'}) + elif diff < 2678400: + # up to 31 days + data.update({'number': diff / 86400.0, 'unit': 'days'}) + elif diff < 31536000: + # up to 365 days + data.update({'number': diff / 2678400.0, 'unit': 'months'}) + else: + data.update({'number': diff / 31536000.0, 'unit': 'years'}) + + return data diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..6f1f1d1 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,29 @@ +import platform +import src.services as services +import sys +from src.Service.StatusbarApp import StatusbarApp + + +def main(): + if services.osSwitch.isMacOS(): + _hideMacOSDockIcon() + + # TODO print app version as well + print( + f'\n{StatusbarApp.APP_NAME}\n' + f'Platform: {platform.platform()}\n' + f'Detected OS: {services.osSwitch.os}\n' + f'Python: {sys.version}\n' + ) + + services.appLoop.startLoop() + services.statusbarApp.createApp() + + +def _hideMacOSDockIcon(): + import AppKit + info = AppKit.NSBundle.mainBundle().infoDictionary() + info['LSBackgroundOnly'] = '1' + + +main() diff --git a/src/events.py b/src/events.py new file mode 100644 index 0000000..d0c6366 --- /dev/null +++ b/src/events.py @@ -0,0 +1,21 @@ +from src.Entity.Event import Event + +appLoopIteration = Event() + +clipboardChanged = Event() +"""Raised when clipboard content changes, but before parsing content. + +Content is not yet parsed, so it's not necessarily a valid timestamp. + +@param content: New clipboard content +""" + +timestampChanged = Event() +"""Raised when clipboard content changes, after parsing it and finding a valid timestamp. + +@param content +@type content: Timestamp +""" + +timestampClear = Event() +"""Raised when statusbar clear was triggered.""" diff --git a/src/services.py b/src/services.py new file mode 100644 index 0000000..584b8dd --- /dev/null +++ b/src/services.py @@ -0,0 +1,43 @@ +from src.Service.OSSwitch import OSSwitch +from src.Service.FilesystemHelper import FilesystemHelper +from src.Service.Configuration import Configuration +from src.Service.TimestampParser import TimestampParser +from src.Service.ConfigFileManager import ConfigFileManager +from src.Service.TimestampTextFormatter import TimestampTextFormatter +from src.Service.Debug import Debug +from src.Service.ClipboardManager import ClipboardManager +from src.Service.StatusbarApp import StatusbarApp +from src.Service.AppLoop import AppLoop + +osSwitch = OSSwitch() +filesystemHelper = FilesystemHelper() +configFileManager = ConfigFileManager(filesystemHelper) +config = Configuration(configFileManager) +debug = Debug(config) +timestampParser = TimestampParser(config, debug) +timestampTextFormatter = TimestampTextFormatter(config) +clipboardManager: ClipboardManager +statusbarApp: StatusbarApp + +if osSwitch.isMacOS(): + from src.Service.ClipboardManagerMacOs import ClipboardManagerMacOs + from src.Service.StatusbarAppMacOs import StatusbarAppMacOs + + clipboardManager = ClipboardManagerMacOs(debug) + statusbarApp = StatusbarAppMacOs( + timestampTextFormatter, + clipboardManager, + timestampParser, + config, + configFileManager, + filesystemHelper, + debug, + ) +else: + from src.Service.ClipboardManagerLinux import ClipboardManagerLinux + from src.Service.StatusbarAppLinux import StatusbarAppLinux + + clipboardManager = ClipboardManagerLinux() + statusbarApp = StatusbarAppLinux() + +appLoop = AppLoop(clipboardManager) diff --git a/start.py b/start.py new file mode 100644 index 0000000..d303c11 --- /dev/null +++ b/start.py @@ -0,0 +1 @@ +import src