In this workshop, we'll create a package that makes it easier to keep a journal when using Atom.
We'll store our journal as a directory of simple text files on disk, so it's easy to open in other tools and version control with git. Our top-level journal directory will contain nested directories for each year, which will in turn contain further nested directories for each month. In each month's directory we'll have a markdown file for each day.
/my-journal
/2015
/04
13.md
14.md
15.md
Inside markdown file, each entry from the file's day will begin with its own markdown style h1 heading, containing a timestamp and an optional title.
my-journal/2015/04/13.md:
# 10:45 AM – Hanging out in Marlborough, NZ
It's a bit cloudy, but we had some fantastic wine yesterday and are enjoying the
unspoiled serenity of the New Zealand landscape. It's good to be back here...
# 8:00 PM
That was a really great day. Tomorrow I need to make some progress on my talk...
By building a handful of simple features around these conventions, we can make Atom a more convenient tool for keeping a journal. In part 1, we'll focus on making it easier to create new journal entries. Then, in part 2, we'll add a feature for browsing existing entries.
The master
branch of this repository contains all the code we'll be writing. So before we start, let's clone it, install it into Atom, and try it out in its completed form. Then we'll reset back to the beginning and walk through how to build it.
First, let's clone the repository and apm link
it so that Atom loads it as a package.
git clone https://github.com/nathansobo/journal.git
cd journal
apm link
Now any new Atom window should have our package installed and active. Existing windows will need to be reloaded by selecting View > Reload
from the application menu. You can check that it's loaded by opening the command palette with ctrl-shift-P
and typing journal
. You should see a Journal: New Entry
command and a Journal: List Entries
command.
Now we'll create a branch and reset it to a much earlier state in the project so we can build the journal package out together.
git checkout -b workshop
git reset --hard part0
Every part of this workshop is represented as a tagged commit in this git repository, which I'll be referencing throughout this guide. If you ever get off track want to jump to a particular state of the project, you can use git reset --hard <tag>
on your workshop
branch to blow away all your current state and jump straight there.
In part 1, we'll be implementing the journal:new-entry
command, accessible from the command palette. This will automatically open a markdown file with a path based on the current date and insert a heading based on the current time.
In this step, we add an integration test for our command on the main module of our package.
- Before the package activates, we assign a
journal.path
configuration variable to point at our fixtures directory. - We build a Date object frozen at a particular point in time and mock the
getCurrentDate
method on the main module to return it. This method will be used by the implementation so we can control the time for testing purposes. - We dispatch the command on the workspace element.
- We wait for the dispatched command to cause an editor to be opened to the expected path. Since the command is asynchronous, we have to wait for this condition rather than asserting it.
If we run package specs with View > Developer > Run Package Specs
, our spec should wait 5 seconds and then fail.
In this step, we add a schema for the configuration variable we assigned in the spec, journal.path
.
Now we get part of the way to making the test pass.
- We create a
Journal
class to represent our journal on disk. It has acreateNewEntry
method that we leave unimplemented with some logging, and agetPath
method that pulls from the configuration variable we just defined. - We require our new
Journal
class in the main module of the package. When the package is activated, we add a new command that calls thecreateNewEntry
method on the journal instance. - We add a
getCurrentDate
method, which just constructs and returns a date object. It is used by our command implementation so we can easily control the date in our tests.
Once we complete this step, we should be able to try out our journal:new-entry
command after reloading the current Atom window. If we open the developer tools with View > Developer > Toggle Developer Tools
, we should see our log statements when we run the command from the command palette.
Now we replace the logging with some real code.
- We call
atom.workspace.open
with a path that we construct based on the date in theentryPathForDate
method. - In
entryPathForDate
, we pad single-digit months and days with leading zeroes, then combine them with the journal path from the configuration to construct a path to the today's entry.
If we run package specs with View > Developer > Run Package Specs
they will now pass.
Now we extend our test to expect an entry heading based on the time to be inserted.
- We add a
runs
block, which won't run until ourwaitsFor
condition is satisfied. - Inside the block, we assert that the first line of the opened editor has a heading and that the cursor is two lines below the heading at the beginning of the line, ready to type an entry.
If you run this test, it should fail.
Now we update our implementation to insert a heading.
- The
workspace.open()
call returns aPromise
object. We add a resolution callback to this promise withthen
that inserts a heading followed by two newlines into the opened editor. - We add a new
formattedTimeForDate
method that switches our time to a 12 hour clock and zero-pads the minutes.
The test should now pass.
Our current code works well for the first entry of the day, but falls down when we want to start adding multiple entries. Here we add some tests and make them pass in the same commit.
Starting at the bottom, in the test:
- We add an
editor
variable to the top of the test so we can share it acrosswaitsFor
andruns
blocks. - We insert some text in the first entry, then move the cursor to the middle of the inserted text to simulate something that could occur in real life: The cursor doesn't always get left at the end of the last entry.
- Then we advance our fake clock and dispatch the command again.
- We expect our new heading to be inserted on row 4.
If you run this test now, it should time out because we don't insert the heading cleanly.
The current implementation inserts times correctly, but it doesn't give us a chance to type an entry title. We can use the snippets package to create a slick workflow around inserting an optional title. Let's start with the tests:
- In the
beforeEach
, we also wait to activate thesnippets
package. - We change the expectations to expect a
–
separator to be inserted after the time. - We dispatch
snippets:next-tab-stop
commands on the editor to test our tab stop locations.
Now the implementation.
- Whenever we use one package from another, we do so via a semantically versioned service. We declare our use of the
snippets
service in theconsumedServices
section of thepackage.json
. - When we consume a service, we specify a method on our main module that will be called with the service when it becomes available. Here we wire the
snippets
service to be passed to theconsumeSnippetsService
method. - In
consumeSnippetService
, we assign the service on the journal object. Then we construct and return aDisposable
instance with a function to be called when the service is deactivated. We just clear the service on the journal object so we don't attempt to use it after its gone. - In our command implementation, we now use the
@snippetsService
instance variable if it's available, callinginsertSnippet
with a specially formatted snippet string that includes our desired tab stops.
This concludes part 1. If you want to test it out, configure a journal directory (the default is ~/journal
) and run the command. An editor should open with the correct path and a heading at the top.
Part 1 allowed us to easily create new entries. This section will focus on making it easy to browse and open existing entries.
Again, we start with a test. But this time its a unit test on our Journal
class for a new method, Journal::listEntries
. This method will return a promise that resolves with information about all current journal entries.
- We create a
fixtures
directory with some sample journal entries. - In our test, we set the journal path configuration variable to point at our
fixtures
directory, then call our method. - We make an assertion about the result of our method based on the data on disk. Note that each entry includes the
displayText
property, which is what we want to show in our listing. It also includes thefilePath
androw
for the entry.
If you run this test, it will fail.
Now we make our test pass. This feature requires us to pull in a library called scandal
as a dependency to help us scan our journal directory for every entry. To add scandal
to your package.json
and install it at the same time, run the following in your package directory on the command line:
npm install --save scandal
Now in the listEntries
method, we'll use objects from scandal
to build up a search:
- First, we build
PathScanner
andPathSearcher
instances that will be used in a call tosearch
at the end of the method. - We start with a
PathScanner
instance rooted at the journal's directory and configured with a glob pattern to only include paths matching our expected format: four digits for the year, a/
, two digits for the month, another/
, and two digits for the day followed by the.md
extension. - We don't build our
PathSearcher
with any special parameters, but do subscribe to any results it finds. We'll return to this results handler in a second. - At the bottom of the method, we return a
Promise
that only resolves once our search is completed. Inside the promise, we run a search for lines matching the format of our journal entry headings, then resolve the promise with theentries
array once the search is complete. - We build up the
entries
array as each result is found. We extract the date from thefilePath
, the time and title from thelineText
of each match. Each match fromscandal
also includes therange
of the match, which we can use to determine therow
. Sincescandal
searches in chronological order and we want our entries in reverse chronological order, weunshift
each result onty the array.
Now if you run the test from the previous step, it should pass.
This step adds a second dependency, atom-space-pen-views
, which contains a fuzzy-filtering select list we will use use to list the entries. Again, you can install it as follows:
npm install --save atom-space-pen-views
First, we add a simple test for the journal:list-entries
command. This doesn't test everything about our entry list, but covers that it basically works. Since most of the behavior is provided by a library, it should be enough coverage.
- We attach the workspace to the DOM so we can test how focus is handled.
- We dispatch the command.
- We wait for the
entryListPanel
, which we expect to save as a property on the main module, to be visible. - Then we wait for its items to be populated and assert that it is focused once this occurs.
Now, we implement the entry list:
- We start by adding an
EntryList
view class as a subclass ofSelectListView
, which we require from theatom-space-pen-views
library we just installed. TheSelectListView
implements most of the behavior we want already, but we customize it in a few ways. - We override
initialize
to callcancel
when the view loses focus. - We implement
viewForItem
to translate objects from our::listEntries
method to HTML elements. - We implement
getFilterKey
to tell the select list which property to use to filter results. - We add an empty implementation of the
confirmed
method, which handles one of the entries being selected. We'll add an implementation in the next step.
Then we add the command:
- When
journal:list-entries
is invoked, we callshowEntryList
. - This calls
createEntryList
, which builds the list if it hasn't already been constructed. We'll display the list in a modal panel, hiding the panel and refocusing the currently active pane whenever the list is cancelled. - Then, in
showEntryList
, we show the panel, invokeJournal::listEntries
to scan all the current entries, and assign items on the list when the scan operation completes.
This should be enough to pass the test. If you populate your journal directory with some entries, you should be able to try this out after reloading your Atom window.
The final step is to allow entries to be opened when they are selected.
- Again, we'll start with the tests. We extend the last test to select the second entry and confirm our selection. We then add another
waitsFor
block to wait for the entry to be opened. We then assert we're on the expected row. - The implementation is actually pretty easy. In the previously-empty
confirmed
method on ourEntryList
, we simply callatom.workspace.open
with the selected path, passing theinitialLine
option with the row number of our entry so we jump straight to it upon opening.
Congratulations. There are lots of ways this package could be enhanced, but what you've built here is useful enough to make keeping a daily journal in Atom a lot more convenient. Even better, we've explored many different techniques that will be useful in building other kinds of packages. Good luck!