Compare commits

...

12 Commits
v1.0.5 ... main

27 changed files with 539 additions and 229 deletions

View File

@ -3,7 +3,6 @@
using System; using System;
using System.Linq; using System.Linq;
using System.IO; using System.IO;
using Microsoft.SqlServer.Server;
using Parser; using Parser;
using Parser.Render; using Parser.Render;
using Parser.Model.Parser; using Parser.Model.Parser;
@ -16,8 +15,7 @@
{ {
if(e.ExceptionObject is FicdownException) if(e.ExceptionObject is FicdownException)
{ {
Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine(e.ExceptionObject.ToString());
Console.Error.WriteLine(e.ExceptionObject.ToString());
Environment.Exit(3); Environment.Exit(3);
} }
}; };
@ -85,98 +83,115 @@
ShowHelp(); ShowHelp();
return 0; return 0;
} }
if (string.IsNullOrWhiteSpace(format) || string.IsNullOrWhiteSpace(infile))
{
ShowHelp();
return 1;
}
if (!File.Exists(infile))
{
Console.WriteLine(@"Source file {0} not found.", infile);
return 2;
}
if (string.IsNullOrWhiteSpace(output))
if (format == "html")
output = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"html");
else if (format == "epub")
output = "output.epub";
if (!string.IsNullOrWhiteSpace(output) && (Directory.Exists(output) || File.Exists(output))) Logger.Initialize(debug);
var logger = Logger.GetLogger<Program>();
var lintMode = format == "lint";
if(!lintMode)
{ {
Console.WriteLine(@"Specified output {0} already exists.", output); if (string.IsNullOrWhiteSpace(format) || string.IsNullOrWhiteSpace(infile))
return 2;
}
if (!string.IsNullOrWhiteSpace(tempdir))
{
if (!Directory.Exists(tempdir))
{ {
Console.WriteLine(@"Template directory {0} does not exist.", tempdir); ShowHelp();
return 1;
}
if (!File.Exists(infile))
{
logger.Error($"Source file {infile} not found.");
return 2; return 2;
} }
if (!File.Exists(Path.Combine(tempdir, "index.html")) || if (string.IsNullOrWhiteSpace(output))
!File.Exists(Path.Combine(tempdir, "scene.html")) || if (format == "html")
!File.Exists(Path.Combine(tempdir, "styles.css"))) output = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"html");
{ else if (format == "epub")
Console.WriteLine( output = "output.epub";
@"Template directory must contain ""index.html"", ""scene.html"", and ""style.css"" files."); else if(format == "lint")
} lintMode = true;
}
if (!string.IsNullOrWhiteSpace(images) && !Directory.Exists(images)) if (!string.IsNullOrWhiteSpace(output) && (Directory.Exists(output) || File.Exists(output)))
{ {
Console.WriteLine(@"Images directory {0} does not exist.", images); logger.Error($"Specified output {output} already exists.");
return 2; return 2;
}
if (!string.IsNullOrWhiteSpace(tempdir))
{
if (!Directory.Exists(tempdir))
{
logger.Error($"Template directory {tempdir} does not exist.");
return 2;
}
if (!File.Exists(Path.Combine(tempdir, "index.html")) ||
!File.Exists(Path.Combine(tempdir, "scene.html")) ||
!File.Exists(Path.Combine(tempdir, "styles.css")))
{
logger.Error(
@"Template directory must contain ""index.html"", ""scene.html"", and ""style.css"" files.");
}
}
if (!string.IsNullOrWhiteSpace(images) && !Directory.Exists(images))
{
logger.Error($"Images directory {images} does not exist.");
return 2;
}
} }
var parser = new FicdownParser(); var parser = new FicdownParser();
var storyText = File.ReadAllText(infile);
Console.WriteLine(@"Parsing story..."); string storyText;
if(!lintMode)
{
storyText = File.ReadAllText(infile);
logger.Log(@"Parsing story...");
}
else
{
storyText = Console.In.ReadToEnd();
}
var story = parser.ParseStory(storyText); var story = parser.ParseStory(storyText);
story.Orphans.ToList().ForEach(o => parser.Warnings.Select(w => w.ToString()).Distinct().ToList().ForEach(s => logger.Raw(s));
{ story.Orphans.ToList().ForEach(o => logger.Raw($"Warning L{o.LineNumber},1: \"{o.Name}\": Unreachable {o.Type}"));
var currentColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Error.WriteLine("Warning (line {0}): {1} {2} is unreachable", o.LineNumber, o.Type, o.Name);
Console.ForegroundColor = currentColor;
});
IRenderer rend; if(!lintMode && parser.Warnings.Count() == 0)
switch (format)
{ {
case "html": IRenderer rend;
Directory.CreateDirectory(output); switch (format)
rend = new HtmlRenderer(language); {
break; case "html":
case "epub": Directory.CreateDirectory(output);
if (string.IsNullOrWhiteSpace(author)) rend = new HtmlRenderer(language);
{ break;
Console.WriteLine(@"Epub format requires the --author argument."); case "epub":
if (string.IsNullOrWhiteSpace(author))
{
logger.Error(@"Epub format requires the --author argument.");
return 1;
}
rend = new EpubRenderer(author, bookid, language);
break;
default:
ShowHelp();
return 1; return 1;
} }
rend = new EpubRenderer(author, bookid, language);
break; if (!string.IsNullOrWhiteSpace(tempdir))
default: {
ShowHelp(); rend.IndexTemplate = File.ReadAllText(Path.Combine(tempdir, "index.html"));
return 1; rend.SceneTemplate = File.ReadAllText(Path.Combine(tempdir, "scene.html"));
rend.StylesTemplate = File.ReadAllText(Path.Combine(tempdir, "styles.css"));
};
if (!string.IsNullOrWhiteSpace(images)) rend.ImageDir = images;
logger.Log(@"Rendering story...");
rend.Render(story, output, debug);
logger.Log(@"Done.");
} }
if (!string.IsNullOrWhiteSpace(tempdir))
{
rend.IndexTemplate = File.ReadAllText(Path.Combine(tempdir, "index.html"));
rend.SceneTemplate = File.ReadAllText(Path.Combine(tempdir, "scene.html"));
rend.StylesTemplate = File.ReadAllText(Path.Combine(tempdir, "styles.css"));
};
if (!string.IsNullOrWhiteSpace(images)) rend.ImageDir = images;
Console.WriteLine(@"Rendering story...");
rend.Render(story, output, debug);
Console.WriteLine(@"Done.");
return 0; return 0;
} }
@ -184,7 +199,7 @@
private static void ShowHelp() private static void ShowHelp()
{ {
Console.WriteLine( Console.WriteLine(
@"Usage: ficdown.exe @"Usage: ficdown
--format (html|epub) --format (html|epub)
--in ""/path/to/source.md"" --in ""/path/to/source.md""
[--out ""/path/to/output""] [--out ""/path/to/output""]
@ -193,7 +208,9 @@
[--author ""Author Name""] [--author ""Author Name""]
[--bookid ""ePub Book ID""] [--bookid ""ePub Book ID""]
[--language ""language""] [--language ""language""]
[--debug]"); [--debug]
or: ficdown --format lint
(reads input from stdin)");
} }
} }
} }

View File

