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