Version 2.0.0 (small API change)
[nikiroo-utils.git] / src / be / nikiroo / utils / resources / Bundle.java
1 package be.nikiroo.utils.resources;
2
3 import java.awt.Color;
4 import java.io.BufferedWriter;
5 import java.io.File;
6 import java.io.FileInputStream;
7 import java.io.FileOutputStream;
8 import java.io.IOException;
9 import java.io.InputStreamReader;
10 import java.io.OutputStreamWriter;
11 import java.io.Reader;
12 import java.io.Writer;
13 import java.lang.reflect.Field;
14 import java.util.ArrayList;
15 import java.util.HashMap;
16 import java.util.List;
17 import java.util.Locale;
18 import java.util.Map;
19 import java.util.MissingResourceException;
20 import java.util.PropertyResourceBundle;
21 import java.util.ResourceBundle;
22
23 /**
24 * This class encapsulate a {@link ResourceBundle} in UTF-8. It allows to
25 * retrieve values associated to an enumeration, and allows some additional
26 * methods.
27 * <p>
28 * It also sports a writable change map, and you can save back the
29 * {@link Bundle} to file with {@link Bundle#updateFile(String)}.
30 *
31 * @param <E>
32 * the enum to use to get values out of this class
33 *
34 * @author niki
35 */
36
37 public class Bundle<E extends Enum<E>> {
38 /** The type of E. */
39 protected Class<E> type;
40 /**
41 * The {@link Enum} associated to this {@link Bundle} (all the keys used in
42 * this {@link Bundle} will be of this type).
43 */
44 protected Enum<?> keyType;
45
46 private TransBundle<E> descriptionBundle;
47
48 /** R/O map */
49 private Map<String, String> map;
50 /** R/W map */
51 private Map<String, String> changeMap;
52
53 /**
54 * Create a new {@link Bundles} of the given name.
55 *
56 * @param type
57 * a runtime instance of the class of E
58 * @param name
59 * the name of the {@link Bundles}
60 * @param descriptionBundle
61 * the description {@link TransBundle}, that is, a
62 * {@link TransBundle} dedicated to the description of the values
63 * of the given {@link Bundle} (can be NULL)
64 */
65 protected Bundle(Class<E> type, Enum<?> name,
66 TransBundle<E> descriptionBundle) {
67 this.type = type;
68 this.keyType = name;
69 this.descriptionBundle = descriptionBundle;
70
71 this.map = new HashMap<String, String>();
72 this.changeMap = new HashMap<String, String>();
73 setBundle(name, Locale.getDefault(), false);
74 }
75
76 /**
77 * Return the value associated to the given id as a {@link String}.
78 *
79 * @param id
80 * the id of the value to get
81 *
82 * @return the associated value, or NULL if not found (not present in the
83 * resource file)
84 */
85 public String getString(E id) {
86 return getString(id.name());
87 }
88
89 /**
90 * Set the value associated to the given id as a {@link String}.
91 *
92 * @param id
93 * the id of the value to get
94 * @param value
95 * the value
96 *
97 */
98 public void setString(E id, String value) {
99 setString(id.name(), value);
100 }
101
102 /**
103 * Return the value associated to the given id as a {@link String} suffixed
104 * with the runtime value "_suffix" (that is, "_" and suffix).
105 * <p>
106 * Will only accept suffixes that form an existing id.
107 *
108 * @param id
109 * the id of the value to get
110 * @param suffix
111 * the runtime suffix
112 *
113 * @return the associated value, or NULL if not found (not present in the
114 * resource file)
115 */
116 public String getStringX(E id, String suffix) {
117 String key = id.name()
118 + (suffix == null ? "" : "_" + suffix.toUpperCase());
119
120 try {
121 id = Enum.valueOf(type, key);
122 return getString(id);
123 } catch (IllegalArgumentException e) {
124
125 }
126
127 return null;
128 }
129
130 /**
131 * Set the value associated to the given id as a {@link String} suffixed
132 * with the runtime value "_suffix" (that is, "_" and suffix).
133 * <p>
134 * Will only accept suffixes that form an existing id.
135 *
136 * @param id
137 * the id of the value to get
138 * @param suffix
139 * the runtime suffix
140 * @param value
141 * the value
142 */
143 public void setStringX(E id, String suffix, String value) {
144 String key = id.name()
145 + (suffix == null ? "" : "_" + suffix.toUpperCase());
146
147 try {
148 id = Enum.valueOf(type, key);
149 setString(id, value);
150 } catch (IllegalArgumentException e) {
151
152 }
153 }
154
155 /**
156 * Return the value associated to the given id as a {@link Boolean}.
157 *
158 * @param id
159 * the id of the value to get
160 *
161 * @return the associated value
162 */
163 public Boolean getBoolean(E id) {
164 String str = getString(id);
165 if (str != null && str.length() > 0) {
166 if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("on")
167 || str.equalsIgnoreCase("yes"))
168 return true;
169 if (str.equalsIgnoreCase("false") || str.equalsIgnoreCase("off")
170 || str.equalsIgnoreCase("no"))
171 return false;
172
173 }
174
175 return null;
176 }
177
178 /**
179 * Return the value associated to the given id as a {@link Boolean}.
180 *
181 * @param id
182 * the id of the value to get
183 * @param def
184 * the default value when it is not present in the config file or
185 * if it is not a boolean value
186 *
187 * @return the associated value
188 */
189 public boolean getBoolean(E id, boolean def) {
190 Boolean b = getBoolean(id);
191 if (b != null)
192 return b;
193
194 return def;
195 }
196
197 /**
198 * Return the value associated to the given id as an {@link Integer}.
199 *
200 * @param id
201 * the id of the value to get
202 *
203 * @return the associated value
204 */
205 public Integer getInteger(E id) {
206 try {
207 return Integer.parseInt(getString(id));
208 } catch (Exception e) {
209 }
210
211 return null;
212 }
213
214 /**
215 * Return the value associated to the given id as an int.
216 *
217 * @param id
218 * the id of the value to get
219 * @param def
220 * the default value when it is not present in the config file or
221 * if it is not a int value
222 *
223 * @return the associated value
224 */
225 public int getInteger(E id, int def) {
226 Integer i = getInteger(id);
227 if (i != null)
228 return i;
229
230 return def;
231 }
232
233 /**
234 * Return the value associated to the given id as a {@link Character}.
235 *
236 * @param id
237 * the id of the value to get
238 *
239 * @return the associated value
240 */
241 public Character getCharacter(E id) {
242 String s = getString(id).trim();
243 if (s.length() > 0) {
244 return s.charAt(0);
245 }
246
247 return null;
248 }
249
250 /**
251 * Return the value associated to the given id as a {@link Character}.
252 *
253 * @param id
254 * the id of the value to get
255 * @param def
256 * the default value when it is not present in the config file or
257 * if it is not a char value
258 *
259 * @return the associated value
260 */
261 public char getCharacter(E id, char def) {
262 String s = getString(id).trim();
263 if (s.length() > 0) {
264 return s.charAt(0);
265 }
266
267 return def;
268 }
269
270 /**
271 * Return the value associated to the given id as a {@link Color}.
272 *
273 * @param id
274 * the id of the value to get
275 *
276 * @return the associated value
277 */
278 public Color getColor(E id) {
279 Color color = null;
280
281 String bg = getString(id).trim();
282 if (bg.startsWith("#") && (bg.length() == 7 || bg.length() == 9)) {
283 try {
284 int r = Integer.parseInt(bg.substring(1, 3), 16);
285 int g = Integer.parseInt(bg.substring(3, 5), 16);
286 int b = Integer.parseInt(bg.substring(5, 7), 16);
287 int a = 255;
288 if (bg.length() == 9) {
289 a = Integer.parseInt(bg.substring(7, 9), 16);
290 }
291 color = new Color(r, g, b, a);
292 } catch (NumberFormatException e) {
293 color = null; // no changes
294 }
295 }
296
297 // Try by name if still not found
298 if (color == null) {
299 try {
300 Field field = Color.class.getField(bg);
301 color = (Color) field.get(null);
302 } catch (Exception e) {
303 }
304 }
305 //
306
307 return color;
308 }
309
310 /**
311 * Set the value associated to the given id as a {@link Color}.
312 *
313 * @param id
314 * the id of the value to set
315 * @param color
316 * the new color
317 */
318 public void setColor(E id, Color color) {
319 // Check for named colours first
320 try {
321 Field[] fields = Color.class.getFields();
322 for (Field field : fields) {
323 if (field.equals(color)) {
324 setString(id, field.getName());
325 return;
326 }
327 }
328 } catch (Exception e) {
329 }
330 //
331
332 String r = Integer.toString(color.getRed(), 16);
333 String g = Integer.toString(color.getGreen(), 16);
334 String b = Integer.toString(color.getBlue(), 16);
335 String a = "";
336 if (color.getAlpha() < 255) {
337 a = Integer.toString(color.getAlpha(), 16);
338 }
339
340 setString(id, "#" + r + g + b + a);
341 }
342
343 /**
344 * Create/update the .properties file.
345 * <p>
346 * Will use the most likely candidate as base if the file does not already
347 * exists and this resource is translatable (for instance, "en_US" will use
348 * "en" as a base if the resource is a translation file).
349 * <p>
350 * Will update the files in {@link Bundles#getDirectory()}; it <b>MUST</b>
351 * be set.
352 *
353 * @throws IOException
354 * in case of IO errors
355 */
356 public void updateFile() throws IOException {
357 updateFile(Bundles.getDirectory());
358 }
359
360 /**
361 * Create/update the .properties file.
362 * <p>
363 * Will use the most likely candidate as base if the file does not already
364 * exists and this resource is translatable (for instance, "en_US" will use
365 * "en" as a base if the resource is a translation file).
366 *
367 * @param path
368 * the path where the .properties files are, <b>MUST NOT</b> be
369 * NULL
370 *
371 * @throws IOException
372 * in case of IO errors
373 */
374 public void updateFile(String path) throws IOException {
375 File file = getUpdateFile(path);
376
377 BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
378 new FileOutputStream(file), "UTF-8"));
379
380 writeHeader(writer);
381 writer.write("\n");
382 writer.write("\n");
383
384 for (Field field : type.getDeclaredFields()) {
385 Meta meta = field.getAnnotation(Meta.class);
386 if (meta != null) {
387 E id = E.valueOf(type, field.getName());
388 String info = getMetaInfo(meta);
389
390 if (info != null) {
391 writer.write(info);
392 writer.write("\n");
393 }
394
395 writeValue(writer, id);
396 }
397 }
398
399 writer.close();
400 }
401
402 /**
403 * The description {@link TransBundle}, that is, a {@link TransBundle}
404 * dedicated to the description of the values of the given {@link Bundle}
405 * (can be NULL).
406 *
407 * @return the description {@link TransBundle}
408 */
409 public TransBundle<E> getDescriptionBundle() {
410 return descriptionBundle;
411 }
412
413 /**
414 * Reload the {@link Bundle} data files.
415 *
416 * @param resetToDefault
417 * reset to the default configuration (do not look into the
418 * possible user configuration files, only take the original
419 * configuration)
420 */
421 public void reload(boolean resetToDefault) {
422 setBundle(keyType, Locale.getDefault(), resetToDefault);
423 }
424
425 /**
426 * Check if the internal map contains the given key.
427 *
428 * @param key
429 * the key to check for
430 *
431 * @return true if it does
432 */
433 protected boolean containsKey(String key) {
434 return changeMap.containsKey(key) || map.containsKey(key);
435 }
436
437 /**
438 * Get the value for the given key if it exists in the internal map, or NULL
439 * if not.
440 *
441 * @param key
442 * the key to check for
443 *
444 * @return the value, or NULL
445 */
446 protected String getString(String key) {
447 if (changeMap.containsKey(key)) {
448 return changeMap.get(key);
449 }
450
451 if (map.containsKey(key)) {
452 return map.get(key);
453 }
454
455 return null;
456 }
457
458 /**
459 * Set the value for this key, in the change map (it is kept in memory, not
460 * yet on disk).
461 *
462 * @param key
463 * the key
464 * @param value
465 * the associated value
466 */
467 protected void setString(String key, String value) {
468 changeMap.put(key, value == null ? null : value.trim());
469 }
470
471 /**
472 * Return formated, display-able information from the {@link Meta} field
473 * given. Each line will always starts with a "#" character.
474 *
475 * @param meta
476 * the {@link Meta} field
477 *
478 * @return the information to display or NULL if none
479 */
480 protected String getMetaInfo(Meta meta) {
481 String desc = meta.description();
482 boolean group = meta.group();
483 Meta.Format format = meta.format();
484 String[] list = meta.list();
485 boolean nullable = meta.nullable();
486 String info = meta.info();
487 boolean array = meta.array();
488
489 // Default, empty values -> NULL
490 if (desc.length() + list.length + info.length() == 0 && !group
491 && nullable && format == Meta.Format.STRING) {
492 return null;
493 }
494
495 StringBuilder builder = new StringBuilder();
496 builder.append("# ").append(desc);
497 if (desc.length() > 20) {
498 builder.append("\n#");
499 }
500
501 if (group) {
502 builder.append("This item is used as a group, its content is not expected to be used.");
503 } else {
504 builder.append(" (FORMAT: ").append(format)
505 .append(nullable ? "" : " (required)");
506 builder.append(") ").append(info);
507
508 if (list.length > 0) {
509 builder.append("\n# ALLOWED VALUES:");
510 for (String value : list) {
511 builder.append(" \"").append(value).append("\"");
512 }
513 }
514
515 if (array) {
516 builder.append("\n# (This item accept a list of comma-separated values)");
517 }
518 }
519
520 return builder.toString();
521 }
522
523 /**
524 * The display name used in the <tt>.properties file</tt>.
525 *
526 * @return the name
527 */
528 protected String getBundleDisplayName() {
529 return keyType.toString();
530 }
531
532 /**
533 * Write the header found in the configuration <tt>.properties</tt> file of
534 * this {@link Bundles}.
535 *
536 * @param writer
537 * the {@link Writer} to write the header in
538 *
539 * @throws IOException
540 * in case of IO error
541 */
542 protected void writeHeader(Writer writer) throws IOException {
543 writer.write("# " + getBundleDisplayName() + "\n");
544 writer.write("#\n");
545 }
546
547 /**
548 * Write the given id to the config file, i.e., "MY_ID = my_curent_value"
549 * followed by a new line
550 *
551 * @param writer
552 * the {@link Writer} to write into
553 * @param id
554 * the id to write
555 *
556 * @throws IOException
557 * in case of IO error
558 */
559 protected void writeValue(Writer writer, E id) throws IOException {
560 writeValue(writer, id.name(), getString(id));
561 }
562
563 /**
564 * Write the given data to the config file, i.e., "MY_ID = my_curent_value"
565 * followed by a new line
566 *
567 * @param writer
568 * the {@link Writer} to write into
569 * @param id
570 * the id to write
571 * @param value
572 * the id's value
573 *
574 * @throws IOException
575 * in case of IO error
576 */
577 protected void writeValue(Writer writer, String id, String value)
578 throws IOException {
579 writer.write(id);
580 writer.write(" = ");
581
582 if (value == null) {
583 value = "";
584 }
585
586 String[] lines = value.replaceAll("\t", "\\\\\\t").split("\n");
587 for (int i = 0; i < lines.length; i++) {
588 writer.write(lines[i]);
589 if (i < lines.length - 1) {
590 writer.write("\\n\\");
591 }
592 writer.write("\n");
593 }
594 }
595
596 /**
597 * Return the source file for this {@link Bundles} from the given path.
598 *
599 * @param path
600 * the path where the .properties files are
601 *
602 * @return the source {@link File}
603 */
604 protected File getUpdateFile(String path) {
605 return new File(path, keyType.name() + ".properties");
606 }
607
608 /**
609 * Change the currently used bundle, and reset all changes.
610 *
611 * @param name
612 * the name of the bundle to load
613 * @param locale
614 * the {@link Locale} to use
615 * @param resetToDefault
616 * reset to the default configuration (do not look into the
617 * possible user configuration files, only take the original
618 * configuration)
619 */
620 protected void setBundle(Enum<?> name, Locale locale, boolean resetToDefault) {
621 changeMap.clear();
622 String dir = Bundles.getDirectory();
623
624 boolean found = false;
625 if (!resetToDefault && dir != null) {
626 try {
627 File file = getPropertyFile(dir, name.name(), locale);
628 if (file != null) {
629 Reader reader = new InputStreamReader(new FileInputStream(
630 file), "UTF8");
631 resetMap(new PropertyResourceBundle(reader));
632 found = true;
633 }
634 } catch (IOException e) {
635 e.printStackTrace();
636 }
637 }
638
639 if (!found) {
640 String bname = type.getPackage().getName() + "." + name.name();
641 try {
642 resetMap(ResourceBundle
643 .getBundle(bname, locale, type.getClassLoader(),
644 new FixedResourceBundleControl()));
645 } catch (Exception e) {
646 // We have no bundle for this Bundle
647 System.err.println("No bundle found for: " + bname);
648 resetMap(null);
649 }
650 }
651 }
652
653 /**
654 * Reset the backing map to the content of the given bundle, or empty if
655 * bundle is NULL.
656 *
657 * @param bundle
658 * the bundle to copy
659 */
660 protected void resetMap(ResourceBundle bundle) {
661 this.map.clear();
662
663 if (bundle != null) {
664 for (E field : type.getEnumConstants()) {
665 try {
666 String value = bundle.getString(field.name());
667 this.map.put(field.name(),
668 value == null ? null : value.trim());
669 } catch (MissingResourceException e) {
670 }
671 }
672 }
673 }
674
675 /**
676 * Take a snapshot of the changes in memory in this {@link Bundle} made by
677 * the "set" methods ( {@link Bundle#setString(Enum, String)}...) at the
678 * current time.
679 *
680 * @return a snapshot to use with {@link Bundle#restoreSnapshot(Object)}
681 */
682 public Object takeSnapshot() {
683 return new HashMap<String, String>(changeMap);
684 }
685
686 /**
687 * Restore a snapshot taken with {@link Bundle}, or reset the current
688 * changes if the snapshot is NULL.
689 *
690 * @param snap
691 * the snapshot or NULL
692 */
693 @SuppressWarnings("unchecked")
694 public void restoreSnapshot(Object snap) {
695 if (snap == null) {
696 changeMap.clear();
697 } else {
698 if (snap instanceof Map) {
699 changeMap = (Map<String, String>) snap;
700 } else {
701 throw new Error(
702 "Restoring changes in a Bundle must be done on a changes snapshot, "
703 + "or NULL to discard current changes");
704 }
705 }
706 }
707
708 /**
709 * Return the resource file that is closer to the {@link Locale}.
710 *
711 * @param dir
712 * the directory to look into
713 * @param name
714 * the file base name (without <tt>.properties</tt>)
715 * @param locale
716 * the {@link Locale}
717 *
718 * @return the closest match or NULL if none
719 */
720 private File getPropertyFile(String dir, String name, Locale locale) {
721 List<String> locales = new ArrayList<String>();
722 if (locale != null) {
723 String country = locale.getCountry() == null ? "" : locale
724 .getCountry();
725 String language = locale.getLanguage() == null ? "" : locale
726 .getLanguage();
727 if (!language.isEmpty() && !country.isEmpty()) {
728 locales.add("_" + language + "-" + country);
729 }
730 if (!language.isEmpty()) {
731 locales.add("_" + language);
732 }
733 }
734
735 locales.add("");
736
737 File file = null;
738 for (String loc : locales) {
739 file = new File(dir, name + loc + ".properties");
740 if (file.exists()) {
741 break;
742 } else {
743 file = null;
744 }
745 }
746
747 return file;
748 }
749 }