@ -4,13 +4,19 @@
using Parser; using Parser;
using Model.Parser; using Model.Parser;
using Extensions; using Extensions;
using System.Collections.Generic;
public class BlockHandlerTests public class BlockHandlerTests
{ {
private BlockHandler NewBlockHandler
{
get { return new BlockHandler { Warnings = new List<FicdownException>() }; }
}
[Fact] [Fact]
public void NoStoryBlockThrowsException() public void NoStoryBlockThrowsException()
{ {
var bh = new BlockHandler(); var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@" Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
## this file has no story ## this file has no story
just a lonely scene".ToLines()))); just a lonely scene".ToLines())));
@ -19,7 +25,7 @@ just a lonely scene".ToLines())));
[Fact] [Fact]
public void StoryWithNoAnchorThrowsException() public void StoryWithNoAnchorThrowsException()
{ {
var bh = new BlockHandler(); var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@" Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
# my story # my story
doesn't link to a scene doesn't link to a scene
@ -30,17 +36,23 @@ nothing links here".ToLines())));
[Fact] [Fact]
public void StoriesWithFancyAnchorsThrowExceptions() public void StoriesWithFancyAnchorsThrowExceptions()
{ {
var bh = new BlockHandler(); var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@" bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene?conditional) # [my story](/a-scene?conditional)
story with a conditional story with a conditional
## a scene ## a scene
this is a scene".ToLines()))); this is a scene".ToLines()));
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@" Assert.NotEmpty(bh.Warnings);
bh = NewBlockHandler;
bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene#toggle) # [my story](/a-scene#toggle)
story with a toggle story with a toggle
## a scene ## a scene
this is a scene".ToLines()))); this is a scene".ToLines()));
Assert.NotEmpty(bh.Warnings);
bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@" Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene#?conditional#toggle) # [my story](/a-scene#?conditional#toggle)
story with a conditional and a toggle story with a conditional and a toggle
@ -51,7 +63,7 @@ this is a scene".ToLines())));
[Fact] [Fact]
public void StoryLinkingToNonExistentSceneThrowsException() public void StoryLinkingToNonExistentSceneThrowsException()
{ {
var bh = new BlockHandler(); var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@" Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@"
# [a story](/non-existent) # [a story](/non-existent)
this story links to a first scene that doesn't exist this story links to a first scene that doesn't exist
@ -62,7 +74,7 @@ this scene is so cold and lonely".ToLines())));
[Fact] [Fact]
public void StoryWithALegitAnchorParses() public void StoryWithALegitAnchorParses()
{ {
var bh = new BlockHandler(); var bh = NewBlockHandler;
bh.ParseBlocks(bh.ExtractBlocks(@" bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene) # [my story](/a-scene)
story with a simple link story with a simple link
@ -73,8 +85,8 @@ this is a scene".ToLines()));
[Fact] [Fact]
public void StoryWithDuplicateActionsThrowsException() public void StoryWithDuplicateActionsThrowsException()
{ {
var bh = new BlockHandler(); var bh = NewBlockHandler;
Assert.Throws<FicdownException>(() => bh.ParseBlocks(bh.ExtractBlocks(@" bh.ParseBlocks(bh.ExtractBlocks(@"
# [a story](/a-scene) # [a story](/a-scene)
this story is action-happy this story is action-happy
## a scene ## a scene
@ -84,13 +96,14 @@ this is an action
## another scene ## another scene
this is another scene this is another scene
### an action ### an action
oops, this is the same action!".ToLines()))); oops, this is the same action!".ToLines()));
Assert.NotEmpty(bh.Warnings);
} }
[Fact] [Fact]
public void StoryWithScenesAndActionsParses() public void StoryWithScenesAndActionsParses()
{ {
var bh = new BlockHandler(); var bh = NewBlockHandler;
var story = bh.ParseBlocks(bh.ExtractBlocks(@" var story = bh.ParseBlocks(bh.ExtractBlocks(@"
# [my story](/a-scene) # [my story](/a-scene)
story with a simple link story with a simple link

View File

@ -9,6 +9,7 @@
[Fact] [Fact]
public void CanParseValidStoryFile() public void CanParseValidStoryFile()
{ {
Logger.Initialize(true);
var parser = new FicdownParser(); var parser = new FicdownParser();
var storyText = File.ReadAllText(Path.Combine(Template.BaseDir, "TestStories", "CloakOfDarkness.md")); var storyText = File.ReadAllText(Path.Combine(Template.BaseDir, "TestStories", "CloakOfDarkness.md"));
var story = parser.ParseStory(storyText); var story = parser.ParseStory(storyText);

View File

@ -1,36 +1,40 @@
namespace Ficdown.Parser.Tests namespace Ficdown.Parser.Tests
{ {
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Model.Parser;
using Parser; using Parser;
using Xunit; using Xunit;
public class UtilityTests public class UtilityTests
{ {
private List<FicdownException> Warnings = new List<FicdownException>();
private Utilities Utilities private Utilities Utilities
{ {
get { return Utilities.GetInstance("none", 0); } get { return Utilities.GetInstance(Warnings, "none", 0); }
} }
[Fact] [Fact]
public void FullAnchorMatches() public void FullAnchorMatches()
{ {
var anchorStr = @"[Link text](/target-scene)"; var anchorStr = @"[Link text](/target-scene)";
var anchor = Utilities.ParseAnchor(anchorStr); var anchor = Utilities.ParseAnchor(anchorStr, 0, 0);
Assert.Equal(anchorStr, anchor.Original); Assert.Equal(anchorStr, anchor.Original);
anchorStr = @"[Link text](?condition-state#toggle-state ""Title text"")"; anchorStr = @"[Link text](?condition-state#toggle-state ""Title text"")";
anchor = Utilities.ParseAnchor(anchorStr); anchor = Utilities.ParseAnchor(anchorStr, 0, 0);
Assert.Equal(anchorStr, anchor.Original); Assert.Equal(anchorStr, anchor.Original);
anchorStr = @"[Link text](""Title text"")"; anchorStr = @"[Link text](""Title text"")";
anchor = Utilities.ParseAnchor(anchorStr); anchor = Utilities.ParseAnchor(anchorStr, 0, 0);
Assert.Equal(anchorStr, anchor.Original); Assert.Equal(anchorStr, anchor.Original);
} }
[Fact] [Fact]
public void AnchorWithTargetMatches() public void AnchorWithTargetMatches()
{ {
var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene)"); var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("target-scene", anchor.Href.Target); Assert.Equal("target-scene", anchor.Href.Target);
} }
@ -38,15 +42,15 @@
[Fact] [Fact]
public void AnchorsWithConditionsMatch() public void AnchorsWithConditionsMatch()
{ {
var anchor = Utilities.ParseAnchor(@"[Link text](?condition-state)"); var anchor = Utilities.ParseAnchor(@"[Link text](?condition-state)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.True(anchor.Href.Conditions["condition-state"]); Assert.True(anchor.Href.Conditions["condition-state"]);
anchor = Utilities.ParseAnchor(@"[Link text](?!condition-state)"); anchor = Utilities.ParseAnchor(@"[Link text](?!condition-state)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.False(anchor.Href.Conditions["condition-state"]); Assert.False(anchor.Href.Conditions["condition-state"]);
anchor = Utilities.ParseAnchor(@"[Link text](?condition-1&!condition-2)"); anchor = Utilities.ParseAnchor(@"[Link text](?condition-1&!condition-2)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.True(anchor.Href.Conditions["condition-1"]); Assert.True(anchor.Href.Conditions["condition-1"]);
Assert.False(anchor.Href.Conditions["condition-2"]); Assert.False(anchor.Href.Conditions["condition-2"]);
@ -55,15 +59,15 @@
[Fact] [Fact]
public void AnchorsWithTogglesMatch() public void AnchorsWithTogglesMatch()
{ {
var anchor = Utilities.ParseAnchor(@"[Link text](#toggle-state)"); var anchor = Utilities.ParseAnchor(@"[Link text](#toggle-state)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("#toggle-state", anchor.Href.Original); Assert.Equal("#toggle-state", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)"); anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("#toggle-1+toggle-2", anchor.Href.Original); Assert.Equal("#toggle-1+toggle-2", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)"); anchor = Utilities.ParseAnchor(@"[Link text](#toggle-1+toggle-2)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("#toggle-1+toggle-2", anchor.Href.Original); Assert.Equal("#toggle-1+toggle-2", anchor.Href.Original);
} }
@ -71,11 +75,11 @@
[Fact] [Fact]
public void AnchorsWithTitlesMatch() public void AnchorsWithTitlesMatch()
{ {
var anchor = Utilities.ParseAnchor(@"[Link text](""Title text"")"); var anchor = Utilities.ParseAnchor(@"[Link text](""Title text"")", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("Title text", anchor.Title); Assert.Equal("Title text", anchor.Title);
anchor = Utilities.ParseAnchor(@"[Talking to Kid](""Lobby"")"); anchor = Utilities.ParseAnchor(@"[Talking to Kid](""Lobby"")", 0, 0);
Assert.Equal("Talking to Kid", anchor.Text); Assert.Equal("Talking to Kid", anchor.Text);
Assert.Equal("Lobby", anchor.Title); Assert.Equal("Lobby", anchor.Title);
} }
@ -83,20 +87,20 @@
[Fact] [Fact]
public void ComplexAnchorsMatch() public void ComplexAnchorsMatch()
{ {
var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state#toggle-state ""Title text"")"); var anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state#toggle-state ""Title text"")", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("/target-scene?condition-state#toggle-state", anchor.Href.Original); Assert.Equal("/target-scene?condition-state#toggle-state", anchor.Href.Original);
Assert.Equal("Title text", anchor.Title); Assert.Equal("Title text", anchor.Title);
anchor = Utilities.ParseAnchor(@"[Link text](/target-scene#toggle-state)"); anchor = Utilities.ParseAnchor(@"[Link text](/target-scene#toggle-state)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("/target-scene#toggle-state", anchor.Href.Original); Assert.Equal("/target-scene#toggle-state", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state)"); anchor = Utilities.ParseAnchor(@"[Link text](/target-scene?condition-state)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("/target-scene?condition-state", anchor.Href.Original); Assert.Equal("/target-scene?condition-state", anchor.Href.Original);
anchor = Utilities.ParseAnchor(@"[Link text](?condition-state#toggle-state)"); anchor = Utilities.ParseAnchor(@"[Link text](?condition-state#toggle-state)", 0, 0);
Assert.Equal("Link text", anchor.Text); Assert.Equal("Link text", anchor.Text);
Assert.Equal("?condition-state#toggle-state", anchor.Href.Original); Assert.Equal("?condition-state#toggle-state", anchor.Href.Original);
} }

View File

@ -4,6 +4,7 @@
namespace Ficdown.Parser namespace Ficdown.Parser
{ {
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Model.Parser; using Model.Parser;
using Parser; using Parser;
@ -11,32 +12,81 @@ namespace Ficdown.Parser
public class FicdownParser public class FicdownParser
{ {
private static Logger _logger = Logger.GetLogger<FicdownParser>();
public List<FicdownException> Warnings { get; private set; }
private IBlockHandler _blockHandler; private IBlockHandler _blockHandler;
internal IBlockHandler BlockHandler internal IBlockHandler BlockHandler
{ {
get { return _blockHandler ?? (_blockHandler = new BlockHandler()); } get
set { _blockHandler = value; } {
return _blockHandler ??
(_blockHandler = new BlockHandler { Warnings = Warnings });
}
set
{
_blockHandler = value;
_blockHandler.Warnings = Warnings;
}
} }
private IGameTraverser _gameTraverser; private IGameTraverser _gameTraverser;
internal IGameTraverser GameTraverser internal IGameTraverser GameTraverser
{ {
get { return _gameTraverser ?? (_gameTraverser = new GameTraverser()); } get { return _gameTraverser ??
set { _gameTraverser = value; } (_gameTraverser = new GameTraverser { Warnings = Warnings }); }
set
{
_gameTraverser = value;
_gameTraverser.Warnings = Warnings;
}
} }
private IStateResolver _stateResolver; private IStateResolver _stateResolver;
internal IStateResolver StateResolver internal IStateResolver StateResolver
{ {
get { return _stateResolver ?? (_stateResolver = new StateResolver()); } get
set { _stateResolver = value; } {
return _stateResolver ??
(_stateResolver = new StateResolver { Warnings = Warnings });
}
set
{
_stateResolver = value;
_stateResolver.Warnings = Warnings;
}
}
public FicdownParser()
{
Warnings = new List<FicdownException>();
} }
public ResolvedStory ParseStory(string storyText) public ResolvedStory ParseStory(string storyText)
{ {
var lines = storyText.Split(new[] {"\n", "\r\n"}, StringSplitOptions.None); var lines = storyText.Split(new[] {"\n", "\r\n"}, StringSplitOptions.None);
_logger.Debug($"Parsed {lines.Length} lines.");
var blocks = BlockHandler.ExtractBlocks(lines); var blocks = BlockHandler.ExtractBlocks(lines);
_logger.Debug($"Extracted {blocks.Count()} blocks.");
var story = BlockHandler.ParseBlocks(blocks); var story = BlockHandler.ParseBlocks(blocks);
_logger.Debug("Finished initial story breakdown.");
// dupe scene sanity check
foreach(var key in story.Scenes.Keys)
{
foreach(var scene in story.Scenes[key])
{
foreach(var otherScene in story.Scenes[key].Where(s => s != scene))
{
if((scene.Conditions == null && otherScene.Conditions == null)
|| (scene.Conditions != null && otherScene.Conditions != null
&& scene.Conditions.Count == otherScene.Conditions.Count
&& !scene.Conditions.Except(otherScene.Conditions).Any()))
Warnings.Add(new FicdownException(scene.Name, string.Format("Scene defined again on line {0}", otherScene.LineNumber), scene.LineNumber));
}
}
}
GameTraverser.Story = story; GameTraverser.Story = story;
var resolved = StateResolver.Resolve(GameTraverser.Enumerate(), story); var resolved = StateResolver.Resolve(GameTraverser.Enumerate(), story);
resolved.Orphans = GameTraverser.OrphanedScenes.Select(o => new Orphan resolved.Orphans = GameTraverser.OrphanedScenes.Select(o => new Orphan

View File

@ -12,7 +12,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Epub4Net" Version="1.2.0" /> <PackageReference Include="Epub4Net" Version="1.2.0" />
<PackageReference Include="Ionic.Zip" Version="1.9.1.8" /> <PackageReference Include="Ionic.Zip" Version="1.9.1.8" />
<PackageReference Include="MarkdownSharp" Version="2.0.5" /> <PackageReference Include="Markdig" Version="0.17.1" />
<PackageReference Include="System.Security.Permissions" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.5.1" /> <PackageReference Include="System.Text.Encoding.CodePages" Version="4.5.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

63
Ficdown.Parser/Logger.cs Normal file
View File

@ -0,0 +1,63 @@
namespace Ficdown.Parser
{
using System;
using System.Collections.Generic;
public class Logger
{
private static bool _initialized = false;
private static bool _debug = false;
private static Dictionary<Type, Logger> _cache;
public Type Type { get; private set; }
private Logger(Type type)
{
Type = type;
}
public static void Initialize(bool debug)
{
_debug = debug;
_cache = new Dictionary<Type, Logger>();
_initialized = true;
}
public static Logger GetLogger<T>()
{
var type = typeof(T);
lock(_cache)
{
if(!_cache.ContainsKey(type))
_cache.Add(type, new Logger(type));
}
return _cache[type];
}
private string Decorate(string message)
{
return $"{DateTime.Now.ToString("")} <{Type.Name}> {message}";
}
public void Raw(string message)
{
Console.WriteLine(message);
}
public void Log(string message)
{
Raw(Decorate(message));
}
public void Debug(string message)
{
if(!_debug) return;
Log($"DEBUG: {message}");
}
public void Error(string message, Exception ex = null)
{
Console.Error.WriteLine(Decorate($"ERROR: {message}"));
}
}
}

View File

@ -6,5 +6,7 @@
public string Text { get; set; } public string Text { get; set; }
public Href Href { get; set; } public Href Href { get; set; }
public string Title { get; set; } public string Title { get; set; }
public int LineNumber { get; set; }
public int ColNumber { get; set; }
} }
} }

View File

@ -6,24 +6,25 @@ namespace Ficdown.Parser.Model.Parser
{ {
public string BlockName { get; private set; } public string BlockName { get; private set; }
public int? LineNumber { get; private set; } public int? LineNumber { get; private set; }
public int? ColNumber { get; private set; }
public FicdownException(string blockName, int? lineNumber, string message) : base(message) public FicdownException(string blockName, string message, int? lineNumber = null, int? colNumber = null) : base(message)
{ {
BlockName = blockName; BlockName = blockName;
LineNumber = lineNumber; LineNumber = lineNumber;
ColNumber = colNumber;
} }
public FicdownException(string message) : base(message) { } public FicdownException(string message) : base(message) { }
public override string ToString() public override string ToString()
{ {
return !string.IsNullOrEmpty(BlockName) return string.Format("Error L{0},{1}: {2}",
? string.Format("Error in block \"{0}\" (Line {1}): {2}", LineNumber ?? 1,
BlockName, ColNumber ?? 1,
LineNumber.HasValue !string.IsNullOrEmpty(BlockName)
? LineNumber.ToString() ? string.Format("\"{0}\": {1}", BlockName, Message)
: "unknown", Message) : Message);
: string.Format("Error: {0}", Message);
} }
} }
} }

View File

@ -7,6 +7,8 @@
internal class PageState internal class PageState
{ {
public StateManager Manager { get; set; }
public Guid Id { get; set; } public Guid Id { get; set; }
public Scene Scene { get; set; } public Scene Scene { get; set; }
public State State { get; set; } public State State { get; set; }
@ -21,12 +23,12 @@
public string UniqueHash public string UniqueHash
{ {
get { return _uniqueHash ?? (_uniqueHash = StateManager.GetUniqueHash(State, Scene.Key)); } get { return _uniqueHash ?? (_uniqueHash = Manager.GetUniqueHash(State, Scene.Key)); }
} }
public string CompressedHash public string CompressedHash
{ {
get { return _compressedHash ?? (_compressedHash = StateManager.GetCompressedHash(this)); } get { return _compressedHash ?? (_compressedHash = Manager.GetCompressedHash(this)); }
} }
} }
} }

View File

@ -4,6 +4,7 @@
{ {
public int Id { get; set; } public int Id { get; set; }
public string Toggle { get; set; } public string Toggle { get; set; }
public string RawDescription { get; set; }
public string Description { get; set; } public string Description { get; set; }
public int LineNumber { get; set; } public int LineNumber { get; set; }
public bool Visited { get; set; } public bool Visited { get; set; }

View File

@ -8,6 +8,7 @@
public string Name { get; set; } public string Name { get; set; }
public string Key { get; set; } public string Key { get; set; }
public string Description { get; set; } public string Description { get; set; }
public string RawDescription { get; set; }
public IDictionary<string, bool> Conditions { get; set; } public IDictionary<string, bool> Conditions { get; set; }
public int LineNumber { get; set; } public int LineNumber { get; set; }
public bool Visited { get; set; } public bool Visited { get; set; }

View File

@ -10,6 +10,8 @@
internal class BlockHandler : IBlockHandler internal class BlockHandler : IBlockHandler
{ {
public List<FicdownException> Warnings { get; set; }
public IEnumerable<Block> ExtractBlocks(IEnumerable<string> lines) public IEnumerable<Block> ExtractBlocks(IEnumerable<string> lines)
{ {
var blocks = new List<Block>(); var blocks = new List<Block>();
@ -45,22 +47,22 @@
public Story ParseBlocks(IEnumerable<Block> blocks) public Story ParseBlocks(IEnumerable<Block> blocks)
{ {
// get the story // get the story
var storyBlock = blocks.SingleOrDefault(b => b.Type == BlockType.Story); var storyBlocks = blocks.Where(b => b.Type == BlockType.Story);
if(storyBlock == null) throw new FicdownException("No story block found"); if(storyBlocks.Count() == 0) throw new FicdownException("No story block found");
if(storyBlocks.Count() > 1) throw new FicdownException("More than one story block found");
Anchor storyAnchor; var storyBlock = storyBlocks.Single();
try
var storyAnchor = Utilities.GetInstance(Warnings, storyBlock.Name, storyBlock.LineNumber).ParseAnchor(storyBlock.Name, storyBlock.LineNumber, 1);
if(storyAnchor == null || storyAnchor.Href == null)
{ {
storyAnchor = Utilities.GetInstance(storyBlock.Name, storyBlock.LineNumber).ParseAnchor(storyBlock.Name); throw new FicdownException(storyBlock.Name, "Story name must be an anchor pointing to the first scene", storyBlock.LineNumber);
}
catch(FicdownException ex)
{
throw new FicdownException(ex.BlockName, ex.LineNumber, "Story block must be an anchor pointing to the first scene");
} }
if (storyAnchor.Href.Target == null || storyAnchor.Href.Conditions != null || if (storyAnchor.Href.Target == null || storyAnchor.Href.Conditions != null ||
storyAnchor.Href.Toggles != null) storyAnchor.Href.Toggles != null)
throw new FicdownException(storyBlock.Name, storyBlock.LineNumber, "Story href should only have target"); Warnings.Add(new FicdownException(storyBlock.Name, "Story href should only have a target", storyBlock.LineNumber));
var story = new Story var story = new Story
{ {
@ -88,11 +90,11 @@
var a = blocks.First(b => b.Type == BlockType.Action && blocks.Any(d => b != d && BlockToAction(b, 0).Toggle == BlockToAction(d, 0).Toggle)); var a = blocks.First(b => b.Type == BlockType.Action && blocks.Any(d => b != d && BlockToAction(b, 0).Toggle == BlockToAction(d, 0).Toggle));
var actionA = BlockToAction(a, a.LineNumber); var actionA = BlockToAction(a, a.LineNumber);
var dupe = blocks.First(b => b.Type == BlockType.Action && b != a && BlockToAction(b, 0).Toggle == actionA.Toggle); var dupe = blocks.First(b => b.Type == BlockType.Action && b != a && BlockToAction(b, 0).Toggle == actionA.Toggle);
throw new FicdownException(actionA.Toggle, actionA.LineNumber, string.Format("Action is defined again on line {0}", dupe.LineNumber)); Warnings.Add(new FicdownException(actionA.Toggle, string.Format("Action is defined again on line {0}", dupe.LineNumber), actionA.LineNumber));
} }
if (!story.Scenes.ContainsKey(storyAnchor.Href.Target)) if (!story.Scenes.ContainsKey(storyAnchor.Href.Target))
throw new FicdownException(storyBlock.Name, storyBlock.LineNumber, string.Format("Story targets non-existent scene: {0}", storyAnchor.Href.Target)); throw new FicdownException(storyBlock.Name, string.Format("Story links to undefined scene: {0}", storyAnchor.Href.Target), storyBlock.LineNumber);
story.FirstScene = storyAnchor.Href.Target; story.FirstScene = storyAnchor.Href.Target;
return story; return story;
@ -105,22 +107,23 @@
{ {
Id = id, Id = id,
LineNumber = block.LineNumber, LineNumber = block.LineNumber,
RawDescription = string.Join("\n", block.Lines.Select(l => l.Text)),
Description = string.Join("\n", block.Lines.Select(l => l.Text)).Trim() Description = string.Join("\n", block.Lines.Select(l => l.Text)).Trim()
}; };
try Anchor sceneName;
if(RegexLib.Anchors.IsMatch(block.Name) && (sceneName = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).ParseAnchor(block.Name, block.LineNumber, 1)).Href != null)
{ {
var sceneName = Utilities.GetInstance(block.Name, block.LineNumber).ParseAnchor(block.Name);
scene.Name = sceneName.Title != null ? sceneName.Title.Trim() : sceneName.Text.Trim(); scene.Name = sceneName.Title != null ? sceneName.Title.Trim() : sceneName.Text.Trim();
scene.Key = Utilities.GetInstance(block.Name, block.LineNumber).NormalizeString(sceneName.Text); scene.Key = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).NormalizeString(sceneName.Text);
if(sceneName.Href.Target != null || sceneName.Href.Toggles != null) if(sceneName.Href.Target != null || sceneName.Href.Toggles != null)
throw new FicdownException(block.Name, block.LineNumber, string.Format("Scene href should only have conditions: {0}", block.Name)); Warnings.Add(new FicdownException(block.Name, "Scene href should only have conditions", block.LineNumber));
scene.Conditions = sceneName.Href.Conditions; scene.Conditions = sceneName.Href.Conditions;
} }
catch(FicdownException) else
{ {
scene.Name = block.Name.Trim(); scene.Name = block.Name.Trim();
scene.Key = Utilities.GetInstance(block.Name, block.LineNumber).NormalizeString(block.Name); scene.Key = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).NormalizeString(block.Name);
} }
return scene; return scene;
@ -131,7 +134,8 @@
return new Action return new Action
{ {
Id = id, Id = id,
Toggle = Utilities.GetInstance(block.Name, block.LineNumber).NormalizeString(block.Name), Toggle = Utilities.GetInstance(Warnings, block.Name, block.LineNumber).NormalizeString(block.Name),
RawDescription = string.Join("\n", block.Lines.Select(l => l.Text)),
Description = string.Join("\n", block.Lines.Select(l => l.Text)).Trim(), Description = string.Join("\n", block.Lines.Select(l => l.Text)).Trim(),
LineNumber = block.LineNumber LineNumber = block.LineNumber
}; };

View File

@ -6,6 +6,7 @@
internal interface IBlockHandler internal interface IBlockHandler
{ {
List<FicdownException> Warnings { set; }
IEnumerable<Block> ExtractBlocks(IEnumerable<string> lines); IEnumerable<Block> ExtractBlocks(IEnumerable<string> lines);
Story ParseBlocks(IEnumerable<Block> blocks); Story ParseBlocks(IEnumerable<Block> blocks);
} }

View File

@ -7,6 +7,7 @@
internal interface IStateResolver internal interface IStateResolver
{ {
List<FicdownException> Warnings { set; }
ResolvedStory Resolve(IEnumerable<PageState> pages, Story story); ResolvedStory Resolve(IEnumerable<PageState> pages, Story story);
} }
} }

View File

@ -1,6 +1,5 @@
namespace Ficdown.Parser.Parser namespace Ficdown.Parser.Parser
{ {
using System;
using System.Linq; using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;

View File

@ -10,11 +10,14 @@
internal class StateResolver : IStateResolver internal class StateResolver : IStateResolver
{ {
private static Logger _logger = Logger.GetLogger<StateResolver>();
private static readonly Random _random = new Random((int) DateTime.Now.Ticks); private static readonly Random _random = new Random((int) DateTime.Now.Ticks);
private readonly IDictionary<string, string> _pageNames; private readonly IDictionary<string, string> _pageNames;
private readonly HashSet<string> _usedNames; private readonly HashSet<string> _usedNames;
private Story _story; private Story _story;
public List<FicdownException> Warnings { private get; set; }
public StateResolver() public StateResolver()
{ {
_pageNames = new Dictionary<string, string>(); _pageNames = new Dictionary<string, string>();
@ -23,6 +26,7 @@
public ResolvedStory Resolve(IEnumerable<PageState> pages, Story story) public ResolvedStory Resolve(IEnumerable<PageState> pages, Story story)
{ {
_logger.Debug("Resolving story paths...");
_story = story; _story = story;
return new ResolvedStory return new ResolvedStory
{ {
@ -43,12 +47,15 @@
private string ResolveAnchor(string blockName, int lineNumber, Anchor anchor, IDictionary<string, bool> playerState, string targetHash) private string ResolveAnchor(string blockName, int lineNumber, Anchor anchor, IDictionary<string, bool> playerState, string targetHash)
{ {
var text = anchor.Text; var text = anchor.Text;
if (anchor.Href.Conditions != null) if (anchor.Href != null && anchor.Href.Conditions != null)
{ {
var satisfied = Utilities.GetInstance(blockName, lineNumber).ConditionsMet(playerState, anchor.Href.Conditions); var satisfied = Utilities.GetInstance(Warnings, blockName, lineNumber).ConditionsMet(playerState, anchor.Href.Conditions);
var alts = Utilities.GetInstance(blockName, lineNumber).ParseConditionalText(text); var alts = Utilities.GetInstance(Warnings, blockName, lineNumber).ParseConditionalText(anchor);
var replace = alts[satisfied]; if(alts != null)
text = RegexLib.EscapeChar.Replace(replace, string.Empty); {
var replace = alts[satisfied];
text = RegexLib.EscapeChar.Replace(replace, string.Empty);
}
} }
return !string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(targetHash) return !string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(targetHash)
? string.Format("[{0}](/{1})", text, GetPageNameForHash(targetHash)) ? string.Format("[{0}](/{1})", text, GetPageNameForHash(targetHash))
@ -67,7 +74,7 @@
if (page.State.ActionsToShow[i]) if (page.State.ActionsToShow[i])
{ {
var actionTuple = _story.Actions.Single(a => a.Value.Id == i + 1); var actionTuple = _story.Actions.Single(a => a.Value.Id == i + 1);
var actionAnchors = Utilities.GetInstance(page.Scene.Name, page.Scene.LineNumber).ParseAnchors(actionTuple.Value.Description); var actionAnchors = Utilities.GetInstance(Warnings, page.Scene.Name, page.Scene.LineNumber).ParseAnchors(actionTuple.Value.RawDescription);
var anchorDict = GetStateDictionary(page); var anchorDict = GetStateDictionary(page);
if ( if (
actionAnchors.Any( actionAnchors.Any(
@ -86,7 +93,7 @@
} }
} }
var anchors = Utilities.GetInstance(page.Scene.Name, page.Scene.LineNumber).ParseAnchors(page.Scene.Description); var anchors = Utilities.GetInstance(Warnings, page.Scene.Name, page.Scene.LineNumber).ParseAnchors(page.Scene.RawDescription);
var stateDict = GetStateDictionary(page); var stateDict = GetStateDictionary(page);
var text = var text =
RegexLib.EmptyListItem.Replace( RegexLib.EmptyListItem.Replace(

View File

@ -1,7 +1,5 @@
 namespace Ficdown.Parser.Parser
namespace Ficdown.Parser.Parser
{ {
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -9,19 +7,32 @@ namespace Ficdown.Parser.Parser
internal class Utilities internal class Utilities
{ {
public static Utilities GetInstance(string blockName, int lineNumber) private List<FicdownException> _warnings { get; set; }
public static Utilities GetInstance()
{ {
return new Utilities return new Utilities
{ {
_warnings = new List<FicdownException>(),
_blockName = string.Empty
};
}
public static Utilities GetInstance(List<FicdownException> warnings, string blockName, int lineNumber)
{
return new Utilities
{
_warnings = warnings,
_blockName = blockName, _blockName = blockName,
_lineNumber = lineNumber _lineNumber = lineNumber
}; };
} }
public static Utilities GetInstance(string blockName) public static Utilities GetInstance(List<FicdownException> warnings, string blockName)
{ {
return new Utilities return new Utilities
{ {
_warnings = warnings,
_blockName = blockName _blockName = blockName
}; };
} }
@ -34,7 +45,7 @@ namespace Ficdown.Parser.Parser
return Regex.Replace(Regex.Replace(raw.ToLower(), @"^\W+|\W+$", string.Empty), @"\W+", "-"); return Regex.Replace(Regex.Replace(raw.ToLower(), @"^\W+|\W+$", string.Empty), @"\W+", "-");
} }
private Href ParseHref(string href) private Href ParseHref(string href, int lineNumber, int colNumber)
{ {
var match = RegexLib.Href.Match(href); var match = RegexLib.Href.Match(href);
if (match.Success) if (match.Success)
@ -57,27 +68,55 @@ namespace Ficdown.Parser.Parser
: null : null
}; };
} }
throw new FicdownException(_blockName, _lineNumber, string.Format("Invalid href: {0}", href)); _warnings.Add(new FicdownException(_blockName, string.Format("Invalid href: {0}", href), lineNumber, colNumber));
return null;
} }
public Anchor ParseAnchor(string anchorText) public Anchor ParseAnchor(string anchorText, int lineNumber, int colNumber)
{ {
var match = RegexLib.Anchors.Match(anchorText); var match = RegexLib.Anchors.Match(anchorText);
if (!match.Success) throw new FicdownException(_blockName, _lineNumber, string.Format("Invalid anchor: {0}", anchorText)); if (!match.Success)
return MatchToAnchor(match); {
_warnings.Add(new FicdownException(_blockName, string.Format("Invalid anchor: {0}", anchorText), lineNumber, colNumber));
return null;
}
return MatchToAnchor(match, lineNumber, colNumber);
}
private void PosFromIndex(string text, int index, out int line, out int col)
{
line = 1;
col = 1;
for (int i = 0; i <= index - 1; i++)
{
col++;
if (text[i] == '\n')
{
line++;
col = 1;
}
}
} }
public IList<Anchor> ParseAnchors(string text) public IList<Anchor> ParseAnchors(string text)
{ {
var matches = RegexLib.Anchors.Matches(text); var matches = RegexLib.Anchors.Matches(text);
return matches.Cast<Match>().Select(MatchToAnchor).ToList(); return matches.Cast<Match>().Select(m =>
{
int line, col;
PosFromIndex(text, m.Index, out line, out col);
if(_lineNumber.HasValue) line += _lineNumber.Value;
return MatchToAnchor(m, line, col);
}).ToList();
} }
private Anchor MatchToAnchor(Match match) private Anchor MatchToAnchor(Match match, int lineNumber, int colNumber)
{ {
var astr = match.Groups["anchor"].Value; var astr = match.Groups["anchor"].Value;
var txstr = match.Groups["text"].Value; var txstr = match.Groups["text"].Value;
var ttstr = match.Groups["title"].Value; var ttstr = match.Groups["title"].Success
? match.Groups["title"].Value
: null;
var hrefstr = match.Groups["href"].Value; var hrefstr = match.Groups["href"].Value;
if (hrefstr.StartsWith(@"""")) if (hrefstr.StartsWith(@""""))
{ {
@ -89,14 +128,21 @@ namespace Ficdown.Parser.Parser
Original = !string.IsNullOrEmpty(astr) ? astr : null, Original = !string.IsNullOrEmpty(astr) ? astr : null,
Text = !string.IsNullOrEmpty(txstr) ? txstr : null, Text = !string.IsNullOrEmpty(txstr) ? txstr : null,
Title = ttstr, Title = ttstr,
Href = ParseHref(hrefstr) Href = ParseHref(hrefstr, lineNumber, colNumber),
LineNumber = lineNumber,
ColNumber = colNumber
}; };
} }
public IDictionary<bool, string> ParseConditionalText(string text) public IDictionary<bool, string> ParseConditionalText(Anchor anchor)
{ {
var match = RegexLib.ConditionalText.Match(text); var match = RegexLib.ConditionalText.Match(anchor.Text);
if (!match.Success) throw new FicdownException(_blockName, _lineNumber, string.Format(@"Invalid conditional text: {0}", text)); if (!match.Success)
{
_warnings.Add(new FicdownException(_blockName, string.Format(@"Invalid conditional text: {0}", anchor.Text), anchor.LineNumber, anchor.ColNumber));
return null;
}
return new Dictionary<bool, string> return new Dictionary<bool, string>
{ {
{true, match.Groups["true"].Value}, {true, match.Groups["true"].Value},

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Model.Parser;
using Model.Player; using Model.Player;
using Model.Story; using Model.Story;
using Parser; using Parser;
@ -10,6 +11,7 @@
internal class GameTraverser : IGameTraverser internal class GameTraverser : IGameTraverser
{ {
private static Logger _logger = Logger.GetLogger<GameTraverser>();
private StateManager _manager; private StateManager _manager;
private Queue<StateQueueItem> _processingQueue; private Queue<StateQueueItem> _processingQueue;
private IDictionary<string, PageState> _processed; private IDictionary<string, PageState> _processed;
@ -17,6 +19,8 @@
private IDictionary<int, Action> _actionMatrix; private IDictionary<int, Action> _actionMatrix;
private bool _wasRun = false; private bool _wasRun = false;
public List<FicdownException> Warnings { private get; set; }
private Story _story; private Story _story;
public Story Story public Story Story
{ {
@ -25,7 +29,7 @@
{ {
_story = value; _story = value;
_actionMatrix = _story.Actions.ToDictionary(a => a.Value.Id, a => a.Value); _actionMatrix = _story.Actions.ToDictionary(a => a.Value.Id, a => a.Value);
_manager = new StateManager(_story); _manager = new StateManager(_story, Warnings);
_processingQueue = new Queue<StateQueueItem>(); _processingQueue = new Queue<StateQueueItem>();
_processed = new Dictionary<string, PageState>(); _processed = new Dictionary<string, PageState>();
_compressed = new Dictionary<string, PageState>(); _compressed = new Dictionary<string, PageState>();
@ -56,6 +60,7 @@
_wasRun = true; _wasRun = true;
// generate comprehensive enumeration // generate comprehensive enumeration
_logger.Debug("Enumerating story scenes...");
var initial = _manager.InitialState; var initial = _manager.InitialState;
_processingQueue.Enqueue(new StateQueueItem _processingQueue.Enqueue(new StateQueueItem
@ -63,6 +68,7 @@
Page = initial, Page = initial,
AffectedStates = new List<State> {initial.AffectedState} AffectedStates = new List<State> {initial.AffectedState}
}); });
var interval = 0;
while (_processingQueue.Count > 0) while (_processingQueue.Count > 0)
{ {
var state = _processingQueue.Dequeue(); var state = _processingQueue.Dequeue();
@ -71,9 +77,13 @@
_processed.Add(state.Page.UniqueHash, state.Page); _processed.Add(state.Page.UniqueHash, state.Page);
ProcessState(state); ProcessState(state);
} }
if(++interval % 100 == 0 || _processingQueue.Count == 0)
_logger.Debug($"Processed {interval} scenes, {_processingQueue.Count} queued...");
} }
// make sure every page gets affected data on every page that it links to // make sure every page gets affected data on every page that it links to
_logger.Debug("Processing scene links...");
foreach (var pageTuple in _processed) foreach (var pageTuple in _processed)
{ {
foreach (var linkTuple in pageTuple.Value.Links) foreach (var linkTuple in pageTuple.Value.Links)
@ -92,6 +102,7 @@
} }
// compress redundancies // compress redundancies
_logger.Debug("Compressing redundant scenes...");
foreach (var row in _processed) foreach (var row in _processed)
{ {
if (!_compressed.ContainsKey(row.Value.CompressedHash)) if (!_compressed.ContainsKey(row.Value.CompressedHash))
@ -125,15 +136,15 @@
var states = new HashSet<string>(); var states = new HashSet<string>();
var anchors = Utilities.GetInstance(currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseAnchors(currentState.Page.Scene.Description).ToList(); var anchors = Utilities.GetInstance(Warnings, currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseAnchors(currentState.Page.Scene.RawDescription).ToList();
foreach (var action in GetActionsForPage(currentState.Page)) foreach (var action in GetActionsForPage(currentState.Page))
{ {
action.Visited = true; action.Visited = true;
anchors.AddRange(Utilities.GetInstance(action.Toggle, action.LineNumber).ParseAnchors(action.Description)); anchors.AddRange(Utilities.GetInstance(Warnings, action.Toggle, action.LineNumber).ParseAnchors(action.RawDescription));
} }
var conditionals = var conditionals =
anchors.SelectMany( anchors.SelectMany(
a => a.Href.Conditions != null ? a.Href.Conditions.Select(c => c.Key) : new string[] {}) a => a.Href != null && a.Href.Conditions != null ? a.Href.Conditions.Select(c => c.Key) : new string[] {})
.Distinct() .Distinct()
.ToArray(); .ToArray();
var hasFirstSeen = RegexLib.BlockQuotes.IsMatch(currentState.Page.Scene.Description); var hasFirstSeen = RegexLib.BlockQuotes.IsMatch(currentState.Page.Scene.Description);
@ -143,32 +154,46 @@
// signal to previous scenes that this scene's used conditionals are important // signal to previous scenes that this scene's used conditionals are important
if(currentState.Page.Scene.Conditions != null) if(currentState.Page.Scene.Conditions != null)
foreach (var conditional in currentState.Page.Scene.Conditions) foreach (var conditional in currentState.Page.Scene.Conditions)
_manager.ToggleStateOn(affected, conditional.Key); {
foreach (var conditional in conditionals) _manager.ToggleStateOn(affected, conditional); var anchor = anchors.FirstOrDefault(a =>
a.Href.Conditions != null
&& a.Href.Conditions.Keys.Contains(conditional.Key));
_manager.ToggleStateOn(affected, conditional.Key, currentState.Page.Scene.Name, anchor != null ? anchor.LineNumber : currentState.Page.Scene.LineNumber, anchor != null ? anchor.ColNumber : 1);
}
foreach (var conditional in conditionals)
{
var anchor = anchors.FirstOrDefault(a =>
a.Href.Conditions != null
&& a.Href.Conditions.Keys.Contains(conditional));
_manager.ToggleStateOn(affected, conditional, currentState.Page.Scene.Name, anchor != null ? anchor.LineNumber : currentState.Page.Scene.LineNumber, anchor != null ? anchor.ColNumber : 1);
}
// signal to previous scenes if this scene has first-seen text // signal to previous scenes if this scene has first-seen text
if (hasFirstSeen) _manager.ToggleSeenSceneOn(affected, currentState.Page.Scene.Id); if (hasFirstSeen) _manager.ToggleSeenSceneOn(affected, currentState.Page.Scene.Id);
} }
foreach (var anchor in anchors.Where(a => a.Href.Target != null || a.Href.Toggles != null)) foreach (var anchor in anchors.Where(a => a.Href != null && (a.Href.Target != null || a.Href.Toggles != null)))
{ {
// don't follow links that would be hidden // don't follow links that would be hidden
if (anchor.Href.Conditions != null && if (anchor.Href.Conditions != null &&
string.IsNullOrEmpty( string.IsNullOrEmpty(
Utilities.GetInstance(currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseConditionalText(anchor.Text)[ Utilities.GetInstance(Warnings, currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ParseConditionalText(anchor)[
Utilities.GetInstance(currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ConditionsMet(StateResolver.GetStateDictionary(currentState.Page), Utilities.GetInstance(Warnings, currentState.Page.Scene.Name, currentState.Page.Scene.LineNumber).ConditionsMet(StateResolver.GetStateDictionary(currentState.Page),
anchor.Href.Conditions)])) continue; anchor.Href.Conditions)])) continue;
var newState = _manager.ResolveNewState(anchor, currentState.Page); var newState = _manager.ResolveNewState(anchor, currentState.Page);
if (!currentState.Page.Links.ContainsKey(anchor.Original)) if(newState.Scene != null)
currentState.Page.Links.Add(anchor.Original, newState.UniqueHash);
if (!states.Contains(newState.UniqueHash) && !_processed.ContainsKey(newState.UniqueHash))
{ {
states.Add(newState.UniqueHash); if (!currentState.Page.Links.ContainsKey(anchor.Original))
var newAffected = new List<State>(currentState.AffectedStates); currentState.Page.Links.Add(anchor.Original, newState.UniqueHash);
newAffected.Add(newState.AffectedState);
_processingQueue.Enqueue(new StateQueueItem {Page = newState, AffectedStates = newAffected}); if (!states.Contains(newState.UniqueHash) && !_processed.ContainsKey(newState.UniqueHash))
{
states.Add(newState.UniqueHash);
var newAffected = new List<State>(currentState.AffectedStates);
newAffected.Add(newState.AffectedState);
_processingQueue.Enqueue(new StateQueueItem {Page = newState, AffectedStates = newAffected});
}
} }
} }
} }

View File

@ -1,11 +1,13 @@
namespace Ficdown.Parser.Player namespace Ficdown.Parser.Player
{ {
using System.Collections.Generic; using System.Collections.Generic;
using Model.Parser;
using Model.Player; using Model.Player;
using Model.Story; using Model.Story;
internal interface IGameTraverser internal interface IGameTraverser
{ {
List<FicdownException> Warnings { set; }
Story Story { get; set; } Story Story { get; set; }
IEnumerable<PageState> Enumerate(); IEnumerable<PageState> Enumerate();
IEnumerable<Scene> OrphanedScenes { get; } IEnumerable<Scene> OrphanedScenes { get; }

View File

@ -11,16 +11,33 @@
internal class StateManager internal class StateManager
{ {
private static Logger _logger = Logger.GetLogger<StateManager>();
private readonly Story _story; private readonly Story _story;
private readonly Dictionary<string, int> _stateMatrix; private readonly Dictionary<string, int> _stateMatrix;
private readonly int _sceneCount; private readonly int _sceneCount;
private readonly int _actionCount; private readonly int _actionCount;
private BitArray _scenesSeenMask;
public StateManager(Story story) private List<FicdownException> _warnings { get; set; }
public StateManager(Story story, List<FicdownException> warnings)
{ {
_warnings = warnings;
_story = story; _story = story;
var allScenes = _story.Scenes.SelectMany(s => s.Value); var allScenes = _story.Scenes.SelectMany(s => s.Value);
_sceneCount = allScenes.Max(s => s.Id); _sceneCount = allScenes.Max(s => s.Id);
_scenesSeenMask = new BitArray(_sceneCount);
// figure out which scenes can affect state
var masked = 0;
foreach(var scene in allScenes)
{
if(Utilities.GetInstance().ParseAnchors(scene.RawDescription).Any(a => a.Href.Toggles != null) || RegexLib.BlockQuotes.IsMatch(scene.RawDescription))
{
_scenesSeenMask[scene.Id - 1] = true;
masked++;
}
}
_actionCount = _story.Actions.Count > 0 ? _story.Actions.Max(a => a.Value.Id) : 0; _actionCount = _story.Actions.Count > 0 ? _story.Actions.Max(a => a.Value.Id) : 0;
_stateMatrix = new Dictionary<string, int>(); _stateMatrix = new Dictionary<string, int>();
var state = 0; var state = 0;
@ -28,23 +45,34 @@
var toggle in var toggle in
allScenes.SelectMany( allScenes.SelectMany(
sc => sc =>
Utilities.GetInstance(sc.Name, sc.LineNumber).ParseAnchors(sc.Description) Utilities.GetInstance(_warnings, sc.Name, sc.LineNumber).ParseAnchors(sc.RawDescription)
.SelectMany( .SelectMany(
a => a =>
a.Href.Toggles != null a.Href != null && a.Href.Toggles != null
? a.Href.Toggles.Where(t => !_stateMatrix.ContainsKey(t)) ? a.Href.Toggles.Where(t => !_stateMatrix.ContainsKey(t))
: new string[] {}))) : new string[] {})))
{ {
_stateMatrix.Add(toggle, state++); _stateMatrix.Add(toggle, state++);
} }
_logger.Debug($"{_sceneCount} scenes ({masked} can change state).");
_logger.Debug($"{_actionCount} actions.");
_logger.Debug($"{_stateMatrix.Count()} states.");
} }
public PageState InitialState public PageState InitialState
{ {
get get
{ {
var scene = _story.Scenes[_story.FirstScene].Where(s => s.Conditions == null);
if(scene.Count() == 0)
throw new FicdownException(_story.Name, string.Format("Story links to undefined scene: {0}", _story.FirstScene));
if(scene.Count() > 1)
_warnings.Add(new FicdownException(_story.Name, string.Format("Story links to scene that is defined more than once: {0}", _story.FirstScene)));
return new PageState return new PageState
{ {
Manager = this,
Id = Guid.Empty, Id = Guid.Empty,
Links = new Dictionary<string, string>(), Links = new Dictionary<string, string>(),
State = new State State = new State
@ -61,7 +89,7 @@
ActionsToShow = new BitArray(_actionCount), ActionsToShow = new BitArray(_actionCount),
ActionFirstToggles = null ActionFirstToggles = null
}, },
Scene = _story.Scenes[_story.FirstScene].Single(s => s.Conditions == null), Scene = scene.First(),
StateMatrix = _stateMatrix StateMatrix = _stateMatrix
}; };
} }
@ -83,7 +111,7 @@
if(actionFirstToggles == null) actionFirstToggles = new List<bool>(); if(actionFirstToggles == null) actionFirstToggles = new List<bool>();
newState.State.ActionsToShow[_story.Actions[toggle].Id - 1] = true; newState.State.ActionsToShow[_story.Actions[toggle].Id - 1] = true;
if ( if (
Utilities.GetInstance(_story.Actions[toggle].Toggle, _story.Actions[toggle].LineNumber).ParseAnchors(_story.Actions[toggle].Description) Utilities.GetInstance(_warnings, _story.Actions[toggle].Toggle, _story.Actions[toggle].LineNumber).ParseAnchors(_story.Actions[toggle].RawDescription)
.Any(a => a.Href.Conditions != null && a.Href.Conditions.ContainsKey(toggle))) .Any(a => a.Href.Conditions != null && a.Href.Conditions.ContainsKey(toggle)))
actionFirstToggles.Add(!current.State.PlayerState[_stateMatrix[toggle]]); actionFirstToggles.Add(!current.State.PlayerState[_stateMatrix[toggle]]);
} }
@ -93,13 +121,16 @@
newState.State.ActionFirstToggles = actionFirstToggles != null newState.State.ActionFirstToggles = actionFirstToggles != null
? new BitArray(actionFirstToggles.ToArray()) ? new BitArray(actionFirstToggles.ToArray())
: null; : null;
newState.Scene = GetScene(current.Scene.Name, current.Scene.LineNumber, target, newState.State.PlayerState); newState.Scene = GetScene(current.Scene.Name, anchor, target, newState.State.PlayerState);
return newState; return newState;
} }
public void ToggleStateOn(State state, string toggle) public void ToggleStateOn(State state, string toggle, string blockName, int lineNumber, int colNumber)
{ {
state.PlayerState[_stateMatrix[toggle]] = true; if(_stateMatrix.ContainsKey(toggle))
state.PlayerState[_stateMatrix[toggle]] = true;
else
_warnings.Add(new FicdownException(blockName, string.Format("Conditional for undefined state: {0}", toggle), lineNumber, colNumber));
} }
public void ToggleSeenSceneOn(State state, int sceneId) public void ToggleSeenSceneOn(State state, int sceneId)
@ -107,14 +138,14 @@
state.ScenesSeen[sceneId - 1] = true; state.ScenesSeen[sceneId - 1] = true;
} }
public static string GetUniqueHash(State state, string sceneKey) public string GetUniqueHash(State state, string sceneKey)
{ {
var combined = var combined =
new bool[ new bool[
state.PlayerState.Count + state.ScenesSeen.Count + state.ActionsToShow.Count + state.PlayerState.Count + state.ScenesSeen.Count + state.ActionsToShow.Count +
(state.ActionFirstToggles != null ? state.ActionFirstToggles.Count : 0)]; (state.ActionFirstToggles != null ? state.ActionFirstToggles.Count : 0)];
state.PlayerState.CopyTo(combined, 0); state.PlayerState.CopyTo(combined, 0);
state.ScenesSeen.CopyTo(combined, state.PlayerState.Count); state.ScenesSeen.And(_scenesSeenMask).CopyTo(combined, state.PlayerState.Count);
state.ActionsToShow.CopyTo(combined, state.PlayerState.Count + state.ScenesSeen.Count); state.ActionsToShow.CopyTo(combined, state.PlayerState.Count + state.ScenesSeen.Count);
if (state.ActionFirstToggles != null) if (state.ActionFirstToggles != null)
state.ActionFirstToggles.CopyTo(combined, state.ActionFirstToggles.CopyTo(combined,
@ -122,11 +153,15 @@
var ba = new BitArray(combined); var ba = new BitArray(combined);
var byteSize = (int)Math.Ceiling(combined.Length / 8.0); var byteSize = (int)Math.Ceiling(combined.Length / 8.0);
var encoded = new byte[byteSize]; var encoded = new byte[byteSize];
for(var i = 0; i < byteSize; i++)
{
encoded[i] = 0;
}
ba.CopyTo(encoded, 0); ba.CopyTo(encoded, 0);
return string.Format("{0}=={1}", sceneKey, Convert.ToBase64String(encoded)); return string.Format("{0}=={1}", sceneKey, Convert.ToBase64String(encoded));
} }
public static string GetCompressedHash(PageState page) public string GetCompressedHash(PageState page)
{ {
var compressed = new State var compressed = new State
{ {
@ -138,24 +173,26 @@
return GetUniqueHash(compressed, page.Scene.Key); return GetUniqueHash(compressed, page.Scene.Key);
} }
private Scene GetScene(string blockName, int lineNumber, string target, BitArray playerState) private Scene GetScene(string blockName, Anchor anchor, string target, BitArray playerState)
{ {
if (!_story.Scenes.ContainsKey(target)) if (_story.Scenes.ContainsKey(target))
throw new FicdownException(blockName, lineNumber, string.Format("Encountered link to non-existent scene: {0}", target));
Scene newScene = null;
foreach (var scene in _story.Scenes[target])
{ {
if (ConditionsMatch(scene, playerState) && Scene newScene = null;
(newScene == null || newScene.Conditions == null || foreach (var scene in _story.Scenes[target])
scene.Conditions.Count > newScene.Conditions.Count))
{ {
newScene = scene; if (ConditionsMatch(scene, playerState) &&
(newScene == null || newScene.Conditions == null ||
(scene.Conditions != null && scene.Conditions.Count > newScene.Conditions.Count)))
{
newScene = scene;
}
} }
if (newScene == null)
_warnings.Add(new FicdownException(blockName, string.Format("Link to scene that is undefined for conditionals: {0}", target), anchor.LineNumber, anchor.ColNumber));
return newScene;
} }
if (newScene == null) _warnings.Add(new FicdownException(blockName, string.Format("Link to undefined scene: {0}", target), anchor.LineNumber, anchor.ColNumber));
throw new FicdownException(blockName, lineNumber, string.Format("Scene {0} reached with unmatched player state", target)); return null;
return newScene;
} }
private bool ConditionsMatch(Scene scene, BitArray playerState) private bool ConditionsMatch(Scene scene, BitArray playerState)
@ -163,15 +200,18 @@
if (scene.Conditions == null) return true; if (scene.Conditions == null) return true;
scene.Conditions.ToList().ForEach(c => scene.Conditions.ToList().ForEach(c =>
{ {
if(!_stateMatrix.ContainsKey(c.Key)) throw new FicdownException(scene.Name, scene.LineNumber, string.Format("Reference to non-existent state: {0}", c.Key)); if(!_stateMatrix.ContainsKey(c.Key))
_warnings.Add(new FicdownException(scene.Name, string.Format("Conditional for undefined state: {0}", c.Key), scene.LineNumber));
}); });
return scene.Conditions.All(c => playerState[_stateMatrix[c.Key]] == c.Value); return scene.Conditions.Where(c => _stateMatrix.ContainsKey(c.Key)).All(c => playerState[_stateMatrix[c.Key]] == c.Value);
} }
private PageState ClonePage(PageState page) private PageState ClonePage(PageState page)
{ {
return new PageState return new PageState
{ {
Manager = this,
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Links = new Dictionary<string, string>(), Links = new Dictionary<string, string>(),
State = new State State = new State

View File

@ -89,6 +89,7 @@
public class EpubRenderer : HtmlRenderer public class EpubRenderer : HtmlRenderer
{ {
private static readonly Logger _logger = Logger.GetLogger<EpubRenderer>();
private readonly string _author; private readonly string _author;
private readonly string _bookId; private readonly string _bookId;
private readonly string _language; private readonly string _language;
@ -103,6 +104,7 @@
public override void Render(Model.Parser.ResolvedStory story, string outPath, bool debug = false) public override void Render(Model.Parser.ResolvedStory story, string outPath, bool debug = false)
{ {
_logger.Debug("Generating epub...");
var temppath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); var temppath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(temppath); Directory.CreateDirectory(temppath);
base.Render(story, temppath, debug); base.Render(story, temppath, debug);

View File

@ -3,15 +3,16 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using MarkdownSharp; using Markdig;
using Model.Parser; using Model.Parser;
using Parser; using Parser;
public class HtmlRenderer : IRenderer public class HtmlRenderer : IRenderer
{ {
private static Logger _logger = Logger.GetLogger<HtmlRenderer>();
private readonly string _language; private readonly string _language;
protected readonly Markdown Markdown; public List<FicdownException> Warnings { private get; set; }
public string IndexTemplate { get; set; } public string IndexTemplate { get; set; }
public string SceneTemplate { get; set; } public string SceneTemplate { get; set; }
@ -23,7 +24,6 @@
public HtmlRenderer(string language) public HtmlRenderer(string language)
{ {
_language = language; _language = language;
Markdown = new Markdown();
} }
public virtual void Render(ResolvedStory story, string outPath, bool debug = false) public virtual void Render(ResolvedStory story, string outPath, bool debug = false)
@ -40,11 +40,12 @@
protected void GenerateHtml(ResolvedStory story, string outPath, bool debug) protected void GenerateHtml(ResolvedStory story, string outPath, bool debug)
{ {
_logger.Debug("Generating HTML...");
var index = FillTemplate(IndexTemplate ?? Template.Index, new Dictionary<string, string> var index = FillTemplate(IndexTemplate ?? Template.Index, new Dictionary<string, string>
{ {
{"Language", _language}, {"Language", _language},
{"Title", story.Name}, {"Title", story.Name},
{"Description", Markdown.Transform(story.Description)}, {"Description", Markdown.ToHtml(story.Description)},
{"FirstScene", string.Format("{0}.html", story.FirstPage)} {"FirstScene", string.Format("{0}.html", story.FirstPage)}
}); });
@ -55,7 +56,7 @@
File.WriteAllText(Path.Combine(outPath, "styles.css"), StylesTemplate ?? Template.Styles); File.WriteAllText(Path.Combine(outPath, "styles.css"), StylesTemplate ?? Template.Styles);
var content = page.Content; var content = page.Content;
foreach (var anchor in Utilities.GetInstance(page.Name).ParseAnchors(page.Content)) foreach (var anchor in Utilities.GetInstance(Warnings, page.Name).ParseAnchors(page.Content))
{ {
var newAnchor = string.Format("[{0}]({1}.html)", anchor.Text, anchor.Href.Target); var newAnchor = string.Format("[{0}]({1}.html)", anchor.Text, anchor.Href.Target);
content = content.Replace(anchor.Original, newAnchor); content = content.Replace(anchor.Original, newAnchor);
@ -70,7 +71,7 @@
{ {
{"Language", _language}, {"Language", _language},
{"Title", story.Name}, {"Title", story.Name},
{"Content", Markdown.Transform(content)} {"Content", Markdown.ToHtml(content)}
}); });
File.WriteAllText(Path.Combine(outPath, string.Format("{0}.html", page.Name)), scene); File.WriteAllText(Path.Combine(outPath, string.Format("{0}.html", page.Name)), scene);

View File

@ -1,10 +1,11 @@
namespace Ficdown.Parser.Render namespace Ficdown.Parser.Render
{ {
using System.Security.Cryptography.X509Certificates; using System.Collections.Generic;
using Model.Parser; using Model.Parser;
public interface IRenderer public interface IRenderer
{ {
List<FicdownException> Warnings { set; }
string IndexTemplate { get; set; } string IndexTemplate { get; set; }
string SceneTemplate { get; set; } string SceneTemplate { get; set; }
string StylesTemplate { get; set; } string StylesTemplate { get; set; }

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Rudis Muiznieks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -10,7 +10,7 @@ test:
dotnet test Ficdown.Parser.Tests dotnet test Ficdown.Parser.Tests
publish: clean publish: clean
rm /tmp/ficdown* rm -rf /tmp/ficdown*
dotnet publish --self-contained -c Release -r linux-x64 Ficdown.Console dotnet publish --self-contained -c Release -r linux-x64 Ficdown.Console
tar -C Ficdown.Console/bin/Release/netcoreapp2.1/linux-x64/publish -cvzf /tmp/ficdown-linux64.tar.gz . tar -C Ficdown.Console/bin/Release/netcoreapp2.1/linux-x64/publish -cvzf /tmp/ficdown-linux64.tar.gz .
dotnet publish --self-contained -c Release -r win-x64 Ficdown.Console dotnet publish --self-contained -c Release -r win-x64 Ficdown.Console

View File

@ -1,6 +1,6 @@
# Ficdown # Ficdown
Ficdown is a system for building interactive fiction using MarkDown syntax. See [Ficdown.com](http://www.ficdown.com) for more information. Ficdown is a system for building interactive fiction using MarkDown syntax.
This project contains the core Ficdown library for parsing Ficdown stories, as well as a console application that can be used to generate HTML or epub ebook formats. This project contains the core Ficdown library for parsing Ficdown stories, as well as a console application that can be used to generate HTML or epub ebook formats.
@ -10,7 +10,7 @@ Ficdown is written using .NET Core and should run on Windows, Linux, and OSX wit
## Obtaining ## Obtaining
If you want to use Ficdown to convert your stories into ebooks, download the latest version from the [releases](https://github.com/rudism/Ficdown/releases) page and decompress it somewhere on your hard drive. Ficdown does not include an installer, the application and all of its dependencies are included directly in the zip archive. If you want to use Ficdown to convert your stories into ebooks, download the latest version from the [releases](https://code.sitosis.com/rudism/ficdown/releases) page and decompress it somewhere on your hard drive. Ficdown does not include an installer, the application and all of its dependencies are included directly in the zip archive.
## Usage ## Usage
@ -29,8 +29,8 @@ The pre-built releases are self-contained .NET Core deployments, so you should b
Running ficdown.exe without any arguments will produce the following help text: Running ficdown.exe without any arguments will produce the following help text:
Usage: ficdown.exe Usage: ficdown.exe
--format (html|epub) --format (html|epub|lint)
--in "/path/to/source.md" --in "/path/to/source.md" (lint reads stdin)
[--out "/path/to/output"] [--out "/path/to/output"]
[--template "/path/to/template/dir"] [--template "/path/to/template/dir"]
[--images "/path/to/images/dir"] [--images "/path/to/images/dir"]
@ -85,6 +85,10 @@ If you pass this option, all of the pages in your story will include output at t
To generate other formats than HTML or epub, you will have to use third party tools. [Calibre](http://www.calibre-ebook.com) is a popular ebook management suite that includes the ability to convert books from almost any format to any other format. Also, Amazon has an official tool called [KindleGen](http://www.amazon.com/gp/feature.html?docId=1000765211) that you can use to convert your epub to a format that can be read on Kindles. To generate other formats than HTML or epub, you will have to use third party tools. [Calibre](http://www.calibre-ebook.com) is a popular ebook management suite that includes the ability to convert books from almost any format to any other format. Also, Amazon has an official tool called [KindleGen](http://www.amazon.com/gp/feature.html?docId=1000765211) that you can use to convert your epub to a format that can be read on Kindles.
### Interactive Website ## Additional Tools
Ficdown stories can be played interactively in a web browser without even requiring the command line utility here. See [Ficdown.js](https://github.com/rudism/Ficdown.js) for a Javascript Ficdown parser and interpreter that you can include on your own website to present your Ficdown stories. - Ficdown stories can be played interactively in a web browser without even requiring the command line utility here. See [Ficdown.js](https://code.sitosis.com/rudism/ficdown.js) for a Javascript Ficdown parser and interpreter that you can include on your own website to present your Ficdown stories.
- [Ficdown-editor](https://byfernanz.github.io/ficdown-editor/) is a web-based GUI for writing Ficdown.
- [Prop](https://github.com/ByFernanz/prop) is a YAML-header style preprocessor for Ficdown