mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-11-06 04:33:05 -05:00
DBD::mysql 5 refuses to connect to a MariaDB server and upstream does not consider that a bug, so a MariaDB system needs DBD::MariaDB. Prefer it when present; it drives both servers. ZM_DB_TYPE cannot select it, which is why setting it to MariaDB does not work: the same value builds the PHP PDO dsn, where the only valid driver is mysql whichever server is in use. Swapping the dsn scheme is not enough on its own. DBD::MariaDB spells DBD::mysql's mysql_* parameters mariadb_* and ignores parameters it does not recognise, so a dsn built with the wrong prefix does not fail: it drops the socket path and the TLS settings and connects over plain TCP. Build the dsn in one place from the chosen driver, renaming caller supplied options too, since zmupdate.pl asks for mysql_multi_statements. Read the id of an inserted row through DBI's last_insert_id rather than the mysql_insertid handle attribute, which DBD::MariaDB does not have and would answer undef for, leaving events and objects with no Id. DBD::MariaDB is always utf8mb4, so it takes no mysql_enable_utf8mb4 attribute. Let cmake accept either driver instead of requiring DBD::mysql, checking in the same order, and warn when neither is installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019URmtYqza6Rzi6F7cmabSm
1000 lines
35 KiB
Perl
1000 lines
35 KiB
Perl
# ==========================================================================
|
|
#
|
|
# ZoneMinder Object Module, $Date$, $Revision$
|
|
# Copyright (C) 2001-2017 ZoneMinder LLC
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
#
|
|
# ==========================================================================
|
|
#
|
|
# This module contains the common definitions and functions used by the rest
|
|
# of the ZoneMinder scripts
|
|
#
|
|
package ZoneMinder::Object;
|
|
|
|
use 5.006;
|
|
use strict;
|
|
use warnings;
|
|
use Time::HiRes qw{ gettimeofday tv_interval };
|
|
use Carp qw( cluck );
|
|
|
|
require ZoneMinder::Base;
|
|
require ZoneMinder::Object_Type;
|
|
|
|
our @ISA = qw(ZoneMinder::Base);
|
|
|
|
# ==========================================================================
|
|
#
|
|
# General Utility Functions
|
|
#
|
|
# ==========================================================================
|
|
|
|
sub def_or_undef {
|
|
return defined($_[0]) ? $_[0] : 'undef';
|
|
}
|
|
|
|
use ZoneMinder::Config qw(:all);
|
|
use ZoneMinder::Logger qw(:all);
|
|
use ZoneMinder::Database qw(:all);
|
|
|
|
use vars qw/ $AUTOLOAD $log $dbh %cache $no_cache/;
|
|
|
|
*log = \$ZoneMinder::Logger::logger;
|
|
*dbh = \$ZoneMinder::Database::dbh;
|
|
|
|
my $debug = 0;
|
|
$no_cache = 0;
|
|
use constant DEBUG_ALL=>0;
|
|
|
|
sub init_cache {
|
|
$no_cache = 0;
|
|
%cache = ();
|
|
} # end sub init_cache
|
|
|
|
sub new {
|
|
my ( $parent, $id, $data ) = @_;
|
|
|
|
my $ref = ref $id;
|
|
$cache{$parent} = {} if ! $cache{$parent};
|
|
my $sub_cache = $cache{$parent};
|
|
|
|
my $self = {};
|
|
bless $self, $parent;
|
|
|
|
if (!$ref) {
|
|
# single primary key
|
|
no strict 'refs';
|
|
my $primary_key = ${$parent.'::primary_key'};
|
|
if ( ! $primary_key ) {
|
|
my ( $caller, undef, $line ) = caller;
|
|
Error( 'NO primary_key for type ' . $parent . ' called from '.$caller.$line);
|
|
return;
|
|
} # end if
|
|
|
|
if ( $id and (!$no_cache) and $$sub_cache{$id} ) {
|
|
if ( $data ) {
|
|
# The reason to use load is if we have overridden it in the object,
|
|
$$sub_cache{$id} = $self;
|
|
$$sub_cache{$id}->load( $data );
|
|
}
|
|
return $$sub_cache{$id};
|
|
}
|
|
|
|
if ( ( $$self{$primary_key} = $id ) or $data ) {
|
|
#$log->debug("loading $parent $id") if $debug or DEBUG_ALL;
|
|
$self->load( $data );
|
|
if ( !$no_cache ) {
|
|
$$sub_cache{$id} = $self;
|
|
} # end if
|
|
} # end if
|
|
} elsif ($ref eq 'ARRAY') {
|
|
@$self{@$id} = @$data{@$id} if @$id;
|
|
$self->load( $data );
|
|
#$log->debug( $parent . ': ' .$self->to_string() );
|
|
return $self;
|
|
} # end if single or multiple primary key
|
|
|
|
return $self;
|
|
} # end sub new
|
|
|
|
sub load {
|
|
my ( $self, $data ) = @_;
|
|
my $type = ref $self;
|
|
if ( !$data ) {
|
|
no strict 'refs';
|
|
my $table = ${$type.'::table'};
|
|
if ( ! $table ) {
|
|
Error('No table for type '.$type);
|
|
return;
|
|
} # end if
|
|
my $primary_key = ${$type.'::primary_key'};
|
|
if ( !$primary_key ) {
|
|
Error('No primary_key for type '.$type);
|
|
return;
|
|
} # end if
|
|
|
|
if ( ! $$self{$primary_key} ) {
|
|
my ( $caller, undef, $line ) = caller;
|
|
Error("$type ::load called without $primary_key from $caller:$line");
|
|
} else {
|
|
Debug("Loading $type from $table WHERE $primary_key = $$self{$primary_key}");
|
|
$data = $ZoneMinder::Database::dbh->selectrow_hashref("SELECT * FROM `$table` WHERE `$primary_key`=?", {}, $$self{$primary_key});
|
|
if ( !$data ) {
|
|
if ( $ZoneMinder::Database::dbh->errstr ) {
|
|
Error( "Failure to load Object record for $type id:$$self{$primary_key}: Reason: " . $ZoneMinder::Database::dbh->errstr );
|
|
} else {
|
|
Debug("No Results Loading $type from $table WHERE $primary_key = $$self{$primary_key}");
|
|
} # end if
|
|
delete $$self{$primary_key};
|
|
} # end if
|
|
} # end if
|
|
} # end if ! $data
|
|
if ( $data and %$data ) {
|
|
@$self{keys %$data} = values %$data;
|
|
} # end if
|
|
return $data;
|
|
} # end sub load
|
|
|
|
sub lock_and_load {
|
|
my ( $self ) = @_;
|
|
my $type = ref $self;
|
|
|
|
no strict 'refs';
|
|
my $table = ${$type.'::table'};
|
|
if ( ! $table ) {
|
|
Error('NO table for type '.$type);
|
|
return;
|
|
} # end if
|
|
my $primary_key = ${$type.'::primary_key'};
|
|
if ( !$primary_key ) {
|
|
Error('No primary_key for type ' . $type);
|
|
return;
|
|
} # end if
|
|
|
|
if ( !$$self{$primary_key} ) {
|
|
my ( $caller, undef, $line ) = caller;
|
|
Error("$type ::lock_and_load called without $primary_key from $caller:$line");
|
|
return;
|
|
}
|
|
|
|
Debug("Lock and Load $type from $table WHERE $primary_key = $$self{$primary_key}");
|
|
my $data = $ZoneMinder::Database::dbh->selectrow_hashref("SELECT * FROM `$table` WHERE `$primary_key`=? FOR UPDATE", {}, $$self{$primary_key});
|
|
if ( ! $data ) {
|
|
if ( $ZoneMinder::Database::dbh->errstr ) {
|
|
Error("Failure to load Object record for $type id:$$self{$primary_key}: Reason: ".$ZoneMinder::Database::dbh->errstr);
|
|
} else {
|
|
Debug("No Results Lock and Loading $type from $table WHERE $primary_key = $$self{$primary_key}");
|
|
} # end if
|
|
} # end if
|
|
if ( $data and %$data ) {
|
|
@$self{keys %$data} = values %$data;
|
|
} else {
|
|
Debug("No values Lock and Loading $type from $table WHERE $primary_key = $$self{$primary_key}");
|
|
} # end if
|
|
} # end sub lock_and_load
|
|
|
|
|
|
sub save {
|
|
my ( $self, $data, $force_insert ) = @_;
|
|
|
|
my $type = ref $self;
|
|
if ( ! $type ) {
|
|
my ( $caller, undef, $line ) = caller;
|
|
$log->error("No type in Object::save. self:$self from $caller:$line");
|
|
}
|
|
my $local_dbh = eval '$'.$type.'::dbh';
|
|
if ( ! $local_dbh ) {
|
|
$local_dbh = $ZoneMinder::Database::dbh;
|
|
if ( $debug or DEBUG_ALL ) {
|
|
$log->debug("Using global dbh");
|
|
}
|
|
}
|
|
$self->set( $data ? $data : {} );
|
|
if ( $debug or DEBUG_ALL ) {
|
|
if ( $data ) {
|
|
foreach my $k ( keys %$data ) {
|
|
$log->debug("Object::save after set $k => $$data{$k} $$self{$k}");
|
|
}
|
|
}
|
|
}
|
|
|
|
my $table = eval '$'.$type.'::table';
|
|
my $fields = eval '\%'.$type.'::fields';
|
|
my $debug = eval '$'.$type.'::debug';
|
|
#$debug = DEBUG_ALL if ! $debug;
|
|
|
|
my %sql;
|
|
foreach my $k ( keys %$fields ) {
|
|
$sql{$$fields{$k}} = $$self{$k} if defined $$fields{$k};
|
|
} # end foreach
|
|
if ( ! $force_insert ) {
|
|
$sql{$$fields{updated_on}} = 'NOW()' if exists $$fields{updated_on};
|
|
} # end if
|
|
my $serial = eval '$'.$type.'::serial';
|
|
my @identified_by = eval '@'.$type.'::identified_by';
|
|
|
|
my $ac = ZoneMinder::Database::start_transaction( $local_dbh ) if $local_dbh->{AutoCommit};
|
|
if ( ! $serial ) {
|
|
my $insert = $force_insert;
|
|
my %serial = eval '%'.$type.'::serial';
|
|
if ( ! %serial ) {
|
|
$log->debug("No serial") if $debug;
|
|
# No serial columns defined, which means that we will do saving by delete/insert instead of insert/update
|
|
if ( @identified_by ) {
|
|
my $where = join(' AND ', map { '`'.$$fields{$_}.'`=?' } @identified_by );
|
|
if ( $debug ) {
|
|
$log->debug("DELETE FROM `$table` WHERE $where");
|
|
} # end if
|
|
|
|
if ( ! ( ( $_ = $local_dbh->prepare("DELETE FROM `$table` WHERE $where") ) and $_->execute( @$self{@identified_by} ) ) ) {
|
|
$where =~ s/\?/\%s/g;
|
|
$log->error("Error deleting: DELETE FROM $table WHERE " . sprintf($where, map { defined $_ ? $_ : 'undef' } ( @$self{@identified_by}) ).'):' . $local_dbh->errstr);
|
|
$local_dbh->rollback() if $ac;
|
|
ZoneMinder::Database::end_transaction( $local_dbh, $ac ) if $ac;
|
|
return $local_dbh->errstr;
|
|
} elsif ( $debug ) {
|
|
$log->debug("SQL successful DELETE FROM $table WHERE $where");
|
|
} # end if
|
|
} # end if
|
|
$insert = 1;
|
|
} else {
|
|
foreach my $id ( @identified_by ) {
|
|
if ( ! $serial{$id} ) {
|
|
my ( $caller, undef, $line ) = caller;
|
|
$log->error("$id nor in serial for $type from $caller:$line") if $debug;
|
|
next;
|
|
}
|
|
if ( ! $$self{$id} ) {
|
|
#($$self{$id}) = ($sql{$$fields{$id}}) = $local_dbh->selectrow_array( q{SELECT nextval('} . $serial{$id} . q{')} );
|
|
#$log->debug("SQL statement execution SELECT $s returned $$self{$id}") if $debug or DEBUG_ALL;
|
|
$insert = 1;
|
|
} # end if
|
|
} # end foreach
|
|
} # end if ! %serial
|
|
|
|
if ( $insert ) {
|
|
my @keys = keys %sql;
|
|
my $command = "INSERT INTO `$table` (" . join(',', @keys ) . ') VALUES (' . join(',', map { '?' } @sql{@keys} ) . ')';
|
|
if ( ! ( ( $_ = $local_dbh->prepare($command) ) and $_->execute( @sql{@keys} ) ) ) {
|
|
my $error = $local_dbh->errstr;
|
|
$command =~ s/\?/\%s/g;
|
|
$log->error('SQL statement execution failed: ('.sprintf($command, , map { defined $_ ? $_ : 'undef' } ( @sql{@keys}) ).'):' . $local_dbh->errstr);
|
|
$local_dbh->rollback() if $ac;
|
|
ZoneMinder::Database::end_transaction( $local_dbh, $ac ) if $ac;
|
|
return $error;
|
|
} # end if
|
|
if ( $debug or DEBUG_ALL ) {
|
|
$command =~ s/\?/\%s/g;
|
|
$log->debug('SQL statement execution: ('.sprintf($command, , map { defined $_ ? $_ : 'undef' } ( @sql{@keys} ) ).'):' );
|
|
} # end if
|
|
} else {
|
|
my @keys = keys %sql;
|
|
my $command = "UPDATE `$table` SET " . join(',', map { '`'.$_ . '` = ?' } @keys ) . ' WHERE ' . join(' AND ', map { '`'.$_ . '` = ?' } @$fields{@identified_by} );
|
|
if ( ! ( $_ = $local_dbh->prepare($command) and $_->execute( @sql{@keys,@$fields{@identified_by}} ) ) ) {
|
|
my $error = $local_dbh->errstr;
|
|
$command =~ s/\?/\%s/g;
|
|
$log->error('SQL failed: ('.sprintf($command, , map { defined $_ ? $_ : 'undef' } ( @sql{@keys, @$fields{@identified_by}}) ).'):' . $local_dbh->errstr);
|
|
$local_dbh->rollback() if $ac;
|
|
ZoneMinder::Database::end_transaction( $local_dbh, $ac ) if $ac;
|
|
return $error;
|
|
} # end if
|
|
if ( $debug or DEBUG_ALL ) {
|
|
$command =~ s/\?/\%s/g;
|
|
$log->debug('SQL DEBUG: ('.sprintf($command, map { defined $_ ? $_ : 'undef' } ( @sql{@keys,@$fields{@identified_by}} ) ).'):' );
|
|
} # end if
|
|
} # end if
|
|
} else { # not identified_by
|
|
@identified_by = ('Id') if ! @identified_by;
|
|
|
|
# If the size of the arrays are not equal which means one or more are missing
|
|
my @identified_by_without_values = map { $$self{$_} ? () : $_ } @identified_by;
|
|
my $need_serial = @identified_by_without_values > 0;
|
|
|
|
if ( $force_insert or $need_serial ) {
|
|
my @keys = keys %sql;
|
|
my $command = "INSERT INTO `$table` (" . join(',', map { '`'.$_.'`' } @keys ) . ') VALUES (' . join(',', map { '?' } @sql{@keys} ) . ')';
|
|
if ( ! ( $_ = $local_dbh->prepare($command) and $_->execute( @sql{@keys} ) ) ) {
|
|
$command =~ s/\?/\%s/g;
|
|
my $error = $local_dbh->errstr;
|
|
$log->error('SQL failed: ('.sprintf($command, map { defined $_ ? $_ : 'undef' } ( @sql{@keys}) ).'):' . $error);
|
|
$local_dbh->rollback() if $ac;
|
|
ZoneMinder::Database::end_transaction( $local_dbh, $ac ) if $ac;
|
|
return $error;
|
|
} # end if
|
|
if ( $debug or DEBUG_ALL ) {
|
|
$command =~ s/\?/\%s/g;
|
|
$log->debug('SQL DEBUG: ('.sprintf($command, map { defined $_ ? $_ : 'undef' } ( @sql{@keys} ) ).'):' );
|
|
} # end if
|
|
# DBI's portable accessor: DBD::MariaDB has no mysql_insertid, and
|
|
# would hand back undef, leaving the new row's Id unset.
|
|
$$self{Id} = $local_dbh->last_insert_id(undef, undef, undef, undef) if !$$self{Id};
|
|
} else {
|
|
delete $sql{created_on};
|
|
my @keys = keys %sql;
|
|
my %identified_by = map { $_, $_ } @identified_by;
|
|
|
|
@keys = map { $identified_by{$_} ? () : $$fields{$_} } @keys;
|
|
my $command = "UPDATE `$table` SET " . join(',', map { '`'.$_ . '` = ?' } @keys ) . ' WHERE ' . join(' AND ', map { '`'.$$fields{$_} .'`= ?' } @identified_by );
|
|
if ( ! ( $_ = $local_dbh->prepare($command) and $_->execute( @sql{@keys}, @sql{@$fields{@identified_by}} ) ) ) {
|
|
my $error = $local_dbh->errstr;
|
|
$command =~ s/\?/\%s/g;
|
|
$log->error('SQL failed: ('.sprintf($command, map { defined $_ ? $_ : 'undef' } ( @sql{@keys}, @sql{@$fields{@identified_by}} ) ).'):' . $error) if $log;
|
|
$local_dbh->rollback() if $ac;
|
|
ZoneMinder::Database::end_transaction( $local_dbh, $ac ) if $ac;
|
|
return $error;
|
|
} # end if
|
|
if ( $debug or DEBUG_ALL ) {
|
|
$command =~ s/\?/\%s/g;
|
|
$log->debug('SQL DEBUG: ('.sprintf($command, map { defined $_ ? ( ref $_ eq 'ARRAY' ? join(',',@{$_}) : $_ ) : 'undef' } ( @sql{@keys}, @$self{@identified_by} ) ).'):' );
|
|
} # end if
|
|
} # end if
|
|
} # end if
|
|
ZoneMinder::Database::end_transaction( $local_dbh, $ac ) if $ac;
|
|
return '';
|
|
} # end sub save
|
|
|
|
sub set {
|
|
my ( $self, $params ) = @_;
|
|
my @set_fields = ();
|
|
|
|
my $type = ref $self;
|
|
my %fields = eval ('%'.$type.'::fields');
|
|
if ( ! %fields ) {
|
|
$log->warn("ZoneMinder::Object::set called on an object ($type) with no fields".$@);
|
|
} # end if
|
|
my %defaults = eval('%'.$type.'::defaults');
|
|
|
|
if ( ref $params ne 'HASH' ) {
|
|
my ( $caller, undef, $line ) = caller;
|
|
$log->error("$type -> set called with non-hash params from $caller $line");
|
|
}
|
|
|
|
if ( $params ) {
|
|
foreach my $field ( keys %{$params} ) {
|
|
$log->debug("field: $field, ".def_or_undef($$self{$field}).' =? param: '.def_or_undef($$params{$field})) if $debug or DEBUG_ALL;
|
|
if ( ( ! defined $$self{$field} ) or ($$self{$field} ne $params->{$field}) ) {
|
|
# Only make changes to fields that have changed
|
|
if ( defined $fields{$field} ) {
|
|
$$self{$field} = $$params{$field};
|
|
push @set_fields, $fields{$field}, $$params{$field}; #mark for sql updating
|
|
} # end if has a column
|
|
$log->debug("Running $field with $$params{$field}") if $debug;
|
|
if ( my $func = $self->can( $field ) ) {
|
|
$func->( $self, $$params{$field} );
|
|
} # end if has function
|
|
} # end if has change
|
|
} # end foreach field
|
|
} # end if $params
|
|
|
|
foreach my $field ( keys %defaults ) {
|
|
if ( ( ! exists $$self{$field} ) or (!defined $$self{$field}) or ( $$self{$field} eq '' ) ) {
|
|
$log->debug("Setting default ($field) (".def_or_undef($$self{$field}).') ('.def_or_undef($defaults{$field}).') ') if $debug;
|
|
if ( defined $defaults{$field} ) {
|
|
if ( $defaults{$field} eq '' or $defaults{$field} eq 'NOW()' ) {
|
|
$$self{$field} = $defaults{$field};
|
|
} else {
|
|
$$self{$field} = eval($defaults{$field});
|
|
$log->error( "Eval error of object default $field default ($defaults{$field}) Reason: " . $@ ) if $@;
|
|
} # end if
|
|
} else {
|
|
$$self{$field} = $defaults{$field};
|
|
} # end if
|
|
$log->debug("Setting default for ($field) using (".def_or_undef($defaults{$field}).') to ('.def_or_undef($$self{$field}).') ') if $debug;
|
|
} elsif ( defined $fields{$field} and $$self{$field} ) {
|
|
$$self{$field} = transform( $type, $field, $$self{$field} );
|
|
} # end if
|
|
} # end foreach default
|
|
return @set_fields;
|
|
} # end sub set
|
|
|
|
sub transform {
|
|
my $type = ref $_[0];
|
|
$type = $_[0] if ! $type;
|
|
my $fields = eval '\%'.$type.'::fields';
|
|
my $value = $_[2];
|
|
|
|
if ( defined $$fields{$_[1]} ) {
|
|
my @transforms = eval('@{$'.$type.'::transforms{$_[1]}}');
|
|
$log->debug("Transforms for $_[1] before $_[2]: @transforms") if $debug;
|
|
if ( @transforms ) {
|
|
foreach my $transform ( @transforms ) {
|
|
if ( $transform =~ /^s\// or $transform =~ /^tr\// ) {
|
|
eval '$value =~ ' . $transform;
|
|
} elsif ( $transform =~ /^<(\d+)/ ) {
|
|
if ( $value > $1 ) {
|
|
$value = undef;
|
|
} # end if
|
|
} else {
|
|
$log->debug("evalling $value ".$transform . " Now value is $value" );
|
|
eval '$value '.$transform;
|
|
$log->error("Eval error $@") if $@;
|
|
}
|
|
$log->debug("After $transform: $value") if $debug;
|
|
} # end foreach
|
|
} # end if
|
|
} else {
|
|
$log->error("Object::transform ($_[1]) not in fields for $type");
|
|
} # end if
|
|
return $value;
|
|
|
|
} # end sub transform
|
|
|
|
sub to_string {
|
|
my $type = ref($_[0]);
|
|
my $fields = eval '\%'.$type.'::fields';
|
|
if ( $fields and %{$fields} ) {
|
|
return $type . ': '. join(' ', map { $_[0]{$_} ? "$_ => $_[0]{$_}" : () } sort { $a cmp $b } keys %$fields );
|
|
}
|
|
return $type . ': '. join(' ', map { $_ .' => '.(defined $_[0]{$_} ? $_[0]{$_} : 'undef') } sort { $a cmp $b } keys %{$_[0]} );
|
|
}
|
|
|
|
# We make this a separate function so that we can use it to generate the sql statements for each value in an OR
|
|
sub find_operators {
|
|
my ( $field, $type, $operator, $value ) = @_;
|
|
$log->debug("find_operators: field($field) type($type) op($operator) value($value)") if DEBUG_ALL;
|
|
|
|
my $add_placeholder = ( ! ( $field =~ /\?/ ) ) ? 1 : 0;
|
|
|
|
if ( $operator eq '='
|
|
or $operator eq '!='
|
|
or $operator eq '<'
|
|
or $operator eq '>'
|
|
or $operator eq '<='
|
|
or $operator eq '>='
|
|
or $operator eq '<<=' ) {
|
|
return ( $field.$type.' ' . $operator . ( $add_placeholder ? ' ?' : '' ), $value );
|
|
} elsif ( $operator eq 'not' ) {
|
|
return ( '( NOT ' . $field.$type.')', $value );
|
|
} elsif ( $operator eq '&&' or $operator eq '<@' or $operator eq '@>' ) {
|
|
if ( ref $value eq 'ARRAY' ) {
|
|
if ( $field =~ /^\(/ ) {
|
|
return ( 'ARRAY('.$field.$type.') ' . $operator . ' ?', $value );
|
|
} else {
|
|
return ( $field.$type.' ' . $operator . ' ?', $value );
|
|
} # emd of
|
|
} else {
|
|
return ( $field.$type.' ' . $operator . ' ?', [ $value ] );
|
|
} # end if
|
|
} elsif ( $operator eq 'exists' ) {
|
|
return ( $value ? '' : 'NOT ' ) . 'EXISTS ' . $field.$type;
|
|
} elsif ( $operator eq 'in' or $operator eq 'not in' ) {
|
|
if ( ref $value eq 'ARRAY' ) {
|
|
return ( $field.$type.' ' . $operator . ' ('. join(',', map { '?' } @{$value} ) . ')', @{$value} );
|
|
} else {
|
|
return ( $field.$type.' ' . $operator . ' (?)', $value );
|
|
} # end if
|
|
} elsif ( $operator eq 'contains' ) {
|
|
return ( '? IN '.$field.$type, $value );
|
|
} elsif ( $operator eq 'does not contain' ) {
|
|
return ( '? NOT IN '.$field.$type, $value );
|
|
} elsif ( $operator eq 'like' or $operator eq 'ilike' ) {
|
|
return $field.'::text ' . $operator . ' ?', $value;
|
|
} elsif ( $operator eq 'null_or_<=' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' <= ?)', $value;
|
|
} elsif ( $operator eq 'is null or <=' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' <= ?)', $value;
|
|
} elsif ( $operator eq 'null_or_>=' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' >= ?)', $value;
|
|
} elsif ( $operator eq 'is null or >=' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' >= ?)', $value;
|
|
} elsif ( $operator eq 'null_or_>' or $operator eq 'is null or >' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' > ?)', $value;
|
|
} elsif ( $operator eq 'null_or_<' or $operator eq 'is null or <' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' < ?)', $value;
|
|
} elsif ( $operator eq 'null_or_=' or $operator eq 'is null or =' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' = ?)', $value;
|
|
} elsif ( $operator eq 'null or in' or $operator eq 'is null or in' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' IN ('.join(',', map { '?' } @{$value} ) . '))', @{$value};
|
|
} elsif ( $operator eq 'null or not in' ) {
|
|
return '('.$field.$type.' IS NULL OR '.$field.$type.' NOT IN ('.join(',', map { '?' } @{$value} ) . '))', @{$value};
|
|
} elsif ( $operator eq 'exists' ) {
|
|
return ( $value ? ' EXISTS ' : 'NOT EXISTS ' ).$field;
|
|
} elsif ( $operator eq 'lc' ) {
|
|
return 'lower('.$field.$type.') = ?', $value;
|
|
} elsif ( $operator eq 'uc' ) {
|
|
return 'upper('.$field.$type.') = ?', $value;
|
|
} elsif ( $operator eq 'trunc' ) {
|
|
return 'trunc('.$field.$type.') = ?', $value;
|
|
} elsif ( $operator eq 'any' ) {
|
|
if ( ref $value eq 'ARRAY' ) {
|
|
return '(' . join(',', map { '?' } @{$value} ).") = ANY($field)", @{$value};
|
|
} else {
|
|
return "? = ANY($field)", $value;
|
|
} # end if
|
|
} elsif ( $operator eq 'not any' ) {
|
|
if ( ref $value eq 'ARRAY' ) {
|
|
return '(' . join(',', map { '?' } @{$value} ).") != ANY($field)", @{$value};
|
|
} else {
|
|
return "? != ANY($field)", $value;
|
|
} # end if
|
|
} elsif ( $operator eq 'is null' ) {
|
|
if ( $value ) {
|
|
return $field.$type. ' is null';
|
|
} else {
|
|
return $field.$type. ' is not null';
|
|
} # end if
|
|
} elsif ( $operator eq 'is not null' ) {
|
|
if ( $value ) {
|
|
return $field.$type. ' is not null';
|
|
} else {
|
|
return $field.$type. ' is null';
|
|
} # end if
|
|
} else {
|
|
$log->warn("find_operators: op not found field($field) type($type) op($operator) value($value)");
|
|
} # end if
|
|
return;
|
|
} # end sub find_operators
|
|
|
|
sub get_fields_values {
|
|
my ( $object_type, $search, $param_keys ) = @_;
|
|
|
|
my @used_fields;
|
|
my @where;
|
|
my @values;
|
|
no strict 'refs';
|
|
|
|
foreach my $k ( @$param_keys ) {
|
|
if ( $k eq 'or' ) {
|
|
my $or_ref = ref $$search{or};
|
|
|
|
if ( $or_ref eq 'HASH' ) {
|
|
my @keys = keys %{$$search{or}};
|
|
if ( @keys ) {
|
|
my ( $where, $values, $used_fields ) = get_fields_values( $object_type, $$search{or}, \@keys );
|
|
|
|
push @where, '('.join(' OR ', @{$where} ).')';
|
|
push @values, @{$values};
|
|
} else {
|
|
$log->error("No keys in or");
|
|
}
|
|
|
|
} elsif ( $or_ref eq 'ARRAY' ) {
|
|
my %s = @{$$search{or}};
|
|
my ( $where, $values, $used_fields ) = get_fields_values( $object_type, \%s, [ keys %s ] );
|
|
push @where, '('.join(' OR ', @{$where} ).')';
|
|
push @values, @{$values};
|
|
|
|
} else {
|
|
$log->error("Deprecated use of or $or_ref for $$search{or}");
|
|
} # end if
|
|
push @used_fields, $k;
|
|
next;
|
|
} elsif ( $k eq 'and' ) {
|
|
my $and_ref = ref $$search{and};
|
|
if ( $and_ref eq 'HASH' ) {
|
|
my @keys = keys %{$$search{and}};
|
|
if ( @keys ) {
|
|
my ( $where, $values, $used_fields ) = get_fields_values( $object_type, $$search{and}, \@keys );
|
|
|
|
push @where, '('.join(' AND ', @{$where} ).')';
|
|
push @values, @{$values};
|
|
} else {
|
|
$log->error("No keys in and");
|
|
}
|
|
} elsif ( $and_ref eq 'ARRAY' and @{$$search{and}} ) {
|
|
my @sub_where;
|
|
|
|
for( my $p_index = 0; $p_index < @{$$search{and}}; $p_index += 2 ) {
|
|
my %p = ( $$search{and}[$p_index], $$search{and}[$p_index+1] );
|
|
|
|
my ( $where, $values, $used_fields ) = get_fields_values( $object_type, \%p, [ keys %p ] );
|
|
push @sub_where, @{$where};
|
|
push @values, @{$values};
|
|
}
|
|
push @where, '('.join(' AND ', @sub_where ).')';
|
|
} else {
|
|
$log->error("incorrect ref of and $and_ref");
|
|
}
|
|
push @used_fields, $k;
|
|
next;
|
|
}
|
|
my ( $field, $type, $function ) = $k =~ /^([_\+\w\-]+)(::\w+\[?\]?)?[\s_]*(.*)?$/;
|
|
$type = '' if ! defined $type;
|
|
$log->debug("$object_type param $field($type) func($function) " . ( ref $$search{$k} eq 'ARRAY' ? join(',',@{$$search{$k}}) : $$search{$k} ) ) if DEBUG_ALL;
|
|
|
|
foreach ( 'find_fields', 'fields' ) {
|
|
my $fields = \%{$object_type.'::'.$_};
|
|
if ( ! $fields ) {
|
|
$log->debug("No $fields in $object_type") if DEBUG_ALL;
|
|
next;
|
|
} # end if
|
|
|
|
if ( ! $$fields{$field} ) {
|
|
#$log->debug("No $field in $_ for $object_type") if DEBUG_ALL;
|
|
next;
|
|
} # end if
|
|
|
|
# This allows mainly for find_fields to reference multiple values, opinion in Project, value
|
|
foreach my $db_field ( ref $$fields{$field} eq 'ARRAY' ? @{$$fields{$field}} : $$fields{$field} ) {
|
|
if ( ! $function ) {
|
|
$db_field .= $type;
|
|
|
|
if ( ref $$search{$k} eq 'ARRAY' ) {
|
|
$log->debug("Have array for $k $$search{$k}") if DEBUG_ALL;
|
|
|
|
if ( ! ( $db_field =~ /\?/ ) ) {
|
|
if ( @{$$search{$k}} != 1 ) {
|
|
push @where, '`'.$db_field .'` IN ('.join(',', map {'?'} @{$$search{$k}} ) . ')';
|
|
} else {
|
|
push @where, '`'.$db_field.'`=?';
|
|
} # end if
|
|
} else {
|
|
$log->debug("Have question ? for $k $$search{$k} $db_field") if DEBUG_ALL;
|
|
|
|
$db_field =~ s/=/IN/g;
|
|
my $question_replacement = '('.join(',', map {'?'} @{$$search{$k}} ) . ')';
|
|
$db_field =~ s/\?/$question_replacement/;
|
|
push @where, $db_field;
|
|
}
|
|
push @values, @{$$search{$k}};
|
|
} elsif ( ref $$search{$k} eq 'HASH' ) {
|
|
foreach my $p_k ( keys %{$$search{$k}} ) {
|
|
my $v = $$search{$k}{$p_k};
|
|
if ( ref $v eq 'ARRAY' ) {
|
|
push @where, '`'.$db_field.'` IN ('.join(',', map {'?'} @{$v} ) . ')';
|
|
push @values, $p_k, @{$v};
|
|
} else {
|
|
push @where, '`'.$db_field.'`=?';
|
|
push @values, $p_k, $v;
|
|
} # end if
|
|
} # end foreach p_k
|
|
} elsif ( ! defined $$search{$k} ) {
|
|
push @where, $db_field.' IS NULL';
|
|
} else {
|
|
if ( ! ( $db_field =~ /\?/ ) ) {
|
|
push @where, '`'.$db_field .'`=?';
|
|
} else {
|
|
push @where, $db_field;
|
|
}
|
|
push @values, $$search{$k};
|
|
} # end if
|
|
push @used_fields, $k;
|
|
} else {
|
|
#my @w =
|
|
#ref $search{$k} eq 'ARRAY' ?
|
|
#map { find_operators( $field, $type, $function, $_ ); } @{$search{$k}} :
|
|
my ( $w, @v ) = find_operators( $db_field, $type, $function, $$search{$k} );
|
|
if ( $w ) {
|
|
#push @where, '(' . join(' OR ', @w ) . ')';
|
|
push @where, $w;
|
|
push @values, @v if @v;
|
|
push @used_fields, $k;
|
|
} # end if @w
|
|
} # end if has function or not
|
|
} # end foreach db_field
|
|
} # end foreach find_field
|
|
} # end foreach k
|
|
return ( \@where, \@values, \@used_fields );
|
|
}
|
|
|
|
sub find {
|
|
no strict 'refs';
|
|
my $object_type = shift;
|
|
my $debug = ${$object_type.'::debug'};
|
|
$debug = DEBUG_ALL if ! $debug;
|
|
|
|
my $starttime = [gettimeofday] if $debug;
|
|
my $params;
|
|
if ( @_ == 1 ) {
|
|
$params = $_[0];
|
|
if ( ref $params ne 'HASH' ) {
|
|
$log->error("params $params was not a has");
|
|
} # end if
|
|
} else {
|
|
$params = { @_ };
|
|
} # end if
|
|
|
|
my $local_dbh = ${$object_type.'::dbh'};
|
|
if ($$params{dbh}) {
|
|
$local_dbh = $$params{dbh};
|
|
delete $$params{dbh};
|
|
} elsif (!$local_dbh) {
|
|
$local_dbh = $dbh;
|
|
} # end if
|
|
|
|
my $sql = find_sql($object_type, $params);
|
|
|
|
my $do_cache = $$sql{columns} ne '*' ? 0 : 1;
|
|
|
|
#$log->debug( 'find prepare: ' . sprintf('%.4f', tv_interval($starttime)*1000) ." useconds") if $debug;
|
|
my $data = $local_dbh->selectall_arrayref($$sql{sql}, { Slice => {} }, @{$$sql{values}});
|
|
if ( ! $data ) {
|
|
$log->error('Error ' . $local_dbh->errstr() . " loading $object_type ($$sql{sql}) (". join(',', map { ref $_ eq 'ARRAY' ? 'ARRAY('.join(',',@$_).')' : $_ } @{$$sql{values}} ) . ')' );
|
|
return ();
|
|
#} elsif ( ( ! @$data ) and $debug ) {
|
|
#$log->debug("No $type ($sql) (@values) " );
|
|
} elsif ( $debug ) {
|
|
$log->debug("Loading Debug:$debug $object_type ($$sql{sql}) (".join(',', map { ref $_ eq 'ARRAY' ? join(',', @{$_}) : $_ } @{$$sql{values}}).') # of results:' . @$data . ' in ' . sprintf('%.4f', tv_interval($starttime)*1000) .' useconds' );
|
|
} # end if
|
|
|
|
my $fields = \%{$object_type.'::fields'};
|
|
my $primary_key = ${$object_type.'::primary_key'};
|
|
if ($primary_key) {
|
|
if ( ! ($fields and keys %{$fields}) ) {
|
|
return map { new($object_type, $$_{$primary_key}, $_ ) } @$data;
|
|
} elsif ( $$fields{$primary_key} ) {
|
|
return map { new($object_type, $_->{$$fields{$primary_key}}, $_) } @$data;
|
|
}
|
|
} else {
|
|
my @identified_by = eval '@'.$object_type.'::identified_by';
|
|
if (@identified_by) {
|
|
return map { new($object_type, \@identified_by, $_, !$do_cache) } @$data;
|
|
} else {
|
|
my ( $caller, undef, $line ) = caller;
|
|
Error('NO primary_key for type ' . $object_type . ' called from '.$caller.$line);
|
|
return;
|
|
} # end if
|
|
} # end if
|
|
} # end sub find
|
|
|
|
sub find_one {
|
|
my $object_type = shift;
|
|
my $params;
|
|
if ( @_ == 1 ) {
|
|
$params = $_[0];
|
|
} else {
|
|
%{$params} = @_;
|
|
} # end if
|
|
$$params{limit}=1;
|
|
my @Results = $object_type->find(%$params);
|
|
my ( $caller, undef, $line ) = caller;
|
|
$log->debug("returning to $caller:$line from find_one") if DEBUG_ALL;
|
|
return $Results[0] if @Results;
|
|
} # end sub find_one
|
|
|
|
sub find_sql {
|
|
no strict 'refs';
|
|
my $object_type = shift;
|
|
|
|
my $debug = ${$object_type.'::debug'};
|
|
$debug = DEBUG_ALL if ! $debug;
|
|
|
|
my $params;
|
|
if ( @_ == 1 ) {
|
|
$params = $_[0];
|
|
if ( ref $params ne 'HASH' ) {
|
|
$log->error("params $params was not a has");
|
|
} # end if
|
|
} else {
|
|
$params = { @_ };
|
|
} # end if
|
|
|
|
my %sql = (
|
|
( distinct => ( exists $$params{distinct} ? 1:0 ) ),
|
|
( columns => ( exists $$params{columns} ? $$params{columns} : '*' ) ),
|
|
( table => ( exists $$params{table} ? $$params{table} : ${$object_type.'::table'} )),
|
|
'group by'=> $$params{'group by'},
|
|
limit => $$params{limit},
|
|
offset => $$params{offset},
|
|
);
|
|
if ( exists $$params{order} ) {
|
|
$sql{order} = $$params{order};
|
|
} else {
|
|
my $order = eval '$'.$object_type.'::default_sort';
|
|
#$log->debug("default sort: $object_type :: default_sort = $order") if DEBUG_ALL;
|
|
$sql{order} = $order if $order;
|
|
} # end if
|
|
delete @$params{'distinct','columns','table','group by','limit','offset','order'};
|
|
|
|
my @where;
|
|
my @values;
|
|
if ( exists $$params{custom} ) {
|
|
push @where, '(' . (shift @{$$params{custom}}) . ')';
|
|
push @values, @{$$params{custom}};
|
|
delete $$params{custom};
|
|
} # end if
|
|
|
|
my @param_keys = keys %$params;
|
|
|
|
# no operators, just which fields are being searched on. Mostly just useful for detection of the deleted field.
|
|
my %used_fields;
|
|
|
|
# We use this search hash so that we can mash it up and leave the params hash alone
|
|
my %search;
|
|
@search{@param_keys} = @$params{@param_keys};
|
|
|
|
my ( $where, $values, $used_fields ) = get_fields_values( $object_type, \%search, \@param_keys );
|
|
delete @search{@{$used_fields}};
|
|
@used_fields{ @{$used_fields} } = @{$used_fields};
|
|
push @where, @{$where};
|
|
push @values, @{$values};
|
|
|
|
my $fields = \%{$object_type.'::fields'};
|
|
|
|
#optimise this
|
|
if ( $$fields{deleted} and ! $used_fields{deleted} ) {
|
|
push @where, 'deleted=?';
|
|
push @values, 0;
|
|
} # end if
|
|
$sql{where} = \@where;
|
|
$sql{values} = \@values;
|
|
$sql{used_fields} = \%used_fields;
|
|
|
|
foreach my $k ( keys %search ) {
|
|
$log->error("Extra parameters in $object_type ::find $k => $search{$k}");
|
|
Carp::cluck("Extra parameters in $object_type ::find $k => $search{$k}");
|
|
} # end foreach
|
|
|
|
$sql{sql} = join( ' ',
|
|
( 'SELECT', ( $sql{distinct} ? ('DISTINCT') : () ) ),
|
|
( $sql{columns}, 'FROM', $sql{table} ),
|
|
( @{$sql{where}} ? ('WHERE', join(' AND ', @{$sql{where}})) : () ),
|
|
( $sql{order} ? ( 'ORDER BY', $sql{order} ) : () ),
|
|
( $sql{'group by'} ? ( 'GROUP BY', $sql{'group by'} ) : () ),
|
|
( $sql{limit} ? ( 'LIMIT', $sql{limit}) : () ),
|
|
( $sql{offset} ? ( 'OFFSET', $sql{offset} ) : () ),
|
|
);
|
|
#$log->debug("Loading Debug:$debug $object_type ($sql) (".join(',', map { ref $_ eq 'ARRAY' ? join(',', @{$_}) : $_ } @values).')' ) if $debug;
|
|
return \%sql;
|
|
} # end sub find_sql
|
|
|
|
sub array_diff(\@\@) {
|
|
my %e = map { $_ => undef } @{$_[1]};
|
|
return @{[ ( grep { (exists $e{$_}) ? ( delete $e{$_} ) : ( 1 ) } @{ $_[0] } ), keys %e ] };
|
|
}
|
|
|
|
sub changes {
|
|
my ( $self, $params ) = @_;
|
|
|
|
my $type = ref $self;
|
|
if ( ! $type ) {
|
|
my ( $caller, undef, $line ) = caller;
|
|
$log->error("No type in Object::changes. self:$self from $caller:$line");
|
|
}
|
|
my $fields = eval ('\%'.$type.'::fields');
|
|
if (!$fields) {
|
|
$log->warn('Object::changes called on an object with no fields');
|
|
return;
|
|
} # end if
|
|
my @results;
|
|
|
|
foreach my $field (sort keys %$fields) {
|
|
next if ! exists $$params{$field};
|
|
|
|
if ( ref $$self{$field} eq 'ARRAY' ) {
|
|
my @second = ref $$params{$field} eq 'ARRAY' ? @{$$params{$field}} : ($$params{$field});
|
|
if (array_diff(@{$$self{$field}}, @second)) {
|
|
push @results, [ $field, $$self{$field}, $$params{$field} ];
|
|
}
|
|
} elsif (
|
|
(!defined($$self{$field}) and defined($$params{$field}))
|
|
or
|
|
(defined($$self{$field}) and !defined($$params{$field}))
|
|
) {
|
|
push @results, $field;
|
|
} elsif ( defined($$self{$field}) and defined($$params{$field}) and ($$self{$field} ne $$params{$field}) ) {
|
|
push @results, $field;
|
|
}
|
|
}
|
|
return @results;
|
|
}
|
|
|
|
sub clone {
|
|
my $new = {};
|
|
bless $new, ref $_[0];
|
|
@$new{keys %{$_[0]}} = values %{$_[0]};
|
|
return $new;
|
|
} # end sub clone
|
|
|
|
sub Object_Type {
|
|
if ( $_[0]{object_type_id} ) {
|
|
$_[0]{Object_Type} = new ZoneMinder::Object_Type( $_[0]{object_type_id} );
|
|
} else {
|
|
$_[0]{Object_Type} = ZoneMinder::Object_Type->find_one( name=>ref $_[0] );
|
|
$_[0]{Object_Type} = new ZoneMinder::Object_Type() if ! $_[0]{Object_Type};
|
|
} # end if
|
|
return $_[0]{Object_Type};
|
|
} # end sub Object_Type
|
|
|
|
sub object_type {
|
|
if ( @_ > 1 ) {
|
|
my $Type = ZoneMinder::Object_Type->find_one( name => $_[1] );
|
|
if ( ! $Type ) {
|
|
$Type = new ZoneMinder::Object_Type();
|
|
$Type->save({ Name=>$_[1], Human=>$_[1] });
|
|
} # end if
|
|
$_[0]{object_type} = $Type->Name();
|
|
$_[0]{object_type_id} = $Type->Id();
|
|
} # end if
|
|
if ( ! $_[0]{object_type} ) {
|
|
$_[0]{object_type} = new ZoneMinder::Object_Type( $_[0]{object_type_id} )->Name();
|
|
} # end if
|
|
return $_[0]{object_type};
|
|
} # end sub object_type
|
|
|
|
sub Object {
|
|
my $self = shift;
|
|
if ( @_ ) {
|
|
$self->object_type( ref $_[0] );
|
|
$$self{object_id} = $_[0]{id};
|
|
$$self{Object} = $_[0];
|
|
} # end if
|
|
my $type = $self->object_type();
|
|
if ( !$type ) {
|
|
Error('No type in Object::Object'. $self->to_string()) if ref $self ne 'ZoneMinder::Log';
|
|
return undef;
|
|
} # end if
|
|
my ( $module ) = $type =~ /ZoneMinder::(.*)/;
|
|
if ( $module ) {
|
|
eval {
|
|
require "ZoneMinder/$module.pm";
|
|
};
|
|
if ( ! $$self{Object} ) {
|
|
$_ = $type->new($$self{object_id});
|
|
Debug( 'Returning object of type ' . ref $_ ) if $debug;
|
|
$$self{Object} = $_;
|
|
}
|
|
return $$self{Object};
|
|
} else {
|
|
Error("Unvalid object $type");
|
|
return new ZoneMinder::Object();
|
|
}
|
|
} # end sub Object
|
|
|
|
|
|
sub AUTOLOAD {
|
|
my $type = ref($_[0]);
|
|
Carp::cluck("No type in autoload") if ! $type;
|
|
if ( DEBUG_ALL ) {
|
|
Carp::cluck("Using AUTOLOAD $AUTOLOAD");
|
|
}
|
|
my $name = $AUTOLOAD;
|
|
$name =~ s/.*://;
|
|
if ( @_ > 1 ) {
|
|
return $_[0]{$name} = $_[1];
|
|
}
|
|
return $_[0]{$name};
|
|
}
|
|
|
|
sub DESTROY {
|
|
}
|
|
|
|
1;
|
|
__END__
|
|
|
|
# Below is stub documentation for your module. You'd better edit it!
|
|
|
|
=head1 NAME
|
|
|
|
ZoneMinder::Object
|
|
|
|
=head1 SYNOPSIS
|
|
|
|
use parent ZoneMinder::Object;
|
|
|
|
This package should likely not be used directly, as it is meant mainly to be a parent for all other ZoneMinder classes.
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
A base Object to act as parent for other ZoneMinder Objects.
|
|
|
|
=head2 EXPORT
|
|
|
|
None by default.
|
|
|
|
=head1 AUTHOR
|
|
|
|
Isaac Connor, E<lt>isaac@zoneminder.comE<gt>
|
|
|
|
=head1 COPYRIGHT AND LICENSE
|
|
|
|
Copyright (C) 2001-2017 ZoneMinder LLC
|
|
|
|
Licensed under the GNU General Public License v2 or later; see the COPYING
|
|
file distributed with ZoneMinder for the full text.
|
|
|
|
|
|
=cut
|