mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-06-06 01:21:18 -04:00
Make it work
This commit is contained in:
parent
01ad2fd711
commit
eb59cbde28
11 changed files with 581 additions and 0 deletions
22
DiscordChatExporter.sln
Normal file
22
DiscordChatExporter.sln
Normal file
|
@ -0,0 +1,22 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio 15
|
||||
VisualStudioVersion = 15.0.26430.13
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{4BE915D1-129C-49E2-860E-62045ACA5EAD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
27
DiscordChatExporter/DiscordChatExporter.csproj
Normal file
27
DiscordChatExporter/DiscordChatExporter.csproj
Normal file
|
@ -0,0 +1,27 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net45</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Services\ExportTemplate.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Services\ExportTemplate.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="1.9.71" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
|
||||
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
21
DiscordChatExporter/Models/Attachment.cs
Normal file
21
DiscordChatExporter/Models/Attachment.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Attachment
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public long ContentLength { get; }
|
||||
|
||||
public Attachment(string id, string url, string fileName, long contentLength)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
FileName = fileName;
|
||||
ContentLength = contentLength;
|
||||
}
|
||||
}
|
||||
}
|
22
DiscordChatExporter/Models/ChatLog.cs
Normal file
22
DiscordChatExporter/Models/ChatLog.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class ChatLog
|
||||
{
|
||||
public string ChannelId { get; }
|
||||
|
||||
public IReadOnlyList<User> Participants { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public ChatLog(string channelId, IEnumerable<Message> messages)
|
||||
{
|
||||
ChannelId = channelId;
|
||||
Messages = messages.ToArray();
|
||||
Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
33
DiscordChatExporter/Models/Message.cs
Normal file
33
DiscordChatExporter/Models/Message.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class Message
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public DateTime TimeStamp { get; }
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public IReadOnlyList<Attachment> Attachments { get; }
|
||||
|
||||
public Message(string id, DateTime timeStamp, User author, string content, IEnumerable<Attachment> attachments)
|
||||
{
|
||||
Id = id;
|
||||
TimeStamp = timeStamp;
|
||||
Author = author;
|
||||
Content = content;
|
||||
Attachments = attachments.ToArray();
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Content;
|
||||
}
|
||||
}
|
||||
}
|
29
DiscordChatExporter/Models/User.cs
Normal file
29
DiscordChatExporter/Models/User.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Models
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string AvatarHash { get; }
|
||||
|
||||
public string AvatarUrl => AvatarHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256"
|
||||
: "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png";
|
||||
|
||||
public User(string id, string name, string avatarHash)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
AvatarHash = avatarHash;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
}
|
32
DiscordChatExporter/Options.cs
Normal file
32
DiscordChatExporter/Options.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using CommandLine;
|
||||
using CommandLine.Text;
|
||||
|
||||
namespace DiscordChatExporter
|
||||
{
|
||||
public class Options
|
||||
{
|
||||
[Option('t', "token", Required = true, HelpText = "Discord access token")]
|
||||
public string Token { get; set; }
|
||||
|
||||
[Option('c', "channel", Required = true, HelpText = "ID of the text channel to export")]
|
||||
public string ChannelId { get; set; }
|
||||
|
||||
[HelpOption]
|
||||
public string GetUsage()
|
||||
{
|
||||
var help = new HelpText
|
||||
{
|
||||
Heading = new HeadingInfo("DiscordChatExporter"),
|
||||
Copyright = new CopyrightInfo("Alexey 'Tyrrrz' Golub", 2017),
|
||||
AdditionalNewLineAfterOption = true,
|
||||
AddDashesToOption = true
|
||||
};
|
||||
help.AddPreOptionsLine("Usage: DiscordChatExporter.exe " +
|
||||
"-t REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 " +
|
||||
"-c 459360869055190534");
|
||||
help.AddOptions(this);
|
||||
|
||||
return help;
|
||||
}
|
||||
}
|
||||
}
|
37
DiscordChatExporter/Program.cs
Normal file
37
DiscordChatExporter/Program.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Models;
|
||||
using DiscordChatExporter.Services;
|
||||
|
||||
namespace DiscordChatExporter
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static readonly Options Options = new Options();
|
||||
|
||||
private static readonly DiscordApiService DiscordApiService = new DiscordApiService();
|
||||
private static readonly ExportService ExportService = new ExportService();
|
||||
|
||||
private static async Task MainAsync(string[] args)
|
||||
{
|
||||
// Parse cmd args
|
||||
CommandLine.Parser.Default.ParseArgumentsStrict(args, Options);
|
||||
|
||||
// Get messages
|
||||
Console.WriteLine("Getting messages...");
|
||||
var messages = await DiscordApiService.GetMessagesAsync(Options.Token, Options.ChannelId);
|
||||
var chatLog = new ChatLog(Options.ChannelId, messages);
|
||||
|
||||
// Export
|
||||
Console.WriteLine("Exporting messages...");
|
||||
ExportService.Export($"{Options.ChannelId}.html", chatLog);
|
||||
}
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Console.Title = "Discord Chat Exporter";
|
||||
|
||||
MainAsync(args).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
94
DiscordChatExporter/Services/DiscordApiService.cs
Normal file
94
DiscordChatExporter/Services/DiscordApiService.cs
Normal file
|
@ -0,0 +1,94 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Services
|
||||
{
|
||||
public class DiscordApiService
|
||||
{
|
||||
private const string ApiRoot = "https://discordapp.com/api";
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
private IEnumerable<Message> ParseMessages(string json)
|
||||
{
|
||||
var messagesJson = JArray.Parse(json);
|
||||
foreach (var messageJson in messagesJson)
|
||||
{
|
||||
// Get basic data
|
||||
string id = messageJson.Value<string>("id");
|
||||
var timeStamp = messageJson.Value<DateTime>("timestamp");
|
||||
string content = messageJson.Value<string>("content");
|
||||
|
||||
// Get author
|
||||
var authorJson = messageJson["author"];
|
||||
string authorId = authorJson.Value<string>("id");
|
||||
string authorName = authorJson.Value<string>("username");
|
||||
string authorAvatarHash = authorJson.Value<string>("avatar");
|
||||
|
||||
// Get attachment
|
||||
var attachmentsJson = messageJson["attachments"];
|
||||
var attachments = new List<Attachment>();
|
||||
foreach (var attachmentJson in attachmentsJson)
|
||||
{
|
||||
string attachmentId = attachmentJson.Value<string>("id");
|
||||
string attachmentUrl = attachmentJson.Value<string>("url");
|
||||
string attachmentFileName = attachmentJson.Value<string>("filename");
|
||||
long attachmentContentLength = attachmentJson.Value<long>("size");
|
||||
|
||||
var attachment = new Attachment(attachmentId, attachmentUrl, attachmentFileName, attachmentContentLength);
|
||||
attachments.Add(attachment);
|
||||
}
|
||||
|
||||
var author = new User(authorId, authorName, authorAvatarHash);
|
||||
var message = new Message(id, timeStamp, author, content, attachments);
|
||||
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Message>> GetMessagesAsync(string token, string channelId)
|
||||
{
|
||||
var result = new List<Message>();
|
||||
|
||||
// We are going backwards from last message to first
|
||||
// ...collecting everything between them in batches
|
||||
string beforeId = null;
|
||||
while (true)
|
||||
{
|
||||
// Form request url
|
||||
string url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
|
||||
if (beforeId.IsNotBlank())
|
||||
url += $"&before={beforeId}";
|
||||
|
||||
// Get response
|
||||
string response = await _httpClient.GetStringAsync(url);
|
||||
|
||||
// Parse
|
||||
var messages = ParseMessages(response);
|
||||
|
||||
// Add messages to list
|
||||
string currentMessageId = null;
|
||||
foreach (var message in messages)
|
||||
{
|
||||
result.Add(message);
|
||||
currentMessageId = message.Id;
|
||||
}
|
||||
|
||||
// If no messages - break
|
||||
if (currentMessageId == null) break;
|
||||
|
||||
// Otherwise offset the next request
|
||||
beforeId = currentMessageId;
|
||||
}
|
||||
|
||||
// Messages appear newest first, we need to reverse
|
||||
result.Reverse();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
180
DiscordChatExporter/Services/ExportService.cs
Normal file
180
DiscordChatExporter/Services/ExportService.cs
Normal file
|
@ -0,0 +1,180 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Models;
|
||||
using HtmlAgilityPack;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Services
|
||||
{
|
||||
public class ExportService
|
||||
{
|
||||
private class MessageGroup
|
||||
{
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime FirstTimeStamp { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
FirstTimeStamp = firstTimeStamp;
|
||||
Messages = messages.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private HtmlDocument GetTemplate()
|
||||
{
|
||||
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using (var stream = assembly.GetManifestResourceStream(templateName))
|
||||
{
|
||||
var doc = new HtmlDocument();
|
||||
doc.Load(stream);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
||||
{
|
||||
var result = new List<MessageGroup>();
|
||||
|
||||
// Group adjacent messages by timestamp and author
|
||||
var buffer = new List<Message>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var bufferFirst = buffer.FirstOrDefault();
|
||||
|
||||
// Group break condition
|
||||
bool breakCondition =
|
||||
bufferFirst != null &&
|
||||
(
|
||||
message.Author.Id != bufferFirst.Author.Id ||
|
||||
(message.TimeStamp - bufferFirst.TimeStamp).TotalHours > 1 ||
|
||||
message.TimeStamp.Hour != bufferFirst.TimeStamp.Hour
|
||||
);
|
||||
|
||||
// If condition is true - flush buffer
|
||||
if (breakCondition)
|
||||
{
|
||||
var group = new MessageGroup(bufferFirst.Author, bufferFirst.TimeStamp, buffer);
|
||||
result.Add(group);
|
||||
buffer.Clear();
|
||||
}
|
||||
|
||||
// Add message to buffer
|
||||
buffer.Add(message);
|
||||
}
|
||||
|
||||
// Add what's remaining in buffer
|
||||
if (buffer.Any())
|
||||
{
|
||||
var bufferFirst = buffer.First();
|
||||
var group = new MessageGroup(bufferFirst.Author, bufferFirst.TimeStamp, buffer);
|
||||
result.Add(group);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string FormatMessageContent(string content)
|
||||
{
|
||||
// Encode HTML
|
||||
content = HtmlDocument.HtmlEncode(content);
|
||||
|
||||
// Links from URLs
|
||||
content = Regex.Replace(content, "((^|\\s)(https?|ftp)://[^\\s/$.?#].[^\\s]*($|\\s))",
|
||||
"<a href=\"$1\">$1</a>");
|
||||
|
||||
// Preformatted multiline
|
||||
content = Regex.Replace(content, "```([^`]*?)```", e => "<pre>" + e.Groups[1].Value + "</pre>");
|
||||
|
||||
// Preformatted
|
||||
content = Regex.Replace(content, "`([^`]*?)`", e => "<pre>" + e.Groups[1].Value + "</pre>");
|
||||
|
||||
// Bold
|
||||
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
|
||||
|
||||
// Italic
|
||||
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
|
||||
|
||||
// Underline
|
||||
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
|
||||
|
||||
// Strike through
|
||||
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
|
||||
|
||||
// New lines
|
||||
content = content.Replace("\n", "</br>");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
public void Export(string filePath, ChatLog chatLog)
|
||||
{
|
||||
var doc = GetTemplate();
|
||||
|
||||
// Info
|
||||
var infoHtml = doc.GetElementbyId("info");
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
|
||||
string participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
|
||||
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
|
||||
|
||||
// Messages
|
||||
var logHtml = doc.GetElementbyId("log");
|
||||
var messageGroups = GroupMessages(chatLog.Messages);
|
||||
foreach (var messageGroup in messageGroups)
|
||||
{
|
||||
// Container
|
||||
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
|
||||
|
||||
// Avatar
|
||||
messageHtml.AppendChild(HtmlNode.CreateNode("<img class=\"msg-avatar\" " +
|
||||
$"src=\"{messageGroup.Author.AvatarUrl}\"></img>"));
|
||||
|
||||
// Body
|
||||
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
|
||||
|
||||
// Author
|
||||
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
|
||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
|
||||
|
||||
// Date
|
||||
string timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
|
||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
|
||||
|
||||
// Separate messages
|
||||
foreach (var message in messageGroup.Messages)
|
||||
{
|
||||
// Content
|
||||
if (message.Content.IsNotBlank())
|
||||
{
|
||||
string content = FormatMessageContent(message.Content);
|
||||
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
|
||||
}
|
||||
|
||||
// Attachments
|
||||
if (message.Attachments.Any())
|
||||
{
|
||||
// Attachments
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
messageBodyHtml.AppendChild(
|
||||
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
|
||||
$"<a href=\"{attachment.Url}\">" +
|
||||
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
|
||||
"</a></div>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.Save(filePath);
|
||||
}
|
||||
}
|
||||
}
|
84
DiscordChatExporter/Services/ExportTemplate.html
Normal file
84
DiscordChatExporter/Services/ExportTemplate.html
Normal file
|
@ -0,0 +1,84 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Discord Chat Log</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #37bcf7;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
background-color: #f9f9f9;
|
||||
font-family: Consolas, Courier New, Courier, Monospace;
|
||||
display: inline;
|
||||
}
|
||||
div#info {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
color: #939799;
|
||||
}
|
||||
div#log {
|
||||
width: 100%;
|
||||
}
|
||||
div.msg {
|
||||
display: flex;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-top: 1px solid #eceeef;
|
||||
}
|
||||
img.msg-avatar {
|
||||
flex: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
div.msg-body {
|
||||
flex: 1;
|
||||
margin-left: 15px;
|
||||
}
|
||||
span.msg-user {
|
||||
color: #2f3136;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
span.msg-date {
|
||||
margin-left: 5px;
|
||||
color: #b7bcbf;
|
||||
font-size: 0.8em;
|
||||
font-weight: 200;
|
||||
}
|
||||
div.msg-content {
|
||||
padding-top: 5px;
|
||||
color: #939799;
|
||||
}
|
||||
div.msg-attachment {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
img.msg-attachment {
|
||||
max-width: 50%;
|
||||
max-height: 500px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="info" />
|
||||
<div id="log" />
|
||||
</body>
|
||||
</html>
|
Loading…
Add table
Add a link
Reference in a new issue