This repository has been archived on 2022-05-13. You can view files and clone it, but cannot push or open issues or pull requests.
chronometrist/cl/chronometrist.org

3322 lines
126 KiB
Org Mode
Raw Permalink Normal View History

2022-03-26 14:53:01 +00:00
# -*- mode: poly-org; -*-
#+TITLE: Chronometrist
#+SUBTITLE: A friendly and powerful personal time tracker and analyzer
2022-03-26 17:54:42 +00:00
#+DESCRIPTION: Common Lisp implementation
2022-03-26 14:53:01 +00:00
#+AUTHOR: contrapunctus
#+TODO: TODO TEST WIP EXTEND CLEANUP FIXME HACK REVIEW |
2022-03-26 14:53:01 +00:00
#+PROPERTY: header-args :tangle yes :comments link
* Introduction
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: introduction
:END:
2022-03-26 14:53:01 +00:00
This is a book about Chronometrist, a time tracker for Emacs, written by a humble hobbyist hacker. It also happens to contain the canonical copy of the source code, and can be loaded as an Emacs Lisp program using the =literate-elisp= library.
I hope this book—when completed—passes Tim Daly's [[https://www.youtube.com/watch?v=Av0PQDVTP4A&t=8m52s]["Hawaii Test"]], in which a programmer with no knowledge of this program whatsover can read this book end-to-end, and come out as much of an expert in its maintenance as the original author.
—contrapunctus
* Explanation
:PROPERTIES:
:DESCRIPTION: The design, the implementation, and a little history
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: explanation
2022-03-26 14:53:01 +00:00
:END:
** Why I wrote Chronometrist
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: why-i-wrote-chronometrist
:END:
2022-03-26 14:53:01 +00:00
It probably started off with a desire for introspection and self-awareness - what did I do all day? How much time have I spent doing X? It is also a tool to help me stay focused on a single task at a time, and to not go overboard and work on something for 7 hours straight.
2022-03-26 17:32:39 +00:00
At first I tried an Android application, "A Time Tracker". The designs of Chronometrist's main buffer and the =report= buffer still resemble that of A Time Tracker. However, every now and then I'd forget to start or stop tracking. Each time I did, I had to open an SQLite database and edit UNIX timestamps to correct it, which was not fun :\
2022-03-26 14:53:01 +00:00
Later, I discovered John Wiegley's =timeclock=. It turned out that since it was an Emacs extension (actually, a part of Emacs), I was more likely to use it. Chronometrist started out as an "A Time Tracker"-like UI for =timeclock=, moving to an s-expression backend later.
Quite recently, after around two years of Chronometrist developement and use, I discovered that Org mode has a time tracking feature, too. Even though I've embraced some of [[#explanation-literate-programming][the joys of Org]], I'm not quite at ease with the idea of storing data in a complex text format with only one complete implementation.
** Design goals
:PROPERTIES:
:DESCRIPTION: Some vague objectives which guided the project
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: design-goals
2022-03-26 14:53:01 +00:00
:END:
1. Don't make assumptions about the user's profession
- e.g. timeclock seems to assume you're using it for a 9-to-5/contractor job
2. Incentivize use
* Hooks allow the time tracker to automate tasks and become a useful part of your workflow
3. Make it easy to edit data using existing, familiar tools
* We don't use an SQL database, where changing a single field is tricky [fn:1]
* We use a text file containing s-expressions (easy for humans to read and write)
* We use ISO-8601 for timestamps (easy for humans to read and write) rather than UNIX epoch time
4. Reduce human errors in tracking
5. Have a useful, informative, interactive interface
6. Support mouse and keyboard use equally
[fn:1] I still have doubts about this. Having SQL as a query language would be very useful in perusing the stored data. Maybe we should have tried to create a companion mode to edit SQL databases interactively?
** Terminology
:PROPERTIES:
:DESCRIPTION: Explanation of some terms used later
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: terminology
2022-03-26 14:53:01 +00:00
:END:
Chronometrist records /time intervals/ (earlier called "events") as plists. Each plist contains at least a =:name "<name>"=, a =:start "<iso-timestamp>"=, and (except in case of an ongoing task) a =:stop "<iso-timestamp>"=.
+ row :: a row of a table in a =tabulated-list-mode= buffer; an element of =tabulated-list-entries=.
+ schema :: the column descriptor of a table in a =tabulated-list-mode= buffer; the value of =tabulated-list-format=.
See also [[#explanation-time-formats][Currently-Used Time Formats]]
** Overview
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: overview
:END:
2022-03-26 14:53:01 +00:00
At its most basic, we read data from a [[#program-backend][backend]], store it in a [[#program-data-structures][hash table]], and [[#program-frontend-chronometrist][display it]] as a [[elisp:(find-library "tabulated-list-mode")][=tabulated-list-mode=]] buffer. When the file is changed—whether by the program or the user—we [[refresh-file][update the hash table]] and the [[#program-frontend-chronometrist-refresh][buffer]].
In addition, we implement a [[#program-pretty-printer][plist pretty-printer]] and some [[#program-migration][migration commands]].
Extensions exist for -
1. [[file:chronometrist-key-values.org][attaching arbitrary metadata]] to time intervals, and
2. support for the [[file:chronometrist-third.org][Third Time system]]
3. [[https://tildegit.org/contrapunctus/chronometrist-goal][time goals and alerts]]
** Optimization
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: optimization
:END:
2022-03-26 14:53:01 +00:00
It is of great importance that Chronometrist be responsive -
+ A responsive program is more likely to be used; recall our design goal of 'incentivizing use'.
+ Being an Emacs program, freezing the UI for any human-noticeable length of time is unacceptable - it prevents the user from working on anything in their environment.
Thus, I have considered various optimization strategies, and so far implemented two.
*** Prevent excess creation of file watchers
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: prevent-excess-creation-of-file-watchers
:END:
2022-03-26 14:53:01 +00:00
One of the earliest 'optimizations' of great importance turned out to simply be a bug - turns out, if you run an identical call to [[elisp:(describe-function 'file-notify-add-watch)][=file-notify-add-watch=]] twice, you create /two/ file watchers and your callback will be called /twice./ We were creating a file watcher /each time the =chronometrist= command was run./ 🤦 This was causing humongous slowdowns each time the file changed. 😅
2022-03-26 17:32:39 +00:00
+ It was fixed in v0.2.2 by making the watch creation conditional, using =-fs-watch= to store the watch object.
2022-03-26 14:53:01 +00:00
*** Preserve hash table state for some commands
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: preserve-hash-table-state-some-commands
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 14:53:01 +00:00
NOTE - this has been replaced with a more general optimization - see next section.
2022-03-26 17:32:39 +00:00
The next one was released in v0.5. Till then, any time the [[* chronometrist-file][=file=]] was modified, we'd clear the =events= hash table and read data into it again. The reading itself is nearly-instant, even with ~2 years' worth of data [fn:2] (it uses Emacs' [[elisp:(describe-function 'read)][=read=]], after all), but the splitting of [[#explanation-midnight-spanning-intervals][midnight-spanning events]] is the real performance killer.
2022-03-26 14:53:01 +00:00
After the optimization...
2022-03-26 17:32:39 +00:00
1. Two backend functions (=sexp-new= and =sexp-replace-last=) were modified to set a flag (=-inhibit-read-p=) before saving the file.
2. If this flag is non-nil, [[* refresh-file][=refresh-file=]] skips the expensive calls to =events-populate=, =tasks-from-table=, and =tags-history-populate=, and resets the flag.
3. Instead, the aforementioned backend functions modify the relevant variables - =events=, =task-list=, and =tags-history= - via...
* =events-add= / =events-replace-last=
* =task-list-add=, and
* =tags-history-add= / =tags-history-replace-last=, respectively.
2022-03-26 14:53:01 +00:00
2022-03-26 17:32:39 +00:00
There are still some operations which [[* refresh-file][=refresh-file=]] runs unconditionally - which is to say there is scope for further optimization, if or when required.
2022-03-26 14:53:01 +00:00
[fn:2] As indicated by exploratory work in the =parsimonious-reading= branch, where I made a loop to only =read= and collect s-expressions from the file. It was near-instant...until I added event splitting to it.
*** Determine type of change made to file
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: determine-type-of-change-made-to-file
:END:
2022-03-26 17:32:39 +00:00
Most changes, whether made through user-editing or by Chronometrist commands, happen at the end of the file. We try to detect the kind of change made - whether the last expression was modified, removed, or whether a new expression was added to the end - and make the corresponding change to =events=, instead of doing a full parse again (=events-populate=). The increase in responsiveness has been significant.
2022-03-26 14:53:01 +00:00
2022-03-26 17:32:39 +00:00
When =refresh-file= is run by the file system watcher, it uses =file-hash= to assign indices and a hash to =-file-state=. The next time the file changes, =file-change-type= compares this state to the current state of the file to determine the type of change made.
2022-03-26 14:53:01 +00:00
Challenges -
1. Correctly detecting the type of change
2022-03-26 17:32:39 +00:00
2. Updating =task-list= and the Chronometrist buffer, when a new task is added or the last interval for a task is removed (v0.6.4)
2022-03-26 14:53:01 +00:00
3. Handling changes made to an active interval after midnight
* use the date from the plist's =:start= timestamp instead of the date today
* =:append= - normally, add to table; for spanning intervals, invalid operation
* =:modify= - normally, replace in table; for spanning intervals, split and replace
* =:remove= - normally, remove from table; for spanning intervals, split and remove
Effects on the task list
1. When a plist is added, the =:name= might be new, in which case we need to add it to the task list.
2. When the last plist is modified, the =:name= may have changed -
1. the =:name= might be new and require addition to the task list.
2. the old plist may have been the only plist for the old =:name=, so we need to check if there are any other plists with the old =:name=. If there are none, the old =:name= needs to be removed from the task list.
3. When the last plist is removed, it may have been the only plist for the old =:name=, so we need to check if there are any other plists with the old =:name=. If there are none, the old =:name= needs to be removed from the task list.
** Midnight-spanning intervals
:PROPERTIES:
:DESCRIPTION: Events starting on one day and ending on another
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: midnight-spanning-intervals
2022-03-26 14:53:01 +00:00
:END:
A unique problem in working with Chronometrist, one I had never foreseen, was tasks which start on one day and end on another. For instance, you start working on something at =2021-01-01 23:00= hours and stop on =2021-01-02 01:00=.
These mess up data consumption in all sorts of unforeseen ways, especially interval calculations and acquiring intervals for a specific date. In case of two of the most common operations throughout the program -
1. finding the intervals recorded on a given date
2. finding the time spent on a task on a given day - if the day's intervals used for this contain a midnight-spanning interval, you'll have inaccurate results - it will include yesterday's time from the interval as well as today's.
There are a few different approaches of dealing with them. (Currently, Chronometrist uses #3.)
*** Check the code of the first event of the day (timeclock format)
:PROPERTIES:
:DESCRIPTION: When the code of the first event in the day is "o", it's a midnight-spanning event.
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: check-code-of-first-event-of-day
2022-03-26 14:53:01 +00:00
:END:
+ Advantage - very simple to detect
+ Disadvantage - "in" and "out" events must be represented separately
*** Split them at the file level
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: split-in-file
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 14:53:01 +00:00
+ Advantage - operation is performed only once for each such event + simpler data-consuming code + reduced post-parsing load.
+ What happens when the user changes their day-start-time? The split-up events are now split wrongly, and the second event may get split /again./
Possible solutions -
1. Add function to check if, for two events A and B, the =:stop= of A is the same as the =:start= of B, and that all their other tags are identical. Then we can re-split them according to the new day-start-time.
+ Implemented as [[#program-data-structures-plists-split-p][plist-split-p]]
2. Add a =:split= tag to split events. It can denote that the next event was originally a part of this one.
3. Re-check and update the file when the day-start-time changes.
- Possible with ~add-variable-watcher~ or ~:custom-set~ in Customize (thanks bpalmer)
This strategy is implemented in the [[#program-backend-plist-group][plist-group]] backend. Custom day-start time is not yet implemented - if ever implemented, it will probably require exporting the file, with all split intervals being combined and re-split.
*** Split them at the hash-table-level
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: split-in-hash-table
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:32:39 +00:00
Handled by ~sexp-events-populate~
2022-03-26 14:53:01 +00:00
+ Advantage - simpler data-consuming code.
*** Split them at the data-consumer level (e.g. when calculating time for one day/getting events for one day)
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: split-at-data-consumer-level
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 14:53:01 +00:00
+ Advantage - reduced repetitive post-parsing load.
** Point restore behaviour
:PROPERTIES:
:DESCRIPTION: The desired behaviour of point in Chronometrist
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: point-restore-behaviour
2022-03-26 14:53:01 +00:00
:END:
After hacking, always test for and ensure the following -
2022-03-26 17:32:39 +00:00
1. Toggling the buffer via [[#program-frontend-chronometrist-command][chronometrist]]/[[#program-frontend-report-command][report]]/[[#program-frontend-statistics-command][statistics]] should preserve point
2022-03-26 14:53:01 +00:00
2. The timer function should preserve point when the buffer is current
3. The timer function should preserve point when the buffer is not current, but is visible in another window
4. The next/previous week keys and buttons should preserve point.
2022-03-26 17:32:39 +00:00
** report date range logic
2022-03-26 14:53:01 +00:00
:PROPERTIES:
:DESCRIPTION: Deriving dates in the current week
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: report-date-range-logic
2022-03-26 14:53:01 +00:00
:END:
2022-03-26 17:32:39 +00:00
A quick description, starting from the first time [[#program-frontend-report-command][report]] is run in an Emacs session -
1. We get the current date as a =ts= struct, using date.
2022-03-27 21:08:41 +00:00
2. The variable =week-start-day= stores the day we consider the week to start with. The default is "Sunday".
2022-03-26 14:53:01 +00:00
2022-03-27 21:08:41 +00:00
We check if the date from #2 is on the week start day, else decrement it till we are, using =(previous-week-start)=.
3. We store the date from #3 in the global variable =-ui-date=.
4. By counting up from =-ui-date=, we get dates for the days in the next 7 days using =(date->dates-in-week)=. We store them in =-ui-week-dates=.
2022-03-26 14:53:01 +00:00
2022-03-27 21:08:41 +00:00
The dates in =-ui-week-dates= are what is finally used to query the data displayed in the buffer.
5. To get data for the previous/next weeks, we decrement/increment the date in =-ui-date= by 7 days and repeat the above process (via =(previous-week)= / =(next-week)=).
2022-03-26 14:53:01 +00:00
** Literate programming
:PROPERTIES:
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: literate-programming
2022-03-26 14:53:01 +00:00
:END:
The shift from a bunch of Elisp files to a single Org literate program was born out of frustration with programs stored as text files, which are expensive to restructure (especially in the presence of a VCS). While some dissatisfactions remain, I generally prefer the outcome - tree and source-block folding, tags, properties, and =org-match= have made it trivial to get different views of the program, and literate programming may allow me to express the "explanation" documentation in the same context as the program, without having to try to link between documentation and source.
*** Tangling
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tangling
:END:
2022-03-26 17:54:42 +00:00
At first, I tried tangling. Back when I used =benchmark.el= to test it, =org-babel-tangle= took about 30 seconds to tangle this file. Thus, I wrote a little sed one-liner (in the file-local variables) to do the tangling, which was nearly instant. It emitted anything between lines matching the exact strings ="#+BEGIN_SRC lisp"= and ="#+END_SRC"= -
2022-03-26 14:53:01 +00:00
#+BEGIN_SRC org :tangle no
2022-03-26 17:54:42 +00:00
# eval: (progn (make-local-variable 'after-save-hook) (add-hook 'after-save-hook (lambda () (start-process-shell-command "sed-tangle" "sed-tangle" "sed -n -e '/#+BEGIN_SRC lisp$/,/#+END_SRC$/{//!p;};/#+END_SRC/i\\ ' chronometrist.org | sed -E 's/^ +$//' > chronometrist.el"))))
2022-03-26 14:53:01 +00:00
#+END_SRC
*** literate-elisp-load
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: literate-elisp-load
:END:
2022-03-26 14:53:01 +00:00
Later, we switched from tangling to using the =literate-elisp= package to loading this Org file directly - a file =chronometrist.el= would be used to load =chronometrist.org=.
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no :load no
2022-03-26 14:53:01 +00:00
(literate-elisp-load
(format "%schronometrist.org" (file-name-directory load-file-name)))
#+END_SRC
This way, source links (e.g. help buffers, stack traces) would lead to this Org file, and this documentation was available to each user, within the comfort of their Emacs. The presence of the =.el= file meant that users of =use-package= did not need to make any changes to their configuration.
*** Reject modernity, return to tangling
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: reject-modernity,-return-to-tangling
:END:
2022-03-26 14:53:01 +00:00
For all its benefits, the previous approach broke autoloads and no sane way could be devised to make them work, so back we came to tangling. =org-babel-tangle-file= seems to be quicker when run as a Git pre-commit hook - a few seconds' delay before I write a commit message.
Certain tools like =checkdoc= remain a pain to use with any kind of literate program. This will probably continue to be the case until these tools are fixed or extended.
*** Definition metadata
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: definition-metadata
:END:
2022-03-26 14:53:01 +00:00
Each definition has its own heading. The type of definition is stored in tags -
1. custom group
2. [custom|hook|internal] variable
3. keymap (use variable instead?)
4. macro
5. function
* does not refer to external state
* primarily used for the return value
6. reader
* reads external state without modifying it
* primarily used for the return value
7. writer
* modifies external state, namely a data structure or file
* primarily used for side-effects
8. procedure
* any other impure function
* usually affects the display
* primarily used for side-effects
9. major/minor mode
10. command
Further details are stored in properties -
1. :INPUT: (for functions)
2. :VALUE: list|hash table|...
* for functions, this is the return value
3. :STATE: <external file or data structure read or written to>
*** TODO Issues [40%]
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: issues
:END:
2022-03-26 14:53:01 +00:00
1. [X] When opening this file, Emacs may freeze at the prompt for file-local variable values; if so, C-g will quit the prompt, and permanently marking them as safe will make the freezing stop. [fn:3]
2. [ ] I like =visual-fill-column-mode= for natural language, but I don't want it applied to code blocks. =polymode.el= may hold answers.
3. [X] Is there a tangling solution which requires only one command (e.g. currently we use two =sed= s) but is equally fast? [fn:3]
* Perhaps we can get rid of the requirement of adding newlines after each source block, and add the newlines ourselves. That gives us control, and also makes it possible to insert Org text in the middle of a definition without unnecessary newlines.
4. [ ] =nameless-insert-name= does not work in source blocks.
5. [ ] Some source blocks don't get syntax highlighted.
* A workaround is to press =M-o M-o=
[fn:3] No longer a problem since we switched to =literate-elisp=
** Currently-Used Time Formats
:PROPERTIES:
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: currently-used-time-formats
2022-03-26 14:53:01 +00:00
:END:
*** ts
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: ts
:END:
2022-03-26 14:53:01 +00:00
ts.el struct
* Used by nearly all internal functions
*** iso-timestamp
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: iso-timestamp
:END:
2022-03-26 14:53:01 +00:00
="YYYY-MM-DDTHH:MM:SSZ"=
* Used in the s-expression file format
2022-03-26 17:32:39 +00:00
* Read by sexp-events-populate
* Used in the plists in the events hash table values
2022-03-26 14:53:01 +00:00
*** iso-date
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: iso-date
:END:
2022-03-26 14:53:01 +00:00
="YYYY-MM-DD"=
2022-03-26 17:32:39 +00:00
* Used as hash table keys in events - can't use ts structs for keys, you'd have to make a hash table predicate which uses ts=
2022-03-26 14:53:01 +00:00
*** seconds
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: seconds
:END:
2022-03-26 14:53:01 +00:00
integer seconds as duration
* Used for most durations
* May be changed to floating point to allow larger durations. The minimum range of =most-positive-fixnum= is 536870911, which seems to be enough to represent durations of 17 years.
2022-03-26 17:32:39 +00:00
* Used for update intervals (update-interval, change-update-interval)
2022-03-26 14:53:01 +00:00
*** minutes
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: minutes
:END:
2022-03-26 14:53:01 +00:00
integer minutes as duration
2022-03-26 17:32:39 +00:00
* Used by [[https://tildegit.org/contrapunctus/chronometrist-goal][goal]] (goals-list, get-goal) - minutes seems like the ideal unit for users to enter
2022-03-26 14:53:01 +00:00
*** list-duration
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: list-duration
:END:
2022-03-26 14:53:01 +00:00
=(hours minute seconds)=
2022-03-26 17:32:39 +00:00
* Only returned by =seconds-to-hms=, called by =format-time=
2022-03-26 14:53:01 +00:00
** Backend protocol
:PROPERTIES:
:CREATED: 2022-01-05T19:00:12+0530
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: backend-protocol
2022-03-26 14:53:01 +00:00
:END:
The protocol as of now, with remarks -
1. =backend-run-assertions (backend)=
2. =view-backend (backend)=
3. =edit-backend (backend)= - these two would make sense if there was only one way to view/edit a backend. But if we want [[file:TODO.org::#viewing-editing-frontends][viewing/editing frontends]], there would be many.
4. =backend-empty-p (backend)=
5. =backend-modified-p (backend)=
6. =create-file (backend &optional file)=
7. =latest-date-records (backend)=
8. =insert (backend plist)=
9. =remove-last (backend)=
10. =latest-record (backend)=
11. =task-records-for-date (backend task date-ts)=
12. =replace-last (backend plist)=
13. =to-file (input-hash-table output-backend output-file)=
14. =on-add (backend)=
15. =on-modify (backend)=
16. =on-remove (backend)=
17. =on-change (backend)=
18. =verify (backend)=
2022-04-03 04:23:10 +00:00
19. =reset-backend (backend)= - probably rename to "initialize"
20. =memory-layer-empty-p (backend)= - needs a more generic name; perhaps "initialized-p", to go with #20
21. =to-hash-table (backend)=
22. =to-list (backend)=
2022-03-26 14:53:01 +00:00
There are many operations which are file-oriented, whereas I have tried to treat files as implementation details. =create-file=, for instance, is used by =to-file=; I could make creation of files implicit by moving it into =initialize-instance=, but that would mean creation of files in =to-file= would require creation of a backend object. That seems to me to be an abuse of implicit behaviour; and what would backends which are not file-backed do in =to-file=, then? There's probably a way to do it, but I had other things I preferred to tackle first.
2022-03-26 17:30:58 +00:00
** generic loop interface for iterating over records
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: generic-loop-interface-iterating-over-records
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 14:53:01 +00:00
Of all the ways to work with Chronometrist data, both as part of the program and as part of my occasional "queries", my favorite was to use =cl-loop=.
2022-03-26 17:32:39 +00:00
First, there was the =loop-file= macro, which handled the sole backend at that time - the plist backend. It took care of the common logic (=read= ing each plist in the file, checking loop termination conditions), and let the client code specify (with the terseness of =cl-loop=) what they wanted to do with the data.
2022-03-26 14:53:01 +00:00
2022-03-26 17:32:39 +00:00
During the migration to the CLOS-based backend design began the quest to make =loop-file= work with generic backends - it eventually became =loop-records= and =loop-days=. The idea was to call a generic function (=record-iterator= and =day-iterator=) which would return a new record on each call. Internal state of each of these generic functions was stored in backend slots. No list would be built up, unless the client code specified an accumulation clause.
2022-03-26 14:53:01 +00:00
Most recently, gilberth of #lispcafe suggested an alternate approach - trying to build a list of records first, and using =cl-loop= (or any other iteration mechanism) on that. Testing the two approaches yielded a clear advantage for this new suggestion. The test was to generate key-values suitable for completion history from my full Chronometrist data to date (the plist backend had ~6.6k plists in a 1.2M file), using almost identical =cl-loop= client code for both cases. Here was the output from =(benchmark 1 ...)= -
| approach | backend | benchmark output |
|----------+-------------+--------------------------------------------------|
| current | plist | "Elapsed time: 5.322056s (0.709023s in 4 GCs)" |
| current | plist-group | "Elapsed time: 107.159170s (1.064125s in 6 GCs)" |
| new | plist | "Elapsed time: 0.559264s (0.172344s in 1 GCs)" |
| new | plist-group | "Elapsed time: 0.671106s (0.179435s in 1 GCs)" |
In addition, with this approach, client code can use any kind of iteration constructs they fancy - not just =cl-loop= but also =dolist=, higher-order functions (including those from =dash= and =seq=), =loopy=, etc.
2022-03-26 17:32:39 +00:00
The macro still exists in its non-generic form as =loop-sexp-file=, providing a common way to loop over s-expressions in a text file, used by =to-list= in both backends and =to-hash-table= in the plist group backend.
2022-03-26 14:53:01 +00:00
* How-to guides for maintainers
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: how-to-guides-maintainers
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 14:53:01 +00:00
** How to set up Emacs to contribute
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: how-to-set-up-emacs-to-contribute
:END:
2022-03-26 14:53:01 +00:00
# Different approaches to this setup -
# 1. Document it and let contributors do it voluntarily.
# * Downside - if a contributor does not do it, it can lead to inconsistency and an additional headache for the maintainer.
# 2. Put it in file variables and/or =.dir-locals.el=.
# * Downside - if the contributor does not have a dependency installed (e.g. nameless), Emacs will create an error. That's not the UX I want for someone opening the file for the first time - it does not tell them that they need to install something.
# + Technically, a friendlier error message could be displayed. But you want to read/edit a document and it asks you to install something first...kind of a flow breaker.
# 3. Use a tool which installs developement dependencies for you, e.g. Cask, Eldev.
# * May look into this in the future. But as of now I don't even foresee any contributors 😓
1. Install [[https://github.com/Malabarba/Nameless][nameless-mode]] for easier reading of Emacs Lisp code, and [[https://github.com/jingtaozf/literate-elisp][literate-elisp]] to load this file directly without tangling.
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no :load no
2022-03-26 14:53:01 +00:00
(mapcar #'package-install '(nameless literate-elisp))
#+END_SRC
2. Create a =.dir-locals-2.el= in the project root, containing -
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no :load no
2022-03-26 14:53:01 +00:00
((org-mode
.
((eval . (nameless-mode))
(eval . (progn
(make-local-variable 'after-save-hook)
;; you can't `defun' in one `eval' and use the
;; function in another `eval', apparently
(add-hook
'after-save-hook
2022-03-26 17:32:39 +00:00
(defun tangle ()
2022-03-26 14:53:01 +00:00
(interactive)
(compile
(mapconcat #'shell-quote-argument
`("emacs" "-q" "-Q" "--batch"
"--eval=(require 'ob-tangle)"
,(format "--eval=(org-babel-tangle-file \"%s\")"
(buffer-file-name)))
" ")))))))))
#+END_SRC
3. Set up compiling, linting, and testing with =makem.sh=. First, define this command -
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no :load no
2022-03-26 14:53:01 +00:00
(defun run-makem ()
(interactive)
(cd (locate-dominating-file default-directory "makem.sh"))
(compile "./makem.sh compile lint test-ert"))
#+END_SRC
Then, run it after staging the files -
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no :load no
2022-03-26 14:53:01 +00:00
(add-hook 'magit-post-stage-hook #'run-makem)
#+END_SRC
Or after tangling ends -
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no :load no
2022-03-26 14:53:01 +00:00
(add-hook 'org-babel-post-tangle-hook #'run-makem)
#+END_SRC
** How to tangle this file
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: how-to-tangle-this-file
:END:
2022-03-26 14:53:01 +00:00
Use =org-babel= (=org-babel-tangle= / =org-babel-tangle-file=), /not/ =literate-elisp-tangle=. The file emitted by the latter does not contain comments - thus, it does not contain library headers or abide by =checkdoc='s comment conventions.
* The Program
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: program
2022-04-27 10:56:26 +00:00
:END:
2022-04-02 02:40:39 +00:00
** chronometrist :package:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: chronometrist
:END:
2022-03-26 14:53:01 +00:00
#+BEGIN_SRC lisp
(in-package :cl)
(defpackage :chronometrist
2022-04-02 02:36:23 +00:00
(:use :cl :trivia)
(:import-from :uiop
:xdg-config-home :xdg-data-home
:strcat :split-string)
(:import-from :local-time
2022-04-22 17:00:13 +00:00
:parse-timestring :now :today :timestamp-to-unix :adjust-timestamp
:timestamp< :format-timestring)
2022-03-27 21:41:05 +00:00
(:export
;; customizable variables
:*user-configuration-file* :*user-data-file*
:*day-start-time* :*week-start-day*
:*weekday-number-alist* :*active-backend*
:*sexp-pretty-print-function* :*task-list*
:*sqlite-properties-function*
;; classes
:backend
:day :date :intervals :events :properties
:interval :name :interval-start :interval-stop
:event :event-time
2022-04-02 16:43:47 +00:00
;; protocol
:*backends-alist* :active-backend :register-backend
2022-04-02 16:43:47 +00:00
:backend-file
:task-list
:view-backend :edit-backend
:backend-empty-p :backend-modified-p
2022-04-13 05:18:24 +00:00
:backend-run-assertions
:create-file
:get-day :insert-day :remove-day
2022-04-13 05:18:24 +00:00
:on-change :on-add :on-modify :on-remove
:to-file :to-hash-table :to-list :list-tasks
:active-days :count-records
2022-04-08 03:42:02 +00:00
:file-backend-mixin :elisp-sexp-backend
;; extended protocol
:remove-last :replace-last
:latest-record :task-records-for-date :latest-date-records
;; helpers
:make-hash-table-1 :split-plist :iso-to-date :plist-key-values
2022-04-22 17:00:13 +00:00
:task-duration-one-day
;; debugging
:*debug-enable*
:*debug-buffer*))
2022-03-26 14:53:01 +00:00
(in-package :chronometrist)
#+END_SRC
** Common definitions
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: common-definitions
:END:
2022-04-02 17:06:45 +00:00
*** create XDG directories
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: create-xdg-directories
:END:
2022-04-02 17:06:45 +00:00
#+BEGIN_SRC lisp
(defvar *xdg-config-dir*
(merge-pathnames "chronometrist/" (uiop:xdg-config-home)))
(defvar *xdg-data-dir*
(merge-pathnames "chronometrist/" (uiop:xdg-data-home)))
(mapcar #'ensure-directories-exist
(list *xdg-config-dir* *xdg-data-dir*))
#+END_SRC
*** *user-configuration-file* :custom:variable:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: user-configuration-file
2022-04-27 10:56:26 +00:00
:END:
#+BEGIN_SRC lisp
(defvar *user-configuration-file*
2022-04-02 17:06:45 +00:00
(merge-pathnames "config.lisp" *xdg-config-dir*))
#+END_SRC
*** *user-data-file* :custom:variable:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: user-data-file
2022-04-27 10:56:26 +00:00
:END:
#+BEGIN_SRC lisp
2022-04-03 04:50:40 +00:00
(defvar *user-data-file* (merge-pathnames "chronometrist" *xdg-data-dir*)
"Absolute path and file name (without extension) for the Chronometrist database.")
#+END_SRC
*** make-hash-table-1 :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: make-hash-table-1
:END:
#+BEGIN_SRC lisp
(defun make-hash-table-1 ()
"Return an empty hash table with `equal' as test."
(make-hash-table :test #'equal))
#+END_SRC
*** *day-start-time* :custom:variable:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: day-start-time
2022-04-27 10:56:26 +00:00
:END:
=chronometrist-events-maybe-split= refers to this, but I'm not sure this has the desired effect at the moment—haven't even tried using it.
#+BEGIN_SRC lisp
(defvar *day-start-time* "00:00:00"
"The time at which a day is considered to start, in \"HH:MM:SS\".
The default is midnight, i.e. \"00:00:00\".")
#+END_SRC
*** plist-remove :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: plist-remove
:END:
#+BEGIN_SRC lisp
(defun plist-remove (plist &rest keys)
"Return PLIST with KEYS and their associated values removed."
(loop for key in keys do (remf plist key))
plist)
#+END_SRC
**** tests
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tests
:END:
#+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test
(ert-deftest plist-remove ()
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :a)
'(:b 2 :c 3 :d 4)))
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :b)
'(:a 1 :c 3 :d 4)))
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :c)
'(:a 1 :b 2 :d 4)))
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :d)
'(:a 1 :b 2 :c 3)))
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :a :b)
'(:c 3 :d 4)))
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :a :d)
'(:b 2 :c 3)))
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :c :d)
'(:a 1 :b 2)))
(should (equal
(plist-remove '(:a 1 :b 2 :c 3 :d 4) :a :b :c :d)
nil))
(should
(equal (plist-remove '(:a 1 :b 2 :c 3 :d 4) :d :a)
'(:b 2 :c 3))))
#+END_SRC
*** plist-key-values :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: plist-key-values
:END:
#+BEGIN_SRC lisp
(defun plist-key-values (plist)
"Return user key-values from PLIST."
(plist-remove plist :name :tags :start :stop))
#+END_SRC
*** plist-p :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: plist-p
:END:
[[file:../tests/chronometrist-tests.org::#tests-common-plist-p][tests]]
#+BEGIN_SRC lisp
(defun plist-p (list)
"Return non-nil if LIST is a property list, i.e. (:KEYWORD VALUE ...)"
(when list
(while (consp list)
(setq list (if (and (keywordp (cl-first list)) (consp (cl-rest list)))
(cddr list)
'not-plist)))
(null list)))
#+END_SRC
** Data structures
:PROPERTIES:
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: data-structures
:END:
Reading directly from the file could be difficult, especially when your most common query is "get all intervals recorded on <date>" [fn:4] - and so, we maintain the hash table =chronometrist-events=, where each key is a date in the ISO-8601 format. The plists in this hash table are free of [[#explanation-midnight-spanning-intervals][midnight-spanning intervals]], making code which consumes it easier to write.
The data from =chronometrist-events= is used by most (all?) interval-consuming functions, but is never written to the user's file itself.
[fn:4] it might be the case that the [[#program-backend][file format]] is not suited to our most frequent operation...
*** apply-time :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: apply-time
:END:
#+BEGIN_SRC lisp
(defun apply-time (time timestamp)
"Return TIMESTAMP with time modified to TIME.
TIME must be a string in the form \"HH:MM:SS\"
TIMESTAMP must be a time string in the ISO-8601 format.
Return value is a ts struct (see `ts.el')."
(let-match (((list h m s)
(mapcar #'parse-integer (split-string time :separator ":"))))
(adjust-timestamp (parse-timestring timestamp)
(set :hour h) (set :minute m) (set :sec s))))
#+END_SRC
**** tests
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tests-1
:END:
#+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test
(ert-deftest apply-time ()
(should
(equal (ts-format "%FT%T%z" (apply-time "01:02:03" "2021-02-17T01:20:18+0530"))
"2021-02-17T01:02:03+0530")))
#+END_SRC
*** split-plist :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: split-plist
:END:
#+BEGIN_SRC lisp
(defun split-plist (plist)
"Return a list of two split plists if PLIST spans a midnight, else nil."
(when (getf plist :stop)
(let ((split-time (split-time (getf plist :start)
(getf plist :stop)
*day-start-time*)))
(when split-time
2022-04-04 01:20:18 +00:00
(let-match* (((plist :start start-1 :stop stop-1) (first split-time))
((plist :start start-2 :stop stop-2) (second split-time))
2022-04-02 16:43:26 +00:00
;; `plist-put' modifies lists in-place. The
;; resulting bugs left me puzzled for a while.
(event-1 (copy-list plist))
(event-2 (copy-list plist)))
(setf (getf event-1 :start) start-1
(getf event-1 :stop) stop-1
(getf event-2 :start) start-2
(getf event-2 :stop) stop-2)
(list event-1 event-2))))))
#+END_SRC
**** tests
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tests-2
:END:
#+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test
(ert-deftest split-plist ()
(should
(null (split-plist
'(:name "Task"
:start "2021-02-17T01:33:12+0530"
:stop "2021-02-17T01:56:08+0530"))))
(should
(equal (split-plist
'(:name "Guitar"
:tags (classical warm-up)
:start "2021-02-12T23:45:21+0530"
:stop "2021-02-13T00:03:46+0530"))
'((:name "Guitar"
:tags (classical warm-up)
:start "2021-02-12T23:45:21+0530"
:stop "2021-02-13T00:00:00+0530")
(:name "Guitar"
:tags (classical warm-up)
:start "2021-02-13T00:00:00+0530"
:stop "2021-02-13T00:03:46+0530")))))
#+END_SRC
*** ht-update :writer:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: ht-update
:END:
#+BEGIN_SRC lisp
(defun ht-update (plist hash-table &optional replace)
"Return HASH-TABLE with PLIST added as the latest interval.
If REPLACE is non-nil, replace the last interval with PLIST."
(let* ((date (->> (getf plist :start)
(parse-timestring )
(ts-format "%F" )))
(events-today (gethash date hash-table)))
(--> (if replace (-drop-last 1 events-today) events-today)
(append it (list plist))
(puthash date it hash-table))
hash-table))
#+END_SRC
*** ht-last-date :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: ht-last-date
:END:
#+BEGIN_SRC lisp
(defun ht-last-date (hash-table)
"Return an ISO-8601 date string for the latest date present in `chronometrist-events'."
(--> (hash-table-keys hash-table)
(sort it #'string-lessp)
(last it)
2022-04-04 01:20:18 +00:00
(first it)))
#+END_SRC
*** ht-last :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: ht-last
:END:
#+BEGIN_SRC lisp
(defun ht-last (&optional (backend (chronometrist:active-backend)))
"Return the last plist from `chronometrist-events'."
(let* ((hash-table (chronometrist-backend-hash-table backend))
(last-date (ht-last-date hash-table)))
(--> (gethash last-date hash-table)
(last it)
(car it))))
#+END_SRC
*** ht-subset :reader:
:PROPERTIES:
:VALUE: hash table
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: ht-subset
:END:
#+BEGIN_SRC lisp
(defun ht-subset (start end hash-table)
"Return a subset of HASH-TABLE.
The subset will contain values between dates START and END (both
inclusive).
START and END must be ts structs (see `ts.el'). They will be
treated as though their time is 00:00:00."
(let ((subset (make-hash-table-1))
(start (date-ts start))
(end (date-ts end)))
(maphash (lambda (key value)
(when (ts-in start end (parse-timestring key))
(puthash key value subset)))
hash-table)
subset))
#+END_SRC
2022-04-22 17:00:13 +00:00
*** task-duration-one-day :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: task-duration-one-day
:END:
#+BEGIN_SRC lisp
2022-05-03 05:13:50 +00:00
(defun task-duration-one-day (task &optional
(date (timestamp-to-unix (today)))
(backend (active-backend)))
"Return total time spent on TASK today or on DATE.
The return value is seconds, as an integer."
(let ((task-intervals (task-records-for-date backend task date)))
(if task-intervals
(reduce #'+ (mapcar #'interval-to-duration task-intervals))
;; no events for this task on DATE, i.e. no time spent
0)))
#+END_SRC
*** active-time-on :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: active-time-on
:END:
#+BEGIN_SRC lisp
(defvar *task-list*)
(defun active-time-on (&optional (date (date-ts)))
"Return the total active time today, or on DATE.
Return value is seconds as an integer."
2022-04-22 17:00:13 +00:00
(->> (--map (task-duration-one-day it date) (*task-list*))
(-reduce #'+)
(truncate)))
#+END_SRC
*** count-active-days :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: count-active-days
:END:
#+BEGIN_SRC lisp
(defun statistics-count-active-days (task table)
"Return the number of days the user spent any time on TASK.
TABLE must be a hash table - if not supplied, `chronometrist-events' is used.
This will not return correct results if TABLE contains records
which span midnights."
(loop for events being the hash-values of table
count (seq-find (lambda (event)
(equal task (getf event :name)))
events)))
#+END_SRC
2022-04-08 03:41:46 +00:00
*** *task-list* :custom:variable:
:PROPERTIES:
:VALUE: list
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: task-list
:END:
#+BEGIN_SRC lisp
(defvar *task-list* nil
2022-04-03 04:50:40 +00:00
"List of tasks to be displayed by the Chronometrist frontend.
Value may be either nil or a list of strings.
If nil, the task list is generated from user data in
2022-04-03 04:50:40 +00:00
`*user-data-file*' and stored in the task-list slot of the
active backend.")
#+END_SRC
** Time functions
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: time-functions
:END:
*** date-iso :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: date-iso
:END:
#+BEGIN_SRC lisp
(defun date-iso (&optional (ts (ts-now)))
(ts-format "%F" ts))
#+END_SRC
*** date-ts :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: date-ts
:END:
#+BEGIN_SRC lisp
(defun date-ts (&optional (ts (ts-now)))
"Return a ts struct representing the time 00:00:00 on today's date.
If TS is supplied, use that date instead of today.
TS should be a ts struct (see `ts.el')."
(ts-apply :hour 0 :minute 0 :second 0 ts))
#+END_SRC
*** format-time-iso8601 :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: format-time-iso8601
:END:
#+BEGIN_SRC lisp
(defun format-time-iso8601 (&optional unix-time)
"Return current date and time as an ISO-8601 timestamp.
Optional argument UNIX-TIME should be a time value (see
`current-time') accepted by `format-time-string'."
2022-04-04 01:20:18 +00:00
(format-timestring nil (or unix-time (now))
:format '((:year 4) "-" (:month 2) "-" (:day 2) "T"
(:hour 2) ":" (:min 2) ":" (:sec 2) :gmt-offset-or-z)))
#+END_SRC
*** FIXME split-time :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: split-time
:END:
It does not matter here that the =:stop= dates in the returned plists are different from the =:start=, because =chronometrist-events-populate= uses only the date segment of the =:start= values as hash table keys. (The hash table keys form the rest of the program's notion of "days", and that of which plists belong to which day.)
2022-04-04 01:20:18 +00:00
Note - this assumes that an event never crosses >1 day. This seems sufficient for all conceivable cases.
#+BEGIN_SRC lisp
(defun split-time (start-time stop-time day-start-time)
"If START-TIME and STOP-TIME intersect DAY-START-TIME, split them into two intervals.
START-TIME and STOP-TIME must be ISO-8601 timestamps e.g. \"YYYY-MM-DDTHH:MM:SSZ\".
DAY-START-TIME must be a string in the form \"HH:MM:SS\" (see
`*day-start-time*')
Return a list in the form
\((:start START-TIME
:stop <day-start time on initial day>)
(:start <day start time on second day>
:stop STOP-TIME))"
;; FIXME - time zones are ignored; may cause issues with
;; time-zone-spanning events
;; The time on which the first provided day starts (according to `*day-start-time*')
(let* ((stop-ts (parse-timestring stop-time))
(first-day-start (apply-time day-start-time start-time))
(next-day-start (adjust-timestamp first-day-start (offset :hour 24))))
;; Does the event stop time exceed the next day start time?
2022-04-04 01:20:18 +00:00
(when (timestamp< next-day-start stop-ts)
(let ((split-time (format-time-iso8601 next-day-start)))
(list `(:start ,start-time :stop ,split-time)
`(:start ,split-time :stop ,stop-time))))))
#+END_SRC
**** tests
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tests-3
:END:
#+BEGIN_SRC lisp :tangle chronometrist-tests.el :load test
(ert-deftest split-time ()
(should
(null
(split-time "2021-02-17T01:33:12+0530"
"2021-02-17T01:56:08+0530"
"00:00:00")))
(should
(equal
(split-time "2021-02-19T23:45:36+0530"
"2021-02-20T00:18:40+0530"
"00:00:00")
'((:start "2021-02-19T23:45:36+0530"
:stop "2021-02-20T00:00:00+0530")
(:start "2021-02-20T00:00:00+0530"
:stop "2021-02-20T00:18:40+0530"))))
(should
(equal
(split-time "2021-02-19T23:45:36+0530"
"2021-02-20T03:18:40+0530"
"01:20:30")
'((:start "2021-02-19T23:45:36+0530"
:stop "2021-02-20T01:20:30+0530")
(:start "2021-02-20T01:20:30+0530"
:stop "2021-02-20T03:18:40+0530")))))
#+END_SRC
*** seconds-to-hms :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: seconds-to-hms
:END:
#+BEGIN_SRC lisp
(defun seconds-to-hms (seconds)
"Convert SECONDS to a vector in the form [HOURS MINUTES SECONDS].
SECONDS must be a positive integer."
(let* ((seconds (truncate seconds))
(s (% seconds 60))
(m (% (/ seconds 60) 60))
(h (/ seconds 3600)))
(list h m s)))
#+END_SRC
*** interval :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: interval
:END:
#+BEGIN_SRC lisp
(defun interval (plist)
"Return the period of time covered by EVENT as a time value.
2022-04-03 04:50:40 +00:00
EVENT should be a plist (see `*user-data-file*')."
(let* ((start-ts (parse-timestring (getf plist :start)))
(stop-iso (getf plist :stop))
;; Add a stop time if it does not exist.
(stop-ts (if stop-iso (parse-timestring stop-iso) (now))))
(ts-diff stop-ts start-ts)))
#+END_SRC
*** format-duration-long :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: format-duration-long
:END:
#+BEGIN_SRC lisp
(defun format-duration-long (seconds)
"Return SECONDS as a human-friendly duration string.
e.g. \"2 hours, 10 minutes\". SECONDS must be an integer. If
SECONDS is less than 60, return a blank string."
(let* ((hours (/ seconds 60 60))
(minutes (% (/ seconds 60) 60))
(hour-string (if (= 1 hours) "hour" "hours"))
(minute-string (if (= 1 minutes) "minute" "minutes")))
(cond ((and (zerop hours) (zerop minutes)) "")
((zerop hours)
(format "%s %s" minutes minute-string))
((zerop minutes)
(format "%s %s" hours hour-string))
(t (format "%s %s, %s %s"
hours hour-string
minutes minute-string)))))
#+END_SRC
**** tests
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tests-4
:END:
#+BEGIN_SRC lisp :tangle ../tests/chronometrist-tests.el :load test
(ert-deftest format-duration-long ()
(should (equal (format-duration-long 5) ""))
(should (equal (format-duration-long 65) "1 minute"))
(should (equal (format-duration-long 125) "2 minutes"))
(should (equal (format-duration-long 3605) "1 hour"))
(should (equal (format-duration-long 3660) "1 hour, 1 minute"))
(should (equal (format-duration-long 3725) "1 hour, 2 minutes"))
(should (equal (format-duration-long 7200) "2 hours"))
(should (equal (format-duration-long 7260) "2 hours, 1 minute"))
(should (equal (format-duration-long 7320) "2 hours, 2 minutes")))
#+END_SRC
*** iso-to-date :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: iso-to-date
:END:
#+BEGIN_SRC lisp
(defun iso-to-date (timestamp)
2022-04-04 01:20:18 +00:00
(first (split-string timestamp :separator "T")))
#+END_SRC
2022-03-26 14:53:01 +00:00
** Backends
:PROPERTIES:
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: backends
2022-03-26 14:53:01 +00:00
:END:
*** protocol
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: protocol
:END:
2022-03-26 14:53:01 +00:00
**** backend :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backend
:END:
2022-03-26 17:32:39 +00:00
The backend may use no files, a single file, or multiple files. Thus, =backend= makes no reference to files, and [[#file-backend-mixin][=file-backend-mixin=]] may be used by single file backends.
2022-03-26 14:53:01 +00:00
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defclass backend ()
2022-03-26 14:53:01 +00:00
((task-list :initform nil
:initarg :task-list
2022-03-26 17:32:39 +00:00
:accessor backend-task-list)))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** *backends-alist* :variable:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: backends-alist
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
(defvar *backends-alist* nil
2022-03-26 14:53:01 +00:00
"Alist of Chronometrist backends.
Each element must be in the form `(KEYWORD TAG OBJECT)', where
TAG is a string used as a tag in customization, and OBJECT is an
EIEIO object such as one returned by `make-instance'.")
#+END_SRC
2022-04-02 16:43:47 +00:00
**** *active-backend* :custom:variable:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: active-backend-variable
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-08 03:41:46 +00:00
(defvar *active-backend* :sqlite
2022-03-26 14:53:01 +00:00
"The backend currently in use.
Value must be a keyword corresponding to a key in
`*backends-alist*'.")
2022-03-26 14:53:01 +00:00
#+END_SRC
**** active-backend :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: active-backend-function
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun active-backend ()
2022-03-26 14:53:01 +00:00
"Return an object representing the currently active backend."
(third (assoc *active-backend* *backends-alist*)))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** register-backend :writer:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: register-backend
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun register-backend (keyword tag object)
"Add backend to `*backends-alist*'.
For values of KEYWORD, TAG, and OBJECT, see `*backends-alist*'.
2022-03-26 14:53:01 +00:00
If a backend with KEYWORD already exists, the existing entry will
be replaced."
2022-04-02 16:13:59 +00:00
(setq *backends-alist* (remove keyword *backends-alist* :key #'car))
(pushnew (list keyword tag object) *backends-alist*))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** task-list :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: task-list-1
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun task-list ()
2022-03-26 14:53:01 +00:00
"Return the list of tasks to be used.
2022-03-26 17:32:39 +00:00
If `task-list' is non-nil, return its value; else,
2022-03-26 14:53:01 +00:00
return a list of tasks from the active backend."
2022-03-26 17:32:39 +00:00
(let ((backend (active-backend)))
2022-04-08 03:42:02 +00:00
;; (format *debug-io* "active backend: ~s~%" backend)
2022-03-26 14:53:01 +00:00
(with-slots (task-list) backend
2022-04-02 16:13:59 +00:00
(or *task-list* task-list (setf task-list (list-tasks backend))))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** day :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: day
:END:
#+BEGIN_SRC lisp
(defclass day ()
((properties :initarg :properties :accessor properties)
(date :initarg :date
:accessor date
:type integer
:documentation "The date as an integer representing the UNIX epoch time.")
(intervals :initarg :intervals
:accessor intervals
:documentation "The intervals associated with this day.")
(events :initarg :events
:accessor events
:documentation "The events associated with this day.")))
#+END_SRC
**** interval :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: interval-1
:END:
#+BEGIN_SRC lisp
(defclass interval ()
((properties :initarg :properties :accessor properties)
2022-04-18 17:45:16 +00:00
(name :initarg :name
:accessor name
:type string
:documentation "The name of the task executed during this interval.")
(start :initarg :start
:accessor interval-start
:type integer
2022-04-18 17:45:16 +00:00
:documentation "The time at which this interval started, as
an integer representing the UNIX epoch time.")
(stop :initarg :stop
:accessor interval-stop
:type integer
2022-04-18 17:45:16 +00:00
:documentation "The time at which this interval ended, as an
integer representing the UNIX epoch time."))
(:documentation "A time range spent on a specific task, with
optional properties."))
#+END_SRC
***** make-interval :constructor:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: make-interval
:END:
#+BEGIN_SRC lisp
(defun make-interval (name start &optional stop properties-string)
(make-instance 'chronometrist:interval
:name name :start start :stop stop
:properties (when properties-string
(read-from-string properties-string))))
#+END_SRC
***** interval-to-duration :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: interval-to-duration
:END:
#+BEGIN_SRC lisp
(defun interval-to-duration (interval)
(with-slots (start stop) interval
(let ((stop (or stop (timestamp-to-unix (now)))))
(- stop start))))
#+END_SRC
**** event :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: event
:END:
#+BEGIN_SRC lisp
(defclass event ()
((properties :initarg :properties :accessor properties)
(name :initarg :name
:accessor name
:type string
:documentation "The name of this event.")
(time :initarg :time
:accessor event-time
:type integer
2022-04-18 17:45:16 +00:00
:documentation "The time at which this interval started, as
an integer representing the UNIX epoch time."))
(:documentation "A named timestamp with optional properties."))
#+END_SRC
2022-03-26 14:53:01 +00:00
**** run-assertions :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: run-assertions
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric backend-run-assertions (backend)
(:documentation "Check common preconditions for any operations on BACKEND.
Signal errors for any unmet preconditions."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** view-backend :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: view-backend
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric view-backend (backend)
(:documentation "Open BACKEND for interactive viewing."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** edit-backend :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: edit-backend
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric edit-backend (backend)
(:documentation "Open BACKEND for interactive editing."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** backend-empty-p :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backend-empty-p
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric backend-empty-p (backend)
(:documentation "Return non-nil if BACKEND contains no records, else nil."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** backend-modified-p :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backend-modified-p
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric backend-modified-p (backend)
(:documentation "Return non-nil if BACKEND is being modified.
2022-03-26 14:53:01 +00:00
For instance, a file-based backend could be undergoing editing by
a user."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** create-file :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: create-file
:END:
2022-03-29 14:36:14 +00:00
[[file:../tests/tests.org::#tests-backend-create-file][tests]]
2022-03-26 14:53:01 +00:00
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric create-file (backend &optional file)
(:documentation "Create file associated with BACKEND.
2022-03-26 14:53:01 +00:00
Use FILE as a path, if provided.
Return path of new file if successfully created, and nil if it already exists."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** get-day :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: get-day
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
(defgeneric get-day (date backend)
(:documentation "Return day associated with DATE from BACKEND, or nil if no such day exists.
DATE should be an integer representing the UNIX epoch time at the start of the day."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** insert-day :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: insert-day
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
(defgeneric insert-day (day backend &key &allow-other-keys)
(:documentation "Insert PLIST as new record in BACKEND.
2022-03-26 14:53:01 +00:00
Return non-nil if record is inserted successfully.
PLIST may be an interval which crosses days."))
2022-03-26 14:53:01 +00:00
2022-04-14 07:04:28 +00:00
#+(or)
2022-03-26 17:32:39 +00:00
(defmethod insert :before ((_backend t) plist &key &allow-other-keys)
2022-03-27 14:47:13 +00:00
(unless (typep plist 'plist)
2022-03-26 14:53:01 +00:00
(error "Not a valid plist: %S" plist)))
#+END_SRC
**** remove-day :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: remove-day
:END:
#+BEGIN_SRC lisp
(defgeneric remove-day (date backend)
(:documentation "Remove day associated with DATE from BACKEND, or nil if no such day exists.
DATE should be an integer representing the UNIX epoch time at the start of the day."))
#+END_SRC
**** remove-last :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: remove-last
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-02 16:43:06 +00:00
(defgeneric remove-last (backend &key &allow-other-keys)
(:documentation "Remove last record from BACKEND.
2022-03-26 14:53:01 +00:00
Return non-nil if record is successfully removed.
Signal an error if there is no record to remove."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** latest-record :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: latest-record
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric latest-record (backend)
(:documentation "Return the latest record from BACKEND as a plist, or nil if BACKEND contains no records.
2022-03-26 14:53:01 +00:00
Return value may be active, i.e. it may or may not have a `:stop'
key-value.
If the latest record starts on one day and ends on another, the
entire (unsplit) record must be returned."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** task-records-for-date :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: task-records-date
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
(declaim (ftype (function (chronometrist:backend
string integer &key &allow-other-keys))
chronometrist:task-records-for-date))
(defgeneric task-records-for-date (backend task date &key &allow-other-keys)
(:documentation "From BACKEND, return records for TASK on DATE-TS as a list of plists.
2022-03-26 14:53:01 +00:00
DATE-TS must be a `ts.el' struct.
Return nil if BACKEND contains no records.")
(:method ((backend chronometrist:backend)
(task string)
(date integer)
&key &allow-other-keys)
(loop for interval in (intervals (get-day date backend))
when (equal task (name interval))
collect interval)))
#+END_SRC
2022-03-26 14:53:01 +00:00
***** task-records-for-date :before:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: before-task-records-for-date
2022-04-27 10:56:26 +00:00
:END:
#+BEGIN_SRC lisp
#+(or)
(defmethod task-records-for-date :before
((_backend t) task date-ts &key &allow-other-keys)
2022-03-27 14:47:13 +00:00
(unless (typep task 'string)
2022-03-26 14:53:01 +00:00
(error "task %S is not a string" task))
2022-03-27 14:47:13 +00:00
(unless (typep date-ts 'ts)
2022-03-26 14:53:01 +00:00
(error "date-ts %S is not a `ts' struct" date-ts)))
#+END_SRC
**** replace-last :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: replace-last
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-02 11:28:11 +00:00
(defgeneric replace-last (backend plist &key &allow-other-keys)
(:documentation "Replace last record in BACKEND with PLIST.
Return non-nil if successful."))
2022-03-26 14:53:01 +00:00
2022-03-26 17:32:39 +00:00
(defmethod replace-last :before ((_backend t) plist &key &allow-other-keys)
2022-03-27 14:47:13 +00:00
(unless (typep plist 'plist)
2022-03-26 14:53:01 +00:00
(error "Not a valid plist: %S" plist)))
#+END_SRC
**** to-file :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-file
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric to-file (input-hash-table output-backend output-file)
(:documentation "Save data from INPUT-HASH-TABLE to OUTPUT-FILE, in OUTPUT-BACKEND format.
Any existing data in OUTPUT-FILE is overwritten."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-add :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-add
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric on-add (backend)
(:documentation "Function called when data is added to BACKEND.
2022-03-26 14:53:01 +00:00
This may happen within Chronometrist (e.g. via
2022-03-26 17:32:39 +00:00
`insert') or outside it (e.g. a user editing the
2022-03-26 14:53:01 +00:00
backend file).
NEW-DATA is the data that was added."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-modify :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-modify
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric on-modify (backend)
(:documentation "Function called when data in BACKEND is modified (rather than added or removed).
2022-03-26 14:53:01 +00:00
This may happen within Chronometrist (e.g. via
2022-03-26 17:32:39 +00:00
`replace-last') or outside it (e.g. a user editing
2022-03-26 14:53:01 +00:00
the backend file).
OLD-DATA and NEW-DATA is the data before and after the changes,
respectively."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-remove :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-remove
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric on-remove (backend)
(:documentation "Function called when data is removed from BACKEND.
2022-03-26 14:53:01 +00:00
This may happen within Chronometrist (e.g. via
2022-03-26 17:32:39 +00:00
`remove-last') or outside it (e.g. a user editing
2022-03-26 14:53:01 +00:00
the backend file).
OLD-DATA is the data that was modified."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-change :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-change
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-02 11:28:11 +00:00
(defgeneric on-change (backend &rest args)
(:documentation "Function to be run when BACKEND changes on disk.
2022-03-26 14:53:01 +00:00
This may happen within Chronometrist (e.g. via
2022-03-26 17:32:39 +00:00
`insert') or outside it (e.g. a user editing the
backend file)."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** verify :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: verify
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric verify (backend)
(:documentation "Check BACKEND for errors in data.
2022-03-26 14:53:01 +00:00
Return nil if no errors are found.
If an error is found, return (LINE-NUMBER . COLUMN-NUMBER) for file-based backends."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-file-path-change :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-file-path-change
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric on-file-path-change (backend old-path new-path)
(:documentation "Function run when the value of `file' is changed.
2022-03-26 14:53:01 +00:00
OLD-PATH and NEW-PATH are the old and new values of
`file', respectively."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** reset-backend :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: reset-backend
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric reset-backend (backend)
(:documentation "Reset data structures for BACKEND."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** to-hash-table :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-hash-table
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric to-hash-table (backend)
(:documentation "Return data in BACKEND as a hash table in chronological order.
2022-03-26 14:53:01 +00:00
Hash table keys are ISO-8601 date strings. Hash table values are
lists of records, represented by plists. Both hash table keys and
hash table values must be in chronological order."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** to-list :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-list
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric to-list (backend)
(:documentation "Return all records in BACKEND as a list of plists."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** memory-layer-empty-p :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: memory-layer-empty-p
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric memory-layer-empty-p (backend)
(:documentation "Return non-nil if memory layer of BACKEND contains no records, else nil."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** extended protocol
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: extended-protocol
:END:
2022-03-26 14:53:01 +00:00
These can be implemented in terms of the minimal protocol above.
***** list-tasks :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: list-tasks
:END:
#+BEGIN_SRC lisp
(defgeneric list-tasks (backend)
(:documentation "Return all tasks recorded in BACKEND as a list of strings."))
#+END_SRC
***** active-days (unimplemented) :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: active-days-(unimplemented)
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric active-days (backend task &key start end)
(:documentation "From BACKEND, return number of days on which TASK had recorded time."))
2022-03-26 14:53:01 +00:00
#+END_SRC
***** count-records (unimplemented) :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: count-records-(unimplemented)
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defgeneric count-records (backend)
(:documentation "Return number of records in BACKEND."))
2022-03-26 14:53:01 +00:00
#+END_SRC
*** Common definitions for s-expression backends
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: common-definitions-sexp-backends
2022-03-26 14:53:01 +00:00
:END:
**** file-backend-mixin :mixin:class:
2022-03-26 14:53:01 +00:00
:PROPERTIES:
:CUSTOM_ID: file-backend-mixin
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defclass file-backend-mixin ()
2022-04-03 04:50:40 +00:00
((file :initform nil
:initarg :file
2022-04-21 04:08:27 +00:00
:type (or pathname null)
2022-04-03 04:50:40 +00:00
:documentation
"Pathname for backend file, without extension. Do not access this
2022-04-03 04:50:40 +00:00
directly - use `backend-file' instead.")
(extension :initform nil
:initarg :extension
:accessor backend-ext
2022-04-21 04:08:27 +00:00
:type string
2022-04-03 04:50:40 +00:00
:documentation
"Extension of backend file.")
2022-03-26 18:03:02 +00:00
(hash-table :initform (make-hash-table-1)
2022-03-26 14:53:01 +00:00
:initarg :hash-table
2022-03-26 17:32:39 +00:00
:accessor backend-hash-table)
2022-03-26 14:53:01 +00:00
(file-watch :initform nil
:initarg :file-watch
2022-03-26 17:32:39 +00:00
:accessor backend-file-watch
2022-03-26 14:53:01 +00:00
:documentation "Filesystem watch object, as returned by `file-notify-add-watch'."))
(:documentation "Mixin for backends storing data in a single file."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** backend-file :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backend-file
:END:
#+BEGIN_SRC lisp
(defmethod backend-file ((backend file-backend-mixin))
"Return the value of the file slot from BACKEND, or a file name
based on `*user-data-file*' and the BACKEND extension slot."
2022-04-03 04:50:40 +00:00
(with-slots (file extension) backend
2022-04-03 14:47:10 +00:00
;; (format t "file: ~a extension: ~a" file extension)
(make-pathname :type extension
:defaults (or file *user-data-file*))))
#+END_SRC
2022-03-26 14:53:01 +00:00
**** setup-file-watch :writer:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: setup-file-watch
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun setup-file-watch (&optional (callback #'refresh-file))
2022-03-26 14:53:01 +00:00
"Arrange for CALLBACK to be called when the backend file changes."
(let* ((backend (active-backend))
2022-04-08 03:42:02 +00:00
(file (chronometrist:backend-file backend))
(file-watch (backend-file-watch backend)))
2022-03-26 14:53:01 +00:00
(unless file-watch
(setq file-watch
(file-notify-add-watch file '(change) callback)))))
#+END_SRC
**** edit-backend :method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: edit-backend-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmethod edit-backend ((backend file-backend-mixin))
2022-04-08 03:42:02 +00:00
(find-file-other-window (chronometrist:backend-file backend))
2022-03-26 14:53:01 +00:00
(goto-char (point-max)))
#+END_SRC
**** reset-backend :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: reset-backend-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmethod reset-backend ((backend file-backend-mixin))
2022-03-26 14:53:01 +00:00
(with-slots (hash-table file-watch
rest-start rest-end rest-hash
file-length last-hash) backend
2022-03-26 17:32:39 +00:00
(reset-task-list backend)
2022-03-26 14:53:01 +00:00
(when file-watch
(file-notify-rm-watch file-watch))
2022-03-26 17:32:39 +00:00
(setf hash-table (to-hash-table backend)
2022-03-26 14:53:01 +00:00
file-watch nil
rest-start nil
rest-end nil
rest-hash nil
file-length nil
last-hash nil)
2022-03-26 17:32:39 +00:00
(setup-file-watch)))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** backend-empty-p :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backend-empty-p-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmethod backend-empty-p ((backend file-backend-mixin))
2022-04-08 03:42:02 +00:00
(let ((file (chronometrist:backend-file backend)))
(or (not (file-exists-p file))
(file-empty-p file))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** memory-layer-empty-p :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: memory-layer-empty-p-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmethod memory-layer-empty-p ((backend file-backend-mixin))
2022-03-26 14:53:01 +00:00
(with-slots (hash-table) backend
(zerop (hash-table-count hash-table))))
#+END_SRC
**** backend-modified-p :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backend-modified-p-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmethod backend-modified-p ((backend file-backend-mixin))
2022-03-26 14:53:01 +00:00
(with-slots (file) backend
(buffer-modified-p
(get-buffer-create
(find-file-noselect file)))))
#+END_SRC
**** elisp-sexp-backend :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: elisp-sexp-backend
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defclass elisp-sexp-backend (backend file-backend-mixin)
2022-03-26 14:53:01 +00:00
((rest-start :initarg :rest-start
:initform nil
2022-03-26 17:32:39 +00:00
:accessor backend-rest-start
2022-04-18 17:45:16 +00:00
:documentation "Integer denoting start of first
s-expression in file.")
2022-03-26 14:53:01 +00:00
(rest-end :initarg :rest-end
:initform nil
2022-03-26 17:32:39 +00:00
:accessor backend-rest-end
2022-04-18 17:45:16 +00:00
:documentation "Integer denoting end of second-last
s-expression in file.")
2022-03-26 14:53:01 +00:00
(rest-hash :initarg :rest-hash
:initform nil
2022-03-26 17:32:39 +00:00
:accessor backend-rest-hash
2022-04-18 17:45:16 +00:00
:documentation "Hash of content between rest-start and
rest-end.")
2022-03-26 14:53:01 +00:00
(file-length :initarg :file-length
:initform nil
2022-03-26 17:32:39 +00:00
:accessor backend-file-length
2022-04-18 17:45:16 +00:00
:documentation "Integer denoting length of file, as
returned by `(point-max)'.")
2022-03-26 14:53:01 +00:00
(last-hash :initarg :last-hash
:initform nil
2022-03-26 17:32:39 +00:00
:accessor backend-last-hash
2022-04-18 17:45:16 +00:00
:documentation "Hash of content between rest-end and
file-length."))
(:documentation "Base class for any text file backend which stores
s-expressions readable by Emacs Lisp."))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** create-file :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: create-file-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:create-file ((backend elisp-sexp-backend) &optional file)
2022-04-08 03:42:02 +00:00
(let ((file (or file (chronometrist:backend-file backend))))
2022-03-26 14:53:01 +00:00
(unless (file-exists-p file)
(with-current-buffer (find-file-noselect file)
(erase-buffer)
(goto-char (point-min))
2022-03-26 17:32:39 +00:00
(insert ";;; -*- mode: sexp; -*-\n\n")
2022-03-26 14:53:01 +00:00
(write-file file))
file)))
#+END_SRC
**** in-file :macro:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: in-file
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmacro sexp-in-file (file &rest body)
2022-03-26 14:53:01 +00:00
"Run BODY in a buffer visiting FILE, restoring point afterwards."
(declare (indent defun) (debug t))
`(with-current-buffer (find-file-noselect ,file)
(save-excursion ,@body)))
#+END_SRC
**** pre-read-check :procedure:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: pre-read-check
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun sexp-pre-read-check (buffer)
2022-03-26 14:53:01 +00:00
"Return non-nil if there is an s-expression before point in BUFFER.
Move point to the start of this s-expression."
(with-current-buffer buffer
(and (not (bobp))
(backward-list)
(or (not (bobp))
(not (looking-at-p "^[[:blank:]]*;"))))))
#+END_SRC
**** loop-sexp-file :macro:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: loop-sexp-file
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmacro loop-sexp-file (_for sexp _in file &rest loop-clauses)
2022-03-26 17:30:58 +00:00
"`loop' LOOP-CLAUSES over s-expressions in FILE.
2022-03-26 14:53:01 +00:00
SEXP is bound to each s-expressions in reverse order (last
expression first)."
2022-03-26 17:30:58 +00:00
(declare (indent defun) (debug 'loop))
2022-03-26 17:32:39 +00:00
`(sexp-in-file ,file
2022-03-26 14:53:01 +00:00
(goto-char (point-max))
2022-03-26 17:30:58 +00:00
(loop with ,sexp
2022-03-26 17:32:39 +00:00
while (and (sexp-pre-read-check (current-buffer))
2022-03-26 14:53:01 +00:00
(setq ,sexp (ignore-errors (read (current-buffer))))
(backward-list))
,@loop-clauses)))
#+END_SRC
**** backend-empty-p :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backend-empty-p-2
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defmethod backend-empty-p ((backend elisp-sexp-backend))
2022-04-08 03:42:02 +00:00
(sexp-in-file (chronometrist:backend-file backend)
2022-03-26 14:53:01 +00:00
(goto-char (point-min))
(not (ignore-errors
(read (current-buffer))))))
#+END_SRC
**** indices and hashes
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: indices-hashes
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun rest-start (file)
(sexp-in-file file
2022-03-26 14:53:01 +00:00
(goto-char (point-min))
(forward-list)
(backward-list)
(point)))
2022-03-26 17:32:39 +00:00
(defun rest-end (file)
(sexp-in-file file
2022-03-26 14:53:01 +00:00
(goto-char (point-max))
(backward-list 2)
(forward-list)
(point)))
#+END_SRC
**** file-hash :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: file-hash
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-08 03:42:02 +00:00
(defun file-hash (start end &optional (file (chronometrist:backend-file (active-backend))))
2022-03-26 17:32:39 +00:00
"Calculate hash of `file' between START and END."
(sexp-in-file file
2022-03-26 14:53:01 +00:00
(secure-hash 'sha1
(buffer-substring-no-properties start end))))
#+END_SRC
***** tests
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tests-5
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle tests.el :load test
2022-03-26 14:53:01 +00:00
(ert-deftest file-hash ()
(let-match* ((file test-file)
((list last-start last-end)
(file-hash :before-last nil nil file))
((list rest-start rest-end rest-hash)
(file-hash nil :before-last t file)))
2022-03-26 14:53:01 +00:00
(message "chronometrist - file-hash test - file path is %s" file)
(should (= 1 rest-start))
(should (= 1254 rest-end))
(should (= 1256 last-start))
(should (= 1426 last-end))))
#+END_SRC
**** file-change-type :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: file-change-type
:END:
2022-03-26 14:53:01 +00:00
+ rest-start - start of first sexp
+ rest-end - end of second last sexp
+ file-length - end of file
+ rest-hash - hash of content between rest-start and rest-end
+ last-hash - hash of content between rest-end and file-length
+ ht-last-sexp - last sexp in memory
+ file-sexp-after-rest - sexp after rest-end
+ file-last-sexp - last sexp in file
| situation | rest-hash | last-hash | file-sexp-after-rest | file-last-sexp | file-length |
|--------------+-----------+-----------+----------------------+-----------------------------------------+------------------------------|
| no change | same | same | same as ht-last-sexp | same as ht-last-sexp and file-last-sexp | same |
| append | same | same | - | (new s-expression) | always greater |
| modify | same | changed | changed | changed | may be smaller |
| remove | same | changed | nil | same as second last sexp | always smaller |
| other change | changed | - | | - | may be smaller than rest-end |
We avoid comparing s-expressions in the file with the contents of the hash table, since the last s-expression might be represented differently in the hash tables of different elisp-sexp backends. Additionally, in =:modify= as well as =nil= situations, there is no s-expression after old-file-length.
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun file-change-type (backend)
2022-03-26 14:53:01 +00:00
"Determine the type of change made to BACKEND's file.
Return
:append if a new s-expression was added to the end,
:modify if the last s-expression was modified,
:remove if the last s-expression was removed,
nil if the contents didn't change, and
t for any other change."
(with-slots
(file file-watch
;; The slots contain the old state of the file.
hash-table
rest-start rest-end rest-hash
file-length last-hash) backend
2022-03-26 17:32:39 +00:00
(let* ((new-length (file-length file))
2022-03-26 14:53:01 +00:00
(new-rest-hash (when (and (>= new-length rest-start)
(>= new-length rest-end))
2022-03-26 17:32:39 +00:00
(file-hash rest-start rest-end file)))
2022-03-26 14:53:01 +00:00
(new-last-hash (when (and (>= new-length rest-end)
(>= new-length file-length))
2022-03-26 17:32:39 +00:00
(file-hash rest-end file-length file))))
;; (debug-message "File indices - old rest-start: %s rest-end: %s file-length: %s new-length: %s"
2022-03-26 14:53:01 +00:00
;; rest-start rest-end file-length new-length)
(cond ((and (= file-length new-length)
(equal rest-hash new-rest-hash)
(equal last-hash new-last-hash))
nil)
((or (< new-length rest-end) ;; File has shrunk so much that we cannot compare rest-hash.
(not (equal rest-hash new-rest-hash)))
t)
;; From here on, it is implicit that the change has happened at the end of the file.
((and (< file-length new-length) ;; File has grown.
(equal last-hash new-last-hash))
:append)
((and (< new-length file-length) ;; File has shrunk.
2022-03-26 17:32:39 +00:00
(not (sexp-in-file file
2022-03-26 14:53:01 +00:00
(goto-char rest-end)
(ignore-errors
(read (current-buffer)))))) ;; There is no sexp after rest-end.
:remove)
(t :modify)))))
#+END_SRC
***** tests
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: tests-6
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle tests.el :load test
2022-03-26 17:32:39 +00:00
(ert-deftest file-change-type ()
(with-slots (file hash-table file-state) plist-test-backend
(let* ((b plist-test-backend)
2022-03-26 14:53:01 +00:00
(test-contents (with-current-buffer (find-file-noselect file)
(buffer-substring (point-min) (point-max)))))
2022-03-26 17:32:39 +00:00
(reset-backend b)
2022-03-26 14:53:01 +00:00
(setf file-state
2022-03-26 17:32:39 +00:00
(list :last (file-hash :before-last nil)
:rest (file-hash nil :before-last t)))
2022-03-26 14:53:01 +00:00
(unwind-protect
(progn
(should
2022-03-26 17:32:39 +00:00
(eq nil (file-change-type file-state)))
2022-03-26 14:53:01 +00:00
(should
(eq :append
(progn
2022-03-26 17:32:39 +00:00
(insert plist-test-backend
2022-03-26 14:53:01 +00:00
'(:name "Append Test"
:start "2021-02-01T13:06:46+0530"
:stop "2021-02-01T13:06:49+0530"))
2022-03-26 17:32:39 +00:00
(tests--change-type-and-update file-state file))))
2022-03-26 14:53:01 +00:00
(should
(eq :modify
(progn
2022-03-26 17:32:39 +00:00
(replace-last plist-test-backend
2022-03-26 14:53:01 +00:00
'(:name "Modify Test"
:tags (some tags)
:start "2021-02-01T13:06:46+0530"
:stop "2021-02-01T13:06:49+0530"))
2022-03-26 17:32:39 +00:00
(tests--change-type-and-update file-state file))))
2022-03-26 14:53:01 +00:00
(should
(eq :remove
(progn
2022-03-26 17:32:39 +00:00
(sexp-in-file file
2022-03-26 14:53:01 +00:00
(goto-char (point-max))
(backward-list 1)
2022-03-26 17:32:39 +00:00
(sexp-delete-list 1)
2022-03-26 14:53:01 +00:00
(save-buffer))
2022-03-26 17:32:39 +00:00
(tests--change-type-and-update file-state file))))
2022-03-26 14:53:01 +00:00
(should
(eq t
(progn
2022-03-26 17:32:39 +00:00
(sexp-in-file file
2022-03-26 14:53:01 +00:00
(goto-char (point-min))
2022-03-26 17:32:39 +00:00
(plist-pp '(:name "Other Change Test"
2022-03-26 14:53:01 +00:00
:start "2021-02-02T17:39:40+0530"
:stop "2021-02-02T17:39:44+0530")
(current-buffer))
(save-buffer))
2022-03-26 17:32:39 +00:00
(tests--change-type-and-update file-state file)))))
2022-03-26 14:53:01 +00:00
(with-current-buffer
(find-file-noselect file)
(delete-region (point-min) (point-max))
(insert test-contents)
(save-buffer))
2022-03-26 17:32:39 +00:00
(reset-backend b)))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** reset-task-list :writer:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: reset-task-list
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun reset-task-list (backend)
2022-03-26 14:53:01 +00:00
"Regenerate BACKEND's task list from its data.
2022-03-26 17:32:39 +00:00
Only takes effect if `task-list' is nil (i.e. the
2022-03-26 14:53:01 +00:00
user has not defined their own task list)."
2022-03-26 17:32:39 +00:00
(unless task-list
(setf (backend-task-list backend) (list-tasks backend))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** add-to-task-list :writer:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: add-to-task-list
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun add-to-task-list (task backend)
2022-03-26 14:53:01 +00:00
"Add TASK to BACKEND's task list, if it is not already present.
2022-03-26 17:32:39 +00:00
Only takes effect if `task-list' is nil (i.e. the
2022-03-26 14:53:01 +00:00
user has not defined their own task list)."
(with-slots (task-list) backend
2022-03-26 17:32:39 +00:00
(unless (and (not task-list)
2022-03-27 14:47:13 +00:00
(member task task-list :test #'equal))
2022-03-26 14:53:01 +00:00
(setf task-list
(sort (cons task task-list)
#'string-lessp)))))
#+END_SRC
**** remove-from-task-list :writer:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: remove-from-task-list
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun remove-from-task-list (task backend)
2022-03-26 14:53:01 +00:00
"Remove TASK from BACKEND's task list if necessary.
TASK is removed if it does not occur in BACKEND's hash table, or
if it only occurs in the newest plist of the same.
2022-03-26 17:32:39 +00:00
Only takes effect if `task-list' is nil (i.e. the
2022-03-26 14:53:01 +00:00
user has not defined their own task list).
Return new value of BACKEND's task list, or nil if
unchanged."
(with-slots (hash-table task-list) backend
2022-03-26 17:32:39 +00:00
(unless task-list
2022-03-26 14:53:01 +00:00
(let (;; number of plists in hash table
2022-03-26 17:30:58 +00:00
(ht-plist-count (loop with count = 0
2022-03-26 14:53:01 +00:00
for intervals being the hash-values of hash-table
2022-03-26 17:30:58 +00:00
do (loop for _interval in intervals
2022-03-27 14:47:13 +00:00
do (incf count))
2022-04-02 02:36:23 +00:00
finally (return count)))
2022-03-26 14:53:01 +00:00
;; index of first occurrence of TASK in hash table, or nil if not found
2022-03-26 17:30:58 +00:00
(ht-task-first-result (loop with count = 0
2022-03-26 14:53:01 +00:00
for intervals being the hash-values of hash-table
2022-03-26 17:30:58 +00:00
when (loop for interval in intervals
2022-03-27 14:47:13 +00:00
do (incf count)
when (equal task (getf interval :name))
2022-03-26 14:53:01 +00:00
return t)
return count)))
(when (or (not ht-task-first-result)
(= ht-task-first-result ht-plist-count))
;; The only interval for TASK is the last expression
(setf task-list (remove task task-list)))))))
#+END_SRC
**** on-change :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-change-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-02 11:28:11 +00:00
(defmethod on-change ((backend elisp-sexp-backend) &rest fs-event)
2022-03-26 14:53:01 +00:00
"Function called when BACKEND file is changed.
This may happen within Chronometrist (through the backend
protocol) or outside it (e.g. a user editing the backend file).
FS-EVENT is the event passed by the `filenotify' library (see `file-notify-add-watch')."
(with-slots (file hash-table file-watch
rest-start rest-end rest-hash
file-length last-hash) backend
(let-match* (((list _ action _ _) fs-event)
(file-state-bound-p (and rest-start rest-end rest-hash
file-length last-hash))
(change (when file-state-bound-p
(file-change-type backend)))
(reset-watch-p (or (eq action 'deleted)
(eq action 'renamed))))
2022-03-26 17:32:39 +00:00
(debug-message "[Method] on-change: file change type %s" change)
2022-03-26 14:53:01 +00:00
;; If only the last plist was changed, update hash table and
;; task list, otherwise clear and repopulate hash table.
(cond ((or reset-watch-p
(not file-state-bound-p) ;; why?
(eq change t))
2022-03-26 17:32:39 +00:00
(reset-backend backend))
2022-03-26 14:53:01 +00:00
(file-state-bound-p
(case change
2022-03-26 14:53:01 +00:00
;; A new s-expression was added at the end of the file
2022-03-26 17:32:39 +00:00
(:append (on-add backend))
2022-03-26 14:53:01 +00:00
;; The last s-expression in the file was changed
2022-03-26 17:32:39 +00:00
(:modify (on-modify backend))
2022-03-26 14:53:01 +00:00
;; The last s-expression in the file was removed
2022-03-26 17:32:39 +00:00
(:remove (on-remove backend))
;; `case' returns nil if the KEYFORM is nil
)))
2022-03-26 17:32:39 +00:00
(setf rest-start (rest-start file)
rest-end (rest-end file)
file-length (file-length file)
last-hash (file-hash rest-end file-length file)
rest-hash (file-hash rest-start rest-end file)))))
2022-03-26 14:53:01 +00:00
#+END_SRC
*** plist group backend
:PROPERTIES:
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: plist-group-backend
2022-03-26 14:53:01 +00:00
:END:
This is largely like the plist backend, but plists are grouped by date by wrapping them in a tagged list -
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no :load no
2022-03-26 14:53:01 +00:00
("<ISO-8601 date>"
(:name "Task Name"
[:keyword <value>]*
:start "<ISO-8601 time>"
:stop "<ISO-8601 time>")
...)
#+END_SRC
This makes it easy and computationally cheap to perform our most common query - getting the plists on a given day. [[#explanation-midnight-spanning-intervals][Midnight-spanning intervals]] are split in the file itself. The downside is that the user, if editing it by hand, must take care to split the intervals.
Note that migrating from the plist backend to the plist group backend is inherently likely to result in more plists compared to the source, as each midnight-spanning plist is split into two.
Concerns specific to the plist group backend -
1. the last plist may be split across two days. In such a situation -
* changing key-values for the last plist would only apply to the most recent one
2022-03-26 17:32:39 +00:00
* +deleting the last plist via =remove-last= would only delete the recent part of the split plist+ fixed
2022-03-26 14:53:01 +00:00
* resetting is unaffected, since that only applies to the last interval, whether or not it's a split plist
* restarting is unaffected, since that only applies to the active interval, and split intervals are always inactive ones
2022-04-03 03:56:36 +00:00
**** chronometrist.plist-group :package:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: chronometrist.plist-group
:END:
2022-04-03 03:56:36 +00:00
#+BEGIN_SRC lisp
(in-package :cl)
(defpackage :chronometrist.plist-group
(:use :cl :trivia)
(:import-from :chronometrist
;; protocol
2022-04-03 04:50:40 +00:00
:backend :file-backend-mixin :elisp-sexp-backend
:register-backend
:backend-file
;; customizable variables
2022-04-03 03:56:36 +00:00
:*user-data-file*
;; helpers
:make-hash-table-1)
2022-04-03 03:56:36 +00:00
(:export
:plist-group-backend
:make-plist-group-backend
2022-04-03 03:56:36 +00:00
;; customizable variables
))
(in-package :chronometrist.plist-group)
#+END_SRC
**** plist-group-backend :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: plist-group-backend-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-03 14:47:10 +00:00
(defclass plist-group-backend (elisp-sexp-backend) ()
(:default-initargs :extension "plg"))
2022-03-26 14:53:01 +00:00
2022-04-08 03:42:02 +00:00
(chronometrist:register-backend
:plist-group "Store records as plists grouped by date."
(make-instance 'plist-group-backend))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** backward-read-sexp :reader:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: backward-read-sexp
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun backward-read-sexp (buffer)
2022-03-26 14:53:01 +00:00
(backward-list)
(save-excursion (read buffer)))
#+END_SRC
**** run-assertions :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: run-assertions-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:backend-run-assertions ((backend file-backend-mixin))
2022-03-26 14:53:01 +00:00
(with-slots (file) backend
(unless (file-exists-p file)
(error "Backend file %S does not exist" file))))
#+END_SRC
**** latest-date-records :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: latest-date-records
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:latest-date-records ((backend plist-group-backend))
2022-03-26 17:32:39 +00:00
(backend-run-assertions backend)
2022-04-08 03:42:02 +00:00
(sexp-in-file (chronometrist:backend-file backend)
2022-03-26 14:53:01 +00:00
(goto-char (point-max))
(ignore-errors
2022-03-26 17:32:39 +00:00
(backward-read-sexp (current-buffer)))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** HACK insert :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: insert
:END:
2022-03-26 14:53:01 +00:00
<<hack-note-plist-group-insert>>
We just want to insert a plist, but as a hack to avoid updating the pretty-printer to handle indentation of plists being inserted into an outer list, we =append= the plist to a plist group and insert/replace the plist group instead.
Situations -
1. new inactive day-crossing record
1. first record - split, insert into two new groups
2. not first record - split, insert into existing group + new group
2. new active record, or new non-day-crossing inactive record
1. first record - insert into new plist group
2. not first record
1. latest recorded date = today - insert into existing group
2. insert into new group
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
#+(or)
2022-04-02 11:28:11 +00:00
(defmethod insert ((backend plist-group-backend) plist
&key (save t)
&allow-other-keys)
2022-04-14 07:04:28 +00:00
;; (check-type plist plist)
2022-03-26 17:32:39 +00:00
(debug-message "[Method] insert: %S" plist)
(backend-run-assertions backend)
2022-03-26 14:53:01 +00:00
(if (not plist)
2022-03-26 17:32:39 +00:00
(error "%s" "`insert' was called with an empty plist")
(sexp-in-file
2022-04-08 03:42:02 +00:00
(chronometrist:backend-file backend)
(let-match* (((list plist-1 plist-2) (split-plist plist))
;; Determine if we need to insert a new plist group
(latest-plist-group (latest-date-records backend))
(backend-latest-date (first latest-plist-group))
(date-today (date-iso))
(insert-new-group (not (equal date-today backend-latest-date)))
(start-date (iso-to-date (getf plist :start)))
(new-plist-group-1 (if latest-plist-group
(append latest-plist-group
(list (or plist-1 plist)))
(list start-date (or plist-1 plist))))
(new-plist-group-2 (when (or plist-2 insert-new-group)
(list date-today (or plist-2 plist)))))
(goto-char (point-max))
(when (not latest-plist-group)
;; first record
(while (forward-comment 1) nil))
(if (and plist-1 plist-2)
;; inactive, day-crossing record
(progn
(when latest-plist-group
;; not the first record
(sexp-pre-read-check (current-buffer))
(sexp-delete-list))
(funcall sexp-pretty-print-function new-plist-group-1 (current-buffer))
(dotimes (_ 2) (default-indent-new-line))
(funcall sexp-pretty-print-function new-plist-group-2 (current-buffer)))
;; active, or non-day-crossing inactive record
;; insert into new group
(if (or (not latest-plist-group) ;; first record
insert-new-group)
(progn
(default-indent-new-line)
(funcall sexp-pretty-print-function new-plist-group-2 (current-buffer)))
;; insert into existing group
(progn
(sexp-pre-read-check (current-buffer))
(sexp-delete-list)
(funcall sexp-pretty-print-function
new-plist-group-1
(current-buffer)))))
(when save (save-buffer))
t))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** plists-split-p :function:
:PROPERTIES:
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: plists-split-p
2022-03-26 14:53:01 +00:00
:END:
2022-03-26 17:32:39 +00:00
[[file:../tests/tests.org::#tests-common-plists-split-p][tests]]
2022-03-26 14:53:01 +00:00
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun plists-split-p (old-plist new-plist)
2022-03-26 14:53:01 +00:00
"Return t if OLD-PLIST and NEW-PLIST are split plists.
Split plists means the :stop time of old-plist must be the same as
the :start time of new-plist, and they must have identical
keyword-values (except :start and :stop)."
(let-match* (;; ((plist :stop old-stop) old-plist)
;; ((plist :start new-start) new-plist)
(old-stop-unix (parse-timestring old-stop))
(new-start-unix (parse-timestring new-start))
2022-03-26 17:32:39 +00:00
(old-plist-no-time (plist-remove old-plist :start :stop))
(new-plist-no-time (plist-remove new-plist :start :stop)))
2022-03-26 14:53:01 +00:00
(and (time-equal-p old-stop-unix
new-start-unix)
(equal old-plist-no-time
new-plist-no-time))))
#+END_SRC
**** last-two-split-p :procedure:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: last-two-split-p
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun last-two-split-p (file)
2022-03-26 14:53:01 +00:00
"Return non-nil if the latest two plists in FILE are split.
FILE must be a file containing plist groups, as created by
2022-03-26 17:32:39 +00:00
`plist-backend'.
2022-03-26 14:53:01 +00:00
Return value is either a list in the form
(OLDER-PLIST NEWER-PLIST), or nil."
2022-03-26 17:32:39 +00:00
(sexp-in-file file
2022-03-26 14:53:01 +00:00
(let* ((newer-group (progn (goto-char (point-max))
(backward-list)
(read (current-buffer))))
(older-group (and (= 2 (length newer-group))
(backward-list 2)
(read (current-buffer))))
;; in case there was just one plist-group in the file
(older-group (unless (equal older-group newer-group)
older-group))
2022-03-26 17:30:58 +00:00
(newer-plist (second newer-group))
(older-plist (first (last older-group))))
2022-03-26 14:53:01 +00:00
(when (and older-plist newer-plist
2022-03-26 17:32:39 +00:00
(plists-split-p older-plist newer-plist))
2022-03-26 14:53:01 +00:00
(list older-plist newer-plist)))))
#+END_SRC
**** plist-unify :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: plist-unify
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun plist-unify (old-plist new-plist)
2022-03-26 14:53:01 +00:00
"Return a plist with the :start of OLD-PLIST and the :stop of NEW-PLIST."
2022-03-26 17:32:39 +00:00
(let ((old-plist-wo-time (plist-remove old-plist :start :stop))
(new-plist-wo-time (plist-remove new-plist :start :stop)))
2022-03-26 14:53:01 +00:00
(cond ((not (and old-plist new-plist)) nil)
((equal old-plist-wo-time new-plist-wo-time)
2022-03-27 14:47:13 +00:00
(let* ((plist (copy-list old-plist))
(new-stop (getf new-plist :stop)))
2022-03-26 14:53:01 +00:00
;; Usually, a split plist has a `:stop' key. However, a
;; user may clock out and delete the stop time, resulting
;; in a split record without a `:stop' key.
(if new-stop
(plist-put plist :stop new-stop)
2022-03-26 17:32:39 +00:00
(plist-remove plist :stop))))
2022-03-26 14:53:01 +00:00
(t (error "Attempt to unify plists with non-identical key-values")))))
#+END_SRC
**** remove-last :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: remove-last-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:remove-last ((backend plist-group-backend) &key (save t) &allow-other-keys)
2022-03-26 14:53:01 +00:00
(with-slots (file) backend
(sexp-in-file
file
(goto-char (point-max))
(when (backend-empty-p backend)
(error "remove-last has nothing to remove in %s"
(eieio-object-class-name backend)))
(when (last-two-split-p file) ;; cannot be checked after changing the file
;; latest plist-group has only one plist, which is split - delete the group
(backward-list)
(sexp-delete-list))
;; remove the last plist in the last plist-group
;; if the plist-group has only one plist, delete the group
(let ((plist-group (save-excursion (backward-list)
(read (current-buffer)))))
(if (= 2 (length plist-group))
(progn (backward-list)
(sexp-delete-list))
(progn
(down-list -1)
(backward-list)
(sexp-delete-list)
(join-line)))
(when save (save-buffer))
t))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** to-list :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-list-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:to-list ((backend plist-group-backend))
2022-03-26 17:32:39 +00:00
(backend-run-assertions backend)
2022-04-08 03:42:02 +00:00
(loop-sexp-file for expr in (chronometrist:backend-file backend)
2022-03-27 14:47:13 +00:00
append (reverse (rest expr))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** to-hash-table :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-hash-table-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:to-hash-table ((backend plist-group-backend))
2022-04-08 03:42:02 +00:00
(let ((file (chronometrist:backend-file backend)))
2022-04-03 14:47:10 +00:00
;; (format t "file: ~a" file)
(with-open-file (in file)
(loop for sexp = (read in nil :eof)
until (eq sexp :eof)
collect sexp into list
finally
(return
(loop with ht = (make-hash-table-1)
for plist-group in list
do (setf (gethash (first plist-group) ht)
(rest plist-group))
finally (return ht)))))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** to-file :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-file-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:to-file (hash-table (backend plist-group-backend) file)
2022-03-27 14:47:13 +00:00
(check-type hash-table hash-table)
2022-03-26 14:53:01 +00:00
(delete-file file)
2022-03-26 17:32:39 +00:00
(create-file backend file)
(reset-backend backend)
(sexp-in-file
file
(goto-char (point-max))
(loop for date being the hash-keys in hash-table
using (hash-value plists)
do (insert (plist-pp (apply #'list date plists)) "\n")
finally (save-buffer))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-add :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-add-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:on-add ((backend plist-group-backend))
2022-03-26 14:53:01 +00:00
"Function run when a new plist-group is added at the end of a
2022-03-26 17:32:39 +00:00
`plist-group-backend' file."
2022-03-26 14:53:01 +00:00
(with-slots (hash-table) backend
2022-04-02 02:36:23 +00:00
(let-match (((cons date plist) (latest-date-records backend)))
(setf (gethash date hash-table) plist)
(add-to-task-list (getf plist :name) backend))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-modify :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-modify-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:on-modify ((backend plist-group-backend))
2022-03-26 14:53:01 +00:00
"Function run when the newest plist-group in a
2022-03-26 17:32:39 +00:00
`plist-group-backend' file is modified."
2022-03-26 14:53:01 +00:00
(with-slots (hash-table) backend
(let-match* (((cons date plists) (latest-date-records backend))
(old-date (ht-last-date hash-table))
(old-plists (gethash old-date hash-table)))
2022-03-26 14:53:01 +00:00
(puthash date plists hash-table)
2022-03-26 17:30:58 +00:00
(loop for plist in old-plists
do (remove-from-task-list (getf plist :name) backend))
2022-03-26 17:30:58 +00:00
(loop for plist in plists
do (add-to-task-list (getf plist :name) backend)))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** on-remove :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: on-remove-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:on-remove ((backend plist-group-backend))
2022-03-26 14:53:01 +00:00
"Function run when the newest plist-group in a
2022-03-26 17:32:39 +00:00
`plist-group-backend' file is deleted."
2022-03-26 14:53:01 +00:00
(with-slots (hash-table) backend
(let* ((old-date (ht-last-date hash-table))
(old-plists (gethash old-date hash-table)))
2022-03-26 17:30:58 +00:00
(loop for plist in old-plists
do (remove-from-task-list (getf plist :name) backend))
2022-03-26 14:53:01 +00:00
(puthash old-date nil hash-table))))
#+END_SRC
**** verify :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: verify-1
:END:
2022-04-03 04:00:36 +00:00
#+BEGIN_SRC lisp :load no :tangle no
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:verify ((backend plist-group-backend))
2022-03-26 14:53:01 +00:00
(with-slots (file hash-table) backend
;; incorrectly ordered groups check
2022-03-26 17:32:39 +00:00
(loop-sexp-file for group in file
2022-03-26 14:53:01 +00:00
with old-date-iso with old-date-unix
with new-date-iso with new-date-unix
;; while (not (bobp))
2022-03-26 17:30:58 +00:00
do (setq new-date-iso (first group)
new-date-unix (parse-timestring new-date-iso))
2022-03-26 14:53:01 +00:00
when (and old-date-unix
(time-less-p old-date-unix
new-date-unix))
2022-03-27 14:47:13 +00:00
do (return (format "%s appears before %s on line %s"
new-date-iso old-date-iso (line-number-at-pos)))
2022-03-26 14:53:01 +00:00
else do (setq old-date-iso new-date-iso
old-date-unix new-date-unix)
2022-04-02 02:36:23 +00:00
finally (return "Yay, no errors! (...that I could find 💀)"))))
2022-03-26 14:53:01 +00:00
#+END_SRC
**** extended protocol
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: extended-protocol-1
:END:
2022-04-08 03:41:46 +00:00
***** list-tasks :method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: list-tasks-1
:END:
2022-04-08 03:41:46 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:list-tasks ((backend backend))
2022-04-08 03:41:46 +00:00
(loop for plist in (to-list backend)
collect (getf plist :name) into names
finally (return
(sort (remove-duplicates names :test #'equal)
#'string-lessp))))
#+END_SRC
2022-03-26 14:53:01 +00:00
***** latest-record :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: latest-record-1
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:latest-record ((backend plist-group-backend))
2022-03-26 14:53:01 +00:00
(with-slots (file) backend
2022-03-26 17:32:39 +00:00
(if (last-two-split-p file)
2022-04-08 03:42:02 +00:00
(apply #'plist-unify (last-two-split-p (chronometrist:backend-file (active-backend))))
2022-03-26 17:32:39 +00:00
(first (last (latest-date-records backend))))))
2022-03-26 14:53:01 +00:00
#+END_SRC
***** task-records-for-date :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:14:59 +00:00
:CUSTOM_ID: plist-group-task-records-for-date
2022-04-27 10:56:26 +00:00
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:task-records-for-date
((backend plist-group-backend) task date-ts &key &allow-other-keys)
2022-03-27 14:47:13 +00:00
(check-type task string)
(check-type date-ts ts)
2022-03-26 17:32:39 +00:00
(backend-run-assertions backend)
(loop for plist in (gethash (date-iso date-ts)
2022-04-13 05:18:24 +00:00
(backend-hash-table backend))
when (equal task (getf plist :name))
collect plist))
2022-03-26 14:53:01 +00:00
#+END_SRC
***** TODO active-days :reader:method:noexport:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: active-days
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no
2022-04-08 03:42:02 +00:00
(defmethod chronometrist:active-days ((backend plist-group-backend) task &key start end)
2022-03-27 14:47:13 +00:00
(check-type task string)
2022-03-26 17:32:39 +00:00
(backend-run-assertions backend))
2022-03-26 14:53:01 +00:00
#+END_SRC
***** replace-last :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: replace-last-1
:END:
2022-03-26 17:32:39 +00:00
=replace-last= is what is used for clocking out, so we split midnight-spanning intervals in this operation.
2022-03-26 14:53:01 +00:00
We apply the same hack as in the [[hack-note-plist-group-insert][insert]] method, removing and inserting the plist group instead of just the specific plist, to avoid having to update the pretty printer.
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:replace-last
((backend plist-group-backend) plist &key &allow-other-keys)
2022-04-14 07:04:28 +00:00
;; (check-type plist plist)
2022-03-26 17:32:39 +00:00
(when (backend-empty-p backend)
2022-03-26 14:53:01 +00:00
(error "No record to replace in %s" (eieio-object-class-name backend)))
2022-04-08 03:42:02 +00:00
(sexp-in-file (chronometrist:backend-file backend)
2022-03-26 17:32:39 +00:00
(remove-last backend :save nil)
2022-03-26 14:53:01 +00:00
(delete-trailing-whitespace)
2022-03-26 17:32:39 +00:00
(insert backend plist :save nil)
2022-03-26 14:53:01 +00:00
(save-buffer)
t))
#+END_SRC
***** count-records :reader:method:noexport:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: count-records
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp :tangle no
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:count-records ((backend plist-group-backend)))
2022-03-26 14:53:01 +00:00
#+END_SRC
2022-03-29 14:35:33 +00:00
*** sqlite backend
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: sqlite-backend
:END:
2022-04-02 02:40:39 +00:00
**** chronometrist.sqlite :package:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: chronometrist.sqlite
:END:
2022-04-02 02:40:39 +00:00
#+BEGIN_SRC lisp
(in-package :cl)
(defpackage :chronometrist.sqlite
2022-04-14 07:04:28 +00:00
(:use :cl)
(:import-from :alexandria
:hash-table-keys)
(:import-from :uiop
:strcat)
(:import-from :local-time
:parse-timestring :timestamp-to-unix)
2022-04-08 03:41:46 +00:00
(:import-from :sqlite
2022-04-13 05:18:24 +00:00
:connect :disconnect
:execute-non-query :execute-single :execute-to-list)
(:import-from :sxql
:yield
:create-table :foreign-key :unique-key
:insert-into :select := :set= :from :order-by :where
:left-join :limit)
(:import-from :alexandria
:flatten)
(:import-from :trivia
:let-match :let-match* :plist)
(:import-from :chronometrist
:make-interval)
2022-04-13 05:18:24 +00:00
(:export :sqlite-backend
2022-04-02 02:40:39 +00:00
;; customizable variables
))
(in-package :chronometrist.sqlite)
2022-04-02 02:37:21 +00:00
#+END_SRC
2022-04-06 01:03:18 +00:00
**** aliases
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: aliases
:END:
2022-04-06 01:03:18 +00:00
#+BEGIN_SRC lisp :load no
(loop for (fn . alias) in '((sqlite:execute-non-query . execute-statement)
(sqlite:execute-single . query-cell)
(sqlite:execute-single . query-row)
(sqlite:execute-single . query-row))
do (setf (fdefinition alias) (symbol-function fn)))
#+END_SRC
2022-04-02 02:43:36 +00:00
**** sqlite-backend :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: sqlite-backend-1
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-04-08 03:42:02 +00:00
(defclass sqlite-backend (chronometrist:backend chronometrist:file-backend-mixin)
((connection :initform nil
2022-03-29 14:35:33 +00:00
:initarg :connection
:accessor backend-connection))
(:default-initargs :extension "sqlite"))
2022-03-29 14:35:33 +00:00
#+END_SRC
**** initialize-instance :method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: initialize-instance
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
(defmethod initialize-instance :after ((backend sqlite-backend) &rest initargs)
2022-03-29 14:35:33 +00:00
"Initialize connection for BACKEND based on its file."
(declare (ignore initargs))
(with-slots (connection file) backend
2022-04-08 03:42:02 +00:00
(let ((file (chronometrist:backend-file backend)))
(when (not connection)
(setf connection (connect file)
file file)))))
2022-04-08 03:41:46 +00:00
2022-04-08 03:42:02 +00:00
(chronometrist:register-backend
:sqlite "Store records in SQLite database."
(make-instance 'sqlite-backend))
2022-03-29 14:35:33 +00:00
#+END_SRC
2022-04-18 17:15:08 +00:00
**** execute-sxql :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: execute-sxql
:END:
2022-04-18 17:15:08 +00:00
#+BEGIN_SRC lisp
(defun execute-sxql (exec-fn sxql database)
"Execute SXQL statement on DATABASE using EXEC-FN.
EXEC-FN must be a function accepting DATABASE, an SQL query string,
and zero or more arguments to the query. `sqlite:execute-single' and
`sqlite:execute-to-list' are two examples of such a function.
SXQL must be an SXQL-statement object acceptable to `sxql:yield'.
DATABASE must be a database object acceptable to the EXEC-FN, such as
that returned by `sqlite:connect'."
(multiple-value-bind (string values)
(yield sxql)
(apply exec-fn database string values)))
#+END_SRC
2022-04-02 02:43:36 +00:00
**** create-file :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: create-file-2
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-04-08 03:42:02 +00:00
(defmethod chronometrist:create-file ((backend sqlite-backend) &optional file)
2022-03-29 14:35:33 +00:00
"Create file for BACKEND if it does not already exist.
Return the connection object from `emacsql-sqlite'."
2022-04-08 03:42:02 +00:00
(let* ((file (or file (chronometrist:backend-file backend)))
2022-03-29 14:36:14 +00:00
(db (or (backend-connection backend)
(setf (backend-connection backend)
(connect file)))))
2022-03-29 14:36:14 +00:00
(loop
for expr in
2022-04-06 03:04:21 +00:00
(list
;; Properties are user-defined key-values stored as JSON.
(create-table :properties
((prop_id :type 'integer :primary-key t)
(properties :type 'text :unique t :not-null t)))
2022-04-02 02:37:21 +00:00
;; An event is a timestamp with a name and optional properties.
2022-04-06 03:04:21 +00:00
(create-table :event_names
((name_id :type 'integer :primary-key t)
(name :type 'text :unique t :not-null t)))
(create-table :events
((event_id :type 'integer :primary-key t)
(name_id :type 'integer :not-null t)
(time :type 'integer :unique t :not-null t)
2022-04-06 03:04:21 +00:00
(prop_id :type 'integer))
(foreign-key '(name_id) :references '(event_names name_id))
(foreign-key '(prop_id) :references '(properties prop_id)))
2022-04-02 02:37:21 +00:00
;; An interval is a time range with a name and optional properties.
2022-04-06 03:04:21 +00:00
(create-table :interval_names
((name_id :type 'integer :primary-key t)
(name :type 'text :unique t :not-null t)))
(create-table :intervals
((interval_id :type 'integer :primary-key t)
(name_id :type 'integer :not-null t)
(start_time :type 'integer :not-null t)
(stop_time :type 'integer)
(prop_id :type 'integer))
(foreign-key '(name_id) :references '(interval_names name_id))
(foreign-key '(prop_id) :references '(properties prop_id))
(unique-key '(name_id start_time stop_time)))
2022-04-02 02:37:21 +00:00
;; A date contains one or more events and intervals. It may
;; also contain properties.
2022-04-06 03:04:21 +00:00
(create-table :dates
((date_id :type 'integer :primary-key t)
(date :type 'integer :unique t :not-null t)
(prop_id :type 'integer))
(foreign-key '(prop_id) :references '(properties prop_id)))
(create-table :date_events
((date_id :type 'integer :not-null t)
(event_id :type 'integer :not-null t))
(foreign-key '(date_id) :references '(dates date_id))
(foreign-key '(event_id) :references '(events event_id)))
(create-table :date_intervals
((date_id :type 'integer :not-null t)
(interval_id :type 'integer :not-null t))
(foreign-key '(date_id) :references '(dates date_id))
(foreign-key '(interval_id) :references '(intervals interval_id))))
do (execute-non-query db (yield expr))
finally
(execute-non-query
db
"CREATE VIEW view_intervals AS
2022-04-02 11:07:35 +00:00
SELECT
2022-04-02 05:28:35 +00:00
interval_id,
name,
datetime(start_time, 'unixepoch', 'localtime'),
datetime(stop_time, 'unixepoch', 'localtime'),
properties
FROM intervals
LEFT JOIN interval_names USING (name_id)
LEFT JOIN properties USING (prop_id)
ORDER BY interval_id DESC;")
2022-04-06 03:04:21 +00:00
(return db))))
#+END_SRC
**** get-day :method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: get-day-1
:END:
#+BEGIN_SRC lisp
(defmethod chronometrist:get-day (date (backend sqlite-backend))
2022-04-18 17:15:08 +00:00
(let-match*
((connection (backend-connection backend))
(day (make-instance 'chronometrist:day))
((list (or (list date-id prop-id) nil))
(execute-to-list
connection "SELECT date_id, prop_id FROM dates WHERE date = ?;" date))
(properties
2022-04-19 04:18:11 +00:00
(when prop-id
(execute-sxql #'execute-single
(select (:properties)
(from :properties)
(where (:= :prop_id prop-id)))
connection))))
(setf (chronometrist:date day) date
(chronometrist:intervals day)
(loop for (name start stop prop-string)
2022-04-19 04:18:11 +00:00
in (execute-sxql
#'execute-to-list
(select (:name :start_time :stop_time :properties)
(from :intervals)
(left-join :interval_names :using (:name_id))
(left-join :properties :using (:prop_id))
(where (:in :interval_id
(select (:interval_id)
(from :date_intervals)
(where (:= :date_id date-id))))))
connection)
collect (make-interval name start stop prop-string))
2022-04-19 04:18:11 +00:00
(chronometrist:events day)
(loop for (name time properties)
in (execute-sxql
#'execute-to-list
(select (:name :time :properties)
(from :events)
(left-join :event_names :using (:name_id))
(left-join :properties :using (:prop_id))
(where (:in :event_id
(select (:event_id)
(from :date_events)
(where (:= :date_id date-id))))))
connection)
collect (make-instance 'chronometrist:event
:name name
:time time
:properties (when properties
(read-from-string properties))))
(chronometrist:properties day)
(when properties
(read-from-string properties)))
day))
#+END_SRC
**** iso-to-unix :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: iso-to-unix
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-03-29 14:36:14 +00:00
(defun iso-to-unix (timestamp)
(timestamp-to-unix (parse-timestring timestamp)))
#+END_SRC
2022-03-29 14:35:33 +00:00
2022-04-08 03:41:46 +00:00
**** to-file :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-file-2
:END:
#+BEGIN_SRC lisp
2022-04-08 03:42:02 +00:00
(defmethod chronometrist:to-file (hash-table (backend sqlite-backend) file)
2022-03-29 14:35:33 +00:00
(with-slots (connection) backend
(delete-file file)
2022-04-06 01:03:18 +00:00
(disconnect connection)
2022-03-29 14:35:33 +00:00
(setf connection nil)
2022-04-13 05:18:24 +00:00
(chronometrist:create-file backend file)
2022-03-29 14:36:14 +00:00
(loop for date in (sort (hash-table-keys hash-table) #'string-lessp) do
2022-03-29 14:35:33 +00:00
;; insert date if it does not exist
2022-04-06 01:03:18 +00:00
(execute-non-query connection
"INSERT OR IGNORE INTO dates (date) VALUES (?);"
(iso-to-unix date))
2022-03-29 14:36:14 +00:00
(loop for plist in (gethash date hash-table) do
(chronometrist:insert-day backend plist)))))
2022-03-29 14:35:33 +00:00
#+END_SRC
**** to-list :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: to-list-2
:END:
#+BEGIN_SRC lisp
2022-04-08 03:42:02 +00:00
(defmethod chronometrist:to-list ((backend sqlite-backend))
(with-slots (connection) backend
))
#+END_SRC
2022-03-29 14:35:33 +00:00
**** insert-properties :writer:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: insert-properties
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-03-29 14:36:14 +00:00
(defun sqlite-insert-properties (backend plist)
2022-03-29 14:35:33 +00:00
"Insert properties from PLIST to (SQLite) BACKEND.
Properties are key-values excluding :name, :start, and :stop.
Insert nothing if the properties already exist. Return the
prop-id of the inserted or existing property."
(with-slots (connection) backend
2022-04-14 07:04:28 +00:00
(let* ((plist (chronometrist:plist-key-values plist))
(encoded-properties
(if (functionp *sqlite-properties-function*)
(funcall *sqlite-properties-function* plist)
2022-04-06 01:03:18 +00:00
(write-to-string plist :escape t :pretty nil :readably t))))
;; (format t "properties: ~s~%" encoded-properties)
2022-04-04 16:03:02 +00:00
(when plist
2022-04-06 01:03:18 +00:00
(execute-non-query connection
"INSERT OR IGNORE INTO properties (properties) VALUES (?);"
encoded-properties)
(execute-single connection
"SELECT (prop_id) FROM properties WHERE properties = ?;"
encoded-properties)))))
2022-03-29 14:35:33 +00:00
#+END_SRC
***** properties-to-json :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: properties-to-json
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-03-29 14:36:14 +00:00
(defun sqlite-properties-to-json (plist)
2022-03-29 14:35:33 +00:00
"Return PLIST as a JSON string."
(json-encode
;; `json-encode' throws an error when it thinks
;; it sees "alists" which have numbers as
;; "keys", so we convert any cons cells and any
;; lists starting with a number to vectors
(-tree-map (lambda (elt)
2022-03-29 14:36:14 +00:00
(cond ((pp-pair-p elt)
2022-03-29 14:35:33 +00:00
(vector (car elt) (cdr elt)))
((consp elt)
(vconcat elt))
(t elt)))
plist)))
#+END_SRC
***** properties-function :custom:variable:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: properties-function
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
(defvar *sqlite-properties-function* nil
2022-03-29 14:35:33 +00:00
"Function used to control the encoding of user key-values.
The function must accept a single argument, the plist of key-values.
Any non-function value results in key-values being inserted as
2022-04-02 02:36:23 +00:00
s-expressions in a text column.")
2022-03-29 14:35:33 +00:00
#+END_SRC
**** insert :writer:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: insert-1
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
(defmethod chronometrist:insert-day
2022-04-13 05:18:24 +00:00
((backend sqlite-backend) plist &key &allow-other-keys)
2022-04-14 07:04:28 +00:00
(let-match (((or (list plist-1 plist-2) nil)
(chronometrist:split-plist plist))
2022-04-06 01:03:18 +00:00
(connection (backend-connection backend)))
(loop
for plist in (if (and plist-1 plist-2)
(list plist-1 plist-2)
(list plist))
do (let-match* (((plist :name name :start start :stop stop) plist)
2022-04-13 05:18:24 +00:00
(date-unix (iso-to-unix (chronometrist:iso-to-date start)))
(start-unix (iso-to-unix start))
(stop-unix (and stop (iso-to-unix stop)))
2022-04-05 05:03:05 +00:00
;; insert interval properties if they do not exist
(prop-id (sqlite-insert-properties backend plist))
(name-id)
(interval-id)
(date-id))
;; insert name if it does not exist
2022-04-06 01:03:18 +00:00
(execute-non-query
connection
"INSERT OR IGNORE INTO interval_names (name) VALUES (?);"
name)
(setq name-id
2022-04-06 01:03:18 +00:00
(execute-single
connection
"SELECT (name_id) FROM interval_names WHERE name = ?;"
name))
2022-04-05 05:03:05 +00:00
;; insert an interval...
2022-04-06 01:03:18 +00:00
(execute-non-query
connection
"INSERT OR IGNORE INTO intervals (name_id, start_time, stop_time, prop_id) VALUES (?, ?, ?, ?);"
name-id start-unix stop-unix prop-id)
2022-04-05 05:03:05 +00:00
(setq interval-id
2022-04-06 01:03:18 +00:00
(execute-single connection
"SELECT (interval_id) FROM intervals WHERE start_time = ?;"
start-unix))
2022-04-05 05:03:05 +00:00
;; ...and associate it with the date
2022-04-06 01:03:18 +00:00
(execute-non-query connection
"INSERT OR IGNORE INTO dates (date) VALUES (?);"
date-unix)
2022-04-05 05:03:05 +00:00
(setq date-id
2022-04-06 01:03:18 +00:00
(execute-single connection
"SELECT (date_id) FROM dates WHERE date = ?;"
date-unix))
(execute-non-query connection
"INSERT INTO date_intervals (date_id, interval_id) VALUES (?, ?);"
date-id interval-id)))))
2022-03-29 14:35:33 +00:00
#+END_SRC
2022-04-08 03:41:46 +00:00
**** list-tasks :reader:method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: list-tasks-2
:END:
#+BEGIN_SRC lisp
2022-04-08 03:42:02 +00:00
(defmethod chronometrist:list-tasks ((backend sqlite-backend))
;; (format *debug-io* "list-tasks (sqlite)")
(with-slots (connection) backend
(flatten (execute-to-list connection
2022-04-08 03:41:46 +00:00
(yield (select :name
(from :interval_names)
(order-by (:asc :name))))))))
#+END_SRC
2022-03-29 14:35:33 +00:00
**** open-file
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: open-file
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-03-29 14:36:14 +00:00
(defmethod edit-backend ((backend sqlite-backend))
2022-03-29 14:35:33 +00:00
(require 'sql)
(switch-to-buffer
(sql-comint-sqlite 'sqlite (list file))))
#+END_SRC
**** latest-record
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: latest-record-2
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-03-29 14:35:33 +00:00
;; SELECT * FROM TABLE WHERE ID = (SELECT MAX(ID) FROM TABLE);
;; SELECT * FROM tablename ORDER BY column DESC LIMIT 1;
2022-04-08 03:42:02 +00:00
(defmethod chronometrist:latest-record ((backend sqlite-backend))
(emacsql db ;; [:select * :from events :order-by rowid :desc :limit 1]
))
2022-03-29 14:35:33 +00:00
#+END_SRC
**** task-records-for-date
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:13:50 +00:00
:CUSTOM_ID: sqlite-task-records-for-date
2022-04-27 10:56:26 +00:00
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-04-13 05:18:24 +00:00
(defmethod chronometrist:task-records-for-date
((backend sqlite-backend) task date &key &allow-other-keys)
2022-05-03 05:13:50 +00:00
(let ((list (execute-sxql
#'execute-to-list
(select (:name :start_time :stop_time :properties)
(from :intervals)
(left-join :interval_names :using (:name_id))
(left-join :properties :using (:prop_id))
(where (:and
(:in :interval_id
(select (:interval_id)
(from :date_intervals)
(where (:= :date_id
(select (:date_id)
(from :dates)
(where (:= :date date)))))))
(:= :name task))))
(backend-connection backend))))
(loop for (name start stop prop-string) in list
collect (make-interval name start stop prop-string))))
2022-03-29 14:35:33 +00:00
#+END_SRC
**** active-days
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: active-days-1
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
2022-04-08 03:42:02 +00:00
(defmethod chronometrist:active-days ((backend sqlite-backend) task &key start end))
2022-03-29 14:35:33 +00:00
#+END_SRC
**** replace-last
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: replace-last-2
:END:
2022-03-29 14:53:40 +00:00
#+BEGIN_SRC lisp
(defmethod chronometrist:replace-last ((backend sqlite-backend)
plist &key &allow-other-keys)
(emacsql db ;; [:delete-from events :where ]
))
2022-03-29 14:35:33 +00:00
#+END_SRC
2022-03-26 14:53:01 +00:00
** Migration
:PROPERTIES:
2022-04-27 10:56:26 +00:00
:CUSTOM_ID: migration
2022-03-26 14:53:01 +00:00
:END:
2022-04-02 02:43:36 +00:00
*** remove-prefix :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: remove-prefix
:END:
2022-03-26 17:54:42 +00:00
#+BEGIN_SRC lisp
2022-03-26 17:32:39 +00:00
(defun remove-prefix (string)
(replace-regexp-in-string "^" "" string))
2022-03-26 14:53:01 +00:00
#+END_SRC
2022-04-08 03:41:46 +00:00
** Frontends
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: frontends
:END:
2022-04-08 03:41:46 +00:00
*** CLIM
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: clim
:END:
2022-04-08 03:41:46 +00:00
**** chronometrist.clim :package:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: chronometrist.clim
:END:
2022-04-08 03:41:46 +00:00
#+BEGIN_SRC lisp
(in-package :cl)
(defpackage :chronometrist.clim
2022-04-08 03:42:02 +00:00
(:use :clim :clim-lisp)
2022-04-22 17:00:13 +00:00
(:import-from :local-time :now :today :timestamp-to-unix)
2022-05-03 05:13:50 +00:00
(:import-from :format-seconds :format-seconds)
2022-04-22 17:00:13 +00:00
(:import-from :chronometrist :task-list :task-duration-one-day)
2022-04-08 03:41:46 +00:00
(:export :run-chronometrist))
(in-package :chronometrist.clim)
#+END_SRC
2022-05-03 05:13:50 +00:00
**** *duration-format-string* :variable:
:PROPERTIES:
:CUSTOM_ID: duration-format-string
:END:
#+BEGIN_SRC lisp
(defvar *duration-format-string* "~2h:~2,'0m:~2,'0s"
"Format string used for durations, acceptable to `format-seconds'.")
#+END_SRC
2022-04-08 03:41:46 +00:00
**** chronometrist :application:frame:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: chronometrist-1
:END:
2022-04-08 03:41:46 +00:00
#+BEGIN_SRC lisp
2022-04-11 17:12:47 +00:00
(define-application-frame chronometrist () ()
2022-04-08 03:41:46 +00:00
(:pointer-documentation t)
2022-04-11 17:12:47 +00:00
(:panes (task-duration :application
:height (graft-height (find-graft))
:width (graft-width (find-graft))
:display-function 'display
:background +black+
:foreground +white+)
(int :interactor
:background +black+
:foreground +white+))
2022-04-11 17:12:47 +00:00
(:layouts (default (vertically () task-duration int))))
#+END_SRC
2022-04-27 10:56:26 +00:00
**** task-duration table pane
:PROPERTIES:
:CUSTOM_ID: task-duration-table-pane
:END:
***** column-specifier :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: column-specifier
:END:
#+BEGIN_SRC lisp
(defclass column-specifier ()
((name :initarg :name :type keyword :accessor column-name)
(label :initarg :label :type string :accessor column-label)))
#+END_SRC
***** make-column-specifier :constructor:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: make-column-specifier
:END:
#+BEGIN_SRC lisp
(defun make-column-specifier (name label)
(make-instance 'column-specifier :name name :label label))
#+END_SRC
***** cell-data :method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: cell-data
:END:
#+BEGIN_SRC lisp
(defgeneric cell-data (name index task date)
(:documentation "Function to determine the data of cell NAME.
NAME must be a keyword.
INDEX is a 1-indexed integer for the row.
TASK is the name of the task in this row, as a string.
DATE is the date, as integer seconds since the UNIX epoch."))
(defmethod cell-data ((name (eql :index))
(index integer)
(task string)
(date integer))
index)
(defmethod cell-data ((name (eql :task))
(index integer)
(task string)
(date integer))
task)
(defmethod cell-data ((name (eql :duration))
(index integer)
(task string)
(date integer))
(task-duration-one-day task date))
#+END_SRC
***** cell-print :method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: cell-print
:END:
#+BEGIN_SRC lisp
(defgeneric cell-print (name index task date cell-data frame pane)
(:documentation "Function to determine the data of cell NAME.
NAME must be a keyword.
INDEX is a 1-indexed integer for the row.
TASK is the name of the task in this row, as a string.
DATE is the date, as integer seconds since the UNIX epoch.
CELL-DATA is the return value of `cell-data'
FRAME and PANE are the CLIM frame and pane as passed to the display function."))
(defmethod cell-print ((name (eql :index))
(index integer) (task string) (date integer)
cell-data-index
frame pane)
(format t "~2@A" cell-data-index))
(defmethod cell-print ((name (eql :task))
(index integer) (task string) (date integer)
cell-data-task
frame pane)
(with-output-as-presentation (pane cell-data-task 'task-name)
(format t "~A" cell-data-task)))
(defmethod cell-print ((name (eql :duration))
(index integer) (task string) (date integer)
duration
frame pane)
(with-output-as-presentation (pane duration 'number)
2022-05-03 05:13:50 +00:00
(if (zerop duration)
(format t "~10@A" "-")
(format-seconds t *duration-format-string* duration))))
#+END_SRC
***** *task-duration-table-spec* :custom:variable:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
2022-05-03 05:13:50 +00:00
:CUSTOM_ID: task-duration-table-spec
2022-04-27 10:56:26 +00:00
:END:
#+BEGIN_SRC lisp
(defvar *task-duration-table-spec*
(loop for (keyword . string) in '((:index . "#")
(:task . "Task")
(:duration . "Time")
;; (:activity . "Active")
)
collect (make-column-specifier keyword string))
"List of table `column-specifier' instances.")
#+END_SRC
***** task-duration-table-function :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: task-duration-table-function
:END:
#+BEGIN_SRC lisp
(defun task-duration-table-function (table-specification)
"Return a table (a list of lists) based on TABLE-SPECIFICATION."
2022-04-22 17:00:13 +00:00
(loop with date = (timestamp-to-unix (today))
for task in (task-list)
for index from 0
when (zerop index)
collect (mapcar #'column-label table-specification)
2022-04-22 17:00:13 +00:00
else
collect (loop for column-spec in table-specification
collect (cell-data (column-name column-spec)
index task date))))
2022-04-11 17:12:47 +00:00
#+END_SRC
**** display
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: display
:END:
2022-04-11 17:12:47 +00:00
***** display :function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: display-1
:END:
2022-04-11 17:12:47 +00:00
#+BEGIN_SRC lisp
(defun display (frame stream)
(display-pane frame stream (pane-name stream)))
#+END_SRC
***** task-name :class:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: task-name
:END:
2022-04-11 17:12:47 +00:00
#+BEGIN_SRC lisp
(defclass task-name () ())
#+END_SRC
***** display-pane :generic:function:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: display-pane
:END:
2022-04-11 17:12:47 +00:00
#+BEGIN_SRC lisp
(defgeneric display-pane (frame stream pane-name)
(:documentation "Display a Chronometrist application pane."))
2022-04-08 03:41:46 +00:00
#+END_SRC
2022-04-11 17:12:47 +00:00
***** display-pane :method:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: display-pane-1
:END:
2022-04-08 03:41:46 +00:00
#+BEGIN_SRC lisp
2022-04-11 17:12:47 +00:00
(defmethod display-pane (frame pane (pane-name (eql 'task-duration)))
"Display the task-duration pane, using `*task-duration-table-spec*'."
2022-04-11 17:12:47 +00:00
(declare (ignorable frame pane pane-name))
2022-04-08 03:41:46 +00:00
;; (format *debug-io* "*application-frame*: ~a~%" *application-frame*)
(let ((stream *standard-output*))
2022-04-08 06:11:50 +00:00
(formatting-table (stream)
2022-04-22 17:00:13 +00:00
(loop
with date = (timestamp-to-unix (today))
for task in (task-list)
for row in (task-duration-table-function *task-duration-table-spec*)
2022-04-22 17:00:13 +00:00
for index from 0 do
(formatting-row (stream)
(if (zerop index)
(loop for string in row
do (formatting-cell (stream)
(format t "~A" string)))
(loop for data in row
for col-spec in *task-duration-table-spec*
do (formatting-cell (stream)
(cell-print (column-name col-spec)
index task date data frame pane)))))))))
2022-04-08 06:11:50 +00:00
#+END_SRC
**** refresh :command:
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: refresh
:END:
2022-04-08 06:11:50 +00:00
#+BEGIN_SRC lisp
(define-chronometrist-command (com-refresh :name t) ())
2022-04-08 03:41:46 +00:00
#+END_SRC
**** run-chronometrist
2022-04-27 10:56:26 +00:00
:PROPERTIES:
:CUSTOM_ID: run-chronometrist
:END:
2022-04-08 03:41:46 +00:00
#+BEGIN_SRC lisp
(defun run-chronometrist (&optional dir)
(run-frame-top-level (make-application-frame 'chronometrist)))
#+END_SRC