001    /*
002    Copyright (c) 1996-2012, Damon Hart-Davis
003    All rights reserved.
004    
005    Redistribution and use in source and binary forms, with or without
006    modification, are permitted provided that the following conditions are
007    met:
008    
009      * Redistributions of source code must retain the above copyright
010        notice, this list of conditions and the following disclaimer.
011    
012      * Redistributions in binary form must reproduce the above copyright
013        notice, this list of conditions and the following disclaimer in the
014        documentation and/or other materials provided with the
015        distribution.
016    
017    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
018    IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
019    TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
020    PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
021    OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
022    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
023    LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
024    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
025    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
026    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
027    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
028    */
029    
030    package org.hd.d.pg2k.webSvr.util;
031    
032    import java.io.IOException;
033    import java.text.DateFormat;
034    import java.util.ArrayList;
035    import java.util.Arrays;
036    import java.util.BitSet;
037    import java.util.Collections;
038    import java.util.Comparator;
039    import java.util.Date;
040    import java.util.Iterator;
041    import java.util.List;
042    import java.util.Random;
043    import java.util.RandomAccess;
044    import java.util.Set;
045    import java.util.SortedMap;
046    import java.util.SortedSet;
047    import java.util.TreeMap;
048    import java.util.concurrent.locks.ReentrantLock;
049    
050    import javax.servlet.ServletContext;
051    import javax.servlet.http.HttpServletRequest;
052    
053    import org.hd.d.pg2k.ai.scorer.ScorerCacheIF;
054    import org.hd.d.pg2k.svrCore.AllExhibitProperties;
055    import org.hd.d.pg2k.svrCore.CS8Bit;
056    import org.hd.d.pg2k.svrCore.Compact7BitString;
057    import org.hd.d.pg2k.svrCore.CoreConsts;
058    import org.hd.d.pg2k.svrCore.ExhibitAttrUtils;
059    import org.hd.d.pg2k.svrCore.ExhibitName;
060    import org.hd.d.pg2k.svrCore.ExhibitPropsComputableMutable;
061    import org.hd.d.pg2k.svrCore.ExhibitStaticAttr;
062    import org.hd.d.pg2k.svrCore.ExhibitThumbnails;
063    import org.hd.d.pg2k.svrCore.GenUtils;
064    import org.hd.d.pg2k.svrCore.LocaleBeanBase;
065    import org.hd.d.pg2k.svrCore.MemoryTools;
066    import org.hd.d.pg2k.svrCore.Name;
067    import org.hd.d.pg2k.svrCore.Name.ExhibitFull;
068    import org.hd.d.pg2k.svrCore.Rnd;
069    import org.hd.d.pg2k.svrCore.TextUtils;
070    import org.hd.d.pg2k.svrCore.ThreadUtils;
071    import org.hd.d.pg2k.svrCore.Tuple;
072    import org.hd.d.pg2k.svrCore.MIME.ExhibitMIME;
073    import org.hd.d.pg2k.svrCore.collections.SimpleLRUMap;
074    import org.hd.d.pg2k.svrCore.props.GenProps;
075    import org.hd.d.pg2k.svrCore.uploader.UploaderUtils;
076    import org.hd.d.pg2k.svrCore.vars.EventPeriod;
077    import org.hd.d.pg2k.svrCore.vars.EventVariableValue;
078    import org.hd.d.pg2k.svrCore.vars.SimpleVariableDefinition;
079    import org.hd.d.pg2k.svrCore.vars.SystemVariables;
080    import org.hd.d.pg2k.webSvr.exhibit.BuiltInFilters;
081    import org.hd.d.pg2k.webSvr.exhibit.DataSourceBean;
082    import org.hd.d.pg2k.webSvr.exhibit.FilterBean;
083    import org.hd.d.pg2k.webSvr.exhibit.FilterExpr;
084    import org.hd.d.pg2k.webSvr.exhibit.FilterIF;
085    import org.hd.d.pg2k.webSvr.exhibit.SortExpr;
086    
087    import ORG.hd.d.IsDebug;
088    
089    /**Utility functions to generate HTML inserts for showing batches of thumbnails/links.
090     * One advantage of having a method here rather than in-line in a JSP
091     * is that this code is pre-compiled off-line for speed and robustness.
092     */
093    public final class HTMLThumbnailInsertGenerators
094        {
095        /**Prevent construction of an instance. */
096        private HTMLThumbnailInsertGenerators() { }
097    
098    
099        /**Minimum width for column display in pixels; strictly positive. */
100        public static final int COLDISP_MIN_WIDTH_PIXELS =
101            ExhibitThumbnails.SML_STATIC_IMAGE_TN_LDIM_PX +
102            2 * PageSkinUtils.GENERIC_TN_BORDER_WIDTH_PIXELS +
103            10; // Fudge factor.
104    
105        /**Minimum height for column display in pixels; strictly positive.
106         * Taken to be the height of display for one exhibit
107         * with a small thumbnail.
108         */
109        public static final int COLDISP_MIN_HEIGHT_PIXELS =
110            ExhibitThumbnails.SML_STATIC_IMAGE_TN_LDIM_PX +
111            2 * PageSkinUtils.GENERIC_TN_BORDER_WIDTH_PIXELS +
112            90; // Fudge factor.
113    
114        /**Assumed approximate mean pixels wide per (small) character; strictly positive. */
115        public static final int COLDISP_MEAN_CHAR_WIDTH_PIXELS = 9;
116    
117        /**Assumed approximate mean pixels wide per (small) character; strictly positive. */
118        public static final int COLDISP_MEAN_CHAR_HEIGHT_PIXELS = 14;
119    
120        /**Cap number of items cached for thumbnail generators for efficiency; strictly positive.
121         * Somewhat more than just the "first page" of 'best exhibit' results
122         * so that we can show some exhibits beyond it.
123         */
124        private static final int MAX_THUMBNAIL_EXHIBITS_TO_RETAIN_FOR_DISPLAY = 2*WebConsts.SINGLE_PAGE_CONTACT_SHEET_TN_COUNT + 1;
125    
126        /**Key to retrieve AEP-linked set of 'random JPEG' exhibits. */
127        private static final DataSourceBean.AEPLinkedKey rndKey = new DataSourceBean.AEPLinkedKey("random JPEG exhibits key");
128    
129        /**Key to retrieve AEP-linked set of 'best' exhibits. */
130        private static final DataSourceBean.AEPLinkedKey bestKey = new DataSourceBean.AEPLinkedKey("best exhibits key");
131    
132        /**Lock to prevent more than one update run on _bestE at once. */
133        private static final ReentrantLock _bestE_update_lock = new ReentrantLock();
134    
135        /**Time before which we will not attempt incremental update of _bestE set.
136         * Set so as to limit fraction of system CPU spent recomputing/updating.
137         * <p>
138         * Is volatile so as to allow access without a lock.
139         * <p>
140         * Private to getBestExhibitsSelection().
141         */
142        private static volatile long _bestE_dontUpdateBefore;
143    
144        /**Makes HTML for row display of the exhibits supplied; never null.
145         * @param exhibits  non-null list of full, valid exhibit names,
146         *     more important first (later items may not be displayed)
147         * @param dataSource  non-null source of exhibit data and thumbnails
148         * @param reduceEffort  if true, reduce CPU and bandwidth load
149         *     in executing this routine and implied by the output
150         * @return empty output if no thumbnails available, else wrappable linked HTML for thumbnail display
151         */
152        public static String makeHTMLExhibitRow(final Name.ExhibitFull exhibits[],
153                final DataSourceBean dataSource,
154                final LocaleBeanBase localeBean,
155                final boolean reduceEffort)
156            throws IOException
157            {
158            // Try to get all thumbnails pre-loaded/computed up-front, concurrently.
159            final BitSet exists = WebUtils.exhibitsHaveThumbnail(dataSource,
160                            Arrays.asList(exhibits),
161                            false, // Small thumbnails.
162                            !reduceEffort);
163    
164            final int n = exhibits.length;
165            final StringBuilder result = new StringBuilder(n << 7);
166    
167            // Get the exhibit properties.
168            final AllExhibitProperties aep = dataSource.getAllExhibitProperties(-1);
169    
170            for(int i = 0; i < n; ++i)
171                {
172                if(!exists.get(i)) { continue; }
173    
174                final Name.ExhibitFull exhibitName = exhibits[i];
175    
176                // Get this exhibit's properties; never null.
177                final ExhibitStaticAttr esa = aep.aeid.getStaticAttr(exhibitName);
178    
179                // Skip entry for a no-longer-extant exhibit,
180                // eg after an AEP change.
181                if(esa == null)
182                    { continue; }
183    
184                // Get the exhibit type...
185                //final ExhibitMIME.ExhibitTypeParameters exhibitType = (ExhibitMIME.getInputFileType(esa.filePath));
186    
187                // Compute the relative URL to the catalogue page; never null.
188                final String catPage = WebUtils.makeCatPageRRURL(exhibitName,
189                                        WebConsts.F_secondary_generated_HTML_suffix);
190    
191                // Try and get a thumbnail, which must be an inlineable image.
192                final java.awt.Dimension thumbnailXyDim = new java.awt.Dimension();
193                final String thumbnailURL = (!exists.get(i)) ? null :
194                    WebUtils.makeHTMLInlineImageThumbnailURL(
195                        dataSource,
196                        exhibitName,
197                        false, // Get a small thumbnail.
198                        false, // Allow CDN URL.
199                        thumbnailXyDim,
200                        reduceEffort);
201    
202                // If we have a thumbnail then insert it.
203                if(thumbnailURL != null)
204                    {
205                    // AKA for title...
206                    final CharSequence specificAKA = GenUtils.getLocalisedTreeDesc(aep, exhibitName, localeBean, false, true, false, true);
207                    final boolean hasSpecificAKA = (specificAKA.length() != 0);
208    
209                    result.
210                        append("<a href=\"").append(catPage).append("\" target=\"_top\">").
211                        append("<img src=\"").append(thumbnailURL).append('\"').
212                            append(" width=").append(thumbnailXyDim.width).
213                            append(" height=").append(thumbnailXyDim.height).
214                            append(" alt=").append(UploaderUtils.quoteHTMLArg(localeBean.getLocalisedMessage("common.thumbnail")));
215                            if(hasSpecificAKA) { result.append(" title=\"").append(specificAKA.toString().replace('"', '\'')).append('"'); }
216                            result.append(WebConsts.TN_IMG_TAG_EXTRA_ATTR).append('>').
217                        append("</a>");
218    
219                    result.append(' '); // Insert some space to allow wrapping, etc.
220                    }
221                }
222    
223            return(result.toString());
224            }
225    
226    
227        /**Makes HTML for columnar (ie taller and thin) display of exhibits; never null.
228         * Given the width and height for display,
229         * and a list of exhibits,
230         * will format and show as many as is reasonably possible in the space.
231         * Exhibits from the start of the list will be shown first.
232         * <p>
233         * Text is horizontally centred.
234         * <p>
235         * This may not show any exhibits at all if the area is too small.
236         * <p>
237         * This will not work well if the space is very narrow or shallow,
238         * much less than about 100 pixels.
239         * <p>
240         * Note that this may not get things exactly right if the user
241         * selects different font sizes in their browser.
242         *
243         * @param exhibits  non-null list of full, valid exhibit names,
244         *     more important first (later items may not be displayed)
245         * @param dataSource  non-null source of exhibit data and thumbnails
246         * @param pixelsWidth  non-negative width of display space in pixels
247         * @param pixelsHeight  non-negative height of display space in pixels
248         * @param reduceEffort  if true, reduce CPU and bandwidth load
249         *     in executing this routine and implied by the output
250         *
251         * @throws java.io.IOException  if there is difficulty retrieving meta-data
252         */
253        public static String makeHTMLExhibitColumnDisplay(final Name.ExhibitFull exhibits[],
254                                                          final int pixelsWidth,
255                                                          final int pixelsHeight,
256                                                          final DataSourceBean dataSource,
257                                                          final LocaleBeanBase localeBean,
258                                                          final boolean reduceEffort)
259            throws IOException
260            {
261            if((exhibits == null) ||
262               (pixelsWidth < 0) ||
263               (pixelsHeight < 0) ||
264               (dataSource == null) ||
265               (localeBean == null))
266                { throw new IllegalArgumentException(); }
267    
268            // If too narrow then return an empty string.
269            if((pixelsWidth < COLDISP_MIN_WIDTH_PIXELS) ||
270                (pixelsHeight < COLDISP_MIN_HEIGHT_PIXELS))
271                { return(""); }
272    
273            // Compute maximum number of exhibits we could display vertically.
274            final int maxVertical = pixelsHeight / COLDISP_MIN_HEIGHT_PIXELS;
275    
276            // Compute the actual number of exhibits we will show.
277            final int toShow = Math.min(maxVertical, exhibits.length);
278            assert(toShow > 0);
279    
280            // Try to get all thumbnails pre-loaded/computed up-front, concurrently.
281            final BitSet exists = WebUtils.exhibitsHaveThumbnail(dataSource,
282                            Arrays.asList(exhibits).subList(0, toShow),
283                            false, // Small thumbnails.
284                            !reduceEffort);
285    
286            // Get the exhibit properties.
287            final AllExhibitProperties aep = dataSource.getAllExhibitProperties(-1);
288    
289            final StringBuilder result = new StringBuilder(exhibits.length * 101);
290            result.append("<table align=center cellpadding=0 cellspacing=0>");
291    
292            for(int i = 0; i < toShow; ++i)
293                {
294                final Name.ExhibitFull exhibitName = exhibits[i];
295    
296                // Check for a bad exhibit name...
297                if(exhibitName == null)
298                    { throw new IllegalArgumentException(); }
299    
300                // Get this exhibit's properties; never null.
301                final ExhibitStaticAttr esa = aep.aeid.getStaticAttr(exhibitName);
302    
303                // Skip entry for a no-longer-extant exhibit,
304                // eg after an AEP change.
305                if(esa == null)
306                    { continue; }
307    
308                // Start new row for new exhibit.
309                result.append("<tr><td align=center height=").
310                    append(COLDISP_MIN_HEIGHT_PIXELS).
311                    append('>');
312    
313                // Get the exhibit type...
314                final ExhibitMIME.ExhibitTypeParameters exhibitType = (ExhibitMIME.getInputFileType(esa.getCharSequence()));
315    
316                // Compute the relative URL to the catalogue page; never null.
317                final String catPage = WebUtils.makeCatPageRRURL(exhibitName,
318                                        WebConsts.F_secondary_generated_HTML_suffix);
319    
320                // Try and get a thumbnail, which must be an inlineable image.
321                final java.awt.Dimension thumbnailXyDim = new java.awt.Dimension();
322                final String thumbnailURL = (!exists.get(i)) ? null :
323                    WebUtils.makeHTMLInlineImageThumbnailURL(
324                        dataSource,
325                        exhibitName,
326                        false, // Get a small thumbnail.
327                        false, // Allow CDN URL.
328                        thumbnailXyDim,
329                        reduceEffort);
330    
331                // If we have a thumbnail, insert it.
332                if(thumbnailURL != null)
333                    {
334                    // AKA for title...
335                    final CharSequence specificAKA = GenUtils.getLocalisedTreeDesc(aep, exhibitName, localeBean, false, true, false, true);
336                    final boolean hasSpecificAKA = (specificAKA.length() != 0);
337    
338                    result.append("<table align=center border=").
339                        append(PageSkinUtils.GENERIC_TN_BORDER_WIDTH_PIXELS).
340                        append("><tr><td>").
341                        append("<a href=\"").append(catPage).append("\" target=_top>").
342                        append("<img src=\"").append(thumbnailURL).append('\"').
343                            append(" width=").append(thumbnailXyDim.width).
344                            append(" height=").append(thumbnailXyDim.height).
345                            append(" alt=").append(UploaderUtils.quoteHTMLArg(localeBean.getLocalisedMessage("common.thumbnail")));
346                            if(hasSpecificAKA) { result.append(" title=\"").append(specificAKA.toString().replace('"', '\'')).append('"'); }
347                            result.append(WebConsts.TN_IMG_TAG_EXTRA_ATTR).append('>').
348                        append("</a></td></tr></table>");
349                    }
350                // Put in some summary info in lieu of a thumbnail.
351                else
352                    {
353                    if(exhibitType != null)
354                        { result.append(exhibitType.description); }
355                    }
356    
357                // Force line-break after thumbnail.
358                result.append("<br />");
359    
360                // Link to the catalogue page.
361                result.append("<a href=\"").
362                    append(catPage).
363                    append("\" target=_top>").
364                    append(TextUtils.sanitiseForXML(ExhibitName.getFileComponent(exhibitName).toString(),
365                                                   pixelsWidth / COLDISP_MEAN_CHAR_WIDTH_PIXELS,
366                                                   false)).
367                    append("</a>");
368    
369                // Force line-break after link.
370                result.append("<br />");
371    
372                // Show size and date.
373                result.append(TextUtils.sizeAsText(esa.length, true)).append("; ");
374                result.append("<nobr>").
375                    append(DateFormat.getDateInstance(DateFormat.MEDIUM, localeBean.getLocale()).format(new Date(esa.timestamp))).
376                    append("</nobr>");
377    
378                result.append("</td></tr>");
379                }
380    
381            result.append("</table>");
382            return(result.toString());
383            }
384    
385        /**Empty (immutable) array to return to indicate no results. */
386        private static final Name.ExhibitFull[] NO_RESULTS = new Name.ExhibitFull[0];
387    
388        /**Key to retrieve AEP-linked set of 'new' exhibits. */
389        private static final DataSourceBean.AEPLinkedKey newKey = new DataSourceBean.AEPLinkedKey("new exhibits key");
390    
391        /**Get random selection of new-ish exhibit full names; never null.
392         * Selects from newest few percent of available exhibits,
393         * and within those the very newest are most likely to be returned.
394         * Note that no absolute age/time limits are imposed.
395         * <p>
396         * If there are any exhibits at all,
397         * and maxExhibits is positive,
398         * this routine ensures that at least one exhibit is a candidate to show.
399         * <p>
400         * These results are cached against the AEP,
401         * and automatically discarded/recomputed when the exhibit set changes.
402         * <p>
403         * May return empty list, but no nulls nor duplicates.
404         * <p>
405         * Should only be used with one servlet context.
406         *
407         * @param application  the servlet context; never null
408         * @param maxExhibits  the maximum number of exhibits to return;
409         *     non-negative
410         * @param rnd          the random number source if non-null, else null to pick top items
411         */
412        public static Name.ExhibitFull[] getNewExhibitSelection(final ServletContext application,
413                                                                final int maxExhibits,
414                                                                final Random rnd)
415            throws IOException
416            { return(getNewExhibitSelection(application, maxExhibits, rnd, false)); }
417    
418        /**Get random selection of new-ish exhibit full names; never null.
419         * Selects from newest few percent of available exhibits,
420         * and within those the very newest are most likely to be returned.
421         * Note that no absolute age/time limits are imposed.
422         * <p>
423         * If there are any exhibits at all,
424         * and maxExhibits is positive,
425         * this routine ensures that at least one exhibit is a candidate to show.
426         * <p>
427         * These results are cached against the AEP,
428         * and automatically discarded/recomputed when the exhibit set changes.
429         * <p>
430         * May return empty list, but no nulls nor duplicates.
431         * <p>
432         * Should only be used with one servlet context.
433         *
434         * @param application  the servlet context; never null
435         * @param maxExhibits  the maximum number of exhibits to return;
436         *     non-negative
437         * @param rnd          the random number source if non-null, else null to pick top items
438         * @param beQuick      if true and the result is needs recomputing from scratch
439         *     then this returns an empty result and attempts to recompute in the background
440         */
441        public static Name.ExhibitFull[] getNewExhibitSelection(final ServletContext application,
442                                                                final int maxExhibits,
443                                                                final Random rnd,
444                                                                final boolean beQuick)
445            throws IOException
446            {
447            if((application == null) ||
448               (maxExhibits < 0))
449                { throw new IllegalArgumentException(); }
450    
451            // Retrieve AEP-linked cache.
452            final DataSourceBean dsb = DataSourceBean.getApplicationInstance(application);
453            if(dsb == null) { throw new IllegalStateException(); }
454            Name.ExhibitFull[] newE;
455            while(null == (newE = (Name.ExhibitFull[]) dsb.getAEPLinkedValue(newKey)))
456                {
457                // If being quick and no result is to hand
458                // then attempt to start computation in the background
459                // and return an empty result...
460                if(beQuick)
461                    {
462                    ThreadUtils.lowPriorityThreadPoolDiscardable.execute(new Runnable(){
463                        public final void run()
464                            {
465                            // Note: we don't pass in the caller's 'Random' instance and request only a minimal result size.
466                            try { getNewExhibitSelection(application, 1, null, false); }
467                            catch(final IOException e) { };
468                            }
469                        });
470                    return(NO_RESULTS);
471                    }
472    
473                // Extract newest exhibits.
474                final AllExhibitProperties aep = dsb.getAllExhibitProperties(-1);
475                final SortedSet<ExhibitStaticAttr> ss = GenUtils.leastN(
476                    Math.min(aep.aeid.length/2, MAX_THUMBNAIL_EXHIBITS_TO_RETAIN_FOR_DISPLAY),
477                    aep.aeid.getAllStaticAttrs().iterator(),
478                    (new Comparator<ExhibitStaticAttr>(){
479                        public final int compare(final ExhibitStaticAttr e1, final ExhibitStaticAttr e2)
480                            {
481                            if(e1.timestamp > e2.timestamp) { return(-1); } // Correct (newest first).
482                            if(e1.timestamp < e2.timestamp) { return(+1); } // Wrong.
483                            return(e1.compareTo(e2)); // Same age: break ties by exhibit name.
484                            }
485                        }));
486                final int len = ss.size();
487                assert(len <= MAX_THUMBNAIL_EXHIBITS_TO_RETAIN_FOR_DISPLAY);
488    
489                newE = new Name.ExhibitFull[len];
490                final Iterator<ExhibitStaticAttr> it = ss.iterator();
491                for(int i = 0; i < len; ++i)
492                    { newE[i] = it.next().getExhibitFullName(); }
493    
494                // Cache raw newest-first array...
495                dsb.putIfAbsentAEPLinkedValue(newKey, newE);
496                }
497    
498            final int candidates = newE.length;
499    
500            // If no suitable exhibits at all,
501            // then return immediately.
502            if(candidates < 1) { return(NO_RESULTS); }
503    
504            // Pick a starting exhibit.
505            // (Forced to be zero if no more exhibits than requested, or no random source.)
506            final int which = ((rnd == null) || (candidates <= maxExhibits)) ?
507                0 : rnd.nextInt(1 + rnd.nextInt(1 + candidates - maxExhibits));
508    
509            // Return a suitable-size chunk from the selected starting point.
510            return(Arrays.copyOfRange(newE, which, which + Math.min(candidates, maxExhibits)));
511            }
512    
513        /**Get random selection of JPEG exhibit full names; never null.
514         * These results are cached against the AEP,
515         * and automatically discarded/recomputed when the exhibit set changes.
516         * <p>
517         * May return empty list, but no nulls nor duplicates.
518         * <p>
519         * Should only be used with one servlet context.
520         *
521         * @param application  the servlet context; never null
522         * @param maxExhibits  the maximum number of exhibits to return;
523         *     non-negative
524         */
525        public static Name.ExhibitFull[] getRandomJPEGSelection(final ServletContext application,
526                                                                final int maxExhibits)
527            throws IOException
528            { return(getRandomJPEGSelection(application, maxExhibits, false)); }
529    
530        /**Get random selection of JPEG exhibit full names; never null.
531         * These results are cached against the AEP,
532         * and automatically discarded/recomputed when the exhibit set changes.
533         * <p>
534         * May return empty list, but no nulls nor duplicates.
535         * <p>
536         * Should only be used with one servlet context.
537         *
538         * @param application  the servlet context; never null
539         * @param maxExhibits  the maximum number of exhibits to return;
540         *     non-negative
541         * @param beQuick      if true and the result is needs recomputing from scratch
542         *     then this returns an empty result and attempts to recompute in the background
543         */
544        public static Name.ExhibitFull[] getRandomJPEGSelection(final ServletContext application,
545                                                                final int maxExhibits,
546                                                                final boolean beQuick)
547            throws IOException
548            {
549            if((application == null) ||
550               (maxExhibits < 0))
551                { throw new IllegalArgumentException(); }
552    
553            // Retrieve AEP-linked cache, creating it if necessary.
554            final DataSourceBean dsb = DataSourceBean.getApplicationInstance(application);
555            if(dsb == null) { throw new IllegalStateException(); }
556            FilterBean rndJPEGE;
557            while(null == (rndJPEGE = (FilterBean) dsb.getAEPLinkedValue(rndKey)))
558                {
559                rndJPEGE = new FilterBean();
560    
561                // Don't retain this data if the system is very short of memory...  Should be quick to recompute...
562                rndJPEGE.setMemorySensitiveCache(true);
563    
564                // Set a name for diagnostics...
565                rndJPEGE.setName("random JPEG");
566    
567                // We filter this (on creation) to be the best N (to improve locality) that are:
568                //   * JPEG images.
569                //   * Too large to be thumbnails themselves (ie not very small).
570                rndJPEGE.setExpr(new SortExpr(new FilterExpr(null, (new FilterIF()
571                    {
572                    final public boolean accept(final AllExhibitProperties aep, final Name.ExhibitFull exhibitName)
573                        {
574                        final ExhibitStaticAttr esa = aep.aeid.getStaticAttr(exhibitName);
575                        if(null == esa) { return(false); }
576    
577                        // Quick-n-dirty filter to exclude very small images...
578                        if(esa.length <= ExhibitThumbnails.STD_ABS_MAX_BYTES)
579                            { return (false); }
580    
581                        // Quick filter to exclude non-JPEG exhibits.
582                        final ExhibitMIME.ExhibitTypeParameters et = (ExhibitMIME.getInputFileType(esa.getCharSequence()));
583                        if((et == null) || (et.type != ExhibitMIME.ET_JPEG))
584                            { return (false); }
585    
586                        return(true); // Looks OK...
587                        }
588                    /**Unique Serialisation class ID generated by http://random&#46;hd&#46;org/. */
589                    private static final long serialVersionUID = -5173930708308579233L;
590                    })),
591                    // Limit number of distinct items so as to improve locality in various system caches
592                    // and save a little memory retaining the collection.
593                    new BuiltInFilters.sortByGoodness(Math.max(503, MAX_THUMBNAIL_EXHIBITS_TO_RETAIN_FOR_DISPLAY))));
594    
595                // Set this to automatically time out and recompute periodically.
596                // Aim to be at about the default system temporal slackness
597                // or the computable-mutable recalculation time if less.
598                // If this timeout is very small then there may be a performance impact.
599                // If too high then we will not reflect changes in popularity quickly.
600                rndJPEGE.setExpiryInterval(Math.min(2*CoreConsts.DEFAULT_TEMPORAL_SLACKNESS_S,
601                                                         ExhibitPropsComputableMutable.MAX_AGE_BEFORE_STALE_MS/2503) *
602                                                (500 + Rnd.fastRnd.nextInt(1000)));
603    
604                // Atomically cache the new bean.
605                dsb.putIfAbsentAEPLinkedValue(rndKey, rndJPEGE);
606                }
607    
608            // Get full list of "good" exhibits best first.
609            final List<Name.ExhibitFull> exhibits = beQuick ?
610                    rndJPEGE.peek(application) : rndJPEGE.select(application);
611            // If beQuick==true and nothing immediately ready then return immediately
612            // but also attempt to do (re)compute in the background.
613            if(exhibits == null)
614                {
615                ThreadUtils.lowPriorityThreadPoolDiscardable.execute(new Runnable(){
616                    public final void run()
617                        {
618                        // Note: we request only a minimal result size.
619                        try { getRandomJPEGSelection(application, 1, false); }
620                        catch(final IOException e) { };
621                        }
622                    });
623                return(NO_RESULTS);
624                }
625    
626            final int ne = exhibits.size();
627    
628            // If no suitable exhibits at all,
629            // then return immediately.
630            if(ne < 1) { return(NO_RESULTS); }
631    
632            final Name.ExhibitFull result[] = new Name.ExhibitFull[Math.min(maxExhibits, ne)];
633            // Pick a starting exhibit...
634            int which = Rnd.fastRnd.nextInt(ne);
635            int loc = 0;
636    
637            // ...and wrap around from it...
638            for(int i = result.length; --i >= 0; )
639                {
640                final Name.ExhibitFull exhibitName = exhibits.get(ne - 1 - which);
641                result[loc++] = exhibitName;
642                if(++which >= ne) { which = 0; }
643                }
644    
645            return(result);
646            }
647    
648    
649        /**If true then invalidate "best" collection if any stale value is found.
650         * Otherwise, only invalidate if there is a significant value change.
651         * (If not then we can survive until the next periodic re-sort.)
652         * This is may be pretty aggressive on CPU and other resources if true.
653         */
654        private static final boolean CHECK_ALL_FOR_STALE = false;
655    
656        /**Out-of-order tolerance we will allow before forcing "best" selection to be recomputed.
657         * This corresponds to an insignificantly small difference
658         * that will rarely be noticed by a (non-obsessive!) human,
659         * so need not force us to invalidate and re-sort the entire collection.
660         */
661        private static final int BEST_EPSILON = Integer.MAX_VALUE / (1 + WebConsts.SINGLE_PAGE_CONTACT_SHEET_TN_COUNT);
662    
663        /**Compute hash for best exhibits (non-null) name array result of getBestExhibitSelection().
664         * Suitable for use in weak ETag.
665         * <p>
666         * Built from the name hash of the first and last items as indicative of the entire list.
667         * <p>
668         * Non-negative; zero if the array is zero-length.
669         */
670        public static final long computeBestExhibitSelectionHash(final Name.ExhibitFull[] names)
671            {
672            if(null == names) { throw new IllegalArgumentException(); }
673    
674            final int length = names.length;
675            if(0 == length) { return(0); }
676            return(((((long) names[0].hashCode()) << 31L) ^ (long) names[length-1].hashCode()) & (~0L >>> 1));
677            }
678    
679        /**Get names of the the best exhibits ("goodness" greater than zero); never null.
680         * These results are cached against the AEP,
681         * and automatically discarded/recomputed when the exhibit set changes,
682         * and also periodically to allow for the values changing over time.
683         * <p>
684         * May return an empty list, but no nulls nor duplicates.
685         * <p>
686         * Should only be used with one servlet context.
687         * <p>
688         * If a random number source is supplied then it is used to chose
689         * a random (though weighted toward best) selection of exhibits.
690         * <p>
691         * If no random number source is supplied then the very best exhibits
692         * are returned.
693         * <p>
694         * (May invalidate its cache and spend a short time updating entries
695         * if any of the items that it is about to return is stale
696         * or the results are no longer in order (best first)
697         * in the hope that non-stale ones will have been computed in the mean time.
698         * This incremental cache validation and recomputation should help
699         * keep the results reasonable without penalising any one caller too much,
700         * and returns reasonable results in a short time on each call.)
701         *
702         * @param application  the servlet context; never null
703         * @param maxExhibits  the maximum number of exhibits to return;
704         *     non-negative
705         * @param rnd          the random number source if non-null, else null to pick top items
706         */
707        public static Name.ExhibitFull[] getBestExhibitSelection(final ServletContext application,
708                                                                 final int maxExhibits,
709                                                                 final Random rnd)
710            throws IOException
711            { return(getBestExhibitSelection(application, maxExhibits, rnd, false)); }
712    
713        /**Get names of the the best exhibits ("goodness" greater than zero); never null.
714         * These results are cached against the AEP,
715         * and automatically discarded/recomputed when the exhibit set changes,
716         * and also periodically to allow for the values changing over time.
717         * <p>
718         * May return an empty list, but no nulls nor duplicates.
719         * <p>
720         * Should only be used with one servlet context.
721         * <p>
722         * If a random number source is supplied then it is used to chose
723         * a random (though weighted toward best) selection of exhibits.
724         * <p>
725         * If no random number source is supplied then the very best exhibits
726         * are returned.
727         * <p>
728         * (May invalidate its cache and spend a short time updating entries
729         * if any of the items that it is about to return is stale
730         * or the results are no longer in order (best first)
731         * in the hope that non-stale ones will have been computed in the mean time.
732         * This incremental cache validation and recomputation should help
733         * keep the results reasonable without penalising any one caller too much,
734         * and returns reasonable results in a short time on each call.)
735         * <p>
736         * This ties not to block for the calculation of any EPCM values.
737         *
738         * @param application  the servlet context; never null
739         * @param maxExhibits  the maximum number of exhibits to return;
740         *     non-negative
741         * @param rnd          the random number source if non-null, else null to pick top items
742         * @param beQuick      if true and the result is needs recomputing from scratch
743         *     then this returns an empty result and attempts to recompute in the background
744         */
745        public static Name.ExhibitFull[] getBestExhibitSelection(final ServletContext application,
746                                                                 final int maxExhibits,
747                                                                 final Random rnd,
748                                                                 final boolean beQuick)
749            throws IOException
750            {
751            if((application == null) ||
752               (maxExhibits < 0))
753                { throw new IllegalArgumentException(); }
754    
755            // Note start time of this call,
756            // as simply fetching/updating the results may take some time.
757            final long startTime = System.currentTimeMillis();
758    
759            // Retrieve AEP-linked cache, creating it if necessary.
760            final DataSourceBean dsb = DataSourceBean.getApplicationInstance(application);
761            if(dsb == null) { throw new IllegalStateException(); }
762            FilterBean bestE;
763            while(null == (bestE = (FilterBean) dsb.getAEPLinkedValue(bestKey)))
764                {
765                bestE = new FilterBean();
766    
767                // Hold onto this data since it is expensive to recompute and is capped in size.
768                bestE.setMemorySensitiveCache(false);
769    
770                // Set a name for diagnostics...
771                bestE.setName("best exhibits");
772    
773                // Dummy/default for quick EPCM approximation.
774                final GenProps gp = new GenProps();
775    
776                // We filter this (on creation) to be:
777                //   * Exhibits better than average/neutral "goodness".
778                // We sort this to be best (highest-goodness) first.
779                bestE.setExpr(new SortExpr(new FilterExpr(null, (new FilterIF()
780                    {
781                    final public boolean accept(final AllExhibitProperties aep, final Name.ExhibitFull exhibitName)
782                        {
783                        // Make do with whatever value is already computed,
784                        // though force at least a fast approximation if at all possible
785                        // to bootstrap the process and provide some differentiation.
786                        final ExhibitPropsComputableMutable epcm =
787                                aep.getExhibitPropsComputableMutable(exhibitName, true, gp, null, null);
788    
789                        // True only if better than neutral.
790                        // We don't care if the value is stale
791                        // (on the grounds that it may still be a reasonable approximation)
792                        // but we reject entirely unknown/uncomputed values.
793                        if((epcm != null) && (epcm.getGoodness() > 0))
794                            { return(true); }
795    
796                        // Not good enough.
797                        return(false);
798                        }
799    
800                    /**Unique Serialisation class ID generated by http://random&#46;hd&#46;org/. */
801                    private static final long serialVersionUID = 671635642661710967L;
802                    })),
803                    // Limit number of items to be considered "best" for efficiency,
804                    // to somewhat more than just the "first page" of 'best exhibit' results
805                    // so that we can show some exhibits beyond it.
806                    new BuiltInFilters.sortByGoodness(MAX_THUMBNAIL_EXHIBITS_TO_RETAIN_FOR_DISPLAY)));
807    
808                // Set this to automatically time out and recompute periodically.
809                // Aim to be at about the default system temporal slackness
810                // or the computable-mutable recalculation time if less.
811                // If this timeout is very small then there may be a performance impact.
812                // If too high then we will not reflect changes in popularity quickly.
813                bestE.setExpiryInterval(Math.min(2*CoreConsts.DEFAULT_TEMPORAL_SLACKNESS_S,
814                                                         ExhibitPropsComputableMutable.MAX_AGE_BEFORE_STALE_MS/2503) *
815                                                (500 + Rnd.fastRnd.nextInt(1000)));
816    
817                // Atomically cache the new bean.
818                dsb.putIfAbsentAEPLinkedValue(bestKey, bestE);
819                }
820    
821            // Get full list of "good" exhibits best first.
822            final List<Name.ExhibitFull> exhibits = beQuick ?
823                    bestE.peek(application) : bestE.select(application);
824            // If being quick and no result is to hand
825            // then attempt to start computation in the background
826            // and return an empty result...
827            if(exhibits == null)
828                {
829                ThreadUtils.lowPriorityThreadPoolDiscardable.execute(new Runnable(){
830                    public final void run()
831                        {
832                        // Note: we don't pass in the caller's 'Random' instance and request only a minimal result size.
833                        try
834                            {
835                            final ExhibitFull[] bestExhibitSelection = getBestExhibitSelection(application, 1, null, false);
836    
837                            // Try to force returned exhibit (if any) EPCM up to date
838                            // to help ensure some progress during invalidation cycle.
839                            if(bestExhibitSelection.length > 0)
840                                { dsb.getAllExhibitProperties(-1).getExhibitPropsComputableMutable(bestExhibitSelection[0], false, dsb.getGenProps(-1), dsb, dsb.getScorerCache()); }
841                            }
842                        catch(final IOException ignore) { }
843                        }
844                    });
845                return(NO_RESULTS);
846                }
847    
848            final int ne = exhibits.size();
849    
850            // If no suitable exhibits at all then return immediately.
851            if(ne < 1)
852                { return(NO_RESULTS); }
853    
854            final Name.ExhibitFull result[] = new Name.ExhibitFull[Math.min(maxExhibits, ne)];
855            // Pick a starting exhibit.
856            // (Forced to be zero if no more exhibits than requested, or no random source.)
857            int which = ((rnd == null) || (ne <= maxExhibits)) ?
858                0 : rnd.nextInt(1 + rnd.nextInt(1 + ne - maxExhibits));
859    
860            // Copy the exhibits in order to the result array.
861            for(int i = 0; i < result.length; ++i)
862                { result[i] = exhibits.get(which++); }
863    
864            // If it is too soon to consider doing an update check
865            // then return our result immediately.
866            if(startTime <= _bestE_dontUpdateBefore) { return(result); }
867    
868            // If an update is already in progress
869            // then return our result immediately.
870            if(!_bestE_update_lock.tryLock()) { return(result); }
871            try
872                {
873                // Recompute any stale (or now-not-good) items found
874                // in the list about to be returned
875                // (plus at least one other at random if possible).
876    
877                // Try to keep run-time of routine well below page-generation time limit,
878                // indeed well below the time allowed for interactive operations.
879                // The more time that can be spent in one go, however,
880                // the more efficient that any incremental updating will be,
881                // at the potential cost of annoying a waiting/blocked user.
882                final long stopBy = startTime + CoreConsts.MAX_INTERACTIVE_DELAY_MS/2;
883    
884                // Check/validate (and try to bring up-to-date)
885                // at least one entry before giving up
886                // so as to try to ensure some progress
887                // if stale entries do need recomputing.
888                // Try to ensure that at least whatever we rated as
889                // the "best" item in this result set
890                // (first in the array) is non-stale by the time we have finished.
891                final AllExhibitProperties aep = dsb.getAllExhibitProperties(-1);
892                final GenProps gp = dsb.getGenProps(-1);
893    
894                // Flag to be set non-null if we need to invalidate the cache.
895                String invalidate = null;
896    
897                // Compute goodness of current "best" item.
898                final int bestGoodness = aep.getExhibitPropsComputableMutable(exhibits.get(0), false, gp, dsb, dsb.getScorerCache()).getGoodness();
899                // Compute goodness of current "worst-best" item.
900                final int worstGoodness = aep.getExhibitPropsComputableMutable(exhibits.get(ne-1), false, gp, dsb, dsb.getScorerCache()).getGoodness();
901    
902                // Some early funnies to check...
903                if((bestGoodness <= 0) || (worstGoodness <= 0) || (worstGoodness > bestGoodness))
904                    { invalidate = "best/worst entries invalid or mis-ordered"; }
905    
906                // Try to ensure all values for results are non-stale and in order.
907                // We don't care if we had to re-compute an entry
908                // as long as it is still roughly in place.
909                // If conserving energy or the system is heavily loaded then trim the work done here.
910                final boolean conserving = GenUtils.mustConservePower() || ThreadUtils.isCPUHeavilyLoaded();
911                for(int i = 0; i < result.length; ++i) // Check "best" items first.
912                    {
913                    // Check if this is a stale, missing, or out-of-order item...
914                    final Name.ExhibitFull exhibitName = result[i];
915                    final ExhibitPropsComputableMutable epcm = aep.getExhibitPropsComputableMutable(
916                            exhibitName, true, gp, dsb, dsb.getScorerCache());
917                    final int goodness = (epcm == null) ? Integer.MIN_VALUE : epcm.getGoodness();
918                    final int precGoodness;
919                    if(goodness <= 0)
920                        {
921                        // Something weird happened and this entry
922                        // is missing or become inadmissible,
923                        // so we must re-filter and re-sort the cache next time.
924                        invalidate = "entry is missing/invalid: " + exhibitName;
925                        if(epcm == null) { break; /* Avoid blowing up later tests with NPEs! */ }
926                        }
927                    // Almost always check that the first item is good; possibly check all.
928                    else if((CHECK_ALL_FOR_STALE || (i == 0)) && epcm.isStale() && !conserving)
929                        {
930                        // Make sure that we recompute our cache next time.
931                        invalidate = "top entry had stale EPCM: " + exhibitName;
932                        }
933                    else if((i < result.length) && (i > 0) &&
934                            (goodness > BEST_EPSILON + (long) (precGoodness = aep.getExhibitPropsComputableMutable(result[i-1], true, gp, dsb, dsb.getScorerCache()).getGoodness())))
935                        {
936                        // Each item in the result array must be
937                        // no more "good" than its predecessor
938                        // (allowing an "epsilon" tolerance).
939                        // If not so, then entries are mis-ordered,
940                        // (so must have been recomputed)
941                        // then we must re-sort the cache next time.
942                        // Note the computation as a long to avoid overflow.
943                        invalidate = "entry (goodness="+goodness+") is out of order wrt neighbour (goodness="+precGoodness+"): " + exhibitName + ", index: "+i;
944                        }
945                    else if((goodness > bestGoodness) || (goodness < worstGoodness))
946                        {
947                        // If we've found one better than the current "best"
948                        // or one worse than the current "worst best"
949                        // then we must re-sort the cache next time.
950                        invalidate = "entry (goodness="+goodness+") is out of range wrt best/worst ("+bestGoodness+"/"+worstGoodness+"): " + exhibitName + ", index: "+i;
951                        }
952    
953                    // Give up now if we've taken a noticeable time...
954                    if(System.currentTimeMillis() > stopBy) { break; }
955                    }
956    
957                // If we are going to invalidate the whole cache,
958                // then spend a little time trying to bring entries up to date first
959                // to make the recomputation process as efficient as possible.
960                // We try to make sure that even if we run out of time that
961                // we still get good coverage by systematic and random sampling.
962                if(invalidate != null)
963                    {
964                    // RandomAccess list of all live exhibits.
965                    final List<Name.ExhibitFull> allExhibits = aep.aeid.getAllExhibitNamesSorted();
966                    assert(allExhibits instanceof RandomAccess); // Fast to access.
967                    final int aes = allExhibits.size();
968    
969                    // If this update itself is being run in the same thread pool used below
970                    // then this submit() may well be discarded due to limited pool space.
971                    ThreadUtils.lowPriorityThreadPoolDiscardable.submit(new Runnable(){ public final void run() {
972                        final ScorerCacheIF scorerCache = dsb.getScorerCache();
973                        // Try to ensure that the first and last candidates are always OK.
974                        // If the bounds are good and all in between are correctly ordered then our result is fine.
975                        aep.getExhibitPropsComputableMutable(exhibits.get(0), false, gp, dsb, scorerCache);
976                        if(!conserving)
977                            {
978                            if(ne > 1)
979                                { aep.getExhibitPropsComputableMutable(exhibits.get(ne-1), false, gp, dsb, scorerCache); }
980                            // Randomly pick one of the intermediate candidate exhibits.
981                            if(ne > 2)
982                                { aep.getExhibitPropsComputableMutable(exhibits.get(1+Rnd.fastRnd.nextInt(ne-2)), false, gp, dsb, scorerCache); }
983                            }
984                        // Randomly pick one of the entire set of live exhibits.
985                        if(aes > 0) { aep.getExhibitPropsComputableMutable(allExhibits.get(Rnd.fastRnd.nextInt(aes)), false, gp, dsb, scorerCache); }
986                        } });
987    
988                    // Do cache invalidation at most once,
989                    // so as to minimise cost to concurrent threads for example.
990                    bestE.invalidate();
991    
992                    // Log the reason for invalidation...
993    if(IsDebug.isDebug) { dsb.log("Invalidated 'best' collection: " + invalidate); }
994                    }
995                }
996            catch(final Exception e)
997                {
998                // Absorb any transient errors from data set shifting beneath us
999                // eg a now-invalid exhibit name may cause an NPE.
1000                dsb.log("WARNING: exception in getBestExhibitSelection(): " + e.getMessage());
1001                // Force recomputation of data in case it is now booby-trapped.
1002                bestE.invalidate();
1003                }
1004            finally
1005                {
1006                // Put off next update to limit overall fraction of CPU time used.
1007                // Note that we only really care about keeping up-to-date
1008                // the entries that are "good".
1009                final long doneAt = System.currentTimeMillis();
1010                final long computeTime = doneAt - startTime;
1011                final long putOffBy = Math.min(13*computeTime, // Keep below ~10% CPU updating this...
1012                    69000 + Rnd.fastRnd.nextInt(39503)); // Cap the delay time...
1013                _bestE_dontUpdateBefore = doneAt + putOffBy; /* Volatile update is atomic. */
1014    
1015                // Let the lock go now that we're done...
1016                _bestE_update_lock.unlock();
1017                }
1018    
1019            return(result);
1020            }
1021    
1022    
1023        /**If true then always omit from the contact sheet any items that do not immediately/ever have a thumbnail. */
1024        private static final boolean CS_ALWAYS_ONLY_SHOW_THUMBNAILS = false;
1025    
1026        /**If true then cache negative ("") contact-sheet cache entries, else recompute such values each time to save memory. */
1027        private static final boolean CS_CACHE_NEGATIVE_EMPTY_RESULTS = false;
1028    
1029        /**If true then try to cache cat page contact sheets; this may require minor cosmetic changes in generated HTML for efficiency. */
1030        public static final boolean CS_CACHE_FOR_CAT_PAGES = true;
1031    
1032    
1033        /**Used as a unique key into the cache for the 'other factors value' (GenProps timestamp, etc) to watch for changes; non-null. */
1034        private static final CS8Bit OFV_KEY = CS8Bit.EMPTY;
1035    
1036        /**Private key used by getContactSheetHTML(); never null. */
1037        private static final DataSourceBean.AEPLinkedKey _CSCacheKey = new DataSourceBean.AEPLinkedKey("_CSCacheKey");
1038    
1039        /**Make "contact print" / "preview" HTML fragment to show a user all similar exhibits, "" if none possible; never null.
1040         * This routine returns a table of (small thumbnails of) all the exhibits
1041         * with the same main words prefix as the supplied word, in order,
1042         * in as compact and efficient form as reasonable.
1043         * <p>
1044         * Note that if using the 'Hoverbox' feature,
1045         * then overlay standard thumbnails may appear above and to the left or right of
1046         * a small thumbnail being hovered over,
1047         * so sufficient clearance should be given above this HTML insert
1048         * to avoid conflicts with surrounding material, such as ads.
1049         * <p>
1050         * This may choose to omit or show substitute text for exhibits with
1051         * no immediately-available thumbnail.
1052         * <p>
1053         * This does not generate any headings.
1054         * <p>
1055         * If this encounters an error (eg an exception, or inconsistent arguments)
1056         * then it will attempt to return gracefully with a "no-contact-sheet-available"
1057         * empty value ("").
1058         * <p>
1059         * This may spend a limited amount of time trying to create thumbnails,
1060         * bring up-to-date computable data, etc, but in any case tries to be quick.
1061         * <p>
1062         * Appends a special HTML comment tag to the result
1063         * if incomplete due to missing thumbnails or lack of time.
1064         * <p>
1065         * This may cache completed results against all of the exhibit names given,
1066         * to be discarded if the AEP for the supplied DataSourceBean changes,
1067         * or other relevant data changes.
1068         *
1069         * @param exhibitName  this name of the main exhibit for which a contact sheet
1070         *     is to be generated; must be a valid, non-null exhibit name
1071         * @param specialColour  HTML colour name (or #RRGGBB value) to distinguish
1072         *     the main exhibit in the contact sheet somehow,
1073         *     quoted if it need be for an attribute value,
1074         *     may be ignored for efficiency/consistency;
1075         *     null if no such marking to be done
1076         * @param dataSource  data source for thumbnails, etc; never null
1077         * @param smartSortedNames  list of all exhibit names,
1078         *     sorted with the SMART_ORDER Comparator,
1079         *     and should be random-access else this will probably be horribly slow;
1080         *     never null
1081         *
1082         * @return "" if a contact sheet is not applicable or cannot be generated
1083         *     (so result is safe to drop into HTML without checking if need be)
1084         *     else a well-formed HTML fragment suitable to drop into a catalogue page;
1085         *     never null
1086         *
1087         * <p>
1088         * TODO: separate AEP-linked long-lived negatively-cached ("") values from auto-expiring short-lived positive values to improve cache behaviour
1089         */
1090        @SuppressWarnings("unchecked")
1091        public static String getContactSheetHTML(final Name.ExhibitFull exhibitName,
1092                                                 final String specialColour,
1093                                                 final DataSourceBean dataSource,
1094                                                 final List<Name.ExhibitFull> smartSortedNames)
1095            {
1096            if((null == exhibitName) ||
1097               (dataSource == null) ||
1098               (smartSortedNames == null))
1099                { throw new IllegalArgumentException(); }
1100    
1101            // Note start time...
1102            final long startTime = System.currentTimeMillis();
1103    
1104            final int maxPixelsWidth = PageSkinUtils.MAX_LIGHTBOX_WIDTH_PX;
1105    
1106    //        try
1107                {
1108                // Compute the (lower-cased) main-words component of the main exhibit.
1109                // We'll potentially display in the contact sheet all exhibits
1110                // with the same (lower-cased to match catalogue sort behaviour) main-words component.
1111                // This is the lookup key in our AEP-linked map of cached results.
1112                final SortedSet<String> attrWords = ExhibitAttrUtils.getAttrWords().getAttrWordsSortedSet();
1113                final CS8Bit mainWordsLC = // Probably marginally more efficient to extract from short name.
1114                    new CS8Bit(exhibitName.getShortName().getMainWordsComponent(attrWords).toString().toLowerCase());
1115    
1116                // If cacheing then first ensure that our cache map exists...
1117                // The cache, if used, maps from main-words component to HTML.
1118                // The HTML text is stored as a compact text string
1119                // from which the text can be recovered using toString().
1120                // The map is thread-safe and size limited.
1121                // Each entry may be quite large.
1122                // We are prepared to discard the cache entirely if memory is stressed.
1123                // Limit maximum cache size in proportion to heap size; strictly positive.
1124                // Aim to allow ~2000 entries per 1GB of heap space (at initialisation of this instance).
1125                // Caches non-trivial text wrapped as AutoExpirable to force rebuild periodically,
1126                // for example because of embedded CDN URLs of possibly limited lifetime validity.
1127                MemoryTools.CacheMiniMap<CS8Bit,Object> cache;
1128                if(CS_CACHE_FOR_CAT_PAGES)
1129                    {
1130                    while(null == (cache = (MemoryTools.CacheMiniMap<CS8Bit,Object>) dataSource.getAEPLinkedValue(_CSCacheKey)))
1131                        { dataSource.putIfAbsentAEPLinkedValue(_CSCacheKey, SimpleLRUMap.<CS8Bit,Object>create(Math.max(1<<5, (int) Math.min(1<<12, Runtime.getRuntime().totalMemory() >> 18)), _CSCacheKey.comment)); }
1132    
1133                    // Note that there is a map from a unique key (not a valid MWLC value)
1134                    // to current GP timestamp and preferred mirror/CDN URL details.
1135                    // If those change then the cache is cleared to avoid having cached egregious embedded URLs.
1136                    // Slightly race-prone, but likely only a couple of broken thumbnails at worst...
1137                    final GenProps genProps = dataSource.getGenProps(-1);
1138                    final Long gpts = Long.valueOf(genProps.timestamp);
1139                    final String hostCDN = MirrorSelectionUtils.chooseMirrorHostForHighBandwidth(null, dataSource);
1140                    final Tuple.Pair<Long, String> ofv = new Tuple.Pair<Long, String>(gpts, hostCDN);
1141                    final Object extantTS = cache.get(OFV_KEY);
1142                    if(!ofv.equals(extantTS))
1143                        {
1144                        if(null != extantTS)
1145                            {
1146    if(IsDebug.isDebug) { dataSource.log("getContactSheetHTML(): gp/CDN changed; clearing cache"); }
1147                            cache.clear();
1148                            }
1149                        cache.put(OFV_KEY, ofv);
1150                        }
1151                    }
1152    
1153                // See if we have the value cached already.
1154                // (A "" value indicates an empty result that need not be recomputed.)
1155                // If not then compute it.
1156                final Object cachedValue;
1157                String result;
1158                CS8Bit key = mainWordsLC; // Key into cache...
1159                if(!CS_CACHE_FOR_CAT_PAGES || (null == (cachedValue = cache.get(key))))
1160                    {
1161                    // This key may be shareable, so intern() it.
1162                    if(CS_CACHE_FOR_CAT_PAGES) { key = MemoryTools.intern(key); }
1163    
1164                    final int ssnLen = smartSortedNames.size();
1165                    // We must use a comparator that matches how the names were sorted...
1166                    final int ourPos = Collections.binarySearch(smartSortedNames, exhibitName, ExhibitAttrUtils.getAttrWords().SMART_ORDER);
1167                    if((ourPos < 0) || (ourPos >= ssnLen))
1168                        { return(""); /* The main exhibit cannot be found; exit gracefully and never cache this result. */ }
1169    
1170                    // Find the first exhibit that matches our main words component (inclusive).
1171                    // (We'll ignore case differences here regardless of how the smart-sort works.)
1172                    int firstMatch = ourPos;
1173                    for(int i = ourPos; --i >= 0; )
1174                        {
1175                        final CharSequence mwc = smartSortedNames.get(i).getShortName().getMainWordsComponent(attrWords);
1176                        if(!TextUtils.contentEqualsIgnoreCase(mwc, mainWordsLC))
1177                            { break; }
1178                        firstMatch = i; // This matched, so move the limit down...
1179                        }
1180    
1181                    // Now find the last matching exhibit (exclusive).
1182                    int lastPos;
1183                    for(lastPos = ourPos+1 ; lastPos < ssnLen; ++lastPos)
1184                        {
1185                        final CharSequence mwc = smartSortedNames.get(lastPos).getShortName().getMainWordsComponent(attrWords);
1186                        if(!TextUtils.contentEqualsIgnoreCase(mwc, mainWordsLC))
1187                            { break; }
1188                        }
1189    
1190                    // Note sublist of matching entries.
1191                    // Take copy if in might be necessary to erase entries...
1192                    final List<Name.ExhibitFull> matchingNames = CS_ALWAYS_ONLY_SHOW_THUMBNAILS ?
1193                        new ArrayList<Name.ExhibitFull>(smartSortedNames.subList(firstMatch, lastPos)) :
1194                        smartSortedNames.subList(firstMatch, lastPos);
1195    
1196                    // If there is only one matching name
1197                    // then it is the main exhibit,
1198                    // and therefore there is no contact sheet to show.
1199                    if(matchingNames.size() <= 1)
1200                        {
1201                        if(CS_CACHE_FOR_CAT_PAGES && CS_CACHE_NEGATIVE_EMPTY_RESULTS) { cache.put(key, ""); }
1202                        return(""); /* No sheet to show. */
1203                        }
1204    
1205                    // If we only want to show in our sheet
1206                    // thumbnails available here and now,
1207                    // then weed any others out of our list.
1208                    if(CS_ALWAYS_ONLY_SHOW_THUMBNAILS)
1209                        {
1210                        for(final Iterator<Name.ExhibitFull> it = matchingNames.iterator(); it.hasNext(); )
1211                            {
1212                            final Name.ExhibitFull ex = it.next();
1213                            // Zap items without an extant thumbnail...
1214                            if(!WebUtils.exhibitHasThumbnail(dataSource, ex, false, false))
1215                                { it.remove(); }
1216                            }
1217    
1218                        // If nothing is left in our list
1219                        // then return an empty sheet immediately.
1220                        if(matchingNames.isEmpty())
1221                            {
1222                            if(CS_CACHE_FOR_CAT_PAGES && CS_CACHE_NEGATIVE_EMPTY_RESULTS) { cache.put(key, ""); }
1223                            return(""); /* No sheet to show. */
1224                            }
1225                        // If the only thing left in our list is our main exhibit
1226                        // then return an empty sheet immediately.
1227                        if((matchingNames.size() == 1) && exhibitName.equals(matchingNames.get(0)))
1228                            {
1229                            if(CS_CACHE_FOR_CAT_PAGES && CS_CACHE_NEGATIVE_EMPTY_RESULTS) { cache.put(key, ""); }
1230                            return(""); /* No sheet to show. */
1231                            }
1232                        }
1233    
1234                    // Target time to stop doing optional computations by;
1235                    // probably short enough to avoid annoying interactive users
1236                    // while giving us the opportunity to get most/all thumbnails
1237                    // in the contact sheet.
1238                    final long stopBy = startTime + WebConsts.MAX_PG_DOWNLOAD_MS/2;
1239    
1240                    // Return our generated HTML fragment...
1241                    result = generateGenericContactSheetHTML(maxPixelsWidth, matchingNames, CS_CACHE_FOR_CAT_PAGES ? null : specialColour, exhibitName, stopBy, dataSource);
1242                    final boolean finished = !result.endsWith(CS_INCOMPLETE);
1243    
1244    if(IsDebug.isDebug && (System.currentTimeMillis() > stopBy)) { dataSource.log("INFO: getContactSheetHTML(): overran computing contact sheet (matching="+matchingNames.size()+", finished="+finished+") for "+exhibitName); }
1245    
1246                    // If we are cacheing and the result is complete,
1247                    // and there's plenty of free memory at the moment,
1248                    // then cache/capture the result, in a compact form if possible.
1249                    if(CS_CACHE_FOR_CAT_PAGES && finished && MemoryTools.lotsFree())
1250                        {
1251                        // There is no point intern()ing this result text
1252                        // since we assume that it will be unique for each key except for trivial cases.
1253                        // Cache the text wrapped as an AutoExpirable with limited lifetime
1254                        // to gradually reclaim unused entries automatically
1255                        // and avoid hanging on to embedded CDN-related URLs too long (they may become invalid).
1256                        Object optionallyCompacted = result;
1257                        try { optionallyCompacted = Compact7BitString.convertToCompact7BitString(result, null); }
1258                        catch(final IllegalArgumentException e)
1259                            {
1260                            /* Not 7-bit text, so fall through to cache as uncompacted String. */
1261    if(IsDebug.isDebug) { dataSource.log("getContactSheetHTML(): COULD NOT COMPACT contact sheet non-7-bit-text for "+exhibitName, e); }
1262                            }
1263                        final Object toCache = optionallyCompacted;
1264                        // Put in cache, wrapped to expire automatically.
1265                        cache.put(key, new MemoryTools.AutoExpirableFixedLifeBase(WebConsts.DEFAULT_PAGE_CACHE_MS << 1)
1266                            { @Override public String toString() { return(toCache.toString()); } });
1267    if(IsDebug.isDebug) { dataSource.log("getContactSheetHTML(): cached result of "+result.length()+" chars for "+exhibitName); }
1268                        }
1269                    }
1270                // Use the (non-null) cached value.
1271                else { result = cachedValue.toString(); }
1272    
1273                return(result);
1274                }
1275    //        catch(final IOException e)
1276    //            {
1277    //            // Error encountered, so give up gracefully.
1278    //            return("");
1279    //            }
1280            }
1281    
1282        /**Request-level attribute set non-null before creating the header to load Hoverbox CSS. */
1283        private static final String REQ_ATTR_NAME_LOAD_HOVERBOX_CCS = "org.hd.pg2k.enableHB";
1284    
1285        /**Call this before generating the header to load the Hoverbox CSS.
1286         * Does nothing if the `Hoverbox' is not enabled.
1287         * <p>
1288         * This is idempotent, and sets a request-level attribute.
1289         */
1290        public static final void enableHoverboxCSS(final HttpServletRequest request)
1291            {
1292            // Do nothing if Hoverbox is not enabled.
1293            if(!WebConsts.SUPPORT_HOVERBOX) { return; }
1294            request.setAttribute(REQ_ATTR_NAME_LOAD_HOVERBOX_CCS, Boolean.TRUE);
1295            }
1296    
1297        /**Returns true if the the Hoverbox CSS has been enabled with enableHoverboxCSS().
1298         * Does nothing if the `Hoverbox' is not enabled.
1299         * <p>
1300         * This checks a request-level attribute.
1301         */
1302        public static final boolean hoverboxCSSIsEnabled(final HttpServletRequest request)
1303            {
1304            // Always false if Hoverbox is not enabled.
1305            if(!WebConsts.SUPPORT_HOVERBOX) { return(false); }
1306            // Check if the attribute has been set non-null.
1307            return(null != request.getAttribute(REQ_ATTR_NAME_LOAD_HOVERBOX_CCS));
1308            }
1309    
1310        /**Unique comment tag at end of HTML for contact sheet if we ran out of time or it is otherwise incomplete. */
1311        public static final String CS_INCOMPLETE = "<!--INCOMPLETE-->";
1312    
1313        /**Maximum that 'Hoverbox' overlay thumbnail may extend outside the table containing small thumbnails, in pixels; strictly positive.
1314         * This should match up with the Hoverbox CSS targets
1315         * and possibly a fudge-factor for various browsers' rendering quirks.
1316         */
1317        private static final int MAX_HOVERBOX_OVERSPILL_PX = 192 - 16;
1318    
1319        /**If padding a lightbox out with a overlay image safety area, do we insert popular links? */
1320        private static final boolean LIGHTBOX_PADDING_POP_LINKS = true;
1321    
1322        /**Allow this many pixels' border in lightbox table; non-negative. */
1323        private static final int LIGHTBOX_TABLE_BORDER_PX = 1;
1324    
1325        /**Generate contact sheet of the exhibits given, in order; never null.
1326         * Has special HTML comment tag at end to indicate if the result
1327         * is incomplete due to missing thumbnails or lack of time.
1328         * <p>
1329         * Note that if using the 'Hoverbox' feature,
1330         * then overlay larger thumbnails may appear above a thumbnail being hovered over,
1331         * so we insert sufficient padding around the thumbnail table
1332         * to avoid conflicts with surrounding page material, such as ads.
1333         *
1334         * @param exhibitNames  in-order list of exhibits to display; never null
1335         * @param specialExhibitName  name of "special" exhibit to emphasise;
1336         *     null if none
1337         * @param stopBy  approximate target time to finish by;
1338         *     can be set in the past to generate HTML as quickly as possible
1339         * @param specialColour  HTML colour name (or #RRGGBB value) to distinguish
1340         *     the main exhibit in the contact sheet somehow,
1341         *     quoted if it need be for an attribute value;
1342         *     null if no such marking to be done
1343         * @param maxPixelsWidth  maximum pixels available for display (approx);
1344         *     strictly positive
1345         * @param dataSource  data source for thumbnails, etc; never null
1346         * @return  HTML fragment for contact sheet ending with CS_INCOMPLETE to show incomplete, or "";
1347         *     never null
1348         */
1349        public static String generateGenericContactSheetHTML(final int maxPixelsWidth,
1350                                                             final List<Name.ExhibitFull> exhibitNames,
1351                                                             final String specialColour,
1352                                                             final Name.ExhibitFull specialExhibitName,
1353                                                             final long stopBy,
1354                                                             final DataSourceBean dataSource)
1355            {
1356            if((maxPixelsWidth <= 0) ||
1357                    (exhibitNames == null) ||
1358                    (dataSource == null))
1359                { throw new IllegalArgumentException(); }
1360    
1361            final int nExhibits = exhibitNames.size();
1362            if(nExhibits == 0)
1363                { return(""); }
1364    
1365            try
1366                {
1367                final AllExhibitProperties aep = dataSource.getAllExhibitProperties(-1);
1368    
1369                // Find out all those thumbnails whose status is unknown in the CDN.
1370                // TODO: we might be able to use CDN-hosted thumbnails even without local copies...
1371                final Set<Name.ExhibitFull> unknownCDNStatus =
1372                    CDNUtils.findTNsWithUnknownCDNStatus(dataSource, exhibitNames, true);
1373    
1374                // Find out what small thumbnails are to hand locally right now,
1375                // possibly forcing their creation.
1376                final BitSet thumbnailExists = WebUtils.exhibitsHaveThumbnail(dataSource, exhibitNames, false, true);
1377                // If supporting 'hoverbox' then we want to know about standard thumbnails too.
1378                // We don't try to force the creation of these...
1379                final BitSet stdThumbnailExists = (!WebConsts.SUPPORT_HOVERBOX) ? null :
1380                    WebUtils.exhibitsHaveThumbnail(dataSource, exhibitNames, true, false);
1381                // Note if this lightbox is 'hoverable', ie if using the 'Hoverbox' feature
1382                // and if any thumbnails are capable of being hovered over
1383                // (ie both small and std sizes are available for any one exhibit).
1384                final boolean hoverable = WebConsts.SUPPORT_HOVERBOX && thumbnailExists.intersects(stdThumbnailExists);
1385    
1386                // Allow for Hoverbox 'overspill' padding around the lightbox if necessary.
1387                final int innerMaxPixelsWidth = (!hoverable) ? maxPixelsWidth :
1388                    (maxPixelsWidth - MAX_HOVERBOX_OVERSPILL_PX);
1389    
1390                // Calculate how many columns of thumbnails we may be able to show in this lightbox.
1391                // We assume that each thumbnail may be its maximum size in both dimensions.
1392                // We always show at least one column.
1393                final int tnSize = ExhibitThumbnails.SML_STATIC_IMAGE_TN_LDIM_PX;
1394                final int colWidth = (tnSize + 2*LIGHTBOX_TABLE_BORDER_PX);
1395                // Try to use an even number of columns if possible.
1396                final int maxCols = Math.max(1, (innerMaxPixelsWidth / colWidth) & ~1);
1397                final int actualCols = Math.min(maxCols, nExhibits);
1398                final int actualRows = (nExhibits + (maxCols-1)) / maxCols;
1399    
1400                final int _capacity = 256 + (nExhibits * 1024);
1401                // Generate the HTML as a table.
1402                final StringBuilder result = new StringBuilder(_capacity);
1403                boolean firstRow = true; // Are we producing the first row?
1404                int columnNumber = 0; // Which column are we about to fill?
1405    
1406                // Wrap a safety buffer zone around the lightbox if hoverable.
1407                if(hoverable)
1408                    {
1409                    // Filler text to put in the side safety zone, if large enough...
1410                    // But don't spend ages doing this.
1411                    final String popularLinks = ((!LIGHTBOX_PADDING_POP_LINKS) || (actualRows <= 2)) ? "" :
1412                        popularUsefulLinksNarrowCol(dataSource,
1413                            (actualRows-1) * tnSize, // Slightly conservative height...
1414                            3*(MAX_HOVERBOX_OVERSPILL_PX/4), // Slightly conservative width...
1415                            false, // Link to HTML catalogue pages.
1416                            stopBy);
1417                    // If the lightbox is not tall enough or we can't generate any popular links
1418                    // then use a default filler text.
1419                    // Attempt to have the links ignored for ad targeting, etc...
1420                    final String fillerText = popularLinks.isEmpty() ? "&gt;&gt;&gt;" :
1421                        ("<!-- google_ad_section_start(weight=ignore) -->" +
1422                         "<big><strong>+</strong></big><br />" +
1423                         popularLinks +
1424                         "<!-- google_ad_section_end -->");
1425    
1426                    result.append("<table border=0 cellpadding=0>").
1427                        append("<tr align=center><th width=").append(MAX_HOVERBOX_OVERSPILL_PX).
1428                            append(" height=").append(MAX_HOVERBOX_OVERSPILL_PX).
1429                            append("><big>light box</big></th><td>(Hover over thumbnail for enlarged view.)</td></tr>").
1430                        append("<tr align=center><td>").append(fillerText).append("</td><td>");
1431                    }
1432    
1433                // Start the table...
1434                // We omit quotes around attribute values to save as much bandwidth as possible.
1435                result.append("<table cellpadding=0 border=").append(LIGHTBOX_TABLE_BORDER_PX).
1436                    // Try to help the browser to lay out the table quickly...
1437                    append(" cols=").append(actualCols).
1438                    append(" width=").append(actualCols * colWidth);
1439                if(hoverable) { result.append(" class=hoverbox"); }
1440                result.append('>');
1441    
1442                // Start the first row.
1443                final String rowStartHTML = "<tr height="+tnSize+" align=center>";
1444                result.append(rowStartHTML);
1445    
1446                final SortedSet<String> attrWords = ExhibitAttrUtils.getAttrWords().getAttrWordsSortedSet();
1447    
1448                // Set true if we ran out of time
1449                // or couldn't get a thumbnail that should be available.
1450                boolean incomplete = false;
1451    
1452                // If more than a very small fraction have unknown statuses
1453                // then treat the generated HTML as incomplete and not to be cached.
1454                // At this point attempt discovery of any with unknown status.
1455                // (We don't want be be forcing browsers to load the same thumbnails
1456                // from CDN in one place and this server in another.)
1457                if(unknownCDNStatus.size() > (exhibitNames.size() >> 4))
1458                    { incomplete = true; }
1459    
1460                boolean reduceEffort = false;
1461    
1462                for(int i = 0; i < nExhibits; ++i)
1463                    {
1464                    final Name.ExhibitFull ex = exhibitNames.get(i);
1465    
1466                    // If we have run off the end of this row,
1467                    // then finish this row and start a new one,
1468                    // and note that we are not on the first row any more.
1469                    if(columnNumber++ >= maxCols)
1470                        {
1471                        result.append("</tr>").append(rowStartHTML);
1472                        columnNumber = 1; // Note that we are just about to fill column 0 of the new row below...
1473                        firstRow = false;
1474                        }
1475    
1476                    // Make this cell with a link to the target catalogue page.
1477                    // On the first row only, force the width too.
1478                    // Mark the main exhibit with a special background colour if so requested.
1479                    result.append("<td");
1480                    if(firstRow)
1481                        { result.append(" width=").append(tnSize); }
1482                    if((specialColour != null) && (ex.equals(specialExhibitName)))
1483                        { result.append(" bgcolor=").append(specialColour); }
1484                    result.append('>');
1485    
1486                    // Extract part of name that distinguishes this exhibit from its peers.
1487                    final int bp[] = ExhibitName.getMainAndAttrWordComponentBoundaries(ex, attrWords);
1488                    // Make unique-ish summary with first word and tail attributes...
1489                    final String uniquePart = ExhibitName.getMainWords(ex, attrWords).nextElement() + " ~ " + ex.subSequence(bp[1]+1, ex.length());
1490                    // Make unique-ish bit that is easier to word-wrap.
1491                    final String uWW = uniquePart.replace(ExhibitName.WORD_SEP, ' ');
1492    
1493                    // Have we run out of time and need to reduce effort spent?
1494                    // Once true, stays true for this call.
1495                    if(!reduceEffort)
1496                        { reduceEffort = System.currentTimeMillis() >= stopBy; }
1497    
1498                    // Compute the relative URL to the catalogue page; never null.
1499                    final String catPage = WebUtils.makeCatPageRRURL(ex,
1500                                            WebConsts.F_secondary_generated_HTML_suffix);
1501    
1502                    // Try and create HTML for an extant thumbnail,
1503                    // which must be an HTML inlineable image/object.
1504                    final java.awt.Dimension thumbnailXyDim = new java.awt.Dimension();
1505                    final String thumbnailURL = (!thumbnailExists.get(i)) ? null :
1506                        WebUtils.makeHTMLInlineImageThumbnailURL(
1507                            dataSource,
1508                            ex,
1509                            false, // Get a small thumbnail.
1510                            false, // Allow CDN URL.
1511                            thumbnailXyDim,
1512                            reduceEffort);
1513    
1514                    // If we have a thumbnail then insert it, wrapped as a link.
1515                    if(thumbnailURL != null)
1516                        {
1517                        // If doing 'Hoverbox' then we need an 'li' CSS positioning anchor.
1518                        if(hoverable) { result.append("<ul><li>"); }
1519                        final String quotedAltText = UploaderUtils.quoteHTMLArg(uWW);
1520                        result.append("<a href=\"").append(catPage).append("\">").
1521                            // Insert small thumbnail...
1522                            append("<img src=\"").append(thumbnailURL).append('\"').
1523                                append(" border=0").
1524                                append(" width=").append(thumbnailXyDim.width).
1525                                append(" height=").append(thumbnailXyDim.height).
1526                                append(" alt=").append(quotedAltText).
1527                                append(" title=").append(quotedAltText).
1528                                append('>');
1529                        // If we are supporting Hoverbox AND we have a standard thumbnail
1530                        // then put it here with class "preview" to be undisplayed until hovered over.
1531                        if(hoverable && stdThumbnailExists.get(i))
1532                            {
1533                            final java.awt.Dimension thumbnailStdXyDim = new java.awt.Dimension();
1534                            final String thumbnailStdRRURL =
1535                                WebUtils.makeHTMLInlineImageThumbnailURL(
1536                                    dataSource,
1537                                    ex,
1538                                    true, // Get a std thumbnail.
1539                                    false, // Allow CDN URL.
1540                                    thumbnailStdXyDim,
1541                                    true);
1542                            if(thumbnailStdRRURL != null)
1543                                {
1544                                result.append("<img src=\"").append(thumbnailStdRRURL).append('\"').
1545                                    append(" class=preview").
1546                                    append(" border=2").
1547                                    append(" width=").append(thumbnailStdXyDim.width).
1548                                    append(" height=").append(thumbnailStdXyDim.height).
1549                                    append(" alt=").append(quotedAltText).
1550                                    append(" title=").append(quotedAltText).
1551                                    append('>');
1552                                }
1553                            }
1554                        result.append("</a>");
1555                        if(hoverable) { result.append("</li></ul>"); }
1556                        }
1557                    // Show some summary info in lieu of a thumbnail.
1558                    else
1559                        {
1560                        // Get this exhibit's properties; never null.
1561                        final ExhibitStaticAttr esa = aep.aeid.getStaticAttr(ex);
1562    
1563                        // Get the exhibit type...
1564                        final ExhibitMIME.ExhibitTypeParameters exhibitType =
1565                                (esa == null) ? null : (ExhibitMIME.getInputFileType(esa.getCharSequence()));
1566    
1567                        result.append("<small>");
1568    
1569                        if(exhibitType != null)
1570                            { result.append(exhibitType.description).append("<br />"); }
1571    
1572                        result.append("<a href=\"").append(catPage).append("\"><i>");
1573                        result.append(uWW); // Make word-wrap possible on (eg) FF1.
1574                        result.append("</i></a>");
1575                        result.append("</small>");
1576    
1577                        // If this apparently *should* have had a thumbnail
1578                        // then mark as our result as incomplete.
1579                        if((exhibitType != null) && (exhibitType.canPossiblyCreateThumbnailOfSameMIMEType()))
1580                            { incomplete = true; }
1581                        }
1582    
1583                    result.append("</td>");
1584                    }
1585    
1586                // End the last row...
1587                result.append("</tr>");
1588    
1589                // End the table...
1590                result.append("</table>");
1591    
1592                // End the wrapper table for Hoverbox, if necessary.
1593                if(hoverable) { result.append("</td></tr></table>"); }
1594    
1595                // If we had to reduce effort (and thus not compute the best-possible result)
1596                // then mark the result as incomplete so that we can try again.
1597                if(incomplete)
1598                    { result.append(CS_INCOMPLETE); }
1599    
1600    if(IsDebug.isDebug && (result.length() > _capacity)) { System.err.println("WARNING: generateGenericContactSheetHTML(): _capacity="+_capacity+" length()="+result.length()+" for exhibit count="+nExhibits); }
1601    
1602                return(result.toString());
1603                }
1604            catch(final Exception e)
1605                {
1606                return(""); // In case of error return an empty result.
1607                }
1608            }
1609    
1610        /**Generate a narrow column of popular/useful internal links for HTML/XHTML, "" if none; never null.
1611         * Puts a default reasonable cap on generation time
1612         * as a fraction of maximum page-generation time.
1613         * <p>
1614         * May cache/reuse these results for identical size/format parameter for a while for speed.
1615         *
1616         * @param dsb  data source; never null
1617         * @param pixelsHigh  approx number of pixels high; strictly positive
1618         * @param pixelsWide  approx number of pixels wide; strictly positive
1619         * @param toXHTML  if true then link to XHTML catalogue pages rather than HTML pages
1620         */
1621        public static String popularUsefulLinksNarrowCol(final DataSourceBean dsb,
1622                                                         final int pixelsHigh,
1623                                                         final int pixelsWide,
1624                                                         final boolean toXHTML)
1625            {
1626            return(popularUsefulLinksNarrowCol(dsb, pixelsHigh, pixelsWide, toXHTML,
1627                    System.currentTimeMillis() + 1 + (WebConsts.MAX_PG_DOWNLOAD_MS>>4)));
1628            }
1629    
1630        /**Generate a narrow column of popular/useful internal links for HTML/XHTML, "" if none; never null.
1631         * TODO: cache/reuse these results for identical size/format parameter for a while for speed.
1632         *
1633         * @param dsb  data source; never null
1634         * @param pixelsHigh  approx number of pixels high; strictly positive
1635         * @param pixelsWide  approx number of pixels wide; strictly positive
1636         * @param toXHTML  if true then link to XHTML catalogue pages rather than HTML pages
1637         * @param stopBy  try to stop by the specified time.
1638         */
1639        public static String popularUsefulLinksNarrowCol(final DataSourceBean dsb,
1640                                                         final int pixelsHigh,
1641                                                         final int pixelsWide,
1642                                                         final boolean toXHTML,
1643                                                         final long stopBy)
1644            {
1645            if((dsb == null) ||
1646               (pixelsHigh <= 0) || (pixelsWide <= 0))
1647                { throw new IllegalArgumentException(); }
1648    
1649            try
1650                {
1651                final AllExhibitProperties aep = dsb.getAllExhibitProperties(-1);
1652                final GenProps gp = dsb.getGenProps(-1);
1653    
1654                final int maxLinks = pixelsHigh / COLDISP_MEAN_CHAR_HEIGHT_PIXELS;
1655                final int maxChars = pixelsWide / COLDISP_MEAN_CHAR_WIDTH_PIXELS;
1656    
1657                // If no space to show anything then return...
1658                if((maxLinks < 1) || (maxChars < 1)) { return(""); }
1659    
1660                // Collect map from sorted clipped link text to (full) names of exhibits
1661                // to link to catalogue pages for.
1662                // These are kept sorted in a reasonable order.
1663                final SortedMap<String, Name.ExhibitFull> exhibits = new TreeMap<String, Name.ExhibitFull>(String.CASE_INSENSITIVE_ORDER);
1664    
1665                // Periods/intervals' data that we will use, in order.
1666                final long currentInterval = EventPeriod.VLONG.getIntervalNumber(System.currentTimeMillis());
1667                final long periods[] =
1668                    {
1669                    currentInterval-1, // Previous period (cheap, stable, reasonably topical).
1670                    currentInterval, // Current period (more topical, more expensive, more noisy).
1671                    0, // "All" period (less topical, more expensive, more comprehensive).
1672                    };
1673    
1674                // System events that we will use, in order.
1675                // Randomise these a little each time.
1676                final SimpleVariableDefinition defs[] =
1677                    {
1678                            (Rnd.fastRnd.nextBoolean() ?
1679                            SystemVariables.ACCESSPATTERN_CLICKTHROUGH : // Usefulness to users and possible profit to us!
1680                            SystemVariables.ACCESSPATTERN_COMPLETED_DOWNLOAD_LOCAL), // Local popularity.
1681                    (Rnd.fastRnd.nextBoolean() ?
1682                        SystemVariables.ACCESSPATTERN_COMPLETED_DOWNLOAD : // Pure popularity.
1683                        SystemVariables.ACCESSPATTERN_CAT_PAGE_VIEW), // Pure popularity.
1684                    };
1685    
1686                // Pick items from further down the lists as we go...
1687                // Only to show the best item for a given displayed text/link...
1688                topLoop: for(int rank = 0; rank < maxLinks; ++rank)
1689                    {
1690                    for(int p = 0; p < periods.length; ++p)
1691                        {
1692                        for(final SimpleVariableDefinition def : defs)
1693                            {
1694                            // Once we have enough links then stop collecting.
1695                            if(exhibits.size() >= maxLinks) { break topLoop; }
1696                            // If we've taken too long then stop collecting.
1697                            if(System.currentTimeMillis() > stopBy) { break topLoop; }
1698    
1699                            final EventVariableValue[] eventValues = dsb.getEventValues(def, EventPeriod.VLONG, periods[p], null);
1700                            if((eventValues.length == 0) || (eventValues[0] == null)) { continue; }
1701                            final EventVariableValue eventValue = eventValues[0];
1702                            if(rank >= eventValue.getTotalDistinctValues()) { continue; }
1703    
1704                            // Only use the exhibit if it seems (still) to be valid.
1705                            final Object exhibitShortName = eventValue.getValueByRank(rank);
1706                            if(!(exhibitShortName instanceof String)) { continue; }
1707                            final String sn = (String) exhibitShortName;
1708                            // Skip potentially-sensitive subjects.
1709                            if(GenUtils.isSensitive(sn, gp)) { continue; }
1710                            // Extract the full name.
1711                            final Name.ExhibitFull fullName = aep.aeid.getFullName(sn);
1712                            if(fullName != null)
1713                                {
1714                                final String linkText = TextUtils.sanitiseForXML(sn, maxChars, false);
1715    
1716                                // If we have a different extant item with this link text
1717                                // then keep that with the better score.
1718                                // Do NOT force an expensive recomputation of either score.
1719                                final Name.ExhibitFull extant = exhibits.get(linkText);
1720                                if((extant != null) && !extant.equals(fullName))
1721                                    {
1722                                    try
1723                                        {
1724                                        if(aep.getExhibitPropsComputableMutable(extant, true, gp, dsb, dsb.getScorerCache()).getGoodness() >=
1725                                           aep.getExhibitPropsComputableMutable(fullName, true, gp, dsb, dsb.getScorerCache()).getGoodness())
1726                                            { continue; /* New exhibit is no better than extant. */ }
1727                                        }
1728                                    // Allow for transients/races causing NPEs, etc.
1729                                    catch(final Exception e)
1730                                        { continue; /* In case of error keep extant value. */ }
1731                                    }
1732    
1733                                // Use the new value.
1734                                exhibits.put(linkText, fullName);
1735                                }
1736                            }
1737                        }
1738                    }
1739    
1740                if(exhibits.size() == 0) { return(""); } // Nothing to show...
1741    
1742                // Generate the HTML...
1743                final StringBuilder sb = new StringBuilder(32 + exhibits.size()*4*maxChars);
1744                sb.append("<small>");
1745                for(final String linkText : exhibits.keySet())
1746                    {
1747                    final Name.ExhibitFull fullName = exhibits.get(linkText);
1748    
1749                    // Should be a currently-extant exhibit.
1750                    assert(aep.aeid.isPresent(fullName));
1751    
1752                    // Compute the relative URL to the catalogue page; never null.
1753                    final String catPage = WebUtils.makeCatPageRRURL(fullName,
1754                                    toXHTML ? WebConsts.F_secondary_generated_XHTML_suffix : WebConsts.F_secondary_generated_HTML_suffix);
1755    
1756                    // Link to the catalogue page.
1757                    sb.append("<a href=\"").
1758                        append(catPage).
1759                        append("\">").
1760                        append(linkText).
1761                        append("</a><br />");
1762                    }
1763                sb.append("</small>");
1764                return(sb.toString());
1765                }
1766            catch(final Exception e)
1767                {
1768                e.printStackTrace();
1769                return(""); // Give up quietly in case of error.
1770                }
1771            }
1772        }