/*
* Overchan Android (Meta Imageboard Client)
* Copyright (C) 2014-2016 miku-nyan <https://github.com/miku-nyan>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package nya.miku.wishmaster.ui.downloading;
import java.io.BufferedWriter;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.URLDecoder;
import java.text.DateFormat;
import java.util.Locale;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import nya.miku.wishmaster.R;
import nya.miku.wishmaster.api.ChanModule;
import nya.miku.wishmaster.api.models.AttachmentModel;
import nya.miku.wishmaster.api.models.BadgeIconModel;
import nya.miku.wishmaster.api.models.BoardModel;
import nya.miku.wishmaster.api.models.PostModel;
import nya.miku.wishmaster.api.models.ThreadModel;
import nya.miku.wishmaster.api.models.UrlPageModel;
import nya.miku.wishmaster.api.util.ChanModels;
import nya.miku.wishmaster.cache.SerializablePage;
import nya.miku.wishmaster.common.MainApplication;
import org.apache.commons.lang3.StringEscapeUtils;
import android.content.res.Resources;
import android.graphics.Color;
import android.text.Html;
/**
* Построение (компиляция) HTML-страницы.<br>
* Строится на основе разметки страниц, генерируемых чистым движком wakaba.<br>
* Полученная страница должна быть совместима с юзерскриптом freedollchan.
* @author miku-nyan
*
*/
public class HtmlBuilder implements Closeable {
/** Имена файлов (css и js), которые будут использоваться веб-страницей */
public static final String[] ASSETS = new String[] {
"futaba.css", "photon.css", "burichan.css", "gurochan.css", "dollscript.js", "wakaba3.js"
};
/** Папка в которой должны будут лежать необходимые для веб-страницы файлы ({@link #ASSETS}) */
public static final String DATA_DIR = "data";
private static final String DOLLSCRIPT = "dollscript.js";
private static final String WAKABA3JS = "wakaba3.js";
private static final String[] CSS = new String[] { "Futaba", "Photon", "Burichan", "Gurochan" };
private static final String[] CSS_LINKS = new String[] { "futaba.css", "photon.css", "burichan.css", "gurochan.css" };
private static final Pattern A_HREF_PATTERN = Pattern.compile("<a\\s+(?:[^>]*?\\s+)?href=\"([^\"]*)\"");
private static final String CSS_FORMAT_1 = "<link rel=\"stylesheet\" type=\"text/css\" href=\"%s\" title=\"%s\" /> ";
private static final String CSS_FORMAT_2 = "<link rel=\"alternate stylesheet\" type=\"text/css\" href=\"%s\" title=\"%s\" />";
private static final String CSS_FORMAT_3 = "[<a href=\"javascript:set_stylesheet('%s')\">%s</a>] ";
private static final String HTML_HEADER_1 =
"<!DOCTYPE html>" +
"<script type=\"text/javascript\" src=\"";
private static final String HTML_HEADER_2 =
"\"></script>" +
"<html>" +
"<head>" +
"<meta http-equiv=\"Content-Type\" content=\"text/html;charset=utf-8\" />" +
"<title>";
private static final String HTML_HEADER_3 =
"</title>" +
"<link rel=\"icon\" type=\"image/png\" href=\"";
private static final String HTML_HEADER_4 =
"\" />" +
"<style type=\"text/css\"> " +
"body { margin: 0; padding: 8px; margin-bottom: auto; } " +
"blockquote blockquote { margin-left: 0em } " +
"form { margin-bottom: 0px } " +
"form .trap { display:none } " +
".postarea { text-align: center } " +
".postarea table { margin: 0px auto; text-align: left } " +
".file { border: none; float: left; margin: 2px 20px } " +
".thumb { border: none; float: left; margin: 2px 20px } " +
".nothumb { float: left; background: #eee; border: 2px dashed #aaa; text-align: center; " +
"margin: 2px 20px; padding: 1em 0.5em 1em 0.5em; } " +
".reply blockquote, blockquote :last-child { margin-bottom: 0em } " +
".reflink a { color: inherit; text-decoration: none } " +
".reply .filesize { margin-left: 20px } " +
".userdelete { float: right; text-align: center; white-space: nowrap } " +
".replypage .replylink { display: none } " +
"</style>";
private static final String HTML_HEADER_5 =
"<script type=\"text/javascript\">var style_cookie=\"wakabastyle\";</script>" +
"<script type=\"text/javascript\" src=\"";
private static final String HTML_HEADER_6 =
"\"></script>" +
"</head>" +
"<body class=\"replypage\">" +
"<div class=\"adminbar\"> ";
private static final String HTML_HEADER_7 =
"</div>" +
"<div class=\"logo\">";
private static final String HTML_HEADER_8 =
"</div>" +
"<hr />" +
"<form id=\"delform\" action=\"/wakaba/wakaba.pl\" method=\"post\">";
private static final String HTML_FOOTER =
"</form>" +
"<p class=\"footer\"> - " +
"<a href=\"http://miku-nyan.github.io/Overchan-Android/\">overchan-android</a> + " +
"<a href=\"http://wakaba.c3.cx/\">wakaba 3.0.9</a> + " +
"<a href=\"http://www.2chan.net/\">futaba</a> + " +
"<a href=\"http://www.1chan.net/futallaby/\">futallaby</a> -</p>" +
"</body>" +
"</html>";
private final Writer buf;
private final OutputStream _stream;
private final boolean writeDeleted;
private final RefsGetter refsGetter;
private Resources res;
private ChanModule chan;
private UrlPageModel pageModel;
private BoardModel boardModel;
private DateFormat dateFormat;
/**
* Конструктор класса
* @param out поток, в который будет записан HTML
* @param refsGetter интерфейс для получения ссылок на вложения и картинки
*/
public HtmlBuilder(OutputStream out, RefsGetter refsGetter) throws IOException {
this(out, true, refsGetter);
}
/**
* Конструктор класса
* @param out поток, в который будет записан HTML
* @param writeDeleted записывать удалённые посты
* @param refsGetter интерфейс для получения ссылок на вложения и картинки
*/
public HtmlBuilder(OutputStream out, boolean writeDeleted, RefsGetter refsGetter) throws IOException {
_stream = out;
buf = new BufferedWriter(new OutputStreamWriter(out, "UTF-8"));
this.writeDeleted = writeDeleted;
this.refsGetter = refsGetter;
}
public void write(SerializablePage page) throws IOException {
this.res = MainApplication.getInstance().resources;
this.chan = MainApplication.getInstance().getChanModule(page.boardModel.chan);
this.pageModel = page.pageModel;
this.boardModel = page.boardModel;
this.dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM);
this.dateFormat.setTimeZone(TimeZone.getTimeZone(boardModel.timeZoneId));
String logo = (page.boardModel.boardDescription != null ? page.boardModel.boardDescription : page.boardModel.boardName);
try {
String url = chan.buildUrl(pageModel);
logo += " <a href=\"" + url + "\">(" + chan.getChanName() + ")</a>";
} catch (Exception e) { /* ignore */ }
buildHeader(buildTitle(page), logo);
if (page.posts != null && page.posts.length != 0) {
ThreadModel thread = new ThreadModel();
thread.posts = page.posts;
thread.threadNumber = page.posts[0].number;
thread.postsCount = -1;
thread.attachmentsCount = -1;
buildThread(thread);
}
if (page.threads != null) {
for (ThreadModel thread : page.threads) buildThread(thread);
}
buf.write(HTML_FOOTER);
buf.flush();
}
public static String buildTitle(SerializablePage page) {
String title;
if (page.posts != null && page.posts.length != 0) {
title = "/" + page.boardModel.boardName + " - ";
title += page.posts[0].subject != null && page.posts[0].subject.length() != 0 ?
page.posts[0].subject : Html.fromHtml(page.posts[0].comment).toString().replace('\n', ' ');
if (title.length() > 255) title = title.substring(0, 256);
} else {
title = page.boardModel.boardDescription != null ? page.boardModel.boardDescription : page.boardModel.boardName;
}
return title;
}
@Override
public void close() throws IOException {
try {
buf.close();
} catch (IOException e) {
_stream.close();
throw e;
}
}
private void buildHeader(String pageTitle, String logoTitle) throws IOException {
buf.write(HTML_HEADER_1);
buf.write(DATA_DIR + "/" + DOLLSCRIPT);
buf.write(HTML_HEADER_2);
buf.write(pageTitle);
buf.write(HTML_HEADER_3);
buf.write(refsGetter.getFavicon());
buf.write(HTML_HEADER_4);
buf.write(String.format(Locale.US, CSS_FORMAT_1, (DATA_DIR + "/" + CSS_LINKS[0]), CSS[0]));
for (int i=1; i<CSS.length; ++i) buf.write(String.format(Locale.US, CSS_FORMAT_2, (DATA_DIR + "/" + CSS_LINKS[i]), CSS[i]));
buf.write(HTML_HEADER_5);
buf.write(DATA_DIR + "/" + WAKABA3JS);
buf.write(HTML_HEADER_6);
for (int i=0; i<CSS.length; ++i) buf.write(String.format(Locale.US, CSS_FORMAT_3, CSS[i], CSS[i]));
buf.write(HTML_HEADER_7);
buf.write(logoTitle);
buf.write(HTML_HEADER_8);
}
private void buildThread(ThreadModel thread) throws IOException {
PostModel[] posts = thread.posts;
if (posts == null || posts.length == 0) return;
buildPost(posts[0], true);
for (int i=1; i<posts.length; ++i) buildPost(posts[i], false);
closeThread();
}
private void closeThread() throws IOException {
buf.write("<br clear=\"left\" /><hr /> ");
}
private void buildPost(PostModel model, boolean isOpPost) throws IOException {
if (!isOpPost && !writeDeleted && model.deleted) return;
if (!isOpPost) {
buf.write("<table><tbody><tr><td class=\"doubledash\">>></td> <td class=\"reply\" id=\"reply");
buf.write(model.number);
buf.write("\"> ");
}
buf.write("<a name=\"");
buf.write(model.number);
buf.write("\"></a> <label><input type=\"checkbox\" name=\"delete\" value=\"");
buf.write(model.number);
buf.write("\" /> <span class=\"");
buf.write(isOpPost ? "filetitle" : "replytitle");
buf.write("\">");
if (model.subject != null) buf.write(StringEscapeUtils.escapeHtml4(model.subject));
buf.write("</span> <span class=\"");
if (!isOpPost) buf.write("comment");
buf.write("postername\">");
if (model.color != Color.TRANSPARENT) {
buf.write("<font color=\"");
buf.write(String.format("#%06X", (0xFFFFFF & model.color)));
buf.write("\">■</font>");
}
String name = StringEscapeUtils.escapeHtml4(model.name == null ? model.email : model.name);
if (name != null) {
if (model.email != null && model.email.length() != 0) {
buf.write("<a href=\"");
if (!model.email.contains(":")) buf.write("mailto:");
buf.write(model.email);
buf.write("\">");
buf.write(name);
buf.write("</a>");
} else buf.write(name);
}
buf.write("</span> ");
if (model.icons != null) {
boolean firstIcon = true;
for (BadgeIconModel icon : model.icons) {
if (!firstIcon) buf.write(" ");
firstIcon = false;
buf.write("<img hspace=\"3\" src=\"");
buf.write(refsGetter.getIcon(icon));
buf.write("\" title=\"");
buf.write((icon.description != null && icon.description.length() != 0) ?
icon.description :
(icon.source == null ? "" : icon.source.substring(icon.source.lastIndexOf('/') + 1)));
buf.write("\" border=\"0\" />");
}
buf.write(' ');
}
if (model.trip != null && model.trip.length() != 0) {
buf.write("<span class=\"postertrip\">");
buf.write(StringEscapeUtils.escapeHtml4(model.trip));
buf.write("</span> ");
}
if (model.op) buf.write("<span class=\"opmark\"># OP</span> ");
buf.write(StringEscapeUtils.escapeHtml4(dateFormat.format(model.timestamp)));
buf.write("</label> <span class=\"reflink\"> <a href=\"javascript:insert('>>");
buf.write(model.number);
buf.write("')\">No.");
buf.write(model.number);
buf.write("</a> </span>");
if (model.deleted) buf.write("<span class=\"de-post-deleted\"></span>");
buf.write(" ");
if (model.attachments != null && model.attachments.length != 0) {
buf.write("<br />");
boolean single = model.attachments.length == 1;
for (AttachmentModel attachment : model.attachments) buildAttachment(attachment, single);
if (!single) buf.write("<br clear=\"left\" />");
}
buf.write("<blockquote>");
buf.write(fixComment(model.comment));
buf.write("</blockquote>");
if (!isOpPost) {
buf.write("</td></tr></tbody></table>");
}
}
private String fixComment(String comment) {
comment = comment.replaceAll("(?i)<aibquote>", "<span class=\"unkfunc\">").replaceAll("(?i)</aibquote>", "</span>").
replaceAll("(?i)<aibspoiler>", "<span class=\"spoiler\">").replaceAll("(?i)</aibspoiler>", "</span>");
Matcher m = A_HREF_PATTERN.matcher(comment);
if (!m.find()) return comment;
StringBuffer sb = new StringBuffer();
do {
String group = m.group();
String found = m.group(1);
int oldPos = m.start(1) - m.start();
int oldLen = found.length();
String url;
if (found.startsWith("#")) {
try {
String thisThreadUrl = chan.buildUrl(pageModel);
int i = thisThreadUrl.indexOf('#');
if (i != -1) thisThreadUrl = thisThreadUrl.substring(0, i);
String postNumber = chan.parseUrl(thisThreadUrl + found).postNumber;
url = "#" + postNumber != null ? postNumber : pageModel.threadNumber;
} catch (Exception e) {
url = found;
}
} else {
url = chan.fixRelativeUrl(found);
try {
UrlPageModel linkModel = chan.parseUrl(url);
if (ChanModels.hashUrlPageModel(linkModel).equals(ChanModels.hashUrlPageModel(pageModel))) {
url = "#" + linkModel.postNumber;
}
} catch (Exception e) { /* ignore */ }
}
m.appendReplacement(sb, url.equals(found) ? group : (group.substring(0, oldPos) + url + group.substring(oldPos + oldLen)));
} while (m.find());
m.appendTail(sb);
return sb.toString();
}
private void buildAttachment(AttachmentModel model, boolean isSingle) throws IOException {
int tnWidth, tnHeight;
if (model.width > 0 && model.height > 0) {
float scale = 200f / Math.max(model.width, model.height);
if (scale > 1) scale = 1;
tnWidth = (int)(scale * model.width);
tnHeight = (int)(scale * model.height);
} else {
tnWidth = -1;
tnHeight = -1;
}
String thumbRef = refsGetter.getThumbnail(model);
String origRef = refsGetter.getOriginal(model);
String filenameDesc;
if (model.type != AttachmentModel.TYPE_OTHER_NOTFILE) {
filenameDesc = model.path != null ? model.path : model.thumbnail;
filenameDesc = filenameDesc.substring(filenameDesc.lastIndexOf('/') + 1);
try {
filenameDesc = URLDecoder.decode(filenameDesc, "UTF-8");
} catch (Exception e) {/*ignore*/}
} else {
filenameDesc = res.getString(R.string.html_external);
}
if (!isSingle) buf.write("<div class=\"file\">");
buf.write("<span class=\"filesize\">");
if (model.type != AttachmentModel.TYPE_OTHER_NOTFILE) buf.write(res.getString(R.string.html_file));
buf.write(" <a target=\"_blank\" href=\"");
buf.write(origRef);
buf.write("\">");
buf.write(filenameDesc);
buf.write("</a>");
if (model.type != AttachmentModel.TYPE_OTHER_NOTFILE) {
buf.write(isSingle ? " - " : "<br />");
buf.write("(<em>");
boolean first = true;
if (model.size != -1) {
first = false;
buf.write(String.format(Locale.US, "%d KB", model.size));
}
if (model.width > 0 && model.height > 0) {
if (!first) buf.write(", "); else first = false;
buf.write(String.format(Locale.US, "%dx%d", model.width, model.height));
}
if (model.originalName != null && model.originalName.length() > 0) {
if (!first) buf.write(", "); else first = false;
buf.write(model.originalName);
}
buf.write("</em>)");
}
buf.write("</span> ");
if (thumbRef != null) {
if (isSingle) {
buf.write(" <span class=\"thumbnailmsg\">");
buf.write(res.getString(R.string.html_thumbnailmsg));
buf.write("</span>");
}
buf.write("<br /><a target=\"_blank\" href=\"");
buf.write(origRef);
buf.write("\"> <img src=\"");
buf.write(thumbRef);
if (tnWidth == -1) {
buf.write("\" onload=\"with (this) {if (offsetHeight > offsetWidth) style.height = '200px'; else style.width = '200px'}\"");
} else {
buf.write(String.format(Locale.US, "\" width=\"%d\" height=\"%d\"", tnWidth, tnHeight));
}
buf.write(String.format(Locale.US, " alt=\"%s\" ", filenameDesc));
if (isSingle) buf.write("class=\"thumb\" ");
buf.write("/></a>");
}
if (!isSingle) buf.write("</div>");
}
public static interface RefsGetter {
/** Получить местонахождение значка favicon (локальный файл) */
String getFavicon();
/** Получить местонахождение оригинала вложения (может быть удалённым как локальным файлом, так и удалённым URL) */
String getOriginal(AttachmentModel attachment);
/** Получить метонахождение картинки превью вложения (локльный файл или null) */
String getThumbnail(AttachmentModel attachment);
/** Получить местонахождение картинки со значком (локальный файл, не может быть null) */
String getIcon(BadgeIconModel icon);
}
}