... | ... |
@@ -58,8 +58,80 @@ sub startup { |
58 | 58 |
} |
59 | 59 |
}); |
60 | 60 |
|
61 |
+ # DBI |
|
62 |
+ my $db_file = $self->home->rel_file('db/gitprep.db'); |
|
63 |
+ my $dbi = DBIx::Custom->connect( |
|
64 |
+ dsn => "dbi:SQLite:database=$db_file", |
|
65 |
+ connector => 1, |
|
66 |
+ option => {sqlite_unicode => 1} |
|
67 |
+ ); |
|
68 |
+ $self->dbi($dbi); |
|
69 |
+ |
|
70 |
+ # Create user table |
|
71 |
+ eval { |
|
72 |
+ my $sql = <<"EOS"; |
|
73 |
+create table user ( |
|
74 |
+ row_id integer primary key autoincrement, |
|
75 |
+ id not null unique, |
|
76 |
+ config not null |
|
77 |
+); |
|
78 |
+EOS |
|
79 |
+ $dbi->execute($sql); |
|
80 |
+ }; |
|
81 |
+ |
|
82 |
+ # Create project table |
|
83 |
+ eval { |
|
84 |
+ my $sql = <<"EOS"; |
|
85 |
+create table project ( |
|
86 |
+ row_id integer primary key autoincrement, |
|
87 |
+ user_id not null, |
|
88 |
+ name not null, |
|
89 |
+ config not null, |
|
90 |
+ unique(user_id, name) |
|
91 |
+); |
|
92 |
+EOS |
|
93 |
+ $dbi->execute($sql); |
|
94 |
+ }; |
|
95 |
+ |
|
96 |
+ # Model |
|
97 |
+ my $models = [ |
|
98 |
+ {table => 'user', primary_key => 'id'}, |
|
99 |
+ {table => 'project', primary_key => ['user_id', 'name']} |
|
100 |
+ ]; |
|
101 |
+ $dbi->create_model($_) for @$models; |
|
102 |
+ |
|
103 |
+ # Fiter |
|
104 |
+ $dbi->register_filter(json => sub { |
|
105 |
+ my $value = shift; |
|
106 |
+ |
|
107 |
+ if (ref $value) { |
|
108 |
+ return decode('UTF-8', Mojo::JSON->new->encode($value)); |
|
109 |
+ } |
|
110 |
+ else { |
|
111 |
+ return Mojo::JSON->new->decode(encode('UTF-8', $value)); |
|
112 |
+ } |
|
113 |
+ }); |
|
114 |
+ |
|
115 |
+ # Validator |
|
116 |
+ my $validator = Validator::Custom->new; |
|
117 |
+ $self->validator($validator); |
|
118 |
+ |
|
119 |
+ # Helper |
|
120 |
+ $self->helper(gitprep_api => sub { Gitprep::API->new(shift) }); |
|
121 |
+ |
|
61 | 122 |
# Route |
62 | 123 |
my $r = $self->routes->route->to('main#'); |
124 |
+ |
|
125 |
+ # DBViewer(only development) |
|
126 |
+ if ($self->mode eq 'development') { |
|
127 |
+ eval { |
|
128 |
+ $self->plugin( |
|
129 |
+ 'DBViewer', |
|
130 |
+ dsn => "dbi:SQLite:database=$db_file", |
|
131 |
+ route => $r |
|
132 |
+ ); |
|
133 |
+ }; |
|
134 |
+ } |
|
63 | 135 |
|
64 | 136 |
# Home |
65 | 137 |
$r->get('/')->to('#home'); |
... | ... |
@@ -124,49 +196,6 @@ sub startup { |
124 | 196 |
# Compare |
125 | 197 |
$r->get('/compare/(#rev1)...(#rev2)')->to('#compare'); |
126 | 198 |
} |
127 |
- |
|
128 |
- # DBI |
|
129 |
- my $db_file = $self->home->rel_file('db/gitprep.db'); |
|
130 |
- my $dbi = DBIx::Custom->connect( |
|
131 |
- dsn => "dbi:SQLite:database=$db_file", |
|
132 |
- connector => 1, |
|
133 |
- option => {sqlite_unicode => 1} |
|
134 |
- ); |
|
135 |
- |
|
136 |
- eval { |
|
137 |
- # Create table |
|
138 |
- my $sql = <<"EOS"; |
|
139 |
-create table user ( |
|
140 |
- row_id integer primary key autoincrement, |
|
141 |
- id not null unique, |
|
142 |
- config not null |
|
143 |
-); |
|
144 |
-EOS |
|
145 |
- $dbi->execute($sql); |
|
146 |
- }; |
|
147 |
- $self->dbi($dbi); |
|
148 |
- |
|
149 |
- # Model |
|
150 |
- $dbi->create_model({table => 'user', primary_key => 'id'}); |
|
151 |
- |
|
152 |
- # Fiter |
|
153 |
- $dbi->register_filter(json => sub { |
|
154 |
- my $value = shift; |
|
155 |
- |
|
156 |
- if (ref $value) { |
|
157 |
- return decode('UTF-8', Mojo::JSON->new->encode($value)); |
|
158 |
- } |
|
159 |
- else { |
|
160 |
- return Mojo::JSON->new->decode(encode('UTF-8', $value)); |
|
161 |
- } |
|
162 |
- }); |
|
163 |
- |
|
164 |
- # Validator |
|
165 |
- my $validator = Validator::Custom->new; |
|
166 |
- $self->validator($validator); |
|
167 |
- |
|
168 |
- # Helper |
|
169 |
- $self->helper(gitprep_api => sub { Gitprep::API->new(shift) }); |
|
170 | 199 |
} |
171 | 200 |
|
172 | 201 |
1; |
... | ... |
@@ -5,7 +5,7 @@ use Carp 'croak'; |
5 | 5 |
use File::Find 'find'; |
6 | 6 |
use File::Basename qw/basename dirname/; |
7 | 7 |
use Fcntl ':mode'; |
8 |
-use File::Path 'mkpath'; |
|
8 |
+use File::Path qw/mkpath rmtree/; |
|
9 | 9 |
use File::Copy 'move'; |
10 | 10 |
|
11 | 11 |
# Attributes |
... | ... |
@@ -198,69 +198,6 @@ sub blob_size_kb { |
198 | 198 |
return $size_kb; |
199 | 199 |
} |
200 | 200 |
|
201 |
-sub check_head_link { |
|
202 |
- my ($self, $dir) = @_; |
|
203 |
- |
|
204 |
- # Chack head |
|
205 |
- my $head_file = "$dir/HEAD"; |
|
206 |
- return ((-e $head_file) || |
|
207 |
- (-l $head_file && readlink($head_file) =~ /^refs\/heads\//)); |
|
208 |
-} |
|
209 |
- |
|
210 |
-sub _cmd { |
|
211 |
- my ($self, $user, $project, @cmd) = @_; |
|
212 |
- |
|
213 |
- my $home = $self->rep_home; |
|
214 |
- |
|
215 |
- my $rep = "$home/$user/$project.git"; |
|
216 |
- |
|
217 |
- # Execute git command |
|
218 |
- return ($self->bin, "--git-dir=$rep", @cmd); |
|
219 |
-} |
|
220 |
- |
|
221 |
-sub create_repository { |
|
222 |
- my ($self, $user, $project, $opts) = @_; |
|
223 |
- |
|
224 |
- # Repository |
|
225 |
- my $rep_home = $self->rep_home; |
|
226 |
- my $rep = "$rep_home/$user/$project.git"; |
|
227 |
- mkpath $rep; |
|
228 |
- |
|
229 |
- # Git init |
|
230 |
- my @git_init_cmd = $self->_cmd($user, $project, 'init', '--bare'); |
|
231 |
- system(@git_init_cmd) == 0 |
|
232 |
- or croak "Can't execute git init"; |
|
233 |
- |
|
234 |
- # Add git-daemon-export-ok |
|
235 |
- { |
|
236 |
- my $file = "$rep/git-daemon-export-ok"; |
|
237 |
- open my $fh, '>', $file |
|
238 |
- or croak "Can't create git-daemon-export-ok: $!" |
|
239 |
- } |
|
240 |
- |
|
241 |
- # HTTP support |
|
242 |
- my @git_update_server_info_cmd = $self->_cmd( |
|
243 |
- $user, |
|
244 |
- $project, |
|
245 |
- '--bare', |
|
246 |
- 'update-server-info' |
|
247 |
- ); |
|
248 |
- system(@git_update_server_info_cmd) == 0 |
|
249 |
- or croak "Can't execute git --bare update-server-info"; |
|
250 |
- move("$rep/hooks/post-update.sample", "$rep/hooks/post-update") |
|
251 |
- or croak "Can't move post-update"; |
|
252 |
- |
|
253 |
- # Description |
|
254 |
- if (my $description = $opts->{description}) { |
|
255 |
- my $file = "$rep/description"; |
|
256 |
- open my $fh, '>', $file |
|
257 |
- or croak "Can't open $file: $!"; |
|
258 |
- print $fh $description |
|
259 |
- or croak "Can't write $file: $!"; |
|
260 |
- close $fh; |
|
261 |
- } |
|
262 |
-} |
|
263 |
- |
|
264 | 201 |
sub branch_exists { |
265 | 202 |
my ($self, $user, $project) = @_; |
266 | 203 |
|
... | ... |
@@ -316,31 +253,63 @@ sub branch_commits { |
316 | 253 |
return $commits; |
317 | 254 |
} |
318 | 255 |
|
319 |
-sub separated_commit { |
|
320 |
- my ($self, $user, $project, $rev1, $rev2) = @_; |
|
256 |
+sub check_head_link { |
|
257 |
+ my ($self, $dir) = @_; |
|
321 | 258 |
|
322 |
- # Command "git diff-tree" |
|
323 |
- my @cmd = $self->_cmd( |
|
324 |
- $user, |
|
325 |
- $project, |
|
326 |
- 'show-branch', |
|
327 |
- $rev1, |
|
328 |
- $rev2 |
|
329 |
- ); |
|
330 |
- open my $fh, "-|", @cmd |
|
331 |
- or croak 500, "Open git-show-branch failed"; |
|
259 |
+ # Chack head |
|
260 |
+ my $head_file = "$dir/HEAD"; |
|
261 |
+ return ((-e $head_file) || |
|
262 |
+ (-l $head_file && readlink($head_file) =~ /^refs\/heads\//)); |
|
263 |
+} |
|
332 | 264 |
|
333 |
- my $commits = []; |
|
334 |
- my $start; |
|
335 |
- my @lines = <$fh>; |
|
336 |
- my $last_line = pop @lines; |
|
337 |
- my $commit; |
|
338 |
- if (defined $last_line) { |
|
339 |
- my ($id) = $last_line =~ /^.*?\[(.+)?\]/; |
|
340 |
- $commit = $self->parse_commit($user, $project, $id); |
|
341 |
- } |
|
265 |
+sub create_repository { |
|
266 |
+ my ($self, $user, $project, $opts) = @_; |
|
342 | 267 |
|
343 |
- return $commit; |
|
268 |
+ my $rep_home = $self->rep_home; |
|
269 |
+ my $rep = "$rep_home/$user/$project.git"; |
|
270 |
+ eval { |
|
271 |
+ # Repository |
|
272 |
+ mkpath $rep; |
|
273 |
+ |
|
274 |
+ # Git init |
|
275 |
+ my @git_init_cmd = $self->_cmd($user, $project, 'init', '--bare'); |
|
276 |
+ system(@git_init_cmd) == 0 |
|
277 |
+ or croak "Can't execute git init"; |
|
278 |
+ |
|
279 |
+ # Add git-daemon-export-ok |
|
280 |
+ { |
|
281 |
+ my $file = "$rep/git-daemon-export-ok"; |
|
282 |
+ open my $fh, '>', $file |
|
283 |
+ or croak "Can't create git-daemon-export-ok: $!" |
|
284 |
+ } |
|
285 |
+ |
|
286 |
+ # HTTP support |
|
287 |
+ my @git_update_server_info_cmd = $self->_cmd( |
|
288 |
+ $user, |
|
289 |
+ $project, |
|
290 |
+ '--bare', |
|
291 |
+ 'update-server-info' |
|
292 |
+ ); |
|
293 |
+ system(@git_update_server_info_cmd) == 0 |
|
294 |
+ or croak "Can't execute git --bare update-server-info"; |
|
295 |
+ move("$rep/hooks/post-update.sample", "$rep/hooks/post-update") |
|
296 |
+ or croak "Can't move post-update"; |
|
297 |
+ |
|
298 |
+ # Description |
|
299 |
+ if (my $description = $opts->{description}) { |
|
300 |
+ my $file = "$rep/description"; |
|
301 |
+ open my $fh, '>', $file |
|
302 |
+ or croak "Can't open $file: $!"; |
|
303 |
+ print $fh $description |
|
304 |
+ or croak "Can't write $file: $!"; |
|
305 |
+ close $fh; |
|
306 |
+ } |
|
307 |
+ }; |
|
308 |
+ if ($@) { |
|
309 |
+ my $error = $@; |
|
310 |
+ $self->remove_repository($user, $project); |
|
311 |
+ die "$error\n"; |
|
312 |
+ } |
|
344 | 313 |
} |
345 | 314 |
|
346 | 315 |
sub commits_number { |
... | ... |
@@ -363,6 +332,12 @@ sub commits_number { |
363 | 332 |
return $commits_num; |
364 | 333 |
} |
365 | 334 |
|
335 |
+sub exists_repository { |
|
336 |
+ my ($self, $user, $project) = @_; |
|
337 |
+ |
|
338 |
+ return -e $self->rep($user, $project); |
|
339 |
+} |
|
340 |
+ |
|
366 | 341 |
sub file_type { |
367 | 342 |
my ($self, $mode) = @_; |
368 | 343 |
|
... | ... |
@@ -732,6 +707,7 @@ sub projects { |
732 | 707 |
$rep->{age} = $activity[0]; |
733 | 708 |
$rep->{age_string} = $activity[1]; |
734 | 709 |
} |
710 |
+ else { $rep->{age} = 0 } |
|
735 | 711 |
|
736 | 712 |
my $description = $self->description($user, $project) || ''; |
737 | 713 |
$rep->{description} = $self->_chop_str($description, 25, 5); |
... | ... |
@@ -1151,6 +1127,16 @@ sub parse_ls_tree_line { |
1151 | 1127 |
return \%res; |
1152 | 1128 |
} |
1153 | 1129 |
|
1130 |
+sub remove_repository { |
|
1131 |
+ my ($self, $user, $project) = @_; |
|
1132 |
+ |
|
1133 |
+ my $rep_home = $self->rep_home; |
|
1134 |
+ croak "Can't remove repository. repositry home is empty" |
|
1135 |
+ if !defined $rep_home || $rep_home eq ''; |
|
1136 |
+ my $rep = "$rep_home/$user/$project.git"; |
|
1137 |
+ rmtree $rep; |
|
1138 |
+} |
|
1139 |
+ |
|
1154 | 1140 |
sub search_bin { |
1155 | 1141 |
my $self = shift; |
1156 | 1142 |
|
... | ... |
@@ -1175,6 +1161,33 @@ sub search_bin { |
1175 | 1161 |
return; |
1176 | 1162 |
} |
1177 | 1163 |
|
1164 |
+sub separated_commit { |
|
1165 |
+ my ($self, $user, $project, $rev1, $rev2) = @_; |
|
1166 |
+ |
|
1167 |
+ # Command "git diff-tree" |
|
1168 |
+ my @cmd = $self->_cmd( |
|
1169 |
+ $user, |
|
1170 |
+ $project, |
|
1171 |
+ 'show-branch', |
|
1172 |
+ $rev1, |
|
1173 |
+ $rev2 |
|
1174 |
+ ); |
|
1175 |
+ open my $fh, "-|", @cmd |
|
1176 |
+ or croak 500, "Open git-show-branch failed"; |
|
1177 |
+ |
|
1178 |
+ my $commits = []; |
|
1179 |
+ my $start; |
|
1180 |
+ my @lines = <$fh>; |
|
1181 |
+ my $last_line = pop @lines; |
|
1182 |
+ my $commit; |
|
1183 |
+ if (defined $last_line) { |
|
1184 |
+ my ($id) = $last_line =~ /^.*?\[(.+)?\]/; |
|
1185 |
+ $commit = $self->parse_commit($user, $project, $id); |
|
1186 |
+ } |
|
1187 |
+ |
|
1188 |
+ return $commit; |
|
1189 |
+} |
|
1190 |
+ |
|
1178 | 1191 |
sub snapshot_name { |
1179 | 1192 |
my ($self, $project, $cid) = @_; |
1180 | 1193 |
|
... | ... |
@@ -1285,33 +1298,6 @@ sub _chop_str { |
1285 | 1298 |
} |
1286 | 1299 |
} |
1287 | 1300 |
|
1288 |
-sub _mode_str { |
|
1289 |
- my $self = shift; |
|
1290 |
- my $mode = oct shift; |
|
1291 |
- |
|
1292 |
- # Mode to string |
|
1293 |
- if ($self->_s_isgitlink($mode)) { return 'm---------' } |
|
1294 |
- elsif (S_ISDIR($mode & S_IFMT)) { return 'drwxr-xr-x' } |
|
1295 |
- elsif (S_ISLNK($mode)) { return 'lrwxrwxrwx' } |
|
1296 |
- elsif (S_ISREG($mode)) { |
|
1297 |
- if ($mode & S_IXUSR) { |
|
1298 |
- return '-rwxr-xr-x'; |
|
1299 |
- } else { |
|
1300 |
- return '-rw-r--r--' |
|
1301 |
- } |
|
1302 |
- } else { return '----------' } |
|
1303 |
- |
|
1304 |
- return; |
|
1305 |
-} |
|
1306 |
- |
|
1307 |
-sub _s_isgitlink { |
|
1308 |
- my ($self, $mode) = @_; |
|
1309 |
- |
|
1310 |
- # Check if git link |
|
1311 |
- my $s_ifgitlink = 0160000; |
|
1312 |
- return (($mode & S_IFMT) == $s_ifgitlink) |
|
1313 |
-} |
|
1314 |
- |
|
1315 | 1301 |
sub timestamp { |
1316 | 1302 |
my ($self, $date) = @_; |
1317 | 1303 |
|
... | ... |
@@ -1370,6 +1356,44 @@ sub trees { |
1370 | 1356 |
return $trees; |
1371 | 1357 |
} |
1372 | 1358 |
|
1359 |
+sub _cmd { |
|
1360 |
+ my ($self, $user, $project, @cmd) = @_; |
|
1361 |
+ |
|
1362 |
+ my $home = $self->rep_home; |
|
1363 |
+ |
|
1364 |
+ my $rep = "$home/$user/$project.git"; |
|
1365 |
+ |
|
1366 |
+ # Execute git command |
|
1367 |
+ return ($self->bin, "--git-dir=$rep", @cmd); |
|
1368 |
+} |
|
1369 |
+ |
|
1370 |
+sub _mode_str { |
|
1371 |
+ my $self = shift; |
|
1372 |
+ my $mode = oct shift; |
|
1373 |
+ |
|
1374 |
+ # Mode to string |
|
1375 |
+ if ($self->_s_isgitlink($mode)) { return 'm---------' } |
|
1376 |
+ elsif (S_ISDIR($mode & S_IFMT)) { return 'drwxr-xr-x' } |
|
1377 |
+ elsif (S_ISLNK($mode)) { return 'lrwxrwxrwx' } |
|
1378 |
+ elsif (S_ISREG($mode)) { |
|
1379 |
+ if ($mode & S_IXUSR) { |
|
1380 |
+ return '-rwxr-xr-x'; |
|
1381 |
+ } else { |
|
1382 |
+ return '-rw-r--r--' |
|
1383 |
+ } |
|
1384 |
+ } else { return '----------' } |
|
1385 |
+ |
|
1386 |
+ return; |
|
1387 |
+} |
|
1388 |
+ |
|
1389 |
+sub _s_isgitlink { |
|
1390 |
+ my ($self, $mode) = @_; |
|
1391 |
+ |
|
1392 |
+ # Check if git link |
|
1393 |
+ my $s_ifgitlink = 0160000; |
|
1394 |
+ return (($mode & S_IFMT) == $s_ifgitlink) |
|
1395 |
+} |
|
1396 |
+ |
|
1373 | 1397 |
sub _slurp { |
1374 | 1398 |
my ($self, $file) = @_; |
1375 | 1399 |
|
... | ... |
@@ -1,5 +1,5 @@ |
1 | 1 |
<% |
2 |
- my $op = param('op'); |
|
2 |
+ my $op = param('op') || ''; |
|
3 | 3 |
|
4 | 4 |
my $errors; |
5 | 5 |
if ($op eq 'create') { |
... | ... |
@@ -8,7 +8,7 @@ |
8 | 8 |
|
9 | 9 |
# Validation |
10 | 10 |
my $params = $api->params; |
11 |
- my $project_check = sub { |
|
11 |
+ my $keyword_check = sub { |
|
12 | 12 |
my $value = shift; |
13 | 13 |
|
14 | 14 |
return ($value || '') =~ /^[a-zA-Z0-9_\-]+$/ |
... | ... |
@@ -16,7 +16,7 @@ |
16 | 16 |
my $rule = [ |
17 | 17 |
project => [ |
18 | 18 |
['not_blank' => 'Repository name is empty'], |
19 |
- [$project_check => 'Invalid repository name'] |
|
19 |
+ [$keyword_check => 'Invalid repository name'] |
|
20 | 20 |
], |
21 | 21 |
description => [ |
22 | 22 |
'any' |
... | ... |
@@ -25,20 +25,49 @@ |
25 | 25 |
my $validator = app->validator; |
26 | 26 |
my $vresult = $validator->validate($params, $rule); |
27 | 27 |
|
28 |
+ # Git |
|
29 |
+ my $git = app->git; |
|
28 | 30 |
if ($vresult->is_ok) { |
29 |
- my $user = session('user_id'); |
|
31 |
+ # Not logined |
|
32 |
+ unless ($api->logined) { |
|
33 |
+ return $self->render_exception; |
|
34 |
+ } |
|
35 |
+ |
|
30 | 36 |
my $data = $vresult->data; |
37 |
+ my $user = session('user_id'); |
|
31 | 38 |
my $project = $data->{project}; |
32 | 39 |
my $description = $data->{description}; |
33 |
- |
|
34 |
- app->git->create_repository( |
|
35 |
- $user, |
|
36 |
- $project, |
|
37 |
- {description => $description} |
|
38 |
- ); |
|
39 |
- |
|
40 |
- $self->redirect_to("/$user/$project"); |
|
41 |
- return 1; |
|
40 |
+ |
|
41 |
+ if ($git->exists_repository($user, $project)) { |
|
42 |
+ $errors = ['Repositry already exists']; |
|
43 |
+ } |
|
44 |
+ else { |
|
45 |
+ # Create repository |
|
46 |
+ eval { |
|
47 |
+ $git->create_repository( |
|
48 |
+ $user, |
|
49 |
+ $project, |
|
50 |
+ {description => $description} |
|
51 |
+ ); |
|
52 |
+ }; |
|
53 |
+ $api->croak($@) if $@; |
|
54 |
+ |
|
55 |
+ # Create repository data |
|
56 |
+ eval { |
|
57 |
+ app->dbi->model('project') |
|
58 |
+ ->insert({config => '{}'}, id => [$user, $project]); |
|
59 |
+ }; |
|
60 |
+ if ($@) { |
|
61 |
+ my $error = $@; |
|
62 |
+ $git->remove_repository($user, $project); |
|
63 |
+ $api->croak($error); |
|
64 |
+ } |
|
65 |
+ |
|
66 |
+ # Go to user page |
|
67 |
+ $self->redirect_to("/$user/$project"); |
|
68 |
+ |
|
69 |
+ return 1; |
|
70 |
+ } |
|
42 | 71 |
} |
43 | 72 |
else { $errors = $vresult->messages } |
44 | 73 |
} |
... | ... |
@@ -2,3 +2,9 @@ |
2 | 2 |
<div class="text-center" style="margin-bottom:10px"> |
3 | 3 |
<a href="https://github.com/yuki-kimoto/gitprep">Gitprep</a> |
4 | 4 |
</div> |
5 |
+ |
|
6 |
+% if (app->mode eq 'development') { |
|
7 |
+ <div class="text-center"> |
|
8 |
+ <a href="<%= url_for('/dbviewer') %>">DBViewer</a> |
|
9 |
+ </div> |
|
10 |
+% } |
... | ... |
@@ -19,7 +19,7 @@ |
19 | 19 |
<h3>Repositories</h3> |
20 | 20 |
|
21 | 21 |
<table class="table"> |
22 |
- % for my $rep (@$reps) { |
|
22 |
+ % for my $rep (sort { $a->{age} <=> $b->{age} } @$reps) { |
|
23 | 23 |
<tr> |
24 | 24 |
% my $pname = $rep->{name}; |
25 | 25 |
<td> |