View Javadoc

1   /***
2    *     Ambient - A music player for the Android platform
3    Copyright (C) 2007 Martin Vysny
4    
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU General Public License as published by
7    the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9    
10   This program is distributed in the hope that it will be useful,
11   but WITHOUT ANY WARRANTY; without even the implied warranty of
12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   GNU General Public License for more details.
14  
15   You should have received a copy of the GNU General Public License
16   along with this program.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  package sk.baka.ambient.commons;
19  
20  import java.text.CharacterIterator;
21  import java.text.ParseException;
22  import java.text.StringCharacterIterator;
23  import java.util.ArrayList;
24  import java.util.Arrays;
25  import java.util.Iterator;
26  import java.util.List;
27  import java.util.StringTokenizer;
28  
29  import sk.baka.ambient.collection.TrackMetadataBean;
30  
31  /***
32   * <p>
33   * Formats tags to a string. The following tags are supported:
34   * </p>
35   * <ul>
36   * <li>
37   * <li>%title - {@link TrackMetadataBean#getDisplayableName() the title}</li>
38   * <li>%album - {@link TrackMetadataBean#getAlbum() album name}</li>
39   * <li>%artist - {@link TrackMetadataBean#getArtist() the artist}</li>
40   * <li>%genre - {@link TrackMetadataBean#getGenre() the genre}</li>
41   * <li>%bitrate - {@link TrackMetadataBean#getBitrate() bitrate} in kbps
42   * (without the "kbps" string)</li>
43   * <li>%year - {@link TrackMetadataBean#getYearReleased() year}</li>
44   * <li>%length - {@link TrackMetadataBean#getLength() length} in the h:mm:ss
45   * format</li>
46   * <li>%track - {@link TrackMetadataBean#getTrackNumber() track number}</li>
47   * </ul>
48   * <p>
49   * If you surround a portion of text that contains a token (or a group) with
50   * curly braces, that section will be hidden if all contained tokens and/or
51   * groups are empty.
52   * </p>
53   * <p>
54   * This object is not thread-safe.
55   * </p>
56   * 
57   * @author Martin Vysny
58   */
59  public final class TagFormatter {
60  
61  	/***
62  	 * The format string.
63  	 */
64  	public final String formatString;
65  
66  	/***
67  	 * Creates new formatter and compiles the format string.
68  	 * 
69  	 * @param formatString
70  	 *            the string
71  	 * @throws ParseException
72  	 *             if we failed to parse the format string.
73  	 */
74  	public TagFormatter(final String formatString) throws ParseException {
75  		this(formatString, tokenizeString(formatString).iterator(), false,
76  				null, -1);
77  	}
78  
79  	/***
80  	 * Child formatters.
81  	 */
82  	private final List<TagFormatter> childFormatters = new ArrayList<TagFormatter>();
83  
84  	/***
85  	 * All supported tags.
86  	 */
87  	private static final List<String> tags = new ArrayList<String>(Arrays
88  			.asList(new String[] { "%title", "%album", "%artist", "%genre",
89  					"%bitrate", "%year", "%length", "%track" }));
90  
91  	/***
92  	 * Returns the value of given tag for given track.
93  	 * 
94  	 * @param tagName
95  	 *            the tag name, one of {@link #tags}.
96  	 * @param track
97  	 *            the track meta.
98  	 * @return displayable value, never <code>null</code>
99  	 */
100 	private static String getTagValue(final String tagName,
101 			final TrackMetadataBean track) {
102 		if ("%title".equals(tagName)) {
103 			return track.getDisplayableName();
104 		}
105 		if ("%album".equals(tagName)) {
106 			return MiscUtils.emptyIfNull(track.getAlbum());
107 		}
108 		if ("%artist".equals(tagName)) {
109 			return MiscUtils.emptyIfNull(track.getArtist());
110 		}
111 		if ("%genre".equals(tagName)) {
112 			return MiscUtils.emptyIfNull(track.getGenre());
113 		}
114 		if ("%bitrate".equals(tagName)) {
115 			return track.getBitrate() == 0 ? "" : String.valueOf(track
116 					.getBitrate());
117 		}
118 		if ("%year".equals(tagName)) {
119 			return MiscUtils.emptyIfNull(track.getYearReleased());
120 		}
121 		if ("%length".equals(tagName)) {
122 			return TrackMetadataBean.getDisplayableLength(track.getLength());
123 		}
124 		if ("%track".equals(tagName)) {
125 			return MiscUtils.emptyIfNull(track.getTrackNumber());
126 		}
127 		throw new RuntimeException("Unknown tag: " + tagName);
128 	}
129 
130 	private TagFormatter(final String formatString,
131 			final Iterator<String> tokens, final boolean parsingGroup,
132 			final TagFormatter parent, final int index) throws ParseException {
133 		super();
134 		this.formatString = formatString;
135 		this.parent = parent;
136 		this.index = index;
137 		for (; tokens.hasNext();) {
138 			final String token = tokens.next();
139 			if ("{".equals(token)) {
140 				final TagFormatter innerGroup = new TagFormatter(formatString,
141 						tokens, true, this, childFormatters.size());
142 				childFormatters.add(innerGroup);
143 				continue;
144 			}
145 			if ("}".equals(token)) {
146 				if (!parsingGroup)
147 					throw new ParseException("Unexpected }", -1);
148 				// finish parsing.
149 				return;
150 			}
151 			final int tagIndex = findTag(token);
152 			final TagFormatter innerGroup = new TagFormatter(token,
153 					tagIndex >= 0, this, childFormatters.size());
154 			childFormatters.add(innerGroup);
155 		}
156 		if (parsingGroup)
157 			throw new ParseException("Missing }", -1);
158 	}
159 
160 	/***
161 	 * Creates a tag formatter which simply returns a tag value or a string
162 	 * literal.
163 	 * 
164 	 * @param value
165 	 *            the value
166 	 * @param isTagName
167 	 *            if <code>true</code> then the <code>value</code> is a tag
168 	 *            name to resolve. if <code>false</code> then the
169 	 *            <code>value</code> is simply a string literal.
170 	 * @param parent
171 	 *            The parent, <code>null</code> if this is the root formatter.
172 	 * @param index
173 	 *            Index in the parent's list of children.
174 	 */
175 	private TagFormatter(final String value, final boolean isTagName,
176 			final TagFormatter parent, final int index) {
177 		super();
178 		this.parent = parent;
179 		this.index = index;
180 		if (isTagName)
181 			tag = value;
182 		else
183 			stringLiteral = value;
184 		formatString = value;
185 	}
186 
187 	/***
188 	 * If not <code>null</code> then this formatter translates to a single tag
189 	 * value.
190 	 */
191 	private String tag = null;
192 
193 	/***
194 	 * If not <code>null</code> then this formatter translates into a string
195 	 * literal.
196 	 */
197 	private String stringLiteral = null;
198 
199 	/***
200 	 * The parent, <code>null</code> if this is the root formatter.
201 	 */
202 	private final TagFormatter parent;
203 
204 	/***
205 	 * Index in the parent's list of children.
206 	 */
207 	private final int index;
208 
209 	/***
210 	 * Returns <code>true</code> if contains one or more child formatters.
211 	 * 
212 	 * @return <code>true</code> if this formatter is a compound formatter (a
213 	 *         group).
214 	 */
215 	private boolean isCompound() {
216 		return (tag == null) && (stringLiteral == null)
217 				&& !childFormatters.isEmpty();
218 	}
219 
220 	/***
221 	 * Returns <code>true</code> if this formatter behaves like a delimiter.
222 	 * Delimiters separate visible elements which are not delimiters.
223 	 * 
224 	 * @return <code>true</code> if this formatter is a delimiter.
225 	 */
226 	private boolean isDelimiter() {
227 		return isCompound() && (childFormatters.size() == 1)
228 				&& (childFormatters.get(0).isStringLiteral());
229 	}
230 
231 	/***
232 	 * Formats given track and appends it into given string builder.
233 	 * 
234 	 * @param builder
235 	 *            the builder to append the formatted track to.
236 	 * @param bean
237 	 *            the track to format
238 	 * @param collapse
239 	 *            if <code>true</code> then the formatter will collapse when
240 	 *            all tags/groups are empty.
241 	 */
242 	public void format(final StringBuilder builder,
243 			final TrackMetadataBean bean, final boolean collapse) {
244 		// simple cases
245 		if (isStringLiteral()) {
246 			builder.append(stringLiteral);
247 			return;
248 		}
249 		if (tag != null) {
250 			builder.append(getTagValue(tag, bean));
251 			return;
252 		}
253 		// regular case
254 		if (collapse && isCollapsed(bean))
255 			return;
256 		for (final TagFormatter child : childFormatters) {
257 			child.format(builder, bean, true);
258 		}
259 	}
260 
261 	/***
262 	 * Serves for the cache validity verification. If current bean is not the
263 	 * same instance as this bean then the cache is invalid.
264 	 */
265 	private TrackMetadataBean lastBean;
266 
267 	/***
268 	 * Cache of the {@link #isCollapsed(TrackMetadataBean)} property.
269 	 */
270 	private boolean isCollapsedCached;
271 
272 	/***
273 	 * Checks if this formatter produces collapsed output - if all tags and
274 	 * groups contained in this formatter are collapsed or return an empty tag.
275 	 * 
276 	 * @param bean
277 	 *            the data provider
278 	 * @return <code>true</code> if all non-string formatters are collapsed.
279 	 */
280 	private boolean isCollapsed(final TrackMetadataBean bean) {
281 		if (lastBean == bean)
282 			return isCollapsedCached;
283 		lastBean = bean;
284 		// simple cases
285 		if (isStringLiteral()) {
286 			isCollapsedCached = false;
287 			return false;
288 		}
289 		if (tag != null) {
290 			isCollapsedCached = getTagValue(tag, bean).length() == 0;
291 			return isCollapsedCached;
292 		}
293 		// delimiter?
294 		if (isDelimiter()) {
295 			// check if there is at least one non-delimiter uncollapsed item
296 			if (parent == null) {
297 				isCollapsedCached = true;
298 				return true;
299 			}
300 			for (int i = index - 1; i >= 0; i--) {
301 				final TagFormatter sibling = parent.childFormatters.get(i);
302 				if (sibling.isDelimiter()) {
303 					isCollapsedCached = true;
304 					return true;
305 				}
306 				if (sibling.isCollapsed(bean))
307 					continue;
308 				// allright, we have found such item. Check if there is one at
309 				// the other side of the delimiter.
310 				final int size = parent.childFormatters.size();
311 				for (int j = index + 1; j < size; j++) {
312 					final TagFormatter nextSibling = parent.childFormatters
313 							.get(j);
314 					if (nextSibling.isDelimiter()) {
315 						// this delimiter will be invisible. update its cache
316 						sibling.lastBean = bean;
317 						sibling.isCollapsedCached = true;
318 						continue;
319 					}
320 					if (!nextSibling.isCollapsed(bean)) {
321 						// visible items on both sides, we are visible
322 						isCollapsedCached = false;
323 						return false;
324 					}
325 				}
326 			}
327 			// no visible item, return true
328 			isCollapsedCached = true;
329 			return true;
330 		}
331 		// common compound formatter case
332 		boolean wasNonLiteral = false;
333 		for (final TagFormatter child : childFormatters) {
334 			if (child.isStringLiteral())
335 				continue;
336 			wasNonLiteral = true;
337 			if (!child.isCollapsed(bean)) {
338 				isCollapsedCached = false;
339 				return false;
340 			}
341 		}
342 		isCollapsedCached = wasNonLiteral;
343 		return wasNonLiteral;
344 	}
345 
346 	private boolean isStringLiteral() {
347 		return stringLiteral != null;
348 	}
349 
350 	/***
351 	 * Formats given track.
352 	 * 
353 	 * @param bean
354 	 *            the track to format
355 	 * @return never-<code>null</code> formatted string.
356 	 */
357 	public String format(final TrackMetadataBean bean) {
358 		final StringBuilder b = new StringBuilder();
359 		format(b, bean, false);
360 		return b.toString();
361 	}
362 
363 	private static List<String> tokenizeString(final String string) {
364 		final StringTokenizer tokenizer = new StringTokenizer(string, "%{}",
365 				true);
366 		final List<String> result = new ArrayList<String>();
367 		// first pass - tokenize
368 		while (tokenizer.hasMoreTokens()) {
369 			final String token = tokenizer.nextToken();
370 			if ("%{}".indexOf(token.charAt(0)) >= 0) {
371 				final CharacterIterator iter = new StringCharacterIterator(
372 						token);
373 				for (char c = iter.first(); c != CharacterIterator.DONE; c = iter
374 						.next()) {
375 					result.add(String.valueOf(c));
376 				}
377 			} else {
378 				result.add(token);
379 			}
380 		}
381 		// second pass - convert a % separator followed by a valid tag name to a
382 		// tag.
383 		for (int i = 0; i < result.size(); i++) {
384 			if (!"%".equals(result.get(i)))
385 				continue;
386 			if (i + 1 >= result.size())
387 				continue;
388 			String nextToken = "%" + result.get(i + 1);
389 			final int tagIndex = findTag(nextToken);
390 			if (tagIndex < 0)
391 				continue;
392 			// we found a tag!
393 			final String tag = tags.get(tagIndex);
394 			result.set(i, tag);
395 			nextToken = nextToken.substring(tag.length());
396 			if (nextToken.length() == 0) {
397 				result.remove(i + 1);
398 				i--;
399 			} else {
400 				result.set(i + 1, nextToken);
401 			}
402 		}
403 		return result;
404 	}
405 
406 	private final static int findTag(final String tagName) {
407 		for (int tagIndex = 0; tagIndex < tags.size(); tagIndex++) {
408 			if (tagName.startsWith(tags.get(tagIndex))) {
409 				return tagIndex;
410 			}
411 		}
412 		return -1;
413 	}
414 
415 	@Override
416 	public String toString() {
417 		if (isStringLiteral())
418 			return "literal: " + stringLiteral;
419 		if (tag != null)
420 			return "tag: " + tag;
421 		if (isDelimiter())
422 			return "delimiter: '" + childFormatters.get(0).stringLiteral + "'";
423 		return "compound formatter";
424 	}
425 
426 }