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 )