AGMSScriptOCron

AGMSScriptOCron is a BeOS / Haiku OS program which runs templated command lines on a schedule.

Quick Introduction

AGMSScriptOCron maintains a library of Commands which can be executed when requested or when a specified time of day arrives.

The Commands are edited using BeOS / Haiku OS scripting messages, usually sent by using the "hey" command line tool. There is also a commercial version of AGMSScriptOCron with a Graphical User Interface, if you find command lines tedious.

A specialist sets up a command line template for each Model of a Command, specifying the full command line arguments and marking out key Fields within those arguments for end-user modification. Some Models come built-in, and you can create your own.

For example, a Model that pops up an alert box with an end-user defined message would have a template something like alert --info "Message Text", and a Field named Message Text with some default text that the end-user can change to be their message.

The end-user creates their Commands by copying the Model Command and filling in the Field values with their own text, for example with AGMSScriptOCron running in the background, try this:

hey application/scriptocron get model
hey application/scriptocron create command with "name=My Alert" and model=alert
hey application/scriptocron set value of field "Message Text" of command "My Alert" to "Time to put on the tea kettle."
hey application/scriptocron set trigger of command "My Alert" to "* * * * 0,15,30,45"
hey application/scriptocron do edit of command "my alert"
Most of those do the obvious thing. The first line lists the available Models, the middle ones create a Command that displays a message every quarter of an hour, the last line "do edit" saves the changes (as compared to "delete edit" which reverts to the previously saved version). Double quotes are needed around elements with spaces.

Commands are triggered either by a cron style time specification or by BeOS/Haiku scripting operations. For example, if the end-user doesn't want to wait for the next quarter hour to happen, they can manually run it like this:

hey application/scriptocron do run of command "my alert"

The alert box will pop up with the user's message. Coincidentally, a log file will be written to "settings/AGMSScriptOCron/Logs/My Alert" with time stamped output from running the Command.

A commercial program named Fetchit! with a graphical user interface built on top of AGMSScriptOCron is available from Tune Tracker Systems. The GUI covers most end user operations (though you still need to use "hey" for advanced things like creating new Fields). It lists of all your Commands, with colour coded state and progress bars for each one. Clicking on one lets you edit the values of the Fields of the Command, set the trigger time, view logs, and so on. See http://tunetrackersystems.com/fetchit.html for details and screen-shots.

Longer Example

Here's a longer example, showing how to create your own novel Commands. In this one, we'll make a Command to rename a file.

