#include "archive_parser.h" #include "src/list_item.h" #include "src/types.h" #include #include #include #include #include #include #include Archive::Archive(QString outbox_filename, ArchiveType archive_type) : outbox_filename(outbox_filename), archive_type(archive_type) {} Archive::InitError Archive::init() { QFile outbox_file(outbox_filename); if (!outbox_file.open(QIODevice::ReadOnly | QIODevice::Text)) return FailedOpeningFile; QJsonParseError json_error; QJsonDocument outbox_json_document = QJsonDocument::fromJson(outbox_file.readAll(), &json_error); outbox_file.close(); if (json_error.error != QJsonParseError::NoError) return JsonParseError; if (outbox_json_document.isEmpty()) return JsonEmpty; if (outbox_json_document.isNull()) return JsonNull; if (outbox_json_document.isObject()) outbox_json = new QJsonObject (outbox_json_document.object()); else return JsonNotObject; // Do some more throughful checks to make sure that the JSON is actually valid and is a Mastodon data export (the only type supported currently) if (not (outbox_json->contains("@context") and outbox_json->value("@context").isString() and (outbox_json->value("@context").toString() == "https://www.w3.org/ns/activitystreams"))) return JsonNotActivityStream; if (outbox_json->contains("orderedItems") and outbox_json->value("orderedItems").isArray()) { outbox_items = new QJsonArray(outbox_json->value("orderedItems").toArray()); // we'll need it during Archive's lifetime } archive_root_dir = QFileInfo(outbox_filename).absoluteDir(); return NoError; } Archive::~Archive() { delete outbox_json; outbox_json = nullptr; delete outbox_items; outbox_items = nullptr; } bool is_status_type_allowed(StatusType status_type, ViewStatusTypes allowed_types) { switch (status_type) { case PUBLIC: return allowed_types.includePublic; case UNLISTED: return allowed_types.includeUnlisted; case PRIVATE: return allowed_types.includePrivate; case DIRECT: return allowed_types.includeDirect; case REBLOG: return allowed_types.includeReblogs; default: return true; } } StatusType get_status_type(QJsonObject obj) { /* * public: * to: #Public * unlisted: * to: /followers * cc: #Public * followers: * to: /followers * direct: * to: the Actors mentioned */ StatusType status_type = UNKNOWN; bool is_private_or_unlisted = false; QJsonArray to; if (obj.value("to").isArray()) { to = obj.value("to").toArray(); // see https://www.w3.org/TR/activitypub/#public-addressing for (auto collection : to) { QString col = collection.toString(); if (col == "https://www.w3.org/ns/activitystreams#Public") { return PUBLIC; // status' privacy can only be promoted to a higher level } else if (col.endsWith("/followers")) // at least for Mastodon… is_private_or_unlisted = true; // private or unlisted } } if (obj.value("cc").isArray()) { QJsonArray cc = obj.value("cc").toArray(); for (auto collection : cc) { QString col = collection.toString(); if (col == "https://www.w3.org/ns/activitystreams#Public" and is_private_or_unlisted) { return UNLISTED; // status' privacy can only be promoted to a higher level } } if (is_private_or_unlisted) return PRIVATE; else if (to.size() > 0) return DIRECT; else if (to.size() == 0 and cc.size() == 0) // sending a direct message to no one or to another actor that doesn't exist anymore return DIRECT; } return status_type; } void Archive::update_status_list(ViewStatusTypes allowed_types, QListWidget *parent) { int i = 0; for (auto&& item : *outbox_items) { if (item.isObject()){ QJsonObject obj = item.toObject(); if (not obj.value("type").isString()) // this shouldn't happen, but you never know goto next_item; QString activity_type = obj.value("type").toString(); if (not (activity_type == "Create" or activity_type == "Announce")) // ignoring everything that isn't a Create or Announce activity for now goto next_item; // Determine the status' type StatusType status_type; if (activity_type == "Announce") status_type = REBLOG; else status_type = get_status_type(obj); if (not is_status_type_allowed(status_type, allowed_types)) goto next_item; if (activity_type == "Create" and obj.value("object").isObject()) { QJsonObject activity = obj.value("object").toObject(); bool has_attachment = false; if (activity.contains("attachment") and not activity["attachment"].toArray().isEmpty()) has_attachment = true; if (allowed_types.onlyWithAttachment and not has_attachment) goto next_item; if (activity.value("content").isString()) { // Strip HTML for display in list, according to https://stackoverflow.com/a/12157835 QTextDocument strip_html; strip_html.setHtml(activity.value("content").toString()); StatusListItem *item = new StatusListItem(strip_html.toPlainText(), status_type, has_attachment, this, parent, i); } } else if (activity_type == "Announce" and obj["object"].isString()) { StatusListItem *item = new StatusListItem(activity_type, REBLOG, false, this, parent, i); } } next_item: ++i; } } QString get_html_template(const QString& template_name) { static QHash* templates = new QHash; if (not templates->contains(template_name)) { QFile file("src/templates/"+ template_name +".html"); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { templates->insert(template_name, "Error loading status_info template."); return templates->value(template_name); } qDebug() << "Loaded HTML template:" << template_name; templates->insert(template_name, file.readAll()); } return templates->value(template_name); } QString get_html_status_object_as_list(QJsonObject obj, QString element_name) { QString text; if (obj.contains(element_name)) { QJsonArray to = obj.value(element_name).toArray(); for (auto collection : to) text.append(collection.toString() + ", "); } if (text.isEmpty()) text = "(empty)"; else text.chop(2); // remove leftover ", " return text; } QString get_html_status_object_languages(QJsonObject obj, QString element_name = "contentMap") { QString text; if (obj.contains(element_name) and obj.value(element_name).isObject()) { QJsonObject content_map = obj.value(element_name).toObject(); QStringList languages = content_map.keys(); for (auto lang : languages) text.append(lang + ", "); } if (text.isEmpty()) text = "(unknown)"; else text.chop(2); // remove leftover ", " return text; } QString Archive::get_html_status_attachments(QJsonValueRef attachments_ref, int text_zone_width) { QString text; QJsonArray attachments = attachments_ref.toArray(); int i = 1; for (auto attachment_ref : attachments) { QString att_html(get_html_template("attachment")); QJsonObject attachment = attachment_ref.toObject(); att_html.replace("{{id}}", QString::number(i)); if (attachment.contains("url")) { QString url = attachment["url"].toString(); // Initialize attachment_dir if it hasn't been done if (attachment_dir_have_to_find) find_attachment_dir(url); // Also add a "/", after the attachment dir path, to make sure that the url starts with one as sometimes the ones in the json do not. We could make this prettier (but a bit slower) by only adding it conditionally. This might be worth it as the path can be copied and opened in the browser. url.prepend(attachment_dir.absolutePath() + "/"); att_html.replace("{{path}}", url); att_html.replace("{{filename}}", QFileInfo(url).fileName()); // FIXME: this is ugly } if (attachment.contains("mediaType") and attachment["mediaType"].toString().startsWith("image/")) // dynamically resize image based on the display widget size to avoid horizontal scrolling att_html.replace("{{img-width}}", QString::number((float)text_zone_width - (float)text_zone_width*0.15)); else att_html.replace("{{img-width}}", "0"); if (attachment.contains("name")) att_html.replace("{{alt-text}}", attachment["name"].toString()); text.append(att_html); ++i; } return text; } // status_index is assumed to be a valid index QString Archive::get_html_status_info(int status_index, int text_zone_width, StatusType status_type, QLocale* locale) { QString info_text(get_html_template("status_info")); QJsonObject obj = outbox_items->at(status_index).toObject(); if (obj.contains("type") and obj["type"].isString()) info_text.replace("{{type}}", obj["type"].toString()); if (obj.contains("published") and obj["published"].isString()) { // TODO: add a UI setting for configuring the display of local time or UTC time. QDateTime date = QDateTime::fromString(obj["published"].toString(), Qt::ISODate).toLocalTime(); // Using QLocale::toString() is forward compatible with Qt 6 as QDateTime::toString() will not return anymore a string in the system locale. info_text.replace("{{published}}", locale->toString(date)); } if (obj.contains("id") and obj["id"].isString()) info_text.replace("{{url-id}}", obj["id"].toString()); info_text.replace("{{to}}", get_html_status_object_as_list(obj, "to")); info_text.replace("{{cc}}", get_html_status_object_as_list(obj, "cc")); if (obj["object"].isObject()) { QJsonObject activity = obj["object"].toObject(); if (activity.contains("summary")) { QString summary_text = activity["summary"].toString(); if (summary_text.isEmpty()) summary_text = "(empty)"; info_text.replace("{{summary}}", summary_text); } info_text.replace("{{attachment-div}}", get_html_status_attachments(activity["attachment"], text_zone_width)); if (activity.contains("url") and activity["url"].isString()) info_text.replace("{{url-status}}", activity["url"].toString()); if (activity["content"].isString()) { info_text.replace("{{content}}", activity["content"].toString()); info_text.replace("{{lang}}", get_html_status_object_languages(activity)); } } else if (obj["object"].isString()) { QString text = obj["object"].toString(); QString content_text; if (status_type == REBLOG) { content_text = "Reblog of this object: {{url}}"; content_text.replace("{{url}}", text); info_text.replace("{{url-status}}", text); } else content_text = text; info_text.replace("{{content}}", content_text); info_text.replace("{{summary}}", "(empty)"); info_text.replace("{{lang}}", "(not applicable)"); info_text.replace("{{attachment-div}}", ""); } return info_text; } QString Archive::get_html_status_text(int status_index) { QString text(""); QJsonObject obj = outbox_items->at(status_index).toObject(); if (obj["object"].isObject()) { QJsonObject activity = obj["object"].toObject(); if (activity.contains("summary")) { QString summary_text = activity["summary"].toString(); if (not summary_text.isEmpty()) text.append(QString("

CW: {{summary}}

").replace("{{summary}}", summary_text)); } if (activity["content"].isString()) { text.append(QString("
{{content}}
").replace("{{content}}", activity["content"].toString())); } } return text; } void Archive::find_attachment_dir(QString example_attachment) { // Find the root directory name of the attachment QString root_name = example_attachment.split('/', Qt::SkipEmptyParts)[0]; // Iterate over each subdirectory of the archive to find the directory containing the attachments archive_root_dir.setFilter(QDir::Dirs|QDir::NoDotAndDotDot); QDirIterator it(archive_root_dir, QDirIterator::Subdirectories); while (it.hasNext()) { QString current_dir = it.next(); if (current_dir.section('/', -1) == root_name) { // We have found the directory // Remove the root_name component (that should be at the end) as it will be added back due to being part of the attachment url attachment_dir.setPath(current_dir.remove(root_name)); attachment_dir_have_to_find = false; if (not attachment_dir.exists()) // This shouldn't happen, but log it in case qDebug() << "Attachment dir does not exist!" << attachment_dir.canonicalPath(); return; } } // If the attachment directory wasn't found, it will be searched for next attachment url parsing as attachment_dir_have_to_find wasn't touched (and is still true) }