split out morphology; make all tests pass apart from morphology POD
[scpubgit/stemmatology.git] / base / lib / Text / Tradition / Directory.pm
CommitLineData
8d9a1cd8 1package Text::Tradition::Directory;
2
3use strict;
4use warnings;
5use Moose;
98a6cab2 6use DBI;
0a900793 7use Encode qw/ decode_utf8 /;
ad1291ee 8use KiokuDB::GC::Naive;
8d9a1cd8 9use KiokuDB::TypeMap;
10use KiokuDB::TypeMap::Entry::Naive;
951ddfe8 11use Safe::Isa;
861c3e27 12use Text::Tradition::Error;
8d9a1cd8 13
cf7e4e7b 14## users
15use KiokuX::User::Util qw(crypt_password);
f3f26624 16use Text::Tradition::Store;
cf7e4e7b 17use Text::Tradition::User;
f3f26624 18use Text::Tradition::TypeMap::Entry;
cf7e4e7b 19
8d9a1cd8 20extends 'KiokuX::Model';
21
12523041 22=head1 NAME
23
24Text::Tradition::Directory - a KiokuDB interface for storing and retrieving traditions
25
26=head1 SYNOPSIS
27
28 use Text::Tradition::Directory;
29 my $d = Text::Tradition::Directory->new(
30 'dsn' => 'dbi:SQLite:mytraditions.db',
31 'extra_args' => { 'create' => 1 },
32 );
33
34 my $tradition = Text::Tradition->new( @args );
951ddfe8 35 $tradition->enable_stemmata;
9ba651b9 36 my $stemma = $tradition->add_stemma( dotfile => $dotfile );
12523041 37 $d->save_tradition( $tradition );
12523041 38
39 foreach my $id ( $d->traditions ) {
40 print $d->tradition( $id )->name;
12523041 41 }
770f7a2b 42
43 ## Users:
44 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
45 my $newuser = $userstore->add_user({ username => 'fred',
46 password => 'somepassword' });
47
48 my $fetchuser = $userstore->find_user({ username => 'fred' });
49 if($fetchuser->check_password('somepassword')) {
50 ## login user or .. whatever
51 }
52
53 my $user = $userstore->deactivate_user({ username => 'fred' });
54 if(!$user->active) {
55 ## shouldnt be able to login etc
56 }
12523041 57
58=head1 DESCRIPTION
59
60Text::Tradition::Directory is an interface for storing and retrieving text traditions and all their data, including an associated stemma hypothesis. It is an instantiation of a KiokuDB::Model, storing traditions and associated stemmas by UUID.
61
770f7a2b 62=head1 ATTRIBUTES
63
64=head2 MIN_PASS_LEN
65
66Constant for the minimum password length when validating passwords,
67defaults to "8".
68
69=cut
70
71has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
72
12523041 73=head1 METHODS
74
75=head2 new
76
56cf65bd 77Returns a Directory object.
12523041 78
98a6cab2 79=head2 traditionlist
12523041 80
98a6cab2 81Returns a hashref mapping of ID => name for all traditions in the directory.
12523041 82
83=head2 tradition( $id )
84
85Returns the Text::Tradition object of the given ID.
86
56cf65bd 87=head2 save( $tradition )
12523041 88
56cf65bd 89Writes the given tradition to the database, returning its ID.
12523041 90
d7ba60b4 91=head2 delete( $tradition )
92
93Deletes the given tradition object from the database.
94WARNING!! Garbage collection does not yet work. Use this sparingly.
95
12523041 96=begin testing
97
861c3e27 98use TryCatch;
12523041 99use File::Temp;
951ddfe8 100use Safe::Isa;
12523041 101use Text::Tradition;
12523041 102use_ok 'Text::Tradition::Directory';
103
104my $fh = File::Temp->new();
105my $file = $fh->filename;
106$fh->close;
107my $dsn = "dbi:SQLite:dbname=$file";
861c3e27 108my $uuid;
12523041 109my $t = Text::Tradition->new(
56cf65bd 110 'name' => 'inline',
111 'input' => 'Tabular',
112 'file' => 't/data/simple.txt',
113 );
951ddfe8 114my $stemma_enabled;
115eval { $stemma_enabled = $t->enable_stemmata; };
56cf65bd 116
861c3e27 117{
118 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
119 'extra_args' => { 'create' => 1 } );
951ddfe8 120 ok( $d->$_isa('Text::Tradition::Directory'), "Got directory object" );
861c3e27 121
122 my $scope = $d->new_scope;
123 $uuid = $d->save( $t );
124 ok( $uuid, "Saved test tradition" );
125
951ddfe8 126 SKIP: {
127 skip "Analysis package not installed", 5 unless $stemma_enabled;
128 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
129 ok( $d->save( $t ), "Updated tradition with stemma" );
130 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
131 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
132 try {
133 $d->save( $s );
134 } catch( Text::Tradition::Error $e ) {
135 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
136 like( $e->message, qr/Cannot directly save non-Tradition object/,
137 "Exception has correct message" );
138 }
861c3e27 139 }
140}
141my $nt = Text::Tradition->new(
142 'name' => 'CX',
143 'input' => 'CollateX',
144 'file' => 't/data/Collatex-16.xml',
145 );
951ddfe8 146ok( $nt->$_isa('Text::Tradition'), "Made new tradition" );
861c3e27 147
148{
149 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
150 my $scope = $f->new_scope;
98a6cab2 151 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
861c3e27 152 my $nuuid = $f->save( $nt );
153 ok( $nuuid, "Stored second tradition" );
98a6cab2 154 my @tlist = $f->traditionlist;
155 is( scalar @tlist, 2, "Directory index has both traditions" );
861c3e27 156 my $tf = $f->tradition( $uuid );
98a6cab2 157 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
158 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
861c3e27 159 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
951ddfe8 160 my $sid;
161 SKIP: {
162 skip "Analysis package not installed", 4 unless $stemma_enabled;
163 $sid = $f->object_to_id( $tf->stemma(0) );
164 try {
165 $f->tradition( $sid );
166 } catch( Text::Tradition::Error $e ) {
167 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
168 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
169 }
170 try {
171 $f->delete( $sid );
172 } catch( Text::Tradition::Error $e ) {
173 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
174 like( $e->message, qr/Cannot directly delete non-Tradition object/,
175 "Exception has correct message" );
176 }
861c3e27 177 }
ad39942e 178
861c3e27 179 $f->delete( $uuid );
180 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
951ddfe8 181 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" ) if $stemma_enabled;
98a6cab2 182 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
861c3e27 183}
184
d7ba60b4 185{
861c3e27 186 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
187 my $scope = $g->new_scope;
98a6cab2 188 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
ad39942e 189 my $ntobj = $g->tradition( 'CX' );
09909f9d 190 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
191 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
ad39942e 192 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
861c3e27 193}
12523041 194
195=end testing
196
197=cut
fc7b6388 198use Text::Tradition::TypeMap::Entry;
12523041 199
12523041 200has +typemap => (
fc7b6388 201 is => 'rw',
202 isa => 'KiokuDB::TypeMap',
203 default => sub {
204 KiokuDB::TypeMap->new(
205 isa_entries => {
f3f26624 206 # now that we fall back to YAML deflation, all attributes of
207 # Text::Tradition will be serialized to YAML as individual objects
208 # Except if we declare a specific entry type here
fc7b6388 209 "Text::Tradition" =>
f3f26624 210 KiokuDB::TypeMap::Entry::MOP->new(),
211 # We need users to be naive entries so that they hold
212 # references to the original tradition objects, not clones
213 "Text::Tradition::User" =>
214 KiokuDB::TypeMap::Entry::MOP->new(),
215 "Text::Tradition::Collation" =>
216 KiokuDB::TypeMap::Entry::MOP->new(),
217 "Text::Tradition::Witness" =>
218 KiokuDB::TypeMap::Entry::MOP->new(),
fb4caab6 219 "Graph" => Text::Tradition::TypeMap::Entry->new(),
7e17346f 220 "Set::Scalar" => Text::Tradition::TypeMap::Entry->new(),
fc7b6388 221 }
222 );
223 },
8d9a1cd8 224);
225
98a6cab2 226# Push some columns into the extra_args
227around BUILDARGS => sub {
228 my $orig = shift;
229 my $class = shift;
230 my $args;
231 if( @_ == 1 ) {
232 $args = $_[0];
233 } else {
234 $args = { @_ };
235 }
f3f26624 236 my @column_args;
98a6cab2 237 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
f3f26624 238 @column_args = ( 'columns',
52dcc672 239 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 },
240 'public' => { 'data_type' => 'bool', 'is_nullable' => 1 } ] );
98a6cab2 241 }
f3f26624 242 my $ea = $args->{'extra_args'};
243 if( ref( $ea ) eq 'ARRAY' ) {
244 push( @$ea, @column_args );
245 } elsif( ref( $ea ) eq 'HASH' ) {
246 $ea = { %$ea, @column_args };
247 } else {
248 $ea = { @column_args };
249 }
250 $args->{'extra_args'} = $ea;
251
98a6cab2 252 return $class->$orig( $args );
253};
254
f3f26624 255override _build_directory => sub {
256 my($self) = @_;
257 Text::Tradition::Store->connect(@{ $self->_connect_args },
258 resolver_constructor => sub {
259 my($class) = @_;
260 $class->new({ typemap => $self->directory->merged_typemap,
261 fallback_entry => Text::Tradition::TypeMap::Entry->new() });
262 });
263};
264
7cb56251 265## These checks don't cover store($id, $obj)
fc7b6388 266# before [ qw/ store update insert delete / ] => sub {
267before [ qw/ delete / ] => sub {
8d9a1cd8 268 my $self = shift;
861c3e27 269 my @nontrad;
270 foreach my $obj ( @_ ) {
951ddfe8 271 if( ref( $obj ) && !$obj->$_isa( 'Text::Tradition' )
272 && !$obj->$_isa('Text::Tradition::User') ) {
861c3e27 273 # Is it an id => Tradition hash?
274 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
275 my( $k ) = keys %$obj;
951ddfe8 276 next if $obj->{$k}->$_isa('Text::Tradition');
8d9a1cd8 277 }
861c3e27 278 push( @nontrad, $obj );
8d9a1cd8 279 }
12523041 280 }
861c3e27 281 if( @nontrad ) {
282 throw( "Cannot directly save non-Tradition object of type "
283 . ref( $nontrad[0] ) );
284 }
285};
12523041 286
d7ba60b4 287# TODO Garbage collection doesn't work. Suck it up and live with the
288# inflated DB.
d94224d9 289after delete => sub {
290 my $self = shift;
291 my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
292 $self->directory->backend->delete( $gc->garbage->members );
293};
56cf65bd 294
295sub save {
861c3e27 296 my $self = shift;
297 return $self->store( @_ );
12523041 298}
299
56cf65bd 300sub tradition {
301 my( $self, $id ) = @_;
302 my $obj = $self->lookup( $id );
ad39942e 303 unless( $obj ) {
304 # Try looking up by name.
305 foreach my $item ( $self->traditionlist ) {
306 if( $item->{'name'} eq $id ) {
307 $obj = $self->lookup( $item->{'id'} );
308 last;
309 }
310 }
311 }
951ddfe8 312 if( $obj && !$obj->$_isa('Text::Tradition') ) {
861c3e27 313 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
12523041 314 }
56cf65bd 315 return $obj;
12523041 316}
8d9a1cd8 317
98a6cab2 318sub traditionlist {
861c3e27 319 my $self = shift;
fefeeeda 320 my ($user) = @_;
321
322 return $self->user_traditionlist($user) if($user);
323
324 my @tlist;
98a6cab2 325 # If we are using DBI, we can do it the easy way; if not, the hard way.
326 # Easy way still involves making a separate DBI connection. Ew.
0a900793 327 if( $self->dsn =~ /^dbi:(\w+):/ ) {
328 my $dbtype = $1;
98a6cab2 329 my @connection = @{$self->directory->backend->connect_info};
330 # Get rid of KiokuDB-specific arg
331 pop @connection if scalar @connection > 4;
0a900793 332 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
333 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
98a6cab2 334 my $dbh = DBI->connect( @connection );
52dcc672 335 my $q = $dbh->prepare( 'SELECT id, name, public from entries WHERE class = "Text::Tradition"' );
98a6cab2 336 $q->execute();
337 while( my @row = $q->fetchrow_array ) {
0a900793 338 my( $id, $name ) = @row;
339 # Horrible horrible hack
340 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
52dcc672 341 push( @tlist, { 'id' => $row[0], 'name' => $row[1], 'public' => $row[2] } );
98a6cab2 342 }
343 } else {
344 $self->scan( sub { my $o = shift;
345 push( @tlist, { 'id' => $self->object_to_id( $o ),
52dcc672 346 'name' => $o->name,
347 'public' => $o->public } ) } );
98a6cab2 348 }
349 return @tlist;
861c3e27 350}
351
352sub throw {
353 Text::Tradition::Error->throw(
354 'ident' => 'database error',
355 'message' => $_[0],
356 );
357}
358
cf7e4e7b 359
360# has 'directory' => (
361# is => 'rw',
362# isa => 'KiokuX::Model',
363# handles => []
364# );
365
366## TODO: Some of these methods should probably optionally take $user objects
367## instead of hashrefs.
368
369## It also occurs to me that all these methods don't need to be named
370## XX_user, but leaving that way for now incase we merge this code
371## into ::Directory for one-store.
372
a445ce40 373=head1 USER DIRECTORY METHODS
cf7e4e7b 374
a445ce40 375=head2 add_user( $userinfo )
cf7e4e7b 376
377Takes a hashref of C<username>, C<password>.
378
379Create a new user object, store in the KiokuDB backend, and return it.
380
381=cut
382
383sub add_user {
384 my ($self, $userinfo) = @_;
10ef7653 385
386 my $username = $userinfo->{username};
cf7e4e7b 387 my $password = $userinfo->{password};
7cb56251 388 my $role = $userinfo->{role} || 'user';
cf7e4e7b 389
b77f6c1b 390 throw( "No username given" ) unless $username;
391 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
392 . " characters long" )
393 unless ( $self->validate_password($password) || $username =~ /^https?:/ );
cf7e4e7b 394
395 my $user = Text::Tradition::User->new(
396 id => $username,
397 password => ($password ? crypt_password($password) : ''),
a528f0f6 398 email => ($userinfo->{email} ? $userinfo->{email} : $username),
7cb56251 399 role => $role,
cf7e4e7b 400 );
401
cf7e4e7b 402 $self->store($user->kiokudb_object_id, $user);
403
404 return $user;
405}
406
a445ce40 407=head2 create_user( $userinfo )
408
409Takes a hashref that can either be suitable for add_user (see above) or be
410a hash of OpenID user information from Credential::OpenID.
411
412=cut
413
cf7e4e7b 414sub create_user {
10ef7653 415 my ($self, $userinfo) = @_;
416
417 ## No username means probably an OpenID based user
418 if(!exists $userinfo->{username}) {
a445ce40 419 _extract_openid_data($userinfo);
10ef7653 420 }
421
422 return $self->add_user($userinfo);
423}
424
425## Not quite sure where this method should be.. Auth /
426## Credential::OpenID just pass us back the chunk of extension data
a445ce40 427sub _extract_openid_data {
10ef7653 428 my ($userinfo) = @_;
429
430 ## Spec says SHOULD use url as identifier
431 $userinfo->{username} = $userinfo->{url};
432
433 ## Use email addy as display if available
434 if(exists $userinfo->{extensions} &&
435 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
436 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
437 ## Somewhat ugly attribute extension reponse, contains
438 ## google-email string which we can use as the id
439
a528f0f6 440 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
10ef7653 441 }
442
443 return;
cf7e4e7b 444}
445
a445ce40 446=head2 find_user( $userinfo )
cf7e4e7b 447
10ef7653 448Takes a hashref of C<username>, and possibly openIDish results from
449L<Net::OpenID::Consumer>.
cf7e4e7b 450
451Fetches the user object for the given username and returns it.
452
453=cut
454
455sub find_user {
456 my ($self, $userinfo) = @_;
10ef7653 457
458 ## No username means probably an OpenID based user
459 if(!exists $userinfo->{username}) {
a445ce40 460 _extract_openid_data($userinfo);
10ef7653 461 }
462
463 my $username = $userinfo->{username};
cf7e4e7b 464
df8c12f0 465 ## No logins if user is deactivated (use lookup to fetch to re-activate)
466 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
10ef7653 467 return if(!$user || !$user->active);
468
c80a73ea 469# print STDERR "Found user, $username, email is :", $user->email, ":\n";
df8c12f0 470
471 return $user;
cf7e4e7b 472}
473
a445ce40 474=head2 modify_user( $userinfo )
cf7e4e7b 475
476Takes a hashref of C<username> and C<password> (same as add_user).
477
478Retrieves the user, and updates it with the new information. Username
479changing is not currently supported.
480
481Returns the updated user object, or undef if not found.
482
483=cut
484
485sub modify_user {
486 my ($self, $userinfo) = @_;
487 my $username = $userinfo->{username};
488 my $password = $userinfo->{password};
4d4c5789 489 my $role = $userinfo->{role};
cf7e4e7b 490
52dcc672 491 throw( "Missing username" ) unless $username;
cf7e4e7b 492
cf7e4e7b 493 my $user = $self->find_user({ username => $username });
b77f6c1b 494 throw( "Could not find user $username" ) unless $user;
cf7e4e7b 495
4d4c5789 496 if($password) {
52dcc672 497 throw( "Bad password" ) unless $self->validate_password($password);
4d4c5789 498 $user->password(crypt_password($password));
499 }
500 if($role) {
501 $user->role($role);
502 }
cf7e4e7b 503
504 $self->update($user);
505
506 return $user;
507}
508
a445ce40 509=head2 deactivate_user( $userinfo )
cf7e4e7b 510
511Takes a hashref of C<username>.
512
513Sets the users C<active> flag to false (0), and sets all traditions
514assigned to them to non-public, updates the storage and returns the
515deactivated user.
516
517Returns undef if user not found.
518
519=cut
520
521sub deactivate_user {
522 my ($self, $userinfo) = @_;
523 my $username = $userinfo->{username};
524
b77f6c1b 525 throw( "Need to specify a username for deactivation" ) unless $username;
cf7e4e7b 526
527 my $user = $self->find_user({ username => $username });
b77f6c1b 528 throw( "User $username not found" ) unless $user;
cf7e4e7b 529
530 $user->active(0);
531 foreach my $tradition (@{ $user->traditions }) {
532 ## Not implemented yet
533 # $tradition->public(0);
534 }
cf7e4e7b 535
536 ## Should we be using Text::Tradition::Directory also?
537 $self->update(@{ $user->traditions });
538
539 $self->update($user);
540
541 return $user;
542}
543
a445ce40 544=head2 reactivate_user( $userinfo )
cf7e4e7b 545
546Takes a hashref of C<username>.
547
548Returns the user object if already activated. Activates (sets the
549active flag to true (1)), updates the storage and returns the user.
550
551Returns undef if the user is not found.
552
553=cut
554
555sub reactivate_user {
556 my ($self, $userinfo) = @_;
557 my $username = $userinfo->{username};
558
b77f6c1b 559 throw( "Need to specify a username for reactivation" ) unless $username;
cf7e4e7b 560
df8c12f0 561 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
b77f6c1b 562 throw( "User $username not found" ) unless $user;
cf7e4e7b 563
564 return $user if $user->active;
565
566 $user->active(1);
567 $self->update($user);
568
569 return $user;
570}
571
a445ce40 572=head2 delete_user( $userinfo )
cf7e4e7b 573
770f7a2b 574CAUTION: Deletes actual data!
cf7e4e7b 575
576Takes a hashref of C<username>.
577
578Returns undef if the user doesn't exist.
579
580Removes the user from the store and returns 1.
581
582=cut
583
584sub delete_user {
585 my ($self, $userinfo) = @_;
586 my $username = $userinfo->{username};
587
b77f6c1b 588 throw( "Need to specify a username for deletion" ) unless $username;
cf7e4e7b 589
cf7e4e7b 590 my $user = $self->find_user({ username => $username });
b77f6c1b 591 throw( "User $username not found" ) unless $user;
cf7e4e7b 592
593 ## Should we be using Text::Tradition::Directory for this bit?
594 $self->delete( @{ $user->traditions });
595
596 ## Poof, gone.
597 $self->delete($user);
598
599 return 1;
600}
601
a445ce40 602=head2 validate_password( $password )
cf7e4e7b 603
604Takes a password string. Returns true if it is longer than
605L</MIN_PASS_LEN>, false otherwise.
606
607Used internally by L</add_user>.
608
609=cut
610
611sub validate_password {
612 my ($self, $password) = @_;
613
614 return if !$password;
615 return if length($password) < $self->MIN_PASS_LEN;
616
617 return 1;
618}
619
a445ce40 620=head2 user_traditionlist( $user )
621
622Returns a tradition list (see specification above) but containing only
623those traditions visible to the specified user. If $user is the string
624'public', returns only publicly-viewable traditions.
625
626=cut
627
628sub user_traditionlist {
629 my ($self, $user) = @_;
630
631 my @tlist;
632 if(ref $user && $user->is_admin) {
633 ## Admin sees all
634 return $self->traditionlist();
635 } elsif(ref $user) {
636 ## We have a user object already, so just fetch its traditions and use tose
637 foreach my $t (@{ $user->traditions }) {
638 push( @tlist, { 'id' => $self->object_to_id( $t ),
639 'name' => $t->name } );
640 }
641 return @tlist;
642 } elsif($user ne 'public') {
643 die "Passed neither a user object nor 'public' to user_traditionlist";
644 }
645
646 ## Search for all traditions which allow public viewing
647 my @list = grep { $_->{public} } $self->traditionlist();
648 return @list;
649}
650
8d9a1cd8 6511;
12523041 652
027d819c 653=head1 LICENSE
654
655This package is free software and is provided "as is" without express
656or implied warranty. You can redistribute it and/or modify it under
657the same terms as Perl itself.
658
659=head1 AUTHOR
660
661Tara L Andrews E<lt>aurum@cpan.orgE<gt>