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