Skip to content

Instantly share code, notes, and snippets.

@hepcat72
Created February 28, 2024 14:39
Show Gist options
  • Save hepcat72/41a7f71b1f5af589879e00fb7ebc75d2 to your computer and use it in GitHub Desktop.
Save hepcat72/41a7f71b1f5af589879e00fb7ebc75d2 to your computer and use it in GitHub Desktop.
Log project time by mission control desktop
-- 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, rleach@princeton.edu
-- 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:
@hepcat72
Copy link
Author

Caveats

  • Note that the logError function is new and not fully tested. I created it today when I decided to make the script distributable.
  • Also new, is the split function, though it has been reasonably tested.

Known issues:

  • Certain apps's names are not discerned correctly. For example, "Visual Studio Code" is shown as "Electron", which has to do with what the apps call themselves in their own code. I have solved this issue in other scripts I've created, to grab the app's name as it is listed in the menu bar, but I haven't done that for this script yet.
  • Note that after reboot(/logout?), the Stickies windows all collect on the first (/current) desktop. There are multiple reasons why I chose to use Stickies, despite this downside. See this stack post for an explanation. I have a separate script I use to record and re-establish pre-shutdown window desktop distribution, but honestly, I usually do it manually because it's slow/disruptive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment