Merge branch 'json-validator' into develop
This commit is contained in:
commit
0c5fa11809
|
@ -6,6 +6,7 @@ and update check API.
|
|||
## Contents
|
||||
* [Overview](#overview)
|
||||
* [Log parser](#log-parser)
|
||||
* [JSON validator](#json-validator)
|
||||
* [Web API](#web-api)
|
||||
* [For SMAPI developers](#for-smapi-developers)
|
||||
* [Local development](#local-development)
|
||||
|
@ -16,9 +17,41 @@ The `SMAPI.Web` project provides an API and web UI hosted at `*.smapi.io`.
|
|||
|
||||
### Log parser
|
||||
The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are
|
||||
persisted in a compressed form to Pastebin.
|
||||
persisted in a compressed form to Pastebin. The log parser lives at https://log.smapi.io.
|
||||
|
||||
The log parser lives at https://log.smapi.io.
|
||||
### JSON validator
|
||||
The JSON validator provides a web UI for uploading and sharing JSON files, and validating them
|
||||
as plain JSON or against a predefined format like `manifest.json` or Content Patcher's
|
||||
`content.json`. The JSON validator lives at https://json.smapi.io.
|
||||
|
||||
Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/)
|
||||
format, with some special properties:
|
||||
* The root schema may have a `@documentationURL` field, which is the URL to the user-facing
|
||||
documentation for the format (if any).
|
||||
* Any part of the schema can define an `@errorMessages` field, which specifies user-friendly errors
|
||||
which override the auto-generated messages. These are indexed by error type. For example:
|
||||
```js
|
||||
"pattern": "^[a-zA-Z0-9_.-]+\\.dll$",
|
||||
"@errorMessages": {
|
||||
"pattern": "Invalid value; must be a filename ending with .dll."
|
||||
}
|
||||
```
|
||||
|
||||
You can also reference these schemas in your JSON file directly using the `$schema` field, for
|
||||
text editors that support schema validation. For example:
|
||||
```js
|
||||
{
|
||||
"$schema": "https://smapi.io/schemas/manifest.json",
|
||||
"Name": "Some mod",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Current schemas:
|
||||
|
||||
format | schema URL
|
||||
------ | ----------
|
||||
[SMAPI `manifest.json`](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest) | https://smapi.io/schemas/manifest.json
|
||||
|
||||
### Web API
|
||||
SMAPI provides a web API at `api.smapi.io` for use by SMAPI and external tools. The URL includes a
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Schema;
|
||||
using StardewModdingAPI.Web.Framework;
|
||||
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
||||
using StardewModdingAPI.Web.Framework.Compression;
|
||||
using StardewModdingAPI.Web.Framework.ConfigModels;
|
||||
using StardewModdingAPI.Web.ViewModels.JsonValidator;
|
||||
|
||||
namespace StardewModdingAPI.Web.Controllers
|
||||
{
|
||||
/// <summary>Provides a web UI for validating JSON schemas.</summary>
|
||||
internal class JsonValidatorController : Controller
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The site config settings.</summary>
|
||||
private readonly SiteConfig Config;
|
||||
|
||||
/// <summary>The underlying Pastebin client.</summary>
|
||||
private readonly IPastebinClient Pastebin;
|
||||
|
||||
/// <summary>The underlying text compression helper.</summary>
|
||||
private readonly IGzipHelper GzipHelper;
|
||||
|
||||
/// <summary>The section URL for the schema validator.</summary>
|
||||
private string SectionUrl => this.Config.JsonValidatorUrl;
|
||||
|
||||
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
|
||||
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
|
||||
{
|
||||
["none"] = "None",
|
||||
["manifest"] = "Manifest"
|
||||
};
|
||||
|
||||
/// <summary>The schema ID to use if none was specified.</summary>
|
||||
private string DefaultSchemaID = "manifest";
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/***
|
||||
** Constructor
|
||||
***/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="siteConfig">The context config settings.</param>
|
||||
/// <param name="pastebin">The Pastebin API client.</param>
|
||||
/// <param name="gzipHelper">The underlying text compression helper.</param>
|
||||
public JsonValidatorController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
|
||||
{
|
||||
this.Config = siteConfig.Value;
|
||||
this.Pastebin = pastebin;
|
||||
this.GzipHelper = gzipHelper;
|
||||
}
|
||||
|
||||
/***
|
||||
** Web UI
|
||||
***/
|
||||
/// <summary>Render the schema validator UI.</summary>
|
||||
/// <param name="schemaName">The schema name with which to validate the JSON.</param>
|
||||
/// <param name="id">The paste ID.</param>
|
||||
[HttpGet]
|
||||
[Route("json")]
|
||||
[Route("json/{schemaName}")]
|
||||
[Route("json/{schemaName}/{id}")]
|
||||
public async Task<ViewResult> Index(string schemaName = null, string id = null)
|
||||
{
|
||||
schemaName = this.NormaliseSchemaName(schemaName);
|
||||
|
||||
var result = new JsonValidatorModel(this.SectionUrl, id, schemaName, this.SchemaFormats);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return this.View("Index", result);
|
||||
|
||||
// fetch raw JSON
|
||||
PasteInfo paste = await this.GetAsync(id);
|
||||
if (string.IsNullOrWhiteSpace(paste.Content))
|
||||
return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
|
||||
result.SetContent(paste.Content);
|
||||
|
||||
// parse JSON
|
||||
JToken parsed;
|
||||
try
|
||||
{
|
||||
parsed = JToken.Parse(paste.Content);
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message)));
|
||||
}
|
||||
|
||||
// format JSON
|
||||
result.SetContent(parsed.ToString(Formatting.Indented));
|
||||
|
||||
// skip if no schema selected
|
||||
if (schemaName == "none")
|
||||
return this.View("Index", result);
|
||||
|
||||
// load schema
|
||||
JSchema schema;
|
||||
{
|
||||
FileInfo schemaFile = this.FindSchemaFile(schemaName);
|
||||
if (schemaFile == null)
|
||||
return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'."));
|
||||
schema = JSchema.Parse(System.IO.File.ReadAllText(schemaFile.FullName));
|
||||
}
|
||||
|
||||
// get format doc URL
|
||||
result.FormatUrl = this.GetExtensionField<string>(schema, "@documentationUrl");
|
||||
|
||||
// validate JSON
|
||||
parsed.IsValid(schema, out IList<ValidationError> rawErrors);
|
||||
var errors = rawErrors
|
||||
.Select(error => new JsonValidatorErrorModel(error.LineNumber, error.Path, this.GetFlattenedError(error)))
|
||||
.ToArray();
|
||||
return this.View("Index", result.AddErrors(errors));
|
||||
}
|
||||
|
||||
/***
|
||||
** JSON
|
||||
***/
|
||||
/// <summary>Save raw JSON data.</summary>
|
||||
[HttpPost, AllowLargePosts]
|
||||
[Route("json")]
|
||||
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
|
||||
{
|
||||
if (request == null)
|
||||
return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid."));
|
||||
|
||||
// normalise schema name
|
||||
string schemaName = this.NormaliseSchemaName(request.SchemaName);
|
||||
|
||||
// get raw log text
|
||||
string input = request.Content;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty."));
|
||||
|
||||
// upload log
|
||||
input = this.GzipHelper.CompressString(input);
|
||||
SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input);
|
||||
|
||||
// handle errors
|
||||
if (!result.Success)
|
||||
return this.View("Index", new JsonValidatorModel(this.SectionUrl, result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}"));
|
||||
|
||||
// redirect to view
|
||||
UriBuilder uri = new UriBuilder(new Uri(this.SectionUrl));
|
||||
uri.Path = $"{uri.Path.TrimEnd('/')}/{schemaName}/{result.ID}";
|
||||
return this.Redirect(uri.Uri.ToString());
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>Fetch raw text from Pastebin.</summary>
|
||||
/// <param name="id">The Pastebin paste ID.</param>
|
||||
private async Task<PasteInfo> GetAsync(string id)
|
||||
{
|
||||
PasteInfo response = await this.Pastebin.GetAsync(id);
|
||||
response.Content = this.GzipHelper.DecompressString(response.Content);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>Get a flattened, human-readable message representing a schema validation error.</summary>
|
||||
/// <param name="error">The error to represent.</param>
|
||||
/// <param name="indent">The indentation level to apply for inner errors.</param>
|
||||
private string GetFlattenedError(ValidationError error, int indent = 0)
|
||||
{
|
||||
// get override error
|
||||
string message = this.GetOverrideError(error.Schema, error.ErrorType);
|
||||
if (message != null)
|
||||
return message;
|
||||
|
||||
// get friendly representation of main error
|
||||
message = error.Message;
|
||||
switch (error.ErrorType)
|
||||
{
|
||||
case ErrorType.Enum:
|
||||
message = $"Invalid value. Found '{error.Value}', but expected one of '{string.Join("', '", error.Schema.Enum)}'.";
|
||||
break;
|
||||
|
||||
case ErrorType.Required:
|
||||
message = $"Missing required fields: {string.Join(", ", (List<string>)error.Value)}.";
|
||||
break;
|
||||
}
|
||||
|
||||
// add inner errors
|
||||
foreach (ValidationError childError in error.ChildErrors)
|
||||
message += "\n" + "".PadLeft(indent * 2, ' ') + $"==> {childError.Path}: " + this.GetFlattenedError(childError, indent + 1);
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>Get a normalised schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
|
||||
/// <param name="schemaName">The raw schema name to normalise.</param>
|
||||
private string NormaliseSchemaName(string schemaName)
|
||||
{
|
||||
schemaName = schemaName?.Trim().ToLower();
|
||||
return !string.IsNullOrWhiteSpace(schemaName)
|
||||
? schemaName
|
||||
: this.DefaultSchemaID;
|
||||
}
|
||||
|
||||
/// <summary>Get the schema file given its unique ID.</summary>
|
||||
/// <param name="id">The schema ID.</param>
|
||||
private FileInfo FindSchemaFile(string id)
|
||||
{
|
||||
// normalise ID
|
||||
id = id?.Trim().ToLower();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return null;
|
||||
|
||||
// get matching file
|
||||
DirectoryInfo schemaDir = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas"));
|
||||
foreach (FileInfo file in schemaDir.EnumerateFiles("*.json"))
|
||||
{
|
||||
if (file.Name.Equals($"{id}.json"))
|
||||
return file;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Get an override error from the JSON schema, if any.</summary>
|
||||
/// <param name="schema">The schema or subschema that raised the error.</param>
|
||||
/// <param name="errorType">The error type.</param>
|
||||
private string GetOverrideError(JSchema schema, ErrorType errorType)
|
||||
{
|
||||
// get override errors
|
||||
IDictionary<string, string> errors = this.GetExtensionField<Dictionary<string, string>>(schema, "@errorMessages");
|
||||
if (errors == null)
|
||||
return null;
|
||||
errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase);
|
||||
|
||||
// get matching error
|
||||
return errors.TryGetValue(errorType.ToString(), out string errorPhrase)
|
||||
? errorPhrase
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Get an extension field from a JSON schema.</summary>
|
||||
/// <typeparam name="T">The field type.</typeparam>
|
||||
/// <param name="schema">The schema whose extension fields to search.</param>
|
||||
/// <param name="key">The case-insensitive field key.</param>
|
||||
private T GetExtensionField<T>(JSchema schema, string key)
|
||||
{
|
||||
if (schema.ExtensionData != null)
|
||||
{
|
||||
foreach (var pair in schema.ExtensionData)
|
||||
{
|
||||
if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase))
|
||||
return pair.Value.ToObject<T>();
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,11 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StardewModdingAPI.Web.Framework;
|
||||
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
||||
using StardewModdingAPI.Web.Framework.Compression;
|
||||
using StardewModdingAPI.Web.Framework.ConfigModels;
|
||||
using StardewModdingAPI.Web.Framework.LogParsing;
|
||||
using StardewModdingAPI.Web.Framework.LogParsing.Models;
|
||||
|
@ -27,9 +25,8 @@ namespace StardewModdingAPI.Web.Controllers
|
|||
/// <summary>The underlying Pastebin client.</summary>
|
||||
private readonly IPastebinClient Pastebin;
|
||||
|
||||
/// <summary>The first bytes in a valid zip file.</summary>
|
||||
/// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks>
|
||||
private const uint GzipLeadBytes = 0x8b1f;
|
||||
/// <summary>The underlying text compression helper.</summary>
|
||||
private readonly IGzipHelper GzipHelper;
|
||||
|
||||
|
||||
/*********
|
||||
|
@ -41,10 +38,12 @@ namespace StardewModdingAPI.Web.Controllers
|
|||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="siteConfig">The context config settings.</param>
|
||||
/// <param name="pastebin">The Pastebin API client.</param>
|
||||
public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin)
|
||||
/// <param name="gzipHelper">The underlying text compression helper.</param>
|
||||
public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
|
||||
{
|
||||
this.Config = siteConfig.Value;
|
||||
this.Pastebin = pastebin;
|
||||
this.GzipHelper = gzipHelper;
|
||||
}
|
||||
|
||||
/***
|
||||
|
@ -84,8 +83,8 @@ namespace StardewModdingAPI.Web.Controllers
|
|||
return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." });
|
||||
|
||||
// upload log
|
||||
input = this.CompressString(input);
|
||||
SavePasteResult result = await this.Pastebin.PostAsync(input);
|
||||
input = this.GzipHelper.CompressString(input);
|
||||
SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", input);
|
||||
|
||||
// handle errors
|
||||
if (!result.Success)
|
||||
|
@ -106,75 +105,8 @@ namespace StardewModdingAPI.Web.Controllers
|
|||
private async Task<PasteInfo> GetAsync(string id)
|
||||
{
|
||||
PasteInfo response = await this.Pastebin.GetAsync(id);
|
||||
response.Content = this.DecompressString(response.Content);
|
||||
response.Content = this.GzipHelper.DecompressString(response.Content);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>Compress a string.</summary>
|
||||
/// <param name="text">The text to compress.</param>
|
||||
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
|
||||
private string CompressString(string text)
|
||||
{
|
||||
// get raw bytes
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(text);
|
||||
|
||||
// compressed
|
||||
byte[] compressedData;
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true))
|
||||
zipStream.Write(buffer, 0, buffer.Length);
|
||||
|
||||
stream.Position = 0;
|
||||
compressedData = new byte[stream.Length];
|
||||
stream.Read(compressedData, 0, compressedData.Length);
|
||||
}
|
||||
|
||||
// prefix length
|
||||
byte[] zipBuffer = new byte[compressedData.Length + 4];
|
||||
Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4);
|
||||
|
||||
// return string representation
|
||||
return Convert.ToBase64String(zipBuffer);
|
||||
}
|
||||
|
||||
/// <summary>Decompress a string.</summary>
|
||||
/// <param name="rawText">The compressed text.</param>
|
||||
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
|
||||
private string DecompressString(string rawText)
|
||||
{
|
||||
// get raw bytes
|
||||
byte[] zipBuffer;
|
||||
try
|
||||
{
|
||||
zipBuffer = Convert.FromBase64String(rawText);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return rawText; // not valid base64, wasn't compressed by the log parser
|
||||
}
|
||||
|
||||
// skip if not gzip
|
||||
if (BitConverter.ToUInt16(zipBuffer, 4) != LogParserController.GzipLeadBytes)
|
||||
return rawText;
|
||||
|
||||
// decompress
|
||||
using (MemoryStream memoryStream = new MemoryStream())
|
||||
{
|
||||
// read length prefix
|
||||
int dataLength = BitConverter.ToInt32(zipBuffer, 0);
|
||||
memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4);
|
||||
|
||||
// read data
|
||||
byte[] buffer = new byte[dataLength];
|
||||
memoryStream.Position = 0;
|
||||
using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
|
||||
gZipStream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
// return original string
|
||||
return Encoding.UTF8.GetString(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
|||
Task<PasteInfo> GetAsync(string id);
|
||||
|
||||
/// <summary>Save a paste to Pastebin.</summary>
|
||||
/// <param name="name">The paste name.</param>
|
||||
/// <param name="content">The paste content.</param>
|
||||
Task<SavePasteResult> PostAsync(string content);
|
||||
Task<SavePasteResult> PostAsync(string name, string content);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,8 +67,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
|||
}
|
||||
|
||||
/// <summary>Save a paste to Pastebin.</summary>
|
||||
/// <param name="name">The paste name.</param>
|
||||
/// <param name="content">The paste content.</param>
|
||||
public async Task<SavePasteResult> PostAsync(string content)
|
||||
public async Task<SavePasteResult> PostAsync(string name, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -85,7 +86,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
|||
api_user_key = this.UserKey,
|
||||
api_dev_key = this.DevKey,
|
||||
api_paste_private = 1, // unlisted
|
||||
api_paste_name = $"SMAPI log {DateTime.UtcNow:s}",
|
||||
api_paste_name = name,
|
||||
api_paste_expire_date = "N", // never expire
|
||||
api_paste_code = content
|
||||
}))
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace StardewModdingAPI.Web.Framework.Compression
|
||||
{
|
||||
/// <summary>Handles GZip compression logic.</summary>
|
||||
internal class GzipHelper : IGzipHelper
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The first bytes in a valid zip file.</summary>
|
||||
/// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks>
|
||||
private const uint GzipLeadBytes = 0x8b1f;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Compress a string.</summary>
|
||||
/// <param name="text">The text to compress.</param>
|
||||
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
|
||||
public string CompressString(string text)
|
||||
{
|
||||
// get raw bytes
|
||||
byte[] buffer = Encoding.UTF8.GetBytes(text);
|
||||
|
||||
// compressed
|
||||
byte[] compressedData;
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true))
|
||||
zipStream.Write(buffer, 0, buffer.Length);
|
||||
|
||||
stream.Position = 0;
|
||||
compressedData = new byte[stream.Length];
|
||||
stream.Read(compressedData, 0, compressedData.Length);
|
||||
}
|
||||
|
||||
// prefix length
|
||||
byte[] zipBuffer = new byte[compressedData.Length + 4];
|
||||
Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4);
|
||||
|
||||
// return string representation
|
||||
return Convert.ToBase64String(zipBuffer);
|
||||
}
|
||||
|
||||
/// <summary>Decompress a string.</summary>
|
||||
/// <param name="rawText">The compressed text.</param>
|
||||
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
|
||||
public string DecompressString(string rawText)
|
||||
{
|
||||
// get raw bytes
|
||||
byte[] zipBuffer;
|
||||
try
|
||||
{
|
||||
zipBuffer = Convert.FromBase64String(rawText);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return rawText; // not valid base64, wasn't compressed by the log parser
|
||||
}
|
||||
|
||||
// skip if not gzip
|
||||
if (BitConverter.ToUInt16(zipBuffer, 4) != GzipHelper.GzipLeadBytes)
|
||||
return rawText;
|
||||
|
||||
// decompress
|
||||
using (MemoryStream memoryStream = new MemoryStream())
|
||||
{
|
||||
// read length prefix
|
||||
int dataLength = BitConverter.ToInt32(zipBuffer, 0);
|
||||
memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4);
|
||||
|
||||
// read data
|
||||
byte[] buffer = new byte[dataLength];
|
||||
memoryStream.Position = 0;
|
||||
using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
|
||||
gZipStream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
// return original string
|
||||
return Encoding.UTF8.GetString(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
namespace StardewModdingAPI.Web.Framework.Compression
|
||||
{
|
||||
/// <summary>Handles GZip compression logic.</summary>
|
||||
internal interface IGzipHelper
|
||||
{
|
||||
/*********
|
||||
** Methods
|
||||
*********/
|
||||
/// <summary>Compress a string.</summary>
|
||||
/// <param name="text">The text to compress.</param>
|
||||
string CompressString(string text);
|
||||
|
||||
/// <summary>Decompress a string.</summary>
|
||||
/// <param name="rawText">The compressed text.</param>
|
||||
string DecompressString(string rawText);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
|||
/// <summary>The root URL for the log parser.</summary>
|
||||
public string LogParserUrl { get; set; }
|
||||
|
||||
/// <summary>The root URL for the JSON validator.</summary>
|
||||
public string JsonValidatorUrl { get; set; }
|
||||
|
||||
/// <summary>The root URL for the mod list.</summary>
|
||||
public string ModListUrl { get; set; }
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.2.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.8.1" />
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
|
||||
<PackageReference Include="Pathoschild.FluentNexus" Version="0.7.1" />
|
||||
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -20,6 +20,7 @@ using StardewModdingAPI.Web.Framework.Clients.GitHub;
|
|||
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
|
||||
using StardewModdingAPI.Web.Framework.Clients.Nexus;
|
||||
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
||||
using StardewModdingAPI.Web.Framework.Compression;
|
||||
using StardewModdingAPI.Web.Framework.ConfigModels;
|
||||
using StardewModdingAPI.Web.Framework.RewriteRules;
|
||||
|
||||
|
@ -149,6 +150,9 @@ namespace StardewModdingAPI.Web
|
|||
devKey: api.PastebinDevKey
|
||||
));
|
||||
}
|
||||
|
||||
// init helpers
|
||||
services.AddSingleton<IGzipHelper>(new GzipHelper());
|
||||
}
|
||||
|
||||
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
|
||||
|
@ -199,7 +203,7 @@ namespace StardewModdingAPI.Web
|
|||
redirects.Add(new ConditionalRewriteSubdomainRule(
|
||||
shouldRewrite: req =>
|
||||
req.Host.Host != "localhost"
|
||||
&& (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods."))
|
||||
&& (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("json.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods."))
|
||||
&& !req.Path.StartsWithSegments("/content")
|
||||
&& !req.Path.StartsWithSegments("/favicon.ico")
|
||||
));
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||
{
|
||||
/// <summary>The view model for a JSON validator error.</summary>
|
||||
public class JsonValidatorErrorModel
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The line number on which the error occurred.</summary>
|
||||
public int Line { get; set; }
|
||||
|
||||
/// <summary>The field path in the JSON file where the error occurred.</summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>A human-readable description of the error.</summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public JsonValidatorErrorModel() { }
|
||||
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="line">The line number on which the error occurred.</param>
|
||||
/// <param name="path">The field path in the JSON file where the error occurred.</param>
|
||||
/// <param name="message">A human-readable description of the error.</param>
|
||||
public JsonValidatorErrorModel(int line, string path, string message)
|
||||
{
|
||||
this.Line = line;
|
||||
this.Path = path;
|
||||
this.Message = message;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||
{
|
||||
/// <summary>The view model for the JSON validator page.</summary>
|
||||
public class JsonValidatorModel
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The root URL for the log parser controller.</summary>
|
||||
public string SectionUrl { get; set; }
|
||||
|
||||
/// <summary>The paste ID.</summary>
|
||||
public string PasteID { get; set; }
|
||||
|
||||
/// <summary>The schema name with which the JSON was validated.</summary>
|
||||
public string SchemaName { get; set; }
|
||||
|
||||
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
|
||||
public readonly IDictionary<string, string> SchemaFormats;
|
||||
|
||||
/// <summary>The validated content.</summary>
|
||||
public string Content { get; set; }
|
||||
|
||||
/// <summary>The schema validation errors, if any.</summary>
|
||||
public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
|
||||
|
||||
/// <summary>An error which occurred while uploading the JSON to Pastebin.</summary>
|
||||
public string UploadError { get; set; }
|
||||
|
||||
/// <summary>An error which occurred while parsing the JSON.</summary>
|
||||
public string ParseError { get; set; }
|
||||
|
||||
/// <summary>A web URL to the user-facing format documentation.</summary>
|
||||
public string FormatUrl { get; set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public JsonValidatorModel() { }
|
||||
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="sectionUrl">The root URL for the log parser controller.</param>
|
||||
/// <param name="pasteID">The paste ID.</param>
|
||||
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
|
||||
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
|
||||
public JsonValidatorModel(string sectionUrl, string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
|
||||
{
|
||||
this.SectionUrl = sectionUrl;
|
||||
this.PasteID = pasteID;
|
||||
this.SchemaName = schemaName;
|
||||
this.SchemaFormats = schemaFormats;
|
||||
}
|
||||
|
||||
/// <summary>Set the validated content.</summary>
|
||||
/// <param name="content">The validated content.</param>
|
||||
public JsonValidatorModel SetContent(string content)
|
||||
{
|
||||
this.Content = content;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Set the error which occurred while uploading the log to Pastebin.</summary>
|
||||
/// <param name="error">The error message.</param>
|
||||
public JsonValidatorModel SetUploadError(string error)
|
||||
{
|
||||
this.UploadError = error;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Set the error which occurred while parsing the JSON.</summary>
|
||||
/// <param name="error">The error message.</param>
|
||||
public JsonValidatorModel SetParseError(string error)
|
||||
{
|
||||
this.ParseError = error;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Add validation errors to the response.</summary>
|
||||
/// <param name="errors">The schema validation errors.</param>
|
||||
public JsonValidatorModel AddErrors(params JsonValidatorErrorModel[] errors)
|
||||
{
|
||||
this.Errors = this.Errors.Concat(errors).ToArray();
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||
{
|
||||
/// <summary>The view model for a JSON validation request.</summary>
|
||||
public class JsonValidatorRequestModel
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The schema name with which to validate the JSON.</summary>
|
||||
public string SchemaName { get; set; }
|
||||
|
||||
/// <summary>The raw content to validate.</summary>
|
||||
public string Content { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
@using StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||
@model JsonValidatorModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "JSON validator";
|
||||
}
|
||||
|
||||
@section Head {
|
||||
@if (Model.PasteID != null)
|
||||
{
|
||||
<meta name="robots" content="noindex" />
|
||||
}
|
||||
<link rel="stylesheet" href="~/Content/css/json-validator.css" />
|
||||
<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"></script>
|
||||
<script>
|
||||
$(function () {
|
||||
smapi.jsonValidator(@Json.Serialize(Model.SectionUrl), @Json.Serialize(Model.PasteID));
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@* upload result banner *@
|
||||
@if (Model.UploadError != null)
|
||||
{
|
||||
<div class="banner error">
|
||||
<strong>Oops, the server ran into trouble saving that file.</strong><br />
|
||||
<small>Error details: @Model.UploadError</small>
|
||||
</div>
|
||||
}
|
||||
else if (Model.ParseError != null)
|
||||
{
|
||||
<div class="banner error">
|
||||
<strong>Oops, couldn't parse that JSON.</strong><br />
|
||||
Share this link to let someone see this page: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br />
|
||||
(Or <a href="@Model.SectionUrl">validate a new file</a>.)<br />
|
||||
<br />
|
||||
<small v-pre>Error details: @Model.ParseError</small>
|
||||
</div>
|
||||
}
|
||||
else if (Model.PasteID != null)
|
||||
{
|
||||
<div class="banner success">
|
||||
<strong>Share this link to let someone else see this page:</strong> <code>@(new Uri(new Uri(Model.SectionUrl), $"{Model.SchemaName}/{Model.PasteID}"))</code><br />
|
||||
(Or <a href="@Model.SectionUrl">validate a new file</a>.)
|
||||
</div>
|
||||
}
|
||||
|
||||
@* upload new file *@
|
||||
@if (Model.Content == null)
|
||||
{
|
||||
<h2>Upload a JSON file</h2>
|
||||
<form action="@Model.SectionUrl" method="post">
|
||||
<ol>
|
||||
<li>
|
||||
Choose the JSON format:<br />
|
||||
<select id="format" name="SchemaName">
|
||||
@foreach (var pair in Model.SchemaFormats)
|
||||
{
|
||||
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
|
||||
}
|
||||
</select>
|
||||
</li>
|
||||
<li>
|
||||
Drag the file onto this textbox (or paste the text in):<br />
|
||||
<textarea id="input" name="Content" placeholder="paste file here"></textarea>
|
||||
</li>
|
||||
<li>
|
||||
Click this button:<br />
|
||||
<input type="submit" id="submit" value="save file" />
|
||||
</li>
|
||||
</ol>
|
||||
</form>
|
||||
}
|
||||
|
||||
@* validation results *@
|
||||
@if (Model.Content != null)
|
||||
{
|
||||
<div id="output">
|
||||
@if (Model.UploadError == null)
|
||||
{
|
||||
<div>
|
||||
Change JSON format:
|
||||
<select id="format" name="format">
|
||||
@foreach (var pair in Model.SchemaFormats)
|
||||
{
|
||||
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h2>Validation errors</h2>
|
||||
@if (Model.FormatUrl != null)
|
||||
{
|
||||
<p>See <a href="@Model.FormatUrl">format documentation</a>.</p>
|
||||
}
|
||||
|
||||
@if (Model.Errors.Any())
|
||||
{
|
||||
<table id="metadata" class="table">
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Field</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
|
||||
@foreach (JsonValidatorErrorModel error in Model.Errors)
|
||||
{
|
||||
<tr>
|
||||
<td><a href="#L@(error.Line)">@error.Line</a></td>
|
||||
<td>@error.Path</td>
|
||||
<td>@error.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>No errors found.</p>
|
||||
}
|
||||
}
|
||||
|
||||
<h2>Raw content</h2>
|
||||
<pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre>
|
||||
</div>
|
||||
}
|
|
@ -16,9 +16,14 @@
|
|||
<h4>SMAPI</h4>
|
||||
<ul>
|
||||
<li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li>
|
||||
<li><a href="https://stardewvalleywiki.com/Modding:Index">Modding docs</a></li>
|
||||
</ul>
|
||||
|
||||
<h4>Tools</h4>
|
||||
<ul>
|
||||
<li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility</a></li>
|
||||
<li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li>
|
||||
<li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li>
|
||||
<li><a href="@SiteConfig.Value.JsonValidatorUrl">JSON validator</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="content-column">
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"RootUrl": "http://localhost:59482/",
|
||||
"ModListUrl": "http://localhost:59482/mods/",
|
||||
"LogParserUrl": "http://localhost:59482/log/",
|
||||
"JsonValidatorUrl": "http://localhost:59482/json/",
|
||||
"BetaEnabled": false,
|
||||
"BetaBlurb": null
|
||||
},
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
"RootUrl": null, // see top note
|
||||
"ModListUrl": null, // see top note
|
||||
"LogParserUrl": null, // see top note
|
||||
"JsonValidatorUrl": null, // see top note
|
||||
"BetaEnabled": null, // see top note
|
||||
"BetaBlurb": null // see top note
|
||||
},
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
/*********
|
||||
** Main layout
|
||||
*********/
|
||||
#content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#output {
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#output table td {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#output table tr th,
|
||||
#output table tr td {
|
||||
padding: 0 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Result banner
|
||||
*********/
|
||||
.banner {
|
||||
border: 2px solid gray;
|
||||
border-radius: 5px;
|
||||
margin-top: 1em;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.banner.success {
|
||||
border-color: green;
|
||||
background: #CFC;
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
border-color: red;
|
||||
background: #FCC;
|
||||
}
|
||||
|
||||
/*********
|
||||
** Validation results
|
||||
*********/
|
||||
.table {
|
||||
border-bottom: 1px dashed #888888;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#metadata th, #metadata td {
|
||||
text-align: left;
|
||||
padding-right: 0.7em;
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid #000000;
|
||||
background: #ffffff;
|
||||
border-radius: 5px;
|
||||
border-spacing: 1px;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
box-shadow: 1px 1px 1px 1px #dddddd;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.table tr:nth-child(even) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#output div.sunlight-line-highlight-active {
|
||||
background-color: #eeeacc;
|
||||
}
|
||||
|
||||
/*********
|
||||
** 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;
|
||||
}
|
|
@ -73,7 +73,7 @@ a {
|
|||
}
|
||||
|
||||
#sidebar h4 {
|
||||
margin: 0 0 0.2em 0;
|
||||
margin: 1.5em 0 0.2em 0;
|
||||
width: 10em;
|
||||
border-bottom: 1px solid #CCC;
|
||||
font-size: 0.8em;
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
/* globals $ */
|
||||
|
||||
var smapi = smapi || {};
|
||||
|
||||
/**
|
||||
* Manages the logic for line range selections.
|
||||
* @param {int} maxLines The maximum number of lines in the content.
|
||||
*/
|
||||
smapi.LineNumberRange = function (maxLines) {
|
||||
var self = this;
|
||||
|
||||
/**
|
||||
* @var {int} minLine The first line in the selection, or null if no lines selected.
|
||||
*/
|
||||
self.minLine = null;
|
||||
|
||||
/**
|
||||
* @var {int} maxLine The last line in the selection, or null if no lines selected.
|
||||
*/
|
||||
self.maxLine = null;
|
||||
|
||||
/**
|
||||
* Parse line numbers from a URL hash.
|
||||
* @param {string} hash the URL hash to parse.
|
||||
*/
|
||||
self.parseFromUrlHash = function (hash) {
|
||||
self.minLine = null;
|
||||
self.maxLine = null;
|
||||
|
||||
// parse hash
|
||||
var hashParts = hash.match(/^#L(\d+)(?:-L(\d+))?$/);
|
||||
if (!hashParts || hashParts.length <= 1)
|
||||
return;
|
||||
|
||||
// extract min/max lines
|
||||
self.minLine = parseInt(hashParts[1]);
|
||||
self.maxLine = parseInt(hashParts[2]) || self.minLine;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a URL hash for the current line range.
|
||||
* @returns {string} The generated URL hash.
|
||||
*/
|
||||
self.buildHash = function() {
|
||||
if (!self.minLine)
|
||||
return "";
|
||||
else if (self.minLine === self.maxLine)
|
||||
return "#L" + self.minLine;
|
||||
else
|
||||
return "#L" + self.minLine + "-L" + self.maxLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all selected lines.
|
||||
* @returns {Array<int>} The selected line numbers.
|
||||
*/
|
||||
self.getLinesSelected = function() {
|
||||
// format
|
||||
if (!self.minLine)
|
||||
return [];
|
||||
|
||||
var lines = [];
|
||||
for (var i = self.minLine; i <= self.maxLine; i++)
|
||||
lines.push(i);
|
||||
return lines;
|
||||
};
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/**
|
||||
* UI logic for the JSON validator page.
|
||||
* @param {any} sectionUrl The base JSON validator page URL.
|
||||
* @param {any} pasteID The Pastebin paste ID for the content being viewed, if any.
|
||||
*/
|
||||
smapi.jsonValidator = function (sectionUrl, pasteID) {
|
||||
/**
|
||||
* The original content element.
|
||||
*/
|
||||
var originalContent = $("#raw-content").clone();
|
||||
|
||||
/**
|
||||
* The currently highlighted lines.
|
||||
*/
|
||||
var selection = new smapi.LineNumberRange();
|
||||
|
||||
/**
|
||||
* Rebuild the syntax-highlighted element.
|
||||
*/
|
||||
var formatCode = function () {
|
||||
// reset if needed
|
||||
$(".sunlight-container").replaceWith(originalContent.clone());
|
||||
|
||||
// apply default highlighting
|
||||
Sunlight.highlightAll({
|
||||
lineHighlight: selection.getLinesSelected()
|
||||
});
|
||||
|
||||
// fix line links
|
||||
$(".sunlight-line-number-margin a").each(function() {
|
||||
var link = $(this);
|
||||
var lineNumber = parseInt(link.text());
|
||||
link
|
||||
.attr("id", "L" + lineNumber)
|
||||
.attr("href", "#L" + lineNumber)
|
||||
.removeAttr("name")
|
||||
.data("line-number", lineNumber);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll the page so the selected range is visible.
|
||||
*/
|
||||
var scrollToRange = function() {
|
||||
if (!selection.minLine)
|
||||
return;
|
||||
|
||||
var targetLine = Math.max(1, selection.minLine - 5);
|
||||
$("#L" + targetLine).get(0).scrollIntoView();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialise the JSON validator page.
|
||||
*/
|
||||
var init = function () {
|
||||
// set initial code formatting
|
||||
selection.parseFromUrlHash(location.hash);
|
||||
formatCode();
|
||||
scrollToRange();
|
||||
|
||||
// update code formatting on hash change
|
||||
$(window).on("hashchange", function() {
|
||||
selection.parseFromUrlHash(location.hash);
|
||||
formatCode();
|
||||
scrollToRange();
|
||||
});
|
||||
|
||||
// change format
|
||||
$("#output #format").on("change", function() {
|
||||
var schemaName = $(this).val();
|
||||
location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString();
|
||||
});
|
||||
|
||||
// upload form
|
||||
var input = $("#input");
|
||||
if (input.length) {
|
||||
// disable submit if it's empty
|
||||
var toggleSubmit = function () {
|
||||
var hasText = !!input.val().trim();
|
||||
submit.prop("disabled", !hasText);
|
||||
};
|
||||
input.on("input", toggleSubmit);
|
||||
toggleSubmit();
|
||||
|
||||
// drag & drop file
|
||||
input.on({
|
||||
'dragover dragenter': function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
'drop': function (e) {
|
||||
var dataTransfer = e.originalEvent.dataTransfer;
|
||||
if (dataTransfer && dataTransfer.files.length) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var file = dataTransfer.files[0];
|
||||
var reader = new FileReader();
|
||||
reader.onload = $.proxy(function (file, $input, event) {
|
||||
$input.val(event.target.result);
|
||||
toggleSubmit();
|
||||
}, this, file, $("#input"));
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
init();
|
||||
};
|
|
@ -23,7 +23,7 @@ smapi.logParser = function (data, sectionUrl) {
|
|||
}
|
||||
|
||||
// set local time started
|
||||
if(data)
|
||||
if (data)
|
||||
data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2);
|
||||
|
||||
// init app
|
||||
|
@ -100,7 +100,7 @@ smapi.logParser = function (data, sectionUrl) {
|
|||
updateModFilters();
|
||||
},
|
||||
|
||||
filtersAllow: function(modId, level) {
|
||||
filtersAllow: function (modId, level) {
|
||||
return this.showMods[modId] !== false && this.showLevels[level] !== false;
|
||||
},
|
||||
|
||||
|
@ -121,16 +121,15 @@ smapi.logParser = function (data, sectionUrl) {
|
|||
var submit = $("#submit");
|
||||
|
||||
// instruction OS chooser
|
||||
var chooseSystem = function() {
|
||||
var chooseSystem = function () {
|
||||
systemInstructions.hide();
|
||||
systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show();
|
||||
}
|
||||
};
|
||||
systemOptions.on("click", chooseSystem);
|
||||
chooseSystem();
|
||||
|
||||
// disable submit if it's empty
|
||||
var toggleSubmit = function()
|
||||
{
|
||||
var toggleSubmit = function () {
|
||||
var hasText = !!input.val().trim();
|
||||
submit.prop("disabled", !hasText);
|
||||
}
|
||||
|
@ -139,18 +138,18 @@ smapi.logParser = function (data, sectionUrl) {
|
|||
|
||||
// drag & drop file
|
||||
input.on({
|
||||
'dragover dragenter': function(e) {
|
||||
'dragover dragenter': function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
'drop': function(e) {
|
||||
'drop': function (e) {
|
||||
var dataTransfer = e.originalEvent.dataTransfer;
|
||||
if (dataTransfer && dataTransfer.files.length) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var file = dataTransfer.files[0];
|
||||
var reader = new FileReader();
|
||||
reader.onload = $.proxy(function(file, $input, event) {
|
||||
reader.onload = $.proxy(function (file, $input, event) {
|
||||
$input.val(event.target.result);
|
||||
toggleSubmit();
|
||||
}, this, file, $("#input"));
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://smapi.io/schemas/manifest.json",
|
||||
"title": "SMAPI manifest",
|
||||
"description": "Manifest file for a SMAPI mod or content pack",
|
||||
"@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Name": {
|
||||
"title": "Mod name",
|
||||
"description": "The mod's display name. SMAPI uses this in player messages, logs, and errors.",
|
||||
"type": "string",
|
||||
"examples": [ "Lookup Anything" ]
|
||||
},
|
||||
"Author": {
|
||||
"title": "Mod author",
|
||||
"description": "The name of the person who created the mod. Ideally this should include the username used to publish mods.",
|
||||
"type": "string",
|
||||
"examples": [ "Pathoschild" ]
|
||||
},
|
||||
"Version": {
|
||||
"title": "Mod version",
|
||||
"description": "The mod's semantic version. Make sure you update this for each release! SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game).",
|
||||
"$ref": "#/definitions/SemanticVersion"
|
||||
},
|
||||
"Description": {
|
||||
"title": "Mod description",
|
||||
"description": "A short explanation of what your mod does (one or two sentences), shown in the SMAPI log.",
|
||||
"type": "string",
|
||||
"examples": [ "View metadata about anything by pressing a button." ]
|
||||
},
|
||||
"UniqueID": {
|
||||
"title": "Mod unique ID",
|
||||
"description": "A unique identifier for your mod. The recommended format is \"Username.ModName\", with no spaces or special characters. SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game). When another mod needs to reference this mod, it uses the unique ID.",
|
||||
"$ref": "#/definitions/ModID"
|
||||
},
|
||||
"EntryDll": {
|
||||
"title": "Mod entry DLL",
|
||||
"description": "The DLL filename SMAPI should load for this mod. Mutually exclusive with ContentPackFor.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_.-]+\\.dll$",
|
||||
"examples": "LookupAnything.dll",
|
||||
"@errorMessages": {
|
||||
"pattern": "Invalid value; must be a filename ending with .dll."
|
||||
}
|
||||
},
|
||||
"ContentPackFor": {
|
||||
"title": "Content pack for",
|
||||
"description": "Specifies the mod which can read this content pack.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"UniqueID": {
|
||||
"title": "Required unique ID",
|
||||
"description": "The unique ID of the mod which can read this content pack.",
|
||||
"$ref": "#/definitions/ModID"
|
||||
},
|
||||
"MinimumVersion": {
|
||||
"title": "Required minimum version",
|
||||
"description": "The minimum semantic version of the mod which can read this content pack, if applicable.",
|
||||
"$ref": "#/definitions/SemanticVersion"
|
||||
}
|
||||
},
|
||||
|
||||
"required": [ "UniqueID" ]
|
||||
},
|
||||
"MinimumApiVersion": {
|
||||
"title": "Minimum API version",
|
||||
"description": "The minimum SMAPI version needed to use this mod. If a player tries to use the mod with an older SMAPI version, they'll see a friendly message saying they need to update SMAPI. This also serves as a proxy for the minimum game version, since SMAPI itself enforces a minimum game version.",
|
||||
"$ref": "#/definitions/SemanticVersion"
|
||||
},
|
||||
"Dependencies": {
|
||||
"title": "Mod dependencies",
|
||||
"description": "Specifies other mods to load before this mod. If a dependency is required and a player tries to use the mod without the dependency installed, the mod won't be loaded and they'll see a friendly message saying they need to install those.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"UniqueID": {
|
||||
"title": "Dependency unique ID",
|
||||
"description": "The unique ID of the mod to load first.",
|
||||
"$ref": "#/definitions/ModID"
|
||||
},
|
||||
"MinimumVersion": {
|
||||
"title": "Dependency minimum version",
|
||||
"description": "The minimum semantic version of the mod to load first, if applicable.",
|
||||
"$ref": "#/definitions/SemanticVersion"
|
||||
},
|
||||
"IsRequired": {
|
||||
"title": "Dependency is required",
|
||||
"description": "Whether the dependency is required. Default true if not specified."
|
||||
}
|
||||
},
|
||||
"required": [ "UniqueID" ]
|
||||
}
|
||||
},
|
||||
"UpdateKeys": {
|
||||
"title": "Mod update keys",
|
||||
"description": "Specifies where SMAPI should check for mod updates, so it can alert the user with a link to your mod page. See https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_]+/[A-Za-z0-9_]+|ModDrop:\\d+)$",
|
||||
"@errorMessages": {
|
||||
"pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"SemanticVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-zA-Z0-9]+[\\-\\.]?)+))?$", // derived from SMAPI.Toolkit.SemanticVersion
|
||||
"examples": [ "1.0.0", "1.0.1-beta.2" ],
|
||||
"@errorMessages": {
|
||||
"pattern": "Invalid semantic version; must be formatted like 1.2.0 or 1.2.0-prerelease.tags. See https://semver.org/ for more info."
|
||||
}
|
||||
},
|
||||
"ModID": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_.-]+$", // derived from SMAPI.Toolkit.Utilities.PathUtilities.IsSlug
|
||||
"examples": [ "Pathoschild.LookupAnything" ]
|
||||
}
|
||||
},
|
||||
|
||||
"required": [ "Name", "Author", "Version", "Description", "UniqueID" ],
|
||||
"oneOf": [
|
||||
{
|
||||
"required": [ "EntryDll" ]
|
||||
},
|
||||
{
|
||||
"required": [ "ContentPackFor" ]
|
||||
}
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"@errorMessages": {
|
||||
"oneOf": "Can't specify both EntryDll or ContentPackFor, they're mutually exclusive."
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue