Presentation
The NoteWriter is a CLI to generate notes from files.
Users edit files in Markdown (with a few extensions). The NoteWriter parses these files to extract different objects (note, flashcard, reminder, etc.).
Code Organization
The repository also contains additional directories not directly related to the implementation:
Implementation
Core (internal/core
)
Most of the code (and most of the tests) is present in this package.
A Repository
(repository.go
) is the parent container. A repository traverses directories to find Markdown File
(file.go
). A file can contains Note
defined using Markdown headings (note.go
), some of which can be Flashcard
when using the corresponding kind (flashcard.go
), Media
resources referenced using Markdown link (media.go
), special Link
when using convention on Markdown link’s titles (link.go
), and Reminder
when using special tags (reminder.go
).
File
, Note
, Flashcard
, Media
, Link
, Reminder
represents the Object
(object.go
) managed by The NoteWriter and stored inside .nt/objects
indirectly using commits. (Blobs are also stored inside this directory.)
The method walk
defined on Repository
makes easy to find files to process (= non-ignorable Markdown files):
All objects must satisfy this interface:
This interface makes easy to factorize common logic betwen objects (ex: all objects can reference other objects and be dumped to YAML inside .nt/objects
).
Each object is uniquely defined by an OID (a 40-character string) randomly generated from a UUID (see NewOID()
), except in tests where the generation is reproducible.
Each object can be Read()
from a YAML document and Write()
to a YAML document using the common Go abstractions io.Reader
and io.Writer
.
Each object can contains SubObjects()
, for example, a file can contains notes, or Blobs()
, which are binary files generated from medias, and can references other objects through Relations()
, for example, a note can use the special attribute @references
to mention that the note is referenced elsewhere. These methods make easy for the repository to process a whole graph of objects without having the inspect their types.
These objects must also be stored in a relational database using SQLite. An additional interface must be satisfied for these objects:
These stateful objects must implement the method Save()
(which will commnly use the singleton CurrentDB()
to retrieve a connection to the database). This method will check the State()
to determine if the object must be saved using a query INSERT
, UPDATE
, or DELETE
. If no changes have been done, the method Save
must still update the value of the field LastCheckedAt
(= useful to detect dead rows in database, which are objects that are no longer present in files).
The method Refresh()
requires an object to determine if its content is still up-to-date. For example, notes can include other notes using the syntax ![[wikilink#note]]
. When a included note is edited, all notes including it must be refreshed to update their content too.
Linter internal/core/lint.go
The command nt lint
check for violations. All files are inspected (rules may have changed even if files haven’t been modified). The linter reuses the method walk
to traverse the repository. The linter doesn’t bother with stateful objects and reuses the type ParsedFile
, ParsedNote
, ParsedMedia
to find errors.
Each rule is defined using the type LintRule
:
For example, we can write a custom rule (not supported) to validate a file doesn’t contains more than 100 notes.
Each rule must be declared in the global variable LintRules
in the same file:
Media (internal/medias
)
Medias are static files included in notes using the image syntax:
When processing these medias, The NoteWriter will create blobs inside the directory .nt/objects/
. The OID is the SHA1 determined from the file content.
Images, videos, sounds are processed. Indeed, The NoteWriter will optimize these medias like this:
- Images are converted to AVIF in different sizes (preview = mobile and grid view, large = full-size view, original = original size).
- Audios are converted to MP3.
- Videos are converted to WebM and a preview image is generated from the first frame.
The AVIF, MP3, and WebM formats are used for their great compression performance and their support (including mobile devices).
By default, The NoteWriter uses the external command ffmpeg
(internal/medias/ffmpeg
) to convert and resize medias. All converters must satisfy this interface:
For example, we can draft a note including a large picture:
You can open the generated file. Ex (MacOS):
Testing
The NoteWriter works with files. Testing the application by mocking interactions with the file system would be cumbersome.
The package internal/testutil
exposes various functions to duplicate a directory that are reused by functions inside internal/core/core_test.go
to provide a complete repository of notes:
- Copy Markdown files present under
internal/core/testdata
(aka golden files). - Init a valid
.nt
directory and ensureCurrentRepository()
reads from this repository. - Return the temporary directory (automatically cleaned after the test completes)
Example (SetUpRepositoryFromGoldenDirNamed
):
Various methods exist:
SetUpRepositoryFromGoldenFile
initializes a repository containing a single file named after the test (TestCommandAdd
=>testdata/TestCommandAdd.md
).SetUpRepositoryFromGoldenFileNamed
is identical to previous function but accepts the file name.SetUpRepositoryFromGoldenDir
initializes a repository from a directory named after the test (TestCommandAdd
=>testdata/TestCommandAdd/
).SetUpRepositoryFromGoldenDirNamed
is identical to previous function but accepts the directory name.
In addition, several utilities are sometimes required to make tests reproductible:
FreezeNow()
andFreezeAt(time.Time)
ensure successive calls toclock.Now()
returns a precise timestamp.SetNextOIDs(...string)
,UseFixedOID(string)
, andUseSequenceOID()
ensure generated OIDs are deterministic (using respectively a predefined sequence of OIDs, the same OIDs, or OIDs incremented by 1).
Test helpers SetUpXXX
restore the initial configuration using t.Cleanup()
.
F.A.Q.
How to migrate SQL schema
When the method CurrentDB().Client()
is first called, the SQL database is read to initialize the connection. Then, the code uses golang-migrate
to determine if migrations (internal/core/sql/*.sql
) must be run.
How to use transactions with SQLite
Use CurrentDB().Client()
to retrieve a valid connection to the SQLite database stored in .nt/database.db
.
Sometimes, you may want to use transactions. For example, when using nt add
, if an error occurs when reading a corrupted file, we want to rollback changes to left the database intact. The DB
exposes methods BeginTransaction()
, RollbackTransaction()
, and CommitTransaction()
for this purpose. Other methods continue to use CurrentDB().Client()
to create the connection; if a transaction is currently in progress, it will be returned.
Often, the commands update the relational SQLite database and various files inside .nt
like .nt/index
. The implemented approach is to write files just after committing the SQL transaction to minimize the risk: