pbot/lib/PBot/Core/Storage/HashObject.pm

300 lines
8.9 KiB
Perl

# File: HashObject.pm
#
# Purpose: Provides a hash-table object with an abstracted API that includes
# setting and deleting values, saving to and loading from files, etc. Provides
# case-insensitive access to the index key while preserving original case when
# displaying index key.
#
# Data is stored in working memory for lightning fast performance. If a filename
# is provided, data is written to the file after any modifications.
# SPDX-FileCopyrightText: 2010-2023 Pragmatic Software <pragma78@gmail.com>
# SPDX-License-Identifier: MIT
package PBot::Core::Storage::HashObject;
use PBot::Imports;
use Text::Levenshtein::XS qw(distance);
use JSON;
sub new {
my ($class, %args) = @_;
my $self = bless {}, $class;
Carp::croak("Missing pbot reference to " . __FILE__) unless exists $args{pbot};
$self->{pbot} = delete $args{pbot};
$self->initialize(%args);
return $self;
}
sub initialize {
my ($self, %conf) = @_;
$self->{name} = $conf{name} // 'unnammed';
$self->{hash} = {};
$self->{filename} = $conf{filename};
if (not defined $self->{filename}) {
Carp::carp("Missing filename for $self->{name} HashObject, will not be able to save to or load from file.");
}
}
sub load {
my ($self, $filename) = @_;
# allow overriding $self->{filename} with $filename parameter
$filename //= $self->{filename};
# no filename? nothing to load
if (not defined $filename) {
Carp::carp "No $self->{name} filename specified -- skipping loading from file";
return;
}
$self->{pbot}->{logger}->log("Loading $self->{name} from $filename\n");
if (not open(FILE, "< $filename")) {
$self->{pbot}->{logger}->log("Skipping loading from file: Couldn't open $filename: $!\n");
return;
}
# slurp file into $contents
my $contents = do {
local $/;
<FILE>;
};
close FILE;
eval {
# first try to deocde json, throws exception on misparse/errors
my $newhash = decode_json $contents;
# clear current hash only if decode succeeded
$self->clear;
# update internal hash
$self->{hash} = $newhash;
# update existing entries to use _name to preserve typographical casing
# e.g., when someone edits a config file by hand, they might add an
# entry with uppercase characters in its name.
foreach my $index (keys %{$self->{hash}}) {
if (not exists $self->{hash}->{$index}->{_name}) {
if ($index ne lc $index) {
if (exists $self->{hash}->{lc $index}) {
Carp::croak "Cannot update $self->{name} object $index; duplicate object found";
}
my $data = delete $self->{hash}->{$index};
$data->{_name} = $index; # _name is original typographical case
$self->{hash}->{lc $index} = $data; # index key is lowercased
}
}
}
};
if ($@) {
# json parse error or such
$self->{pbot}->{logger}->log("Warning: failed to load $filename: $@\n");
}
}
sub save {
my ($self, $filename) = @_;
# allow parameter overriding internal field
$filename //= $self->{filename};
# no filename? nothing to save
if (not defined $filename) {
Carp::carp "No $self->{name} filename specified -- skipping saving to file.\n";
return;
}
$self->{pbot}->{logger}->log("Saving $self->{name} to $filename\n");
# add update_version to metadata
if (not $self->get_data('$metadata$', 'update_version')) {
$self->add('$metadata$', { update_version => PBot::VERSION::BUILD_REVISION });
}
# ensure `name` metadata is current
$self->set('$metadata$', 'name', $self->{name}, 1);
# encode hash as JSON
my $json = JSON->new;
my $json_text = $json->pretty->canonical->utf8->encode($self->{hash});
# print JSON to file
open(FILE, "> $filename") or die "Couldn't open $filename: $!\n";
print FILE "$json_text\n";
close(FILE);
}
sub clear {
my ($self) = @_;
$self->{hash} = {};
}
sub levenshtein_matches {
my ($self, $keyword) = @_;
my @matches;
foreach my $index (sort keys %{$self->{hash}}) {
my $distance = distance($keyword, $index, 20);
next if not defined $distance;
my $length_a = length $keyword;
my $length_b = length $index;
my $length = $length_a > $length_b ? $length_a : $length_b;
if ($length != 0 && $distance / $length < 0.50) {
push @matches, $index;
}
}
return 'none' if not @matches;
my $result = join ', ', @matches;
# "a, b, c, d" -> "a, b, c or d"
$result =~ s/(.*), /$1 or /;
return $result;
}
sub set {
my ($self, $index, $key, $value, $dont_save) = @_;
my $lc_index = lc $index;
# find similarly named keys
if (not exists $self->{hash}->{$lc_index}) {
my $result = "$self->{name}: $index not found; similar matches: ";
$result .= $self->levenshtein_matches($index);
return $result;
}
if (not defined $key) {
# if no key provided, then list all keys and values
my $result = "[$self->{name}] " . $self->get_key_name($lc_index) . " keys: ";
my @entries;
foreach my $key (sort grep { $_ ne '_name' } keys %{$self->{hash}->{$lc_index}}) {
push @entries, "$key: $self->{hash}->{$lc_index}->{$key}";
}
if (@entries) {
$result .= join ";\n", @entries;
} else {
$result .= 'none';
}
return $result;
}
if (not defined $value) {
# if no value provided, then show this key's value
$value = $self->{hash}->{$lc_index}->{$key};
} else {
# otherwise update the value belonging to key
$self->{hash}->{$lc_index}->{$key} = $value;
$self->save unless $dont_save;
}
return "[$self->{name}] " . $self->get_key_name($lc_index) . ": $key " . (defined $value ? "set to $value" : "is not set.");
}
sub unset {
my ($self, $index, $key) = @_;
my $lc_index = lc $index;
if (not exists $self->{hash}->{$lc_index}) {
my $result = "$self->{name}: $index not found; similar matches: ";
$result .= $self->levenshtein_matches($index);
return $result;
}
if (defined delete $self->{hash}->{$lc_index}->{$key}) {
$self->save;
return "[$self->{name}] " . $self->get_key_name($lc_index) . ": $key unset.";
} else {
return "[$self->{name}] " . $self->get_key_name($lc_index) . ": $key does not exist.";
}
}
sub exists {
my ($self, $index, $data_index) = @_;
return exists $self->{hash}->{lc $index} if not defined $data_index;
return exists $self->{hash}->{lc $index}->{$data_index};
}
sub get_key_name {
my ($self, $index) = @_;
my $lc_index = lc $index;
return $lc_index if not exists $self->{hash}->{$lc_index};
return exists $self->{hash}->{$lc_index}->{_name} ? $self->{hash}->{$lc_index}->{_name} : $lc_index;
}
sub get_keys {
my ($self, $index) = @_;
return grep { $_ ne '$metadata$' } keys %{$self->{hash}} if not defined $index;
return grep { $_ ne '_name' } keys %{$self->{hash}->{lc $index}};
}
sub get_data {
my ($self, $index, $data_index) = @_;
my $lc_index = lc $index;
return undef if not exists $self->{hash}->{$lc_index};
return $self->{hash}->{$lc_index} if not defined $data_index;
return $self->{hash}->{$lc_index}->{$data_index};
}
sub add {
my ($self, $index, $data, $dont_save) = @_;
my $lc_index = lc $index;
# preserve case of index
if ($index ne $lc_index) {
$data->{_name} = $index;
}
$self->{hash}->{$lc_index} = $data;
$self->save unless $dont_save;
return "$index added to $self->{name}.";
}
sub remove {
my ($self, $index, $data_index, $dont_save) = @_;
my $lc_index = lc $index;
if (not exists $self->{hash}->{$lc_index}) {
my $result = "$self->{name}: $index not found; similar matches: ";
$result .= $self->levenshtein_matches($lc_index);
return $result;
}
if (defined $data_index) {
if (defined delete $self->{hash}->{$lc_index}->{$data_index}) {
delete $self->{hash}->{$lc_index} if keys(%{$self->{hash}->{$lc_index}}) == 1;
$self->save unless $dont_save;
return $self->get_key_name($lc_index) . ".$data_index removed from $self->{name}";
} else {
return "$self->{name}: " . $self->get_key_name($lc_index) . ".$data_index does not exist.";
}
}
my $data = delete $self->{hash}->{$lc_index};
if (defined $data) {
$self->save unless $dont_save;
my $name = exists $data->{_name} ? $data->{_name} : $lc_index;
return "$name removed from $self->{name}.";
} else {
return "$self->{name}: $data_index does not exist.";
}
}
1;