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