There will be two input Fields, one with the full path name to the file, and the other with the new name. We'll use the "mv" command line tool to do it, though it won't work across different disk volumes ("copyattr -d" would be needed, since regular "cp" doesn't copy BFS attributes). First we'll change the current directory to the location of the file (it starts out somewhere useless), then do the move.

Here are the command lines you would type in:

date > /boot/home/Junk.txt
hey application/scriptocron create command with "name=Rename a File"
hey application/scriptocron let command "Rename a File" do create field with name=Path
hey application/scriptocron let command "Rename a File" do create field with "name=New Name"
hey application/scriptocron set value of field path of command "Rename a File" to "/boot/home/Junk.txt"
hey application/scriptocron set value of field "new name" of command "Rename a File" to "Trashy.txt"
hey application/scriptocron set template of command "Rename a File" to "cd \"PathAsDir\" ; pwd ; mv -v \"PathAsName\" \"New Name\""
hey application/scriptocron get template of command "Rename a File"
hey application/scriptocron get CommandLine of command "Rename a File"
hey application/scriptocron do run of command "rename a file"
hey application/scriptocron do edit of command "rename a file"
hey application/scriptocron do run of command "rename a file"
hey application/scriptocron get log of command "rename a file"
hey application/scriptocron do run of command "rename a file"
hey application/scriptocron get log of command "rename a file"

Here is what it looks like when doing it for real:

Wed Dec 30 18:04:32 605 /tmp>date > /boot/home/Junk.txt
Wed Dec 30 18:05:17 606 /tmp>hey application/scriptocron create command with "name=Rename a File"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:05:24 607 /tmp>hey application/scriptocron let command "Rename a File" do create field with name=Path
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:05:31 608 /tmp>hey application/scriptocron let command "Rename a File" do create field with "name=New Name"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:05:39 609 /tmp>hey application/scriptocron set value of field path of command "Rename a File" to "/boot/home/Junk.txt"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:05:46 610 /tmp>hey application/scriptocron set value of field "new name" of command "Rename a File" to "Trashy.txt"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:05:53 611 /tmp>hey application/scriptocron set template of command "Rename a File" to "cd \"PathAsDir\" ; pwd ; mv -v \"PathAsName\" \"New Name\""
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:06:02 612 /tmp>hey application/scriptocron get template of command "Rename a File"
Reply BMessage(B_REPLY):
   "result" (B_STRING_TYPE) : "cd "PathAsDir" ; pwd ; mv -v "PathAsName" "New Name""
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:06:12 613 /tmp>hey application/scriptocron get CommandLine of command "Rename a File"
Reply BMessage(B_REPLY):
   "result" (B_STRING_TYPE) : "cd "/boot/home/" ; pwd ; mv -v "Junk.txt" "Trashy.txt""
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:06:19 614 /tmp>hey application/scriptocron do run of command "rename a file"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : -2147483634 (0x8000000E)
   "message" (B_STRING_TYPE) : "Can't run Command named "Rename a File" because it is Editing instead of Ready to Run"

Wed Dec 30 18:06:27 615 /tmp>hey application/scriptocron do edit of command "rename a file"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:06:34 616 /tmp>hey application/scriptocron do run of command "rename a file"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:06:42 617 /tmp>hey application/scriptocron get log of command "rename a file"
Reply BMessage(B_REPLY):
   "result" (B_STRING_TYPE) : "
================================================================================

Command "Rename a File" started on Wed Dec 30 18:06:42 2015.
cd "/boot/home/" ; pwd ; mv -v "Junk.txt" "Trashy.txt"
/boot/home
Junk.txt -> Trashy.txt
Command "Rename a File" finished on Wed Dec 30 18:06:43 2015.
It provided an exit code of 0 (by convention zero means OK).
"
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:06:49 618 /tmp>hey application/scriptocron do run of command "rename a file"
Reply BMessage(B_REPLY):
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:07:00 619 /tmp>hey application/scriptocron get log of command "rename a file"
Reply BMessage(B_REPLY):
   "result" (B_STRING_TYPE) : "
================================================================================

Command "Rename a File" started on Wed Dec 30 18:06:59 2015.
cd "/boot/home/" ; pwd ; mv -v "Junk.txt" "Trashy.txt"
/boot/home
/bin/mv: Junk.txt: No such file or directory
Command "Rename a File" finished on Wed Dec 30 18:07:00 2015.
It provided an exit code of 1 (by convention zero means OK).
"
   "error" (B_INT32_TYPE) : 0 (0x00000000)

Wed Dec 30 18:07:07 620 /tmp>

List of Scripting Operations

To see what scripting operations are currently available, run AGMSScriptOCron from the command line. The ones described here are as of version 1.132. You can also use hey application/scriptocron getsuites to get the usage help text for the top level scripting operations. For other levels, you'll have to create a Command and then do hey application/scriptocron getsuites of command [0]. Similarly, hey application/scriptocron getsuites of field [0] of command [0] will tell you what Fields can do.

Application Scripting

Quit
Stop the program. Useful if it's running as a server.
Get Serial
Returns the change serial number for the Application overall. It gets increased by some small amount (may wrap around to negative values eventually) when this object or any of its children have been changed. Useful for knowing when to update a GUI displaying this object.
Count Command
Counts the number of Commands currently in the list of commands.
Create Command
Create a new Command. The Command's default name is "Unnamed". You can set a name for the new Command by adding a string named "name" to the BMessage used for scripting. If you want the new Command to be a copy of a Model Command, add a string named "model" with the name of the Model to use. If no Model is specified, you get an empty Command (no Fields, no command line, etc). The new Command starts in Edit mode, so it doesn't accidentally start running. The Hey command would look like: hey AGMSScriptOCron create Command with name=NewCommandNameHere and model=ModelNameHere
N/A Command
Specify a particular Command sub-object for further scripting. It's better to refer to Commands by name than by index, since the array is kept in alphabetical order and adding or removing Commands will affect the indices of several other Commands. The Hey command syntax would be: "hey AGMSScriptOCron get something of Command NameOfCommandHere" For indexing, it would be: "hey AGMSScriptOCron get something of Command [0]" (note the space between Command and [0]). If those don't work, try "hey AGMSScriptOCron let Command [0] do get something" which goes through a different code path to find the target object.
Get Model
Gets a list of the names of the Models, returned as an array of strings, in alphabetical order. A Model is a saved Command that can be copied to make a new Command. There are built-in ones for common tasks and ones which the user can create from existing Commands.
Create Model
Creates a Model from a Command. An extra string called "name" specifies the name of the Command to be copied into the new Model (which will have the same name). Fails if the Command doesn't exist or the name is already in use by another Model.
Delete Model
Deletes the Model specified by the name specifier. Try: "hey AGMSScriptOCron delete Model NameOfModelHere"

Command Scripting

Get Name
Gets the name of this Command.
Set Name NewValue
Changes the name of this Command. This will also affect the order of the Commands array (which is always in alphabetical order), so the index to this Command and others may change. Fails if the new name is already in use. You can change the name at any time.
Get Serial
Returns the change serial number for this Command, increased when this object or any of its children change in any way (including new log text, settings modifications, etc).
Delete Command
Delete this Command. Fails if the command is not in edit mode (running or idle). Hey syntax is a bit odd, use "hey AGMSScriptOCron let Command [3] do delete". A bit of a hack has been put in so you can use the more obvious "delete Command [3]" syntax, also "hey AGMSScriptOCron delete Command of Command [3]" does work.
Create Duplicate
Make a copy of this Command. The new name of the command is "Unnamed" unless you specify it with a string called "name". The new name must not exist, otherwise this operation will fail. Can make a copy of a Command in any state; the new one will always start in the Edit state.
Create Edit
Start editing this Command. This creates a backup copy of the Command so that it can be restored to its original state if you cancel the editing. Can only start editing if the Command isn't running or otherwise in use. Changes the Command state to being edited, which will prevent other uses of the Command (like running it) while it is being edited. Use "hey AGMSScriptOCron let Command [3] do create edit"
Delete Edit
Cancel the editing of this Command. Restores it to its original state, unless you've somehow created another command with the old name. In that rare case, you'll get an error and the Command and its backup copy are deleted rather than being restored.
Execute Edit
Finish editing the Command. Gets rid of the backup copy of the original state of the Command, and changes the Command state to being ready to run. The Hey command awkwardly uses "do" to mean execute, so it would have a double do like: hey AGMSScriptOCron let Command [0] do do Edit
Get Edit
Returns true if the Command is being edited, false otherwise.
Execute Run
Start running the Command. Fails if it is being edited or is already running. The Hey command awkwardly uses "do" to mean execute, so it would have a double do like: hey AGMSScriptOCron let Command [0] do do Run
Get Run
Returns true if the Command is running, false otherwise.
Get Template
Get the Template for building the command line used by this Command.
Set Template NewValue
Set the Template for building the command line used by this Command. Various magic words in the Template will be replaced with specific values. The most basic magic word is a Field name, which will get replaced with the Field's Value. Another is a Field name with "AsDir" appended, which uses the Value up to and including the last slash (either kind) character. Similarly, "AsName" is all the text after the last slash. "ThisCommandName" is replaced by the name of the Command.
Get CommandLine
Get the current command line for this Command. Will substitute Field names (and other related magic words) in the Template with their current Values to make the command line.
Get Trigger
Get the Trigger string used by this Command.
Set Trigger NewValue
Set the Trigger string used by this Command. A Trigger string can be several things, which control when this Command is started. If it's an empty string, the Command can only be manually started. If it's "@Launch" then the Command starts up when AGMSScriptOCron starts running (usually soon after the computer boots up, if it's configured that way). If it's a Cron style string then the Command is started whenever the current time (local time zone) matches. Finally, if it's the name of some other Command then this Command is started when that other Command finishes successfully (successful means with an exit code of zero). I might as well explain Cron style time strings here. Technically they're a modified version of the Unix crontab format (look it up online if you wish to read more). It's a way of specifying dates and times, formatted as five fields separated by white space in this order (opposite of crontab order): DayOfWeek Month DayOfMonth Hour Minute. Each field can contain a single value, a bunch of values separated by commas, a range of values using a hyphen, or be an asterisk to mean all possible values. All fields must match to activate a time trigger. So to specify the top of the hour at 2am on January 6th (years aren't mentioned, so it would happen every year at that time), it would be "* 1 6 2 0". To specify 11:30pm on work days it would be "Mon-Fri * * 23 30". For something happening every quarter hour from noon to just before 5pm (16:45 would be the last time) it would be "* * * 12-16 0,15,30,45". The sneakiest may be for things like the first Friday of the month "Fri * 1-7 0 0" which activates at midnight if the day is a Friday and in the first 7 days of the month (one of them must be a Friday?). Note that the standard Unix crontab interpretation does it differently when combining day of week and day of month. The valid range for minutes is 0 to 59, hours 0-23, days 1-31, months 1-12 (January to December), day of week 0-6 (Sunday, Monday, ..., Saturday).
Get Log
Get the accumulated log output (currently Standard Output and Standard Error combined) from the execution of this Command. The accumulated data (which is stored in memory) is also cleared at this point. The serial number is also changed whenever new log data arrives. If you are running a particularly verbose command, it's best to periodically get the log output data to avoid missing anything.
Get LogFileEnable
Returns a boolean that is TRUE if the output from this Command is being logged to a file. FALSE if it's not being filed away. There's also a "pathname" string with the pathname of the log file for this Command.
Set LogFileEnable NewValue
Turns on or off file logging of this Command's output. Takes effect only when the Command starts running, not during. The boolean "data" field needs to be set to TRUE to enable file logging, false to turn it off. Regular in-memory logging proceeds irregardless. Logged text will be appended to the file; it's up to you to delete it if it gets too big. The log file will have the same name as the Command (minus slashes) and be stored in the the program's directory in the user settings directory. Typically it will be named something like /boot/home/config/settings/AGMSScriptOCron/CommandName. If you don't want it there, replace the file with a symbolic link to a different file in a different place.
Create Field
Create a new Field inside the Command. The Command needs to be in Edit mode. The Field's default name is "Unnamed". You can set a name for the new Field by adding a string named "name" to the BMessage used for scripting. The Hey command would look like: hey AGMSScriptOCron let Command [0] do create Field with name=NewFieldNameHere
Count Field
Counts the number of Fields in the Command.
N/A Field
Specify a particular Field sub-object of a Command for further scripting. It's better to refer to Fields by name than by index, since the array is kept in alphabetical order and adding or removing Fields will affect the indices of several other Fields.

Field Scripting

Get Name
Gets the name of this Field.
Set Name NewValue
Changes the name of this Field. This will also affect the order of the Fields array (which is always in alphabetical order), so the index to this Field and others may change. Fails if the new name is already in use or the parent Command isn't in edit mode.
Get Serial
Returns the change serial number for this Field, increased when this object changes in any way (usually due to user editing).
Delete Field
Delete this Field. Fails if the parent command is not in edit mode.
Get Value
Gets the Value of the Field.
Set Value NewValue
Sets the Value of the Field. This is the user provided string that will be substituted in the command line everywhere the Field name is present.

UserData Scripting

Both Commands and Fields have UserData capability. This lets you store arbitrary data identified by a key word, and retrieve it later using the same key word.

Set UserData NewValue
Changes the value stored in the UserData entry for the specified name or at the specified index to the contents of your "data" argument(s). If you don't specify any data, then the contents become NULL and when you get it next time, the "result" field will be missing. If the entry doesn't exist and you used the name specifier, it will be created (thus the lack of a B_CREATE_PROPERTY operation). UserData is used for storing key and value pairs of user specified data. It's often used by a GUI on top of this program to store things associated with Commands (whether to use the basic or advanced GUI for a Command) and Fields (such as the data type related UI to pop up to edit a Field). The key (passed in the name specifier of the scripting operation) is a non-empty UTF-8 string, with case insensitive matching. The value can be any of the BMessage field data types (including a BMessage) and can also be an array of them.
Delete UserData
Deletes the UserData entry for the specified name or at the specified index.
Count UserData
Counts the number of UserData entries currently stored. Coincidentally and not surprisingly, valid index specifiers are from zero up to this number minus one.
Get UserData
Gets the value stored in the UserData entry for the specified name or at the specified index number (zero based, be wary of the shuffling which happens when items are added or removed - they're kept in alphabetical order). Returned in the usual "result" output, which will be missing if the stored value is NULL. Here are some standard UserData keys/values:

Description/string. Supplies a verbose description of the Command.

DisplayType/["LocalDir"|"String"|"URL"] Specifies how to display a Field in the GUI version of the program. If not specified, you get the usual String display. The LocalDir one uses a file requester to select a directory, the resulting path always ends with a slash. The URL one breaks apart the URL (only FTP and HTTP(S) ones) into all the parts and lets the user edit each part individually before reassembling the result into a new URL (with encoding of odd characters too).

GUITitles/string. Changes the title of various GUI elements used in displaying a Field like the LocalDir one from the default of "To", "Browse" and "Save files to" to be whatever the strings are. The different elements separated by "|" characters and the order of the elements is Field DisplayType specific.

OriginalModel/string. If present then the user has not edited the template (they may have changed fields etc). The original Model's name is given by the string. Later on when doing software updates, we can replace the Template of the Command with the more recent Template from the Model of the same name. When the user changes the Template, this entry is deleted.

Viewing the Settings

It's somewhat awkward to poke and prod with scripting messsages to see what's going on. You can get a quick view of what you have by looking at the settings file. It's a flattened BMessage, which you can examine with the right tools.

ViewIt

If you use ViewIt, with the "Dig Files" checkbox turned on, you can drag and drop the "AGMSScriptOCron Settings" file into it (and then select all with the keyboard and drag and drop the text into a text editor). The results look like this:

File: /boot/home/config/settings/AGMSScriptOCron/AGMSScriptOCron Settings
 BMessage file:
 > What='ASOC'
 > B_MESSAGE_TYPE        "CommandList"            
 >  | What=B_ARCHIVED_OBJECT
 >  | B_STRING_TYPE         "class"                  "Command"
 >  | B_STRING_TYPE         "_name"                  "My Alert"
 >  | B_MESSAGE_TYPE        "UserData" #1            
 >  |  | What=B_SIMPLE_DATA
 >  |  | B_STRING_TYPE         "key"                    "Description"
 >  |  | B_STRING_TYPE         "result"                 "Pops up an alert box with your message.  Set the Trigger string to be the name of some other command and this one will run when that one has finished successfully."
 >  | B_MESSAGE_TYPE        "UserData" #2            
 >  |  | What=B_SIMPLE_DATA
 >  |  | B_STRING_TYPE         "key"                    "Original Model"
 >  |  | B_STRING_TYPE         "result"                 "Alert"
 >  | B_BOOL_TYPE           "LogFileEnabled"         1
 >  | B_STRING_TYPE         "Template"               "alert --info "Message Text""
 >  | B_STRING_TYPE         "Trigger"                "* * * * 0,15,30,45"
 >  | B_MESSAGE_TYPE        "FieldList"              
 >  |  | What=B_ARCHIVED_OBJECT
 >  |  | B_STRING_TYPE         "class"                  "Field"
 >  |  | B_STRING_TYPE         "_name"                  "Message Text"
 >  |  | B_STRING_TYPE         "Value"                  "Time to put on the tea kettle."

QuickRes

QuickRes can also show you flattened BMessages. Start a new resource file in QuickRes, drag and drop the "AGMSScriptOCron Settings" file into it, then edit the resulting resource entry line changing its type from "RAWT" to "MSGG" (you need to click a few times on the RAWT to make it editable). Use the menu to Save As, and in the file requester "File Format" menu, pick "Source (.rdef)" and save it somewhere. Open the resulting file in a text editor to see something like this:

/*
** /boot/var/tmp/x
**
** Automatically generated by BResourceParser on
** Wednesday, December 30, 2015 at 16:21:50.
**
*/

#include "x.h"

resource(1, "AGMSScriptOCron Settings") message('ASOC') {
	"CommandList" = archive(, 'ARCV') Command {
		"_name" = "My Alert",
		"UserData" = message('DATA') {
			"key" = "Description",
			"result" = #'CSTR' array {
					"Pops up an alert box with your message.  Set the Trigger string "
					"to be the name of some other command and this one will run when "
					"that one has finished successfully."
				}
		},
		"UserData" = message('DATA') {
			"key" = "Original Model",
			"result" = "Alert"
		},
		"LogFileEnabled" = true,
		"Template" = "alert --info \"Message Text\"",
		"Trigger" = "* * * * 0,15,30,45",
		"FieldList" = archive(, 'ARCV') Field {
			"_name" = "Message Text",
			"Value" = "Time to put on the tea kettle."
		}
	}
};

Other Possibilities

You could hack up Haiku's "rc" or the similar "beres" and "deres" tools included in the QuickRes package to convert between .rdef text files and flattened BMessages rather than resource files.

Contact Info

If you have questions, comments, suggestions send them to me. I'm AGMS on BeShare, agmsmith@ncf.ca by e-mail. I've got a web site too where you can find more of my BeOS / Haiku software. If it has moved due to the passage of time, try searching for "Alexander G. M. Smith". My PGP key is known as 2A2CFFBB, with key fingerprint = FB3D 3722 F680 8D23 3F2F 5B5A 653F D057 2A2C FFBB.

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

Hope you find AGMSScriptOCron useful!
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v2

iQIcBAEBCAAGBQJWhHTnAAoJEGU/0FcqLP+7W6cP/2jkI/wiTw+GYt9drKuxq8MG
CzjvYSqJ0SEySTB8O1ja5GsPfxQ249xBH2pq73L0PC85Pe2RWsFDKV/m2WKmbOC9
kUSxUaSMDcyUD0854Pe8WZEsx2wU1iKnQHsP5x0mb1gXqKFSMkvAVxX7MCQOU8W2
1af7LHkS5zggOtE6fsXujrJLU+kFjdq51EXyzmBdFwBi9P6TadxP+Hj8v727SYv9
IYaNllMFDlIewxpQdCTpMNl8JPwHFyLBA5+w1ucXJlB1GsVhq1m1A3v33UYeEGB9
If4rVidtnPsddchmIc+D6OVYHe0UbbDKcYVNwODq7zHxoXXm21M7JO3eDeYGFHim
zWrO3AsxHJZ+94oTEt1ILJCTxUPzVpY1AEBh6Qdm8RbjXciaQq+T/6PWiB7lTT+A
V5jCtu+poWKft/rh/+BLJf4DnD9DF+yS4Ft91NCLcdsiVTVKzWJrsINtO8qytqL2
Rl9tEcemZvuX8u+n4jorEPxVG4saIUBkgUxBg2UHzpi9jjFwOrdn1BP65fvG9ocV
ME8JcYDMz1RL77iQcWDgZBHNirjpnoIjwFK1lemWyDyM4VQzXBT6kB3ab1Lt73Gz
xiG5l9J4YGtAnVSdfwSwyEWjbIgN3AeMfqf0GKGiHQhTBfh5nzPWsY+6QnUbalY0
FcrT1gRt4KLcnyrX4UfC
=TOlS
-----END PGP SIGNATURE-----

Copyright © 2015 by Alexander G. M. Smith.