View Javadoc

1   /* -*- mode: JDE; c-basic-offset: 2; indent-tabs-mode: nil -*-
2    *
3    * $Id: HttpUnitPublisher.java,v 1.15 2004/07/27 20:20:34 ljnelson Exp $
4    *
5    * Copyright (c) 2003 Laird Jarrett Nelson.
6    *
7    * Permission is hereby granted, free of charge, to any person obtaining a copy
8    * of this software and associated documentation files (the "Software"), to deal
9    * in the Software without restriction, including without limitation the rights
10   * to use, copy, modify, merge, publish, distribute, sublicense and/or sell
11   * copies of the Software, and to permit persons to whom the Software is
12   * furnished to do so, subject to the following conditions:
13   *
14   * The above copyright notice and this permission notice shall be included in
15   * all copies or substantial portions of the Software.
16   *
17   * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18   * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19   * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
20   * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21   * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22   * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23   * SOFTWARE.
24   *
25   * The original copy of this license is available at
26   * http://www.opensource.org/license/mit-license.html.
27   */
28  package sfutils.frs.web;
29  
30  import java.io.Serializable;
31  import java.io.FileInputStream;
32  import java.io.OutputStream;
33  import java.io.File;
34  import java.io.InputStream;
35  import java.io.IOException;
36  
37  import java.net.MalformedURLException;
38  import java.net.URL;
39  import java.net.URLConnection;
40  
41  import java.text.DateFormat;
42  import java.text.SimpleDateFormat;
43  
44  import java.util.Arrays;
45  import java.util.ArrayList;
46  import java.util.Collection;
47  import java.util.Collections;
48  import java.util.Date;
49  import java.util.SortedSet;
50  import java.util.TreeSet;
51  
52  import java.util.logging.Logger;
53  
54  import com.meterware.httpunit.ClientProperties;
55  import com.meterware.httpunit.GetMethodWebRequest;
56  import com.meterware.httpunit.HttpUnitOptions;
57  import com.meterware.httpunit.TableCell;
58  import com.meterware.httpunit.UploadFileSpec;
59  import com.meterware.httpunit.WebConversation;
60  import com.meterware.httpunit.WebForm;
61  import com.meterware.httpunit.WebLink;
62  import com.meterware.httpunit.WebRequest;
63  import com.meterware.httpunit.WebResponse;
64  import com.meterware.httpunit.WebTable;
65  
66  import org.xml.sax.SAXException;
67  
68  import sfutils.Administrator;
69  import sfutils.Project;
70  
71  import sfutils.frs.FileRelease;
72  import sfutils.frs.FileSpecification;
73  import sfutils.frs.HideableNamedObject;
74  import sfutils.frs.Package;
75  import sfutils.frs.Publisher;
76  import sfutils.frs.PublishingException;
77  
78  /***
79   * A {@link Publisher} that uses the <a
80   * href="http://httpunit.sourceforge.net/">HttpUnit</a> framework to interface
81   * with the <a href="http://sourceforge.net/">SourceForge</a> File Release
82   * System.
83   *
84   * @author     <a href="mailto:ljnelson94@alumni.amherst.edu">Laird Nelson</a>
85   * @version    $Revision: 1.15 $ $Date: 2004/07/27 20:20:34 $
86   * @since      June 19, 2003
87   * @see        Publisher
88   * @see        FileRelease
89   * @see        <a
90   *               href="http://sourceforge.net/docman/display_doc.php?docid=6445&group_id=1">Guide
91   *               to the SourceForge File Release System (FRS)</a>
92   * @todo       Possibly reorganize to be less flow-driven and more stateless.
93   *               Ideas include methods that extract identifiers for projects,
94   *               packages and file releases, and methods that perform operations
95   *               on projects, packages and file releases given their
96   *               identifiers.
97   * @todo       Introduce case insensitivity in appropriate places.
98   */
99  public class HttpUnitPublisher implements Publisher, Serializable {
100 
101   /***
102    * The {@link String} that represents the URL to use to log in to <a
103    * href="http://sourceforge.net/">SourceForge</a>.
104    */
105   private static final String LOGIN_URL = 
106     "http://sourceforge.net/account/login.php";
107 
108   /***
109    * A {@link String} representing a package or file release's visibility
110    * status.
111    */
112   private static final String VISIBLE = "1";
113 
114   /***
115    * A {@link String} that indicates that a value of a form has been turned on.
116    */
117   private static final String ON = "1";
118 
119   /***
120    * A {@link String} that corresponds to a file release's or package's hidden
121    * status.
122    */
123   private static final String HIDDEN = "3";
124 
125   /***
126    * The name of a form parameter in the <a
127    * href="http://sourceforge.net/">SourceForge</a> file release system whose
128    * value indicates what action to take next.
129    */
130   private static final String PACKAGE_ACTION_PARAMETER = "func";
131 
132   /***
133    * One possible value for the form parameter whose name is equal to the value
134    * of the {@link #PACKAGE_ACTION_PARAMETER} field.
135    */
136   private static final String UPDATE_PACKAGE = "update_package";
137 
138   /***
139    * One possible value for the form parameter whose name is equal to the value
140    * of the {@link #PACKAGE_ACTION_PARAMETER} field.
141    */
142   private static final String ADD_PACKAGE = "add_package";
143 
144   /***
145    * A form parameter name whose corresponding value will be the name of a
146    * package.
147    */
148   private static final String PACKAGE_NAME = "package_name";
149 
150   /***
151    * A form parameter name whose corresponding value will be the identifier of a
152    * package.
153    */
154   private static final String PACKAGE_ID = "package_id";
155 
156   /***
157    * A form parameter name whose corresponding value will be the identifier of a
158    * project.
159    */
160   private static final String GROUP_ID = "group_id";
161 
162   /***
163    * A form parameter name whose corresponding value will be either {@link
164    * #HIDDEN} or {@link #VISIBLE}.
165    */
166   private static final String STATUS = "status_id";
167 
168   /***
169    * A form parameter name whose corresponding value will be the name of a file
170    * release.
171    */
172   private static final String RELEASE_NAME = "release_name";
173 
174   /***
175    * A form parameter name whose corresponding value will be the release date of
176    * a file release.
177    */
178   private static final String RELEASE_DATE = "release_date";
179 
180   /***
181    * A form parameter name whose corresponding value will be a {@link File} that
182    * will be uploaded to the <a href="http://sourceforge.net/">SourceForge</a>
183    * website to describe the changes in a given file release.
184    */
185   private static final String UPLOAD_CHANGE_LOG = "uploaded_changes";
186 
187   /***
188    * A form parameter name whose corresponding value will be a {@link File} that
189    * will be uploaded to the <a href="http://sourceforge.net/">SourceForge</a>
190    * website to describe the changes in a given file release from one version to
191    * the next.
192    */
193   private static final String UPLOAD_RELEASE_NOTES = "uploaded_notes";
194 
195   /***
196    * A form parameter name whose value will be the contents of a changelog.
197    */
198   private static final String CHANGE_LOG = "release_changes";
199 
200   /***
201    * A form parameter name whose value will be the contents of a release notes
202    * file.
203    */
204   private static final String RELEASE_NOTES = "release_notes";
205 
206   /***
207    * A form parameter name whose value will indicate whether preformatted text
208    * is to be preserved.  Its value may be equal to the value of the {@link #ON}
209    * field.
210    */
211   private static final String PRESERVE_FORMATTED_TEXT = "preformatted";
212 
213   /***
214    * An <code>int</code> that corresponds to the step on the "edit release" page
215    * that will actually edit the file release in question.
216    */
217   private static final int EDIT_RELEASE_STEP = 1;
218 
219   /***
220    * An <code>int</code> that corresponds to the step on the "edit release" page
221    * that will select files to be included as part of the file release in
222    * question.
223    */
224   private static final int ADD_FILES_STEP = 2;
225 
226   /***
227    * An <code>int</code> that corresponds to the step on the "edit release" page
228    * that will cause other <a href="http://sourceforge.net/">SourceForge</a>
229    * users monitoring the file release in question to be notified by email of
230    * its release.  
231    */
232   private static final int NOTIFY_OTHERS_STEP = 4;
233 
234   /***
235    * A relative URL {@link String} used as the value for a certain form's
236    * <code>ACTION</code> attribute when the form is designed to return a page
237    * that allows the user to edit the file releases that belong to a package.
238    */
239   private static final String EDIT_RELEASES_ACTION =
240     "/project/admin/editreleases.php";
241 
242   /***
243    * A relative URL {@link String} used as the value for a certain form's
244    * <code>ACTION</code> attribute when the form is designed to return a page
245    * that allows the user to edit the packages that belong to a project.
246    */
247   private static final String EDIT_PACKAGES_ACTION =
248     "/project/admin/editpackages.php";
249 
250   /***
251    * The {@link Logger} used by all instances of this class.  This field is
252    * never <code>null</code>.
253    */
254   private static final Logger LOGGER;
255 
256   /***
257    * The {@link DateFormat} used to format release dates.  This field is never
258    * <code>null</code> and <i>must</i> be synchronized on before use.
259    */
260   private static final DateFormat DATE_FORMATTER;
261 
262   /***
263    * Static initializer; initializes the {@link Logger} used by this class as
264    * well as other private fields.
265    */
266   static {
267     LOGGER = Logger.getLogger(HttpUnitPublisher.class.getName());
268     DATE_FORMATTER = new SimpleDateFormat("yyyy-MM-dd");
269     HttpUnitOptions.setExceptionsThrownOnScriptError(false);
270     HttpUnitOptions.setScriptingEnabled(false);
271   }
272 
273   /***
274    * Creates a new {@link HttpUnitPublisher}.
275    */
276   public HttpUnitPublisher() {
277     super();
278   }
279 
280   /***
281    * Publishes the supplied {@link FileRelease} to its associated {@link
282    * Project} area on <a href="http://sourceforge.net/">SourceForge</a>.
283    *
284    * @param      release
285    *               the {@link FileRelease} to publish; must not be
286    *               <code>null</code>
287    * @exception  PublishingException
288    *               if the supplied {@link FileRelease} could not be published
289    */
290   public void publish(final FileRelease release)
291     throws PublishingException {
292 
293     // Validate the FileRelease object graph.
294     assertNotNull(release, "release");
295     final FileSpecification[] files = release.getFileSpecifications();
296     assertArrayFull(files, "files");
297     final Package pkg = release.getPackage();
298     assertNotNull(pkg, "pkg");
299     final Project project = pkg.getProject();
300     assertNotNull(project, "project");
301     final String projectShortName = project.getShortName();
302     assertNotNull(projectShortName, "projectShortName");
303     final Administrator admin = project.getAdministrator();
304     assertNotNull(admin, "admin");
305     final String userName = admin.getName();
306     assertNotNull(userName, "userName");
307     final String password = admin.getPassword();
308 
309     // Begin our session.
310     final WebConversation conversation = new WebConversation();
311     final ClientProperties clientProperties = conversation.getClientProperties();
312     if (clientProperties != null) {
313       clientProperties.setAcceptGzip(false);
314     }
315 
316     try {
317 
318       // First, log in.
319       final WebResponse loginResponse = this.login(conversation, project);
320       assertNotNull(loginResponse, "loginResponse");
321       LOGGER.info("Retrieved " + loginResponse.getTitle() +
322                   " (result of logging in)");
323       LOGGER.info("Logged in as " + userName);
324 
325       // Next, request the project Summary page.
326       final WebResponse summaryPage =
327         this.getSummaryPage(conversation, projectShortName);
328       assertNotNull(summaryPage, "summaryPage");
329       LOGGER.info("Retrieved " + summaryPage.getTitle() + " (summary page)");
330 
331       // Next, go to the Admin page linked off it.
332       final WebResponse adminPage = this.getAdminPage(summaryPage);
333       assertNotNull(adminPage, "adminPage");
334       LOGGER.info("Retrieved " + adminPage.getTitle() + " (admin page)");
335 
336       // Now go to the Packages page.
337       finaltrong> WebResponse packagesPage = this.getPackagesPage(adminPage);
338       assertNotNull(packagesPage, "packagesPage");
339       LOGGER.info("Retrieved " + packagesPage.getTitle() + " (packages page)");
340 
341       // Work around a race condition in SourceForge that means that we have to
342       // upload our files now.
343       this.uploadFiles(release);
344 
345       // Get the Edit Release page from it.  This is a bulky operation.
346       WebResponse editReleasePage =
347         this.getEditReleasePage(conversation,
348                                 packagesPage,
349                                 release);
350       assertNotNull(editReleasePage, "editReleasePage");
351       LOGGER.info("Retrieved " + editReleasePage.getTitle() +
352                   " (packages page)");
353 
354       // Go, finally, edit the new or existing file release that corresponds to
355       // the supplied FileRelease object.
356       this.processFileRelease(editReleasePage, release);
357       LOGGER.info("Finished processing release.");
358 
359     } catch (final SAXException wrapMe) {
360       throw new PublishingException(wrapMe);
361     }
362 
363   }
364 
365   /***
366    * Ensures that all the properties and attributes specified in the supplied
367    * {@link FileRelease} are saved persistently to <a
368    * href="http://sourceforge.net/">SourceForge</a>.  This method is called by
369    * the {@link #processFileRelease(WebResponse, FileRelease)} method.
370    *
371    * <p>This method never returns <code>null</code>.</p>
372    *
373    * @param      editReleasePage
374    *               the {@link WebResponse} that corresponds to the edit release
375    *               page in <a href="http://sourceforge.net/">SourceForge</a>'s
376    *               file release system; must not be <code>null</code>
377    * @param      release
378    *               the {@link FileRelease} to save; must not be
379    *               <code>null</code>
380    * @return     what amounts to the same page as the supplied {@link
381    *               WebResponse} that hopefully now reflects the attributes and
382    *               properties of the supplied {@link FileRelease}; never
383    *               <code>null</code>
384    * @exception  PublishingException
385    *               if an error occurs 
386    */
387   protected WebResponse saveFileReleaseAttributes(WebResponse editReleasePage,
388                                                   final FileRelease release)
389     throws PublishingException {
390     assertNotNull(editReleasePage, "editReleasePage");
391     assertNotNull(release, "release");
392     final String formattedDate = this.formatReleaseDate(release);
393     assertNotNull(formattedDate, "formattedDate");
394     try {
395       final WebForm step1Form = 
396         this.getStepForm(editReleasePage, EDIT_RELEASE_STEP);
397       assertNotNull(step1Form, "step1Form");
398       step1Form.setParameter(RELEASE_DATE, formattedDate);
399       if (release.isHidden()) {
400         step1Form.setParameter(STATUS, HIDDEN);
401       } else {
402         step1Form.setParameter(STATUS, VISIBLE);
403       }
404       if (release.getPreserveFormattedText()) {
405         step1Form.setParameter(PRESERVE_FORMATTED_TEXT, ON);
406       } else {
407         step1Form.removeParameter(PRESERVE_FORMATTED_TEXT);
408       }
409       final File changeLogFile = release.getChangeLogFile();
410       if (changeLogFile != null && changeLogFile.canRead()) {
411         step1Form.setParameter(UPLOAD_CHANGE_LOG,
412                                new UploadFileSpec[] { 
413                                  new UploadFileSpec(changeLogFile) 
414                                });
415       } else {
416         final String changeLogText = release.getChangeLog();
417         if (changeLogText != null) {
418           step1Form.setParameter(CHANGE_LOG, changeLogText);
419         }
420       }
421       final File releaseNotesFile = release.getReleaseNotesFile();
422       if (releaseNotesFile != null && releaseNotesFile.canRead()) {
423         step1Form.setParameter(UPLOAD_RELEASE_NOTES,
424                                new UploadFileSpec[] { 
425                                  new UploadFileSpec(releaseNotesFile)
426                                });
427       } else {
428         final String releaseNotesText = release.getReleaseNotes();
429         if (releaseNotesText != null) {
430           step1Form.setParameter(RELEASE_NOTES, releaseNotesText);
431         }
432       }
433       editReleasePage = step1Form.submit();
434       assertNotNull(editReleasePage, "editReleasePage");
435       return editReleasePage;
436     } catch (final SAXException wrapMe) {
437       throw new PublishingException(wrapMe);
438     } catch (final IOException wrapMe) {
439       throw new PublishingException(wrapMe);
440     }
441   }
442 
443   /***
444    * Formats the {@linkplain FileRelease#getReleaseDate() release date
445    * associated with the supplied <code>FileRelease</code>}.  This method may
446    * return <code>null</code>.
447    *
448    * @param      release
449    *               the {@link FileRelease} whose {@linkplain
450    *               FileRelease#getReleaseDate() release date} will be formatted;
451    *               if <code>null</code>, then <code>null</code> will be returned
452    * @return     the formatted {@link Date}, or <code>null</code>
453    */
454   protected String formatReleaseDate(final FileRelease release) {
455     if (release == null) {
456       return null;
457     }
458     final Date releaseDate = release.getReleaseDate();
459     if (releaseDate == null) {
460       return null;
461     }
462     synchronized (DATE_FORMATTER) {
463       return DATE_FORMATTER.format(releaseDate);
464     }
465   }
466 
467   /***
468    * Works on the "edit release" page so that the supplied {@link FileRelease}
469    * is effectively saved to <a href="http://sourceforge.net/">SourceForge</a>.
470    * Returns the "edit release" page after all edits have been made.
471    *
472    * <p>This method is called by the {@link #publish(FileRelease)} method and
473    * will never return <code>null</code>.</p>
474    *
475    * @param      editReleasePage
476    *               a {@link WebResponse} that represents the "edit release"
477    *               page; must not be <code>null</code>
478    * @param      release
479    *               the {@link FileRelease} to be released; must not be
480    *               <code>null</code>
481    * @return     a {@link WebResponse} representing the "edit release" page
482    *               after all edits have been made; never <code>null</code>
483    * @exception  PublishingException
484    *               if an error occurs
485    */
486   protected WebResponse processFileRelease(WebResponse editReleasePage,
487                                            final FileRelease release)
488     throws PublishingException {
489     editReleasePage = this.saveFileReleaseAttributes(editReleasePage, release);
490     editReleasePage = this.addFiles(editReleasePage, release);
491     editReleasePage = this.editFiles(editReleasePage, release);
492     editReleasePage = this.notifyOthers(editReleasePage, release);
493     return editReleasePage;
494   }
495 
496   /***
497    * Ensures that other <a href="http://sourceforge.net/">SourceForge</a> users
498    * who have expressed interest are notified when the supplied {@link
499    * FileRelease} is released.  Returns the "edit release" page after all edits
500    * have been made.  This method is called by the {@link
501    * #processFileRelease(WebResponse, FileRelease)} method and never returns
502    * <code>null</code>.
503    *
504    * @param      editReleasePage
505    *               a {@link WebResponse} that represents the "edit release"
506    *               page; must not be <code>null</code>
507    * @param      release
508    *               the {@link FileRelease} to be released; must not be
509    *               <code>null</code>
510    * @return     a {@link WebResponse} representing the "edit release" page
511    *               after all edits have been made; never <code>null</code>
512    * @exception  PublishingException
513    *               if an error occurs
514    */
515   protected WebResponse notifyOthers(WebResponse editReleasePage,
516                                      final FileRelease release)
517     throws PublishingException {
518     assertNotNull(editReleasePage, "editReleasePage");
519     assertNotNull(release, "release");
520     if (release.getNotifyOthers()) {
521       final WebForm form =
522         this.getStepForm(editReleasePage, NOTIFY_OTHERS_STEP);
523       if (form != null) {
524         try {
525           editReleasePage = form.submit();
526         } catch (final SAXException kaboom) {
527           throw new PublishingException(kaboom);
528         } catch (final IOException kaboom) {
529           throw new PublishingException(kaboom);
530         }
531       }
532     }
533     return editReleasePage;
534   }
535 
536   /***
537    * Adds the {@link File}s that were uploaded earlier in the publishing process
538    * to the <a href="http://sourceforge.net/">SourceForge</a> representation of
539    * the supplied {@link FileRelease}.  This method works on the "edit release"
540    * page and makes the necessary edits there before returning a {@link
541    * WebResponse} representing the "edit release" page with all the edits
542    * complete.
543    *
544    * <p>This method is called by the {@link #processFileRelease(WebResponse,
545    * FileRelease)} method and will never return <code>null</code>.</p>
546    *
547    * @param      editReleasePage
548    *               a {@link WebResponse} that represents the "edit release"
549    *               page; must not be <code>null</code>
550    * @param      release
551    *               the {@link FileRelease} to be released; must not be
552    *               <code>null</code>
553    * @return     a {@link WebResponse} representing the "edit release" page 
554    *               after all edits have been made; never <code>null</code>
555    * @exception  PublishingException
556    *               if an error occurs
557    */
558   protected WebResponse addFiles(WebResponse editReleasePage,
559                                  final FileRelease release)
560     throws PublishingException {
561     assertNotNull(editReleasePage, "editReleasePage");
562     assertNotNull(release, "release");
563     final FileSpecification[] specs = release.getFileSpecifications();
564     assertArrayFull(specs, "specs");
565 
566     try {
567       WebForm form = this.getStepForm(editReleasePage, ADD_FILES_STEP);
568       assertNotNull(form, "form");
569 
570       // Next, we submit the form, which effectively causes it to refresh.
571       // Lordy, this interface sucks.
572       editReleasePage = form.submit();
573       assertNotNull(editReleasePage, "editReleasePage");
574 
575       form = this.getStepForm(editReleasePage, ADD_FILES_STEP);
576       assertNotNull(form, "form");
577 
578       // We take advantage of the fact that the value for a file checkbox is the
579       // filename itself.
580       final SortedSet shortFileNames = this.extractShortFileNames(release);
581       assertNotNull(shortFileNames, "shortFileNames");
582       LOGGER.info("Setting file_list[] to " + shortFileNames);
583       form.setParameter("file_list[]",
584                         (String[])shortFileNames.toArray(new String[shortFileNames.size()]));
585 
586       editReleasePage = form.submit();
587       assertNotNull(editReleasePage, "editReleasePage");
588       return editReleasePage;
589 
590     } catch (final IOException kaboom) {
591       throw new PublishingException(kaboom);
592     } catch (final SAXException kaboom) {
593       throw new PublishingException(kaboom);
594     }
595 
596   }
597 
598   /***
599    * Edits the attributes of the {@link File}s that were uploaded earlier in the
600    * publishing process.  This method works on the "edit release"
601    * page and makes the necessary edits there before returning a {@link
602    * WebResponse} representing the "edit release" page with all the edits
603    * complete.
604    *
605    * <p>This method is called by the {@link #processFileRelease(WebResponse,
606    * FileRelease)} method and will never return <code>null</code>.</p>
607    *
608    * @param      editReleasePage
609    *               a {@link WebResponse} that represents the "edit release"
610    *               page; must not be <code>null</code>
611    * @param      release
612    *               the {@link FileRelease} to be released; must not be
613    *               <code>null</code>
614    * @return     a {@link WebResponse} representing the "edit release" page
615    *               after all edits have been made; never <code>null</code>
616    * @exception  PublishingException
617    *               if an error occurs
618    */
619   protected WebResponse editFiles(WebResponse editReleasePage,
620                                   final FileRelease release)
621     throws PublishingException {
622     assertNotNull(editReleasePage, "editReleasePage");
623     assertNotNull(release, "release");
624     try {
625       WebForm[] forms =
626         this.retainFormsWithAction(editReleasePage.getForms(),
627                                    EDIT_RELEASES_ACTION);
628       assertArrayFull(forms, "forms");
629       if (forms.length < 2) {
630         throw new PublishingException("Expected at least two forms");
631       } else if (forms.length == 2) {
632         // No step three form; no files were uploaded.  We're done.
633         return editReleasePage;
634       }
635 
636       // OK, some more weird assembly here.  Get the table that wraps the forms
637       // for editing each individual file.  We have to get this in addition to
638       // the forms themselves, because the file name to which a given form
639       // applies is stored as plain text in a table cell.
640       WebTable table = editReleasePage.getTableStartingWithPrefix("Filename");
641       assertNotNull(table, "table");
642 
643       WebForm form;
644       String title;
645       FileSpecification spec;
646       int formIndex = 2;
647       int tableRowIndex = 1;
648       while (formIndex < forms.length) {
649         form = forms[formIndex];
650         assertNotNull(form, "form");
651         if (form.hasParameterNamed("im_sure")) {
652           tableRowIndex += 3;
653         } else if (form.hasParameterNamed("processor_id") &&
654                    form.hasParameterNamed("type_id")) {
655           title = table.getCellAsText(tableRowIndex, 0);
656           assertNotNull(title, "title");
657           spec = release.getFileSpecification(title);
658           assertNotNull(spec, "spec");
659           form.setParameter("processor_id", 
660                             String.valueOf(spec.getProcessorType()));
661           form.setParameter("type_id", String.valueOf(spec.getFileType()));
662           editReleasePage = form.submit();
663           assertNotNull(editReleasePage, "editReleasePage");
664           forms =
665             this.retainFormsWithAction(editReleasePage.getForms(),
666                                        EDIT_RELEASES_ACTION);
667           assertArrayFull(forms, "forms");
668           table = editReleasePage.getTableStartingWithPrefix("Filename");
669           assertNotNull(table, "table");
670         } else {
671           // ???
672         }
673         formIndex++;
674       }
675 
676       return editReleasePage;
677     } catch (final IOException kaboom) {
678       throw new PublishingException(kaboom);
679     } catch (final SAXException kaboom) {
680       throw new PublishingException(kaboom);
681     }
682   }
683 
684   /***
685    * A convenience method that returns a {@link WebForm} representing the form
686    * on the "edit release" page with the supplied "step".  For example, the
687    * {@link WebForm} corresponding to the form labeled "Step 1" will be returned
688    * if the supplied step is equal to <code>1</code>.  This method is called by
689    * the {@link #addFiles(WebResponse, FileRelease)}, {@link
690    * #notifyOthers(WebResponse, FileRelease)} and {@link
691    * #saveFileReleaseAttributes(WebResponse, FileRelease)} methods and may
692    * return <code>null</code>.
693    *
694    * @param      editReleasePage
695    *               a {@link WebResponse} that represents the "edit release"
696    *               page; must not be <code>null</code>
697    * @param      step
698    *               the "step number" of the form on the "edit release" page that
699    *               chooses the {@link WebForm} to be returned; must be greater
700    *               than or equal to <code>1</code>
701    * @return     an appropriate {@link WebForm}, or <code>null</code>
702    * @exception  PublishingException
703    *               if an error occurs
704    */
705   protected WebForm getStepForm(final WebResponse editReleasePage,
706                                 final int step)
707     throws PublishingException {
708     assertNotNull(editReleasePage, "editReleasePage");
709     WebForm[] forms = null;
710     try {
711       forms = this.retainFormsWithAction(editReleasePage.getForms(),
712                                          EDIT_RELEASES_ACTION);
713     } catch (final SAXException kaboom) {
714       throw new PublishingException(kaboom);
715     }
716     assertArrayFull(forms, "forms");
717     WebForm form;
718     for (int i = 0; i < forms.length; i++) {
719       form = forms[i];
720       if (form != null && form.hasParameterNamed("step" + step)) {
721         return form;
722       }
723     }
724     return null;
725   }
726 
727   /***
728    * Uploads all {@link File}s that are reachable from the supplied {@link
729    * FileRelease} to <code>ftp://upload.sourceforge.net/incoming/</code>.  The
730    * default implementation of this method creates and starts a new {@link
731    * HttpUnitPublisher.FileUploader} for each {@link File}, so it is tacitly
732    * assumed that the total number of {@link File}s to be uploaded will be
733    * relatively small.  This method is called by the {@link
734    * #publish(FileRelease)} method.
735    *
736    * @param      release
737    *               the {@link FileRelease} containing {@link FileSpecification}s
738    *               containing the {@link File}s to be uploaded; must not be
739    *               <code>null</code>
740    * @exception  PublishingException
741    *               if an error occurs
742    */
743   public void uploadFiles(final FileRelease release)
744     throws PublishingException {
745     assertNotNull(release, "release");
746     final File[] files = release.getFiles();
747     assertArrayFull(files, "files");
748     File file;
749     String shortName;
750     URL url;
751     URLConnection connection;
752     final Thread[] threads = new Thread[files.length];
753     Thread thread;
754     final Collection errors = Collections.synchronizedList(new ArrayList());
755     try {
756       for (int i = 0; i < files.length; i++) {
757         file = files[i];
758         if (file != null && file.canRead()) {
759           thread = new FileUploader(file, errors);
760           thread.start();
761           threads[i] = thread;
762         }
763       }
764       for (int i = 0; i < threads.length; i++) {
765         threads[i].join();
766       }
767       if (!errors.isEmpty()) {
768         final Exception[] exceptions =
769           (Exception[])errors.toArray(new Exception[errors.size()]);
770         throw new UploadException(exceptions);
771       }
772     } catch (final InterruptedException ignore) {
773       throw new UploadException(new InterruptedException[] { ignore });
774     }
775   }
776 
777   /***
778    * Assembles a {@link SortedSet} of {@linkplain File#getName()
779    * <code>File</code> "short name"s} from the supplied {@link FileRelease}.
780    * This method is called by the {@link #addFiles(WebResponse, FileRelease)}
781    * method and never returns <code>null</code>.
782    *
783    * @param      release
784    *               the {@link FileRelease} to work on; must not be
785    *               <code>null</code>
786    * @return     a {@link SortedSet} of {@linkplain File#getName()
787    *               <code>File</code> "short name"s} from the supplied {@link
788    *               FileRelease}
789    * @exception  PublishingException
790    *               if an error occurs
791    */
792   protected SortedSet extractShortFileNames(final FileRelease release)
793     throws PublishingException {
794     assertNotNull(release, "release");
795     final SortedSet names = new TreeSet();
796     names.addAll(Arrays.asList(release.getShortFileNames()));
797     return names;
798   }
799 
800   /***
801    * Returns a {@link WebResponse} that represents the "summary page" for the
802    * supplied <a href="http://sourceforge.net/">SourceForge</a> project.  This
803    * method will never return <code>null</code>.
804    *
805    * @param      conversation
806    *               the {@link WebConversation} to which all interaction with <a
807    *               href="http://sourceforge.net/">SourceForge</a> logically
808    *               belongs; must not be <code>null</code>
809    * @param      projectShortName
810    *               the "short name" of the <a
811    *               href="http://sourceforge.net/">SourceForge</a> project whose
812    *               summary page should be returned; must not be
813    *               <code>null</code>
814    * @return     a {@link WebResponse} corresponding to the "summary page" for
815    *               the supplied <a
816    *               href="http://sourceforge.net/">SourceForge</a> project; never
817    *               <code>null</code>
818    * @exception  PublishingException
819    *               if an error occurs 
820    */
821   protected WebResponse getSummaryPage(final WebConversation conversation,
822                                        final String projectShortName)
823     throws PublishingException {
824     assertNotNull(conversation, "conversation");
825     assertNotNull(projectShortName, "projectShortName");
826 
827     final WebRequest mainProjectPageRequest =
828       new GetMethodWebRequest("http://sourceforge.net/projects/" +
829                               projectShortName +
830                               "/");
831     try {
832       final WebResponse returnMe =
833         conversation.getResponse(mainProjectPageRequest);
834       assertNotNull(returnMe, "returnMe");
835       return returnMe;
836     } catch (final IOException kaboom) {
837       throw new PublishingException(kaboom);
838     } catch (final SAXException wrapMe) {
839       throw new PublishingException(wrapMe);
840     }
841   }
842 
843   /***
844    * Returns a {@link WebResponse} that represents the "admin page" for the <a
845    * href="http://sourceforge.net/">SourceForge</a> project summarized in the
846    * supplied {@link WebResponse} that must have been supplied by the {@link
847    * #getSummaryPage(WebConversation, String)} method.  This method will never
848    * return <code>null</code>.
849    *
850    * @param      summaryPage
851    *               the {@link WebResponse} returned by the {@link
852    *               #getSummaryPage(WebConversation, String)} method; must not be
853    *               <code>null</code>
854    * @return     a {@link WebResponse} that represents the "admin page" for the
855    *               <a href="http://sourceforge.net/">SourceForge</a> project
856    *               summarized in the supplied {@link WebResponse}; never
857    *               <code>null</code>
858    * @exception  PublishingException
859    *               if an error occurs
860    */
861   protected WebResponse getAdminPage(final WebResponse summaryPage)
862     throws PublishingException {
863     assertNotNull(summaryPage, "summaryPage");
864 
865     try {
866       final WebLink[] links = summaryPage.getLinks();
867       assertArrayFull(links, "summaryPage.getLinks()");
868 
869       final WebLink adminLink = this.findLinkWithExactText(links, "Admin");
870       assertNotNull(adminLink, "adminLink");
871 
872       final WebResponse returnMe = adminLink.click();
873       assertNotNull(returnMe, "returnMe");
874 
875       return returnMe;
876 
877     } catch (final IOException wrapMe) {
878       throw new PublishingException(wrapMe);
879     } catch (final SAXException wrapMe) {
880       throw new PublishingException(wrapMe);
881     }
882   }
883 
884   /***
885    * Returns a {@link WebResponse} that represents the "packages page" for the
886    * <a href="http://sourceforge.net/">SourceForge</a> project summarized in the
887    * supplied {@link WebResponse} that must have been supplied by the {@link
888    * #getAdminPage(WebResponse)} method.  This method will never return
889    * <code>null</code>.
890    *
891    * @param      adminPage
892    *               the {@link WebResponse} returned by the {@link
893    *               #getAdminPage(WebResponse)} method; must not be
894    *               <code>null</code>
895    * @return     a {@link WebResponse} that represents the "packages page" for
896    *               the <a href="http://sourceforge.net/">SourceForge</a> project
897    *               summarized in the supplied {@link WebResponse}; never
898    *               <code>null</code>
899    * @exception  PublishingException
900    *               if an error occurs
901    */
902   protected WebResponse getPackagesPage(final WebResponse adminPage)
903     throws PublishingException {
904     assertNotNull(adminPage, "adminPage");
905 
906     try {
907       final WebLink fileReleasesLink =
908         adminPage.getLinkWith("File Releases");
909       assertNotNull(fileReleasesLink, "fileReleasesLink");
910 
911       finaltrong> WebResponse packagesPage = fileReleasesLink.click();
912       assertNotNull(packagesPage, "packagesPage");
913 
914       returntrong> packagesPage;
915 
916     } catch (final IOException wrapMe) {
917       throw new PublishingException(wrapMe);
918     } catch (final SAXException wrapMe) {
919       throw new PublishingException(wrapMe);
920     }
921   }
922 
923   /***
924    * Logs the supplied {@link Project}'s {@link Project#getAdministrator()
925    * Administrator} into the supplied {@link Project} on <a
926    * href="http://sourceforge.net/">SourceForge</a>.  The login lasts for as
927    * long as the supplied {@link WebConversation} is in memory.
928    *
929    * <p>This method never returns <code>null</code>.</p>
930    *
931    * @param      conversation
932    *               the {@link WebConversation} in whose scope the login will
933    *               take place; must not be <code>null</code>
934    * @param      project
935    *               the {@link Project} to log into; also the source of the
936    *               {@link Administrator} who will be logged in; must not be
937    *               <code>null</code> and must provide a non-<code>null</code>
938    *               {@link Project#getAdministrator() Administrator}
939    * @return     the {@link WebResponse} corresponding to the page that is shown
940    *               once a login has completed successfully; never
941    *               <code>null</code>
942    * @exception  InvalidCredentialsException
943    *               if the associated {@link Administrator} could not log in
944    * @exception  PublishingException
945    *               if any other error occurs
946    */
947   protected WebResponse login(final WebConversation conversation,
948                               final Project project)
949     throws InvalidCredentialsException, PublishingException {
950     assertNotNull(conversation, "conversation");
951     assertNotNull(project, "project");
952     final Administrator administrator = project.getAdministrator();
953     assertNotNull(administrator, "administrator");
954     final String userName = administrator.getName();
955     assertNotNull(userName, "userName");
956     final String password = administrator.getPassword();
957 
958     try {
959       final WebResponse getLoginPageResponse =
960         conversation.getResponse(new GetMethodWebRequest(LOGIN_URL));
961       assertNotNull(getLoginPageResponse, "getLoginPageResponse");
962       LOGGER.info("Retrieved " + getLoginPageResponse.getTitle());
963 
964       final WebForm[] forms = getLoginPageResponse.getForms();
965       assertArrayFull(forms, "forms");
966 
967       final WebForm loginForm =
968         this.findFormWithAction(forms,
969                                 "https://sourceforge.net/account/login.php");
970       assertNotNull(loginForm, "loginForm");
971 
972       loginForm.setParameter("form_loginname", userName);
973       loginForm.setParameter("form_pw", password);
974       loginForm.removeParameter("stay_in_ssl");
975       loginForm.removeParameter("persistent_login");
976 
977       final WebRequest loginSubmissionRequest = loginForm.getRequest("login");
978       assertNotNull(loginSubmissionRequest, "loginSubmissionRequest");
979 
980       final WebResponse loginResponse =
981         conversation.getResponse(loginSubmissionRequest);
982       assertNotNull(loginResponse, "loginResponse");
983       LOGGER.info("Retrieved " + loginResponse.getTitle());
984 
985       final String text = loginResponse.getText();
986       assertNotNull(text, "text");
987 
988       if (text.indexOf("Invalid Password or User Name") >= 0) {
989         throw new InvalidCredentialsException(userName, password);
990       }
991 
992       return loginResponse;
993 
994     } catch (final IOException wrapMe) {
995       throw new PublishingException(wrapMe);
996     } catch (final SAXException wrapMe) {
997       throw new PublishingException(wrapMe);
998     }
999   }
1000 
1001   /***
1002    * A convenience method that extracts a {@link WebLink} from the supplied
1003    * array of {@link WebLink}s provided that its {@linkplain WebLink#asText()
1004    * text} is equal to the supplied {@link String}.  This method may return
1005    * <code>null</code>.
1006    *
1007    * @param      links
1008    *               the array of {@link WebLink}s from which an appropriate
1009    *               {@link WebLink} is to be extracted; may be <code>null</code>
1010    *               in which case <code>null</code> will be returned
1011    * @param      textToMatch
1012    *               the {@link String} to which a {@link WebLink}'s {@linkplain
1013    *               WebLink#asText() text} must be equal in order to be selected;
1014    *               if <code>null</code>, then <code>null</code> will be returned
1015    * @return     a {@link WebLink} from the supplied array of {@link WebLink}s
1016    *               provided that its {@linkplain WebLink#asText() text} is equal
1017    *               to the supplied {@link String}, or <code>null</code>
1018    */
1019   protected WebLink findLinkWithExactText(final WebLink[] links,
1020                                           final String textToMatch) {
1021     if (textToMatch == null || links == null || links.length <= 0) {
1022       return null;
1023     }
1024     WebLink link = null;
1025     String linkText;
1026     boolean found = false;
1027     for (int i = 0; i < links.length; i++) {
1028       link = links[i];
1029       if (link != null) {
1030         linkText = link.asText();
1031         if (linkText != null) {
1032           if (textToMatch.equals(linkText.trim())) {
1033             found = true;
1034             break;
1035           }
1036         }
1037       }
1038     }
1039     if (!found) {
1040       return null;
1041     }
1042     assert link != null;
1043     return link;
1044   }
1045 
1046   /***
1047    * Extracts a {@link WebForm} from the supplied array of {@link WebForm}s,
1048    * provided that its {@linkplain WebForm#getAction() action} is equal to the
1049    * supplied {@link String}.  This method may return <code>null</code>.
1050    *
1051    * @param      forms
1052    *               the array of {@link WebForm}s to consider; if
1053    *               <code>null</code> then <code>null</code> will be returned
1054    * @param      action
1055    *               the action {@link String} to which a given {@link WebForm}'s
1056    *               {@linkplain WebForm#getAction() action} must be equal in
1057    *               order for that {@link WebForm} to be returned; if
1058    *               <code>null</code> then <code>null</code> will be returned
1059    * @return     a {@link WebForm} from the supplied array of {@link WebForm}s,
1060    *               provided that its {@linkplain WebForm#getAction() action} is
1061    *               equal to the supplied {@link String}, or <code>null</code>
1062    */
1063   protected WebForm findFormWithAction(final WebForm[] forms,
1064                                        final String action) {
1065     if (forms == null || forms.length <= 0) {
1066       return null;
1067     }
1068     WebForm form;
1069     String formAction;
1070     for (int i = 0; i < forms.length; i++) {
1071       form = forms[i];
1072       if (form != null) {
1073         formAction = form.getAction();
1074         if (formAction == null) {
1075           if (action == null) {
1076             return null;
1077           }
1078         } else {
1079           if (formAction.equals(action)) {
1080             return form;
1081           }
1082         }
1083       }
1084     }
1085     return null;
1086   }
1087 
1088   /***
1089    * Returns an array of {@link WebForm}s whose elements are those {@link
1090    * WebForm}s extracted from the supplied {@link WebForm} array with
1091    * {@linkplain WebForm#getAction() actions} equal to the supplied {@link
1092    * String}.  This method never returns <code>null</code>.
1093    *
1094    * @param      forms
1095    *               the {@link WebForm} array in which to find candidates; may 
1096    *               be <code>null</code>
1097    * @param      action
1098    *               the {@linkplain WebForm#getAction() action} {@link String} to
1099    *               match; may be <code>null</code>
1100    * @return     an array of {@link WebForm}s whose elements are those {@link
1101    *               WebForm}s extracted from the supplied {@link WebForm} array
1102    *               with {@linkplain WebForm#getAction() actions} equal to the
1103    *               supplied {@link String}; never <code>null</code>
1104    */
1105   protected WebForm[] retainFormsWithAction(final WebForm[] forms,
1106                                             final String action) {
1107     if (forms == null || forms.length <= 0) {
1108       return new WebForm[0];
1109     }
1110     final Collection returnForms = new ArrayList(forms.length);
1111     WebForm form;
1112     String formAction;
1113     for (int i = 0; i < forms.length; i++) {
1114       form = forms[i];
1115       if (form != null) {
1116         formAction = form.getAction();
1117         if (formAction == null) {
1118           if (action == null) {
1119             returnForms.add(form);
1120           }
1121         } else {
1122           if (formAction.equals(action)) {
1123             returnForms.add(form);
1124           }
1125         }
1126       }
1127     }
1128     return (WebForm[])returnForms.toArray(new WebForm[returnForms.size()]);
1129   }
1130 
1131   /***
1132    * Creates a new package on the <a
1133    * href="http://sourceforge.net/">SourceForge</a> website and returns the
1134    * resulting "packages page", which should now include the newly-created
1135    * package among its list of existing packages.  This method may return
1136    * <code>null</code>, or at least cannot be guaranteed to return a
1137    * non-<code>null</code> result.
1138    *
1139    * <p>This method is called from the {@link
1140    * #getEditReleasePage(WebConversation, WebResponse, FileRelease)} method.</p>
1141    *
1142    * @param      createPackageForm
1143    *               a {@link WebForm} that represents the "create package" form
1144    *               on the "packages page"; must not be <code>null</code>
1145    * @param      packageToCreate
1146    *               the {@link Package} to be created on <a
1147    *               href="http://sourceforge.net/">SourceForge</a>; must not be
1148    *               <code>null</code>
1149    * @return     a {@link WebResponse} representing the "packages page", or
1150    *               <code>null</code>
1151    * @exception  PublishingException
1152    *               if an error occurs
1153    */
1154   protected WebResponse createPackage(final WebForm createPackageForm,
1155                                       Package packageToCreate)/package-summary.html">final Package packageToCreate)
1156     throws PublishingException {
1157     assertNotNull(createPackageForm, "createPackageForm");
1158     assertNotNull(packageToCreate, "packageToCreate");
1159     String packageName = packageToCreate.getName();
1160     assertNotNull(packageName, "packageName");
1161 
1162     try {
1163       if (!EDIT_PACKAGES_ACTION.equals(createPackageForm.getAction()) ||
1164           !ADD_PACKAGE.equals(createPackageForm.getParameterValue(PACKAGE_ACTION_PARAMETER))) {
1165         throw new PublishingException("Wrong form");
1166       }
1167       createPackageForm.setParameter(PACKAGE_NAME, packageName);
1168       return createPackageForm.submit();
1169     } catch (final IOException kaboom) {
1170       throw new PublishingException(kaboom);
1171     } catch (final SAXException kaboom) {
1172       throw new PublishingException(kaboom);
1173     }
1174   }
1175 
1176   /***
1177    * Returns a {@link WebResponse} representing the "edit release" page.
1178    * Producing this page may involve creating a new file release or a new
1179    * package along the way to accurately reflect the contents of the supplied
1180    * {@link FileRelease}.  This method never returns <code>null</code>.
1181    *
1182    * @param      conversation
1183    *               the {@link WebConversation} currently in effect; must not be
1184    *               <code>null</code>
1185    * @param      packagesPage
1186    *               a {@link WebResponse} representing the "packages page"; must
1187    *               not be <code>null</code>
1188    * @param      fileRelease
1189    *               the {@link FileRelease} whose <a
1190    *               href="http://sourceforge.net/">SourceForge</a> analog is to
1191    *               be edited; must not be <code>null</code>
1192    * @return     a {@link WebResponse} representing the "edit release" page;
1193    *               never <code>null</code>
1194    * @exception  PublishingException
1195    *               if an error occurs 
1196    */
1197   protected WebResponse getEditReleasePage(final WebConversation conversation,
1198                                            WebResponse packagesPage,
1199                                            final FileRelease fileRelease)
1200     throws PublishingException {
1201     assertNotNull(conversation, "conversation");
1202     assertNotNull(packagesPage, "packagesPage");
1203     assertNotNull(fileRelease, "fileRelease");
1204     final Package pkg = fileRelease.getPackage();
1205     assertNotNull(pkg, "pkg");
1206     final Project project = pkg.getProject();
1207     assertNotNull(project, "project");
1208     final String fileReleaseName = fileRelease.getName();
1209 
1210     try {
1211 
1212       // Get all the forms on the packages page that can affect packages.  These
1213       // will include:
1214       //
1215       // (Legend: {x} = link; _x__ = textbox; [[x]] = dropdown; (x) = button)
1216       //
1217       //
1218       //           Releases                Package Name     Status    Update
1219       // {Add Releases}{Edit Releases} _foo______________ [[Hidden]] (Update)
1220       // {Add Releases}{Edit Releases} _bar______________ [[Active]] (Update)
1221       //
1222       // New Package Name:
1223       // _____________
1224       //
1225       // (Create This Package)
1226       //
1227       //
1228       // The (Update) and (Create This Package) buttons will submit to the
1229       // associated action, which is, in all cases,
1230       // /project/admin/editpackages.php, even though (Update) causes an update
1231       // to happen to an EXISTING package, and (Create This Package) causes a
1232       // NEW package to be created.  Note that the New Package form will ALWAYS
1233       // exist.
1234       finaltrong> WebForm[] packageForms =
1235         this.retainFormsWithAction(packagesPage.getForms(),
1236                                    EDIT_PACKAGES_ACTION);
1237       assertArrayFull(packageForms, "packageForms");
1238 
1239       iftrong> (packageForms.length == 1) {
1240         // If there is only one form on the page (with the relevant action
1241         // attribute; see above), then it is guaranteed to be the form for
1242         // adding a new package.  That means that there are no existing
1243         // packages.  So we need to create one.  We'll call this method
1244         // recursively and return its result, since the creation of a new
1245         // package will cause that package to show up as an editable package
1246         // when the packages page is displayed again.
1247         return
1248           this.getEditReleasePage(conversation,
1249                                   this.createPackage(packageForms[0],
1250                                                      pkg),
1251                                   fileRelease);
1252       }
1253 
1254       // By the time we get here, we know that there is more than one package
1255       // form, which means that we have at least one existing package.  Spin
1256       // through these package forms and see if they contain the package we've
1257       // been handed.
1258       WebForm createPackageForm = null;
1259       WebForm updatePackageForm = null;
1260       WebForm currentPackageForm;
1261       for (int i = 0; i < packageForms.length; i++) {
1262         currentPackageForm = packageForms[i];
1263         if (this.isUpdatePackageFormFor(currentPackageForm, pkg)) {
1264           updatePackageForm = currentPackageForm;
1265           break;
1266         } else if (this.isAddPackageForm(currentPackageForm)) {
1267           createPackageForm = currentPackageForm;
1268           // (don't do a break here; loop around again, maximizing our chances
1269           // of finding an update package form)
1270         }
1271       }
1272       if (updatePackageForm == null) {
1273         assertNotNull(createPackageForm, "createPackageForm");
1274 
1275         // Create a new package.  We'll call this method recursively and return
1276         // its result, since the creation of a new package will cause that
1277         // package to show up as an editable package when the packages page is
1278         // displayed again.
1279         return
1280           this.getEditReleasePage(conversation,
1281                                   this.createPackage(createPackageForm,
1282                                                      pkg),
1283                                   fileRelease);
1284       }
1285 
1286       // We found the package form we want to work with.  Make sure the package
1287       // status is sync'ed up.  This may involve a form submission that will
1288       // return us to this page.
1289       packagesPage = this.synchronizeStatus(updatePackageForm, pkg);
1290 
1291       // We now need to save several things: the package ID, and its "group" ID
1292       // (which is really the ID of the project that got us here).  We need
1293       // these because in order to actually edit or add a file release later, we
1294       // need to call a script that is used SourceForge-wide, and depends on
1295       // these identifiers to function.
1296 
1297       // Get the package identifier...
1298       finaltrong> String packageID = updatePackageForm.getParameterValue(PACKAGE_ID);
1299       assertNotNull(packageID, "packageID");
1300 
1301       pkg.setID(packageID);
1302 
1303       // ...and then grab its "group ID", which I think is really the
1304       // project identifier.
1305       final String groupID = updatePackageForm.getParameterValue(GROUP_ID);
1306       assertNotNull(groupID, "groupID");
1307 
1308       project.setID(groupID);
1309 
1310       final WebResponse releasesPage =
1311         this.getReleasesPage(conversation, packageID, groupID);
1312 
1313       if (releasesPage != null) {
1314         // 1. packagesPage --(edit releases)--> releasesPage --(edit this release)--> editReleasePage
1315         //                                      ^^^^^^^^^^^^
1316         LOGGER.info("Found releases page for " + pkg.getName());
1317         final WebLink editThisReleaseLink =
1318           this.getEditReleaseLink(releasesPage, fileRelease);
1319         if (editThisReleaseLink != null) {
1320           return editThisReleaseLink.click();
1321         }
1322       }
1323 
1324       // 2. packagesPage --(edit releases)--> noReleasesPage --(back)----> packagesPage --(add release)--> createReleasePage --(create)--> editReleasePage
1325       //                                                                   ^^^^^^^^^^^^
1326       // or
1327       // 3. packagesPage --(edit releases)--> releasesPage --(no match)--> packagesPage --(add release)--> createReleasePage --(create)--> editReleasePage
1328       //                                                                   ^^^^^^^^^^^^
1329       final String newReleaseURL = 
1330         this.buildNewReleaseHref(conversation, packageID, groupID);
1331       assertNotNull(newReleaseURL, "newReleaseURL");
1332 
1333       final WebResponse createReleasePage = 
1334         conversation.getResponse(newReleaseURL);
1335       assertNotNull(createReleasePage, "createReleasePage");
1336       LOGGER.info("Retrieved " + createReleasePage.getTitle());
1337 
1338       final WebForm newReleaseForm =
1339         this.findFormWithAction(createReleasePage.getForms(),
1340                                 "/project/admin/newrelease.php");
1341       assertNotNull(newReleaseForm, "newReleaseForm");
1342 
1343       newReleaseForm.setParameter(RELEASE_NAME, fileRelease.getName());
1344       newReleaseForm.setParameter(PACKAGE_ID, packageID);
1345       newReleaseForm.setParameter(GROUP_ID, groupID);
1346 
1347       return newReleaseForm.submit();
1348 
1349     } catch (final IOException kaboom) {
1350       throw new PublishingException(kaboom);
1351     } catch (final SAXException kaboom) {
1352       throw new PublishingException(kaboom);
1353     }
1354   }
1355 
1356   /***
1357    * Changes the {@linkplain HideableNamedObject#isHidden() hidden status} of
1358    * the <a href="http://sourceforge.net/'>SourceForge</a> analog of the
1359    * supplied {@link HideableNamedObject} to match that of the supplied {@link
1360    * HideableNamedObject}.  This method is called from the {@link
1361    * #getEditReleasePage(WebConversation, WebResponse, FileRelease)} method and
1362    * never returns <code>null</code>.
1363    *
1364    * @param      updateForm
1365    *               a {@link WebForm} capable of updating the hidden status of
1366    *               either a file release or a package (or another type of object
1367    *               that can be hidden); must not be <code>null</code>
1368    * @param      hideable
1369    *               a {@link HideableNamedObject} whose {@linkplain
1370    *               HideableNamedObject#isHidden() hidden status} will be
1371    *               examined; must not be <code>null</code>
1372    * @return     the result of {@linkplain WebForm#submit() submitting} the
1373    *               the supplied {@link WebForm}; never <code>null</code>
1374    * @exception  PublishingException
1375    *               if an error occurs
1376    */
1377   protected WebResponse synchronizeStatus(final WebForm updateForm,
1378                                           final HideableNamedObject hideable)
1379     throws PublishingException {
1380     assertNotNull(updateForm, "updateForm");
1381     assertNotNull(hideable, "hideable");
1382     final String name = hideable.getName();
1383     assertNotNull(name, "name");
1384 
1385     try {
1386 
1387       // Make sure its status is correct.  If it's visible, and our
1388       // Hideable object says that the status is supposed to be hidden,
1389       // change it accordingly.
1390       final String status = updateForm.getParameterValue(STATUS);
1391       assertNotNull(status, "status");
1392 
1393       WebResponse currentPage = null;
1394       if (VISIBLE.equals(status) && hideable.isHidden()) {
1395         updateForm.setParameter(STATUS, HIDDEN);
1396         LOGGER.info("Changing status for " + name + " to hidden");
1397         currentPage = updateForm.submit();
1398       } else if (HIDDEN.equals(status) && !hideable.isHidden()) {
1399         updateForm.setParameter(STATUS, VISIBLE);
1400         LOGGER.info("Changing status for " + name + " to visible");
1401         currentPage = updateForm.submit();
1402       }
1403 
1404       return currentPage;
1405 
1406     } catch (final IOException kaboom) {
1407       throw new PublishingException(kaboom);
1408     } catch (final SAXException kaboom) {
1409       throw new PublishingException(kaboom);
1410     }
1411   }
1412 
1413   /***
1414    * Returns <code>true</code> if the supplied {@link WebForm} is one that can
1415    * add a package to <a href="http://sourceforge.net/">SourceForge</a>.
1416    *
1417    * @param      packageForm
1418    *               the {@link WebForm} to test; if <code>null</code> then
1419    *               <code>false</code> will be returned
1420    * @return     <code>true</code> if the supplied {@link WebForm} can
1421    *               add a package to <a
1422    *               href="http://sourceforge.net/">SourceForge</a>
1423    */
1424   protectedg> boolean isAddPackageForm(final WebForm packageForm) {
1425     returnong> this.isPackageFormWithFunction(packageForm, ADD_PACKAGE);
1426   }
1427 
1428   /***
1429    * Returns <code>true</code> if the supplied {@link WebForm} is capable of
1430    * updating the <a href="http://sourceforge.net/">SourceForge</a> analog of
1431    * the supplied {@link Package}.
1432    *
1433    * @param      packageForm
1434    *               the {@link WebForm} to test; if <code>null</code> then
1435    *               <code>false</code> will be returned
1436    * @param      pkg
1437    *               the {@link Package} in question; if <code>null</code> then
1438    *               <code>false</code> will be returned
1439    * @return     <code>true</code> if the supplied {@link WebForm} is capable of
1440    *               editing the <a href="http://sourceforge.net/">SourceForge</a>
1441    *               analog of the supplied {@link Package} 
1442    */
1443   protectedg> boolean isUpdatePackageFormFor(final WebForm packageForm,
1444                                            final Package pkg) {
1445     return
1446       packageForm != null &&
1447       pkg != null &&
1448       this.isPackageFormFor(packageForm, pkg) &&
1449       this.isPackageFormWithFunction(packageForm, UPDATE_PACKAGE);
1450   }
1451 
1452   /***
1453    * Returns <code>true</code> if the supplied {@link WebForm} will affect the
1454    * <a href="http://sourceforge.net/">SourceForge</a> analog of the supplied
1455    * {@link Package} in some way.
1456    *
1457    * <p>This method is called only by the {@link
1458    * #isUpdatePackageFormFor(WebForm, Package)} method.</p>
1459    *
1460    * @param      packageForm
1461    *               the {@link WebForm} to test; if <code>null</code> then
1462    *               <code>false</code> will be returned
1463    * @param      pkg
1464    *               the {@link Package} in question; if <code>null</code> then
1465    *               <code>false</code> will be returned
1466    * @return     <code>true</code> if the supplied {@link WebForm} will affect
1467    *               the <a href="http://sourceforge.net/">SourceForge</a>
1468    *               analog of the supplied {@link Package} in some way
1469    */
1470   protectedg> boolean isPackageFormFor(final WebForm packageForm,
1471                                      final Package pkg) {
1472     ifong> (packageForm == null || pkg == null) {
1473       return false;
1474     }
1475     final String pkgName = pkg.getName();
1476     finalong> String packageName =
1477       packageForm.getParameterValue(PACKAGE_NAME);
1478     if (pkgName == null) {
1479       returntrong> packageName == null;
1480     }
1481     returnong> pkgName.equals(packageName);
1482   }
1483 
1484   /***
1485    * Returns <code>true</code> if the supplied {@link WebForm} represents a form
1486    * to edit a package that has the supplied {@link String} as the value of its
1487    * "<code>func</code>" input parameter.  This method is essentially a
1488    * refactored helper method that is called by the {@link
1489    * #isUpdatePackageFormFor(WebForm, Package)} method.
1490    *
1491    * @param      packageForm
1492    *               the {@link WebForm} to test; may be <code>null</code> in
1493    *               which case <code>false</code> is returned
1494    * @param      soughtValue
1495    *               the "<code>func</code>" parameter value to look for; may be
1496    *               <code>null</code> in which case this method will almost
1497    *               certainly return <code>false</code> given that it's extremely
1498    *               unlikely that the SourceForge website will have
1499    *               "<code>null</code>" or "" as the value of this parameter
1500    * @return     <code>true</code> if the supplied {@link WebForm} represents a
1501    *               form to edit a package that has the supplied {@link String}
1502    *               as the value of its "<code>func</code>" input parameter;
1503    *               <code>false</code> in absolutely all other cases 
1504    */
1505   protectedg> boolean isPackageFormWithFunction(final WebForm packageForm,
1506                                               final String soughtValue) {
1507     ifong> (packageForm == null) {
1508       return false;
1509     }
1510     finalong> String packageAction =
1511       packageForm.getParameterValue(PACKAGE_ACTION_PARAMETER);
1512     if (soughtValue == null) {
1513       returntrong> packageAction == null;
1514     }
1515     returnong> soughtValue.equals(packageAction);
1516   }
1517 
1518   /***
1519    * Returns <code>true</code> if the supplied {@link WebResponse} represents
1520    * the "edit releases" page.
1521    *
1522    * @param      page
1523    *               the {@link WebResponse} to test; must not be
1524    *               <code>null</code>
1525    * @param      checkForReleases
1526    *               whether or not to dig a little deeper and see if there are
1527    *               file releases on the page represented by the supplied {@link
1528    *               WebResponse}
1529    * @return     <code>true</code> if the supplied {@link WebResponse}
1530    *               represents the "edit releases" page
1531    * @exception  PublishingException
1532    *               if an error occurs 
1533    */
1534   protected boolean isReleasesPage(final WebResponse page,
1535                                    final boolean checkForReleases)
1536     throws PublishingException {
1537     assertNotNull(page, "page");
1538 
1539     try {
1540       final String title = page.getTitle();
1541       if (title == null || title.indexOf("FRS: Releases") < 0) {
1542         return false;
1543       }
1544       if (checkForReleases) {
1545         final String text = page.getText();
1546         return
1547           text != null &&
1548           text.indexOf("You Have No Releases Of This Package Defined") < 0;
1549       }
1550       return true;
1551     } catch (final IOException kaboom) {
1552       throw new PublishingException(kaboom);
1553     } catch (final SAXException kaboom) {
1554       throw new PublishingException(kaboom);
1555     }
1556   }
1557 
1558   /***
1559    * Returns a {@link String} that, when treated as a URL, will edit the file
1560    * releases that belong to the package and project with the corresponding
1561    * supplied identifiers.  This method never returns <code>null</code>.
1562    *
1563    * @param      conversation
1564    *               the {@link WebConversation} currently in effect; must not be
1565    *               <code>null</code>
1566    * @param      packageID
1567    *               the identifier of the package whose file releases will
1568    *               ultimately be edited; must not be <code>null</code>
1569    * @param      groupID
1570    *               the identifier of the project whose file releases will
1571    *               ultimately be edited; must not be <code>null</code> and must
1572    *               have a package with the supplied package identifier
1573    * @return     a {@link String} that, when treated as a URL, will edit the 
1574    *               file releases that belong to the package and project with the
1575    *               corresponding supplied identifiers; never <code>null</code>
1576    * @exception  PublishingException
1577    *               if an error occurs 
1578    */
1579   protected String buildEditReleasesHref(final WebConversation conversation,
1580                                          final String packageID,
1581                                          final String groupID)
1582     throws PublishingException {
1583     assertNotNull(conversation, "conversation");
1584     assertNotNull(packageID, "packageID");
1585     assertNotNull(groupID, "groupID");
1586 
1587     final WebResponse currentPage = conversation.getCurrentPage();
1588     assertNotNull(currentPage, "currentPage");
1589 
1590     final URL currentPageURL = currentPage.getURL();
1591     assertNotNull(currentPageURL, "currentPageURL");
1592 
1593     final StringBuffer returnMe =
1594       new StringBuffer("editreleases.php?package_id=");
1595     returnMe.append(packageID);
1596     returnMe.append("&group_id=");
1597     returnMe.append(groupID);
1598     try {
1599       return new URL(currentPageURL, returnMe.toString()).toString();
1600     } catch (final MalformedURLException kaboom) {
1601       throw new PublishingException(kaboom);
1602     }
1603   }
1604 
1605   /***
1606    * Returns a {@link String} that, when treated as a URL, will create a new
1607    * file release that will belong to the package and project with the
1608    * corresponding supplied identifiers.  This method never returns
1609    * <code>null</code>.
1610    *
1611    * @param      conversation
1612    *               the {@link WebConversation} currently in effect; must not be
1613    *               <code>null</code>
1614    * @param      packageID
1615    *               the identifier of the package to which a new file release
1616    *               will belong; must not be <code>null</code>
1617    * @param      groupID
1618    *               the identifier of the project to which a new file release
1619    *               will belong; must not be <code>null</code> and must have a
1620    *               package with the supplied package identifier
1621    * @return     a {@link String} that, when treated as a URL, will create a new
1622    *               file release that will belong to the package and project with
1623    *               the corresponding supplied identifiers; never
1624    *               <code>null</code>
1625    * @exception  PublishingException
1626    *               if an error occurs 
1627    */
1628   protected String buildNewReleaseHref(final WebConversation conversation,
1629                                        final String packageID,
1630                                        final String groupID)
1631     throws PublishingException {
1632     assertNotNull(conversation, "conversation");
1633     assertNotNull(packageID, "packageID");
1634     assertNotNull(groupID, "groupID");
1635 
1636     final WebResponse currentPage = conversation.getCurrentPage();
1637     assertNotNull(currentPage, "currentPage");
1638 
1639     final URL currentPageURL = currentPage.getURL();
1640     assertNotNull(currentPageURL, "currentPageURL");
1641 
1642     final StringBuffer returnMe =
1643       new StringBuffer("newrelease.php?package_id=");
1644     returnMe.append(packageID);
1645     returnMe.append("&group_id=");
1646     returnMe.append(groupID);
1647     try {
1648       return new URL(currentPageURL, returnMe.toString()).toString();
1649     } catch (final MalformedURLException kaboom) {
1650       throw new PublishingException(kaboom);
1651     }
1652   }
1653 
1654   /***
1655    * Returns a {@link WebResponse} that represents the "releases page"
1656    * appropriate for the given project and package identifiers.  This method may
1657    * return <code>null</code>.
1658    *
1659    * @param      conversation
1660    *               the {@link WebConversation} currently in effect; must not be
1661    *               <code>null</code>
1662    * @param      packageID
1663    *               the identifier of the package whose releases will be shown;
1664    *               must not be <code>null</code>
1665    * @param      groupID
1666    *               the identifier of the project to which the package with the
1667    *               supplied package identifier must belong; must not be
1668    *               <code>null</code>
1669    * @return     a {@link WebResponse} that represents the "releases page", or
1670    *               <code>null</code>
1671    * @exception  PublishingException
1672    *               if an error occurs
1673    */
1674   protected WebResponse getReleasesPage(final WebConversation conversation,
1675                                         final String packageID,
1676                                         final String groupID)
1677     throws PublishingException {
1678     assertNotNull(conversation, "conversation");
1679     assertNotNull(packageID, "packageID");
1680     assertNotNull(groupID, "groupID");
1681 
1682     try {
1683       final String url =
1684         this.buildEditReleasesHref(conversation, packageID, groupID);
1685       assertNotNull(url, "url");
1686 
1687       final WebResponse editReleasesPage = conversation.getResponse(url);
1688       assertNotNull(editReleasesPage, "editReleasesPage");
1689       LOGGER.info("Retrieved " + editReleasesPage.getTitle());
1690 
1691       if (this.isReleasesPage(editReleasesPage, true)) {
1692         return editReleasesPage;
1693       }
1694       return null;
1695     } catch (final IOException kaboom) {
1696       throw new PublishingException(kaboom);
1697     } catch (final SAXException kaboom) {
1698       throw new PublishingException(kaboom);
1699     }
1700   }
1701 
1702   /***
1703    * A very special-purpose helper method that extracts the "<code>[Edit
1704    * Release]</code>" link from the "releases page" that is appropriate for the
1705    * supplied {@link FileRelease}.  This method may return <code>null</code>.
1706    *
1707    * @param      releasesPage
1708    *               a {@link WebResponse} representing the "releases page"; must
1709    *               not be <code>null</code>
1710    * @param      release
1711    *               the {@link FileRelease} in question; must not be
1712    *               <code>null</code>
1713    * @return     a {@link WebLink} corresponding to the appropriate "<code>[Edit
1714    *               Release]</code>" link, or <code>null</code>
1715    * @exception  PublishingException
1716    *               if an error occurs
1717    */
1718   protected WebLink getEditReleaseLink(final WebResponse releasesPage,
1719                                        final FileRelease release)
1720     throws PublishingException {
1721     assertNotNull(releasesPage, "releasesPage");
1722     assertNotNull(release, "release");
1723     try {
1724       // The releases page has a table of file releases.  The number of rows
1725       // in the table, minus one for its title row, will be equal to the
1726       // number of existing file releases.  So let's look to see if our file
1727       // release is in here.  If it's not, we'll have to back up and add a
1728       // release instead.
1729       final WebTable table = releasesPage.getTableStartingWith("Release Name");
1730       assertNotNull(table, "table");
1731 
1732       final int rowCount = table.getRowCount();
1733       TableCell cell;
1734       String cellText;
1735       String releaseName;
1736       int leftBracketIndex;
1737       WebLink link;
1738       for (int i = 1; i < rowCount; i++) {
1739         cell = table.getTableCell(i, 0);
1740         if (cell != null) {
1741           cellText = cell.asText();
1742           if (cellText != null) {
1743             leftBracketIndex = cellText.indexOf("[Edit This Release]");
1744             if (leftBracketIndex > 0) {
1745               releaseName = cellText.substring(0, leftBracketIndex);
1746               if (releaseName != null) {
1747                 releaseName = releaseName.trim();
1748                 if (releaseName != null &&
1749                     releaseName.equals(release.getName())) {
1750                   link = cell.getLinkWith("[Edit This Release]");
1751                   if (link != null) {
1752                     return link;
1753                   }
1754                 }
1755               }
1756             }
1757           }
1758         }
1759       }
1760       return null;
1761     } catch (final SAXException kaboom) {
1762       throw new PublishingException(kaboom);
1763     }
1764   }
1765 
1766   /***
1767    * A convenience method that throws a {@link NullObjectException} if the
1768    * supplied {@link Object} is <code>null</code>.  This method is primarily
1769    * used to check method arguments and in other cases where a checked {@link
1770    * Exception} is preferred to the usual {@link IllegalArgumentException}
1771    * alternative.
1772    *
1773    * @param      object
1774    *               the {@link Object} to check; if <code>null</code> a {@link
1775    *               NullObjectException} will be thrown
1776    * @param      message
1777    *               a message describing the problem if the supplied {@link
1778    *               Object} is <code>null</code>; may be <code>null</code>
1779    * @exception  NullObjectException
1780    *               if the supplied {@link Object} is <code>null</code>
1781    */
1782   protected static final void assertNotNull(final Object object,
1783                                             final String message)
1784     throws NullObjectException {
1785     if (object == null) {
1786       throw new NullObjectException(message);
1787     }
1788   }
1789 
1790   /***
1791    * A convenience metohd that throws either a {@link NullObjectException} or an
1792    * {@link EmptyArrayException} if the supplied {@link Object} array is
1793    * <code>null</code> or has a length less than or equal to <code>0</code>
1794    * respectively.
1795    *
1796    * @param      object
1797    *               the {@link Object} array to check; if <code>null</code> a
1798    *               {@link NullObjectException} will be thrown and if empty an
1799    *               {@link EmptyArrayException} will be thrown
1800    * @param      message
1801    *               a message describing the problem if the supplied {@link
1802    *               Object} array is <code>null</code> or empty; may be
1803    *               <code>null</code>
1804    * @exception  NullObjectException
1805    *               if the supplied {@link Object} array is <code>null</code>
1806    * @exception  EmptyArrayException
1807    *               if the supplied {@link Object} array has a length less than 
1808    *               or equal to <code>0</code>
1809    */
1810   protected static final void assertArrayFull(final Object[] object,
1811                                               final String message)
1812     throws NullObjectException, EmptyArrayException {
1813     assertNotNull(object, message);
1814     if (object.length <= 0) {
1815       throw new EmptyArrayException(message);
1816     }
1817   }
1818 
1819   /***
1820    * A {@link Thread} that uploads a {@link File} to an FTP site.
1821    *
1822    * @author     <a href="mailto:ljnelson94@alumni.amherst.edu">Laird Nelson</a>
1823    * @version    $Revision: 1.15 $ $Date: 2004/07/27 20:20:34 $
1824    * @since June 19, 2003 */
1825   static final class FileUploader extends Thread {
1826 
1827     /***
1828      * The FTP URL to which this {@link HttpUnitPublisher.FileUploader}'s
1829      * {@linkplain #getFile() associated <code>File</code>} will be uploaded.
1830      */
1831     private static final String UPLOAD_DIRECTORY =
1832       "ftp://upload.sourceforge.net/incoming";
1833 
1834     /***
1835      * The {@link File} to upload.  This field may be <code>null</code>.
1836      *
1837      * @see        #getFile()
1838      */
1839     private final File file;
1840 
1841     /***
1842      * The {@link Collection} to which any {@link Exception}s encountered during
1843      * the execution of the {@link #upload()} method will be added.  This field
1844      * must never be <code>null</code>.
1845      *
1846      * @see        #getErrors()
1847      */
1848     private final Collection errors;
1849 
1850     /***
1851      * Creates a new {@link HttpUnitPublisher.FileUploader}.
1852      *
1853      * @param      file
1854      *               the {@link File} to upload; may be <code>null</code> in
1855      *               which case the {@link #upload()} method will do nothing
1856      * @param      errors
1857      *               the {@link Collection} to which any {@link Exception}s
1858      *               encountered during the execution of the {@link #upload()}
1859      *               method will be added; may be <code>null</code> in which
1860      *               case a new {@link ArrayList} will be used instead
1861      */
1862     FileUploader(final File file, final Collection errors) {
1863       super();
1864       this.file = file;
1865       if (errors == null) {
1866         this.errors = new ArrayList(5);
1867       } else {
1868         this.errors = errors;
1869       }
1870     }
1871 
1872     /***
1873      * Returns the {@link File} to upload.  This method may return
1874      * <code>null</code>.
1875      *
1876      * @return     the {@link File} to upload, or <code>null</code>
1877      */
1878     public File getFile() {
1879       return this.file;
1880     }
1881 
1882     /***
1883      * Returns a {@link Collection} of any errors encountered during the
1884      * execution of either the {@link #run()} or the {@link #upload()} method.
1885      * This method may return <code>null</code>.
1886      *
1887      * @return     a {@link Collection} of errors, or <code>null</code>
1888      */
1889     public final Collection getErrors() {
1890       return this.errors;
1891     }
1892 
1893     /***
1894      * Calls the {@link #upload()} method.
1895      */
1896     public final void run() {
1897       this.upload();
1898     }
1899 
1900     /***
1901      * Uploads this {@link HttpUnitPublisher.FileUploader}'s associated {@link
1902      * #getFile() File} via FTP to the
1903      * <code>ftp://upload.sourceforge.net/incoming/</code> directory.  Any
1904      * errors encountered are placed in the {@linkplain #getErrors() associated
1905      * errors <code>Collection</code>}.
1906      */
1907     public final void upload() {
1908       InputStream inputStream = null;
1909       OutputStream outputStream = null;
1910       final File file = this.getFile();
1911       if (file == null) {
1912         return;
1913       }
1914       final Collection errors = this.getErrors();
1915       try {
1916 
1917         // Get an input stream from the file.
1918         inputStream = new FileInputStream(file);
1919 
1920         // Get an output stream to the FTP location.
1921         final String shortName = file.getName();
1922         assert shortName != null;
1923         final URLConnection connection =
1924           new URL(UPLOAD_DIRECTORY + "/" + shortName).openConnection();
1925         assert connection != null;
1926 
1927         connection.setDoOutput(true);
1928         connection.connect();
1929 
1930         outputStream = connection.getOutputStream();
1931         assert outputStream != null;
1932 
1933         // Copy the file.
1934         this.copyStream(inputStream, outputStream);
1935 
1936       } catch (final Exception kaboom) {
1937         if (errors != null) {
1938           errors.add(kaboom);
1939         }
1940       } finally {
1941         try {
1942           if (outputStream != null) {
1943             outputStream.close();
1944           }
1945         } catch (final IOException ignore) {
1946           // ignore
1947         }
1948         try {
1949           if (inputStream != null) {
1950             inputStream.close();
1951           }
1952         } catch (final IOException ignore) {
1953           // ignore
1954         }
1955       }
1956     }
1957 
1958     /***
1959      * Copies the supplied {@link InputStream} to the supplied {@link
1960      * OutputStream} in chunks of 1,024 bytes.
1961      *
1962      * @param      inputStream
1963      *               the {@link InputStream} to copy; must not be
1964      *               <code>null</code>
1965      * @param      outputStream
1966      *               the {@link OutputStream} to copy the supplied {@link
1967      *               InputStream} to; must not be <code>null</code>
1968      * @exception  IOException
1969      *               if an input/output error occurs
1970      */
1971     private final void copyStream(final InputStream inputStream,
1972                                   final OutputStream outputStream)
1973       throws IOException {
1974       assert inputStream != null;
1975       assert outputStream != null;
1976       final byte[] buffer = new byte[1024];
1977       int numberOfBytesRead = 0;
1978       while ((numberOfBytesRead = inputStream.read(buffer)) != -1) {
1979         outputStream.write(buffer, 0, numberOfBytesRead);
1980       }
1981     }
1982 
1983   }
1984 
1985 }