1 package com.jbidwatcher.auction;
2 /*
3  * Copyright (c) 2000-2007, CyberFOX Software, Inc. All Rights Reserved.
4  *
5  * Developed by mrs (Morgan Schweers)
6  */
7 
8 import com.jbidwatcher.util.*;
9 import com.jbidwatcher.auction.event.EventLogger;
10 import com.jbidwatcher.auction.event.EventStatus;
11 import com.jbidwatcher.util.Currency;
12 import com.jbidwatcher.util.Observer;
13 import com.jbidwatcher.util.config.*;
14 import com.jbidwatcher.util.queue.MQFactory;
15 import com.jbidwatcher.util.db.ActiveRecord;
16 import com.jbidwatcher.util.db.Table;
17 import com.jbidwatcher.util.xml.XMLElement;
18 import com.jbidwatcher.util.xml.XMLInterface;
19 
20 import java.text.MessageFormat;
21 import java.text.SimpleDateFormat;
22 import java.util.*;
23 
24 /**
25  * @brief Contains all the methods to examine, control, and command a
26  * specific auction.
27  *
28  * Where the AuctionInfo class contains information which is purely
29  * retrieved from the server, the AuctionEntry class decorates that
30  * with things like when it was last updated, whether to snipe, any
31  * comment the user might have made on it, etc.
32  *
33  * I.e. AuctionEntry keeps track of things that the PROGRAM needs to
34  * know about the auction, not things that are inherent to auctions.
35  *
36  * This is not descended from AuctionInfo because the actual type of
37  * AuctionInfo varies per server.
38  *
39  * @author Morgan Schweers
40  * @see AuctionInfo
41  * @see SpecificAuction
42  */
43 public class AuctionEntry extends AuctionCore implements Comparable<AuctionEntry>, EntryInterface {
44   private Category mCategory;
45   private Presenter mAuctionEntryPresenter = null;
46 
equals(Object o)47   public boolean equals(Object o) {
48     return o instanceof AuctionEntry && compareTo((AuctionEntry) o) == 0;
49   }
50 
51   /**
52    * @brief Set a status message, and mark that the connection is currently invalid.
53    */
logError()54   public void logError() {
55     setLastStatus("Communications failure talking to the server.");
56     setInvalid();
57   }
58 
bestValue()59   public Currency bestValue() {
60     if (isSniped()) {
61       return getSnipe().getAmount();
62     }
63 
64     return isBidOn() && !isComplete() ? getBid() : getCurrentPrice();
65   }
66 
getSnipeAmount()67   public Currency getSnipeAmount() {
68     return isSniped() ? getSnipe().getAmount() : Currency.NoValue();
69   }
70 
getSnipeQuantity()71   public int getSnipeQuantity() {
72     return isSniped() ? getSnipe().getQuantity() : 0;
73   }
74 
getSnipe()75   private AuctionSnipe getSnipe() {
76     if(mSnipe == null) {
77       if(get("snipe_id") != null) {
78         mSnipe = AuctionSnipe.find(get("snipe_id"));
79         if(mSnipe == null) {
80           //  Couldn't find the snipe in the database.
81           setInteger("snipe_id", null);
82           saveDB();
83         }
84       }
85     }
86     return mSnipe;
87   }
88 
89   /**
90    * A logging class for keeping track of events.
91    *
92    * @see com.jbidwatcher.auction.event.EventLogger
93    */
94   private EventLogger mEntryEvents = null;
95 
96   /**
97    * Have we ever obtained this auction data from the server?
98    */
99   private boolean mLoaded =false;
100 
101   private AuctionSnipe mSnipe = null;
102 
103   /**
104    * How much was a cancelled snipe for?  (Recordkeeping)
105    */
106   private Currency mCancelSnipeBid = null;
107 
108   /**
109    * What AuctionServer is responsible for handling this
110    * AuctionEntry's actions?
111    */
112   private AuctionServerInterface mServer = null;
113 
114   /**
115    * The last time this auction was bid on.  Not presently used,
116    * although set, saved, and loaded consistently.
117    */
118   private long mBidAt = 0;
119 
120   /**
121    * Delta in time from the end of the auction that sniping will
122    * occur at.  It's possible to set a different snipe time for each
123    * auction, although it's not presently implemented through any UI.
124    */
125   private long mSnipeAt = -1;
126 
127   /**
128    * Default delta in time from the end of the auction that sniping
129    * will occur at.  This valus can be read and modified by
130    * getDefaultSnipeTime() & setDefaultSnipeTime().
131    */
132   private static long sDefaultSnipeAt = Constants.THIRTY_SECONDS;
133 
134   private StringBuffer mLastErrorPage = null;
135 
136   /**
137    * Does all the jobs of the constructors, so that the constructors
138    * become simple calls to this function.  Presets up all the
139    * necessary variables, loads any data in, sets the lastUpdated
140    * flag, all the timers, retrieves the auction if necessary.
141    *
142    * @param auctionIdentifier - Each auction site has an identifier that
143    *                            is used to key the auction.
144    */
prepareAuctionEntry(String auctionIdentifier)145   private synchronized void prepareAuctionEntry(String auctionIdentifier) {
146     AuctionInfo info = mServer.create(auctionIdentifier);
147     mLoaded = info != null;
148     if(mLoaded) {
149       setString("identifier", auctionIdentifier);
150       info.saveDB();
151       setInteger("auction_id", info.getId());
152     }
153 
154     /**
155      * Note that a bad auction (couldn't get an auction server, or a
156      * specific auction info object) doesn't have an identifier, and
157      * isn't loaded.  This will fail out the init process, and this
158      * will never be added to the items list.
159      */
160     if (mLoaded) {
161       AuctionServerInterface newServer = (AuctionServerInterface) info.getServer();
162       if(newServer != null) setServer(newServer);
163       Currency currentPrice = info.getBestPrice();
164       setDate("last_updated_at", new Date());
165       setDefaultCurrency(currentPrice);
166       saveDB();
167       notifyObservers(ObserverMode.AFTER_CREATE);
168       updateHighBid();
169       checkHighBidder();
170       checkEnded();
171     }
172   }
173 
174   ///////////////
175   //  Constructor
176 
177   /** Construct an AuctionEntry from just the ID, loading all necessary info
178    * from the server.
179    *
180    * @param auctionIdentifier The auction ID, from which the entire
181    *     AuctionEntry is built by loading data from the server.
182    * @param server - The auction server for this entry.
183    */
AuctionEntry(String auctionIdentifier, AuctionServerInterface server)184   private AuctionEntry(String auctionIdentifier, AuctionServerInterface server) {
185     mServer = server;
186     checkConfigurationSnipeTime();
187     prepareAuctionEntry(auctionIdentifier);
188   }
189 
190   /**
191    * A constructor that does almost nothing.  This is to be used
192    * for loading from XML data later on, where the fromXML function
193    * will fill out all the internal information.  Similarly, ActiveRecord
194    * fills this out when pulling from a database record.
195    * <p/>
196    * Uses the default server.
197    */
AuctionEntry()198   public AuctionEntry() {
199     checkConfigurationSnipeTime();
200     notifyObservers(ObserverMode.AFTER_CREATE);
201   }
202 
hasAuction()203   public boolean hasAuction() {
204     AuctionInfo ai = findByIdOrIdentifier(getAuctionId(), getIdentifier());
205     return (ai != null);
206   }
207 
208   public enum ObserverMode { AFTER_CREATE, AFTER_SAVE }
209   private static List<Observer<AuctionEntry>> allObservers = new ArrayList<Observer<AuctionEntry>>();
210 
notifyObservers(ObserverMode event)211   private void notifyObservers(ObserverMode event) {
212     for(Observer<AuctionEntry> toNotify : allObservers) {
213       switch (event) {
214         case AFTER_CREATE: {
215           toNotify.afterCreate(this);
216           break;
217         }
218         case AFTER_SAVE: {
219           toNotify.afterSave(this);
220         }
221       }
222     }
223   }
224 
addObserver(Observer<AuctionEntry> observer)225   public static void addObserver(Observer<AuctionEntry> observer) {
226     allObservers.add(observer);
227   }
228 
229   /**
230    * Create a new auction entry for the ID passed in.  If it is in the deleted list, or already exists in
231    * the database, it will return null.
232    *
233    * @param identifier - The auction identifier to create an auction for.
234    *
235    * @return - null if the auction is in the deleted entry table, or the existing auction
236    * entry table, otherwise returns a valid AuctionEntry for the auction identifier provided.
237    */
construct(String identifier, AuctionServerInterface server)238   static AuctionEntry construct(String identifier, AuctionServerInterface server) {
239     if (!DeletedEntry.exists(identifier) && findByIdentifier(identifier) == null) {
240       AuctionEntry ae = new AuctionEntry(identifier, server);
241       if(ae.isLoaded()) {
242         String id = ae.saveDB();
243         if (id != null) {
244           JConfig.increment("stats.auctions");
245           return ae;
246         }
247       }
248     }
249     return null;
250   }
251 
construct(AuctionServerInterface server)252   static AuctionEntry construct(AuctionServerInterface server) {
253     AuctionEntry ae = new AuctionEntry();
254     ae.setServer(server);
255     return ae;
256   }
257 
258   /**
259    * @brief Look up to see if the auction is ended yet, just sets
260    * mComplete if it is.
261    */
checkEnded()262   private void checkEnded() {
263     if(!isComplete()) {
264       Date serverTime = new Date(System.currentTimeMillis() +
265                                  getServer().getServerTimeDelta());
266 
267       //  If we're past the end time, update once, and never again.
268       if(serverTime.after(getEndDate())) {
269         setComplete(true);
270         setNeedsUpdate();
271       }
272     }
273   }
274 
275   /////////////
276   //  Accessors
277 
278   /**
279    * @brief Return the server associated with this entry.
280    *
281    * @return The server that this auction entry is associated with.
282    */
getServer()283   public AuctionServerInterface getServer() {
284     return(mServer);
285   }
286 
287   /**
288    * @brief Set the auction server for this entry.
289    *
290    * First, if there are any snipes in the 'old' server, cancel them.
291    * Then set the server to the passed in value.
292    * Then re-set up any snipes associated with the listing.
293    *
294    * @param newServer - The server to associate with this auction entry.
295    */
setServer(AuctionServerInterface newServer)296   public void setServer(AuctionServerInterface newServer) {
297     //noinspection ObjectEquality
298     if(newServer != mServer) {
299       //  "CANCEL_SNIPE #{id}"
300       if(isSniped()) getServer().cancelSnipe(getIdentifier());
301       mServer = newServer;
302       if(isSniped()) getServer().setSnipe(getIdentifier());
303     }
304   }
305 
306   /**
307    * @brief Query whether this entry has ever been loaded from the server.
308    *
309    * Really shouldn't be necessary, but is.  If we try to create an
310    * AuctionEntry with a bad identifier, that doesn't match any
311    * server, or isn't 'live' on the auction server, we need an error
312    * of this sort, to identify that the load failed.  This is mainly
313    * because constructors don't fail.
314    *
315    * @return Whether this entry has ever been loaded from the server.
316    */
isLoaded()317   private boolean isLoaded()    { return(mLoaded); }
318 
319   /**
320    * @brief Check if the current snipe value would be a valid bid currently.
321    *
322    * @return true if the current snipe is at least one minimum bid
323    * increment over the current high bid.  Returns false otherwise.
324    */
isSnipeValid()325   public boolean isSnipeValid() {
326     if(getSnipe() == null) return false;
327 
328     Currency minIncrement = getServer().getMinimumBidIncrement(getCurBid(), getNumBidders());
329     boolean rval = false;
330 
331     try {
332       if(getSnipe().getAmount().getValue() >= getCurBid().add(minIncrement).getValue()) {
333         rval = true;
334       }
335     } catch(Currency.CurrencyTypeException cte) {
336       JConfig.log().handleException("This should never happen (" + getCurBid() + ", " + minIncrement + ", " + getSnipe().getAmount() + ")!", cte);
337     }
338 
339     return rval;
340   }
341 
342   /**
343    * @brief Check if the user has an outstanding snipe on this auction.
344    *
345    * @return Whether there is a snipe waiting on this auction.
346    */
isSniped()347   public boolean isSniped() {
348     return getSnipe() != null;
349   }
350 
351   /**
352    * @brief Check if the user has ever placed a bid (or completed
353    * snipe) on this auction.
354    *
355    * @return Whether the user has ever actually submitted a bid to the
356    * server for this auction.
357    */
isBidOn()358   public boolean isBidOn() { return(getBid() != null && !getBid().isNull()); }
359 
360   /**
361    * @brief Check if the current user is the high bidder on this
362    * auction.
363    *
364    * This should eventually handle multiple users per server, so that
365    * users can have multiple identities per auction site.
366    *
367    * @return Whether the current user is the high bidder.
368    */
isHighBidder()369   public boolean isHighBidder() { return isWinning(); }
370 
isWinning()371   public boolean isWinning() { return getBoolean("winning", false); }
setWinning(boolean state)372   public void setWinning(boolean state) { setBoolean("winning", state); }
373 
374   /**
375    * @brief Check if the current user is the seller for this auction.
376    *
377    * This should eventually handle multiple users per server, so that
378    * users can have multiple identities per auction site.
379    * FUTURE FEATURE -- mrs: 02-January-2003 01:25
380    *
381    * @return Whether the current user is the seller.
382    */
isSeller()383   public boolean isSeller() { return getServer().isCurrentUser(getSellerName()); }
384 
385   /**
386    * @brief What was the highest amount actually submitted to the
387    * server as a bid?
388    *
389    * With some auction servers, it might be possible to find out how
390    * much the user bid, but in general presume this value is only set
391    * by bidding through this program, or firing a snipe.
392    *
393    * @return The highest amount bid through this program.
394    */
getBid()395   public Currency getBid()  { return getMonetary("last_bid_amount"); }
396 
397   /**
398    * @brief Set the highest amount actually submitted to the server as a bid.
399    * What is the maximum amount the user bid on the last time they bid?
400    *
401    * @param highBid - The new high bid value to set for this auction.
402    */
setBid(Currency highBid)403   public void setBid(Currency highBid)  {
404     setMonetary("last_bid_amount", highBid == null ? Currency.NoValue() : highBid);
405     saveDB();
406   }
407 
setBidQuantity(int quant)408   public void setBidQuantity(int quant) {
409     setInteger("last_bid_quantity", quant);
410     saveDB();
411   }
412 
413   /**
414    * @brief What was the most recent number of items actually
415    * submitted to the server as part of a bid?
416    * How many items were bid on the last time the user bid?
417    *
418    * @return The count of items bid on the last time a user bid.
419    */
getBidQuantity()420   public int getBidQuantity() {
421     if(isBidOn()) {
422       Integer i = getInteger("last_bid_quantity");
423       return i != null ? i : 1;
424     }
425     return 0;
426   }
427 
428   /**
429    * @brief Get the default snipe time as configured.
430    *
431    * @return - The default snipe time from the configuration.  If it's
432    * not set, return a standard 30 seconds.
433    */
getGlobalSnipeTime()434   private static long getGlobalSnipeTime() {
435     long snipeTime;
436 
437     String strConfigSnipeAt = JConfig.queryConfiguration("snipemilliseconds");
438     if(strConfigSnipeAt != null) {
439       snipeTime = Long.parseLong(strConfigSnipeAt);
440     } else {
441       snipeTime = Constants.THIRTY_SECONDS;
442     }
443 
444     return snipeTime;
445   }
446 
447   /**
448    * @brief Check if the configuration has a 'snipemilliseconds'
449    * entry, and update the default if it does.
450    */
checkConfigurationSnipeTime()451   private static void checkConfigurationSnipeTime() {
452     sDefaultSnipeAt = getGlobalSnipeTime();
453   }
454 
455   /**
456    * @brief Set how long before auctions are complete to fire snipes
457    * for any auction using the default snipe timer.
458    *
459    * @param newSnipeAt - The number of milliseconds prior to the end
460    * of auctions that the snipe timer will fire.  Can be overridden by
461    * setSnipeTime() on a per-auction basis.
462    */
setDefaultSnipeTime(long newSnipeAt)463   public static void setDefaultSnipeTime(long newSnipeAt) {
464     sDefaultSnipeAt = newSnipeAt;
465   }
466 
getSnipeTime()467   public long getSnipeTime() {
468     return hasDefaultSnipeTime()? sDefaultSnipeAt : mSnipeAt;
469   }
470 
hasDefaultSnipeTime()471   public boolean hasDefaultSnipeTime() {
472     return(mSnipeAt == -1);
473   }
474 
setSnipeTime(long newSnipeTime)475   public void setSnipeTime(long newSnipeTime) {
476     mSnipeAt = newSnipeTime;
477   }
478 
479   /**
480    * @brief Get the time when this entry will no longer be considered
481    * 'newly added', or null if it's been cleared, or is already past.
482    *
483    * @return The time at which this entry is no longer new.
484    */
isJustAdded()485   public boolean isJustAdded() {
486     Date d = getDate("created_at");
487     return (d != null) && (d.getTime() > (System.currentTimeMillis() - (Constants.ONE_MINUTE * 5)));
488   }
489 
490   ///////////////////////////
491   //  Actual logic functions
492 
updateHighBid()493   public void updateHighBid() {
494     int numBidders = getNumBidders();
495 
496     if (numBidders > 0 || isFixed()) {
497       getServer().updateHighBid(getIdentifier());
498     }
499   }
500 
501   /**
502    * @brief On update, we check if we're the high bidder.
503    *
504    * When you change user ID's, you should force a complete update, so
505    * this is synchronized correctly.
506    */
checkHighBidder()507   private void checkHighBidder() {
508     int numBidders = getNumBidders();
509 
510     if(numBidders > 0) {
511       if(isBidOn() && isPrivate()) {
512         Currency curBid = getCurBid();
513         try {
514           if(curBid.less(getBid())) setWinning(true);
515         } catch(Currency.CurrencyTypeException cte) {
516           /* Should never happen...?  */
517           JConfig.log().handleException("This should never happen (bad Currency at this point!).", cte);
518         }
519         if(curBid.equals(getBid())) {
520           setWinning(numBidders == 1);
521           //  winning == false means that there are multiple bidders, and the price that
522           //  two (this user, and one other) bid are exactly the same.  How
523           //  do we know who's first, given that it's a private auction?
524           //
525           //  The only answer I have is to presume that we're NOT first.
526           //  eBay knows the 'true' answer, but how to extract it from them...
527         }
528       } else {
529         setWinning(getServer().isCurrentUser(getHighBidder()));
530       }
531     }
532   }
533 
534   ////////////////////////////
535   //  Periodic logic functions
536 
537   /**
538    * @brief Mark this entry as being not-invalid.
539    */
clearInvalid()540   public void clearInvalid() {
541     setBoolean("invalid", false);
542     saveDB();
543   }
544 
545   /**
546    * @brief Mark this entry as being invalid for some reason.
547    */
setInvalid()548   public void setInvalid() {
549     setBoolean("invalid", true);
550     saveDB();
551   }
552 
553   /**
554    * @brief Is this entry invalid for any reason?
555    *
556    * Is the data reasonably synchronized with the server?  (When the
557    * site stops providing the data, or an error occurs when retrieving
558    * this auction, this will be true.)
559    *
560    * @return - True if this auction is considered invalid, false if it's okay.
561    */
isInvalid()562   public boolean isInvalid() {
563     return getBoolean("invalid", false);
564   }
565 
566   /**
567    * @brief Store a user-specified comment about this item.
568    * Allow the user to add a personal comment about this auction.
569    *
570    * @param newComment - The comment to keep track of.  If it's empty,
571    * we effectively delete the comment.
572    */
setComment(String newComment)573   public void setComment(String newComment) {
574     if(newComment.trim().length() == 0)
575       setString("comment", null);
576     else
577       setString("comment", newComment.trim());
578     saveDB();
579   }
580 
581   /**
582    * @brief Get any user-specified comment regarding this auction.
583    *
584    * @return Any comment the user may have stored about this item.
585    */
getComment()586   public String getComment() {
587     return getString("comment");
588   }
589 
590   /**
591    * @brief Add an auction-specific status message into its own event log.
592    *
593    * @param inStatus - A string that explains what the event is.
594    */
setLastStatus(String inStatus)595   public void setLastStatus(String inStatus) {
596     getEvents().setLastStatus(inStatus);
597   }
598 
setShipping(Currency newShipping)599   public void setShipping(Currency newShipping) {
600     setMonetary("shipping", newShipping);
601     saveDB();
602   }
603 
604   /**
605    * @brief Get a plain version of the event list, where each line is
606    * a seperate event, including the title and identifier.
607    *
608    * @return A string with all the event information included.
609    */
getLastStatus()610   public String getLastStatus() { return getEvents().getLastStatus(); }
611 
612   /**
613    * @brief Get either a plain version of the events, or a complex
614    * (bulk) version which doesn't include the title and identifier,
615    * since those are set by the AuctionEntry itself, and are based
616    * on its own data.
617    *
618    * @return A string with all the event information included.
619    */
getStatusHistory()620   public String getStatusHistory() {
621     return getEvents().getAllStatuses();
622   }
623 
getStatusCount()624   public int getStatusCount() {
625     return getEvents().getStatusCount();
626   }
627 
getEvents()628   private EventLogger getEvents() {
629     if(mEntryEvents == null) mEntryEvents = new EventLogger(getIdentifier(), getId(), getTitle());
630     return mEntryEvents;
631   }
632 
633   //////////////////////////
634   //  XML Handling functions
635   protected final String[] infoTags = { "info", "bid", "snipe", "complete", "invalid", "comment", "log", "multisnipe", "shipping", "category", "winning" };
636   @SuppressWarnings({"ReturnOfCollectionOrArrayField"})
getTags()637   protected String[] getTags() { return infoTags; }
638 
639   /**
640    * @brief XML load-handling.  It would be really nice to be able to
641    * abstract this for all the classes that serialize to XML.
642    *
643    * @param tagId - The index into 'entryTags' for the current tag.
644    * @param curElement - The current XML element that we're loading from.
645    */
646   @SuppressWarnings({"FeatureEnvy"})
handleTag(int tagId, XMLElement curElement)647   protected void handleTag(int tagId, XMLElement curElement) {
648     switch(tagId) {
649       case 0:  //  Get the general auction information
650         //  TODO -- What if it's already in the database?
651         break;
652       case 1:  //  Get bid info
653         Currency bidAmount = Currency.getCurrency(curElement.getProperty("CURRENCY"),
654                                           curElement.getProperty("PRICE"));
655         setBid(bidAmount);
656         setBidQuantity(Integer.parseInt(curElement.getProperty("QUANTITY")));
657         if(curElement.getProperty("WHEN", null) != null) {
658           mBidAt = Long.parseLong(curElement.getProperty("WHEN"));
659         }
660         break;
661       case 2:  //  Get the snipe info together
662         Currency snipeAmount = Currency.getCurrency(curElement.getProperty("CURRENCY"),
663                                             curElement.getProperty("PRICE"));
664         prepareSnipe(snipeAmount, Integer.parseInt(curElement.getProperty("QUANTITY")));
665         mSnipeAt = Long.parseLong(curElement.getProperty("SECONDSPRIOR"));
666         break;
667       case 3:
668         setComplete(true);
669         break;
670       case 4:
671         setInvalid();
672         break;
673       case 5:
674         setComment(curElement.getContents());
675         break;
676       case 6:
677         mEntryEvents = new EventLogger(getIdentifier(), getId(), getTitle());
678         mEntryEvents.fromXML(curElement);
679         break;
680       case 7:
681         MQFactory.getConcrete("multisnipe_xml").enqueue(getIdentifier() + " " + curElement.toString());
682         break;
683       case 8:
684         Currency shipping = Currency.getCurrency(curElement.getProperty("CURRENCY"),
685                                          curElement.getProperty("PRICE"));
686         setShipping(shipping);
687         break;
688       case 9:
689         setCategory(curElement.getContents());
690         setSticky(curElement.getProperty("STICKY", "false").equals("true"));
691         break;
692       case 10:
693         setWinning(true);
694         break;
695       default:
696         break;
697         // commented out for FORWARDS compatibility.
698         //        throw new RuntimeException("Unexpected value when handling AuctionEntry tags!");
699     }
700   }
701 
toXML()702   public XMLElement toXML() {
703     return toXML(true);
704   }
705 
706   /**
707    * @brief Check everything and build an XML element that contains as
708    * children all of the values that need storing for this item.
709    *
710    * This would be so much more useful if it were 'standard'.
711    *
712    * @return An XMLElement containing as children, all of the key
713    * values associated with this auction entry.
714    */
715   @SuppressWarnings({"FeatureEnvy"})
toXML(boolean includeEvents)716   public XMLElement toXML(boolean includeEvents) {
717     XMLElement xmlResult = new XMLElement("auction");
718 
719     xmlResult.setProperty("id", getIdentifier());
720     AuctionInfo ai = findByIdOrIdentifier(getAuctionId(), getIdentifier());
721     xmlResult.addChild(ai.toXML());
722 
723     if(isBidOn()) {
724       XMLElement xbid = new XMLElement("bid");
725       xbid.setEmpty();
726       xbid.setProperty("quantity", Integer.toString(getBidQuantity()));
727       xbid.setProperty("currency", getBid().fullCurrencyName());
728       xbid.setProperty("price", Double.toString(getBid().getValue()));
729       if(mBidAt != 0) {
730         xbid.setProperty("when", Long.toString(mBidAt));
731       }
732       xmlResult.addChild(xbid);
733     }
734 
735     if(isSniped()) {
736       XMLElement xsnipe = new XMLElement("snipe");
737       xsnipe.setEmpty();
738       xsnipe.setProperty("quantity", Integer.toString(getSnipe().getQuantity()));
739       xsnipe.setProperty("currency", getSnipe().getAmount().fullCurrencyName());
740       xsnipe.setProperty("price", Double.toString(getSnipe().getAmount().getValue()));
741       xsnipe.setProperty("secondsprior", Long.toString(mSnipeAt));
742       xmlResult.addChild(xsnipe);
743     }
744 
745 //    if(isMultiSniped()) xmlResult.addChild(getMultiSnipe().toXML());
746 
747     if(isComplete()) addStatusXML(xmlResult, "complete");
748     if(isInvalid()) addStatusXML(xmlResult, "invalid");
749     if(isDeleted()) addStatusXML(xmlResult, "deleted");
750     if(isWinning()) addStatusXML(xmlResult, "winning");
751 
752     if(getComment() != null) {
753       XMLElement xcomment = new XMLElement("comment");
754       xcomment.setContents(getComment());
755       xmlResult.addChild(xcomment);
756     }
757 
758     if(getCategory() != null) {
759       XMLElement xcategory = new XMLElement("category");
760       xcategory.setContents(getCategory());
761       xcategory.setProperty("sticky", isSticky() ?"true":"false");
762       xmlResult.addChild(xcategory);
763     }
764 
765     if(getShipping() != null) {
766       XMLElement xshipping = new XMLElement("shipping");
767       xshipping.setEmpty();
768       xshipping.setProperty("currency", getShipping().fullCurrencyName());
769       xshipping.setProperty("price", Double.toString(getShipping().getValue()));
770       xmlResult.addChild(xshipping);
771     }
772 
773     if(includeEvents && mEntryEvents != null) {
774       XMLElement xlog = mEntryEvents.toXML();
775       if (xlog != null) {
776         xmlResult.addChild(xlog);
777       }
778     }
779     return xmlResult;
780   }
781 
782   /**
783    * @brief Load auction entries from an XML element.
784    *
785    * @param inXML - The XMLElement that contains the items to load.
786    */
fromXML(XMLInterface inXML)787   public void fromXML(XMLInterface inXML) {
788     String inID = inXML.getProperty("ID", null);
789     if(inID != null) {
790       set("identifier", inID);
791 
792       super.fromXML(inXML);
793 
794       mLoaded = false;
795 
796       if(!isComplete()) setNeedsUpdate();
797 
798       saveDB();
799       if(mEntryEvents == null) {
800         getEvents();
801       }
802       checkHighBidder();
803       saveDB();
804     }
805   }
806 
807   /////////////////////
808   //  Sniping functions
809 
810   /**
811    * @brief Return whether this entry ever had a snipe cancelled or not.
812    *
813    * @return - true if a snipe was cancelled, false otherwise.
814    */
snipeCancelled()815   public boolean snipeCancelled() { return mCancelSnipeBid != null; }
816 
817   /**
818    * @brief Return the amount that the snipe bid was for, before it
819    * was cancelled.
820    *
821    * @return - A currency amount that was set to snipe, but cancelled.
822    */
getCancelledSnipe()823   public Currency getCancelledSnipe() { return mCancelSnipeBid; }
824 
825   /**
826    * Cancel the snipe and clear the multisnipe setting.  This is used for
827    * user-driven snipe cancellations, and errors like the listing going away.
828    *
829    * @param after_end - Is this auction already completed?
830    */
cancelSnipe(boolean after_end)831   public void cancelSnipe(boolean after_end) {
832     handleCancel(after_end);
833 
834     //  If the multisnipe was null, remove the snipe entirely.
835     prepareSnipe(Currency.NoValue(), 0);
836     setInteger("multisnipe_id", null);
837     saveDB();
838   }
839 
handleCancel(boolean after_end)840   private void handleCancel(boolean after_end) {
841     if(isSniped()) {
842       JConfig.log().logDebug("Cancelling Snipe for: " + getTitle() + '(' + getIdentifier() + ')');
843       setLastStatus("Cancelling snipe.");
844       if(after_end) {
845         setBoolean("auto_canceled", true);
846         mCancelSnipeBid = getSnipe().getAmount();
847       }
848     }
849   }
850 
snipeCompleted()851   public void snipeCompleted() {
852     setSnipedAmount(getSnipe().getAmount());
853     setBid(getSnipe().getAmount());
854     setBidQuantity(getSnipe().getQuantity());
855     getSnipe().delete();
856     setInteger("snipe_id", null);
857     mSnipe = null;
858     setDirty();
859     setNeedsUpdate();
860     saveDB();
861   }
862 
setSnipedAmount(Currency amount)863   private void setSnipedAmount(Currency amount) {
864     setMonetary("sniped_amount", amount == null ? Currency.NoValue() : amount);
865   }
866 
867   /**
868    * In this case, the snipe failed, and we want to cancel the snipe, but we
869    * don't want to remove the listing from the multisnipe group, in case you
870    * still win it.  (For example, if you have a bid on it already.)
871    */
snipeFailed()872   public void snipeFailed() {
873     handleCancel(true);
874     setDirty();
875     setNeedsUpdate();
876     saveDB();
877   }
878 
879   /**
880    * @brief Completely update auction info from the server for this auction.
881    */
update()882   public void update() {
883     setDate("last_updated_at", new Date());
884 
885     // We REALLY don't want to leave an auction in the 'updating'
886     // state.  It does bad things.
887     try {
888       getServer().reload(getIdentifier());
889     } catch(Exception e) {
890       JConfig.log().handleException("Unexpected exception during auction reload/update.", e);
891     }
892     try {
893       updateHighBid();
894       checkHighBidder();
895     } catch(Exception e) {
896       JConfig.log().handleException("Unexpected exception during high bidder check.", e);
897     }
898 
899     if (isComplete()) {
900       onComplete();
901     } else {
902       long now = System.currentTimeMillis() + getServer().getServerTimeDelta();
903       Date serverTime = new Date(now);
904 
905       if(now > getEndDate().getTime())
906       //  If we're past the end time, update once, and never again.
907       if (serverTime.after(getEndDate())) {
908         setComplete(true);
909         setNeedsUpdate();
910       }
911     }
912     saveDB();
913   }
914 
onComplete()915   private void onComplete() {
916     boolean won = isHighBidder() && (!isReserve() || isReserveMet());
917     if (won) {
918       JConfig.increment("stats.won");
919       MQFactory.getConcrete("won").enqueue(getIdentifier());
920       // Metrics
921       if(getBoolean("was_sniped")) {
922         JConfig.getMetrics().trackEvent("snipe", "won");
923       } else {
924         JConfig.getMetrics().trackEvent("auction", "won");
925       }
926     } else {
927       MQFactory.getConcrete("notwon").enqueue(getIdentifier());
928       // Metrics
929       if (getBoolean("was_sniped")) {
930         JConfig.getMetrics().trackEvent("snipe", "lost");
931       } else {
932         if(isBidOn()) {
933           JConfig.getMetrics().trackEvent("auction", "lost");
934         }
935       }
936     }
937 
938     if (isSniped()) {
939       //  It's okay to cancel the snipe here; if the auction was won, it would be caught above.
940       setLastStatus("Cancelling snipe, auction is reported as ended.");
941       cancelSnipe(true);
942     }
943   }
944 
prepareSnipe(Currency snipe)945   public void prepareSnipe(Currency snipe) { prepareSnipe(snipe, 1); }
946 
947   /**
948    * @brief Set up the fields necessary for a future snipe.
949    *
950    * This needs to be enhanced to work with multiple items, and
951    * different snipe times.
952    *
953    * @param snipe The amount of money the user wishes to bid at the last moment.
954    * @param quantity The number of items they want to snipe for.
955    */
prepareSnipe(Currency snipe, int quantity)956   public void prepareSnipe(Currency snipe, int quantity) {
957     if(snipe == null || snipe.isNull()) {
958       if(getSnipe() != null) {
959         getSnipe().delete();
960       }
961       setInteger("snipe_id", null);
962       mSnipe = null;
963       getServer().cancelSnipe(getIdentifier());
964     } else {
965       mSnipe = AuctionSnipe.create(snipe, quantity, 0);
966       getServer().setSnipe(getIdentifier());
967     }
968     setDirty();
969     saveDB();
970     MQFactory.getConcrete("Swing").enqueue("SNIPECHANGED");
971   }
972 
973   /**
974    * @brief Refresh the snipe, so it picks up a potentially changed end time, or when initially loading items.
975    */
refreshSnipe()976   public void refreshSnipe() {
977     getServer().setSnipe(getIdentifier());
978   }
979 
980   /**
981    * @brief Bid a given price on an arbitrary number of a particular item.
982    *
983    * @param bid - The amount of money being bid.
984    * @param bidQuantity - The number of items being bid on.
985    *
986    * @return The result of the bid attempt.
987    */
bid(Currency bid, int bidQuantity)988   public int bid(Currency bid, int bidQuantity) {
989     setBid(bid);
990     setBidQuantity(bidQuantity);
991     mBidAt = System.currentTimeMillis();
992 
993     JConfig.log().logDebug("Bidding " + bid + " on " + bidQuantity + " item[s] of (" + getIdentifier() + ")-" + getTitle());
994 
995     int rval = getServer().bid(getIdentifier(), bid, bidQuantity);
996     saveDB();
997     return rval;
998   }
999 
1000   /**
1001    * @brief Buy an item directly.
1002    *
1003    * @param quant - The number of them to buy.
1004    *
1005    * @return The result of the 'Buy' attempt.
1006    */
buy(int quant)1007   public int buy(int quant) {
1008     int rval = AuctionServerInterface.BID_ERROR_NOT_BIN;
1009     Currency bin = getBuyNow();
1010     if(bin != null && !bin.isNull()) {
1011       setBid(getBuyNow());
1012       setBidQuantity(quant);
1013       mBidAt = System.currentTimeMillis();
1014       JConfig.log().logDebug("Buying " + quant + " item[s] of (" + getIdentifier() + ")-" + getTitle());
1015       rval = getServer().buy(getIdentifier(), quant);
1016       // Metrics
1017       if(rval == AuctionServerInterface.BID_BOUGHT_ITEM) {
1018         JConfig.getMetrics().trackEvent("buy", "success");
1019       } else {
1020         JConfig.getMetrics().trackEventValue("buy", "fail", Integer.toString(rval));
1021       }
1022       saveDB();
1023     }
1024     return rval;
1025   }
1026 
1027   /**
1028    * @brief This auction entry needs to be updated.
1029    */
setNeedsUpdate()1030   public void setNeedsUpdate() { setDate("last_updated_at", null); saveDB(); }
1031 
getLastUpdated()1032   public Date getLastUpdated() { return getDate("last_updated_at"); }
1033 
1034   /**
1035    * @brief Get the category this belongs in, usually used for tab names, and fitting in search results.
1036    *
1037    * @return - A category, or null if none has been assigned.
1038    */
getCategory()1039   public String getCategory() {
1040     if(mCategory == null) {
1041       String category_id = get("category_id");
1042       if(category_id != null) {
1043         mCategory = Category.findFirstBy("id", category_id);
1044       }
1045     }
1046     if(mCategory == null) {
1047       setCategory(!isComplete() ? (isSeller() ? "selling" : "current") : "complete");
1048     }
1049 
1050     return mCategory != null ? mCategory.getName() : null;
1051   }
1052 
1053   /**
1054    * @brief Set the category associated with the auction entry.  If the
1055    * auction is ended, this is automatically considered sticky.
1056    *
1057    * @param newCategory - The new category to associate this item with.
1058    */
setCategory(String newCategory)1059   public void setCategory(String newCategory) {
1060     Category c = Category.findFirstByName(newCategory);
1061     if(c == null) {
1062       c = Category.findOrCreateByName(newCategory);
1063     }
1064     setInteger("category_id", c.getId());
1065     mCategory = c;
1066     if(isComplete()) setSticky(true);
1067     saveDB();
1068   }
1069 
1070   /**
1071    * @brief Returns whether or not this auction entry is 'sticky', i.e. sticks to any category it's set to.
1072    * Whether the 'category' information is sticky (i.e. overrides 'deleted', 'selling', etc.)
1073    *
1074    * @return true if the entry is sticky, false otherwise.
1075    */
isSticky()1076   public boolean isSticky() { return getBoolean("sticky"); }
1077 
1078   /**
1079    * @brief Set the sticky flag on or off.
1080    *
1081    * This'll probably be exposed to the user through a right-click context menu, so that people
1082    * can make auctions not move from their sorted categories when they end.
1083    *
1084    * @param beSticky - Whether or not this entry should be sticky.
1085    */
setSticky(boolean beSticky)1086   public void setSticky(boolean beSticky) {
1087     if(beSticky != getBoolean("sticky")) {
1088       setBoolean("sticky", beSticky);
1089       saveDB();
1090     }
1091   }
1092 
1093   // TODO mrs -- Move this to a TimeLeftBuilder class.
1094   public static final String endedAuction = "Auction ended.";
1095   private static final String mf_min_sec = "{6}{2,number,##}m, {7}{3,number,##}s";
1096   private static final String mf_hrs_min = "{5}{1,number,##}h, {6}{2,number,##}m";
1097   private static final String mf_day_hrs = "{4}{0,number,##}d, {5}{1,number,##}h";
1098 
1099   private static final String mf_min_sec_detailed = "{6}{2,number,##} minute{2,choice,0#, |1#, |1<s,} {7}{3,number,##} second{3,choice,0#|1#|1<s}";
1100   private static final String mf_hrs_min_detailed = "{5}{1,number,##} hour{1,choice,0#, |1#, |1<s,} {6}{2,number,##} minute{2,choice,0#|1#|1<s}";
1101   private static final String mf_day_hrs_detailed = "{4}{0,number,##} day{0,choice,0#, |1#, |1<s,}  {5}{1,number,##} hour{1,choice,0#|1#|1<s}";
1102 
1103   //0,choice,0#are no files|1#is one file|1<are {0,number,integer} files}
1104 
convertToMsgFormat(String simpleFormat)1105   private static String convertToMsgFormat(String simpleFormat) {
1106     String msgFmt = simpleFormat.replaceAll("DD", "{4}{0,number,##}");
1107     msgFmt = msgFmt.replaceAll("HH", "{5}{1,number,##}");
1108     msgFmt = msgFmt.replaceAll("MM", "{6}{2,number,##}");
1109     msgFmt = msgFmt.replaceAll("SS", "{7}{3,number,##}");
1110 
1111     return msgFmt;
1112   }
1113 
1114   /**
1115    * @brief Determine the amount of time left, and format it prettily.
1116    *
1117    * @return A nicely formatted string showing how much time is left
1118    * in this auction.
1119    */
getTimeLeft()1120   public String getTimeLeft() {
1121     long rightNow = System.currentTimeMillis();
1122     long officialDelta = getServer().getServerTimeDelta();
1123     long pageReqTime = getServer().getPageRequestTime();
1124 
1125     if(!isComplete()) {
1126       long dateDiff;
1127       try {
1128         dateDiff = getEndDate().getTime() - ((rightNow + officialDelta) - pageReqTime);
1129       } catch(Exception endDateException) {
1130         JConfig.log().handleException("Error getting the end date.", endDateException);
1131         dateDiff = 0;
1132       }
1133 
1134       if(dateDiff > Constants.ONE_DAY * 60) return "N/A";
1135 
1136       if(dateDiff >= 0) {
1137         long days = dateDiff / (Constants.ONE_DAY);
1138         dateDiff -= days * (Constants.ONE_DAY);
1139         long hours = dateDiff / (Constants.ONE_HOUR);
1140         dateDiff -= hours * (Constants.ONE_HOUR);
1141         long minutes = dateDiff / (Constants.ONE_MINUTE);
1142         dateDiff -= minutes * (Constants.ONE_MINUTE);
1143         long seconds = dateDiff / Constants.ONE_SECOND;
1144 
1145         String mf = getTimeFormatter(days, hours);
1146 
1147         Object[] timeArgs = { days,           hours,      minutes,     seconds,
1148                               pad(days), pad(hours), pad(minutes), pad(seconds) };
1149 
1150         return(MessageFormat.format(mf, timeArgs));
1151       }
1152     }
1153     return endedAuction;
1154   }
1155 
1156   @SuppressWarnings({"FeatureEnvy"})
getTimeFormatter(long days, long hours)1157   private static String getTimeFormatter(long days, long hours) {
1158     String mf;
1159     boolean use_detailed = JConfig.queryConfiguration("timeleft.detailed", "false").equals("true");
1160     String cfg;
1161     if(days == 0) {
1162       if(hours == 0) {
1163         mf = use_detailed?mf_min_sec_detailed:mf_min_sec;
1164         cfg = JConfig.queryConfiguration("timeleft.minutes");
1165         if(cfg != null) mf = convertToMsgFormat(cfg);
1166       } else {
1167         mf = use_detailed?mf_hrs_min_detailed:mf_hrs_min;
1168         cfg = JConfig.queryConfiguration("timeleft.hours");
1169         if (cfg != null) mf = convertToMsgFormat(cfg);
1170       }
1171     } else {
1172       mf = use_detailed?mf_day_hrs_detailed:mf_day_hrs;
1173       cfg = JConfig.queryConfiguration("timeleft.days");
1174       if (cfg != null) mf = convertToMsgFormat(cfg);
1175     }
1176     return mf;
1177   }
1178 
pad(long x)1179   private static String pad(long x) {
1180     return (x < 10) ? " " : "";
1181   }
1182 
1183   @Override
hashCode()1184   public int hashCode() {
1185     return getIdentifier().hashCode() ^ getEndDate().hashCode();
1186   }
1187 
1188   /**
1189    * @brief Do a 'standard' compare to another AuctionEntry object.
1190    *
1191    * The standard ordering is as follows:
1192    *    (if identifiers or pointers are equal, entries are equal)
1193    *    If this end date is after the passed in one, we are greater.
1194    *    If this end date is before, we are lesser.
1195    *    Otherwise (EXACTLY equal dates!), order by identifier.
1196    *
1197    * @param other - The AuctionEntry to compare to.
1198    *
1199    * @return - -1 for lesser, 0 for equal, 1 for greater.
1200    */
compareTo(AuctionEntry other)1201   public int compareTo(AuctionEntry other) {
1202     //  We are always greater than null
1203     if(other == null) return 1;
1204     //  We are always equal to ourselves
1205     //noinspection ObjectEquality
1206     if(other == this) return 0;
1207 
1208     String identifier = getIdentifier();
1209 
1210     //  If the identifiers are the same, we're equal.
1211     if(identifier != null && identifier.equals(other.getIdentifier())) return 0;
1212 
1213     final Date myEndDate = getEndDate();
1214     final Date otherEndDate = other.getEndDate();
1215     if(myEndDate == null && otherEndDate != null) return 1;
1216     if(myEndDate != null) {
1217       if(otherEndDate == null) return -1;
1218 
1219       //  If this ends later than the passed in object, then we are 'greater'.
1220       if(myEndDate.after(otherEndDate)) return 1;
1221       if(otherEndDate.after(myEndDate)) return -1;
1222     }
1223 
1224     //  Whoops!  Dates are equal, down to the second probably, or both null...
1225 
1226     //  If this has a null identifier, we're lower.
1227     if(identifier == null && other.getIdentifier() != null) return -1;
1228     if(identifier == null && other.getIdentifier() == null) return 0;
1229     //  At this point, we know identifier != null, so if the compared entry
1230     //  has a null identifier, we sort higher.
1231     if(other.getIdentifier() == null) return 1;
1232 
1233     //  Since this ends exactly at the same time as another auction,
1234     //  check the identifiers (which *must* be different here.
1235     return getIdentifier().compareTo(other.getIdentifier());
1236   }
1237 
1238   /**
1239    * @brief Return a value that indicates the status via bitflags, so that sorted groups by status will show up grouped together.
1240    *
1241    * @return - An integer containing a bitfield of relevant status bits.
1242    */
getFlags()1243   public int getFlags() {
1244     int r_flags = 1;
1245 
1246     if (isFixed()) r_flags = 0;
1247     if (getHighBidder() != null) {
1248       if (isHighBidder()) {
1249         r_flags = 2;
1250       } else if (isSeller() && getNumBidders() > 0 &&
1251                  (!isReserve() || isReserveMet())) {
1252         r_flags = 4;
1253       }
1254     }
1255     if (!getBuyNow().isNull()) {
1256       r_flags += 8;
1257     }
1258     if (isReserve()) {
1259       if (isReserveMet()) {
1260         r_flags += 16;
1261       } else {
1262         r_flags += 32;
1263       }
1264     }
1265     if(hasPaypal()) r_flags += 64;
1266     return r_flags;
1267   }
1268 
1269   private static AuctionInfo sAuction = new AuctionInfo();
1270   @SuppressWarnings({"ObjectEquality"})
isNullAuction()1271   public boolean isNullAuction() { return get("auction_id") == null; }
1272   private boolean deleting = false;
getAuction()1273   public AuctionInfo getAuction() {
1274     String identifier = getString("identifier");
1275     String auctionId = getString("auction_id");
1276 
1277     AuctionInfo info = findByIdOrIdentifier(auctionId, identifier);
1278 
1279     if(info == null) {
1280       if(!deleting) {
1281         deleting = true;
1282         this.delete();
1283       }
1284       return sAuction;
1285     }
1286 
1287     boolean dirty = false;
1288     if (!getDefaultCurrency().equals(info.getDefaultCurrency())) {
1289       setDefaultCurrency(info.getDefaultCurrency());
1290       dirty = true;
1291     }
1292 
1293     if (getString("identifier") == null) {
1294       setString("identifier", info.getIdentifier());
1295       dirty = true;
1296     }
1297 
1298     if (auctionId == null || !auctionId.equals(info.get("id"))) {
1299       setInteger("auction_id", info.getId());
1300       dirty = true;
1301     }
1302     if (dirty) {
1303       saveDB();
1304     }
1305 
1306     return info;
1307   }
1308 
loadSecondary()1309   protected void loadSecondary() {
1310     AuctionInfo ai = findByIdOrIdentifier(getAuctionId(), getIdentifier());
1311     if(ai != null) setAuctionInfo(ai);
1312   }
1313 
1314   /**
1315    * @brief Force this auction to use a particular set of auction
1316    * information for it's core data (like seller's name, current high
1317    * bid, etc.).
1318    *
1319    * @param inAI - The AuctionInfo object to make the new core data.  Must not be null.
1320    */
setAuctionInfo(AuctionInfo inAI)1321   public void setAuctionInfo(AuctionInfo inAI) {
1322     if (inAI.getId() != null) {
1323       setSecondary(inAI.getBacking());
1324 
1325       setDefaultCurrency(inAI.getDefaultCurrency());
1326       setInteger("auction_id", inAI.getId());
1327       setString("identifier", inAI.getIdentifier()); //?
1328 
1329       checkHighBidder();
1330       checkEnded();
1331       saveDB();
1332     }
1333   }
1334 
1335   ////////////////////////////////////////
1336   //  Passthrough functions to AuctionInfo
1337 
1338   /**
1339    * Check current price, and fall back to buy-now price if 'current' isn't set.
1340    *
1341    * @return - The current price, or the buy now if current isn't set.
1342    */
getCurrentPrice()1343   public Currency getCurrentPrice() {
1344     Currency curPrice = getCurBid();
1345     if (curPrice == null || curPrice.isNull()) return getBuyNow();
1346     return curPrice;
1347   }
1348 
getCurrentUSPrice()1349   public Currency getCurrentUSPrice() {
1350     Currency curPrice = getCurBid();
1351     if (curPrice == null || curPrice.isNull()) return getBuyNowUS();
1352     return getUSCurBid();
1353   }
1354 
1355   /**
1356    * @return - Shipping amount, overrides AuctionInfo shipping amount if present.
1357    */
getSellerName()1358   public String getSellerName() { return getAuction().getSellerName(); }
1359 
getStartDate()1360   public Date getStartDate() {
1361     Date start = super.getStartDate();
1362     if(start != null) {
1363       return start;
1364     }
1365 
1366     return Constants.LONG_AGO;
1367   }
1368 
getSnipeDate()1369   public Date getSnipeDate() { return new Date(getEndDate().getTime() - getSnipeTime()); }
1370 
getBrowseableURL()1371   public String getBrowseableURL() { return getServer().getBrowsableURLFromItem(getIdentifier()); }
1372 
setErrorPage(StringBuffer page)1373   public void setErrorPage(StringBuffer page) { mLastErrorPage = page; }
getErrorPage()1374   public StringBuffer getErrorPage() { return mLastErrorPage; }
1375 
getShippingWithInsurance()1376   public Currency getShippingWithInsurance() {
1377     Currency ship = getShipping();
1378     if(ship == null || ship.isNull())
1379       return Currency.NoValue();
1380     else {
1381       ship = addInsurance(ship);
1382     }
1383     return ship;
1384   }
1385 
addInsurance(Currency ship)1386   private Currency addInsurance(Currency ship) {
1387     if(getInsurance() != null &&
1388        !getInsurance().isNull() &&
1389        !isInsuranceOptional()) {
1390       try {
1391         ship = ship.add(getInsurance());
1392       } catch(Currency.CurrencyTypeException cte) {
1393         JConfig.log().handleException("Insurance is somehow a different type than shipping?!?", cte);
1394       }
1395     }
1396     return ship;
1397   }
1398 
isShippingOverridden()1399   public boolean isShippingOverridden() {
1400     Currency ship = getMonetary("shipping");
1401     return ship != null && !ship.isNull();
1402   }
1403 
1404   /**
1405    * Is the auction deleted on the server?
1406    *
1407    * @return - true if the auction has been removed from the server, as opposed to deleted locally.
1408    */
isDeleted()1409   public boolean isDeleted() {
1410     return getBoolean("deleted", false);
1411   }
1412 
1413   /**
1414    * Mark the auction as having been deleted by the auction server.
1415    *
1416    * Generally items are removed by the auction server because the listing is
1417    * too old, violates some terms of service, the seller has been suspended,
1418    * or the seller removed the listing themselves.
1419    */
setDeleted()1420   public void setDeleted() {
1421     if(!isDeleted()) {
1422       setBoolean("deleted", true);
1423       clearInvalid();
1424     } else {
1425       setComplete(true);
1426     }
1427     saveDB();
1428   }
1429 
1430   /**
1431    * Mark the auction as NOT having been deleted by the auction server.
1432    *
1433    * It's possible we mistakenly saw a server-error as a 404 (or they
1434    * presented it as such), so we need to be able to clear the deleted status.
1435    */
clearDeleted()1436   public void clearDeleted() {
1437     if(isDeleted()) {
1438       setBoolean("deleted", false);
1439       saveDB();
1440     }
1441   }
1442 
getAuctionId()1443   public String getAuctionId() { return get("auction_id"); }
1444 
1445   /**
1446    * @return - Has this auction already ended?  We keep track of this, so we
1447    * don't waste time on it afterwards, even as much as creating a
1448    * Date object, and comparing.
1449    */
setComplete(boolean complete)1450   public void setComplete(boolean complete) { setBoolean("ended", complete); saveDB(); }
1451 
1452   /*************************/
1453   /* Database access stuff */
1454   /*************************/
1455 
saveDB()1456   public String saveDB() {
1457     if(isNullAuction()) return null;
1458 
1459     String auctionId = getAuctionId();
1460     if(auctionId != null) set("auction_id", auctionId);
1461 
1462     //  This just makes sure we have a default category before saving.
1463     getCategory();
1464     if(mCategory != null) {
1465       String categoryId = mCategory.saveDB();
1466       if(categoryId != null) set("category_id", categoryId);
1467     }
1468 
1469     if(getSnipe() != null) {
1470       String snipeId = getSnipe().saveDB();
1471       if(snipeId != null) set("snipe_id", snipeId);
1472     }
1473 
1474     if(mEntryEvents != null) {
1475       mEntryEvents.save();
1476     }
1477 
1478     String id = super.saveDB();
1479     set("id", id);
1480     notifyObservers(ObserverMode.AFTER_SAVE);
1481     return id;
1482   }
1483 
reload()1484   public boolean reload() {
1485     try {
1486       AuctionEntry ae = AuctionEntry.findFirstBy("id", get("id"));
1487       if (ae != null) {
1488         setBacking(ae.getBacking());
1489 
1490         AuctionInfo ai = findByIdOrIdentifier(getAuctionId(), getIdentifier());
1491         setAuctionInfo(ai);
1492 
1493         ae.getCategory();
1494         mCategory = ae.mCategory;
1495         mSnipe = ae.getSnipe();
1496         mEntryEvents = ae.getEvents();
1497         return true;
1498       }
1499     } catch (Exception e) {
1500       //  Ignored - the reload semi-silently fails.
1501       JConfig.log().logDebug("reload from the database failed for (" + getIdentifier() + ")");
1502     }
1503     return false;
1504   }
1505 
1506 //  private static Table sDB = null;
getTableName()1507   protected static String getTableName() { return "entries"; }
getDatabase()1508   protected Table getDatabase() {
1509     return getRealDatabase();
1510   }
1511 
1512   private static ThreadLocal<Table> tDB = new ThreadLocal<Table>() {
1513     protected synchronized Table initialValue() {
1514       return openDB(getTableName());
1515     }
1516   };
1517 
getRealDatabase()1518   public static Table getRealDatabase() {
1519     return tDB.get();
1520   }
1521 
findFirstBy(String key, String value)1522   public static AuctionEntry findFirstBy(String key, String value) {
1523     return (AuctionEntry) ActiveRecord.findFirstBy(AuctionEntry.class, key, value);
1524   }
1525 
1526   @SuppressWarnings({"unchecked"})
findActive()1527   public static List<AuctionEntry> findActive() {
1528     String notEndedQuery = "SELECT e.* FROM entries e JOIN auctions a ON a.id = e.auction_id WHERE (e.ended != 1 OR e.ended IS NULL) ORDER BY a.ending_at ASC";
1529     return (List<AuctionEntry>) findAllBySQL(AuctionEntry.class, notEndedQuery);
1530   }
1531 
1532   @SuppressWarnings({"unchecked"})
findEnded()1533   public static List<AuctionEntry> findEnded() {
1534     return (List<AuctionEntry>) findAllBy(AuctionEntry.class, "ended", "1");
1535   }
1536 
1537   /** Already corralled... **/
1538   @SuppressWarnings({"unchecked"})
findAllSniped()1539   public static List<AuctionEntry> findAllSniped() {
1540     return (List<AuctionEntry>) findAllBySQL(AuctionEntry.class, "SELECT * FROM " + getTableName() + " WHERE (snipe_id IS NOT NULL OR multisnipe_id IS NOT NULL)");
1541   }
1542 
1543   private static Date updateSince = new Date();
1544   private static Date endingSoon = new Date();
1545   private static Date hourAgo = new Date();
1546   private static SimpleDateFormat mDateFormat = new SimpleDateFormat(DB_DATE_FORMAT);
1547 
1548   @SuppressWarnings({"unchecked"})
findAllNeedingUpdates(long since)1549   public static List<AuctionEntry> findAllNeedingUpdates(long since) {
1550     long timeRange = System.currentTimeMillis() - since;
1551     updateSince.setTime(timeRange);
1552     return (List<AuctionEntry>) findAllByPrepared(AuctionEntry.class,
1553         "SELECT e.* FROM entries e" +
1554         "  JOIN auctions a ON a.id = e.auction_id" +
1555         "  WHERE (e.ended != 1 OR e.ended IS NULL)" +
1556         "    AND (e.last_updated_at IS NULL OR e.last_updated_at < ?)" +
1557         "  ORDER BY a.ending_at ASC", mDateFormat.format(updateSince));
1558   }
1559 
1560   @SuppressWarnings({"unchecked"})
findEndingNeedingUpdates(long since)1561   public static List<AuctionEntry> findEndingNeedingUpdates(long since) {
1562     long timeRange = System.currentTimeMillis() - since;
1563     updateSince.setTime(timeRange);
1564 
1565     //  Update more frequently in the last 25 minutes.
1566     endingSoon.setTime(System.currentTimeMillis() + 25 * Constants.ONE_MINUTE);
1567     hourAgo.setTime(System.currentTimeMillis() - Constants.ONE_HOUR);
1568 
1569     return (List<AuctionEntry>)findAllByPrepared(AuctionEntry.class,
1570         "SELECT e.* FROM entries e JOIN auctions a ON a.id = e.auction_id" +
1571         "  WHERE (e.last_updated_at IS NULL OR e.last_updated_at < ?)" +
1572         "    AND (e.ended != 1 OR e.ended IS NULL)" +
1573         "    AND a.ending_at < ? AND a.ending_at > ?" +
1574         "  ORDER BY a.ending_at ASC", mDateFormat.format(updateSince),
1575         mDateFormat.format(endingSoon), mDateFormat.format(hourAgo));
1576   }
1577 
1578   @SuppressWarnings({"unchecked"})
findAll()1579   public static List<AuctionEntry> findAll() {
1580     return (List<AuctionEntry>) findAllBySQL(AuctionEntry.class, "SELECT * FROM entries");
1581   }
1582 
count()1583   public static int count() {
1584     return count(AuctionEntry.class);
1585   }
1586 
activeCount()1587   public static int activeCount() {
1588     return getRealDatabase().countBy("(ended != 1 OR ended IS NULL)");
1589   }
1590 
completedCount()1591   public static int completedCount() {
1592     return getRealDatabase().countBy("ended = 1");
1593   }
1594 
uniqueCount()1595   public static int uniqueCount() {
1596     return getRealDatabase().countBySQL("SELECT COUNT(DISTINCT(identifier)) FROM entries WHERE identifier IS NOT NULL");
1597   }
1598 
1599   private static final String snipeFinder = "(snipe_id IS NOT NULL OR multisnipe_id IS NOT NULL) AND (entries.ended != 1 OR entries.ended IS NULL)";
1600 
snipedCount()1601   public static int snipedCount() {
1602     return getRealDatabase().countBy(snipeFinder);
1603   }
1604 
nextSniped()1605   public static AuctionEntry nextSniped() {
1606     String sql = "SELECT entries.* FROM entries, auctions WHERE " + snipeFinder +
1607         " AND (entries.auction_id = auctions.id) ORDER BY auctions.ending_at ASC";
1608     return (AuctionEntry) findFirstBySQL(AuctionEntry.class, sql);
1609   }
1610 
findByIdOrIdentifier(String id, String identifier)1611   private static AuctionInfo findByIdOrIdentifier(String id, String identifier) {
1612     AuctionInfo ai = null;
1613     if(id != null) {
1614       ai = AuctionInfo.find(id);
1615     }
1616 
1617     if (ai == null && identifier != null) {
1618       ai = AuctionInfo.findByIdentifier(identifier);
1619     }
1620     return ai;
1621   }
1622 
1623   /**
1624    * Locate an AuctionEntry by first finding an AuctionInfo with the passed
1625    * in auction identifier, and then looking for an AuctionEntry which
1626    * refers to that AuctionInfo row.
1627    *
1628    * TODO EntryCorral callers? (Probably!)
1629    *
1630    * @param identifier - The auction identifier to search for.
1631    * @return - null indicates that the auction isn't in the database yet,
1632    * otherwise an AuctionEntry will be loaded and returned.
1633    */
findByIdentifier(String identifier)1634   public static AuctionEntry findByIdentifier(String identifier) {
1635     AuctionEntry ae = findFirstBy("identifier", identifier);
1636     AuctionInfo ai;
1637 
1638     if(ae != null) {
1639       ai = findByIdOrIdentifier(ae.getAuctionId(), identifier);
1640       if(ai == null) {
1641         JConfig.log().logMessage("Error loading auction #" + identifier + ", entry found, auction missing.");
1642         ae = null;
1643       }
1644     }
1645 
1646     if(ae == null) {
1647       ai = findByIdOrIdentifier(null, identifier);
1648 
1649       if(ai != null) {
1650         ae = AuctionEntry.findFirstBy("auction_id", ai.getString("id"));
1651         if (ae != null) ae.setAuctionInfo(ai);
1652       }
1653     }
1654 
1655     return ae;
1656   }
1657 
1658   /**
1659    * TODO: Clear from the entry corral?
1660    * @param toDelete
1661    * @return
1662    */
deleteAll(List<AuctionEntry> toDelete)1663   public static boolean deleteAll(List<AuctionEntry> toDelete) {
1664     if(toDelete.isEmpty()) return true;
1665 
1666     String entries = makeCommaList(toDelete);
1667     List<Integer> auctions = new ArrayList<Integer>();
1668     List<AuctionSnipe> snipes = new ArrayList<AuctionSnipe>();
1669 
1670     for(AuctionEntry entry : toDelete) {
1671       auctions.add(entry.getInteger("auction_id"));
1672       if(entry.isSniped()) snipes.add(entry.getSnipe());
1673     }
1674 
1675     boolean success = new EventStatus().deleteAllEntries(entries);
1676     if(!snipes.isEmpty()) success &= AuctionSnipe.deleteAll(snipes);
1677     success &= AuctionInfo.deleteAll(auctions);
1678     success &= getRealDatabase().deleteBy("id IN (" + entries + ")");
1679 
1680     return success;
1681   }
1682 
delete()1683   public boolean delete() {
1684     AuctionInfo ai = findByIdOrIdentifier(getAuctionId(), getIdentifier());
1685     if(ai != null) ai.delete();
1686     if(getSnipe() != null) getSnipe().delete();
1687     return super.delete();
1688   }
1689 
getPresenter()1690   public Presenter getPresenter() {
1691     return mAuctionEntryPresenter;
1692   }
1693 
countByCategory(Category c)1694   public static int countByCategory(Category c) {
1695     if(c == null) return 0;
1696     return getRealDatabase().countBySQL("SELECT COUNT(*) FROM entries WHERE category_id=" + c.getId());
1697   }
1698 
1699   @SuppressWarnings({"unchecked"})
findAllBy(String column, String value)1700   public static List<AuctionEntry> findAllBy(String column, String value) {
1701     return (List<AuctionEntry>)ActiveRecord.findAllBy(AuctionEntry.class, column, value);
1702   }
1703 
setNumBids(int bidCount)1704   public void setNumBids(int bidCount) {
1705     AuctionInfo info = findByIdOrIdentifier(getAuctionId(), getIdentifier());
1706     info.setNumBids(bidCount);
1707     info.saveDB();
1708   }
1709 
1710   @SuppressWarnings({"unchecked"})
findManualUpdates()1711   public static List<AuctionEntry> findManualUpdates() {
1712     return (List<AuctionEntry>) findAllBySQL(AuctionEntry.class, "SELECT e.* FROM entries e JOIN auctions a ON a.id = e.auction_id WHERE e.last_updated_at IS NULL ORDER BY a.ending_at ASC");
1713   }
1714 
isUpdateRequired()1715   public boolean isUpdateRequired() {
1716     return getDate("last_updated_at") == null;
1717   }
1718 
1719   @SuppressWarnings({"unchecked"})
findRecentlyEnded(int itemCount)1720   public static List<AuctionEntry> findRecentlyEnded(int itemCount) {
1721     return (List<AuctionEntry>) findAllBySQL(AuctionEntry.class, "SELECT e.* FROM entries e JOIN auctions a ON a.id = e.auction_id WHERE e.ended = 1 ORDER BY a.ending_at DESC", itemCount);
1722   }
1723 
1724   @SuppressWarnings({"unchecked"})
findEndingSoon(int itemCount)1725   public static List<AuctionEntry> findEndingSoon(int itemCount) {
1726     return (List<AuctionEntry>) findAllBySQL(AuctionEntry.class, "SELECT e.* FROM entries e JOIN auctions a ON a.id = e.auction_id WHERE (e.ended != 1 OR e.ended IS NULL) ORDER BY a.ending_at ASC", itemCount);
1727   }
1728 
1729   @SuppressWarnings({"unchecked"})
findBidOrSniped(int itemCount)1730   public static List<AuctionEntry> findBidOrSniped(int itemCount) {
1731     return (List<AuctionEntry>) findAllBySQL(AuctionEntry.class, "SELECT e.* FROM entries e JOIN auctions a ON a.id = e.auction_id WHERE (e.snipe_id IS NOT NULL OR e.multisnipe_id IS NOT NULL OR e.bid_amount IS NOT NULL) ORDER BY a.ending_at ASC", itemCount);
1732   }
1733 
forceUpdateActive()1734   public static void forceUpdateActive() {
1735     getRealDatabase().execute("UPDATE entries SET last_updated_at=NULL WHERE ended != 1 OR ended IS NULL");
1736   }
1737 
trueUpEntries()1738   public static void trueUpEntries() {
1739     getRealDatabase().execute("UPDATE entries SET auction_id=(SELECT max(id) FROM auctions WHERE auctions.identifier=entries.identifier)");
1740     getRealDatabase().execute("DELETE FROM entries e WHERE id != (SELECT max(id) FROM entries e2 WHERE e2.auction_id = e.auction_id)");
1741   }
1742 
getUnique()1743   public String getUnique() {
1744     return getIdentifier();
1745   }
1746 
setPresenter(Presenter presenter)1747   public void setPresenter(Presenter presenter) {
1748     mAuctionEntryPresenter = presenter;
1749   }
1750 }
1751