ACME-2026
view release on metacpan or search on metacpan
lib/ACME/2026.pm view on Meta::CPAN
=head2 stats
my $stats = stats($plan, %filters);
Returns a hashref with C<total>, C<todo>, C<done>, C<skipped>, and
C<complete_pct>.
=cut
sub plan_new {
my %opts = _normalize_opts(@_);
my $now = _now();
my $plan = {
title => defined $opts{title} ? $opts{title} : '2026',
items => [],
next_id => 1,
created_at => $now,
updated_at => $now,
storage => $opts{storage},
autosave => $opts{autosave} ? 1 : 0,
};
return $plan;
}
sub plan_load {
my ($path, %opts) = @_;
croak 'plan_load requires a path' unless defined $path && length $path;
my $json = _read_file($path);
my $data = eval { JSON::PP->new->decode($json) };
croak "Failed to decode JSON from $path: $@" if $@;
_normalize_plan($data);
$data->{storage} = $path;
$data->{title} = $opts{title} if exists $opts{title};
$data->{autosave} = $opts{autosave} ? 1 : 0 if exists $opts{autosave};
return $data;
}
sub plan_save {
my ($plan, $path) = @_;
_ensure_plan($plan);
$path ||= $plan->{storage};
croak 'plan_save requires a path or plan storage' unless defined $path && length $path;
_normalize_plan($plan);
my $encoder = JSON::PP->new->canonical(1)->pretty(1);
my $json = $encoder->encode($plan);
_write_file_atomic($path, $json);
return 1;
}
sub add_item {
my ($plan, @args) = @_;
_ensure_plan($plan);
my ($title, %opts);
if (@args % 2 == 1) {
$title = shift @args;
%opts = @args;
} else {
%opts = @args;
$title = $opts{title};
lib/ACME/2026.pm view on Meta::CPAN
if (defined $opts{note}) {
_add_note($plan, $item, $opts{note});
} else {
_touch($plan);
}
_maybe_autosave($plan);
return $item->{id};
}
sub update_item {
my ($plan, $id, %attrs) = @_;
_ensure_plan($plan);
my $item = _find_item($plan, $id);
croak "No item with id $id" unless $item;
_reject_unknown('update_item', \%attrs, qw(title list tags tag priority due));
my $changed = 0;
for my $key (qw(title list priority due)) {
lib/ACME/2026.pm view on Meta::CPAN
return $item unless $changed;
$item->{updated_at} = _now();
_touch($plan);
_maybe_autosave($plan);
return $item;
}
sub delete_item {
my ($plan, $id) = @_;
_ensure_plan($plan);
my $items = $plan->{items};
for my $idx (0 .. $#$items) {
next unless defined $items->[$idx]{id} && $items->[$idx]{id} == $id;
my $item = splice(@$items, $idx, 1);
_touch($plan);
_maybe_autosave($plan);
return $item;
}
return;
}
sub get_item {
my ($plan, $id) = @_;
_ensure_plan($plan);
return _find_item($plan, $id);
}
sub add_note {
my ($plan, $id, $note) = @_;
_ensure_plan($plan);
croak 'add_note requires a note' unless defined $note && length $note;
my $item = _find_item($plan, $id);
croak "No item with id $id" unless $item;
_add_note($plan, $item, $note);
_maybe_autosave($plan);
return $item;
}
sub complete_item {
my ($plan, $id, %opts) = @_;
return _set_status($plan, $id, 'done', %opts);
}
sub skip_item {
my ($plan, $id, %opts) = @_;
return _set_status($plan, $id, 'skipped', %opts);
}
sub reopen_item {
my ($plan, $id, %opts) = @_;
return _set_status($plan, $id, 'todo', %opts);
}
sub items {
my ($plan, %filters) = @_;
_ensure_plan($plan);
my @items = @{ $plan->{items} || [] };
if (defined $filters{status}) {
my $status = _normalize_status($filters{status});
@items = grep { $_->{status} eq $status } @items;
}
lib/ACME/2026.pm view on Meta::CPAN
@items = grep { defined $_->{due} && $_->{due} ge $filters{due_after} } @items;
}
if (defined $filters{sort}) {
@items = _sort_items(\@items, $filters{sort});
}
return @items;
}
sub stats {
my ($plan, %filters) = @_;
_ensure_plan($plan);
my @items = items($plan, %filters);
my %stats = (
total => scalar @items,
todo => 0,
done => 0,
skipped => 0,
);
lib/ACME/2026.pm view on Meta::CPAN
$stats{ $item->{status} }++ if exists $stats{ $item->{status} };
}
$stats{complete_pct} = $stats{total}
? int(($stats{done} / $stats{total}) * 100 + 0.5)
: 0;
return \%stats;
}
sub _set_status {
my ($plan, $id, $status, %opts) = @_;
_ensure_plan($plan);
_reject_unknown('_set_status', \%opts, qw(note));
my $item = _find_item($plan, $id);
croak "No item with id $id" unless $item;
$item->{status} = _normalize_status($status);
$item->{updated_at} = _now();
if (defined $opts{note}) {
_add_note($plan, $item, $opts{note});
} else {
_touch($plan);
}
_maybe_autosave($plan);
return $item;
}
sub _normalize_opts {
return %{ $_[0] } if @_ == 1 && ref $_[0] eq 'HASH';
return @_;
}
sub _normalize_plan {
my ($plan) = @_;
_ensure_plan($plan);
$plan->{title} = '2026' unless defined $plan->{title} && length $plan->{title};
$plan->{items} = [] unless ref $plan->{items} eq 'ARRAY';
$plan->{autosave} = $plan->{autosave} ? 1 : 0;
my $max_id = 0;
for my $item (@{ $plan->{items} }) {
next unless ref $item eq 'HASH';
lib/ACME/2026.pm view on Meta::CPAN
$item->{updated_at} = $item->{created_at} unless defined $item->{updated_at};
}
$plan->{next_id} = $next_id if $next_id > $plan->{next_id};
$plan->{created_at} = _now() unless defined $plan->{created_at};
$plan->{updated_at} = $plan->{created_at} unless defined $plan->{updated_at};
return $plan;
}
sub _normalize_status {
my ($status) = @_;
$status = 'todo' if !defined $status || $status eq '';
return $status if $status eq 'todo' || $status eq 'done' || $status eq 'skipped';
croak "Unknown status '$status'";
}
sub _normalize_tags {
my ($tags, $tag) = @_;
my @tags;
if (defined $tags) {
if (ref $tags eq 'ARRAY') {
@tags = @$tags;
} else {
@tags = ($tags);
}
}
push @tags, $tag if defined $tag;
@tags = grep { defined $_ && length $_ } @tags;
return \@tags;
}
sub _normalize_notes {
my ($notes) = @_;
return [] unless defined $notes;
if (ref $notes eq 'ARRAY') {
my @out;
for my $note (@$notes) {
if (ref $note eq 'HASH') {
push @out, $note;
} else {
push @out, { note => $note };
}
}
return \@out;
}
return [ { note => $notes } ];
}
sub _ensure_plan {
my ($plan) = @_;
croak 'Plan must be a hashref' unless ref $plan eq 'HASH';
}
sub _find_item {
my ($plan, $id) = @_;
return unless defined $id;
for my $item (@{ $plan->{items} || [] }) {
next unless defined $item->{id};
return $item if $item->{id} == $id;
}
return;
}
sub _add_note {
my ($plan, $item, $note) = @_;
return unless defined $note && length $note;
push @{ $item->{notes} }, { note => $note, at => _now() };
$item->{updated_at} = _now();
_touch($plan);
}
sub _touch {
my ($plan) = @_;
$plan->{updated_at} = _now();
}
sub _maybe_autosave {
my ($plan) = @_;
return unless $plan->{autosave};
plan_save($plan);
}
sub _sort_items {
my ($items, $sort) = @_;
return @$items unless defined $sort && length $sort;
my $desc = ($sort =~ s/^-//);
if ($sort eq 'due') {
return sort {
my $ad = defined $a->{due} ? $a->{due} : ($desc ? '0000-00-00' : '9999-12-31');
my $bd = defined $b->{due} ? $b->{due} : ($desc ? '0000-00-00' : '9999-12-31');
my $cmp = $ad cmp $bd;
lib/ACME/2026.pm view on Meta::CPAN
if ($sort eq 'title') {
return sort {
my $cmp = lc($a->{title} || '') cmp lc($b->{title} || '');
$desc ? -$cmp : $cmp;
} @$items;
}
return @$items;
}
sub _reject_unknown {
my ($context, $attrs, @known) = @_;
my %known = map { $_ => 1 } @known;
my @unknown = grep { !$known{$_} } keys %$attrs;
return unless @unknown;
croak "$context does not accept: " . join(', ', sort @unknown);
}
sub _now {
return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime());
}
sub _read_file {
my ($path) = @_;
open my $fh, '<', $path or croak "Unable to read $path: $!";
local $/;
return <$fh>;
}
sub _write_file_atomic {
my ($path, $content) = @_;
my ($fh, $tmp) = tempfile('acme2026-XXXXXX', DIR => _temp_dir($path));
print {$fh} $content or croak "Unable to write $tmp: $!";
close $fh or croak "Unable to close $tmp: $!";
rename $tmp, $path or croak "Unable to move $tmp to $path: $!";
}
sub _temp_dir {
my ($path) = @_;
return '.' unless defined $path && length $path;
if ($path =~ /[\/\\]/) {
$path =~ s/[\/\\][^\/\\]+$//;
return length $path ? $path : '.';
}
return '.';
}
=head1 AUTHOR
script/acme2026 view on Meta::CPAN
#!/usr/bin/env perl
use 5.008003;
use strict;
use warnings;
use Getopt::Long qw(GetOptionsFromArray);
use ACME::2026 qw(plan_new plan_load plan_save add_item complete_item items);
sub usage {
my ($msg) = @_;
if ($msg) {
warn "$msg\n";
}
print <<'USAGE';
Usage:
acme2026 add "Title" [--list NAME] [--tag TAG ...] [--priority N] [--due YYYY-MM-DD] [--note TEXT] [--file PATH]
acme2026 complete ID [--note TEXT] [--file PATH]
acme2026 list [--status todo|done|skipped] [--list NAME] [--tag TAG ...] [--sort FIELD] [--file PATH]
Options:
--file, -f Path to JSON storage (default: 2026.json or $ACME_2026_FILE)
--sort One of: due, -due, priority, -priority, created, updated, title, -title
--help, -h Show this help
USAGE
exit($msg ? 1 : 0);
}
sub default_file {
return $ENV{ACME_2026_FILE} || '2026.json';
}
sub load_plan {
my ($path) = @_;
return -e $path ? plan_load($path) : plan_new(storage => $path);
}
my $cmd = shift @ARGV || '';
usage() if $cmd eq '' || $cmd eq 'help' || $cmd eq '--help' || $cmd eq '-h';
if ($cmd eq 'add') {
my $file = default_file();
my ($list, $priority, $due, $note, $help);
( run in 1.168 second using v1.01-cache-2.11-cpan-39bf76dae61 )