001/*
002 * flattr4j - A Java library for Flattr
003 *
004 * Copyright (C) 2011 Richard "Shred" Körber
005 *   http://flattr4j.shredzone.org
006 *
007 * This program is free software: you can redistribute it and/or modify
008 * it under the terms of the GNU General Public License / GNU Lesser
009 * General Public License as published by the Free Software Foundation,
010 * either version 3 of the License, or (at your option) any later version.
011 *
012 * Licensed under the Apache License, Version 2.0 (the "License");
013 * you may not use this file except in compliance with the License.
014 *
015 * This program is distributed in the hope that it will be useful,
016 * but WITHOUT ANY WARRANTY; without even the implied warranty of
017 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
018 */
019package org.shredzone.flattr4j.web.builder;
020
021import java.io.Serializable;
022import java.net.URL;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.Map;
026import java.util.TreeMap;
027
028import org.shredzone.flattr4j.model.Category;
029import org.shredzone.flattr4j.model.CategoryId;
030import org.shredzone.flattr4j.model.Language;
031import org.shredzone.flattr4j.model.LanguageId;
032import org.shredzone.flattr4j.model.Submission;
033import org.shredzone.flattr4j.model.Thing;
034import org.shredzone.flattr4j.model.UserId;
035import org.shredzone.flattr4j.web.ButtonType;
036
037/**
038 * Builds a Flattr button tag.
039 * <p>
040 * The builder uses sensible default settings that can be changed by using its methods.
041 * All methods return a reference to the builder itself, so method calls can be
042 * daisy-chained.
043 * <p>
044 * Example: <code>String button = new ButtonBuilder().url(myUrl).toString();</code>
045 *
046 * @author Richard "Shred" Körber
047 */
048public class ButtonBuilder implements Serializable {
049    private static final long serialVersionUID = -3066039824282852155L;
050
051    private static final int MIN_TITLE_LENGTH = 5;
052    private static final int MAX_TITLE_LENGTH = 100;
053
054    private static final int MIN_DESCRIPTION_LENGTH = 5;
055    private static final int MAX_DESCRIPTION_LENGTH = 1000;
056
057    private String url;
058    private String uid;
059    private String title;
060    private String description;
061    private String category;
062    private String language;
063    private ArrayList<String> tags = new ArrayList<String>();
064    private String revsharekey;
065    private ButtonType type;
066    private boolean hidden = false;
067    private Boolean popout = null;
068    private String style;
069    private String styleClass;
070    private boolean html5 = false;
071    private String prefix = "data-flattr";
072    private TreeMap<String, String> attributes = new TreeMap<String, String>();
073
074    /**
075     * Unique URL to the thing. Always required!
076     */
077    public ButtonBuilder url(String url) {
078        this.url = url;
079        return this;
080    }
081
082    /**
083     * Unique URL to the thing. Always required! This is a convenience method that accepts
084     * {@link URL} objects.
085     */
086    public ButtonBuilder url(URL url) {
087        return url(url.toString());
088    }
089
090    /**
091     * User who published the thing. Required for autosubmit. Optional if the thing is
092     * already published at Flattr.
093     */
094    public ButtonBuilder user(UserId user) {
095        this.uid = user.getUserId();
096        return this;
097    }
098
099    /**
100     * Title of the thing. Required for autosubmit. If a title is set, it must currently
101     * be between 5 and 100 characters long, and must not contain HTML.
102     */
103    public ButtonBuilder title(String title) {
104        if (title.length() < MIN_TITLE_LENGTH) {
105            throw new IllegalArgumentException("title must have at least "
106                + MIN_TITLE_LENGTH + " characters.");
107        }
108        if (title.length() > MAX_TITLE_LENGTH) {
109            throw new IllegalArgumentException("title must not exceed "
110                + MAX_TITLE_LENGTH + " characters.");
111        }
112        this.title = title;
113        return this;
114    }
115
116    /**
117     * Description of the thing. Required for autosubmit. If a description is set, it must
118     * currently be between 5 and 1000 characters long, and must not contain HTML (except
119     * of &lt;br&gt; which is converted to newline).
120     */
121    public ButtonBuilder description(String description) {
122        if (description.length() < MIN_DESCRIPTION_LENGTH) {
123            throw new IllegalArgumentException("description must have at least "
124                + MIN_DESCRIPTION_LENGTH + " characters.");
125        }
126        if (description.length() > MAX_DESCRIPTION_LENGTH) {
127            throw new IllegalArgumentException("description must not exceed "
128                + MAX_DESCRIPTION_LENGTH + " characters.");
129        }
130        this.description = description;
131        return this;
132    }
133
134    /**
135     * Convenience method that automatically truncates the description to its maximum
136     * accepted length.
137     */
138    public ButtonBuilder descriptionTruncate(String description) {
139        String desc = description;
140        if (desc.length() > MAX_DESCRIPTION_LENGTH) {
141            desc = desc.substring(0, desc.length());
142        }
143        return description(desc);
144    }
145
146    /**
147     * The category the thing is categorized with. Required for autosubmit.
148     */
149    public ButtonBuilder category(CategoryId category) {
150        this.category = category.getCategoryId();
151        return this;
152    }
153
154    /**
155     * Specifies the language the thing is published in. Optional.
156     */
157    public ButtonBuilder language(LanguageId language) {
158        this.language = language.getLanguageId();
159        return this;
160    }
161
162    /**
163     * A tag that further describes the thing. Optional. Tags must not contain ',', ';'
164     * and other non-word characters.
165     */
166    public ButtonBuilder tag(String tag) {
167        if (tag.indexOf(',') >= 0 || tag.indexOf(';') >= 0) {
168            throw new IllegalArgumentException("illegal character in tag \"" + tag + '"');
169        }
170        tags.add(tag);
171        return this;
172    }
173
174    /**
175     * Convenience method that adds a collection of tags.
176     */
177    public ButtonBuilder tags(Collection<String> tags) {
178        for (String tag : tags) {
179            tag(tag);
180        }
181        return this;
182    }
183
184    /**
185     * Sets the revenue share key to be used.
186     *
187     * @since 2.5
188     */
189    public ButtonBuilder revsharekey(String key) {
190        this.revsharekey = key;
191        return this;
192    }
193
194    /**
195     * Selects the button type to be used. Optional, defaults to the default button.
196     */
197    public ButtonBuilder button(ButtonType type) {
198        this.type = type;
199        return this;
200    }
201
202    /**
203     * Sets whether to override the popout default and show a popout when hovering with
204     * the mouse over the button.
205     *
206     * @param popout
207     *            {@code true}: always show a popout, {@code false}: never show a popout
208     * @since 2.2
209     */
210    public ButtonBuilder popout(boolean popout) {
211        this.popout = popout;
212        return this;
213    }
214
215    /**
216     * The thing shall not be listed at Flattr. Only used on autosubmit.
217     */
218    public ButtonBuilder hidden() {
219        this.hidden = true;
220        return this;
221    }
222
223    /**
224     * Initializes the builder based on the given {@link Thing}. This is a
225     * convenience method to prepare a link to an existing Thing.
226     */
227    public ButtonBuilder thing(Thing thing) {
228        url(thing.getUrl());
229        title(thing.getTitle());
230        description(thing.getDescription());
231        category(Category.withId(thing.getCategoryId()));
232        language(Language.withId(thing.getLanguageId()));
233        tags(thing.getTags());
234        if (thing.isHidden()) hidden();
235        return this;
236    }
237
238    /**
239     * Initializes the builder based on the given {@link Submission}. This is a
240     * convenience method to prepare an autosubmit of a thing. Invoke
241     * {@link #user(UserId)} together with this method for a successful autosubmission.
242     */
243    public ButtonBuilder thing(Submission thing) {
244        url(thing.getUrl());
245        title(thing.getTitle());
246        description(thing.getDescription());
247        category(thing.getCategory());
248        language(thing.getLanguage());
249        tags(thing.getTags());
250        if (thing.isHidden()) hidden();
251        return this;
252    }
253
254    /**
255     * CSS style to be used.
256     */
257    public ButtonBuilder style(String style) {
258        this.style = style;
259        return this;
260    }
261
262    /**
263     * CSS class to be used. The class "FlattrButton" is always used.
264     */
265    public ButtonBuilder styleClass(String styleClass) {
266        this.styleClass = styleClass;
267        return this;
268    }
269
270    /**
271     * Adds a custom HTML attribute to the generated link tag. If an attribute has already
272     * been added, its value will be replaced. Attributes are written to the tag in
273     * alphabetical order.
274     * <p>
275     * Attributes are added without further checks. It is your responsibility to take care
276     * for HTML compliance.
277     *
278     * @param attribute
279     *            HTML attribute to be added
280     * @param value
281     *            Value of that attribute. The builder takes care for proper HTML
282     *            escaping.
283     */
284    public ButtonBuilder attribute(String attribute, String value) {
285        String check = attribute.trim().toLowerCase();
286        if ("class".equals(check) || "style".equals(check) || "title".equals(check)
287            || "rel".equals(check) || "href".equals(check) || "lang".equals(check)
288            || check.startsWith(prefix)) {
289            throw new IllegalArgumentException("attribute \"" + check + "\" is reserved");
290        }
291
292        this.attributes.put(attribute, value);
293        return this;
294    }
295
296    /**
297     * Generate a HTML5 compliant button.
298     */
299    public ButtonBuilder html5() {
300        this.html5 = true;
301        return this;
302    }
303
304    /**
305     * Sets a HTML5 key prefix. By default "data-flattr" is used. The setting is only used
306     * in HTML5 mode.
307     *
308     * @param prefix
309     *            HTML5 key prefix. The string must start with "data-"
310     */
311    public ButtonBuilder prefix(String prefix) {
312        if (!prefix.startsWith("data-")) {
313            throw new IllegalArgumentException("prefix must start with \"data-\"");
314        }
315        this.prefix = prefix;
316        return this;
317    }
318
319    /**
320     * Builds a button of the current setup.
321     */
322    @Override
323    public String toString() {
324        if (url == null) {
325            throw new IllegalStateException("url is required, but missing");
326        }
327
328        StringBuilder sb = new StringBuilder();
329        sb.append("<a");
330
331        sb.append(" class=\"FlattrButton");
332        if (styleClass != null) {
333            sb.append(' ').append(escape(styleClass));
334        }
335        sb.append('"');
336
337        if (style != null) {
338            sb.append(" style=\"").append(escape(style)).append('"');
339        }
340
341        sb.append(" href=\"").append(escape(url)).append('"');
342
343        if (title != null) {
344            sb.append(" title=\"").append(escape(title)).append('"');
345        }
346
347        if (language != null) {
348            sb.append(" lang=\"").append(escape(language)).append('"');
349        }
350
351        if (html5) {
352            appendHtml5(sb);
353        } else {
354            appendAttributes(sb);
355        }
356
357        if (!attributes.isEmpty()) {
358            for (Map.Entry<String, String> entry : attributes.entrySet()) {
359                sb.append(' ').append(entry.getKey()).append("=\"");
360                sb.append(escape(entry.getValue()));
361                sb.append('"');
362            }
363        }
364
365        sb.append('>');
366
367        if (description != null) {
368            sb.append(description);
369        }
370
371        sb.append("</a>");
372
373        return sb.toString();
374    }
375
376    /**
377     * Appends thing attributes to the {@link StringBuilder}.
378     *
379     * @param sb
380     *            {@link StringBuilder} to append the attributes to
381     */
382    private void appendAttributes(StringBuilder sb) {
383        boolean header = false;
384
385        if (uid != null) {
386            appendAttributesHeader(sb);
387            header = true;
388            sb.append("uid:").append(escape(uid)).append(';');
389        }
390
391        if (category != null) {
392            if (!header) appendAttributesHeader(sb);
393            header = true;
394            sb.append("category:").append(escape(category)).append(';');
395        }
396
397        if (!tags.isEmpty()) {
398            if (!header) appendAttributesHeader(sb);
399            header = true;
400            sb.append("tags:");
401            appendTagList(sb);
402            sb.append(';');
403        }
404
405        if (revsharekey != null) {
406            if (!header) appendAttributesHeader(sb);
407            header = true;
408            sb.append("revsharekey:").append(escape(revsharekey)).append(';');
409        }
410
411        if (type != null && type == ButtonType.COMPACT) {
412            if (!header) appendAttributesHeader(sb);
413            header = true;
414            sb.append("button:compact;");
415        }
416
417        if (popout != null) {
418            if (!header) appendAttributesHeader(sb);
419            header = true;
420            sb.append("popout:").append(popout.booleanValue() ? 1 : 0).append(';');
421        }
422
423        if (hidden) {
424            if (!header) appendAttributesHeader(sb);
425            header = true;
426            sb.append("hidden:1;");
427        }
428
429        if (header) {
430            sb.append('"');
431        }
432    }
433
434    /**
435     * Appends the header for non-http5 attributes.
436     *
437     * @param sb
438     *            {@link StringBuilder} to append the attributes to
439     */
440    private void appendAttributesHeader(StringBuilder sb) {
441        sb.append(" rel=\"flattr;");
442    }
443
444    /**
445     * Appends thing attributes as HTML 5 attributes {@link StringBuilder}.
446     *
447     * @param sb
448     *            {@link StringBuilder} to append the attributes to
449     */
450    private void appendHtml5(StringBuilder sb) {
451        if (uid != null) {
452            appendHtml5Attribute(sb, "uid", uid);
453        }
454
455        if (category != null) {
456            appendHtml5Attribute(sb, "category", category);
457        }
458
459        if (!tags.isEmpty()) {
460            sb.append(' ').append(prefix).append("-tags=\"");
461            appendTagList(sb);
462            sb.append('"');
463        }
464
465        if (revsharekey != null) {
466            appendHtml5Attribute(sb, "revsharekey", revsharekey);
467        }
468
469        if (type != null && type == ButtonType.COMPACT) {
470            appendHtml5Attribute(sb, "button", "compact");
471        }
472
473        if (popout != null) {
474            appendHtml5Attribute(sb, "popout", popout.booleanValue() ? "1" : "0");
475        }
476
477        if (hidden) {
478            appendHtml5Attribute(sb, "hidden", "1");
479        }
480    }
481
482    /**
483     * Appends a single HTML5 attribute.
484     *
485     * @param sb
486     *            {@link StringBuilder} to append the attribute to
487     * @param key
488     *            Attribute key
489     * @param value
490     *            Value (unescaped)
491     */
492    private void appendHtml5Attribute(StringBuilder sb, String key, String value) {
493        sb.append(' ').append(prefix).append('-').append(key).append("=\"");
494        sb.append(escape(value)).append('"');
495    }
496
497    /**
498     * Appends a list of all tags, separated by comma.
499     *
500     * @param sb
501     *            {@link StringBuilder} to append the tags to
502     */
503    private void appendTagList(StringBuilder sb) {
504        boolean needsSeparator = false;
505        for (String tag : tags) {
506            if (needsSeparator) sb.append(',');
507            sb.append(escape(tag));
508            needsSeparator = true;
509        }
510    }
511
512    /**
513     * Escapes a string for use in HTML attributes.
514     *
515     * @param str
516     *            Attribute to be escaped
517     * @return Escaped attribute
518     */
519    private String escape(String str) {
520        return str.replace("&", "&amp;").replace("<", "&lt;").replace("\"", "&quot;");
521    }
522
523}