1 /++
2  + Adds support for logging std.logger messages to HTML files.
3  + Authors: Cameron "Herringway" Ross
4  + Copyright: Copyright Cameron Ross 2016
5  + License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
6  +/
7 module htmllog;
8 import std.algorithm : among;
9 import std.array;
10 import std.conv : to;
11 import std.exception : assumeWontThrow;
12 import std.experimental.logger;
13 import std.format : format, formattedWrite;
14 import std.range : isOutputRange, NullSink, put;
15 import std.stdio : File;
16 import std.traits : EnumMembers;
17 import std.typecons : tuple;
18 /++
19  + Logs messages to a .html file. When viewed in a browser, it provides an
20  + easily-searchable and filterable view of logged messages.
21  +/
22 public class HTMLLogger : Logger {
23 	///File handle being written to.
24 	private File handle;
25 
26 	/++
27 	 + Creates a new log file with the specified path and filename.
28 	 + Params:
29 	 +   logpath = Full path and filename for the log file
30 	 +   lv = Minimum message level to write to the log
31 	 +   defaultMinDisplayLevel = Minimum message level visible by default
32 	 +/
33 	this(string logpath, LogLevel lv = LogLevel.all, LogLevel defaultMinDisplayLevel = LogLevel.all) @safe {
34 		super(lv);
35 		handle.open(logpath, "w");
36 		writeHeader(defaultMinDisplayLevel);
37 	}
38 	/++
39 	 + Writes a log file using an already-opened handle. Note that having
40 	 + pre-existing data in the file will likely cause display errors.
41 	 + Params:
42 	 +   file = Prepared file handle to write log to
43 	 +   lv = Minimum message level to write to the log
44 	 +   defaultMinDisplayLevel = Minimum message level visible by default
45 	 +/
46 	this(File file, LogLevel lv = LogLevel.all, LogLevel defaultMinDisplayLevel = LogLevel.all) @safe in {
47 		assert(file.isOpen);
48 	} body {
49 		super(lv);
50 		handle = file;
51 		writeHeader(defaultMinDisplayLevel);
52 	}
53 	~this() @safe {
54 		if (handle.isOpen) {
55 			writeFmt(HTMLTemplate.footer);
56 			handle.close();
57 		}
58 	}
59 	/++
60 	 + Writes a log message. For internal use by std.experimental.logger.
61 	 + Params:
62 	 +   payLoad = Data for the log entry being written
63 	 + See_Also: $(LINK https://dlang.org/library/std/experimental/logger.html)
64 	 +/
65 	override public void writeLogMsg(ref LogEntry payLoad) @safe {
66 		if (payLoad.logLevel >= logLevel)
67 			writeFmt(HTMLTemplate.entry, payLoad.logLevel, payLoad.timestamp.toISOExtString(), payLoad.timestamp.toSimpleString(), payLoad.moduleName, payLoad.line, payLoad.threadId, HtmlEscaper(payLoad.msg));
68 	}
69 	/++
70 	 + Initializes log file by writing header tags, etc.
71 	 + Params:
72 	 +   minDisplayLevel = Minimum message level visible by default
73 	 +/
74 	private void writeHeader(LogLevel minDisplayLevel) @safe {
75 		static bool initialized = false;
76 		if (initialized)
77 			return;
78 		writeFmt(HTMLTemplate.header, minDisplayLevel.among!(EnumMembers!LogLevel)-1);
79 		initialized = true;
80 	}
81 	/++
82 	 + Safe wrapper around handle.lockingTextWriter().
83 	 + Params:
84 	 +   fmt = Format of string to write
85 	 +   args = Values to place into formatted string
86 	 +/
87 	private void writeFmt(T...)(string fmt, T args) @trusted {
88 		formattedWrite(handle.lockingTextWriter(), fmt, args);
89 		handle.flush();
90 	}
91 }
92 ///
93 @safe unittest {
94 	auto logger = new HTMLLogger("test.html", LogLevel.trace);
95 	logger.fatalHandler = () {};
96 	foreach (i; 0..100) { //Log one hundred of each king of message
97 		logger.trace("Example - Trace");
98 		logger.info("Example - Info");
99 		logger.warning("Example - Warning");
100 		logger.error("Example - Error");
101 		logger.critical("Example - Critical");
102 		logger.fatal("Example - Fatal");
103 	}
104 }
105 /++
106  + Escapes special HTML characters. Avoids allocating where possible.
107  +/
108 private struct HtmlEscaper {
109 	///String to escape
110 	string data;
111 	/+
112 	 + Converts data to escaped HTML string. Outputs to an output range to avoid
113 	 + unnecessary allocation.
114 	 +/
115 	void toString(T)(auto ref T sink) const if (isOutputRange!(T, immutable char)) {
116 		foreach (character; data) {
117 			switch (character) {
118 				default: put(sink, character); break;
119 				case 0: .. case 9:
120 				case 11: .. case 12:
121 				case 14: .. case 31:
122 					put(sink, "&#");
123 					//since we're guaranteed to have a 1 or 2 digit number, this works
124 					put(sink, cast(char)('0'+(character/10)));
125 					put(sink, cast(char)('0'+(character%10)));
126 					break;
127 				case '\n', '\r': put(sink, "<br/>"); break;
128 				case '&': put(sink, "&amp;"); break;
129 				case '<': put(sink, "&lt;"); break;
130 				case '>': put(sink, "&gt;"); break;
131 			}
132 		}
133 	}
134 }
135 @safe pure @nogc unittest {
136 	import std.conv : text;
137 	struct StaticBuf(size_t Size) {
138 		char[Size] data;
139 		size_t offset;
140 		void put(immutable char character) @nogc {
141 			data[offset] = character;
142 			offset++;
143 		}
144 	}
145 	{
146 		auto buf = StaticBuf!0();
147 		HtmlEscaper("").toString(buf);
148 		assert(buf.data == "");
149 	}
150 	{
151 		auto buf = StaticBuf!5();
152 		HtmlEscaper("\n").toString(buf);
153 		assert(buf.data == "<br/>");
154 	}
155 	{
156 		auto buf = StaticBuf!4();
157 		HtmlEscaper("\x1E").toString(buf);
158 		assert(buf.data == "&#30");
159 	}
160 }
161 ///Template components for log file
162 private enum HTMLTemplate = tuple!("header", "entry", "footer")(
163 `<!DOCTYPE html>
164 <html>
165 	<head>
166 		<title>HTML Log</title>
167 		<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
168 		<style content="text/css">
169 			.trace        { color: lightgray; }
170 			.info         { color: black; }
171 			.warning      { color: darkorange; }
172 			.error        { color: darkred; }
173 			.critical     { color: crimson; }
174 			.fatal        { color: red; }
175 
176 			body          { font-size: 10pt; margin: 0px; }
177 			.logmessage   { font-family: monospace; margin-left: 10pt; margin-right: 10pt; }
178 			.log          { margin-top: 15pt; margin-bottom: 15pt; }
179 
180 			time, div.time {
181 				display: inline-block;
182 				width: 180pt;
183 			}
184 			div.source {
185 				display: inline-block;
186 				width: 200pt;
187 			}
188 			div.threadName {
189 				display: inline-block;
190 				width: 100pt;
191 			}
192 			div.message {
193 				display: inline-block;
194 				width: calc(100%% - 500pt);
195 			}
196 			header, footer {
197 				position: fixed;
198 				width: 100%%;
199 				height: 15pt;
200 				z-index: 1;
201 			}
202 			footer {
203 				bottom: 0px;
204 				background-color: lightgray;
205 			}
206 			header {
207 				top: 0px;
208 				background-color: white;
209 			}
210 		</style>
211 		<script language="JavaScript">
212 			function updateLevels(i){
213 				var style = document.styleSheets[0].cssRules[i].style;
214 				if (event.target.checked)
215 					style.display = "";
216 				else
217 					style.display = "none";
218 			}
219 		</script>
220 	</head>
221 	<body>
222 		<header class="logmessage">
223 			<div class="time">Time</div>
224 			<div class="source">Source</div>
225 			<div class="threadName">Thread</div>
226 			<div class="message">Message</div>
227 		</header>
228 		<footer>
229 			<form class="menubar">
230 				<input type="checkbox" id="level0" onChange="updateLevels(0)" checked> <label for="level0">Trace</label>
231 				<input type="checkbox" id="level1" onChange="updateLevels(1)" checked> <label for="level1">Info</label>
232 				<input type="checkbox" id="level2" onChange="updateLevels(2)" checked> <label for="level2">Warning</label>
233 				<input type="checkbox" id="level3" onChange="updateLevels(3)" checked> <label for="level3">Error</label>
234 				<input type="checkbox" id="level4" onChange="updateLevels(4)" checked> <label for="level4">Critical</label>
235 				<input type="checkbox" id="level5" onChange="updateLevels(5)" checked> <label for="level5">Fatal</label>
236 			</form>
237 		</footer>
238 		<script language="JavaScript">
239 			for (var i = 0; i < %s; i++) {
240 				document.styleSheets[0].cssRules[i].style.display = "none";
241 				document.getElementById("level" + i).checked = false;
242 			}
243 		</script>
244 		<div class="log">`,
245 `
246 			<div class="%s logmessage">
247 				<time datetime="%s">%s</time>
248 				<div class="source">%s:%s</div>
249 				<div class="threadName">%s</div>
250 				<div class="message">%s</div>
251 			</div>`,
252 `
253 		</div>
254 	</body>
255 </html>`);