summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs
blob: 7624894c17f72e6abce871604ee0ce4c65c6f0a9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using StardewModdingAPI.Toolkit.Serialisation;
using StardewModdingAPI.Toolkit.Serialisation.Models;

namespace StardewModdingAPI.Toolkit.Framework.ModScanning
{
    /// <summary>Scans folders for mod data.</summary>
    public class ModScanner
    {
        /*********
        ** Fields
        *********/
        /// <summary>The JSON helper with which to read manifests.</summary>
        private readonly JsonHelper JsonHelper;

        /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary>
        private readonly HashSet<string> IgnoreFilesystemEntries = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
        {
            ".DS_Store",
            "mcs",
            "Thumbs.db"
        };

        /// <summary>The extensions for files which an XNB mod may contain. If a mod contains *only* these file extensions, it should be considered an XNB mod.</summary>
        private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
        {
            ".md",
            ".png",
            ".txt",
            ".xnb"
        };


        /*********
        ** Public methods
        *********/
        /// <summary>Construct an instance.</summary>
        /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
        public ModScanner(JsonHelper jsonHelper)
        {
            this.JsonHelper = jsonHelper;
        }

        /// <summary>Extract information about all mods in the given folder.</summary>
        /// <param name="rootPath">The root folder containing mods.</param>
        public IEnumerable<ModFolder> GetModFolders(string rootPath)
        {
            DirectoryInfo root = new DirectoryInfo(rootPath);
            return this.GetModFolders(root, root);
        }

        /// <summary>Extract information from a mod folder.</summary>
        /// <param name="root">The root folder containing mods.</param>
        /// <param name="searchFolder">The folder to search for a mod.</param>
        public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder)
        {
            // find manifest.json
            FileInfo manifestFile = this.FindManifest(searchFolder);

            // set appropriate invalid-mod error
            if (manifestFile == null)
            {
                FileInfo[] files = searchFolder.GetFiles("*", SearchOption.AllDirectories).Where(this.IsRelevant).ToArray();
                if (!files.Any())
                    return new ModFolder(root, searchFolder, null, "it's an empty folder.");
                if (files.All(file => this.PotentialXnbModExtensions.Contains(file.Extension)))
                    return new ModFolder(root, searchFolder, null, "it's an older XNB mod which replaces game files (not run through SMAPI).");
                return new ModFolder(root, searchFolder, null, "it contains files, but none of them are manifest.json.");
            }

            // read mod info
            Manifest manifest = null;
            string manifestError = null;
            {
                try
                {
                    if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null)
                        manifestError = "its manifest is invalid.";
                }
                catch (SParseException ex)
                {
                    manifestError = $"parsing its manifest failed: {ex.Message}";
                }
                catch (Exception ex)
                {
                    manifestError = $"parsing its manifest failed:\n{ex}";
                }
            }

            // normalise display fields
            if (manifest != null)
            {
                manifest.Name = this.StripNewlines(manifest.Name);
                manifest.Description = this.StripNewlines(manifest.Description);
                manifest.Author = this.StripNewlines(manifest.Author);
            }

            return new ModFolder(root, manifestFile.Directory, manifest, manifestError);
        }


        /*********
        ** Private methods
        *********/
        /// <summary>Recursively extract information about all mods in the given folder.</summary>
        /// <param name="root">The root mod folder.</param>
        /// <param name="folder">The folder to search for mods.</param>
        public IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder)
        {
            // skip
            if (folder.FullName != root.FullName && folder.Name.StartsWith("."))
                yield return new ModFolder(root, folder, null, "ignored folder because its name starts with a dot.", shouldBeLoaded: false);

            // recurse into subfolders
            else if (this.IsModSearchFolder(root, folder))
            {
                foreach (DirectoryInfo subfolder in folder.EnumerateDirectories())
                {
                    foreach (ModFolder match in this.GetModFolders(root, subfolder))
                        yield return match;
                }
            }

            // treat as mod folder
            else
                yield return this.ReadFolder(root, folder);
        }

        /// <summary>Find the manifest for a mod folder.</summary>
        /// <param name="folder">The folder to search.</param>
        private FileInfo FindManifest(DirectoryInfo folder)
        {
            while (true)
            {
                // check for manifest in current folder
                FileInfo file = new FileInfo(Path.Combine(folder.FullName, "manifest.json"));
                if (file.Exists)
                    return file;

                // check for single subfolder
                FileSystemInfo[] entries = folder.EnumerateFileSystemInfos().Take(2).ToArray();
                if (entries.Length == 1 && entries[0] is DirectoryInfo subfolder)
                {
                    folder = subfolder;
                    continue;
                }

                // not found
                return null;
            }
        }

        /// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary>
        /// <param name="root">The root mod folder.</param>
        /// <param name="folder">The folder to search for mods.</param>
        private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder)
        {
            if (root.FullName == folder.FullName)
                return true;

            DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray();
            FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray();
            return subfolders.Any() && !files.Any();
        }

        /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary>
        /// <param name="entry">The file or folder.</param>
        private bool IsRelevant(FileSystemInfo entry)
        {
            return !this.IgnoreFilesystemEntries.Contains(entry.Name);
        }

        /// <summary>Strip newlines from a string.</summary>
        /// <param name="input">The input to strip.</param>
        private string StripNewlines(string input)
        {
            return input?.Replace("\r", "").Replace("\n", "");
        }
    }
}