Track Project Time With Alfred Timekeeper



Alfred Time Keeper
Many people juggle multiple projects with different time schedules. From web apps to dedicated programs, there’s already a number of ways to keep track of time spent on a project. Most require net service and/or a program to stay running all the time. Often this isn’t practical.
Alfred Timekeeper works similar to a punch card time keeping system: you save a timestamp when you start and stop working. Since it just records timestamps, a program or web service isn’t always open.
In this tutorial, I'll assume that you’re already familiar with writing workflows in Alfred. If not, please check out these tutorials: Alfred for Beginners, Intermediates, Advanced users and Alfred Debugging.
Overall Design
Many people work with different computers in different locations. That makes keeping track of projects and time management that much harder. By using Dropbox, all of the project and time management information is synchronized automatically.
This approach necessitates the keeping of all information in files and not in computer memory. This restriction requires the use of two data storage arrangements: local directory of information used by that system’s program only, and a synchronized directory that’s shared with every computer connected to it.
The local storage contains the reference to the synchronized directory and the editor to edit timesheets. These files are in Alfred’s data directory location.
The synchronized directory keeps all the timesheets, lists of projects, last known state of recording information, and the timezone information. That will be in a Dropbox location. Any file system synchronization system can be used, I just happen to have Dropbox already.
Creating the Workflow
In Alfred, create a new workflow called Alfred Timekeeper.



In this new workflow, you need to create a File Filter that will only list directories.



You can set the File Types by dragging a directory from Finder to the File Types area. Set the Search Scope to the directory that contains your DropBox account.
Add a Run Script block after this block, unselecting all the escaping options. To that block, add this script:
1 |
#########################
|
2 |
# Contants.
|
3 |
#########################
|
4 |
VPREFS="${HOME}/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/" |
5 |
NVPREFS="${HOME}/Library/Application Support/Alfred 2/Workflow Data/" |
6 |
|
7 |
##############################################################################
|
8 |
# Read the bundleid from the workflow's info.plist
|
9 |
##############################################################################
|
10 |
getBundleId() { |
11 |
/usr/libexec/PlistBuddy -c "Print :bundleid" "info.plist" |
12 |
}
|
13 |
|
14 |
##############################################################################
|
15 |
# Get the workflow data dir
|
16 |
##############################################################################
|
17 |
getDataDir() { |
18 |
local BUNDLEID=$(getBundleId) |
19 |
echo "${NVPREFS}${BUNDLEID}" |
20 |
}
|
21 |
|
22 |
if [ ! -d "$(getDataDir)" ]; then |
23 |
mkdir -p "$(getDataDir)"; |
24 |
touch "$(getDataDir)/dir.txt"; |
25 |
touch "$(getDataDir)/editor.txt"; |
26 |
fi
|
27 |
|
28 |
# Store the directory information.
|
29 |
echo "{query}" > "$(getDataDir)/dir.txt"; |
30 |
|
31 |
# Tell the user.
|
32 |
echo "Set directory to '{query}'."; |
This script checks for the data directory to be there. If not there, it creates the directory and the files used for the workflow. It takes the input in the {query} macro and places it in the dir.txt file in the data directory. It then notifies the user of the action.
Connect the Run Script block to a notification block. All Run Script blocks from here on should connect to this notification block. Set its output to be the {query} macro and set the Only show if passed in argument has content.
Once saved, set the timesheet directory. Inside the Dropbox directory, create a directory for timesheets and use the atk:setdir command to set that directory. You’re now running a multiple system timekeeping program!
That's the basic setup routine for the workflow. Before tackling the main program, you need to setup the working environment.
Setting Up the Go Environment
The easiest way to install the go programming language is by Homebrew. If you haven’t installed Homebrew yet, the tutorial Homebrew Demystified: OS X’s Ultimate Package Manager will show you how.
In a terminal, type:
1 |
brew install go
|
In the home directory, create the directory go. The go language will store all downloaded libraries there. Add to the .bashrc file and/or .zshrc file this line:
1 |
export GOPATH="/Users/<your user name>/go" |
If you’re using fish, add this to the config.fish file:
1 |
set -xg GOPATH "/Users/<your user name>/go" |
After reloading shell, you can install the goAlfred library by typing:
1 |
go get github.com/raguay/goAlfred |
This library makes using the go language with Alfred much easier. It will create the directories to the data storage area for Alfred workflows and make the XML listing needed in Script Filters.
Timekeeper.go
To create the main program file, you have to go to the directory for the workflow. Open the Run Script block.



Click the Open workflow folder button at the bottom of the Script: area. This opens the workflow directory in Finder (or Pathfinder if you have it). To open the directory in a terminal shell, you can use the Alfred Workflow TerminalFinder to open a Finder or Path Finder directory in a terminal session (or iTerm).



Once in the terminal program, type:
1 |
touch Timekeeper.go
|
Open that file in the editor of choice. Once open, put this code in:
1 |
package main |
2 |
|
3 |
//
|
4 |
// Program: TimeKeeper.go
|
5 |
//
|
6 |
// Description: This program runs the feedback logic for selecting the on/off state for
|
7 |
// the currently timed project.
|
8 |
//
|
9 |
|
10 |
//
|
11 |
// Import the libraries we use for this program.
|
12 |
//
|
13 |
import ( |
14 |
"fmt" |
15 |
"github.com/raguay/goAlfred" |
16 |
"io" |
17 |
"io/ioutil" |
18 |
"os" |
19 |
"regexp" |
20 |
"strconv" |
21 |
"strings" |
22 |
"time" |
23 |
)
|
24 |
|
25 |
//
|
26 |
// Setup and constants that are used.
|
27 |
//
|
28 |
// MAXPROJECTS This is the maximum number of projects allowed.
|
29 |
// TSDir This keeps the directory name for the time sheets. It is a complete path.
|
30 |
//
|
31 |
const ( |
32 |
MAXPROJECTS int = 20 |
33 |
)
|
34 |
|
35 |
var TSDir = "" |
36 |
|
37 |
//
|
38 |
// Function: main
|
39 |
//
|
40 |
// Description: This is the main function for the TimeKeeper program. It takes the command line
|
41 |
// and parses it for the proper functionality.
|
42 |
//
|
43 |
func main() { |
44 |
if len(os.Args) > 1 { |
45 |
switch os.Args[1][0] { |
46 |
case 'm': |
47 |
//
|
48 |
// atk:month
|
49 |
//
|
50 |
SystemViewMonth() |
51 |
case 'w': |
52 |
//
|
53 |
// atk:week
|
54 |
//
|
55 |
SystemViewWeek() |
56 |
case 't': |
57 |
//
|
58 |
// atk:current
|
59 |
//
|
60 |
SystemViewDate() |
61 |
case 'r': |
62 |
//
|
63 |
// atk:remove
|
64 |
//
|
65 |
RemoveProject() |
66 |
case 'c': |
67 |
//
|
68 |
// atk:project
|
69 |
//
|
70 |
ChangeProject() |
71 |
case 'b': |
72 |
//
|
73 |
// atk:current
|
74 |
//
|
75 |
SystemViewDay() |
76 |
case 'a': |
77 |
//
|
78 |
// atk:addproject
|
79 |
//
|
80 |
AddProject() |
81 |
case 'o': |
82 |
//
|
83 |
// atk:state
|
84 |
//
|
85 |
StopStart() |
86 |
case 'p': |
87 |
//
|
88 |
// Used for atk:project script fileter
|
89 |
//
|
90 |
project() |
91 |
case 'T': |
92 |
//
|
93 |
// atk:time
|
94 |
//
|
95 |
SystemAllProjects() |
96 |
case 's': |
97 |
fallthrough
|
98 |
default: |
99 |
//
|
100 |
// Used for the script filter on atk:state
|
101 |
//
|
102 |
state() |
103 |
}
|
104 |
}
|
105 |
}
|
106 |
|
107 |
//
|
108 |
// Function: getTimeSheetDir
|
109 |
//
|
110 |
// Description: This function is used to cache a copy of the time
|
111 |
// sheet directory and give it in the return.
|
112 |
//
|
113 |
func getTimeSheetDir() string { |
114 |
if strings.Contains("", TSDir) { |
115 |
Filename := goAlfred.Data() + "/dir.txt" |
116 |
buf, err := ioutil.ReadFile(Filename) |
117 |
if err == nil { |
118 |
//
|
119 |
// Convert the directory path to a string and trim it.
|
120 |
//
|
121 |
TSDir = strings.TrimSpace(string(buf)) |
122 |
}
|
123 |
}
|
124 |
|
125 |
//
|
126 |
// Return the directory to the time sheets.
|
127 |
//
|
128 |
return (TSDir) |
129 |
}
|
130 |
|
131 |
//
|
132 |
// Function: SystemAllProjects
|
133 |
//
|
134 |
// Description: This function will display to the terminal the time for all projects on
|
135 |
// the day given on the command line next.
|
136 |
//
|
137 |
func SystemAllProjects() { |
138 |
//
|
139 |
// Get the current date in case there isn't one on the command line.
|
140 |
//
|
141 |
tm := time.Now() |
142 |
if len(os.Args) > 2 { |
143 |
if strings.Contains("today", os.Args[2]) { |
144 |
//
|
145 |
// Today's date.
|
146 |
//
|
147 |
tm = time.Now() |
148 |
} else if strings.Contains("yesterday", os.Args[2]) { |
149 |
//
|
150 |
// Yesterday is today minus one day.
|
151 |
//
|
152 |
tm = time.Now() |
153 |
tm = tm.AddDate(0, 0, -1) |
154 |
} else { |
155 |
//
|
156 |
// Parse the date string given.
|
157 |
//
|
158 |
tm, _ = time.Parse("2006-Jan-02", os.Args[2]) |
159 |
}
|
160 |
}
|
161 |
|
162 |
//
|
163 |
// Get the list of project names.
|
164 |
//
|
165 |
proj := GetListOfProjects() |
166 |
|
167 |
//
|
168 |
// For each project, get the time spent on it for the given day.
|
169 |
//
|
170 |
numproj := len(proj) - 1 |
171 |
for i := 0; i < numproj; i++ { |
172 |
fmt.Printf("%s: %s\n", proj[i], formatTimeString(GetTimeAtDate(proj[i], tm))) |
173 |
}
|
174 |
}
|
175 |
|
176 |
//
|
177 |
// Function: SystemViewMonth
|
178 |
//
|
179 |
// Description: This function will calculate the time the current month for all the projects.
|
180 |
//
|
181 |
func SystemViewMonth() { |
182 |
//
|
183 |
// Get the current project.
|
184 |
//
|
185 |
currentProject := GetCurrentProject() |
186 |
|
187 |
//
|
188 |
// Get the time on that project for this month. The current time gives the current month.
|
189 |
//
|
190 |
tm := GetTimeAtMonth(currentProject, time.Now()) |
191 |
|
192 |
//
|
193 |
// format the time string and print it out.
|
194 |
//
|
195 |
fmt.Print(formatTimeString(tm)) |
196 |
}
|
197 |
|
198 |
//
|
199 |
// Function: GetTimeAtDate
|
200 |
//
|
201 |
// Description: This function will take a project and calculate the time spent
|
202 |
// on that project for a particular date.
|
203 |
//
|
204 |
func GetTimeAtMonth(project string, date time.Time) int64 { |
205 |
tm := int64(0) |
206 |
dateStart := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, time.UTC) |
207 |
|
208 |
//
|
209 |
// Get the time added up for the whole week.
|
210 |
//
|
211 |
for i := 0; i <= date.Day(); i++ { |
212 |
tm += GetTimeAtDate(project, dateStart.AddDate(0, 0, i)) |
213 |
}
|
214 |
|
215 |
//
|
216 |
// Return the amount of time calculated.
|
217 |
//
|
218 |
return (tm) |
219 |
}
|
220 |
|
221 |
//
|
222 |
// Function: SystemViewWeek
|
223 |
//
|
224 |
// Description: This function will calculate the time the current week for all the projects.
|
225 |
//
|
226 |
// Inputs:
|
227 |
// variable description
|
228 |
//
|
229 |
func SystemViewWeek() { |
230 |
currentProject := GetCurrentProject() |
231 |
tm := GetTimeAtWeek(currentProject, time.Now()) |
232 |
fmt.Print(formatTimeString(tm)) |
233 |
}
|
234 |
|
235 |
//
|
236 |
// Function: GetTimeAtDate
|
237 |
//
|
238 |
// Description: This function will take a project and calculate the time spent
|
239 |
// on that project for a particular date.
|
240 |
//
|
241 |
func GetTimeAtWeek(project string, date time.Time) int64 { |
242 |
tm := int64(0) |
243 |
dateStart := date |
244 |
dateEnd := date |
245 |
switch date.Weekday() { |
246 |
case 0: |
247 |
{
|
248 |
dateEnd = dateEnd.AddDate(0, 0, 6) |
249 |
}
|
250 |
case 1: |
251 |
{
|
252 |
dateStart = dateStart.AddDate(0, 0, -1) |
253 |
dateEnd = dateEnd.AddDate(0, 0, 5) |
254 |
}
|
255 |
case 2: |
256 |
{
|
257 |
dateStart = dateStart.AddDate(0, 0, -2) |
258 |
dateEnd = dateEnd.AddDate(0, 0, 4) |
259 |
}
|
260 |
case 3: |
261 |
{
|
262 |
dateStart = dateStart.AddDate(0, 0, -3) |
263 |
dateEnd = dateEnd.AddDate(0, 0, 3) |
264 |
}
|
265 |
case 4: |
266 |
{
|
267 |
dateStart = dateStart.AddDate(0, 0, -4) |
268 |
dateEnd = dateEnd.AddDate(0, 0, 2) |
269 |
}
|
270 |
case 5: |
271 |
{
|
272 |
dateStart = dateStart.AddDate(0, 0, -5) |
273 |
dateEnd = dateEnd.AddDate(0, 0, 1) |
274 |
}
|
275 |
case 6: |
276 |
{
|
277 |
dateStart = dateStart.AddDate(0, 0, -6) |
278 |
}
|
279 |
}
|
280 |
//
|
281 |
// Get the time added up for th whole week.
|
282 |
//
|
283 |
for i := 0; i < 7; i++ { |
284 |
tm += GetTimeAtDate(project, dateStart.AddDate(0, 0, i)) |
285 |
}
|
286 |
return (tm) |
287 |
}
|
288 |
|
289 |
//
|
290 |
// Function: SystemViewDate
|
291 |
//
|
292 |
// Description: This function will calculate the time for projects at a certain date.
|
293 |
//
|
294 |
func SystemViewDate() { |
295 |
currentProject := GetCurrentProject() |
296 |
tm := GetTimeAtDate(currentProject, time.Now()) |
297 |
fmt.Print(formatTimeString(tm)) |
298 |
}
|
299 |
|
300 |
//
|
301 |
// function: SystemViewDay
|
302 |
//
|
303 |
// Description: This function is for displaying a nice time for the current project.
|
304 |
//
|
305 |
func SystemViewDay() { |
306 |
currentProject := GetCurrentProject() |
307 |
tm := GetTimeAtDate(currentProject, time.Now()) |
308 |
ctime := formatTimeString(tm) |
309 |
state := GetCurrentState() |
310 |
fmt.Printf("The current time on %s is %s. Current state is %s.", currentProject, ctime, state) |
311 |
}
|
312 |
|
313 |
//
|
314 |
// Function: GetTimeAtDate
|
315 |
//
|
316 |
// Description: This function will take a project and calculate the time spent
|
317 |
// on that project for a particular date.
|
318 |
//
|
319 |
func GetTimeAtDate(project string, date time.Time) int64 { |
320 |
//
|
321 |
// Get the current project.
|
322 |
//
|
323 |
filename := generateTimeLogFileName(project, date) |
324 |
tm := readDayTime(filename) |
325 |
return tm |
326 |
}
|
327 |
|
328 |
//
|
329 |
// Function: formatTimeString
|
330 |
//
|
331 |
// Description: This function takes the number of seconds and returns a string
|
332 |
// in hour:minute:seconds format with zero padding.
|
333 |
//
|
334 |
// Input:
|
335 |
// tm time in seconds (an int64)
|
336 |
//
|
337 |
func formatTimeString(tm int64) string { |
338 |
min := int(tm / 60) |
339 |
sec := tm - int64(min*60) |
340 |
hr := min / 60 |
341 |
min = min - (hr * 60) |
342 |
return fmt.Sprintf("%02d:%02d:%02d", hr, min, sec) |
343 |
}
|
344 |
|
345 |
//
|
346 |
// Function: readDayTime
|
347 |
//
|
348 |
// Description: This function reads a time sheet file and calculates the time
|
349 |
// represented in that file.
|
350 |
|
351 |
func readDayTime(filename string) int64 { |
352 |
buf, _ := ioutil.ReadFile(filename) |
353 |
times := regexp.MustCompile("\n|\r").Split(string(buf), -1) |
354 |
|
355 |
//
|
356 |
// Loop through all the time lines.
|
357 |
//
|
358 |
tmwork := int64(0) |
359 |
firsttime := int64(0) |
360 |
first := false |
361 |
for i := 0; i < len(times); i++ { |
362 |
if !strings.Contains("", times[i]) { |
363 |
//
|
364 |
// Split by colon to time and action.
|
365 |
//
|
366 |
parts := strings.Split(times[i], ":") |
367 |
if strings.Contains("start", parts[1]) { |
368 |
firsttime, _ = strconv.ParseInt(parts[0], 10, 64) |
369 |
first = true |
370 |
} else { |
371 |
tm, _ := strconv.ParseInt(parts[0], 10, 64) |
372 |
tmwork += tm - firsttime |
373 |
first = false |
374 |
}
|
375 |
}
|
376 |
}
|
377 |
|
378 |
//
|
379 |
// If a start was the last thing processed, that means it is still being timed. Get the
|
380 |
// current time to see the overall time. firsttime is the time stamp when the start
|
381 |
// was given.
|
382 |
//
|
383 |
if first { |
384 |
currentTime := time.Now() |
385 |
ctime := currentTime.Unix() |
386 |
tmwork += ctime - firsttime |
387 |
}
|
388 |
//
|
389 |
// Return the final Time.
|
390 |
//
|
391 |
return tmwork |
392 |
}
|
393 |
|
394 |
//
|
395 |
// Function: RemoveProject
|
396 |
//
|
397 |
// Description: This function will remove a project from the list a valid projects.
|
398 |
//
|
399 |
func RemoveProject() { |
400 |
//
|
401 |
// Get the project name from the command line.
|
402 |
//
|
403 |
proj := GetCommandLineString() |
404 |
|
405 |
//
|
406 |
// Get the list of project names.
|
407 |
//
|
408 |
projects := GetListOfProjects() |
409 |
|
410 |
//
|
411 |
// Open the projects file in truncation mode to remove all the old stuff.
|
412 |
//
|
413 |
Filename := getTimeSheetDir() + "/projects.txt" |
414 |
Fh, err := os.OpenFile(Filename, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0666) |
415 |
if err != nil { |
416 |
//
|
417 |
// The file would not open. Error out.
|
418 |
//
|
419 |
fmt.Print("Could not open the projects file: ", Filename, "\n") |
420 |
os.Exit(1) |
421 |
}
|
422 |
|
423 |
//
|
424 |
// Loop through all the projects.
|
425 |
//
|
426 |
for i := 0; i < len(projects); i++ { |
427 |
if !strings.Contains(proj, projects[i]) { |
428 |
//
|
429 |
// It is not the project to be removed. Put it into the file.
|
430 |
//
|
431 |
Fh.WriteString(projects[i] + "\n") |
432 |
}
|
433 |
}
|
434 |
|
435 |
//
|
436 |
// Close the file.
|
437 |
//
|
438 |
Fh.Close() |
439 |
|
440 |
//
|
441 |
// Tell the user that the project has been removed.
|
442 |
//
|
443 |
fmt.Print(proj + " has been removed!") |
444 |
}
|
445 |
|
446 |
//
|
447 |
// Function: ChangeProject
|
448 |
//
|
449 |
// Description: This function will change the currently active project. If the old
|
450 |
// project was started, it will stop it first, then set the new project
|
451 |
// and start it.
|
452 |
//
|
453 |
func ChangeProject() { |
454 |
//
|
455 |
// Get the project name from the command line.
|
456 |
//
|
457 |
proj := GetCommandLineString() |
458 |
|
459 |
//
|
460 |
// Get the current project.
|
461 |
//
|
462 |
currentProject := GetCurrentProject() |
463 |
|
464 |
//
|
465 |
// Stop the current project.
|
466 |
//
|
467 |
StopStartProject(currentProject, "stop") |
468 |
|
469 |
//
|
470 |
// Save the new project to the data file.
|
471 |
//
|
472 |
SaveProject(proj) |
473 |
|
474 |
//
|
475 |
// Start the new project.
|
476 |
//
|
477 |
StopStartProject(proj, "start") |
478 |
|
479 |
//
|
480 |
// Tell the user it is started.
|
481 |
//
|
482 |
fmt.Print("The current project is now " + proj + " and is started.") |
483 |
}
|
484 |
|
485 |
//
|
486 |
// Function: GetCommandLineString
|
487 |
//
|
488 |
// Description: This function is used to get the after the function if there is one.
|
489 |
// If not, then just return nothing.
|
490 |
//
|
491 |
func GetCommandLineString() string { |
492 |
//
|
493 |
// See if we have any input other then the command.
|
494 |
//
|
495 |
clstring := "" |
496 |
if len(os.Args) > 2 { |
497 |
clstring = strings.TrimSpace(os.Args[2]) |
498 |
}
|
499 |
|
500 |
//
|
501 |
// Return the the string.
|
502 |
//
|
503 |
return (clstring) |
504 |
}
|
505 |
|
506 |
//
|
507 |
// Function: AddProject
|
508 |
//
|
509 |
// Description: This function will add a new project to the list of current projects.
|
510 |
//
|
511 |
func AddProject() { |
512 |
//
|
513 |
// Get the project name from the command line.
|
514 |
//
|
515 |
proj := GetCommandLineString() |
516 |
|
517 |
//
|
518 |
// Create the file name that contains all the projects.
|
519 |
//
|
520 |
projectFile := getTimeSheetDir() + "/projects.txt" |
521 |
Fh, err := os.OpenFile(projectFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666) |
522 |
if err != nil { |
523 |
Fh, err = os.Create(projectFile) |
524 |
if err != nil { |
525 |
//
|
526 |
// The file would not open. Error out.
|
527 |
//
|
528 |
fmt.Print("Could not open the projects file: ", projectFile, "\n") |
529 |
os.Exit(1) |
530 |
}
|
531 |
}
|
532 |
|
533 |
//
|
534 |
// Write the new command with the time stamp to the buffer.
|
535 |
//
|
536 |
_, err = io.WriteString(Fh, proj+"\n") |
537 |
|
538 |
//
|
539 |
// Lose the file.
|
540 |
//
|
541 |
Fh.Close() |
542 |
|
543 |
//
|
544 |
// Tell the user that the project is added.
|
545 |
//
|
546 |
fmt.Print("Added project " + proj + " to the list.") |
547 |
}
|
548 |
|
549 |
//
|
550 |
// Function: state
|
551 |
//
|
552 |
// Description: This function gives the proper output for changing the state. The state
|
553 |
// first is the one opposite from the current state.
|
554 |
//
|
555 |
func state() { |
556 |
//
|
557 |
// Get the last state of the current project.
|
558 |
//
|
559 |
stateFile := getTimeSheetDir() + "/laststate.txt" |
560 |
buf, _ := ioutil.ReadFile(stateFile) |
561 |
curState := string(buf) |
562 |
|
563 |
//
|
564 |
// Set the first command to the opposite of the current state. That way
|
565 |
// the user simply pushes return to toggle states.
|
566 |
//
|
567 |
if strings.Contains(curState, "start") { |
568 |
goAlfred.AddResult("stop", "stop", "stop", "", "icon.png", "yes", "", "") |
569 |
goAlfred.AddResult("start", "start", "start", "", "icon.png", "yes", "", "") |
570 |
} else { |
571 |
goAlfred.AddResult("start", "start", "start", "", "icon.png", "yes", "", "") |
572 |
goAlfred.AddResult("stop", "stop", "stop", "", "icon.png", "yes", "", "") |
573 |
}
|
574 |
|
575 |
//
|
576 |
// Print out the xml string.
|
577 |
//
|
578 |
fmt.Print(goAlfred.ToXML()) |
579 |
}
|
580 |
|
581 |
//
|
582 |
// Function: project
|
583 |
//
|
584 |
// Description: This function creates a list of the projects and displays the ones
|
585 |
// similar to the input.
|
586 |
//
|
587 |
func project() { |
588 |
//
|
589 |
// Get the project name from the command line.
|
590 |
//
|
591 |
proj := GetCommandLineString() |
592 |
|
593 |
//
|
594 |
// Set our default string.
|
595 |
//
|
596 |
goAlfred.SetDefaultString("Alfred Time Keeper: Sorry, no match...") |
597 |
|
598 |
//
|
599 |
// Get the latest project.
|
600 |
//
|
601 |
latestproject := GetCurrentProject() |
602 |
|
603 |
//
|
604 |
// Get the list of projects.
|
605 |
//
|
606 |
projects := make([]string, MAXPROJECTS) |
607 |
projects = GetListOfProjects() |
608 |
|
609 |
//
|
610 |
// The regexp split statement gives one string more than was split out. The last
|
611 |
// string is a catchall. It does not need to be included.
|
612 |
//
|
613 |
numproj := len(projects) - 1 |
614 |
|
615 |
//
|
616 |
// For each project, create a result line. Show all put the current project.
|
617 |
//
|
618 |
for i := 0; i < numproj; i++ { |
619 |
if !strings.Contains(projects[i], latestproject) { |
620 |
goAlfred.AddResultsSimilar(proj, projects[i], projects[i], projects[i], "", "icon.png", "yes", "", "") |
621 |
}
|
622 |
}
|
623 |
|
624 |
//
|
625 |
// Print out the xml string.
|
626 |
//
|
627 |
fmt.Print(goAlfred.ToXML()) |
628 |
}
|
629 |
|
630 |
//
|
631 |
// Function: GetListOfProjects
|
632 |
//
|
633 |
// Description: This function will return an array of string with the names of the project.
|
634 |
//
|
635 |
func GetListOfProjects() []string { |
636 |
//
|
637 |
// Create the projects array and populate it.
|
638 |
//
|
639 |
projectFile := getTimeSheetDir() + "/projects.txt" |
640 |
buf, _ := ioutil.ReadFile(projectFile) |
641 |
|
642 |
//
|
643 |
// Split out the different project names into separate strings.
|
644 |
//
|
645 |
return (regexp.MustCompile("\n|\r").Split(string(buf), -1)) |
646 |
}
|
647 |
|
648 |
//
|
649 |
// Function: StopStart
|
650 |
//
|
651 |
// Description: This will place a start or stop time stamp for the current project and
|
652 |
// current date.
|
653 |
//
|
654 |
func StopStart() { |
655 |
//
|
656 |
// See if we have any input other then the command. If not, assume a stop command.
|
657 |
//
|
658 |
cmd := "stop" |
659 |
if len(os.Args) > 2 { |
660 |
cmd = strings.ToLower(os.Args[2]) |
661 |
}
|
662 |
|
663 |
//
|
664 |
// Get the current project.
|
665 |
//
|
666 |
currentProject := GetCurrentProject() |
667 |
|
668 |
//
|
669 |
// Run the appropriate function and print the results.
|
670 |
//
|
671 |
fmt.Print(StopStartProject(currentProject, cmd)) |
672 |
}
|
673 |
|
674 |
//
|
675 |
// Function: GetCurrentProject
|
676 |
//
|
677 |
// Description: This function will retrieve the current project from the
|
678 |
// state file.
|
679 |
//
|
680 |
func GetCurrentProject() string { |
681 |
//
|
682 |
// Get the current project.
|
683 |
//
|
684 |
Filename := getTimeSheetDir() + "/project.txt" |
685 |
buf, _ := ioutil.ReadFile(Filename) |
686 |
|
687 |
//
|
688 |
// Convert the current project to a string, trim it, and return it.
|
689 |
//
|
690 |
return (strings.TrimSpace(string(buf))) |
691 |
}
|
692 |
|
693 |
//
|
694 |
// Function: SaveProject
|
695 |
//
|
696 |
// Description: This function will save the given project name to the
|
697 |
// current project file.
|
698 |
//
|
699 |
// Inputs:
|
700 |
// proj Name of the new project
|
701 |
//
|
702 |
func SaveProject(proj string) { |
703 |
//
|
704 |
// Write the new project.
|
705 |
//
|
706 |
Filename := getTimeSheetDir() + "/project.txt" |
707 |
err := ioutil.WriteFile(Filename, []byte(proj), 0666) |
708 |
if err != nil { |
709 |
fmt.Print("Can not write the project file: " + Filename) |
710 |
os.Exit(1) |
711 |
}
|
712 |
}
|
713 |
|
714 |
//
|
715 |
// Function: StopStartProject
|
716 |
//
|
717 |
// Description: This function is used to set the state for the given project.
|
718 |
//
|
719 |
// Inputs:
|
720 |
// currentProject The project to effect the state of.
|
721 |
// cmd The start or stop command.
|
722 |
//
|
723 |
func StopStartProject(currentProject string, cmd string) string { |
724 |
//
|
725 |
// Setup the result string.
|
726 |
//
|
727 |
resultStr := "" |
728 |
|
729 |
currentState := GetCurrentState() |
730 |
|
731 |
//
|
732 |
// Is the current state the same as the new state?
|
733 |
//
|
734 |
if strings.Contains(cmd, currentState) { |
735 |
//
|
736 |
// It is already in that state. Do nothing, but give a message.
|
737 |
//
|
738 |
resultStr = "Already " + cmd + "\n" |
739 |
} else { |
740 |
//
|
741 |
// Okay, we can proceed with writing the new state into the
|
742 |
// dated project file. Open the file for writing.
|
743 |
//
|
744 |
currentTime := time.Now() |
745 |
Filename := generateTimeLogFileName(currentProject, currentTime) |
746 |
Fh, err := os.OpenFile(Filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666) |
747 |
if err != nil { |
748 |
//
|
749 |
// The file would not open. Error out.
|
750 |
//
|
751 |
fmt.Print("Could not open the dated project file: ", Filename, "\n") |
752 |
os.Exit(1) |
753 |
}
|
754 |
|
755 |
//
|
756 |
// Write the new command with the time stamp to the buffer.
|
757 |
//
|
758 |
str := fmt.Sprintf("%d:%s\n", currentTime.Unix(), cmd) |
759 |
_, err = io.WriteString(Fh, str) |
760 |
|
761 |
//
|
762 |
// Lose the file.
|
763 |
//
|
764 |
Fh.Close() |
765 |
|
766 |
//
|
767 |
// Write the laststate file with the new state.
|
768 |
//
|
769 |
ioutil.WriteFile(getTimeSheetDir()+"/laststate.txt", []byte(cmd), 0666) |
770 |
|
771 |
//
|
772 |
// Tell the user it is set.
|
773 |
//
|
774 |
resultStr = currentProject + " is now " + cmd |
775 |
}
|
776 |
|
777 |
//
|
778 |
// Return the resulting string.
|
779 |
//
|
780 |
return (resultStr) |
781 |
}
|
782 |
|
783 |
//
|
784 |
// function: GetCurrentState
|
785 |
//
|
786 |
// Description: This function gets the current state of the project.
|
787 |
//
|
788 |
func GetCurrentState() string { |
789 |
//
|
790 |
// Get the current state.
|
791 |
//
|
792 |
Filename := getTimeSheetDir() + "/laststate.txt" |
793 |
buf, err := ioutil.ReadFile(Filename) |
794 |
currentState := "stop" |
795 |
if err == nil { |
796 |
//
|
797 |
// Convert the current project to a string and trim it.
|
798 |
//
|
799 |
currentState = strings.TrimSpace(string(buf)) |
800 |
}
|
801 |
return currentState |
802 |
}
|
803 |
|
804 |
//
|
805 |
// Function: generateTimeLogFileName
|
806 |
//
|
807 |
// Description: This functions creates the time log file based on the project name and
|
808 |
// date.
|
809 |
//
|
810 |
// Inputs:
|
811 |
// proj Name of the project
|
812 |
// dt Date in question
|
813 |
//
|
814 |
func generateTimeLogFileName(proj string, dt time.Time) string { |
815 |
//
|
816 |
// Generate the proper file name based on the project name and date.
|
817 |
//
|
818 |
filename := getTimeSheetDir() + "/" + proj + "_" + dt.Format("2006-01-02") + ".txt" |
819 |
return (filename) |
820 |
}
|
That code defines all the functionality of the Alfred Timekeeper. The design of the program is to do different functions based on a command letter and options parameters after it. It will get the timesheet directory from Alfred and create the project files and timesheets as needed.
Compiling
You have to compile the program before you can use it. The go language isn’t an interpreted language, but a compiled language. This allows the program to run much faster.
In the terminal program, type:
1 |
go build Timekeeper.go |



If everything is in place properly, you should now have the Timekeeper program in that directory. It is in red above. If something did not get copied right and the compile fails, lookup the line number given and compare it to the above. Or, get a fresh copy from the download given with this tutorial.
Adding Alfred Functions
With the program finished, you need to call it from Alfred. Create a Keyword block with the keyword set to atk:addproject. Connect it to a Run Script block and put this line of code:
1 |
./TimeKeeper a "{query}"
|
All Run Scripts blocks here on out should be set to run bash scripts and none of the escaping options set. The above script allows for creating new projects. Use this to create your different projects. I have the projects: Envato, CustomCT, and Missions created for the work I do.
Now that you have projects, you now need to set one as the current project. Create a Script Filter as below:



Add a Run Script block after it with the following script:
1 |
./Timekeeper c "{query}"
|



Now, you can set the project you want to work on. In the Alfred Prompt, type atk:project and a list of the created projects should appear. Select the project you want and it will automatically start timing for you.
Therefore, if you selected the Tutorials project, there is a file named Tutorials_2014–05–05.txt (that is, as long as you created it on May 5, 2014). Inside that file is a time stamp, a colon, and a start statement. You will also have the project.txt file with the currently selected project, laststate.txt file with the last state (ie: start or stop), and the projects.txt file with a list of created projects.
The timing has started, but now you need to stop it. Create a new Script Filter block with the keyword set to atk:state and the script set to:
1 |
./TimeKeeper s "{query}"
|
That should connect to a Run Script block with the script set to:
1 |
./TimeKeeper o "{query}"
|
The s command tells Timekeeper program to generate an XML output for the next state. It will automatically have the first option in the XML to be the opposite of the current state.



You can now toggle the state of timing a project with atk:state. You can also create hotkeys to start and stop the timing. Try to do that one on your own. You never learn until you try!



You might want an external program to set the state of time recording. Therefore, create a External Trigger block as shown. Connect this block to the previous Run Script block.



The Sample Code at the bottom can be ran by any AppleScript aware program to trigger start/stop actions on the current project. If you replace the text test with stop, it will stop timing. If you replace the text test with start, it will start timing. Used with ControlPlane, you can create a rule to turn off timing when the computer sleeps and resume timing when the computer wakes.
Now that you’re starting and stopping the time, you need to view the time spent. Create a Keyword block with atk:current. Connect it to a Run Script block with this script:
1 |
echo `./Timekeeper b`; |
Connect it to the Notification block.



When you run atk:current in Alfred prompt, you will get the current time spent on the project and the current state.
Second Computer
If you have another computer, setup Alfred to share over Dropbox. Once set up, all of your workflows will automatically update from one computer to the other.



Select the Set sync folder to set up syncing via Dropbox and follow the directions. On the second computer, use the command atk:setdir to set the timesheet directory you created in Dropbox. The two systems can now start and stop project timings. Make sure Dropbox is fully synced in between state changes.
Conclusion
That’s the basics of the Alfred Timekeeper. The workflow packaged with the download is the full version with other features. You now understand how to create your own time management system that’s scalable to use on multiple computers using Dropbox and does not take up computer resources.
Since I use this everyday, I will be adding more features such as a web interface to see the timesheets graphically. If you can come up with other features, try to add them yourself! That’s the fun of working with Alfred workflows.