Created
February 28, 2024 14:39
-
-
Save hepcat72/41a7f71b1f5af589879e00fb7ebc75d2 to your computer and use it in GitHub Desktop.
Log project time by mission control desktop
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Mission Control Desktop Time Tracker | |
-- What is this: This is a script intended to be run via a cron job that logs your current desktop name, cursor position, monitor, app, window name, and other customizable data to a project log file. | |
-- Purpose: Track time spent on projects that are organized by desktop (or custom-set app). See this stack post: https://apple.stackexchange.com/a/470628/55021 | |
-- Author: Robert Leach, Genomics Group, Princeton, [email protected] | |
-- Version: 7.0 (released on 2/28/2024) | |
-- Installation: | |
-- Put a Stickies.app sticky note on each desktop containing a single-word desktop name (as the first word on the first line of the sticky). The first line must also contain the string "dtop" (without the quotes). Be sure that Stickies.app is not assigned to any desktop. | |
-- Create a cron job | |
-- Command: `osascript logCurrentDesktop.osa >> desktop_log.txt` | |
-- Example expression: "* * * * MON-FRI" Runs once a minute of every weekday | |
-- * * * * MON-FRI osascript /path/to/logProjectActivity.osa >> /path/to/project_log.txt | |
-- Note that the first time it runs, you will need to set permissions. I have not yet documented these permissions, so you're on your own. | |
-- Output: | |
-- This outputs a single line containing, in order: | |
-- An integer representing the number of seconds (perl -e 'print time') | |
-- The X and Y coordinates of the cursor (to allow inference of presence) | |
-- The name of the current desktop from the Sticky note described under the requirements | |
-- Example line of output: | |
-- 1709129996 1632,1390 TRACEBASE DELL P2721Q, (1) Script Editor logProjectActivity7.scpt none none none none | |
-- Usage: | |
-- I have a perl script to plot project activity by day or by project, but I have not yet published it, so plotting the data from the project_log.txt is an exercise currently left to the user. Once I have published the perl script, I will create a proper git repo to house it. | |
-- | |
-- Edit the following to your desired values | |
-- | |
set debug_mode to false | |
-- The string anywhere on the first line of the sticky labeling the desktop that identifies it as containing the desktop name (the name should be the first word on the first line) | |
set desktop_stickie_id to "dtop" | |
-- The default desktop name (if the desktop has no stickie window with the desktop_stickie_id | |
set default_desktop_name to "unmonitored" | |
-- POSIX path to the error log file (no spaces allowed) | |
set error_log_file to "./project_log.err" | |
-- Add custom statuses along with a list of app conditions that indicate that status. The structure of the customStatusChecks list is: {"status string when conditions are met",{list of conditions necessary to return that status}}, where the "list of conditions..." is {{"app status of either 'running' or 'front'", "app name", {list of window name strings to match}},...}. If the app status is "running", it checks if the app is running, and if any window strings are supplied: if any one of the windows in the list is open (not if it is in focus). If the app status is "front", it checks if the app has focus, and if the list of window names has any strings in it, it checks if any one of the listed windows of that app has focus. E.g. set customStatusChecks to {{"Meeting",{{"running","zoom.us",{"Zoom Meeting"}}}}} -- will add "Meeting" to the list of returned statuses if the app "zoom.us" is running and if it has a window currently open containing the string "Zoom Meeting" in its name. If thos conditions are not met, the string "none" is added to the list of statuses | |
set customStatusChecks to {¬ | |
{"meeting", ¬ | |
{¬ | |
{"running", "zoom.us", {"Zoom Meeting"}}}}, ¬ | |
{"communication", ¬ | |
{¬ | |
{"front", "Mail", {}}, ¬ | |
{"front", "gen-help", {}}, ¬ | |
{"front", "Slack", {}}}}, ¬ | |
{"planning", ¬ | |
{¬ | |
{"front", "Calendar", {}}, ¬ | |
{"front", "Trello", {}}}}, ¬ | |
{"overhead", ¬ | |
{¬ | |
{"front", "Princeton IT Self Service", {}}, ¬ | |
{"front", "Safari", {"Holiday Schedule", "OIT Store", "Self Service", "TigerCard", "Tiger Transit"}}}}} | |
-- | |
-- No editing below this point | |
-- | |
use AppleScript version "2.4" -- Yosemite (10.10) or later | |
use framework "Foundation" | |
use scripting additions | |
property |⌘| : a reference to current application | |
global debug_mode | |
global desktop_stickie_id | |
global default_desktop_name | |
global error_log_file | |
set customStatuses to my getCustomStatuses(customStatusChecks) | |
set dtop to my getCurrentDesktop() | |
set secs to my getSecondsSinceEpoch() | |
set {mpos, mntr} to my getMousePosition() | |
set {capp, cwin} to my getCurrentApp() | |
set lmsg to join(tab, {secs, mpos, dtop, mntr, capp, cwin, (every item of customStatuses)}) | |
if debug_mode is true then | |
display dialog lmsg | |
end if | |
return lmsg | |
on getCurrentDesktop() | |
--The string anywhere on the first line of the sticky labeling the desktop that identifies it as containing the desktop name (the name should be the first word on the first line) | |
set dtopstr to desktop_stickie_id | |
set dname to default_desktop_name | |
set err_str to "" | |
try | |
tell application "System Events" | |
--obtain the stickie with the desktop name | |
set dstr to name of first item of (windows of application process "Stickies" of application "System Events" whose name contains dtopstr) | |
set dname to first item of (my split(" ", dstr)) | |
end tell | |
on error orig_err | |
set dname to default_desktop_name | |
set num_wins to -1 | |
try | |
tell application "System Events" | |
-- If there's only one sticky window, just assume it doesn't have the dtopstr it should have | |
set num_wins to count of (windows of application process "Stickies" of application "System Events" whose name contains dtopstr) | |
if num_wins is equal to 1 then | |
set dstr to name of first item of (windows of application process "Stickies" of application "System Events") | |
set dname to first item of (my split(" ", dstr)) | |
end if | |
end tell | |
on error | |
my logError(orig_err) | |
end try | |
end try | |
return dname | |
end getCurrentDesktop | |
on getSecondsSinceEpoch() | |
-- Since my plotting script uses perl, I use perl to get the seconds since epoch | |
return do shell script "perl -e 'print(time())'" | |
end getSecondsSinceEpoch | |
on logError(error_msg) | |
set quoted_err to my replacePattern:"\"" inString:error_msg usingThis:"'" | |
set oneline_quoted_err to my replacePattern:(linefeed & "|" & return) inString:error_msg usingThis:" " | |
set error_string to do shell script "echo " & ((current date) as string) & ": " & quoted_err & " >> " & error_log_file | |
return error_string | |
end logError | |
--Currently unused | |
--The value this returns seems unreliable/unintelligible. It apparrently used to be seconds since last input event | |
on getIdleTime() | |
try | |
set idleTime to do shell script "/usr/sbin/ioreg -c IOHIDSystem | awk '/HIDIdleTime/ {print $NF/1000000000; exit}'" | |
on error errstr | |
my logError(errstr) | |
set idleTime to "Error (see" & error_log_file & ")" | |
end try | |
return idleTime | |
end getIdleTime | |
on getMousePosition() | |
--Determine the cursor's x/y coordinates | |
set mousePosition to |⌘|'s NSEvent's mouseLocation() | |
set {X_Mouse, Y_Mouse} to mousePosition as list | |
set X_Mouse to round X_Mouse | |
set Y_Mouse to round Y_Mouse | |
set mpos to (X_Mouse as string) & "," & Y_Mouse as string | |
--Determine which monitor the cursor is on | |
set allScreens to |⌘|'s NSScreen's screens() | |
set monNum to 0 | |
set myMon to "unknown" | |
repeat with aScreen in allScreens | |
set monNum to monNum + 1 | |
if |⌘|'s NSPointInRect(mousePosition, aScreen's frame()) then | |
set foundScreen to contents of aScreen | |
set myMon to (localizedName of foundScreen as string) & ", (" & monNum & ")" | |
exit repeat | |
end if | |
end repeat | |
if debug_mode is true then | |
display dialog myMon | |
end if | |
return {mpos, myMon} | |
end getMousePosition | |
on getCurrentApp() | |
tell application "System Events" | |
set frontApp to name of first application process whose frontmost is true | |
set frontWin to "no window" | |
try | |
set frontWin to name of first window of (first application process whose frontmost is true) | |
on error | |
set frontWin to "no window" | |
end try | |
return {frontApp, frontWin} | |
end tell | |
end getCurrentApp | |
on getCustomStatuses(statusChecks) | |
set myStatuses to {} | |
repeat with statusCheck in statusChecks | |
set theStatus to the first item of statusCheck | |
set appChecks to the second item of statusCheck | |
set foundOne to false | |
repeat with appParams in appChecks | |
set appStatus to the first item of appParams | |
set appName to the second item of appParams | |
set appWindows to the third item of appParams | |
if my isAppActive(appStatus, appName, appWindows) is true then | |
set the end of myStatuses to theStatus | |
set foundOne to true | |
exit repeat | |
end if | |
end repeat | |
if foundOne is false then | |
set the end of myStatuses to "none" | |
end if | |
end repeat | |
return myStatuses | |
end getCustomStatuses | |
on isAppActive(appStatus, appName, windowNames) | |
if appStatus is equal to "running" then | |
if my isAppRunning(appName) is true then | |
if (count of windowNames) is equal to 0 then | |
return true | |
else | |
tell application "System Events" | |
repeat with windowName in windowNames | |
if (name of every window of application process appName) contains windowName then | |
return true | |
end if | |
end repeat | |
end tell | |
end if | |
end if | |
else --assume "front" | |
tell application "System Events" | |
set {frontApp, frontWin} to my getCurrentApp() | |
if (count of windowNames) is equal to 0 then | |
return (frontApp is equal to appName) | |
else if frontApp is equal to appName then | |
repeat with windowName in windowNames | |
if frontWin contains windowName then | |
return true | |
end if | |
end repeat | |
end if | |
end tell | |
end if | |
return false | |
end isAppActive | |
on isAppRunning(appName) | |
tell application "System Events" to (name of processes) contains appName | |
end isAppRunning | |
on join(myDelimiter, myList) | |
set astid to AppleScript's text item delimiters | |
set AppleScript's text item delimiters to myDelimiter | |
set joinedString to myList as text | |
set AppleScript's text item delimiters to astid | |
return joinedString | |
end join | |
on split(myDelimiter, myString) | |
set astid to AppleScript's text item delimiters | |
set AppleScript's text item delimiters to myDelimiter | |
set myList to (myString's text items) | |
set AppleScript's text item delimiters to astid | |
return myList | |
end split | |
--Call like this: set res to my replacePattern:"\\s+" inString:"1 adding-these: 2 3 4" usingThis:"+" | |
--use framework "Foundation" | |
--use scripting additions | |
on replacePattern:thePattern inString:theString usingThis:theTemplate | |
set theRegEx to current application's NSRegularExpression's regularExpressionWithPattern:thePattern options:0 |error|:(missing value) | |
set theResult to theRegEx's stringByReplacingMatchesInString:theString options:0 range:{location:0, |length|:length of theString} withTemplate:theTemplate | |
return theResult as text | |
end replacePattern:inString:usingThis: |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Caveats
logError
function is new and not fully tested. I created it today when I decided to make the script distributable.split
function, though it has been reasonably tested.Known issues: