summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-03-22 19:52:42 -0400
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-03-22 19:52:42 -0400
commit7ca5efbbc576f3c6c43493654b2a0ac040fd4f31 (patch)
treefae7a4e06a14ff7f8d709e2f4d5b8b92b8784a37 /src
parent5ae640dc91adff8dfb0827e2a3c3f6b54be7c612 (diff)
parent6d1494a56c5d04e7bc1ee406810a5a53dea2229a (diff)
downloadSMAPI-7ca5efbbc576f3c6c43493654b2a0ac040fd4f31.tar.gz
SMAPI-7ca5efbbc576f3c6c43493654b2a0ac040fd4f31.tar.bz2
SMAPI-7ca5efbbc576f3c6c43493654b2a0ac040fd4f31.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src')
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs28
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json4
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json4
-rw-r--r--src/SMAPI.Toolkit/SMAPI.Toolkit.csproj2
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs89
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs66
-rw-r--r--src/SMAPI.Web/Program.cs2
-rw-r--r--src/SMAPI.Web/SMAPI.Web.csproj12
-rw-r--r--src/SMAPI.Web/Startup.cs3
-rw-r--r--src/SMAPI.Web/Views/JsonValidator/Index.cshtml9
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml12
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/file-upload.css25
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/json-validator.css25
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css21
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/file-upload.js71
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/json-validator.js51
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js38
-rw-r--r--src/SMAPI/Constants.cs2
-rw-r--r--src/SMAPI/Events/ButtonPressedEventArgs.cs6
-rw-r--r--src/SMAPI/Events/ButtonReleasedEventArgs.cs6
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs7
-rw-r--r--src/SMAPI/Framework/Input/GamePadStateBuilder.cs283
-rw-r--r--src/SMAPI/Framework/Input/IInputStateBuilder.cs29
-rw-r--r--src/SMAPI/Framework/Input/KeyboardStateBuilder.cs78
-rw-r--r--src/SMAPI/Framework/Input/MouseStateBuilder.cs107
-rw-r--r--src/SMAPI/Framework/Input/SInputState.cs347
-rw-r--r--src/SMAPI/Framework/ModHelpers/InputHelper.cs4
-rw-r--r--src/SMAPI/Framework/Monitor.cs13
-rw-r--r--src/SMAPI/Framework/Rendering/SDisplayDevice.cs89
-rw-r--r--src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs121
-rw-r--r--src/SMAPI/Framework/SGame.cs25
-rw-r--r--src/SMAPI/Framework/WatcherCore.cs2
-rw-r--r--src/SMAPI/IMonitor.cs5
-rw-r--r--src/SMAPI/Patches/LoadErrorPatch.cs25
-rw-r--r--src/SMAPI/SMAPI.csproj2
-rw-r--r--src/SMAPI/i18n/hu.json3
36 files changed, 1085 insertions, 531 deletions
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
index 08dd8eed..6a17213c 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData;
using StardewValley;
using StardewValley.Menus;
@@ -59,13 +60,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
yield return this.TryCreate(ItemType.Flooring, id, () => new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory });
// equipment
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Boots").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\Boots").Keys)
yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id));
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\hats").Keys)
yield return this.TryCreate(ItemType.Hat, id, () => new Hat(id));
// weapons
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\weapons").Keys)
{
yield return this.TryCreate(ItemType.Weapon, id, () => (id >= 32 && id <= 34)
? (Item)new Slingshot(id)
@@ -74,7 +75,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
}
// furniture
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Furniture").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\Furniture").Keys)
{
if (id == 1466 || id == 1468)
yield return this.TryCreate(ItemType.Furniture, id, () => new TV(id, Vector2.Zero));
@@ -94,7 +95,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
// secret notes
if (id == 79)
{
- foreach (int secretNoteId in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
+ foreach (int secretNoteId in this.TryLoad<int, string>("Data\\SecretNotes").Keys)
{
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, () =>
{
@@ -233,6 +234,23 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
/*********
** Private methods
*********/
+ /// <summary>Try to load a data file, and return empty data if it's invalid.</summary>
+ /// <typeparam name="TKey">The asset key type.</typeparam>
+ /// <typeparam name="TValue">The asset value type.</typeparam>
+ /// <param name="assetName">The data asset name.</param>
+ private Dictionary<TKey, TValue> TryLoad<TKey, TValue>(string assetName)
+ {
+ try
+ {
+ return Game1.content.Load<Dictionary<TKey, TValue>>(assetName);
+ }
+ catch (ContentLoadException)
+ {
+ // generally due to a player incorrectly replacing a data file with an XNB mod
+ return new Dictionary<TKey, TValue>();
+ }
+ }
+
/// <summary>Create a searchable item if valid.</summary>
/// <param name="type">The item type.</param>
/// <param name="id">The unique ID (if different from the item's parent sheet index).</param>
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index 0e6805dc..dbed84eb 100644
--- a/src/SMAPI.Mods.ConsoleCommands/manifest.json
+++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
- "Version": "3.3.2",
+ "Version": "3.4.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
- "MinimumApiVersion": "3.3.2"
+ "MinimumApiVersion": "3.4.0"
}
diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json
index 5165d2b2..dc8bc8d4 100644
--- a/src/SMAPI.Mods.SaveBackup/manifest.json
+++ b/src/SMAPI.Mods.SaveBackup/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
- "Version": "3.3.2",
+ "Version": "3.4.0",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
- "MinimumApiVersion": "3.3.2"
+ "MinimumApiVersion": "3.4.0"
}
diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
index a7de7166..edb1d612 100644
--- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
+++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
@@ -12,7 +12,7 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="HtmlAgilityPack" Version="1.11.20" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.11.23" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
<PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" />
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs
new file mode 100644
index 00000000..42e283a9
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Text;
+using StardewModdingAPI.Web.Framework.LogParsing.Models;
+
+namespace StardewModdingAPI.Web.Framework.LogParsing
+{
+ /// <summary>Handles constructing log message instances with minimal memory allocation.</summary>
+ internal class LogMessageBuilder
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The local time when the next log was posted.</summary>
+ public string Time { get; set; }
+
+ /// <summary>The log level for the next log message.</summary>
+ public LogLevel Level { get; set; }
+
+ /// <summary>The mod name for the next log message.</summary>
+ public string Mod { get; set; }
+
+ /// <summary>The text for the next log message.</summary>
+ private readonly StringBuilder Text = new StringBuilder();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the next log message has been started.</summary>
+ public bool Started { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Start accumulating values for a new log message.</summary>
+ /// <param name="time">The local time when the log was posted.</param>
+ /// <param name="level">The log level.</param>
+ /// <param name="mod">The mod name.</param>
+ /// <param name="text">The initial log text.</param>
+ /// <exception cref="InvalidOperationException">A log message is already started; call <see cref="Clear"/> before starting a new message.</exception>
+ public void Start(string time, LogLevel level, string mod, string text)
+ {
+ if (this.Started)
+ throw new InvalidOperationException("Can't start new message, previous log message isn't done yet.");
+
+ this.Started = true;
+
+ this.Time = time;
+ this.Level = level;
+ this.Mod = mod;
+ this.Text.Append(text);
+ }
+
+ /// <summary>Add a new line to the next log message being built.</summary>
+ /// <param name="text">The line to add.</param>
+ /// <exception cref="InvalidOperationException">A log message hasn't been started yet.</exception>
+ public void AddLine(string text)
+ {
+ if (!this.Started)
+ throw new InvalidOperationException("Can't add text, no log message started yet.");
+
+ this.Text.Append("\n");
+ this.Text.Append(text);
+ }
+
+ /// <summary>Get a log message for the accumulated values.</summary>
+ public LogMessage Build()
+ {
+ if (!this.Started)
+ return null;
+
+ return new LogMessage
+ {
+ Time = this.Time,
+ Level = this.Level,
+ Mod = this.Mod,
+ Text = this.Text.ToString()
+ };
+ }
+
+ /// <summary>Reset to start a new log message.</summary>
+ public void Clear()
+ {
+ this.Started = false;
+ this.Text.Clear();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index cc91ec51..cce80816 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -201,7 +201,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
}
// mod path line
- else if (message.Level == LogLevel.Debug && this.ModPathPattern.IsMatch(message.Text))
+ else if (message.Level == LogLevel.Info && this.ModPathPattern.IsMatch(message.Text))
{
Match match = this.ModPathPattern.Match(message.Text);
log.ModPath = match.Groups["path"].Value;
@@ -282,43 +282,47 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
/// <exception cref="LogParseException">The log text can't be parsed successfully.</exception>
private IEnumerable<LogMessage> GetMessages(string logText)
{
- LogMessage message = new LogMessage();
- using (StringReader reader = new StringReader(logText))
+ LogMessageBuilder builder = new LogMessageBuilder();
+ using StringReader reader = new StringReader(logText);
+ while (true)
{
- while (true)
- {
- // read data
- string line = reader.ReadLine();
- if (line == null)
- break;
- Match header = this.MessageHeaderPattern.Match(line);
-
- // validate
- if (message.Text == null && !header.Success)
- throw new LogParseException("Found a log message with no SMAPI metadata. Is this a SMAPI log file?");
+ // read line
+ string line = reader.ReadLine();
+ if (line == null)
+ break;
- // start or continue message
- if (header.Success)
- {
- if (message.Text != null)
- yield return message;
+ // match header
+ Match header = this.MessageHeaderPattern.Match(line);
+ bool isNewMessage = header.Success;
- message = new LogMessage
- {
- Time = header.Groups["time"].Value,
- Level = Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true),
- Mod = header.Groups["modName"].Value,
- Text = line.Substring(header.Length)
- };
+ // start/continue message
+ if (isNewMessage)
+ {
+ if (builder.Started)
+ {
+ yield return builder.Build();
+ builder.Clear();
}
- else
- message.Text += "\n" + line;
+
+ builder.Start(
+ time: header.Groups["time"].Value,
+ level: Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true),
+ mod: header.Groups["modName"].Value,
+ text: line.Substring(header.Length)
+ );
}
+ else
+ {
+ if (!builder.Started)
+ throw new LogParseException("Found a log message with no SMAPI metadata. Is this a SMAPI log file?");
- // end last message
- if (message.Text != null)
- yield return message;
+ builder.AddLine(line);
+ }
}
+
+ // end last message
+ if (builder.Started)
+ yield return builder.Build();
}
}
}
diff --git a/src/SMAPI.Web/Program.cs b/src/SMAPI.Web/Program.cs
index 5856fc98..5d13cdf3 100644
--- a/src/SMAPI.Web/Program.cs
+++ b/src/SMAPI.Web/Program.cs
@@ -16,6 +16,8 @@ namespace StardewModdingAPI.Web
// configure web server
WebHost
.CreateDefaultBuilder(args)
+ .CaptureStartupErrors(true)
+ .UseSetting("detailedErrors", "true")
.UseStartup<Startup>()
.Build()
.Run();
diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj
index 97bea0fb..0a978b30 100644
--- a/src/SMAPI.Web/SMAPI.Web.csproj
+++ b/src/SMAPI.Web/SMAPI.Web.csproj
@@ -12,14 +12,14 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Azure.Storage.Blobs" Version="12.3.0" />
+ <PackageReference Include="Azure.Storage.Blobs" Version="12.4.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" />
- <PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" />
- <PackageReference Include="Hangfire.Mongo" Version="0.6.6" />
- <PackageReference Include="HtmlAgilityPack" Version="1.11.20" />
+ <PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" />
+ <PackageReference Include="Hangfire.Mongo" Version="0.6.7" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.11.23" />
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
<PackageReference Include="JetBrains.Annotations" Version="2019.1.3" />
- <PackageReference Include="Markdig" Version="0.18.1" />
+ <PackageReference Include="Markdig" Version="0.18.3" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" />
@@ -27,7 +27,7 @@
<PackageReference Include="Mongo2Go" Version="2.2.12" />
<PackageReference Include="MongoDB.Driver" Version="2.10.2" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" />
- <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" />
+ <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.0" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
</ItemGroup>
<ItemGroup>
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 29086472..56ef9a79 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -192,8 +192,7 @@ namespace StardewModdingAPI.Web
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// basic config
- if (env.IsDevelopment())
- app.UseDeveloperExceptionPage();
+ app.UseDeveloperExceptionPage();
app
.UseCors(policy => policy
.AllowAnyHeader()
diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
index a00c8387..7287e00b 100644
--- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
+++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
@@ -28,14 +28,16 @@
{
<meta name="robots" content="noindex" />
}
- <link rel="stylesheet" href="~/Content/css/json-validator.css?r=20191204" />
+ <link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" />
+ <link rel="stylesheet" href="~/Content/css/json-validator.css?r=202002" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
- <script src="~/Content/js/json-validator.js?r=20191204"></script>
+ <script src="~/Content/js/file-upload.js?r=202002"></script>
+ <script src="~/Content/js/json-validator.js?r=202002"></script>
<script>
$(function() {
smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @Json.Serialize(Model.PasteID));
@@ -86,6 +88,7 @@ else if (!isEditView && Model.PasteID != null)
{
<h2>Upload a JSON file</h2>
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
+ <input id="inputFile" type="file" />
<ol>
<li>
Choose the JSON format:<br />
@@ -97,7 +100,7 @@ else if (!isEditView && Model.PasteID != null)
</select>
</li>
<li>
- Drag the file onto this textbox (or paste the text in):<br />
+ Drag the file onto this textbox <small>(or <a href="#" id="choose-file-link">choose a file</a>)</small>:<br />
<textarea id="input" name="Content" placeholder="paste file here">@Model.Content</textarea>
</li>
<li>
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 87c7f918..2183992b 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -22,10 +22,13 @@
{
<meta name="robots" content="noindex" />
}
- <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20191127" />
+ <link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" />
+ <link rel="stylesheet" href="~/Content/css/log-parser.css?r=202002" />
+
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
- <script src="~/Content/js/log-parser.js?r=20190515"></script>
+ <script src="~/Content/js/file-upload.js?r=202002"></script>
+ <script src="~/Content/js/log-parser.js?r=202002"></script>
<script>
$(function() {
smapi.logParser({
@@ -135,9 +138,10 @@ else if (Model.ParsedLog?.IsValid == true)
<h2>How do I share my log?</h2>
<form action="@this.Url.PlainAction("PostAsync", "LogParser")" method="post">
+ <input id="inputFile" type="file" />
<ol>
<li>
- Drag the file onto this textbox (or paste the text in):<br />
+ Drag the file onto this textbox <small>(or <a href="#" id="choose-file-link">choose a file</a>)</small>:<br />
<textarea id="input" name="input" placeholder="paste log here"></textarea>
</li>
<li>
@@ -300,7 +304,7 @@ else if (Model.ParsedLog?.IsValid == true)
string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable
<tr class="mod @levelStr @sectionStartClass"
- @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
+ @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
<td v-pre>@message.Time</td>
<td v-pre>@message.Level.ToString().ToUpper()</td>
diff --git a/src/SMAPI.Web/wwwroot/Content/css/file-upload.css b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css
new file mode 100644
index 00000000..ff170691
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css
@@ -0,0 +1,25 @@
+#inputFile {
+ display: none;
+}
+
+#input {
+ width: 100%;
+ height: 20em;
+ max-height: 70%;
+ margin: auto;
+ box-sizing: border-box;
+ border-radius: 5px;
+ border: 1px solid #000088;
+ outline: none;
+ box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
+}
+
+#submit {
+ font-size: 1.5em;
+ border-radius: 5px;
+ outline: none;
+ box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
+ cursor: pointer;
+ border: 1px solid #008800;
+ background-color: #cfc;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
index 18195098..de0f8fed 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
@@ -90,28 +90,3 @@
.footer-tip a {
color: gray;
}
-
-/*********
-** Upload form
-*********/
-#input {
- width: 100%;
- height: 20em;
- max-height: 70%;
- margin: auto;
- box-sizing: border-box;
- border-radius: 5px;
- border: 1px solid #000088;
- outline: none;
- box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
-}
-
-#submit {
- font-size: 1.5em;
- border-radius: 5px;
- outline: none;
- box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
- cursor: pointer;
- border: 1px solid #008800;
- background-color: #cfc;
-}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
index 4d4ab326..bfbc8982 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
@@ -301,24 +301,3 @@ div[data-os] {
display: none;
}
-#input {
- width: 100%;
- height: 20em;
- max-height: 70%;
- margin: auto;
- box-sizing: border-box;
- border-radius: 5px;
- border: 1px solid #000088;
- outline: none;
- box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
-}
-
-#submit {
- font-size: 1.5em;
- border-radius: 5px;
- outline: none;
- box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
- cursor: pointer;
- border: 1px solid #008800;
- background-color: #cfc;
-}
diff --git a/src/SMAPI.Web/wwwroot/Content/js/file-upload.js b/src/SMAPI.Web/wwwroot/Content/js/file-upload.js
new file mode 100644
index 00000000..411efad3
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/js/file-upload.js
@@ -0,0 +1,71 @@
+/* globals $ */
+var smapi = smapi || {};
+
+/**
+ * Implements the logic for a log/JSON file upload form.
+ *
+ * @param {object} opts The file upload form options.
+ * @param {jQuery} opts.chooseFileLink The clickable link which shows the file chooser.
+ * @param {jQuery} opts.chooseFileInput The file input element.
+ * @param {jQuery} opts.contentArea The file content area.
+ * @param {jQuery} opts.submitButton The submit button.
+ */
+smapi.fileUpload = function (opts) {
+ /**
+ * Toggle the submit button if the form has content.
+ */
+ var toggleSubmit = function () {
+ var hasText = !!opts.contentArea.val().trim();
+ opts.submitButton.prop("disabled", !hasText);
+ };
+
+ /**
+ * Paste the content of a file into the content area.
+ * @param {File} file The file whose content to paste.
+ */
+ var pasteFile = function (file) {
+ var reader = new FileReader();
+ reader.onload = $.proxy(function (file, $input, event) {
+ $input.val(event.target.result);
+ toggleSubmit();
+ }, this, file, $("#input"));
+ reader.readAsText(file);
+ };
+
+ // initialize
+ if (opts.contentArea.length) {
+ // disable submit button if it's empty
+ opts.contentArea.on("input", toggleSubmit);
+ toggleSubmit();
+
+ // drag & drop file
+ opts.contentArea.on({
+ "dragover dragenter": function (e) {
+ e.preventDefault();
+ },
+ "drop": function (e) {
+ e.preventDefault();
+
+ var transfer = e.originalEvent.dataTransfer;
+ if (transfer && transfer.files.length)
+ pasteFile(transfer.files[0]);
+ }
+ });
+
+ // choose file
+ opts.chooseFileLink.on({
+ "click": function (e) {
+ e.preventDefault();
+ opts.chooseFileInput.click();
+ }
+ });
+ o