1 /**
2  * This module provides class for reading and accessing icon theme descriptions.
3  *
4  * Information about icon themes is stored in special files named index.theme and located in icon theme directory.
5  *
6  * Authors:
7  *  $(LINK2 https://github.com/FreeSlave, Roman Chistokhodov)
8  * Copyright:
9  *  Roman Chistokhodov, 2015-2016
10  * License:
11  *  $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
12  * See_Also:
13  *  $(LINK2 http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html, Icon Theme Specification)
14  */
15 
16 module icontheme.file;
17 
18 package
19 {
20     import std.algorithm;
21     import std.array;
22     import std.conv;
23     import std.exception;
24     import std.path;
25     import std.range;
26     import std.string;
27     import std.traits;
28     import std.typecons;
29 }
30 
31 import icontheme.cache;
32 
33 public import inilike.file;
34 import inilike.common;
35 
36 /**
37  * Adapter of $(D inilike.file.IniLikeGroup) for easy access to icon subdirectory properties.
38  */
39 struct IconSubDir
40 {
41     ///The type of icon sizes for the icons in the directory.
42     enum Type {
43         ///Icons can be used if the size differs at some threshold from the desired size.
44         Threshold,
45         ///Icons can be used if the size does not differ from desired.
46         Fixed,
47         ///Icons are scalable without visible quality loss.
48         Scalable
49     }
50 
51     @safe this(const(IniLikeGroup) group) nothrow {
52         collectException(group.escapedValue("Size").to!uint, _size);
53         collectException(group.escapedValue("MinSize").to!uint, _minSize);
54         collectException(group.escapedValue("MaxSize").to!uint, _maxSize);
55 
56         if (_minSize == 0) {
57             _minSize = _size;
58         }
59 
60         if (_maxSize == 0) {
61             _maxSize = _size;
62         }
63 
64         collectException(group.escapedValue("Threshold").to!uint, _threshold);
65         if (_threshold == 0) {
66             _threshold = 2;
67         }
68 
69         _type = Type.Threshold;
70 
71         string t = group.escapedValue("Type");
72         if (t.length) {
73             if (t == "Fixed") {
74                 _type = Type.Fixed;
75             } else if (t == "Scalable") {
76                 _type = Type.Scalable;
77             }
78         }
79 
80         _context = group.escapedValue("Context");
81         _name = group.groupName();
82     }
83 
84     @safe this(uint size, Type type = Type.Threshold, string context = null, uint minSize = 0, uint maxSize = 0, uint threshold = 2) nothrow pure
85     {
86         _size = size;
87         _context = context;
88         _type = type;
89         _minSize = minSize ? minSize : size;
90         _maxSize = maxSize ? maxSize : size;
91         _threshold = threshold;
92     }
93 
94     /**
95      * The name of section in icon theme file and relative path to icons.
96      */
97     @nogc @safe string name() const nothrow pure {
98         return _name;
99     }
100 
101     /**
102      * Nominal size of the icons in this directory.
103      * Returns: The value associated with "Size" key converted to an unsigned integer, or 0 if the value is not present or not a number.
104      */
105     @nogc @safe uint size() const nothrow pure {
106         return _size;
107     }
108 
109     /**
110      * The context the icon is normally used in.
111      * Returns: The value associated with "Context" key.
112      */
113     @nogc @safe string context() const nothrow pure {
114         return _context;
115     }
116 
117     /**
118      * The type of icon sizes for the icons in this directory.
119      * Returns: The value associated with "Type" key or Type.Threshold if not specified.
120      */
121     @nogc @safe Type type() const nothrow pure {
122         return _type;
123     }
124 
125     /**
126      * The maximum size that the icons in this directory can be scaled to. Defaults to the value of Size if not present.
127      * Returns: The value associated with "MaxSize" key converted to an unsigned integer, or size() if the value is not present or not a number.
128      * See_Also: $(D size), $(D minSize)
129      */
130     @nogc @safe uint maxSize() const nothrow pure {
131         return _maxSize;
132     }
133 
134     /**
135      * The minimum size that the icons in this directory can be scaled to. Defaults to the value of Size if not present.
136      * Returns: The value associated with "MinSize" key converted to an unsigned integer, or size() if the value is not present or not a number.
137      * See_Also: $(D size), $(D maxSize)
138      */
139     @nogc @safe uint minSize() const nothrow pure {
140         return _minSize;
141     }
142 
143     /**
144      * The icons in this directory can be used if the size differ at most this much from the desired size. Defaults to 2 if not present.
145      * Returns: The value associated with "Threshold" key, or 2 if the value is not present or not a number.
146      */
147     @nogc @safe uint threshold() const nothrow pure {
148         return _threshold;
149     }
150 private:
151     uint _size;
152     uint _minSize;
153     uint _maxSize;
154     uint _threshold;
155     Type _type;
156     string _context;
157     string _name;
158 }
159 
160 final class IconThemeGroup : IniLikeGroup
161 {
162     protected @nogc @safe this() nothrow {
163         super("Icon Theme");
164     }
165 
166     /**
167      * Short name of the icon theme, used in e.g. lists when selecting themes.
168      * Returns: The value associated with "Name" key.
169      * See_Also: $(D IconThemeFile.internalName), $(D localizedDisplayName)
170      */
171     @safe string displayName() const nothrow pure {
172         return unescapedValue("Name");
173     }
174     /**
175      * Set "Name" to name escaping the value if needed.
176      */
177     @safe string displayName(string name) {
178         return setUnescapedValue("Name", name);
179     }
180 
181     ///Returns: Localized name of icon theme.
182     @safe string localizedDisplayName(string locale) const nothrow pure {
183         return unescapedValue("Name", locale);
184     }
185 
186     /**
187      * Longer string describing the theme.
188      * Returns: The value associated with "Comment" key.
189      */
190     @safe string comment() const nothrow pure {
191         return unescapedValue("Comment");
192     }
193     /**
194      * Set "Comment" to commentary escaping the value if needed.
195      */
196     @safe string comment(string commentary) {
197         return setUnescapedValue("Comment", commentary);
198     }
199 
200     ///Returns: Localized comment.
201     @safe string localizedComment(string locale) const nothrow pure {
202         return unescapedValue("Comment", locale);
203     }
204 
205     /**
206      * Whether to hide the theme in a theme selection user interface.
207      * Returns: The value associated with "Hidden" key converted to bool using $(D inilike.common.isTrue).
208      */
209     @nogc @safe bool hidden() const nothrow pure {
210         return isTrue(escapedValue("Hidden"));
211     }
212     ///setter
213     @safe bool hidden(bool hide) {
214         setEscapedValue("Hidden", boolToString(hide));
215         return hide;
216     }
217 
218     /**
219      * The name of an icon that should be used as an example of how this theme looks.
220      * Returns: The value associated with "Example" key.
221      */
222     @safe string example() const nothrow pure {
223         return unescapedValue("Example");
224     }
225     /**
226      * Set "Example" to example escaping the value if needed.
227      */
228     @safe string example(string example) {
229         return setUnescapedValue("Example", example);
230     }
231 
232     /**
233      * List of subdirectories for this theme.
234      * Returns: The range of values associated with "Directories" key.
235      */
236     @safe auto directories() const {
237         return IconThemeFile.splitValues(unescapedValue("Directories"));
238     }
239     ///setter
240     string directories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
241         return setUnescapedValue("Directories", IconThemeFile.joinValues(values));
242     }
243 
244     /**
245      * Names of themes that this theme inherits from.
246      * Returns: The range of values associated with "Inherits" key.
247      * Note: It does NOT automatically adds hicolor theme if it's missing.
248      */
249     @safe auto inherits() const {
250         return IconThemeFile.splitValues(unescapedValue("Inherits"));
251     }
252     ///setter
253     string inherits(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
254         return setUnescapedValue("Inherits", IconThemeFile.joinValues(values));
255     }
256 
257 protected:
258     @trusted override void validateKey(string key, string value) const {
259         if (!isValidDesktopFileKey(key)) {
260             throw new IniLikeEntryException("key is invalid", groupName(), key, value);
261         }
262     }
263 }
264 
265 /**
266  * Class representation of index.theme file containing an icon theme description.
267  */
268 final class IconThemeFile : IniLikeFile
269 {
270     /**
271      * Policy about reading extension groups (those start with 'X-').
272      */
273     enum ExtensionGroupPolicy : ubyte {
274         skip, ///Don't save extension groups.
275         preserve ///Save extension groups.
276     }
277 
278     /**
279      * Policy about reading groups with names which meaning is unknown, i.e. it's not extension nor relative directory path.
280      */
281     enum UnknownGroupPolicy : ubyte {
282         skip, ///Don't save unknown groups.
283         preserve, ///Save unknown groups.
284         throwError ///Throw error when unknown group is encountered.
285     }
286 
287     ///Options to manage icon theme file reading
288     static struct IconThemeReadOptions
289     {
290         ///Base $(D inilike.file.IniLikeFile.ReadOptions).
291         IniLikeFile.ReadOptions baseOptions = IniLikeFile.ReadOptions(IniLikeFile.DuplicateGroupPolicy.skip);
292 
293         alias baseOptions this;
294 
295         /**
296          * Set policy about unknown groups. By default they are skipped without errors.
297          * Note that all groups still need to be preserved if desktop file must be rewritten.
298          */
299         UnknownGroupPolicy unknownGroupPolicy = UnknownGroupPolicy.skip;
300 
301         /**
302          * Set policy about extension groups. By default they are all preserved.
303          * Set it to skip if you're not willing to support any extensions in your applications.
304          * Note that all groups still need to be preserved if desktop file must be rewritten.
305          */
306         ExtensionGroupPolicy extensionGroupPolicy = ExtensionGroupPolicy.preserve;
307 
308         ///Setting parameters in any order, leaving not mentioned ones in default state.
309         @nogc @safe this(Args...)(Args args) nothrow pure {
310             foreach(arg; args) {
311                 alias Unqual!(typeof(arg)) ArgType;
312                 static if (is(ArgType == IniLikeFile.ReadOptions)) {
313                     baseOptions = arg;
314                 } else static if (is(ArgType == UnknownGroupPolicy)) {
315                     unknownGroupPolicy = arg;
316                 } else static if (is(ArgType == ExtensionGroupPolicy)) {
317                     extensionGroupPolicy = arg;
318                 } else {
319                     baseOptions.assign(arg);
320                 }
321             }
322         }
323 
324         ///
325         unittest
326         {
327             IconThemeReadOptions options;
328 
329             options = IconThemeReadOptions(
330                 ExtensionGroupPolicy.skip,
331                 UnknownGroupPolicy.preserve,
332                 DuplicateKeyPolicy.skip,
333                 DuplicateGroupPolicy.preserve,
334                 No.preserveComments
335             );
336 
337             assert(options.unknownGroupPolicy == UnknownGroupPolicy.preserve);
338             assert(options.extensionGroupPolicy == ExtensionGroupPolicy.skip);
339             assert(options.duplicateGroupPolicy == DuplicateGroupPolicy.preserve);
340             assert(options.duplicateKeyPolicy == DuplicateKeyPolicy.skip);
341             assert(!options.preserveComments);
342         }
343     }
344 
345     ///
346     unittest
347     {
348         string contents =
349 `[Icon Theme]
350 Name=Theme
351 [X-SomeGroup]
352 Key=Value`;
353 
354         alias IconThemeFile.IconThemeReadOptions IconThemeReadOptions;
355 
356         auto iconTheme = new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(ExtensionGroupPolicy.skip));
357         assert(iconTheme.group("X-SomeGroup") is null);
358 
359     contents =
360 `[Icon Theme]
361 Name=Theme
362 [/invalid group]
363 $=StrangeKey`;
364 
365         iconTheme = new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(UnknownGroupPolicy.preserve, IniLikeGroup.InvalidKeyPolicy.save));
366         assert(iconTheme.group("/invalid group") !is null);
367         assert(iconTheme.group("/invalid group").escapedValue("$") == "StrangeKey");
368 
369     contents =
370 `[X-SomeGroup]
371 Key=Value`;
372 
373         auto thrown = collectException!IniLikeReadException(new IconThemeFile(iniLikeStringReader(contents)));
374         assert(thrown !is null);
375         assert(thrown.lineNumber == 0);
376 
377         contents =
378 `[Icon Theme]
379 Valid=Key
380 $=Invalid`;
381 
382         assertThrown(new IconThemeFile(iniLikeStringReader(contents)));
383         assertNotThrown(new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(IniLikeGroup.InvalidKeyPolicy.skip)));
384 
385         contents =
386 `[Icon Theme]
387 Name=Name
388 [/invalidpath]
389 Key=Value`;
390 
391         assertThrown(new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(UnknownGroupPolicy.throwError)));
392         assertNotThrown(iconTheme = new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(UnknownGroupPolicy.preserve)));
393         assert(iconTheme.cachePath().empty);
394         assert(iconTheme.group("/invalidpath") !is null);
395     }
396 
397 protected:
398     @trusted static bool isDirectoryName(string groupName)
399     {
400         return groupName.pathSplitter.all!isValidFilename;
401     }
402 
403     @trusted override IniLikeGroup createGroupByName(string groupName) {
404         if (groupName == "Icon Theme") {
405             _iconTheme = new IconThemeGroup();
406             return _iconTheme;
407         } else if (groupName.startsWith("X-")) {
408             if (_options.extensionGroupPolicy == ExtensionGroupPolicy.skip) {
409                 return null;
410             } else {
411                 return createEmptyGroup(groupName);
412             }
413         } else if (isDirectoryName(groupName)) {
414             return createEmptyGroup(groupName);
415         } else {
416             final switch(_options.unknownGroupPolicy) {
417                 case UnknownGroupPolicy.skip:
418                     return null;
419                 case UnknownGroupPolicy.preserve:
420                     return createEmptyGroup(groupName);
421                 case UnknownGroupPolicy.throwError:
422                     throw new IniLikeException("Invalid group name: '" ~ groupName ~ "'. Must be valid relative path or start with 'X-'");
423             }
424         }
425     }
426 public:
427     /**
428      * Reads icon theme from file.
429      * Throws:
430      *  $(B ErrnoException) if file could not be opened.
431      *  $(D inilike.file.IniLikeReadException) if error occured while reading the file.
432      */
433     @trusted this(string fileName, IconThemeReadOptions options = IconThemeReadOptions.init) {
434         this(iniLikeFileReader(fileName), options, fileName);
435     }
436 
437     /**
438      * Reads icon theme file from range of IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader.
439      * Throws:
440      *  $(D inilike.file.IniLikeReadException) if error occured while parsing.
441      */
442     this(IniLikeReader)(IniLikeReader reader, IconThemeReadOptions options = IconThemeReadOptions.init, string fileName = null)
443     {
444         _options = options;
445         super(reader, fileName, options.baseOptions);
446         enforce(_iconTheme !is null, new IniLikeReadException("No \"Icon Theme\" group", 0));
447     }
448 
449     ///ditto
450     this(IniLikeReader)(IniLikeReader reader, string fileName, IconThemeReadOptions options = IconThemeReadOptions.init)
451     {
452         this(reader, options, fileName);
453     }
454 
455     /**
456      * Constructs IconThemeFile with empty "Icon Theme" group.
457      */
458     @safe this() {
459         super();
460         _iconTheme = new IconThemeGroup();
461     }
462 
463     ///
464     unittest
465     {
466         auto itf = new IconThemeFile();
467         assert(itf.iconTheme());
468         assert(itf.directories().empty);
469     }
470 
471     /**
472      * Removes group by name. This function will not remove "Icon Theme" group.
473      */
474     @safe override bool removeGroup(string groupName) nothrow {
475         if (groupName != "Icon Theme") {
476             return super.removeGroup(groupName);
477         }
478         return false;
479     }
480 
481     /**
482      * The name of the subdirectory index.theme was loaded from.
483      * See_Also: $(D IconThemeGroup.displayName)
484      */
485     @trusted string internalName() const {
486         return fileName().absolutePath().dirName().baseName();
487     }
488 
489     /**
490      * Some keys can have multiple values, separated by comma. This function helps to parse such kind of strings into the range.
491      * Returns: The range of multiple nonempty values.
492      * See_Also: $(D joinValues)
493      */
494     @trusted static auto splitValues(string values) {
495         return std.algorithm.splitter(values, ',').filter!(s => s.length != 0);
496     }
497 
498     ///
499     unittest
500     {
501         assert(equal(IconThemeFile.splitValues("16x16/actions,16x16/animations,16x16/apps"), ["16x16/actions", "16x16/animations", "16x16/apps"]));
502         assert(IconThemeFile.splitValues(",").empty);
503         assert(IconThemeFile.splitValues("").empty);
504     }
505 
506     /**
507      * Join range of multiple values into a string using comma as separator.
508      * If range is empty, then the empty string is returned.
509      * See_Also: $(D splitValues)
510      */
511     static string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) {
512         auto result = values.filter!( s => s.length != 0 ).joiner(",");
513         if (result.empty) {
514             return string.init;
515         } else {
516             return text(result);
517         }
518     }
519 
520     ///
521     unittest
522     {
523         assert(equal(IconThemeFile.joinValues(["16x16/actions", "16x16/animations", "16x16/apps"]), "16x16/actions,16x16/animations,16x16/apps"));
524         assert(IconThemeFile.joinValues([""]).empty);
525     }
526 
527     /**
528      * Iterating over subdirectories of icon theme.
529      * See_Also: $(D IconThemeGroup.directories)
530      */
531     @trusted auto bySubdir() const {
532         return directories().filter!(dir => group(dir) !is null).map!(dir => IconSubDir(group(dir))).array;
533     }
534 
535     /**
536      * Icon Theme group in underlying file.
537      * Returns: Instance of "Icon Theme" group.
538      * Note: Usually you don't need to call this function since you can rely on alias this.
539      */
540     @nogc @safe inout(IconThemeGroup) iconTheme() nothrow inout {
541         return _iconTheme;
542     }
543 
544     /**
545      * This alias allows to call functions related to "Icon Theme" group without need to call iconTheme explicitly.
546      */
547     alias iconTheme this;
548 
549     /**
550      * Try to load icon cache. Loaded icon cache will be used on icon lookup.
551      * Returns: Loaded $(D icontheme.cache.IconThemeCache) object or null, if cache does not exist or invalid or outdated.
552      * Note: This function expects that icon theme has fileName.
553      * See_Also: $(D icontheme.cache.IconThemeCache), $(D icontheme.lookup.lookupIcon), $(D cache), $(D unloadCache), $(D cachePath)
554      */
555     @trusted auto tryLoadCache(Flag!"allowOutdated" allowOutdated = Flag!"allowOutdated".no) nothrow
556     {
557         string path = cachePath();
558 
559         bool isOutdated = true;
560         collectException(IconThemeCache.isOutdated(path), isOutdated);
561 
562         if (isOutdated && !allowOutdated) {
563             return null;
564         }
565 
566         IconThemeCache myCache;
567         collectException(new IconThemeCache(path), myCache);
568 
569         if (myCache !is null) {
570             _cache = myCache;
571         }
572         return myCache;
573     }
574 
575     /**
576      * Unset loaded cache.
577      */
578     @nogc @safe void unloadCache() nothrow {
579         _cache = null;
580     }
581 
582     /**
583      * Set cache object.
584      * See_Also: $(D tryLoadCache)
585      */
586     @nogc @safe IconThemeCache cache(IconThemeCache setCache) nothrow {
587         _cache = setCache;
588         return _cache;
589     }
590 
591     /**
592      * The object of loaded cache.
593      * Returns: $(D icontheme.cache.IconThemeCache) object loaded via tryLoadCache or set by cache property.
594      */
595     @nogc @safe inout(IconThemeCache) cache() inout nothrow {
596         return _cache;
597     }
598 
599     /**
600      * Path of icon theme cache file.
601      * Returns: Path to icon-theme.cache of corresponding cache file.
602      * Note: This function expects that icon theme has fileName. This function does not check if the cache file exists.
603      */
604     @trusted string cachePath() const nothrow {
605         auto f = fileName();
606         if (f.length) {
607             return buildPath(fileName().dirName, "icon-theme.cache");
608         } else {
609             return null;
610         }
611     }
612 
613 private:
614     IconThemeReadOptions _options;
615     IconThemeGroup _iconTheme;
616     IconThemeCache _cache;
617 }
618 
619 ///
620 unittest
621 {
622     string contents =
623 `# First comment
624 [Icon Theme]
625 Name=Hicolor
626 Name[ru]=Стандартная тема
627 Comment=Fallback icon theme
628 Comment[ru]=Резервная тема
629 Hidden=true
630 Directories=16x16/actions,32x32/animations,scalable/emblems
631 Example=folder
632 Inherits=gnome,hicolor
633 
634 [16x16/actions]
635 Size=16
636 Context=Actions
637 Type=Threshold
638 
639 [32x32/animations]
640 Size=32
641 Context=Animations
642 Type=Fixed
643 
644 [scalable/emblems]
645 Context=Emblems
646 Size=64
647 MinSize=8
648 MaxSize=512
649 Type=Scalable
650 
651 # Will be saved.
652 [X-NoName]
653 Key=Value`;
654 
655     string path = buildPath(".", "test", "Tango", "index.theme");
656 
657     auto iconTheme = new IconThemeFile(iniLikeStringReader(contents), path);
658     assert(equal(iconTheme.leadingComments(), ["# First comment"]));
659     assert(iconTheme.displayName() == "Hicolor");
660     assert(iconTheme.localizedDisplayName("ru") == "Стандартная тема");
661     assert(iconTheme.comment() == "Fallback icon theme");
662     assert(iconTheme.localizedComment("ru") == "Резервная тема");
663     assert(iconTheme.hidden());
664     assert(equal(iconTheme.directories(), ["16x16/actions", "32x32/animations", "scalable/emblems"]));
665     assert(equal(iconTheme.inherits(), ["gnome", "hicolor"]));
666     assert(iconTheme.internalName() == "Tango");
667     assert(iconTheme.example() == "folder");
668     assert(iconTheme.group("X-NoName") !is null);
669 
670     iconTheme.removeGroup("Icon Theme");
671     assert(iconTheme.group("Icon Theme") !is null);
672 
673     assert(iconTheme.cachePath() == buildPath(".", "test", "Tango", "icon-theme.cache"));
674 
675     assert(equal(iconTheme.bySubdir().map!(subdir => tuple(subdir.name(), subdir.size(), subdir.minSize(), subdir.maxSize(), subdir.context(), subdir.type() )),
676                  [tuple("16x16/actions", 16, 16, 16, "Actions", IconSubDir.Type.Threshold),
677                  tuple("32x32/animations", 32, 32, 32, "Animations", IconSubDir.Type.Fixed),
678                  tuple("scalable/emblems", 64, 8, 512, "Emblems", IconSubDir.Type.Scalable)]));
679 
680     version(iconthemeFileTest)
681     {
682         string cachePath = iconTheme.cachePath();
683         assert(cachePath.exists);
684 
685         auto cache = new IconThemeCache(cachePath);
686 
687         assert(iconTheme.cache is null);
688         iconTheme.cache = cache;
689         assert(iconTheme.cache is cache);
690         iconTheme.unloadCache();
691         assert(iconTheme.cache is null);
692 
693         assert(iconTheme.tryLoadCache(Flag!"allowOutdated".yes));
694     }
695 
696     iconTheme.removeGroup("scalable/emblems");
697     assert(iconTheme.group("scalable/emblems") is null);
698 
699     auto itf = new IconThemeFile();
700     itf.displayName = "Oxygen";
701     itf.comment = "Oxygen theme";
702     itf.hidden = true;
703     itf.directories = ["actions", "places"];
704     itf.inherits = ["locolor", "hicolor"];
705     assert(itf.displayName() == "Oxygen");
706     assert(itf.comment() == "Oxygen theme");
707     assert(itf.hidden());
708     assert(equal(itf.directories(), ["actions", "places"]));
709     assert(equal(itf.inherits(), ["locolor", "hicolor"]));
710 }