App-TimeTracker/lib/App/TimeTracker/Command/Core.pm
package App::TimeTracker::Command::Core;
# ABSTRACT: App::TimeTracker Core commands
our $VERSION = '3.010'; # VERSION
use strict;
use warnings;
use 5.010;
use Moose::Role;
use Moose::Util::TypeConstraints;
use App::TimeTracker::Utils qw(now pretty_date error_message);
use App::TimeTracker::Constants qw(MISSING_PROJECT_HELP_MSG);
use File::Copy qw(move);
use File::Find::Rule;
use Data::Dumper;
use Text::Table;
use DateTime;
sub cmd_start {
my $self = shift;
unless ( $self->has_current_project ) {
error_message( MISSING_PROJECT_HELP_MSG );
exit;
}
$self->cmd_stop('no_exit');
my $task = App::TimeTracker::Data::Task->new(
{ start => $self->at || now(),
project => $self->project,
tags => $self->tags,
description => $self->description,
}
);
$self->_current_task($task);
$task->do_start( $self->home );
}
sub cmd_stop {
my ( $self, $dont_exit ) = @_;
my $task = App::TimeTracker::Data::Task->current( $self->home );
unless ($task) {
return if $dont_exit;
say "Currently not working on anything";
exit;
}
my $proto = App::TimeTracker::Proto->new();
my $config = $proto->load_config( undef, $task->project );
my $class = $proto->setup_class( $config, 'stop' );
my $stop_self = $class->name->new(
{ home => $self->home,
at => $self->at || now(),
config => $config,
_current_project => $task->project,
}
);
$stop_self->_current_command('cmd_stop');
$stop_self->_previous_task($task);
# Store in original self too (for plugin usage)
$self->_previous_task($task);
$task->stop( $stop_self->at || now() );
if ( $task->stop < $task->start ) {
say sprintf(
qq{The stop time you specified (%s) is earlier than the start time (%s).\nThis makes no sense.},
$task->stop, $task->start );
my $what_you_meant = $task->stop->clone;
for ( 1 .. 5 ) {
$what_you_meant->add( days => 1 );
last if $what_you_meant > $task->start;
}
if ( $what_you_meant ne $task->start ) {
say "Maybe you wanted to do:\ntracker stop --at '"
. $what_you_meant->strftime('%Y-%m-%d %H:%M') . "'";
}
else {
say
"Maybe it helps if you use the long format to specify the stop time ('2012-01-10 00:15')?";
}
exit;
}
$task->save( $stop_self->home );
move(
$stop_self->home->file('current')->stringify,
$stop_self->home->file('previous')->stringify
);
say "Worked " . $task->duration . " on " . $task->say_project_tags;
}
sub cmd_current {
my $self = shift;
if ( my $task = App::TimeTracker::Data::Task->current( $self->home ) ) {
say "Working "
. $task->_calc_duration( now() ) . " on "
. $task->say_project_tags;
say 'Started at ' . pretty_date( $task->start );
}
elsif ( my $prev = App::TimeTracker::Data::Task->previous( $self->home ) ) {
say
"Currently not working on anything, but the last thing you worked on was:";
say $prev->say_project_tags;
say 'Worked '
. $prev->rounded_minutes
. ' minutes from '
. pretty_date( $prev->start )
. ' till '
. pretty_date( $prev->stop );
}
else {
say
"Currently not working on anything, and I have no idea what you worked on earlier...";
}
}
sub cmd_append {
my $self = shift;
if ( my $task = App::TimeTracker::Data::Task->current( $self->home ) ) {
say "Cannot 'append', you're actually already working on :"
. $task->say_project_tags . "\n";
}
elsif ( my $prev = App::TimeTracker::Data::Task->previous( $self->home ) ) {
my $task = App::TimeTracker::Data::Task->new(
{ start => $prev->stop,
project => $self->project,
tags => $self->tags,
}
);
$self->_current_task($task);
$task->do_start( $self->home );
}
else {
say
"Currently not working on anything and I have no idea what you've been doing.";
}
}
sub cmd_continue {
my $self = shift;
if ( my $task = App::TimeTracker::Data::Task->current( $self->home ) ) {
say "Cannot 'continue', you're working on something:\n"
. $task->say_project_tags;
}
elsif ( my $prev = App::TimeTracker::Data::Task->previous( $self->home ) ) {
my $task = App::TimeTracker::Data::Task->new(
{ start => $self->at || now(),
project => $prev->project,
tags => $prev->tags,
}
);
$self->_current_task($task);
$task->do_start( $self->home );
}
else {
say
"Currently not working on anything, and I have no idea what you worked on earlier...";
}
}
sub cmd_worked {
my $self = shift;
my @files = $self->find_task_files(
{ from => $self->from,
to => $self->to,
projects => $self->fprojects,
tags => $self->ftags,
parent => $self->fparent,
}
);
my $total = 0;
foreach my $file (@files) {
my $task = App::TimeTracker::Data::Task->load( $file->stringify );
$total += $task->seconds // $task->_build_seconds;
}
say $self->beautify_seconds($total);
}
my %LIST_FORMATS = (
compact => [qw(project tag time)],
content => [qw(project tag description)],
medium => [qw(project tag time description)],
default => [qw(project tag duration start stop)],
all => [qw(project tag duration start stop seconds description file)],
long => [qw( project tag duration start stop description)],
);
sub cmd_list {
my $self = shift;
my @files = $self->find_task_files(
{ from => $self->from,
to => $self->to,
projects => $self->fprojects,
tags => $self->ftags,
parent => $self->fparent,
}
);
my $s = \' | ';
my $format =
$self->detail ? 'all' : $self->output ? $self->output : 'default';
my $selected_fields = $LIST_FORMATS{$format} || $LIST_FORMATS{default};
my %fields = map { $_ => 1 } @$selected_fields;
my @header = map { ucfirst($_), $s } @$selected_fields;
pop(@header);
my $table = Text::Table->new(@header);
my $total = 0;
foreach my $file (@files) {
my $task = App::TimeTracker::Data::Task->load( $file->stringify );
my $time = $task->seconds // $task->_build_seconds;
$total += $time;
my @row;
push( @row, $task->project ) if $fields{project};
push( @row, join( ', ', @{ $task->tags } ) || ' ' ) if $fields{tag};
push( @row, $task->duration || 'working' ) if $fields{duration};
push( @row, pretty_date( $task->start ) ) if $fields{start};
push( @row, pretty_date( $task->stop ) ) if $fields{stop};
push( @row, $task->compact_time ) if $fields{time};
push( @row, $time ) if $fields{seconds};
push( @row, $task->description_short || ' ' ) if $fields{description};
push( @row, $file->stringify ) if $fields{file};
$table->add(@row);
}
print $table->title;
print $table->rule( '-', '+' );
print $table->body;
say "total " . $self->beautify_seconds($total);
}
sub cmd_report {
my $self = shift;
my @files = $self->find_task_files(
{ from => $self->from,
to => $self->to,
projects => $self->fprojects,
tags => $self->ftags,
parent => $self->fparent,
}
);
my $total = 0;
my $report = {};
my $format = "%- 20s % 12s\n";
my $projects = $self->project_tree;
foreach my $file (@files) {
my $task = App::TimeTracker::Data::Task->load( $file->stringify );
my $time = $task->seconds // $task->_build_seconds;
my $project = $task->project;
if ( $time >= 60 * 60 * 8 ) {
say "Found dubious trackfile: " . $file->stringify;
say " Are you sure you worked "
. $self->beautify_seconds($time)
. " on one task?";
}
$total += $time;
if ( $self->group eq 'week' ) {
my $week_num = $task->start->week_number;
$report->{$week_num}{'_total'} += $time;
$report->{$week_num}{$project}{time} += $time;
unless ($report->{$week_num}{'_start'}) {
$report->{$week_num}{'_start'} = $self->_first_day_of_week(
$task->start->year, $task->start->week_number
)->dmy('.');
my $end = $task->start->add( { days => 6, hours=>23, minutes=>59 } );
$report->{$week_num}{'_end'} =
$self->_first_day_of_week( $end->year, $end->week_number )->dmy('.')
}
}
if ( $self->group eq 'project' ) {
$report->{$project}{'_total'} += $time;
if ( my $level = $self->detail ) {
my $detail = $task->get_detail($level);
my $tags = $detail->{tags};
if ( $tags && @$tags ) {
# Only use the first assigned tag to calculate the aggregated times and use it
# as tag key.
# Otherwise the same trackfiles would be counted multiple times and the
# aggregated sums would not match up.
$report->{$project}{ $tags->[0] }{time} += $time;
foreach my $tag (@$tags) {
$report->{$project}{ $tags->[0] }{desc} //= '';
if ( my $desc = $detail->{desc} ) {
$report->{$project}{ $tags->[0] }{desc} .= $desc
. "\n"
if
index( $report->{$project}{ $tags->[0] }{desc},
$desc ) == -1;
}
}
}
else {
$report->{$project}{'_untagged'} += $time;
}
}
}
}
if ( $self->group eq 'project' ) {
# sum child-time to all ancestors
my %top_nodes;
foreach my $project ( sort keys %$report ) {
my @ancestors;
$self->_get_ancestors( $report, $projects, $project, \@ancestors );
my $time = $report->{$project}{'_total'} || 0;
foreach my $ancestor (@ancestors) {
$report->{$ancestor}{'_kids'} += $time;
}
$top_nodes{ $ancestors[0] }++ if @ancestors;
$top_nodes{$project}++ if !@ancestors;
}
$self->_say_current_report_interval;
my $padding = '';
my $tagpadding = ' ';
foreach my $project ( sort keys %top_nodes ) {
$self->_print_report_tree( $report, $projects, $project, $padding,
$tagpadding );
}
}
if ( $self->group eq 'week' ) {
my $s = \' | ';
my @header = map { ucfirst($_), $s } qw(week start end time);
pop(@header);
my $table = Text::Table->new(@header);
foreach my $week ( sort keys %$report ) {
my @row;
push @row, $week;
push @row, $report->{$week}->{_start};
push @row, $report->{$week}->{_end};
push @row, $self->beautify_seconds( $report->{$week}->{_total} );
$table->add(@row);
}
print $table->title;
print $table->rule( '-', '+' );
print $table->body;
}
printf( $format, 'total', $self->beautify_seconds($total) );
}
sub _get_ancestors {
my ( $self, $report, $projects, $node, $ancestors ) = @_;
my $parent = $projects->{$node}{parent};
if ($parent) {
unshift( @$ancestors, $parent );
$self->_get_ancestors( $report, $projects, $parent, $ancestors );
}
}
sub _first_day_of_week {
my ( $self, $year, $week ) = @_;
# Week 1 is defined as the one containing January 4:
return DateTime->new( year => $year, month => 1, day => 4 )
->add( weeks => ( $week - 1 ) )->truncate( to => 'week' );
}
sub _print_report_tree {
my ( $self, $report, $projects, $project, $padding, $tagpadding ) = @_;
my $data = $report->{$project};
my $sum = 0;
$sum += $data->{'_total'} if $data->{'_total'};
$sum += $data->{'_kids'} if $data->{'_kids'};
return unless $sum;
my $format = "%- 20s % 12s";
say sprintf(
$padding . $format,
substr( $project, 0, 20 ),
$self->beautify_seconds($sum)
);
if ( my $detail = $self->detail ) {
say sprintf( $padding . $tagpadding . $format,
'untagged', $self->beautify_seconds( delete $data->{'_untagged'} ) )
if $data->{'_untagged'};
foreach my $tag (
sort { $data->{$b}->{time} <=> $data->{$a}->{time} }
grep {/^[^_]/} keys %{$data}
) {
my $time = $data->{$tag}{time};
if ( $detail eq 'description' ) {
my $desc = $data->{$tag}{desc} || 'no desc';
$desc =~ s/\s+$//;
$desc =~ s/\v/, /g;
say sprintf( $padding . $tagpadding . $format . ' %s',
$tag, $self->beautify_seconds($time), $desc );
}
elsif ( $detail eq 'tag' ) {
say sprintf( $padding . $tagpadding . $format,
$tag, $self->beautify_seconds($time) );
}
}
}
foreach my $child ( sort keys %{ $projects->{$project}{children} } ) {
$self->_print_report_tree( $report, $projects, $child,
$padding . ' ', $tagpadding );
}
}
sub cmd_recalc_trackfile {
my $self = shift;
my $file = $self->trackfile;
unless ( -e $file ) {
$file =~ /(?<year>\d\d\d\d)(?<month>\d\d)\d\d-\d{6}_\w+\.trc/;
if ( $+{year} && $+{month} ) {
$file = $self->home->file( $+{year}, $+{month}, $file )->stringify;
unless ( -e $file ) {
error_message( "Cannot find file %s", $self->trackfile );
exit;
}
}
}
my $task = App::TimeTracker::Data::Task->load($file);
$task->save( $self->home );
say "recalced $file";
}
sub cmd_show_config {
my $self = shift;
warn Data::Dumper::Dumper $self->config;
}
sub cmd_init {
my ( $self, $cwd ) = @_;
$cwd ||= Path::Class::Dir->new->absolute;
if ( -e $cwd->file('.tracker.json') ) {
error_message(
"This directory is already set up.\nTry 'tracker show_config' to see the current aggregated config."
);
exit;
}
my $project;
if( $self->has_current_project ) {
$project = $self->_current_project;
} else {
my @dirs = $cwd->dir_list;
$project = $dirs[-1];
}
my $fh = $cwd->file('.tracker.json')->openw;
say $fh <<EOCONFIG;
{
"project":"$project"
}
EOCONFIG
my $projects_file = $self->home->file('projects.json');
my $coder = JSON::XS->new->utf8->pretty->canonical->relaxed;
if ( -e $projects_file ) {
my $projects = $coder->decode( scalar $projects_file->slurp );
$projects->{$project} =
$cwd->file('.tracker.json')->absolute->stringify;
$projects_file->spew( $coder->encode($projects) );
}
say "Set up this directory for time-tracking via file .tracker.json";
}
sub cmd_plugins {
my $self = shift;
my $base =
Path::Class::file( $INC{'App/TimeTracker/Command/Core.pm'} )->parent;
my @hits;
while ( my $file = $base->next ) {
next unless -f $file;
next if $file->basename eq 'Core.pm';
my $plugin = $file->basename;
$plugin =~ s/\.pm$//;
push( @hits, $plugin );
}
say "Installed plugins:\n " . join( ', ', @hits );
}
sub cmd_version {
my $self = shift;
say "This is App::TimeTracker, version " . App::TimeTracker->VERSION;
exit;
}
sub cmd_commands {
my $self = shift;
my @commands;
foreach my $method ( $self->meta->get_all_method_names ) {
next unless $method =~ /^cmd_/;
$method =~ s/^cmd_//;
push( @commands, $method );
}
@commands = sort @commands;
if ( $self->can('autocomplete')
&& $self->autocomplete ) {
say join( ' ', @commands );
}
else {
say "Available commands:";
foreach my $command (@commands) {
say "\t$command";
}
}
exit;
}
sub _load_attribs_worked {
my ( $class, $meta ) = @_;
$meta->add_attribute(
'from' => {
isa => 'TT::DateTime',
is => 'ro',
coerce => 1,
lazy_build => 1,
#cmd_aliases => [qw/start/],
documentation => 'Beginning of time period to report',
}
);
$meta->add_attribute(
'to' => {
isa => 'TT::DateTime',
is => 'ro',
coerce => 1,
#cmd_aliases => [qw/end/],
lazy_build => 1,
documentation => 'End of time period to report',
}
);
$meta->add_attribute(
'this' => {
isa => 'TT::Duration',
is => 'ro',
documentation =>
'Filter by current time period [day|week|month|year], e.g. "--this day" => today',
}
);
$meta->add_attribute(
'last' => {
isa => 'TT::Duration',
is => 'ro',
documentation =>
'Filter by previous time period [day|week|month|year], e.g. "--last day" => yesterday',
}
);
$meta->add_attribute(
'fprojects' => {
isa => 'ArrayRef',
is => 'ro',
documentation => 'Filter by project',
}
);
$meta->add_attribute(
'ftags' => {
isa => 'ArrayRef',
is => 'ro',
documentation => 'Filter by tag',
}
);
$meta->add_attribute(
'fparent' => {
isa => 'Str',
is => 'ro',
documentation => 'Filter by parent (get all children)',
}
);
}
sub _load_attribs_commands {
my ( $class, $meta ) = @_;
$meta->add_attribute(
'autocomplete' => {
isa => 'Bool',
is => 'ro',
default => 0,
documentation => 'Output for autocomplete',
}
);
}
sub _load_attribs_list {
my ( $class, $meta ) = @_;
$class->_load_attribs_worked($meta);
$meta->add_attribute(
'detail' => {
isa => 'Bool',
is => 'ro',
default => 0,
documentation => 'Show all fields',
}
);
$meta->add_attribute(
'output' => {
isa => 'Str',
is => 'ro',
documentation => 'Specify output format. One of: '
. join( ', ', sort keys %LIST_FORMATS ),
}
);
}
sub _load_attribs_report {
my ( $class, $meta ) = @_;
$class->_load_attribs_worked($meta);
$meta->add_attribute(
'detail' => {
isa => enum( [qw(tag description)] ),
is => 'ro',
documentation => 'Be detailed: [tag|description]',
}
);
$meta->add_attribute(
'group' => {
isa => enum( [qw(project week)] ),
is => 'ro',
default => 'project',
documentation => 'Genereta Report by week or project.'
}
);
}
sub _load_attribs_start {
my ( $class, $meta ) = @_;
$meta->add_attribute(
'at' => {
isa => 'TT::DateTime',
is => 'ro',
coerce => 1,
documentation => 'Start at',
}
);
$meta->add_attribute(
'project' => {
isa => 'Str',
is => 'ro',
documentation => 'Project name',
lazy_build => 1,
}
);
$meta->add_attribute(
'description' => {
isa => 'Str',
is => 'rw',
documentation => 'Description',
}
);
}
sub _build_project {
my $self = shift;
return $self->_current_project;
}
*_load_attribs_append = \&_load_attribs_start;
*_load_attribs_continue = \&_load_attribs_start;
*_load_attribs_stop = \&_load_attribs_start;
sub _load_attribs_recalc_trackfile {
my ( $class, $meta ) = @_;
$meta->add_attribute(
'trackfile' => {
isa => 'Str',
is => 'ro',
required => 1,
}
);
}
sub _load_attribs_init {
my ( $class, $meta ) = @_;
$meta->add_attribute(
'project' => {
isa => 'Str',
is => 'ro',
documentation => 'Project name to initialize',
lazy_build => 1,
}
);
}
sub _build_from {
my $self = shift;
if ( my $last = $self->last ) {
return now()->truncate( to => $last )->subtract( $last . 's' => 1 );
}
elsif ( my $this = $self->this ) {
return now()->truncate( to => $this );
}
else {
return now()->truncate( to => 'month' );
}
}
sub _build_to {
my $self = shift;
if ( my $date = $self->this || $self->last ) {
return $self->from->clone->add( $date . 's' => 1 )
->subtract( seconds => 1 );
}
else {
return now();
}
}
sub _say_current_report_interval {
my $self = shift;
printf( "From %s to %s you worked on:\n", $self->from, $self->to );
}
no Moose::Role;
q{ listening to: Train noises on my way from Wien to Graz }
__END__
=pod
=encoding UTF-8
=head1 NAME
App::TimeTracker::Command::Core - App::TimeTracker Core commands
=head1 VERSION
version 3.010
=head1 CORE COMMANDS
More commands are implemented in various plugins. Plugins might also alter and/or amend commands.
=head2 start
~/perl/Your-Project$ tracker start
Started working on Your-Project at 23:44:19
Start tracking the current project now. Automatically stop the previous task, if there was one.
=head3 Options:
=head4 --at TT::DateTime
~/perl/Your-Project$ tracker start --at 12:42
~/perl/Your-Project$ tracker start --at '2011-02-26 12:42'
Start at the specified time/datetime instead of now. If only a time is
provided, the day defaults to today. See L<TT::DateTime> in L<App::TimeTracker>.
=head4 --project SomeProject
~/perl/Your-Project$ tracker start --project SomeProject
Use the specified project instead of the one determined by the current
working directory.
=head4 --description 'some prosa'
~/perl/Your-Project$ tracker start --description "Solving nasty bug"
Supply some descriptive text to the task. Might be used by reporting plugins etc.
=head4 --tags RT1234 [Multiple]
~/perl/Your-Project$ tracker start --tag RT1234 --tag testing
A list of tags to add to the task. Can be used by reporting plugins.
=head2 stop
~/perl/Your-Project$ tracker stop
Worked 00:20:50 on Your-Project
Stop tracking the current project now.
=head3 Options
=head4 --at TT::DateTime
Stop at the specified time/datetime instead of now.
=head2 continue
~/perl/Your-Project$ tracker continue
Continue working on the previous task after a break.
Example:
~$ tracker start --project ExplainContinue --tag testing
Started working on ExplainContinue (testing) at 12:42
# ... time passes, it's now 13:17
~$ tracker stop
Worked 00:35:00 on ExplainContinue
# back from lunch at 13:58
~$ tracker continue
Started working on ExplainContinue (testing) at 13:58
=head3 Options:
same as L<start|/start>
=head2 append
~/perl/Your-Project$ tracker append
Start working on a task at exactly the time you stopped working at the previous task.
Example:
~$ tracker start --project ExplainAppend --tag RT1234
Started working on ExplainAppend (RT1234) at 14:23
# ... time passes (14:46)
~$ tracker stop
Worked 00:23:00 on ExplainAppend (RT1234)
# start working on new ticket
# ...
# but forgot to hit start (14:53)
~$ tracker append --tag RT7890
Started working on ExplainAppend (RT7890) at 14:46
=head3 Options:
same as L<start|/start>
=head2 current
~/perl/Your-Project$ tracker current
Working 00:20:17 on Your-Project
Display what you're currently working on, and for how long.
=head3 No options
=head2 worked
~/perl/Your-Project$ tracker worked [SPAN]
Report the total time worked in the given time span, maybe limited to
some projects.
=head3 Options:
=head4 --from TT::DateTime [REQUIRED (or use --this/--last)]
Begin of reporting interval, defaults to first day of current month.
=head4 --to TT::DateTime [REQUIRED (or use --this/--last)]
End of reporting interval, default to DateTime->now.
=head4 --this [day, week, month, year]
Automatically set C<--from> and C<--to> to the calculated values
~/perl/Your-Project$ tracker worked --this week
17:01:50
=head4 --last [day, week, month, year]
Automatically set C<--from> and C<--to> to the calculated values
~/perl/Your-Project$ tracker worked --last day (=yesterday)
06:39:12
=head4 --project SomeProject [Multiple]
~$ tracker worked --last day --project SomeProject
02:04:47
=head2 report
~/perl/Your-Project$ tracker report
Print out a detailed report of what you did. All worked times are
summed up per project (and optionally per tag)
=head3 Options:
The same options as for L<worked|/worked>, plus:
=head4 --detail
~/perl/Your-Project$ tracker report --last month --detail tag
Valid options are: tag, description
Will print the tag(s) and/or description.
Also calc sums per tag.
=head4 --verbose
~/perl/Your-Project$ tracker report --last month --verbose
Lists all found trackfiles and their respective duration before printing out the report.
=head2 list
~/perl/Your-Project$ tracker list
Print out a detailed report of what you did in a tabular format including start and stop
times.
=head3 Options:
The same options as for L<report|/report>
=head2 init
~/perl/Your-Project$ tracker init
Initialize current directory as a project in which to track time. This step
is required before one can use time tracking commands such as
L<start|/start> and L<stop|/stop>. The initialization step creates a rather
empty F<.tracker.json> config file in the current directory.
=head3 No options
=head2 show_config
~/perl/Your-Project$ tracker show_config
Dump the config that's valid for the current directory. Might be handy when setting up plugins etc.
=head3 No options
=head2 plugins
~/perl/Your-Project$ tracker plugins
List all installed plugins (i.e. stuff in C<App::TimeTracker::Command::*>)
=head3 No options
=head2 recalc_trackfile
~/perl/Your-Project$ tracker recalc_trackfile --trackfile 20110808-232327_App_TimeTracker.trc
Recalculates the duration stored in an old trackfile. Might be useful
after a manual update in a trackfile. Might be unnecessary in the
future, as soon as task duration is always calculated lazily.
=head3 Options:
=head4 --trackfile name_of_trackfile.trc REQUIRED
Only the name of the trackfile is required, but you can also pass in
the absolute path to the file. Broken trackfiles are sometimes
reported during L<report|/report>.
=head2 commands
~/perl/Your-Project$ tracker commands
List all available commands, based on your current config.
=head3 No options
=head1 AUTHOR
Thomas Klausner <domm@plix.at>
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2011 - 2021 by Thomas Klausner.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut