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