diff --git a/applets/QuoteDB.pm b/applets/QuoteDB.pm new file mode 100644 index 00000000..97494806 --- /dev/null +++ b/applets/QuoteDB.pm @@ -0,0 +1,245 @@ +#!/usr/bin/env perl + +# SPDX-FileCopyrightText: 2021 Pragmatic Software +# SPDX-License-Identifier: MIT + +package QuoteDB; + +use v5.26; +use warnings; + +use feature 'signatures'; +no warnings 'experimental::signatures'; + +use DBI; + +my $debug = 10; + +sub new($class, %conf) { + my $self = bless {}, $class; + $self->initialize(%conf); + return $self; +} + +sub initialize($self, %conf) { + $self->{filename} = $conf{filename} // 'quotes.sqlite'; +} + +sub begin($self) { + print STDERR "Opening quotes SQLite database: $self->{filename}\n" if $debug; + + $self->{dbh} = DBI->connect("dbi:SQLite:dbname=$self->{filename}", undef, undef, + { AutoCommit => 0, AutoInactiveDestroy => 1, RaiseError => 1, PrintError => 0, sqlite_unicode => 1 }) or die $DBI::errstr; + + eval { + $self->{dbh}->do(<< 'SQL'); +CREATE TABLE IF NOT EXISTS Quotes ( + id INTEGER PRIMARY KEY, + text TEXT NOT NULL COLLATE NOCASE, + author TEXT NOT NULL COLLATE NOCASE +) +SQL + + $self->{dbh}->do(<< 'SQL'); +CREATE TABLE IF NOT EXISTS Seen ( + channel TEXT NOT NULL COLLATE NOCASE, + id INTEGER +) +SQL + }; + + die $@ if $@; +} + +sub end($self) { + print STDERR "Closing quotes SQLite database\n" if $debug; + + if(exists $self->{dbh} and defined $self->{dbh}) { + $self->{dbh}->commit(); + $self->{dbh}->disconnect(); + delete $self->{dbh}; + } +} + +sub add_quote($self, $text, $author) { + my $id = eval { + my $sth = $self->{dbh}->prepare('INSERT INTO Quotes (text, author) VALUES (?, ?)'); + $sth->bind_param(1, $text) ; + $sth->bind_param(2, $author) ; + $sth->execute(); + return $self->{dbh}->sqlite_last_insert_rowid(); + }; + + die $@ if $@; + return $id; +} + +sub get_quote($self, $id) { + my $quote = eval { + my $sth = $self->{dbh}->prepare('SELECT * FROM Quotes WHERE id == ?'); + $sth->bind_param(1, $id); + $sth->execute(); + return $sth->fetchrow_hashref(); + }; + + die $@ if $@; + return $quote; +} + +sub count_random_quote($self, $channel, $text, $author) { + # convert from regex metachars to SQL LIKE metachars + if (defined $text) { + $text =~ s/\.?\*\??/%/g; + $text =~ s/\./_/g; + } + + my $total = eval { + my $sql = 'SELECT COUNT(*) FROM Quotes'; + my @params; + my $joiner = ' WHERE'; + + if (defined $text) { + $sql .= "$joiner text LIKE ?"; + push @params, '%' . $text . '%'; + $joiner = ' AND'; + } + + if (defined $author) { + $sql .= "$joiner author LIKE ?"; + push @params, '%' . $author. '%'; + $joiner = ' AND'; + } + + my $sth = $self->{dbh}->prepare("$sql"); + $sth->execute(@params); + return $sth->fetchrow_hashref(); + }; + + die $@ if $@; + + my $remaining = eval { + my $sql = 'SELECT COUNT(*) FROM Quotes'; + my @params; + my $joiner = ' WHERE'; + + if (defined $text) { + $sql .= "$joiner text LIKE ?"; + push @params, '%' . $text . '%'; + $joiner = ' AND'; + } + + if (defined $author) { + $sql .= "$joiner author LIKE ?"; + push @params, '%' . $author. '%'; + $joiner = ' AND'; + } + + my $sth = $self->{dbh}->prepare("$sql $joiner id NOT IN (SELECT id FROM Seen WHERE channel = ?)"); + $sth->execute(@params, $channel); + return $sth->fetchrow_hashref(); + }; + + die $@ if $@; + return ($total, $remaining); +} + +sub get_random_quote($self, $channel, $text, $author) { + # convert from regex metachars to SQL LIKE metachars + if (defined $text) { + $text =~ s/\.?\*\??/%/g; + $text =~ s/\./_/g; + } + + my $quote = eval { + my $sql = 'SELECT * FROM Quotes'; + my @params; + my $joiner = ' WHERE'; + + if (defined $text) { + $sql .= "$joiner text LIKE ?"; + push @params, '%' . $text . '%'; + $joiner = ' AND'; + } + + if (defined $author) { + $sql .= "$joiner author LIKE ?"; + push @params, '%' . $author. '%'; + $joiner = ' AND'; + } + + # search for a random unseen quote + my $sth = $self->{dbh}->prepare("$sql $joiner id NOT IN (SELECT id FROM Seen WHERE channel = ?) ORDER BY RANDOM() LIMIT 1"); + $sth->execute(@params, $channel); + my $quote = $sth->fetchrow_hashref(); + + # no unseen quote found + if (not defined $quote) { + # remove queried quotes from Seen table + if (not $self->remove_seen($channel, $sql, \@params)) { + # no matching quotes in Seen table ergo no quote found + return undef; + } + + # try again to search for random unseen quote + $sth->execute(@params, $channel); + $quote = $sth->fetchrow_hashref(); + } + + # mark quote as seen if found + if (defined $quote) { + $self->add_seen($channel, $quote->{id}); + } + + return $quote; + }; + + die $@ if $@; + return $quote; +} + +sub remove_seen($self, $channel, $sql, $params) { + $sql =~ s/^SELECT \*/SELECT id/; + + my $count = eval { + my $sth = $self->{dbh}->prepare("DELETE FROM Seen WHERE channel = ? AND id IN ($sql)"); + $sth->execute($channel, @$params); + return $sth->rows; + }; + + die $@ if $@; + return $count; +} + +sub add_seen($self, $channel, $id) { + eval { + my $sth = $self->{dbh}->prepare('INSERT INTO Seen VALUES (?, ?)'); + $sth->execute($channel, $id); + }; + + die $@ if $@; +} + +sub get_all_quotes($self) { + my $quotes = eval { + my $sth = $self->{dbh}->prepare('SELECT * from Quotes'); + $sth->execute(); + return $sth->fetchall_arrayref({}); + }; + + die $@ if $@; + return $quotes; +} + +sub delete_quote($self, $id) { + eval { + my $sth = $self->{dbh}->prepare('DELETE FROM Quotes WHERE id == ?'); + $sth->execute($id); + + $sth = $self->{dbh}->prepare('DELETE FROM Seen WHERE id == ?'); + $sth->execute($id); + }; + + die $@ if $@; +} + +1; diff --git a/applets/quotes.sqlite b/applets/quotes.sqlite new file mode 100644 index 00000000..26e8c5b3 Binary files /dev/null and b/applets/quotes.sqlite differ diff --git a/applets/random-quote.pl b/applets/random-quote.pl new file mode 100755 index 00000000..b725cfed --- /dev/null +++ b/applets/random-quote.pl @@ -0,0 +1,87 @@ +#!/usr/bin/perl + +# SPDX-FileCopyrightText: 2009-2023 Pragmatic Software +# SPDX-License-Identifier: MIT + +use v5.26; +use warnings; + +use feature 'signatures'; +no warnings 'experimental::signatures'; + +use lib '.'; +use QuoteDB; +use Getopt::Long 'GetOptionsFromArray'; + +my $usage = 'Usage: quote [text] [-a ] [-c] [-h] [-i] -- Use -c to show remaining/total count; -h to hide author; -i to show id'; + +my ($text, $author, $channel, $show_count, $hide_author, $show_id); + +$channel = shift @ARGV; + +if (not defined $channel) { + print "Must have channel as first argument.\n"; + exit 1; +} + +{ + my $opt_error; + local $SIG{__WARN__} = sub { + $opt_error = shift; + chomp $opt_error; + }; + + Getopt::Long::Configure('bundling_override'); + + GetOptionsFromArray( + \@ARGV, + 'author|a=s' => \$author, + 'count|c' => \$show_count, + 'hide|h' => \$hide_author, + 'id|i' => \$show_id, + ); + + $text = "@ARGV"; + + if ($opt_error) { + print "$opt_error: $usage\n"; + exit 1; + } + + if (!length $text && !length $author) { + print "$usage\n"; + exit 1; + } +} + +my $db = QuoteDB->new(); + +$db->begin(); + +my $quote = $db->get_random_quote($channel, $text, $author); + +if ($show_count) { + my ($total, $remaining) = $db->count_random_quote($channel, $text, $author); + + if (defined $total && $total > 0) { + $total = $total->{'COUNT(*)'}; + $remaining = $total - $remaining->{'COUNT(*)'}; + print "$remaining/$total "; + } +} + +if (!defined $quote) { + print "No quote found.\n"; +} else { + if ($show_id) { + print "$quote->{id}: "; + } + + if ($hide_author) { + print "$quote->{text}\n"; + } else { + print "$quote->{text} -- $quote->{author}\n"; + } +} + +$db->end(); diff --git a/lib/PBot/VERSION.pm b/lib/PBot/VERSION.pm index ca3f492c..14d042b5 100644 --- a/lib/PBot/VERSION.pm +++ b/lib/PBot/VERSION.pm @@ -25,8 +25,8 @@ use PBot::Imports; # These are set by the /misc/update_version script use constant { BUILD_NAME => "PBot", - BUILD_REVISION => 4942, - BUILD_DATE => "2026-03-16", + BUILD_REVISION => 4943, + BUILD_DATE => "2026-03-27", }; sub initialize {}