use warnings;
use strict;
-use Crypt::Misc 0.029 qw(encode_b32r decode_b64);
+use Crypt::Misc 0.029 qw(decode_b64 encode_b32r);
use Devel::GlobalDestruction;
use Encode qw(encode);
use File::KDBX::Constants qw(:history :icon);
use File::KDBX::Error;
-use File::KDBX::Util qw(:function :uri generate_uuid load_optional);
-use List::Util qw(sum0);
-use Ref::Util qw(is_plain_hashref is_ref);
-use Scalar::Util qw(looks_like_number refaddr);
+use File::KDBX::Util qw(:assert :class :coercion :erase :function :uri generate_uuid load_optional);
+use Hash::Util::FieldHash;
+use List::Util qw(first sum0);
+use Ref::Util qw(is_coderef is_hashref is_plain_hashref);
+use Scalar::Util qw(blessed looks_like_number);
use Storable qw(dclone);
use Time::Piece;
use boolean;
use namespace::clean;
-use parent 'File::KDBX::Object';
+extends 'File::KDBX::Object';
our $VERSION = '999.999'; # VERSION
my %PLACEHOLDERS;
my %STANDARD_STRINGS = map { $_ => 1 } qw(Title UserName Password URL Notes);
-sub _parent_container { 'entries' }
-
-=attr uuid
-
-128-bit UUID identifying the entry within the database.
-
-=attr icon_id
-
-Integer representing a default icon. See L<File::KDBX::Constants/":icon"> for valid values.
-
-=attr custom_icon_uuid
-
-128-bit UUID identifying a custom icon within the database.
-
=attr foreground_color
Text color represented as a string of the form C<#000000>.
TODO
-=attr tags
+=attr auto_type_enabled
+
+Whether or not the entry is eligible to be matched for auto-typing.
+
+=attr auto_type_obfuscation
+
+Whether or not to use some kind of obfuscation when sending keystroke sequences to applications.
+
+=attr auto_type_default_sequence
-Text string with arbitrary tags which can be used to build a taxonomy.
+The default auto-type keystroke sequence.
-=attr auto_type
+=attr auto_type_associations
-Auto-type details.
+An array of window title / keystroke sequence associations.
{
- enabled => true,
- data_transfer_obfuscation => 0,
- default_sequence => '{USERNAME}{TAB}{PASSWORD}{ENTER}',
- associations => [
- {
- window => 'My Bank - Mozilla Firefox',
- keystroke_sequence => '{PASSWORD}{ENTER}',
- },
- ],
+ window => 'Example Window Title',
+ keystroke_sequence => '{USERNAME}{TAB}{PASSWORD}{ENTER}',
}
-=attr previous_parent_group
-
-128-bit UUID identifying a group within the database.
+Keystroke sequences can have </Placeholders>, most commonly C<{USERNAME}> and C<{PASSWORD}>.
=attr quality_check
MySystem => { value => 'The mainframe' },
}
+There are methods available to provide more convenient access to strings, including L</string>,
+L</string_value>, L</expand_string_value> and L</string_peek>.
+
=attr binaries
-Files or attachments.
+Files or attachments. Binaries are similar to strings except they have a value of bytes instead of test
+characters.
-=attr custom_data
+ {
+ 'myfile.txt' => {
+ value => '...',
+ },
+ 'mysecrets.txt' => {
+ value => '...',
+ protect => true,
+ },
+ }
-A set of key-value pairs used to store arbitrary data, usually used by software to keep track of state rather
-than by end users (who typically work with the strings and binaries).
+There are methods available to provide more convenient access to binaries, including L</binary> and
+L</binary_value>.
=attr history
Array of historical entries. Historical entries are prior versions of the same entry so they all share the
same UUID with the current entry.
-=attr last_modification_time
-
-Date and time when the entry was last modified.
-
-=attr creation_time
-
-Date and time when the entry was created.
-
-=attr last_access_time
-
-Date and time when the entry was last accessed.
-
-=attr expiry_time
-
-Date and time when the entry expired or will expire.
-
-=attr expires
-
-Boolean value indicating whether or not an entry is expired.
-
-=attr usage_count
-
-The number of times an entry has been used, which typically means how many times the C<Password> string has
-been accessed.
-
-=attr location_changed
-
-Date and time when the entry was last moved to a different group.
-
=attr notes
-Alias for the C<Notes> string value.
+Alias for the B<Notes> string value.
=attr password
-Alias for the C<Password> string value.
+Alias for the B<Password> string value.
=attr title
-Alias for the C<Title> string value.
+Alias for the B<Title> string value.
=attr url
-Alias for the C<URL> string value.
+Alias for the B<URL> string value.
=attr username
-Aliases for the C<UserName> string value.
+Aliases for the B<UserName> string value.
=cut
for my $entry (@{$self->history}) {
$entry->{uuid} = $uuid;
}
- # if (defined $old_uuid and my $kdbx = $KDBX{refaddr($self)}) {
- # $kdbx->_update_entry_uuid($old_uuid, $uuid, $self);
- # }
+ $self->_signal('uuid.changed', $uuid, $old_uuid) if defined $old_uuid && $self->is_current;
}
$self->{uuid};
}
-my @ATTRS = qw(uuid custom_data history);
-my %ATTRS = (
- # uuid => sub { generate_uuid(printable => 1) },
- icon_id => ICON_PASSWORD,
- custom_icon_uuid => undef,
- foreground_color => '',
- background_color => '',
- override_url => '',
- tags => '',
- auto_type => sub { +{} },
- previous_parent_group => undef,
- quality_check => true,
- strings => sub { +{} },
- binaries => sub { +{} },
- # custom_data => sub { +{} },
- # history => sub { +[] },
-);
-my %ATTRS_TIMES = (
- last_modification_time => sub { gmtime },
- creation_time => sub { gmtime },
- last_access_time => sub { gmtime },
- expiry_time => sub { gmtime },
- expires => false,
- usage_count => 0,
- location_changed => sub { gmtime },
-);
+# has uuid => sub { generate_uuid(printable => 1) };
+has icon_id => ICON_PASSWORD, coerce => \&to_icon_constant;
+has custom_icon_uuid => undef, coerce => \&to_uuid;
+has foreground_color => '', coerce => \&to_string;
+has background_color => '', coerce => \&to_string;
+has override_url => '', coerce => \&to_string;
+has tags => '', coerce => \&to_string;
+has auto_type => {};
+has previous_parent_group => undef, coerce => \&to_uuid;
+has quality_check => true, coerce => \&to_bool;
+has strings => {};
+has binaries => {};
+has times => {};
+# has custom_data => {};
+# has history => [];
+
+has last_modification_time => sub { gmtime }, store => 'times', coerce => \&to_time;
+has creation_time => sub { gmtime }, store => 'times', coerce => \&to_time;
+has last_access_time => sub { gmtime }, store => 'times', coerce => \&to_time;
+has expiry_time => sub { gmtime }, store => 'times', coerce => \&to_time;
+has expires => false, store => 'times', coerce => \&to_bool;
+has usage_count => 0, store => 'times', coerce => \&to_number;
+has location_changed => sub { gmtime }, store => 'times', coerce => \&to_time;
+
+# has 'auto_type.auto_type_enabled' => true, coerce => \&to_bool;
+has 'auto_type_obfuscation' => 0, path => 'auto_type.data_transfer_obfuscation',
+ coerce => \&to_number;
+has 'auto_type_default_sequence' => '{USERNAME}{TAB}{PASSWORD}{ENTER}',
+ path => 'auto_type.default_sequence', coerce => \&to_string;
+has 'auto_type_associations' => [], path => 'auto_type.associations';
+
my %ATTRS_STRINGS = (
title => 'Title',
username => 'UserName',
url => 'URL',
notes => 'Notes',
);
-
-while (my ($attr, $default) = each %ATTRS) {
- no strict 'refs'; ## no critic (ProhibitNoStrict)
- *{$attr} = sub {
- my $self = shift;
- $self->{$attr} = shift if @_;
- $self->{$attr} //= (ref $default eq 'CODE') ? $default->($self) : $default;
- };
-}
-while (my ($attr, $default) = each %ATTRS_TIMES) {
- no strict 'refs'; ## no critic (ProhibitNoStrict)
- *{$attr} = sub {
- my $self = shift;
- $self->{times} //= {};
- $self->{times}{$attr} = shift if @_;
- $self->{times}{$attr} //= (ref $default eq 'CODE') ? $default->($self) : $default;
- };
-}
while (my ($attr, $string_key) = each %ATTRS_STRINGS) {
no strict 'refs'; ## no critic (ProhibitNoStrict)
*{$attr} = sub { shift->string_value($string_key, @_) };
- *{"expanded_${attr}"} = sub { shift->expanded_string_value($string_key, @_) };
+ *{"expand_${attr}"} = sub { shift->expand_string_value($string_key, @_) };
}
-sub _set_default_attributes {
+my @ATTRS = qw(uuid custom_data history auto_type_enabled);
+sub _set_nonlazy_attributes {
my $self = shift;
- $self->$_ for @ATTRS, keys %ATTRS, keys %ATTRS_TIMES, keys %ATTRS_STRINGS;
+ $self->$_ for @ATTRS, keys %ATTRS_STRINGS, list_attributes(ref $self);
}
sub init {
=method string_value
- $string = $entry->string_value;
+ $string = $entry->string_value($string_key);
+
+Access a string value directly. The arguments are the same as for L</string>. Returns C<undef> if the string
+is not set or is currently memory-protected. This is just a shortcut for:
-Access a string value directly. Returns C<undef> if the string is not set.
+ my $string = do {
+ my $s = $entry->string(...);
+ defined $s ? $s->{value} : undef;
+ };
=cut
return $string->{value};
}
-=method expanded_string_value
+=method expand_string_value
- $string = $entry->expanded_string_value;
+ $string = $entry->expand_string_value;
Same as L</string_value> but will substitute placeholders and resolve field references. Any placeholders that
do not expand to values are left as-is.
See L</Placeholders>.
-Some placeholders (notably field references) require the entry be associated with a database and will throw an
-error if there is no association.
+Some placeholders (notably field references) require the entry be connected to a database and will throw an
+error if it is not.
=cut
}
return if !defined $File::KDBX::PLACEHOLDERS{$placeholder_key};
- my $local_key = join('/', refaddr($self), $placeholder_key);
+ my $local_key = join('/', Hash::Util::FieldHash::id($self), $placeholder_key);
local $PLACEHOLDERS{$local_key} = my $handler = $PLACEHOLDERS{$local_key} // do {
my $handler = $File::KDBX::PLACEHOLDERS{$placeholder_key} or next;
memoize recurse_limit($handler, $PLACEHOLDER_MAX_DEPTH, sub {
return $str;
}
-sub expanded_string_value {
+sub expand_string_value {
my $self = shift;
- my $str = $self->string_value(@_) // return undef;
+ my $str = $self->string_peek(@_) // return undef;
+ my $cleanup = erase_scoped $str;
return $self->_expand_string($str);
}
+=attr expand_notes
+
+Shortcut equivalent to C<< ->expand_string_value('Notes') >>.
+
+=attr expand_password
+
+Shortcut equivalent to C<< ->expand_string_value('Password') >>.
+
+=attr expand_title
+
+Shortcut equivalent to C<< ->expand_string_value('Title') >>.
+
+=attr expand_url
+
+Shortcut equivalent to C<< ->expand_string_value('URL') >>.
+
+=attr expand_username
+
+Shortcut equivalent to C<< ->expand_string_value('UserName') >>.
+
=method other_strings
$other = $entry->other_strings;
return join($delim, @strings);
}
+=method string_peek
+
+ $string = $entry->string_peek($string_key);
+
+Same as L</string_value> but can also retrieve the value from protected-memory if the value is currently
+protected.
+
+=cut
+
sub string_peek {
my $self = shift;
my $string = $self->string(@_);
return defined $string->{value} ? $string->{value} : $self->kdbx->peek($string);
}
-sub password_peek { $_[0]->string_peek('Password') }
+##############################################################################
+
+=method add_auto_type_association
+
+ $entry->add_auto_type_association(\%association);
+
+Add a new auto-type association to an entry.
+
+=cut
+
+sub add_auto_type_association {
+ my $self = shift;
+ my $association = shift;
+ push @{$self->auto_type_associations}, $association;
+}
+
+=method expand_keystroke_sequence
+
+ $string = $entry->expand_keystroke_sequence($keystroke_sequence);
+ $string = $entry->expand_keystroke_sequence(\%association);
+ $string = $entry->expand_keystroke_sequence; # use default auto-type sequence
+
+Get a keystroke sequence after placeholder expansion.
+
+=cut
+
+sub expand_keystroke_sequence {
+ my $self = shift;
+ my $association = shift;
+
+ my $keys;
+ if ($association) {
+ $keys = is_hashref($association) && exists $association->{keystroke_sequence} ?
+ $association->{keystroke_sequence} : defined $association ? $association : '';
+ }
+
+ $keys = $self->auto_type_default_sequence if !$keys;
+ # TODO - Fall back to getting default sequence from parent group, which probably means we shouldn't be
+ # setting a default value in the entry..
+
+ return $self->_expand_string($keys);
+}
##############################################################################
+=method binary
+
+ \%binary = $entry->binary($binary_key);
+
+ $entry->binary($binary_key, \%binary);
+ $entry->binary($binary_key, %attributes);
+ $entry->binary($binary_key, $value); # same as: value => $value
+
+Get or set a binary. Every binary has a unique (to the entry) key and flags and so are returned as a hash
+structure. For example:
+
+ $binary = {
+ value => '...',
+ protect => true, # optional
+ };
+
+Every binary should have a value (but might be C<undef> due to memory protection) and these optional flags
+which might exist:
+
+=for :list
+* C<protect> - Whether or not the binary value should be memory-protected.
+
+=cut
+
sub binary {
my $self = shift;
- my $key = shift or throw 'Must provide a binary key to access';
- if (@_) {
- my $arg = @_ == 1 ? shift : undef;
- my %args;
- @args{keys %$arg} = values %$arg if ref $arg eq 'HASH';
- $args{value} = $arg if !ref $arg;
- while (my ($field, $value) = each %args) {
- $self->{binaries}{$key}{$field} = $value;
+ my %args = @_ == 2 ? (key => shift, value => shift)
+ : @_ % 2 == 1 ? (key => shift, @_) : @_;
+
+ if (!defined $args{key} && !defined $args{value}) {
+ my %standard = (value => 1, protect => 1);
+ my @other_keys = grep { !$standard{$_} } keys %args;
+ if (@other_keys == 1) {
+ my $key = $args{key} = $other_keys[0];
+ $args{value} = delete $args{$key};
}
}
- my $binary = $self->{binaries}{$key} //= {value => ''};
- if (defined (my $ref = $binary->{ref})) {
- $binary = $self->{binaries}{$key} = dclone($self->kdbx->binaries->{$ref});
+
+ my $key = delete $args{key} or throw 'Must provide a binary key to access';
+
+ return $self->{binaries}{$key} = $args{value} if is_plain_hashref($args{value});
+
+ assert { !defined $args{value} || !utf8::is_utf8($args{value}) };
+ while (my ($field, $value) = each %args) {
+ $self->{binaries}{$key}{$field} = $value;
}
- return $binary;
+ return $self->{binaries}{$key};
}
-sub binary_novivify {
- my $self = shift;
- my $binary_key = shift;
- return if !$self->{binaries}{$binary_key} && !@_;
- return $self->binary($binary_key, @_);
-}
+=method binary_value
+
+ $binary = $entry->binary_value($binary_key);
+
+Access a binary value directly. The arguments are the same as for L</binary>. Returns C<undef> if the binary
+is not set or is currently memory-protected. This is just a shortcut for:
+
+ my $binary = do {
+ my $b = $entry->binary(...);
+ defined $b ? $b->{value} : undef;
+ };
+
+=cut
sub binary_value {
my $self = shift;
- my $binary = $self->binary_novivify(@_) // return undef;
+ my $binary = $self->binary(@_) // return undef;
return $binary->{value};
}
-sub auto_type_enabled {
- my $entry = shift;
- # TODO
-}
-
##############################################################################
=method hmac_otp
sub history {
my $self = shift;
my $entries = $self->{history} //= [];
- # FIXME - Looping through entries on each access is too expensive.
- @$entries = map { $self->_wrap_entry($_, $self->kdbx) } @$entries;
+ if (@$entries && !blessed($entries->[0])) {
+ @$entries = map { $self->_wrap_entry($_, $self->kdbx) } @$entries;
+ }
+ assert { !any { !blessed $_ } @$entries };
return $entries;
}
=method prune_history
- $entry->prune_history(%options);
+ @removed_historical_entries = $entry->prune_history(%options);
-Remove as many older historical entries as necessary to get under the database limits. The limits are taken
-from the associated database (if any) or can be overridden with C<%options>:
+Remove just as many older historical entries as necessary to get under the database limits. The limits are
+taken from the connected database (if any) or can be overridden with C<%options>:
=for :list
* C<max_items> - Maximum number of historical entries to keep (default: 10, no limit: -1)
* C<max_size> - Maximum total size (in bytes) of historical entries to keep (default: 6 MiB, no limit: -1)
+* C<max_age> - Maximum age (in days) of historical entries to keep (default: 365, no limit: -1)
=cut
my $self = shift;
my %args = @_;
- my $max_items = $args{max_items} // eval { $self->kdbx->history_max_items }
- // HISTORY_DEFAULT_MAX_ITEMS;
- my $max_size = $args{max_size} // eval { $self->kdbx->history_max_size }
- // HISTORY_DEFAULT_MAX_SIZE;
+ my $max_items = $args{max_items} // eval { $self->kdbx->history_max_items } // HISTORY_DEFAULT_MAX_ITEMS;
+ my $max_size = $args{max_size} // eval { $self->kdbx->history_max_size } // HISTORY_DEFAULT_MAX_SIZE;
+ my $max_age = $args{max_age} // HISTORY_DEFAULT_MAX_AGE;
- # history is ordered oldest to youngest
+ # history is ordered oldest to newest
my $history = $self->history;
+ my @removed;
+
if (0 <= $max_items && $max_items < @$history) {
- splice @$history, -$max_items;
+ push @removed, splice @$history, -$max_items;
}
if (0 <= $max_size) {
my $current_size = $self->history_size;
while ($max_size < $current_size) {
- my $entry = shift @$history;
+ push @removed, my $entry = shift @$history;
$current_size -= $entry->size;
}
}
+
+ if (0 <= $max_age) {
+ my $cutoff = gmtime - ($max_age * 86400);
+ for (my $i = @$history - 1; 0 <= $i; --$i) {
+ my $entry = $history->[$i];
+ next if $cutoff <= $entry->last_modification_time;
+ push @removed, splice @$history, $i, 1;
+ }
+ }
+
+ @removed = sort { $a->last_modification_time <=> $b->last_modification_time } @removed;
+ return @removed;
}
-sub add_history {
+=method add_historical_entry
+
+ $entry->add_historical_entry($entry);
+
+Add an entry to the history.
+
+=cut
+
+sub add_historical_entry {
my $self = shift;
delete $_->{history} for @_;
push @{$self->{history} //= []}, map { $self->_wrap_entry($_) } @_;
}
+=method remove_historical_entry
+
+ $entry->remove_historical_entry($historical_entry);
+
+Remove an entry from the history.
+
+=cut
+
+sub remove_historical_entry {
+ my $self = shift;
+ my $entry = shift;
+ my $history = $self->history;
+
+ my @removed;
+ for (my $i = @$history - 1; 0 <= $i; --$i) {
+ my $item = $history->[$i];
+ next if Hash::Util::FieldHash::id($entry) != Hash::Util::FieldHash::id($item);
+ push @removed, splice @{$self->{history}}, $i, 1;
+ }
+ return @removed;
+}
+
+=method current_entry
+
+ $current_entry = $entry->current_entry;
+
+Get an entry's current entry. If the entry itself is current (not historical), itself is returned.
+
+=cut
+
+sub current_entry {
+ my $self = shift;
+ my $parent = $self->group;
+
+ if ($parent) {
+ my $id = $self->uuid;
+ my $entry = first { $id eq $_->uuid } @{$parent->entries};
+ return $entry if $entry;
+ }
+
+ return $self;
+}
+
+=method is_current
+
+ $bool = $entry->is_current;
+
+Get whether or not an entry is considered current (i.e. not historical). An entry is current if it is directly
+in the parent group's entry list.
+
+=cut
+
+sub is_current {
+ my $self = shift;
+ my $current = $self->current_entry;
+ return Hash::Util::FieldHash::id($self) == Hash::Util::FieldHash::id($current);
+}
+
+=method is_historical
+
+ $bool = $entry->is_historical;
+
+Get whether or not an entry is considered historical (i.e. not current).
+
+This is just the inverse of L</is_current>.
+
+=cut
+
+sub is_historical { !$_[0]->is_current }
+
+=method remove
+
+ $entry = $entry->remove;
+
+Remove an entry from its parent group. If the entry is historical, remove it from the history of the current
+entry. If the entry is current, this behaves the same as L<File::KDBX::Object/remove>.
+
+=cut
+
+sub remove {
+ my $self = shift;
+ my $current = $self->current_entry;
+ return $self if $current->remove_historical_entry($self);
+ $self->SUPER::remove(@_);
+}
+
##############################################################################
-sub begin_work {
+=method searching_enabled
+
+ $bool = $entry->searching_enabled;
+
+Get whether or not an entry may show up in search results. This is determine from the entry's parent group's
+L<File::KDBX::Group/effective_enable_searching> value.
+
+Throws if entry has no parent group or if the entry is not connected to a database.
+
+=cut
+
+sub searching_enabled {
my $self = shift;
- require File::KDBX::Transaction;
- return File::KDBX::Transaction->new($self, @_);
+ my $parent = $self->group;
+ return $parent->effective_enable_searching if $parent;
+ return true;
+}
+
+sub auto_type_enabled {
+ my $self = shift;
+ $self->auto_type->{enabled} = to_bool(shift) if @_;
+ $self->auto_type->{enabled} //= true;
+ return false if !$self->auto_type->{enabled};
+ return true if !$self->is_connected;
+ my $parent = $self->group;
+ return $parent->effective_enable_auto_type if $parent;
+ return true;
+}
+
+##############################################################################
+
+sub _signal {
+ my $self = shift;
+ my $type = shift;
+ return $self->SUPER::_signal("entry.$type", @_);
}
sub _commit {
my $self = shift;
- my $txn = shift;
- $self->add_history($txn->original);
- $self->last_modification_time(gmtime);
+ my $orig = shift;
+ $self->add_historical_entry($orig);
+ my $time = gmtime;
+ $self->last_modification_time($time);
+ $self->last_access_time($time);
}
-sub label { shift->expanded_title(@_) }
+sub label { shift->expand_title(@_) }
+
+### Name of the parent attribute expected to contain the object
+sub _parent_container { 'entries' }
1;
__END__
+=for Pod::Coverage auto_type times
+
=head1 DESCRIPTION
An entry in a KDBX database is a record that can contains strings (also called "fields") and binaries (also
that every entry has:
=for :list
-* C<Title>
-* C<UserName>
-* C<Password>
-* C<URL>
-* C<Notes>
+* B<Title>
+* B<UserName>
+* B<Password>
+* B<URL>
+* B<Notes>
Beyond this, you can store any number of other strings and any number of binaries that you can use for
whatever purpose you want.
a UUID. An entry can also have an icon associated with it, and there are various timestamps. Take a look at
the attributes to see what's available.
+A B<File::KDBX::Entry> is a subclass of L<File::KDBX::Object>.
+
=head2 Placeholders
Entry string and auto-type key sequences can have placeholders or template tags that can be replaced by other
values. Placeholders can appear like C<{PLACEHOLDER}>. For example, a B<URL> string might have a value of
C<http://example.com?user={USERNAME}>. C<{USERNAME}> is a placeholder for the value of the B<UserName> string
-of the same entry. If the C<UserName> string had a value of "batman", the B<URL> string would expand to
+of the same entry. If the B<UserName> string had a value of "batman", the B<URL> string would expand to
C<http://example.com?user=batman>.
Some placeholders take an argument, where the argument follows the tag after a colon but before the closing
* ☑ C<{DT_UTC_MINUTE}> Minute Year component of the current UTC time
* ☑ C<{DT_UTC_SECOND}> - Second component of the current UTC time
-If the current date and time is <2012-07-25 17:05:34>, the "simple" form would be C<20120725170534>.
+If the current date and time is C<2012-07-25 17:05:34>, the "simple" form would be C<20120725170534>.
=head3 Special Key Placeholders
* ☒ C<{CLIPBOARD}>
* ☒ C<{CMD:/CommandLine/Options/}>
* ☑ C<{C:Comment}> - Comments are simply replaced by nothing
-* ☑ C<{ENV:} and C<%ENV%> - Environment variables
+* ☑ C<{ENV:}> and C<%ENV%> - Environment variables
* ☒ C<{GROUP_SEL_NOTES}>
* ☒ C<{GROUP_SEL_PATH}>
* ☒ C<{GROUP_SEL}>
If the placeholder is expanded in the context of an entry, C<$entry> is the B<File::KDBX::Entry> object in
context. Otherwise it is C<undef>. An entry is in context if, for example, the placeholder is in an entry's
-strings or auto-complete key sequences.
+strings or auto-type key sequences.
$File::KDBX::PLACEHOLDERS{'MY_PLACEHOLDER:'} = sub {
my ($entry, $arg) = @_; # ^ Notice the colon here
If the name of the placeholder ends in a colon, then it is expected to receive an argument. During expansion,
everything after the colon and before the end of the placeholder is passed to your placeholder handler
-subroutine. So if the placeholder is C<{MY_PLACEHOLDER:whatever}>, C<$arg> will have the value C<whatever>.
+subroutine. So if the placeholder is C<{MY_PLACEHOLDER:whatever}>, C<$arg> will have the value B<whatever>.
An argument is required for placeholders than take one. I.e. The placeholder handler won't be called if there
is no argument. If you want a placeholder to support an optional argument, you'll need to set the placeholder