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
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
245 if (isStringLiteral()) {
246 builder.append(stringLiteral);
247 return;
248 }
249 if (tag != null) {
250 builder.append(getTagValue(tag, bean));
251 return;
252 }
253
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
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
294 if (isDelimiter()) {
295
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
309
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
316 sibling.lastBean = bean;
317 sibling.isCollapsedCached = true;
318 continue;
319 }
320 if (!nextSibling.isCollapsed(bean)) {
321
322 isCollapsedCached = false;
323 return false;
324 }
325 }
326 }
327
328 isCollapsedCached = true;
329 return true;
330 }
331
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
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
382
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
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 }