changeset 2:23d0d9319a00

Update Also converted everything to LF from CRLF
author Paper <mrpapersonic@gmail.com>
date Sat, 12 Aug 2023 03:16:26 -0400
parents 1ae666fdf9e2
children 190ded9438c0
files CMakeLists.txt rc/icons.qrc rc/icons/README.md src/anilist.cpp src/anime.cpp src/config.cpp src/date.cpp src/dialog/information.cpp src/filesystem.cpp src/include/anilist.h src/include/anime.h src/include/config.h src/include/date.h src/include/filesystem.h src/include/information.h src/include/json.h src/include/now_playing.h src/include/statistics.h src/include/string_utils.h src/include/sys/osx/dark_theme.h src/include/sys/win32/dark_theme.h src/include/time_utils.h src/include/ui_utils.h src/include/window.h src/json.cpp src/main.cpp src/pages/now_playing.cpp src/pages/statistics.cpp src/string_utils.cpp src/sys/osx/dark_theme.mm src/sys/win32/dark_theme.cpp src/time.cpp src/ui_utils.cpp src/window.cpp
diffstat 34 files changed, 2318 insertions(+), 1997 deletions(-) [+]
line wrap: on
line diff
--- a/CMakeLists.txt	Tue Aug 08 19:49:15 2023 -0400
+++ b/CMakeLists.txt	Sat Aug 12 03:16:26 2023 -0400
@@ -1,33 +1,36 @@
-cmake_minimum_required(VERSION 3.5)
-project(weeaboo)
-
-set(SRC_FILES
-	src/main.cpp
-	src/config.cpp
-	src/filesystem.cpp
-	src/anilist.cpp
-	src/anime.cpp
-#	src/pages/statistics.cpp
-#	src/pages/now_playing.cpp
-#	src/dialog/information.cpp
-#	src/ui_utils.cpp
-	src/string_utils.cpp
-	rc/icons.qrc
-	dep/darkstyle/darkstyle.qrc
-)
-
-if(APPLE)
-	list(APPEND SRC_FILES src/sys/osx/dark_theme.mm)
-elseif(WIN32)
-	list(APPEND SRC_FILES src/sys/win32/dark_theme.cpp)
-endif()
-
-add_executable(weeaboo WIN32 MACOSX_BUNDLE ${SRC_FILES})
-set_property(TARGET weeaboo PROPERTY CXX_STANDARD 20)
-set_property(TARGET weeaboo PROPERTY AUTOMOC ON)
-set_property(TARGET weeaboo PROPERTY AUTORCC ON)
-
-find_package(Qt5 COMPONENTS Widgets REQUIRED)
-find_package(CURL REQUIRED)
-target_include_directories(weeaboo PUBLIC ${Qt5Widgets_INCLUDE_DIRS} ${CURL_INCLUDE_DIRS} PRIVATE src/include src/icons dep/json)
-target_link_libraries(weeaboo ${Qt5Widgets_LIBRARIES} ${CURL_LIBRARIES})
+cmake_minimum_required(VERSION 3.5)
+project(weeaboo)
+
+set(SRC_FILES
+	src/main.cpp
+	src/config.cpp
+	src/filesystem.cpp
+	src/anilist.cpp
+	src/anime.cpp
+	src/json.cpp
+	src/date.cpp
+	src/time.cpp
+	src/dialog/information.cpp
+	src/ui_utils.cpp
+	src/string_utils.cpp
+	rc/icons.qrc
+	dep/darkstyle/darkstyle.qrc
+#	src/pages/statistics.cpp
+#	src/pages/now_playing.cpp
+)
+
+if(APPLE)
+	list(APPEND SRC_FILES src/sys/osx/dark_theme.mm)
+elseif(WIN32)
+	list(APPEND SRC_FILES src/sys/win32/dark_theme.cpp)
+endif()
+
+add_executable(weeaboo WIN32 MACOSX_BUNDLE ${SRC_FILES})
+set_property(TARGET weeaboo PROPERTY CXX_STANDARD 20)
+set_property(TARGET weeaboo PROPERTY AUTOMOC ON)
+set_property(TARGET weeaboo PROPERTY AUTORCC ON)
+
+find_package(Qt5 COMPONENTS Widgets REQUIRED)
+find_package(CURL REQUIRED)
+target_include_directories(weeaboo PUBLIC ${Qt5Widgets_INCLUDE_DIRS} ${CURL_INCLUDE_DIRS} PRIVATE src/include src/icons)
+target_link_libraries(weeaboo ${Qt5Widgets_LIBRARIES} ${CURL_LIBRARIES})
--- a/rc/icons.qrc	Tue Aug 08 19:49:15 2023 -0400
+++ b/rc/icons.qrc	Sat Aug 12 03:16:26 2023 -0400
@@ -1,14 +1,14 @@
-<!DOCTYPE rcc><RCC version="1.0">
-	<qresource>
-		<file>icons/16x16/calendar.png</file>
-		<file>icons/16x16/chart.png</file>
-		<file>icons/16x16/clock-history-frame.png</file>
-		<file>icons/16x16/document-list.png</file>
-		<file>icons/16x16/feed.png</file>
-		<file>icons/16x16/film.png</file>
-		<file>icons/16x16/magnifier.png</file>
-		<file>icons/24x24/arrow-circle-double-135.png</file>
-		<file>icons/24x24/folder-open.png</file>
-		<file>icons/24x24/gear.png</file>
-	</qresource>
+<!DOCTYPE rcc><RCC version="1.0">
+	<qresource>
+		<file>icons/16x16/calendar.png</file>
+		<file>icons/16x16/chart.png</file>
+		<file>icons/16x16/clock-history-frame.png</file>
+		<file>icons/16x16/document-list.png</file>
+		<file>icons/16x16/feed.png</file>
+		<file>icons/16x16/film.png</file>
+		<file>icons/16x16/magnifier.png</file>
+		<file>icons/24x24/arrow-circle-double-135.png</file>
+		<file>icons/24x24/folder-open.png</file>
+		<file>icons/24x24/gear.png</file>
+	</qresource>
 </RCC>
\ No newline at end of file
--- a/rc/icons/README.md	Tue Aug 08 19:49:15 2023 -0400
+++ b/rc/icons/README.md	Sat Aug 12 03:16:26 2023 -0400
@@ -1,2 +1,2 @@
-# Icons
+# Icons
 These icons are from Yusuke Kamiyamane's [[Fugue Icons]](https://p.yusukekamiyamane.com/) pack. The original files have been left intact alongside bin2h variants (for easy inclusion in source code).
\ No newline at end of file
--- a/src/anilist.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/anilist.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,235 +1,234 @@
-#include "window.h"
-#include "json.h"
-#include <curl/curl.h>
-#include <chrono>
-#include <exception>
-#include <format>
-#include "anilist.h"
-#include "anime.h"
-#include "config.h"
-#include "string_utils.h"
-#define CLIENT_ID "13706"
-
-size_t AniList::CurlWriteCallback(void *contents, size_t size, size_t nmemb, void *userdata) {
-    ((std::string*)userdata)->append((char*)contents, size * nmemb);
-    return size * nmemb;
-}
-
-std::string AniList::SendRequest(std::string data) {
-	struct curl_slist *list = NULL;
-	std::string userdata;
-	curl = curl_easy_init();
-	if (curl) {
-		list = curl_slist_append(list, "Accept: application/json");
-		list = curl_slist_append(list, "Content-Type: application/json");
-		std::string bearer = "Authorization: Bearer " + session.config.anilist.auth_token;
-		list = curl_slist_append(list, bearer.c_str());
-		curl_easy_setopt(curl, CURLOPT_URL, "https://graphql.anilist.co");
-		curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data.c_str());
-		curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
-		curl_easy_setopt(curl, CURLOPT_WRITEDATA, &userdata);
-		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &CurlWriteCallback);
-		/* FIXME: This sucks. When using HTTPS, we should ALWAYS make sure that our peer
-		   is actually valid. I assume the best way to go about this would be to bundle a
-		   certificate file, and if it's not found we should *prompt the user* and ask them
-		   if it's okay to contact AniList WITHOUT verification. If so, we're golden, and this
-		   flag will be set. If not, we should abort mission.
-
-		   For this program, it's probably fine to just contact AniList without
-		   HTTPS verification. However it should still be in the list of things to do... */
-		curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, FALSE);
-		res = curl_easy_perform(curl);
-		curl_slist_free_all(list);
-		if (res != CURLE_OK) {
-			QMessageBox box(QMessageBox::Icon::Critical, "", QString("curl_easy_perform(curl) failed!: ") + QString(curl_easy_strerror(res)));
-			box.exec();
-			curl_easy_cleanup(curl);
-			return "";
-		}
-		curl_easy_cleanup(curl);
-		return userdata;
-	}
-	return "";
-}
-
-int AniList::GetUserId(std::string name) {
-#define QUERY "query ($name: String) {\n" \
-			  "  User (name: $name) {\n" \
-			  "    id\n" \
-			  "  }\n" \
-			  "}\n"
-	nlohmann::json json = {
-		{"query", QUERY},
-		{"variables", {
-			{"name", name}
-		}}
-	};
-	auto ret = nlohmann::json::parse(SendRequest(json.dump()));
-	return ret["data"]["User"]["id"].get<int>();
-#undef QUERY
-}
-
-/* Maps to convert string forms to our internal enums */
-
-std::map<std::string, enum AnimeWatchingStatus> StringToAnimeWatchingMap = {
-	{"CURRENT",   CURRENT},
-	{"PLANNING",  PLANNING},
-	{"COMPLETED", COMPLETED},
-	{"DROPPED",   DROPPED},
-	{"PAUSED",    PAUSED},
-	{"REPEATING", REPEATING}
-};
-
-std::map<std::string, enum AnimeAiringStatus> StringToAnimeAiringMap = {
-	{"FINISHED",         FINISHED},
-	{"RELEASING",        RELEASING},
-	{"NOT_YET_RELEASED", NOT_YET_RELEASED},
-	{"CANCELLED",        CANCELLED},
-	{"HIATUS",           HIATUS}
-};
-
-std::map<std::string, enum AnimeSeason> StringToAnimeSeasonMap = {
-	{"WINTER", WINTER},
-	{"SPRING", SPRING},
-	{"SUMMER", SUMMER},
-	{"FALL",   FALL}
-};
-
-std::map<std::string, enum AnimeFormat> StringToAnimeFormatMap = {
-	{"TV",       TV},
-	{"TV_SHORT", TV_SHORT},
-	{"MOVIE",    MOVIE},
-	{"SPECIAL",  SPECIAL},
-	{"OVA",      OVA},
-	{"ONA",      ONA},
-	{"MUSIC",    MUSIC},
-	{"MANGA",    MANGA},
-	{"NOVEL",    NOVEL},
-	{"ONE_SHOT", ONE_SHOT}
-};
-
-int AniList::UpdateAnimeList(std::vector<AnimeList>* anime_lists, int id) {
-#define QUERY "query ($id: Int) {\n" \
-"  MediaListCollection (userId: $id, type: ANIME) {\n" \
-"    lists {\n" \
-"      name\n" \
-"      entries {\n" \
-"        score\n" \
-"        notes\n" \
-"        progress\n" \
-"        startedAt {\n" \
-"          year\n" \
-"          month\n" \
-"          day\n" \
-"        }\n" \
-"        completedAt {\n" \
-"          year\n" \
-"          month\n" \
-"          day\n" \
-"        }\n" \
-"        media {\n" \
-"          id\n" \
-"          title {\n" \
-"            userPreferred\n" \
-"          }\n" \
-"          format\n" \
-"          status\n" \
-"          averageScore\n" \
-"          season\n" \
-"          startDate {\n" \
-"            year\n" \
-"            month\n" \
-"            day\n" \
-"          }\n" \
-"          genres\n" \
-"          episodes\n" \
-"          duration\n" \
-"          synonyms\n" \
-"          description(asHtml: false)\n" \
-"        }\n" \
-"      }\n" \
-"    }\n" \
-"  }\n" \
-"}\n"
-	nlohmann::json json = {
-		{"query", QUERY},
-		{"variables", {
-			{"id", id}
-		}}
-	};
-	/* TODO: do a try catch here, catch any json errors and then call
-       Authorize() if needed */
-	auto res = nlohmann::json::parse(SendRequest(json.dump()));
-	/* TODO: make sure that we actually need the wstring converter and see
-	   if we can just get wide strings back from nlohmann::json */
-	for (const auto& list : res["data"]["MediaListCollection"]["lists"].items()) {
-		/* why are the .key() values strings?? */
-		int list_key = std::stoi(list.key());
-		AnimeList anime_list;
-		anime_list.name = StringUtils::Utf8ToWstr(list.value()["name"].get<std::string>());
-		for (const auto& entry : list.value()["entries"].items()) {
-			int entry_key = std::stoi(entry.key());
-			Anime anime;
-			anime.score = entry.value()["score"].get<int>();
-			anime.progress = entry.value()["progress"].get<int>();
-			if (entry.value()["status"].is_string())
-				anime.status = StringToAnimeWatchingMap[entry.value()["status"].get<std::string>()];
-			if (entry.value()["notes"].is_string())
-				anime.notes = StringUtils::Utf8ToWstr(entry.value()["notes"].get<std::string>());
-
-			if (ANILIST_DATE_IS_VALID(entry.value()["startedAt"]))
-				anime.started = ANILIST_DATE_TO_YMD(entry.value()["startedAt"]);
-			if (ANILIST_DATE_IS_VALID(entry.value()["completedAt"]))
-				anime.completed = ANILIST_DATE_TO_YMD(entry.value()["completedAt"]);
-
-			anime.title = StringUtils::Utf8ToWstr(entry.value()["media"]["title"]["userPreferred"].get<std::string>());
-			anime.id = entry.value()["media"]["id"].get<int>();
-			if (!entry.value()["media"]["episodes"].is_null())
-				anime.episodes = entry.value()["media"]["episodes"].get<int>();
-			else // hasn't aired yet
-				anime.episodes = 0;
-
-			if (!entry.value()["media"]["format"].is_null())
-				anime.type = StringToAnimeFormatMap[entry.value()["media"]["format"].get<std::string>()];
-
-			anime.airing = StringToAnimeAiringMap[entry.value()["media"]["status"].get<std::string>()];
-
-			if (ANILIST_DATE_IS_VALID(entry.value()["media"]["startDate"]))
-				anime.air_date = ANILIST_DATE_TO_YMD(entry.value()["media"]["startDate"]);
-
-			if (entry.value()["media"]["averageScore"].is_number())
-				anime.audience_score = entry.value()["media"]["averageScore"].get<int>();
-
-			if (entry.value()["media"]["season"].is_string())
-				anime.season = StringToAnimeSeasonMap[entry.value()["media"]["season"].get<std::string>()];
-
-			if (entry.value()["media"]["duration"].is_number())
-				anime.duration = entry.value()["media"]["duration"].get<int>();
-			else
-				anime.duration = 0;
-
-			if (entry.value()["media"]["genres"].is_array())
-				anime.genres = entry.value()["media"]["genres"].get<std::vector<std::string>>();
-			if (entry.value()["media"]["description"].is_string())
-				anime.synopsis = StringUtils::TextifySynopsis(StringUtils::Utf8ToWstr(entry.value()["media"]["description"].get<std::string>()));
-			anime_list.Add(anime);
-		}
-		anime_lists->push_back(anime_list);
-	}
-	return 1;
-}
-
-int AniList::Authorize() {
-	if (session.config.anilist.auth_token.empty()) {
-		/* Prompt for PIN */
-		QDesktopServices::openUrl(QUrl("https://anilist.co/api/v2/oauth/authorize?client_id=" CLIENT_ID "&response_type=token"));
-		bool ok;
-		QString token = QInputDialog::getText(0, "Credentials needed!", "Please enter the code given to you after logging in to AniList:", QLineEdit::Normal, "", &ok);
-		if (ok && !token.isEmpty()) {
-			session.config.anilist.auth_token = token.toStdString();
-		} else { // fail
-			return 0;
-		}
-	}
-	return 1;
-}
+#include "window.h"
+#include "json.h"
+#include <curl/curl.h>
+#include <chrono>
+#include <exception>
+#include <format>
+#include "anilist.h"
+#include "anime.h"
+#include "config.h"
+#include "string_utils.h"
+#define CLIENT_ID "13706"
+
+size_t AniList::CurlWriteCallback(void *contents, size_t size, size_t nmemb, void *userdata) {
+    ((std::string*)userdata)->append((char*)contents, size * nmemb);
+    return size * nmemb;
+}
+
+std::string AniList::SendRequest(std::string data) {
+	struct curl_slist *list = NULL;
+	std::string userdata;
+	curl = curl_easy_init();
+	if (curl) {
+		list = curl_slist_append(list, "Accept: application/json");
+		list = curl_slist_append(list, "Content-Type: application/json");
+		std::string bearer = "Authorization: Bearer " + session.config.anilist.auth_token;
+		list = curl_slist_append(list, bearer.c_str());
+		curl_easy_setopt(curl, CURLOPT_URL, "https://graphql.anilist.co");
+		curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data.c_str());
+		curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
+		curl_easy_setopt(curl, CURLOPT_WRITEDATA, &userdata);
+		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &CurlWriteCallback);
+		/* FIXME: This sucks. When using HTTPS, we should ALWAYS make sure that our peer
+		   is actually valid. I assume the best way to go about this would be to bundle a
+		   certificate file, and if it's not found we should *prompt the user* and ask them
+		   if it's okay to contact AniList WITHOUT verification. If so, we're golden, and this
+		   flag will be set. If not, we should abort mission.
+
+		   For this program, it's probably fine to just contact AniList without
+		   HTTPS verification. However it should still be in the list of things to do... */
+		curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, FALSE);
+		res = curl_easy_perform(curl);
+		curl_slist_free_all(list);
+		if (res != CURLE_OK) {
+			QMessageBox box(QMessageBox::Icon::Critical, "", QString("curl_easy_perform(curl) failed!: ") + QString(curl_easy_strerror(res)));
+			box.exec();
+			curl_easy_cleanup(curl);
+			return "";
+		}
+		curl_easy_cleanup(curl);
+		return userdata;
+	}
+	return "";
+}
+
+int AniList::GetUserId(std::string name) {
+#define QUERY "query ($name: String) {\n" \
+			  "  User (name: $name) {\n" \
+			  "    id\n" \
+			  "  }\n" \
+			  "}\n"
+	nlohmann::json json = {
+		{"query", QUERY},
+		{"variables", {
+			{"name", name}
+		}}
+	};
+	auto ret = nlohmann::json::parse(SendRequest(json.dump()));
+	return ret["data"]["User"]["id"].get<int>();
+#undef QUERY
+}
+
+/* Maps to convert string forms to our internal enums */
+
+std::map<std::string, enum AnimeWatchingStatus> StringToAnimeWatchingMap = {
+	{"CURRENT",   CURRENT},
+	{"PLANNING",  PLANNING},
+	{"COMPLETED", COMPLETED},
+	{"DROPPED",   DROPPED},
+	{"PAUSED",    PAUSED},
+	{"REPEATING", REPEATING}
+};
+
+std::map<std::string, enum AnimeAiringStatus> StringToAnimeAiringMap = {
+	{"FINISHED",         FINISHED},
+	{"RELEASING",        RELEASING},
+	{"NOT_YET_RELEASED", NOT_YET_RELEASED},
+	{"CANCELLED",        CANCELLED},
+	{"HIATUS",           HIATUS}
+};
+
+std::map<std::string, enum AnimeSeason> StringToAnimeSeasonMap = {
+	{"WINTER", WINTER},
+	{"SPRING", SPRING},
+	{"SUMMER", SUMMER},
+	{"FALL",   FALL}
+};
+
+std::map<std::string, enum AnimeFormat> StringToAnimeFormatMap = {
+	{"TV",       TV},
+	{"TV_SHORT", TV_SHORT},
+	{"MOVIE",    MOVIE},
+	{"SPECIAL",  SPECIAL},
+	{"OVA",      OVA},
+	{"ONA",      ONA},
+	{"MUSIC",    MUSIC},
+	{"MANGA",    MANGA},
+	{"NOVEL",    NOVEL},
+	{"ONE_SHOT", ONE_SHOT}
+};
+
+int AniList::UpdateAnimeList(std::vector<AnimeList>* anime_lists, int id) {
+/* NOTE: these should be in the qrc file */
+#define QUERY "query ($id: Int) {\n" \
+"  MediaListCollection (userId: $id, type: ANIME) {\n" \
+"    lists {\n" \
+"      name\n" \
+"      entries {\n" \
+"        score\n" \
+"        notes\n" \
+"        progress\n" \
+"        startedAt {\n" \
+"          year\n" \
+"          month\n" \
+"          day\n" \
+"        }\n" \
+"        completedAt {\n" \
+"          year\n" \
+"          month\n" \
+"          day\n" \
+"        }\n" \
+"        updatedAt\n" \
+"        media {\n" \
+"          id\n" \
+"          title {\n" \
+"            romaji\n" \
+"            english\n" \
+"            native\n" \
+"          }\n" \
+"          format\n" \
+"          status\n" \
+"          averageScore\n" \
+"          season\n" \
+"          startDate {\n" \
+"            year\n" \
+"            month\n" \
+"            day\n" \
+"          }\n" \
+"          genres\n" \
+"          episodes\n" \
+"          duration\n" \
+"          synonyms\n" \
+"          description(asHtml: false)\n" \
+"        }\n" \
+"      }\n" \
+"    }\n" \
+"  }\n" \
+"}\n"
+	nlohmann::json json = {
+		{"query", QUERY},
+		{"variables", {
+			{"id", id}
+		}}
+	};
+	/* TODO: do a try catch here, catch any json errors and then call
+       Authorize() if needed */
+	auto res = nlohmann::json::parse(SendRequest(json.dump()));
+	/* TODO: make sure that we actually need the wstring converter and see
+	   if we can just get wide strings back from nlohmann::json */
+	for (const auto& list : res["data"]["MediaListCollection"]["lists"].items()) {
+		/* why are the .key() values strings?? */
+		int list_key = std::stoi(list.key());
+		AnimeList anime_list;
+		anime_list.name = StringUtils::Utf8ToWstr(JSON::GetString(list.value(), "name"));
+		for (const auto& entry : list.value()["entries"].items()) {
+			int entry_key = std::stoi(entry.key());
+			Anime anime;
+			anime.score = JSON::GetInt(entry.value(), "score");
+			anime.progress = JSON::GetInt(entry.value(), "progress");
+			anime.status = StringToAnimeWatchingMap[JSON::GetString(entry.value(), "status")];
+			anime.notes = StringUtils::Utf8ToWstr(JSON::GetString(entry.value(), "notes"));
+
+			anime.started.SetYear(JSON::GetInt(entry.value()["startedAt"], "year"));
+			anime.started.SetMonth(JSON::GetInt(entry.value()["startedAt"], "month"));
+			anime.started.SetDay(JSON::GetInt(entry.value()["startedAt"], "day"));
+
+			anime.completed.SetYear(JSON::GetInt(entry.value()["completedAt"], "year"));
+			anime.completed.SetMonth(JSON::GetInt(entry.value()["completedAt"], "month"));
+			anime.completed.SetDay(JSON::GetInt(entry.value()["completedAt"], "day"));
+
+			anime.updated = JSON::GetInt(entry.value(), "updatedAt");
+
+			anime.title.native  = StringUtils::Utf8ToWstr(JSON::GetString(entry.value()["media"]["title"], "native"));
+			anime.title.english = StringUtils::Utf8ToWstr(JSON::GetString(entry.value()["media"]["title"], "english"));
+			anime.title.romaji  = StringUtils::Utf8ToWstr(JSON::GetString(entry.value()["media"]["title"], "romaji"));
+
+			anime.id = JSON::GetInt(entry.value()["media"], "id");
+			anime.episodes = JSON::GetInt(entry.value()["media"], "episodes");
+			anime.type = StringToAnimeFormatMap[JSON::GetString(entry.value()["media"], "format")];
+
+			anime.airing = StringToAnimeAiringMap[JSON::GetString(entry.value()["media"], "status")];
+
+			anime.air_date.SetYear(JSON::GetInt(entry.value()["media"]["startDate"], "year"));
+			anime.air_date.SetMonth(JSON::GetInt(entry.value()["media"]["startDate"], "month"));
+			anime.air_date.SetDay(JSON::GetInt(entry.value()["media"]["startDate"], "day"));
+
+			anime.audience_score = JSON::GetInt(entry.value()["media"], "averageScore");
+			anime.season = StringToAnimeSeasonMap[JSON::GetString(entry.value()["media"], "season")];
+			anime.duration = JSON::GetInt(entry.value()["media"], "duration");
+			anime.synopsis = StringUtils::TextifySynopsis(StringUtils::Utf8ToWstr(JSON::GetString(entry.value()["media"], "duration")));
+
+			if (entry.value()["media"]["genres"].is_array())
+				anime.genres = entry.value()["media"]["genres"].get<std::vector<std::string>>();
+			anime_list.Add(anime);
+		}
+		anime_lists->push_back(anime_list);
+	}
+	return 1;
+#undef QUERY
+}
+
+int AniList::Authorize() {
+	if (session.config.anilist.auth_token.empty()) {
+		/* Prompt for PIN */
+		QDesktopServices::openUrl(QUrl("https://anilist.co/api/v2/oauth/authorize?client_id=" CLIENT_ID "&response_type=token"));
+		bool ok;
+		QString token = QInputDialog::getText(0, "Credentials needed!", "Please enter the code given to you after logging in to AniList:", QLineEdit::Normal, "", &ok);
+		if (ok && !token.isEmpty()) {
+			session.config.anilist.auth_token = token.toStdString();
+		} else { // fail
+			return 0;
+		}
+	}
+	return 1;
+}
--- a/src/anime.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/anime.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,513 +1,539 @@
-#include <chrono>
-#include <string>
-#include <vector>
-#include <cmath>
-#include "window.h"
-#include "anilist.h"
-#include "config.h"
-#include "anime.h"
-//#include "information.h"
-
-std::map<enum AnimeWatchingStatus, std::string> AnimeWatchingToStringMap = {
-	{CURRENT,   "Watching"},
-	{PLANNING,  "Planning"},
-	{COMPLETED, "Completed"},
-	{DROPPED,   "Dropped"},
-	{PAUSED,    "On hold"},
-	{REPEATING, "Rewatching"}
-};
-
-std::map<enum AnimeAiringStatus, std::string> AnimeAiringToStringMap = {
-	{FINISHED,         "Finished"},
-	{RELEASING,        "Airing"},
-	{NOT_YET_RELEASED, "Not aired yet"},
-	{CANCELLED,        "Cancelled"},
-	{HIATUS,           "On hiatus"}
-};
-
-std::map<enum AnimeSeason, std::string> AnimeSeasonToStringMap = {
-	{WINTER, "Winter"},
-	{SPRING, "Spring"},
-	{SUMMER, "Summer"},
-	{FALL,   "Fall"}
-};
-
-std::map<enum AnimeFormat, std::string> AnimeFormatToStringMap = {
-	{TV,       "TV"},
-	{TV_SHORT, "TV short"},
-	{MOVIE,    "Movie"},
-	{SPECIAL,  "Special"},
-	{OVA,      "OVA"},
-	{ONA,      "ONA"},
-	{MUSIC,    "Music video"},
-	/* these should NEVER be in the list. naybe we should
-	   remove them? */
-	{MANGA,    "Manga"},
-	{NOVEL,    "Novel"},
-	{ONE_SHOT, "One-shot"}
-};
-
-Anime::Anime() {}
-Anime::Anime(const Anime& a) {
-	status = a.status;
-	progress = a.progress;
-	score = a.score;
-	started = a.started;
-	completed = a.completed;
-	notes = a.notes;
-	id = a.id;
-	title = a.title;
-	episodes = a.episodes;
-	airing = a.airing;
-	air_date = a.air_date;
-	genres = a.genres;
-	producers = a.producers;
-	type = a.type;
-	season = a.season;
-	audience_score = a.audience_score;
-	synopsis = a.synopsis;
-	duration = a.duration;
-}
-
-void AnimeList::Add(Anime& anime) {
-	if (anime_id_to_anime.contains(anime.id))
-		return;
-	anime_list.push_back(anime);
-	anime_id_to_anime.emplace(anime.id, &anime);
-}
-
-void AnimeList::Insert(size_t pos, Anime& anime) {
-	if (anime_id_to_anime.contains(anime.id))
-		return;
-	anime_list.insert(anime_list.begin()+pos, anime);
-	anime_id_to_anime.emplace(anime.id, &anime);
-}
-
-void AnimeList::Delete(size_t index) {
-	anime_list.erase(anime_list.begin()+index);
-}
-
-void AnimeList::Clear() {
-	anime_list.clear();
-}
-
-size_t AnimeList::Size() const {
-	return anime_list.size();
-}
-
-std::vector<Anime>::iterator AnimeList::begin() noexcept {
-	return anime_list.begin();
-}
-
-std::vector<Anime>::iterator AnimeList::end() noexcept {
-	return anime_list.end();
-}
-
-std::vector<Anime>::const_iterator AnimeList::cbegin() noexcept {
-	return anime_list.cbegin();
-}
-
-std::vector<Anime>::const_iterator AnimeList::cend() noexcept {
-	return anime_list.cend();
-}
-
-AnimeList::AnimeList() {}
-AnimeList::AnimeList(const AnimeList& l) {
-	for (int i = 0; i < l.Size(); i++) {
-		anime_list.push_back(Anime(l[i]));
-	}
-	name = l.name;
-}
-
-AnimeList::~AnimeList() {
-	anime_list.clear();
-	anime_list.shrink_to_fit();
-}
-
-Anime* AnimeList::AnimeById(int id) {
-	return anime_id_to_anime.contains(id) ? anime_id_to_anime[id] : nullptr;
-}
-
-bool AnimeList::AnimeInList(int id) {
-	return anime_id_to_anime.contains(id);
-}
-
-Anime& AnimeList::operator[](std::size_t index) {
-	return anime_list.at(index);
-}
-
-const Anime& AnimeList::operator[](std::size_t index) const {
-	return anime_list.at(index);
-}
-
-/* ------------------------------------------------------------------------- */
-
-/* Thank you qBittorrent for having a great example of a
-   widget model. */
-AnimeListWidgetModel::AnimeListWidgetModel (QWidget* parent, AnimeList* alist)
-                                          : QAbstractListModel(parent)
-										  , list(*alist) {
-	return;
-}
-
-int AnimeListWidgetModel::rowCount(const QModelIndex& parent) const {
-	return list.Size();
-}
-
-int AnimeListWidgetModel::columnCount(const QModelIndex& parent) const {
-	return NB_COLUMNS;
-}
-
-QVariant AnimeListWidgetModel::headerData(const int section, const Qt::Orientation orientation, const int role) const {
-	if (role == Qt::DisplayRole) {
-		switch (section) {
-			case AL_TITLE:
-				return tr("Anime title");
-			case AL_PROGRESS:
-				return tr("Progress");
-			case AL_TYPE:
-				return tr("Type");
-			case AL_SCORE:
-				return tr("Score");
-			case AL_SEASON:
-				return tr("Season");
-			case AL_STARTED:
-				return tr("Date started");
-			case AL_COMPLETED:
-				return tr("Date completed");
-			case AL_NOTES:
-				return tr("Notes");
-			case AL_AVG_SCORE:
-				return tr("Average score");
-			case AL_UPDATED:
-				return tr("Last updated");
-			default:
-				return {};
-		}
-	} else if (role == Qt::TextAlignmentRole) {
-		switch (section) {
-			case AL_TITLE:
-			case AL_NOTES:
-				return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
-			case AL_PROGRESS:
-			case AL_TYPE:
-			case AL_SCORE:
-			case AL_AVG_SCORE:
-				return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
-			case AL_SEASON:
-			case AL_STARTED:
-			case AL_COMPLETED:
-			case AL_UPDATED:
-				return QVariant(Qt::AlignRight | Qt::AlignVCenter);
-			default:
-				return QAbstractListModel::headerData(section, orientation, role);
-		}
-	}
-	return QAbstractListModel::headerData(section, orientation, role);
-}
-
-Anime* AnimeListWidgetModel::GetAnimeFromIndex(const QModelIndex& index) {
-	return (!index.isValid()) ? &(list[index.row()]) : nullptr;
-}
-
-QVariant AnimeListWidgetModel::data(const QModelIndex& index, int role) const {
-	if (!index.isValid())
-		return QVariant();
-	if (role == Qt::DisplayRole) {
-		switch (index.column()) {
-			case AL_TITLE:
-				return QString::fromWCharArray(list[index.row()].title.c_str());
-			case AL_PROGRESS:
-				return list[index.row()].progress;
-			case AL_SCORE:
-				return list[index.row()].score;
-			case AL_TYPE:
-				return QString::fromStdString(AnimeFormatToStringMap[list[index.row()].type]);
-			case AL_SEASON:
-				return QString::fromStdString(AnimeSeasonToStringMap[list[index.row()].season]) + " " + QString::number((int)list[index.row()].air_date.year());
-			case AL_AVG_SCORE:
-				return list[index.row()].audience_score;
-			case AL_STARTED:
-				/* why c++20 chrono is stinky: the game */
-				return QDate(int(list[index.row()].started.year()), static_cast<int>((unsigned int)list[index.row()].started.month()), static_cast<int>((unsigned int)list[index.row()].started.day()));
-			case AL_COMPLETED:
-				return QDate(int(list[index.row()].completed.year()), static_cast<int>((unsigned int)list[index.row()].completed.month()), static_cast<int>((unsigned int)list[index.row()].completed.day()));
-			case AL_NOTES:
-				return QString::fromWCharArray(list[index.row()].notes.c_str());
-			default:
-				return "";
-		}
-	} else if (role == Qt::TextAlignmentRole) {
-		switch (index.column()) {
-			case AL_TITLE:
-			case AL_NOTES:
-				return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
-			case AL_PROGRESS:
-			case AL_TYPE:
-			case AL_SCORE:
-			case AL_AVG_SCORE:
-				return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
-			case AL_SEASON:
-			case AL_STARTED:
-			case AL_COMPLETED:
-				return QVariant(Qt::AlignRight | Qt::AlignVCenter);
-			default:
-				break;
-		}
-	}
-	return QVariant();
-}
-
-/* this should ALWAYS be called if the list is edited */
-void AnimeListWidgetModel::Update() {
-
-}
-
-/* Most of this stuff is const and/or should be edited in the Information dialog
-
-bool AnimeListWidgetModel::setData(const QModelIndex &index, const QVariant &value, int role) {
-	if (!index.isValid() || role != Qt::DisplayRole)
-		return false;
-
-	Anime* const anime = &list[index.row()];
-
-	switch (index.column()) {
-		case AL_TITLE:
-			break;
-		case AL_CATEGORY:
-			break;
-		default:
-			return false;
-	}
-
-	return true;
-}
-*/
-
-int AnimeListWidget::VisibleColumnsCount() const {
-    int count = 0;
-
-    for (int i = 0, end = header()->count(); i < end; i++)
-    {
-        if (!isColumnHidden(i))
-            count++;
-    }
-
-    return count;
-}
-
-void AnimeListWidget::SetColumnDefaults() {
-	setColumnHidden(AnimeListWidgetModel::AL_SEASON, false);
-	setColumnHidden(AnimeListWidgetModel::AL_TYPE, false);
-	setColumnHidden(AnimeListWidgetModel::AL_UPDATED, false);
-	setColumnHidden(AnimeListWidgetModel::AL_PROGRESS, false);
-	setColumnHidden(AnimeListWidgetModel::AL_SCORE, false);
-	setColumnHidden(AnimeListWidgetModel::AL_TITLE, false);
-	setColumnHidden(AnimeListWidgetModel::AL_AVG_SCORE, true);
-	setColumnHidden(AnimeListWidgetModel::AL_STARTED, true);
-	setColumnHidden(AnimeListWidgetModel::AL_COMPLETED, true);
-	setColumnHidden(AnimeListWidgetModel::AL_UPDATED, true);
-	setColumnHidden(AnimeListWidgetModel::AL_NOTES, true);
-}
-
-void AnimeListWidget::DisplayColumnHeaderMenu() {
-    QMenu *menu = new QMenu(this);
-    menu->setAttribute(Qt::WA_DeleteOnClose);
-    menu->setTitle(tr("Column visibility"));
-    menu->setToolTipsVisible(true);
-
-    for (int i = 0; i < AnimeListWidgetModel::NB_COLUMNS; i++)
-    {
-		if (i == AnimeListWidgetModel::AL_TITLE)
-			continue;
-        const auto column_name = model->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
-        QAction *action = menu->addAction(column_name, this, [this, i](const bool checked) {
-            if (!checked && (VisibleColumnsCount() <= 1))
-                return;
-
-            setColumnHidden(i, !checked);
-
-            if (checked && (columnWidth(i) <= 5))
-                resizeColumnToContents(i);
-
-            // SaveSettings();
-        });
-        action->setCheckable(true);
-        action->setChecked(!isColumnHidden(i));
-    }
-
-    menu->addSeparator();
-    QAction *resetAction = menu->addAction(tr("Reset to defaults"), this, [this]()
-    {
-        for (int i = 0, count = header()->count(); i < count; ++i)
-        {
-            SetColumnDefaults();
-        }
-		// SaveSettings();
-    });
-
-    menu->popup(QCursor::pos());
-}
-
-void AnimeListWidget::DisplayListMenu() {
-	/* throw out any other garbage */
-    const QModelIndexList selected_items = selectionModel()->selectedRows();
-    if (selected_items.size() != 1 || !selected_items.first().isValid())
-        return;
-
-	const QModelIndex index = model->index(selected_items.first().row());
-	Anime* anime = model->GetAnimeFromIndex(index);
-	if (!anime)
-		return;
-
-}
-
-void AnimeListWidget::ItemDoubleClicked() {
-	/* throw out any other garbage */
-    const QModelIndexList selected_items = selectionModel()->selectedRows();
-    if (selected_items.size() != 1 || !selected_items.first().isValid())
-        return;
-
-	/* TODO: after we implement our sort model, we have to use mapToSource here... */
-	const QModelIndex index = model->index(selected_items.first().row());
-	Anime* anime = model->GetAnimeFromIndex(index);
-	if (!anime)
-		return;
-
-	/* todo: open information dialog... */
-}
-
-AnimeListWidget::AnimeListWidget(QWidget* parent, AnimeList* alist)
-                               : QTreeView(parent) {
-	model = new AnimeListWidgetModel(parent, alist);
-	this->setModel(model);
-	setUniformRowHeights(true);
-	setAllColumnsShowFocus(false);
-	setSortingEnabled(true);
-	setSelectionMode(QAbstractItemView::ExtendedSelection);
-	setItemsExpandable(false);
-	setRootIsDecorated(false);
-	setContextMenuPolicy(Qt::CustomContextMenu);
-	connect(this, &QAbstractItemView::doubleClicked, this, &ItemDoubleClicked);
-	connect(this, &QWidget::customContextMenuRequested, this, &DisplayListMenu);
-
-	/* Enter & return keys */
-    connect(new QShortcut(Qt::Key_Return, this, nullptr, nullptr, Qt::WidgetShortcut),
-	        &QShortcut::activated, this, &ItemDoubleClicked);
-
-    connect(new QShortcut(Qt::Key_Enter, this, nullptr, nullptr, Qt::WidgetShortcut),
-	        &QShortcut::activated, this, &ItemDoubleClicked);
-
-	header()->setStretchLastSection(false);
-	header()->setContextMenuPolicy(Qt::CustomContextMenu);
-	connect(header(), &QWidget::customContextMenuRequested, this, &DisplayColumnHeaderMenu);
-	// if(!session.config.anime_list.columns) {
-		SetColumnDefaults();
-	// }
-}
-
-AnimeListPage::AnimeListPage(QWidget* parent) : QTabWidget (parent) {
-	setDocumentMode(true);
-	SyncAnimeList();
-	for (AnimeList& list : anime_lists) {
-		addTab(new AnimeListWidget(this, &list), QString::fromWCharArray(list.name.c_str()));
-	}
-}
-
-void AnimeListPage::SyncAnimeList() {
-	switch (session.config.service) {
-		case ANILIST: {
-			AniList anilist = AniList();
-			anilist.Authorize();
-			session.config.anilist.user_id = anilist.GetUserId(session.config.anilist.username);
-			FreeAnimeList();
-			anilist.UpdateAnimeList(&anime_lists, session.config.anilist.user_id);
-			break;
-		}
-	}
-}
-
-void AnimeListPage::FreeAnimeList() {
-	if (anime_lists.size() > 0) {
-		/* FIXME: we may not need this, but to prevent memleaks
-		   we should keep it until we're sure we don't */
-		for (auto& list : anime_lists) {
-			list.Clear();
-		}
-		anime_lists.clear();
-	}
-}
-
-int AnimeListPage::GetTotalAnimeAmount() {
-	int total = 0;
-	for (auto& list : anime_lists) {
-		total += list.Size();
-	}
-	return total;
-}
-
-int AnimeListPage::GetTotalEpisodeAmount() {
-	/* FIXME: this also needs to take into account rewatches... */
-	int total = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			total += anime.progress;
-		}
-	}
-	return total;
-}
-
-/* Returns the total watched amount in minutes. */
-int AnimeListPage::GetTotalWatchedAmount() {
-	int total = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			total += anime.duration*anime.progress;
-		}
-	}
-	return total;
-}
-
-/* Returns the total planned amount in minutes.
-   Note that we should probably limit progress to the
-   amount of episodes, as AniList will let you
-   set episode counts up to 32768. But that should
-   rather be handled elsewhere. */
-int AnimeListPage::GetTotalPlannedAmount() {
-	int total = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			total += anime.duration*(anime.episodes-anime.progress);
-		}
-	}
-	return total;
-}
-
-double AnimeListPage::GetAverageScore() {
-	double avg = 0;
-	int amt = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			avg += anime.score;
-			if (anime.score != 0)
-				amt++;
-		}
-	}
-	return avg/amt;
-}
-
-double AnimeListPage::GetScoreDeviation() {
-	double squares_sum = 0, avg = GetAverageScore();
-	int amt = 0;
-	for (auto& list : anime_lists) {
-		for (auto& anime : list) {
-			if (anime.score != 0) {
-				squares_sum += std::pow((double)anime.score - avg, 2);
-				amt++;
-			}
-		}
-	}
-	return (amt > 0) ? std::sqrt(squares_sum / amt) : 0;
-}
-
-#include "moc_anime.cpp"
+#include <chrono>
+#include <string>
+#include <vector>
+#include <cmath>
+#include "window.h"
+#include "anilist.h"
+#include "config.h"
+#include "anime.h"
+#include "date.h"
+#include "time_utils.h"
+#include "information.h"
+#include "ui_utils.h"
+
+std::map<enum AnimeWatchingStatus, std::string> AnimeWatchingToStringMap = {
+	{CURRENT,   "Watching"},
+	{PLANNING,  "Planning"},
+	{COMPLETED, "Completed"},
+	{DROPPED,   "Dropped"},
+	{PAUSED,    "On hold"},
+	{REPEATING, "Rewatching"}
+};
+
+std::map<enum AnimeAiringStatus, std::string> AnimeAiringToStringMap = {
+	{FINISHED,         "Finished"},
+	{RELEASING,        "Airing"},
+	{NOT_YET_RELEASED, "Not aired yet"},
+	{CANCELLED,        "Cancelled"},
+	{HIATUS,           "On hiatus"}
+};
+
+std::map<enum AnimeSeason, std::string> AnimeSeasonToStringMap = {
+	{UNKNOWN, "Unknown"},
+	{WINTER,  "Winter"},
+	{SPRING,  "Spring"},
+	{SUMMER,  "Summer"},
+	{FALL,    "Fall"}
+};
+
+std::map<enum AnimeFormat, std::string> AnimeFormatToStringMap = {
+	{TV,       "TV"},
+	{TV_SHORT, "TV short"},
+	{MOVIE,    "Movie"},
+	{SPECIAL,  "Special"},
+	{OVA,      "OVA"},
+	{ONA,      "ONA"},
+	{MUSIC,    "Music video"},
+	/* these should NEVER be in the list. naybe we should
+	   remove them? */
+	{MANGA,    "Manga"},
+	{NOVEL,    "Novel"},
+	{ONE_SHOT, "One-shot"}
+};
+
+Anime::Anime() {}
+Anime::Anime(const Anime& a) {
+	status = a.status;
+	progress = a.progress;
+	score = a.score;
+	started = a.started;
+	completed = a.completed;
+	updated = a.updated;
+	notes = a.notes;
+	id = a.id;
+	title = a.title;
+	episodes = a.episodes;
+	airing = a.airing;
+	air_date = a.air_date;
+	genres = a.genres;
+	producers = a.producers;
+	type = a.type;
+	season = a.season;
+	audience_score = a.audience_score;
+	synopsis = a.synopsis;
+	duration = a.duration;
+}
+
+void AnimeList::Add(Anime& anime) {
+	if (anime_id_to_anime.contains(anime.id))
+		return;
+	anime_list.push_back(anime);
+	anime_id_to_anime.emplace(anime.id, &anime);
+}
+
+void AnimeList::Insert(size_t pos, Anime& anime) {
+	if (anime_id_to_anime.contains(anime.id))
+		return;
+	anime_list.insert(anime_list.begin()+pos, anime);
+	anime_id_to_anime.emplace(anime.id, &anime);
+}
+
+void AnimeList::Delete(size_t index) {
+	anime_list.erase(anime_list.begin()+index);
+}
+
+void AnimeList::Clear() {
+	anime_list.clear();
+}
+
+size_t AnimeList::Size() const {
+	return anime_list.size();
+}
+
+std::vector<Anime>::iterator AnimeList::begin() noexcept {
+	return anime_list.begin();
+}
+
+std::vector<Anime>::iterator AnimeList::end() noexcept {
+	return anime_list.end();
+}
+
+std::vector<Anime>::const_iterator AnimeList::cbegin() noexcept {
+	return anime_list.cbegin();
+}
+
+std::vector<Anime>::const_iterator AnimeList::cend() noexcept {
+	return anime_list.cend();
+}
+
+AnimeList::AnimeList() {}
+AnimeList::AnimeList(const AnimeList& l) {
+	for (int i = 0; i < l.Size(); i++) {
+		anime_list.push_back(Anime(l[i]));
+	}
+	name = l.name;
+}
+
+AnimeList::~AnimeList() {
+	anime_list.clear();
+	anime_list.shrink_to_fit();
+}
+
+Anime* AnimeList::AnimeById(int id) {
+	return anime_id_to_anime.contains(id) ? anime_id_to_anime[id] : nullptr;
+}
+
+bool AnimeList::AnimeInList(int id) {
+	return anime_id_to_anime.contains(id);
+}
+
+int AnimeList::GetAnimeIndex(Anime& anime) const {
+	for (int i = 0; i < Size(); i++) {
+		if (&anime_list.at(i) == &anime) { // lazy
+			return i;
+		}
+	}
+	return -1;
+}
+
+Anime& AnimeList::operator[](std::size_t index) {
+	return anime_list.at(index);
+}
+
+const Anime& AnimeList::operator[](std::size_t index) const {
+	return anime_list.at(index);
+}
+
+/* ------------------------------------------------------------------------- */
+
+/* Thank you qBittorrent for having a great example of a
+   widget model. */
+AnimeListWidgetModel::AnimeListWidgetModel (QWidget* parent, AnimeList* alist)
+                                          : QAbstractListModel(parent)
+										  , list(*alist) {
+	return;
+}
+
+int AnimeListWidgetModel::rowCount(const QModelIndex& parent) const {
+	return list.Size();
+}
+
+int AnimeListWidgetModel::columnCount(const QModelIndex& parent) const {
+	return NB_COLUMNS;
+}
+
+QVariant AnimeListWidgetModel::headerData(const int section, const Qt::Orientation orientation, const int role) const {
+	if (role == Qt::DisplayRole) {
+		switch (section) {
+			case AL_TITLE:
+				return tr("Anime title");
+			case AL_PROGRESS:
+				return tr("Progress");
+			case AL_TYPE:
+				return tr("Type");
+			case AL_SCORE:
+				return tr("Score");
+			case AL_SEASON:
+				return tr("Season");
+			case AL_STARTED:
+				return tr("Date started");
+			case AL_COMPLETED:
+				return tr("Date completed");
+			case AL_NOTES:
+				return tr("Notes");
+			case AL_AVG_SCORE:
+				return tr("Average score");
+			case AL_UPDATED:
+				return tr("Last updated");
+			default:
+				return {};
+		}
+	} else if (role == Qt::TextAlignmentRole) {
+		switch (section) {
+			case AL_TITLE:
+			case AL_NOTES:
+				return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
+			case AL_PROGRESS:
+			case AL_TYPE:
+			case AL_SCORE:
+			case AL_AVG_SCORE:
+				return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
+			case AL_SEASON:
+			case AL_STARTED:
+			case AL_COMPLETED:
+			case AL_UPDATED:
+				return QVariant(Qt::AlignRight | Qt::AlignVCenter);
+			default:
+				return QAbstractListModel::headerData(section, orientation, role);
+		}
+	}
+	return QAbstractListModel::headerData(section, orientation, role);
+}
+
+Anime* AnimeListWidgetModel::GetAnimeFromIndex(const QModelIndex& index) {
+	return (index.isValid()) ? &(list[index.row()]) : nullptr;
+}
+
+QVariant AnimeListWidgetModel::data(const QModelIndex& index, int role) const {
+	if (!index.isValid())
+		return QVariant();
+	if (role == Qt::DisplayRole) {
+		switch (index.column()) {
+			case AL_TITLE:
+				return QString::fromWCharArray(list[index.row()].title.english.c_str());
+			case AL_PROGRESS:
+				return list[index.row()].progress;
+			case AL_SCORE:
+				return list[index.row()].score;
+			case AL_TYPE:
+				return QString::fromStdString(AnimeFormatToStringMap[list[index.row()].type]);
+			case AL_SEASON:
+				return QString::fromStdString(AnimeSeasonToStringMap[list[index.row()].season]) + " " + QString::number(list[index.row()].air_date.GetYear());
+			case AL_AVG_SCORE:
+				return list[index.row()].audience_score;
+			case AL_STARTED:
+				return list[index.row()].started.GetAsQDate();
+			case AL_COMPLETED:
+				return list[index.row()].completed.GetAsQDate();
+			case AL_UPDATED: {
+				if (list[index.row()].updated == 0)
+					return QString("-");
+				Time::Duration duration(Time::GetSystemTime() - list[index.row()].updated);
+				return QString::fromStdString(duration.AsRelativeString());
+			}
+			case AL_NOTES:
+				return QString::fromWCharArray(list[index.row()].notes.c_str());
+			default:
+				return "";
+		}
+	} else if (role == Qt::TextAlignmentRole) {
+		switch (index.column()) {
+			case AL_TITLE:
+			case AL_NOTES:
+				return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
+			case AL_PROGRESS:
+			case AL_TYPE:
+			case AL_SCORE:
+			case AL_AVG_SCORE:
+				return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
+			case AL_SEASON:
+			case AL_STARTED:
+			case AL_COMPLETED:
+			case AL_UPDATED:
+				return QVariant(Qt::AlignRight | Qt::AlignVCenter);
+			default:
+				break;
+		}
+	}
+	return QVariant();
+}
+
+void AnimeListWidgetModel::UpdateAnime(Anime& anime) {
+	int i = list.GetAnimeIndex(anime);
+	emit dataChanged(index(i), index(i));
+}
+
+/* Most of this stuff is const and/or should be edited in the Information dialog
+
+bool AnimeListWidgetModel::setData(const QModelIndex &index, const QVariant &value, int role) {
+	if (!index.isValid() || role != Qt::DisplayRole)
+		return false;
+
+	Anime* const anime = &list[index.row()];
+
+	switch (index.column()) {
+		case AL_TITLE:
+			break;
+		case AL_CATEGORY:
+			break;
+		default:
+			return false;
+	}
+
+	return true;
+}
+*/
+
+int AnimeListWidget::VisibleColumnsCount() const {
+    int count = 0;
+
+    for (int i = 0, end = header()->count(); i < end; i++)
+    {
+        if (!isColumnHidden(i))
+            count++;
+    }
+
+    return count;
+}
+
+void AnimeListWidget::SetColumnDefaults() {
+	setColumnHidden(AnimeListWidgetModel::AL_SEASON, false);
+	setColumnHidden(AnimeListWidgetModel::AL_TYPE, false);
+	setColumnHidden(AnimeListWidgetModel::AL_UPDATED, false);
+	setColumnHidden(AnimeListWidgetModel::AL_PROGRESS, false);
+	setColumnHidden(AnimeListWidgetModel::AL_SCORE, false);
+	setColumnHidden(AnimeListWidgetModel::AL_TITLE, false);
+	setColumnHidden(AnimeListWidgetModel::AL_AVG_SCORE, true);
+	setColumnHidden(AnimeListWidgetModel::AL_STARTED, true);
+	setColumnHidden(AnimeListWidgetModel::AL_COMPLETED, true);
+	setColumnHidden(AnimeListWidgetModel::AL_UPDATED, true);
+	setColumnHidden(AnimeListWidgetModel::AL_NOTES, true);
+}
+
+void AnimeListWidget::DisplayColumnHeaderMenu() {
+    QMenu *menu = new QMenu(this);
+    menu->setAttribute(Qt::WA_DeleteOnClose);
+    menu->setTitle(tr("Column visibility"));
+    menu->setToolTipsVisible(true);
+
+    for (int i = 0; i < AnimeListWidgetModel::NB_COLUMNS; i++)
+    {
+		if (i == AnimeListWidgetModel::AL_TITLE)
+			continue;
+        const auto column_name = model->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
+        QAction *action = menu->addAction(column_name, this, [this, i](const bool checked) {
+            if (!checked && (VisibleColumnsCount() <= 1))
+                return;
+
+            setColumnHidden(i, !checked);
+
+            if (checked && (columnWidth(i) <= 5))
+                resizeColumnToContents(i);
+
+            // SaveSettings();
+        });
+        action->setCheckable(true);
+        action->setChecked(!isColumnHidden(i));
+    }
+
+    menu->addSeparator();
+    QAction *resetAction = menu->addAction(tr("Reset to defaults"), this, [this]()
+    {
+        for (int i = 0, count = header()->count(); i < count; ++i)
+        {
+            SetColumnDefaults();
+        }
+		// SaveSettings();
+    });
+
+    menu->popup(QCursor::pos());
+}
+
+void AnimeListWidget::DisplayListMenu() {
+	/* throw out any other garbage */
+    const QModelIndexList selected_items = selectionModel()->selectedRows();
+    if (selected_items.size() != 1 || !selected_items.first().isValid())
+        return;
+
+	const QModelIndex index = model->index(selected_items.first().row());
+	Anime* anime = model->GetAnimeFromIndex(index);
+	if (!anime)
+		return;
+
+}
+
+void AnimeListWidget::ItemDoubleClicked() {
+	/* throw out any other garbage */
+    const QModelIndexList selected_items = selectionModel()->selectedRows();
+    if (selected_items.size() != 1 || !selected_items.first().isValid())
+        return;
+
+	/* TODO: after we implement our sort model, we have to use mapToSource here... */
+	const QModelIndex index = model->index(selected_items.first().row());
+	Anime* anime = model->GetAnimeFromIndex(index);
+	if (!anime)
+		return;
+
+	InformationDialog* dialog = new InformationDialog(*anime, model, this);
+
+    dialog->show();
+    dialog->raise();
+    dialog->activateWindow();
+}
+
+AnimeListWidget::AnimeListWidget(QWidget* parent, AnimeList* alist)
+                               : QTreeView(parent) {
+	model = new AnimeListWidgetModel(parent, alist);
+	setModel(model);
+	setObjectName("listwidget");
+	setStyleSheet("QTreeView#listwidget{border-top:0px;}");
+	setUniformRowHeights(true);
+	setAllColumnsShowFocus(false);
+	setSortingEnabled(true);
+	setSelectionMode(QAbstractItemView::ExtendedSelection);
+	setItemsExpandable(false);
+	setRootIsDecorated(false);
+	setContextMenuPolicy(Qt::CustomContextMenu);
+	connect(this, &QAbstractItemView::doubleClicked, this, &ItemDoubleClicked);
+	connect(this, &QWidget::customContextMenuRequested, this, &DisplayListMenu);
+
+	/* Enter & return keys */
+    connect(new QShortcut(Qt::Key_Return, this, nullptr, nullptr, Qt::WidgetShortcut),
+	        &QShortcut::activated, this, &ItemDoubleClicked);
+
+    connect(new QShortcut(Qt::Key_Enter, this, nullptr, nullptr, Qt::WidgetShortcut),
+	        &QShortcut::activated, this, &ItemDoubleClicked);
+
+	header()->setStretchLastSection(false);
+	header()->setContextMenuPolicy(Qt::CustomContextMenu);
+	connect(header(), &QWidget::customContextMenuRequested, this, &DisplayColumnHeaderMenu);
+	// if(!session.config.anime_list.columns) {
+		SetColumnDefaults();
+	// }
+}
+
+AnimeListPage::AnimeListPage(QWidget* parent) : QTabWidget (parent) {
+	setDocumentMode(false);
+	SyncAnimeList();
+	for (AnimeList& list : anime_lists) {
+		addTab(new AnimeListWidget(this, &list), QString::fromWCharArray(list.name.c_str()));
+	}
+}
+
+void AnimeListPage::SyncAnimeList() {
+	switch (session.config.service) {
+		case ANILIST: {
+			AniList anilist = AniList();
+			anilist.Authorize();
+			session.config.anilist.user_id = anilist.GetUserId(session.config.anilist.username);
+			FreeAnimeList();
+			anilist.UpdateAnimeList(&anime_lists, session.config.anilist.user_id);
+			break;
+		}
+	}
+}
+
+void AnimeListPage::FreeAnimeList() {
+	if (anime_lists.size() > 0) {
+		/* FIXME: we may not need this, but to prevent memleaks
+		   we should keep it until we're sure we don't */
+		for (auto& list : anime_lists) {
+			list.Clear();
+		}
+		anime_lists.clear();
+	}
+}
+
+int AnimeListPage::GetTotalAnimeAmount() {
+	int total = 0;
+	for (auto& list : anime_lists) {
+		total += list.Size();
+	}
+	return total;
+}
+
+int AnimeListPage::GetTotalEpisodeAmount() {
+	/* FIXME: this also needs to take into account rewatches... */
+	int total = 0;
+	for (auto& list : anime_lists) {
+		for (auto& anime : list) {
+			total += anime.progress;
+		}
+	}
+	return total;
+}
+
+/* Returns the total watched amount in minutes. */
+int AnimeListPage::GetTotalWatchedAmount() {
+	int total = 0;
+	for (auto& list : anime_lists) {
+		for (auto& anime : list) {
+			total += anime.duration*anime.progress;
+		}
+	}
+	return total;
+}
+
+/* Returns the total planned amount in minutes.
+   Note that we should probably limit progress to the
+   amount of episodes, as AniList will let you
+   set episode counts up to 32768. But that should
+   rather be handled elsewhere. */
+int AnimeListPage::GetTotalPlannedAmount() {
+	int total = 0;
+	for (auto& list : anime_lists) {
+		for (auto& anime : list) {
+			total += anime.duration*(anime.episodes-anime.progress);
+		}
+	}
+	return total;
+}
+
+double AnimeListPage::GetAverageScore() {
+	double avg = 0;
+	int amt = 0;
+	for (auto& list : anime_lists) {
+		for (auto& anime : list) {
+			avg += anime.score;
+			if (anime.score != 0)
+				amt++;
+		}
+	}
+	return avg/amt;
+}
+
+double AnimeListPage::GetScoreDeviation() {
+	double squares_sum = 0, avg = GetAverageScore();
+	int amt = 0;
+	for (auto& list : anime_lists) {
+		for (auto& anime : list) {
+			if (anime.score != 0) {
+				squares_sum += std::pow((double)anime.score - avg, 2);
+				amt++;
+			}
+		}
+	}
+	return (amt > 0) ? std::sqrt(squares_sum / amt) : 0;
+}
+
+#include "moc_anime.cpp"
--- a/src/config.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/config.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,76 +1,76 @@
-/**
- * config.cpp:
- * parses the config
- *
- * much of this is similar to the code used in
- * wgsdk...
- * maybe some of this will be C++-ified someday ;)
-**/
-#include <filesystem> /* Sorry, C++17 is just sexy. if you have boost you can probably change this */
-#ifdef MACOSX
-#include <NSSystemDirectories.h>
-#endif
-#include <limits.h>
-#include <cstdlib>
-#include <cstring>
-#include <fstream>
-#include "json.h"
-#include "config.h"
-#include "window.h"
-#include "filesystem.h"
-
-std::map<std::string, enum Themes> StringToTheme = {
-	{"Default", OS},
-	{"Light", LIGHT},
-	{"Dark", DARK}
-};
-
-std::map<enum Themes, std::string> ThemeToString = {
-	{OS, "Default"},
-	{LIGHT, "Light"},
-	{DARK, "Dark"}
-};
-
-int Config::Load() {
-	std::filesystem::path cfg_path = get_config_path();
-	if (!std::filesystem::exists(cfg_path))
-		return 0;
-	std::ifstream config_in(cfg_path.string().c_str(), std::ifstream::in);
-	auto config_js = nlohmann::json::parse(config_in);
-/* this macro will make it easier to edit these in the future, if needed */
-#define GET_CONFIG_VALUE(pointer, location, struct, default) \
-	struct = (config_js.contains(pointer)) ? (location) : (default)
-	GET_CONFIG_VALUE("/General/Service"_json_pointer, (enum AnimeListServices)config_js["General"]["Service"].get<int>(), service, NONE);
-	GET_CONFIG_VALUE("/Authorization/AniList/Auth Token"_json_pointer, config_js["Authorization"]["AniList"]["Auth Token"].get<std::string>(), anilist.auth_token, "");
-	GET_CONFIG_VALUE("/Authorization/AniList/Username"_json_pointer, config_js["Authorization"]["AniList"]["Username"].get<std::string>(), anilist.username, "");
-	GET_CONFIG_VALUE("/Authorization/AniList/User ID"_json_pointer, config_js["Authorization"]["AniList"]["User ID"].get<int>(), anilist.user_id, 0);
-	GET_CONFIG_VALUE("/Appearance/Theme"_json_pointer, StringToTheme[config_js["Appearance"]["Theme"].get<std::string>()], theme, OS);
-#undef GET_CONFIG_VALUE
-	config_in.close();
-	return 0;
-}
-
-int Config::Save() {
-	std::filesystem::path cfg_path = get_config_path();
-	if (!std::filesystem::exists(cfg_path.parent_path()))
-		std::filesystem::create_directories(cfg_path.parent_path());
-	std::ofstream config_out(cfg_path.string().c_str(), std::ofstream::out | std::ofstream::trunc);
-	nlohmann::json config_js = {
-		{"General", {
-			{"Service", service}
-		}},
-		{"Authorization", {
-			{"AniList", {
-				{"Auth Token", anilist.auth_token},
-				{"Username",   anilist.username},
-				{"User ID",    anilist.user_id}
-			}}
-		}},
-		{"Appearance", {
-			{"Theme", ThemeToString[theme]}
-		}}
-	};
-	config_out << std::setw(4) << config_js << std::endl;
-	config_out.close();
-	return 0;
-}
+/**
+ * config.cpp:
+ * parses the config
+ *
+ * much of this is similar to the code used in
+ * wgsdk...
+ * maybe some of this will be C++-ified someday ;)
+**/
+#include <filesystem> /* Sorry, C++17 is just sexy. if you have boost you can probably change this */
+#ifdef MACOSX
+#include <NSSystemDirectories.h>
+#endif
+#include <limits.h>
+#include <cstdlib>
+#include <cstring>
+#include <fstream>
+#include "json.h"
+#include "config.h"
+#include "window.h"
+#include "filesystem.h"
+
+std::map<std::string, enum Themes> StringToTheme = {
+	{"Default", OS},
+	{"Light", LIGHT},
+	{"Dark", DARK}
+};
+
+std::map<enum Themes, std::string> ThemeToString = {
+	{OS, "Default"},
+	{LIGHT, "Light"},
+	{DARK, "Dark"}
+};
+
+int Config::Load() {
+	std::filesystem::path cfg_path = get_config_path();
+	if (!std::filesystem::exists(cfg_path))
+		return 0;
+	std::ifstream config_in(cfg_path.string().c_str(), std::ifstream::in);
+	auto config_js = nlohmann::json::parse(config_in);
+/* this macro will make it easier to edit these in the future, if needed */
+#define GET_CONFIG_VALUE(pointer, location, struct, default) \
+	struct = (config_js.contains(pointer)) ? (location) : (default)
+	GET_CONFIG_VALUE("/General/Service"_json_pointer, (enum AnimeListServices)config_js["General"]["Service"].get<int>(), service, NONE);
+	GET_CONFIG_VALUE("/Authorization/AniList/Auth Token"_json_pointer, config_js["Authorization"]["AniList"]["Auth Token"].get<std::string>(), anilist.auth_token, "");
+	GET_CONFIG_VALUE("/Authorization/AniList/Username"_json_pointer, config_js["Authorization"]["AniList"]["Username"].get<std::string>(), anilist.username, "");
+	GET_CONFIG_VALUE("/Authorization/AniList/User ID"_json_pointer, config_js["Authorization"]["AniList"]["User ID"].get<int>(), anilist.user_id, 0);
+	GET_CONFIG_VALUE("/Appearance/Theme"_json_pointer, StringToTheme[config_js["Appearance"]["Theme"].get<std::string>()], theme, OS);
+#undef GET_CONFIG_VALUE
+	config_in.close();
+	return 0;
+}
+
+int Config::Save() {
+	std::filesystem::path cfg_path = get_config_path();
+	if (!std::filesystem::exists(cfg_path.parent_path()))
+		std::filesystem::create_directories(cfg_path.parent_path());
+	std::ofstream config_out(cfg_path.string().c_str(), std::ofstream::out | std::ofstream::trunc);
+	nlohmann::json config_js = {
+		{"General", {
+			{"Service", service}
+		}},
+		{"Authorization", {
+			{"AniList", {
+				{"Auth Token", anilist.auth_token},
+				{"Username",   anilist.username},
+				{"User ID",    anilist.user_id}
+			}}
+		}},
+		{"Appearance", {
+			{"Theme", ThemeToString[theme]}
+		}}
+	};
+	config_out << std::setw(4) << config_js << std::endl;
+	config_out.close();
+	return 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/date.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -0,0 +1,54 @@
+#include "date.h"
+#include <QDate>
+#include <cstdint>
+
+#define MIN(A,B)    ({ __typeof__(A) __a = (A); __typeof__(B) __b = (B); __a < __b ? __a : __b; })
+#define MAX(A,B)    ({ __typeof__(A) __a = (A); __typeof__(B) __b = (B); __a < __b ? __b : __a; })
+
+#define CLAMP(x, low, high) ({\
+  __typeof__(x) __x = (x); \
+  __typeof__(low) __low = (low);\
+  __typeof__(high) __high = (high);\
+  __x > __high ? __high : (__x < __low ? __low : __x);\
+  })
+
+Date::Date() {
+}
+
+Date::Date(int32_t y) {
+	year = MAX(0, y);
+}
+
+Date::Date(int32_t y, int8_t m, int8_t d) {
+	year = MAX(0, y);
+	month = CLAMP(m, 1, 12);
+	day = CLAMP(d, 1, 31);
+}
+
+void Date::SetYear(int32_t y) {
+	year = MAX(0, y);
+}
+
+void Date::SetMonth(int8_t m) {
+	month = CLAMP(m, 1, 12);
+}
+
+void Date::SetDay(int8_t d) {
+	day = CLAMP(d, 1, 31);
+}
+
+int32_t Date::GetYear() {
+	return year;
+}
+
+int8_t Date::GetMonth() {
+	return month;
+}
+
+int8_t Date::GetDay() {
+	return day;
+}
+
+QDate Date::GetAsQDate() {
+	return QDate(year, month, day);
+}
--- a/src/dialog/information.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/dialog/information.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,39 +1,67 @@
-#include "window.h"
-#include "anime.h"
-#include "information.h"
-#include "ui_utils.h"
-#include "string_utils.h"
-
-InformationDialog::InformationDialog(wxWindow* parent, wxWindowID id, const wxString& title, const Anime& anime,
-                                     const wxPoint& position, long style)
-									 : wxDialog(parent, id, title, position, wxSize(840, 613), style) {
-	wxFont font;
-	this->SetBackgroundColour(*wxWHITE);
-	wxPanel* left_panel = new wxPanel(this, wxID_ANY, wxPoint(0, 12), wxSize(170, 518));
-	wxPanel* right_panel = new wxPanel(this, wxID_ANY, wxPoint(170, 12), wxSize(840-186, 518));
-	right_panel->SetBackgroundColour(*wxWHITE);
-	wxTextCtrl* anime_title = new wxTextCtrl(right_panel, wxID_ANY, anime.title, wxPoint(0, 0), wxSize(840-186, 28), wxTE_LEFT | wxBORDER_NONE | wxTE_BESTWRAP | wxTE_READONLY | wxTE_MULTILINE | wxTE_NO_VSCROLL);
-	anime_title->SetForegroundColour(wxTheColourDatabase->Find("STEEL BLUE"));
-	font = anime_title->GetFont();
-	font.SetPointSize(12);
-	anime_title->SetFont(font);
-	wxNotebook* notebook = new wxNotebook(right_panel, wxID_ANY, wxPoint(0, 35), wxSize(840-186, 518-35));
-	notebook->SetBackgroundColour(*wxWHITE);
-	wxPanel* main_information_panel = new wxPanel(notebook, wxID_ANY, wxPoint(6, 6), wxDefaultSize);
-	UiUtils::CreateSelectableTextParagraph(main_information_panel, L"Alternative titles", L"-", 840-186-6-6, 15);
-	std::wstringstream details_data;
-	details_data << AnimeFormatToStringMap[anime.type] << "\n"
-	             << anime.episodes << "\n"
-	             << AnimeAiringToStringMap[anime.airing] << "\n"
-	             << AnimeSeasonToStringMap[anime.season] << " " << int(anime.air_date.year()) << "\n"
-	             << StringUtils::Implode(anime.genres, ", ") << "\n"
-	             << anime.audience_score << "%\n";
-	UiUtils::CreateTextParagraphWithLabels(main_information_panel, L"Details", L"Type:\nEpisodes:\nStatus:\nSeason:\nGenres:\nScore:", details_data.str().c_str(), 840-186-6-6, 94, 0, 50);
-	UiUtils::CreateSelectableTextParagraph(main_information_panel, L"Synopsis", anime.synopsis.c_str(), 840-186-6-6, 200, 0, 200);
-
-	wxPanel* data_input_panel = new wxPanel(notebook, wxID_ANY, wxPoint(6, 6), wxDefaultSize);
-	
-	notebook->AddPage(main_information_panel, "Main information");
-	notebook->AddPage(data_input_panel, "Anime list and settings");
-	
-}
+#include "window.h"
+#include "anime.h"
+#include "information.h"
+#include "ui_utils.h"
+#include "string_utils.h"
+
+#include <QDialogButtonBox>
+
+void InformationDialog::OnOK() {
+	model->UpdateAnime(*anime);
+	QDialog::accept();
+}
+
+InformationDialog::InformationDialog(Anime& a, AnimeListWidgetModel* model, QWidget* parent)
+                                   : QDialog(parent)
+                                     {
+	this->model = model;
+	this->anime = &a;
+	setFixedSize(842, 613);
+	setWindowTitle(tr("Anime Information"));
+	setWindowFlags(Qt::Dialog | Qt::CustomizeWindowHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint);
+	setObjectName("infodiag");
+	QWidget* widget = new QWidget(this);
+	widget->resize(842-175, 530);
+	widget->move(175, 0);
+	widget->setStyleSheet(UiUtils::IsInDarkMode() ? "" : "background-color: white");
+	widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+	QPlainTextEdit* anime_title = new QPlainTextEdit(QString::fromWCharArray(anime->title.english.c_str()), widget);
+	anime_title->setReadOnly(true);
+	anime_title->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+	anime_title->setWordWrapMode(QTextOption::NoWrap);
+	anime_title->setFrameShape(QFrame::NoFrame);
+	anime_title->resize(636, 28);
+	anime_title->move(0, 12);
+	anime_title->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+	anime_title->setStyleSheet("font-size: 16px; color: blue");
+	QTabWidget* tabbed_widget = new QTabWidget(widget);
+	tabbed_widget->resize(636, 485);
+	tabbed_widget->move(0, 45);
+	tabbed_widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+	QWidget* main_information_widget = new QWidget(tabbed_widget);
+	UiUtils::CreateSelectableTextParagraph(main_information_widget, "Alternative titles", "-", QPoint(6, 6), QSize(636-18, 56));
+	QString details_data("");
+	QTextStream details_data_s(&details_data);
+	details_data_s << AnimeFormatToStringMap[anime->type].c_str() << "\n"
+	               << anime->episodes << "\n"
+	               << AnimeAiringToStringMap[anime->airing].c_str() << "\n"
+	               << AnimeSeasonToStringMap[anime->season].c_str() << " " << anime->air_date.GetYear() << "\n"
+	               << StringUtils::Implode(anime->genres, ", ").c_str() << "\n"
+	               << anime->audience_score << "%\n";
+	UiUtils::CreateTextParagraphWithLabels(main_information_widget, "Details", "Type:\nEpisodes:\nStatus:\nSeason:\nGenres:\nScore:", details_data, QPoint(6, 62), QSize(636-18, 142));
+	UiUtils::CreateSelectableTextParagraph(main_information_widget, "Synopsis", QString::fromWCharArray(anime->synopsis.c_str()), QPoint(6, 202), QSize(636-18, 253));
+	tabbed_widget->addTab(main_information_widget, "Main information");
+	QWidget* settings_widget = new QWidget(tabbed_widget);
+	
+	QDialogButtonBox* button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+	connect(button_box, &QDialogButtonBox::accepted, this, &OnOK);
+	connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
+	QVBoxLayout* buttons_layout = new QVBoxLayout(widget);
+	buttons_layout->addWidget(widget, 0, Qt::AlignTop);
+	buttons_layout->addWidget(button_box, 0, Qt::AlignBottom);
+	// this should probably be win32-only
+	setStyleSheet(UiUtils::IsInDarkMode() ? "" : "QDialog#infodiag{background-color: white;}");
+	setLayout(buttons_layout);
+}
+
+#include "moc_information.cpp"
--- a/src/filesystem.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/filesystem.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,25 +1,25 @@
-#ifdef _WIN32
-#include <shlobj.h>
-#elif defined(APPLE)
-#include <NSSearchPathForDirectoriesInDomains.h>
-#endif
-#include <filesystem>
-#include <limits.h>
-#include "config.h"
-#include "filesystem.h"
-
-std::filesystem::path get_config_path(void) {
-	std::filesystem::path cfg_path;
-#ifdef _WIN32
-	char buf[PATH_MAX+1];
-	if (SHGetFolderPathAndSubDir(NULL, CSIDL_APPDATA | CSIDL_FLAG_CREATE, NULL, 0, CONFIG_DIR, buf) == S_OK)
-		cfg_path = std::filesystem::path(buf) / CONFIG_NAME;
-#elif defined(MACOSX)
-	/* hope and pray that std::filesystem can handle tildes... */
-	CFString string = (CFString)NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true);
-	cfg_path = std::filesystem::path(StringUtils::Utf8ToWstr(std::string(CFStringGetCStringPtr(string, UTF8))));
-#else // just assume POSIX
-	cfg_path = std::filesystem::path(getenv("HOME")) / ".config" / CONFIG_DIR / CONFIG_NAME;
-#endif
-	return cfg_path;
-}
+#ifdef _WIN32
+#include <shlobj.h>
+#elif defined(APPLE)
+#include <NSSearchPathForDirectoriesInDomains.h>
+#endif
+#include <filesystem>
+#include <limits.h>
+#include "config.h"
+#include "filesystem.h"
+
+std::filesystem::path get_config_path(void) {
+	std::filesystem::path cfg_path;
+#ifdef _WIN32
+	char buf[PATH_MAX+1];
+	if (SHGetFolderPathAndSubDir(NULL, CSIDL_APPDATA | CSIDL_FLAG_CREATE, NULL, 0, CONFIG_DIR, buf) == S_OK)
+		cfg_path = std::filesystem::path(buf) / CONFIG_NAME;
+#elif defined(MACOSX)
+	/* hope and pray that std::filesystem can handle tildes... */
+	CFString string = (CFString)NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true);
+	cfg_path = std::filesystem::path(StringUtils::Utf8ToWstr(std::string(CFStringGetCStringPtr(string, UTF8))));
+#else // just assume POSIX
+	cfg_path = std::filesystem::path(getenv("HOME")) / ".config" / CONFIG_DIR / CONFIG_NAME;
+#endif
+	return cfg_path;
+}
--- a/src/include/anilist.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/anilist.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,33 +1,22 @@
-#ifndef __anilist_h
-#define __anilist_h
-#include <curl/curl.h>
-#include "anime.h"
-class AniList {
-	public:
-		int Authorize();
-		int GetUserId(std::string name);
-		int UpdateAnimeList(std::vector<AnimeList>* anime_lists, int id);
-
-	private:
-		static size_t CurlWriteCallback(void *contents, size_t size, size_t nmemb, void *userdata);
-		enum AnimeWatchingStatus ConvertWatchingStatusToEnum(std::string status);
-		enum AnimeAiringStatus ConvertAiringStatusToEnum(std::string status);
-		enum AnimeFormat ConvertFormatToEnum(std::string format);
-		enum AnimeSeason ConvertSeasonToEnum(std::string season);
-		std::string SendRequest(std::string data);
-		CURL* curl;
-		CURLcode res;
-};
-
-/* FIXME: at some point, we have to add a separate Date class (which IIRC
-   Kitsu actually does as well), because the standard library functions do
-   not support any null values. Internally, we could represent null or undefined
-   values as... -1?. Also, anything anime-related should probably be in an
-   Anime namespace. */
-#define ANILIST_DATE_IS_VALID(a) \
-	(a["year"].is_number() && a["month"].is_number() && a["day"].is_number())
-#define ANILIST_DATE_TO_YMD(a) \
-	std::chrono::year_month_day(std::chrono::year(a["year"].get<int>()), \
-								std::chrono::month(a["month"].get<int>()), \
-								std::chrono::day(a["day"].get<int>()))
-#endif // __anilist_h
+#ifndef __anilist_h
+#define __anilist_h
+#include <curl/curl.h>
+#include "anime.h"
+#include "json.h"
+class AniList {
+	public:
+		int Authorize();
+		int GetUserId(std::string name);
+		int UpdateAnimeList(std::vector<AnimeList>* anime_lists, int id);
+
+	private:
+		static size_t CurlWriteCallback(void *contents, size_t size, size_t nmemb, void *userdata);
+		enum AnimeWatchingStatus ConvertWatchingStatusToEnum(std::string status);
+		enum AnimeAiringStatus ConvertAiringStatusToEnum(std::string status);
+		enum AnimeFormat ConvertFormatToEnum(std::string format);
+		enum AnimeSeason ConvertSeasonToEnum(std::string season);
+		std::string SendRequest(std::string data);
+		CURL* curl;
+		CURLcode res;
+};
+#endif // __anilist_h
--- a/src/include/anime.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/anime.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,168 +1,175 @@
-#ifndef __anime_h
-#define __anime_h
-#include <vector>
-#include <chrono>
-#include <map>
-#include "window.h"
-
-enum AnimeWatchingStatus {
-	CURRENT,
-	PLANNING,
-	COMPLETED,
-	DROPPED,
-	PAUSED,
-	REPEATING
-};
-
-enum AnimeAiringStatus {
-	FINISHED,
-	RELEASING,
-	NOT_YET_RELEASED,
-	CANCELLED,
-	HIATUS
-};
-
-enum AnimeFormat {
-	TV,
-	TV_SHORT,
-	MOVIE,
-	SPECIAL,
-	OVA,
-	ONA,
-	MUSIC,
-	MANGA,
-	NOVEL,
-	ONE_SHOT
-};
-
-enum AnimeSeason {
-	WINTER,
-	SPRING,
-	SUMMER,
-	FALL
-};
-
-class Anime {
-	public:
-		Anime();
-		Anime(const Anime& a);
-		/* List-specific data */
-		enum AnimeWatchingStatus status;
-		int progress;
-		int score;
-		std::chrono::year_month_day started;
-		std::chrono::year_month_day completed;
-		std::wstring notes;
-
-		/* Useful information */
-		int id;
-		std::wstring title;
-		int episodes;
-		enum AnimeAiringStatus airing;
-		std::chrono::year_month_day air_date;
-		std::vector<std::string> genres;
-		std::vector<std::string> producers;
-		enum AnimeFormat type;
-		enum AnimeSeason season;
-		int audience_score;
-		std::wstring synopsis;
-		int duration;
-};
-
-/* This is a simple wrapper on a vector that provides 
-   methods to make it easier to search the list. */
-class AnimeList {
-	public:
-		AnimeList();
-		AnimeList(const AnimeList& l);
-		~AnimeList();
-		void Add(Anime& anime);
-		void Insert(size_t pos, Anime& anime);
-		void Delete(size_t index);
-		void Clear();
-		std::vector<Anime>::iterator begin() noexcept;
-		std::vector<Anime>::iterator end() noexcept;
-		std::vector<Anime>::const_iterator cbegin() noexcept;
-		std::vector<Anime>::const_iterator cend() noexcept;
-		size_t Size() const;
-		Anime* AnimeById(int id);
-		bool AnimeInList(int id);
-		Anime& operator[](size_t index);
-		const Anime& operator[](size_t index) const;
-		std::wstring name;
-
-	private:
-		std::vector<Anime> anime_list;
-		std::map<int, Anime*> anime_id_to_anime;
-};
-
-class AnimeListWidgetModel : public QAbstractListModel {
-	Q_OBJECT
-	public:
-		enum columns {
-			AL_TITLE,
-			AL_PROGRESS,
-			AL_SCORE,
-			AL_AVG_SCORE,
-			AL_TYPE,
-			AL_SEASON,
-			AL_STARTED,
-			AL_COMPLETED,
-			AL_UPDATED,
-			AL_NOTES,
-			
-			NB_COLUMNS
-		};
-
-		AnimeListWidgetModel(QWidget* parent, AnimeList* alist);
-		~AnimeListWidgetModel() override = default;
-		//QVariant headerData(const int section, const Qt::Orientation orientation, const int role) const;
-		int rowCount(const QModelIndex& parent = QModelIndex()) const;
-		int columnCount(const QModelIndex& parent = QModelIndex()) const;
-		QVariant data(const QModelIndex& index, int role) const;
-		QVariant headerData(const int section, const Qt::Orientation orientation, const int role) const;
-		Anime* GetAnimeFromIndex(const QModelIndex& index);
-		void Update();
-
-	private:
-		//void AddAnime(AnimeList& list);
-		AnimeList& list;
-};
-
-class AnimeListWidget : public QTreeView {
-	Q_OBJECT
-	public:
-		AnimeListWidget(QWidget* parent, AnimeList* alist);
-
-	private slots:
-		void DisplayColumnHeaderMenu();
-		void DisplayListMenu();
-		void ItemDoubleClicked();
-		void SetColumnDefaults();
-		int VisibleColumnsCount() const;
-
-	private:
-		AnimeListWidgetModel* model = nullptr;
-};
-
-class AnimeListPage : public QTabWidget {
-	public:
-		AnimeListPage(QWidget* parent = nullptr);
-		void SyncAnimeList();
-		void FreeAnimeList();
-		int GetTotalAnimeAmount();
-		int GetTotalEpisodeAmount();
-		int GetTotalWatchedAmount();
-		int GetTotalPlannedAmount();
-		double GetAverageScore();
-		double GetScoreDeviation();
-
-	private:
-		std::vector<AnimeList> anime_lists;
-};
-
-extern std::map<enum AnimeSeason, std::string> AnimeSeasonToStringMap;
-extern std::map<enum AnimeFormat, std::string> AnimeFormatToStringMap;
-extern std::map<enum AnimeWatchingStatus, std::string> AnimeWatchingToStringMap;
-extern std::map<enum AnimeAiringStatus, std::string> AnimeAiringToStringMap;
+#ifndef __anime_h
+#define __anime_h
+#include <vector>
+#include <map>
+#include "date.h"
+#include "window.h"
+
+enum AnimeWatchingStatus {
+	CURRENT,
+	PLANNING,
+	COMPLETED,
+	DROPPED,
+	PAUSED,
+	REPEATING
+};
+
+enum AnimeAiringStatus {
+	FINISHED,
+	RELEASING,
+	NOT_YET_RELEASED,
+	CANCELLED,
+	HIATUS
+};
+
+enum AnimeFormat {
+	TV,
+	TV_SHORT,
+	MOVIE,
+	SPECIAL,
+	OVA,
+	ONA,
+	MUSIC,
+	MANGA,
+	NOVEL,
+	ONE_SHOT
+};
+
+enum AnimeSeason {
+	UNKNOWN,
+	WINTER,
+	SPRING,
+	SUMMER,
+	FALL
+};
+
+class Anime {
+	public:
+		Anime();
+		Anime(const Anime& a);
+		/* List-specific data */
+		enum AnimeWatchingStatus status;
+		int progress;
+		int score;
+		Date started;
+		Date completed;
+		int updated; /* this should be 64-bit */
+		std::wstring notes;
+
+		/* Useful information */
+		int id;
+		struct {
+			std::wstring romaji;
+			std::wstring english;
+			std::wstring native;
+		} title;
+		int episodes;
+		enum AnimeAiringStatus airing;
+		Date air_date;
+		std::vector<std::string> genres;
+		std::vector<std::string> producers;
+		enum AnimeFormat type;
+		enum AnimeSeason season;
+		int audience_score;
+		std::wstring synopsis;
+		int duration;
+};
+
+/* This is a simple wrapper on a vector that provides 
+   methods to make it easier to search the list. */
+class AnimeList {
+	public:
+		AnimeList();
+		AnimeList(const AnimeList& l);
+		~AnimeList();
+		void Add(Anime& anime);
+		void Insert(size_t pos, Anime& anime);
+		void Delete(size_t index);
+		void Clear();
+		std::vector<Anime>::iterator begin() noexcept;
+		std::vector<Anime>::iterator end() noexcept;
+		std::vector<Anime>::const_iterator cbegin() noexcept;
+		std::vector<Anime>::const_iterator cend() noexcept;
+		size_t Size() const;
+		Anime* AnimeById(int id);
+		int GetAnimeIndex(Anime& anime) const;
+		bool AnimeInList(int id);
+		Anime& operator[](size_t index);
+		const Anime& operator[](size_t index) const;
+		std::wstring name;
+
+	private:
+		std::vector<Anime> anime_list;
+		std::map<int, Anime*> anime_id_to_anime;
+};
+
+class AnimeListWidgetModel : public QAbstractListModel {
+	Q_OBJECT
+	public:
+		enum columns {
+			AL_TITLE,
+			AL_PROGRESS,
+			AL_SCORE,
+			AL_AVG_SCORE,
+			AL_TYPE,
+			AL_SEASON,
+			AL_STARTED,
+			AL_COMPLETED,
+			AL_UPDATED,
+			AL_NOTES,
+			
+			NB_COLUMNS
+		};
+
+		AnimeListWidgetModel(QWidget* parent, AnimeList* alist);
+		~AnimeListWidgetModel() override = default;
+		//QVariant headerData(const int section, const Qt::Orientation orientation, const int role) const;
+		int rowCount(const QModelIndex& parent = QModelIndex()) const;
+		int columnCount(const QModelIndex& parent = QModelIndex()) const;
+		QVariant data(const QModelIndex& index, int role) const;
+		QVariant headerData(const int section, const Qt::Orientation orientation, const int role) const;
+		Anime* GetAnimeFromIndex(const QModelIndex& index);
+		void UpdateAnime(Anime& anime);
+
+	private:
+		//void AddAnime(AnimeList& list);
+		AnimeList& list;
+};
+
+class AnimeListWidget : public QTreeView {
+	Q_OBJECT
+	public:
+		AnimeListWidget(QWidget* parent, AnimeList* alist);
+
+	private slots:
+		void DisplayColumnHeaderMenu();
+		void DisplayListMenu();
+		void ItemDoubleClicked();
+		void SetColumnDefaults();
+		int VisibleColumnsCount() const;
+
+	private:
+		AnimeListWidgetModel* model = nullptr;
+};
+
+class AnimeListPage : public QTabWidget {
+	public:
+		AnimeListPage(QWidget* parent = nullptr);
+		void SyncAnimeList();
+		void FreeAnimeList();
+		int GetTotalAnimeAmount();
+		int GetTotalEpisodeAmount();
+		int GetTotalWatchedAmount();
+		int GetTotalPlannedAmount();
+		double GetAverageScore();
+		double GetScoreDeviation();
+
+	private:
+		std::vector<AnimeList> anime_lists;
+};
+
+extern std::map<enum AnimeSeason, std::string> AnimeSeasonToStringMap;
+extern std::map<enum AnimeFormat, std::string> AnimeFormatToStringMap;
+extern std::map<enum AnimeWatchingStatus, std::string> AnimeWatchingToStringMap;
+extern std::map<enum AnimeAiringStatus, std::string> AnimeAiringToStringMap;
 #endif // __anime_h
\ No newline at end of file
--- a/src/include/config.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/config.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,34 +1,34 @@
-#ifndef __config_h
-#define __config_h
-/* This should be moved to anime_list.h, but unfortunately
-   #include-ing anime_list.h in this file causes a shitstorm for
-   whatever reason, so I'll just leave it here */
-enum AnimeListServices {
-	NONE,
-	ANILIST
-};
-
-/* todo: make this a class enum */
-enum Themes {
-	LIGHT,
-	DARK,
-	OS
-};
-
-class Config {
-	public:
-		int Load();
-		int Save();
-
-		enum AnimeListServices service;
-		enum Themes theme;
-		struct {
-			std::string auth_token;
-			std::string username;
-			int user_id;
-		} anilist;
-};
-#define CONFIG_DIR  "weeaboo"
-#define CONFIG_NAME "config.json"
-#define MAX_LINE_LENGTH 256
-#endif // __config_h
+#ifndef __config_h
+#define __config_h
+/* This should be moved to anime_list.h, but unfortunately
+   #include-ing anime_list.h in this file causes a shitstorm for
+   whatever reason, so I'll just leave it here */
+enum AnimeListServices {
+	NONE,
+	ANILIST
+};
+
+/* todo: make this a class enum */
+enum Themes {
+	LIGHT,
+	DARK,
+	OS
+};
+
+class Config {
+	public:
+		int Load();
+		int Save();
+
+		enum AnimeListServices service;
+		enum Themes theme;
+		struct {
+			std::string auth_token;
+			std::string username;
+			int user_id;
+		} anilist;
+};
+#define CONFIG_DIR  "weeaboo"
+#define CONFIG_NAME "config.json"
+#define MAX_LINE_LENGTH 256
+#endif // __config_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/include/date.h	Sat Aug 12 03:16:26 2023 -0400
@@ -0,0 +1,23 @@
+#ifndef __date_h
+#define __date_h
+#include <cstdint>
+#include <QDate>
+class Date {
+	public:
+		Date();
+		Date(int32_t y);
+		Date(int32_t y, int8_t m, int8_t d);
+		void SetYear(int32_t y);
+		void SetMonth(int8_t m);
+		void SetDay(int8_t d);
+		int32_t GetYear();
+		int8_t GetMonth();
+		int8_t GetDay();
+		QDate GetAsQDate();
+
+	private:
+		int32_t year = -1;
+		int8_t month = -1;
+		int8_t day = -1;
+};
+#endif // __date_h
\ No newline at end of file
--- a/src/include/filesystem.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/filesystem.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,5 +1,5 @@
-#ifndef __filesystem_h
-#define __filesystem_h
-#include <filesystem>
-std::filesystem::path get_config_path(void);
+#ifndef __filesystem_h
+#define __filesystem_h
+#include <filesystem>
+std::filesystem::path get_config_path(void);
 #endif // __filesystem_h
\ No newline at end of file
--- a/src/include/information.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/information.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,11 +1,14 @@
-#ifndef __information_h
-#define __information_h
-class InformationDialog: public wxDialog {
-	public:
-		InformationDialog(wxWindow* parent, wxWindowID id, const wxString& title,
-						  const Anime& anime, const wxPoint& pos = wxDefaultPosition,
-						  long style = wxDEFAULT_DIALOG_STYLE);
-	private:
-		wxNotebook* dialogText;
-};
+#ifndef __information_h
+#define __information_h
+#include "anime.h"
+class InformationDialog: public QDialog {
+	Q_OBJECT
+	public:
+		InformationDialog(Anime& a, AnimeListWidgetModel* model, QWidget* parent = nullptr);
+
+	private:
+		void OnOK();
+		Anime* anime;
+		AnimeListWidgetModel* model;
+};
 #endif // __information_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/include/json.h	Sat Aug 12 03:16:26 2023 -0400
@@ -0,0 +1,9 @@
+#include "../../dep/json/json.h"
+
+namespace JSON {
+	std::string GetString(nlohmann::json const& json, std::string const& key);
+	int GetInt(nlohmann::json const& json, std::string const& key);
+	int64_t GetInt64(nlohmann::json const& json, std::string const& key);
+	bool GetBoolean(nlohmann::json const& json, std::string const& key);
+	double GetDouble(nlohmann::json const& json, std::string const& key);
+}
--- a/src/include/now_playing.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/now_playing.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,9 +1,9 @@
-#ifndef __now_playing_h
-#define __now_playing_h
-
-class NowPlaying {
-	public:
-		NowPlaying(page_t* page, wxPanel* frame);
-};
-
-#endif // __now_playing_h
+#ifndef __now_playing_h
+#define __now_playing_h
+
+class NowPlaying {
+	public:
+		NowPlaying(page_t* page, wxPanel* frame);
+};
+
+#endif // __now_playing_h
--- a/src/include/statistics.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/statistics.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,40 +1,40 @@
-#ifndef __statistics_h
-#define __statistics_h
-class Statistics;
-
-class StatisticsTimer : public wxTimer {
-	public:
-		StatisticsTimer(Statistics* caller);
-		virtual void Notify();
-
-	private:
-		Statistics* statistics;
-};
-
-class Statistics {
-	public:
-		Statistics(page_t* page, wxPanel* frame);
-		void UpdateStatistics();
-
-	private:
-		std::string MinutesToDateString(int minutes);
-
-		wxPanel* panel;
-		AnimeListPage* anime_list;
-		wxStaticText* anime_list_data;
-
-		wxStaticText* score_distribution_title;
-		wxStaticText* score_distribution_labels;
-		//wxStaticText* score_distribution_graph; // how am I gonna do this
-
-		/* we don't HAVE a local database (yet ;)) */
-		//wxStaticText* local_database_title;
-		//wxStaticText* local_database_labels;
-		//wxStaticText* local_database_data;
-
-		wxStaticText* application_title;
-		wxStaticText* application_labels;
-		wxStaticText* application_data;
-		StatisticsTimer* timer;
-};
+#ifndef __statistics_h
+#define __statistics_h
+class Statistics;
+
+class StatisticsTimer : public wxTimer {
+	public:
+		StatisticsTimer(Statistics* caller);
+		virtual void Notify();
+
+	private:
+		Statistics* statistics;
+};
+
+class Statistics {
+	public:
+		Statistics(page_t* page, wxPanel* frame);
+		void UpdateStatistics();
+
+	private:
+		std::string MinutesToDateString(int minutes);
+
+		wxPanel* panel;
+		AnimeListPage* anime_list;
+		wxStaticText* anime_list_data;
+
+		wxStaticText* score_distribution_title;
+		wxStaticText* score_distribution_labels;
+		//wxStaticText* score_distribution_graph; // how am I gonna do this
+
+		/* we don't HAVE a local database (yet ;)) */
+		//wxStaticText* local_database_title;
+		//wxStaticText* local_database_labels;
+		//wxStaticText* local_database_data;
+
+		wxStaticText* application_title;
+		wxStaticText* application_labels;
+		wxStaticText* application_data;
+		StatisticsTimer* timer;
+};
 #endif // __statistics_h
\ No newline at end of file
--- a/src/include/string_utils.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/string_utils.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,33 +1,33 @@
-#ifndef __string_utils_h
-#define __string_utils_h
-#include <string>
-#include <vector>
-namespace StringUtils {
-	/* Implode function: takes a vector of strings and turns it
-	   into a string, separated by delimiters. */
-	std::string  Implode(const std::vector<std::string>& vector,
-	                     const std::string& delimiter);
-	std::wstring Implode(const std::vector<std::wstring>& vector,
-                         const std::wstring& delimiter);
-
-	/* Conversion from UTF-8 to std::wstring and vice versa */
-	std::string  WstrToUtf8(const std::wstring& string);
-	std::wstring Utf8ToWstr(const std::string& string);
-
-	/* Substring removal functions */
-	std::string  ReplaceAll(const std::string& string,
-						    const std::string& find,
-						    const std::string& replace);
-	std::wstring ReplaceAll(const std::wstring& string,
-                            const std::wstring& find,
-							const std::wstring& replace);
-	std::string  SanitizeLineEndings(const std::string& string);
-	std::wstring SanitizeLineEndings(const std::wstring& string);
-	std::wstring RemoveHtmlTags(const std::wstring& string);
-	std::string  RemoveHtmlTags(const std::string& string);
-
-	/* stupid HTML bullshit */
-	std::string  TextifySynopsis(const std::string& string);
-	std::wstring TextifySynopsis(const std::wstring& string);
-};
+#ifndef __string_utils_h
+#define __string_utils_h
+#include <string>
+#include <vector>
+namespace StringUtils {
+	/* Implode function: takes a vector of strings and turns it
+	   into a string, separated by delimiters. */
+	std::string  Implode(const std::vector<std::string>& vector,
+	                     const std::string& delimiter);
+	std::wstring Implode(const std::vector<std::wstring>& vector,
+                         const std::wstring& delimiter);
+
+	/* Conversion from UTF-8 to std::wstring and vice versa */
+	std::string  WstrToUtf8(const std::wstring& string);
+	std::wstring Utf8ToWstr(const std::string& string);
+
+	/* Substring removal functions */
+	std::string  ReplaceAll(const std::string& string,
+						    const std::string& find,
+						    const std::string& replace);
+	std::wstring ReplaceAll(const std::wstring& string,
+                            const std::wstring& find,
+							const std::wstring& replace);
+	std::string  SanitizeLineEndings(const std::string& string);
+	std::wstring SanitizeLineEndings(const std::wstring& string);
+	std::wstring RemoveHtmlTags(const std::wstring& string);
+	std::string  RemoveHtmlTags(const std::string& string);
+
+	/* stupid HTML bullshit */
+	std::string  TextifySynopsis(const std::string& string);
+	std::wstring TextifySynopsis(const std::wstring& string);
+};
 #endif // __string_utils_h
\ No newline at end of file
--- a/src/include/sys/osx/dark_theme.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/sys/osx/dark_theme.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,10 +1,10 @@
-#ifndef __sys__osx__dark_theme_h
-#define __sys__osx__dark_theme_h
-namespace osx {
-	bool DarkThemeAvailable();
-	bool IsInDarkTheme();
-	void SetToDarkTheme();
-	void SetToLightTheme();
-	void SetToAutoTheme();
-}
+#ifndef __sys__osx__dark_theme_h
+#define __sys__osx__dark_theme_h
+namespace osx {
+	bool DarkThemeAvailable();
+	bool IsInDarkTheme();
+	void SetToDarkTheme();
+	void SetToLightTheme();
+	void SetToAutoTheme();
+}
 #endif // __sys__osx__dark_theme_h
\ No newline at end of file
--- a/src/include/sys/win32/dark_theme.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/sys/win32/dark_theme.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,7 +1,7 @@
-#ifndef __sys__win32__dark_theme_h
-#define __sys__win32__dark_theme_h
-namespace win32 {
-	bool DarkThemeAvailable();
-	bool IsInDarkTheme();
-}
+#ifndef __sys__win32__dark_theme_h
+#define __sys__win32__dark_theme_h
+namespace win32 {
+	bool DarkThemeAvailable();
+	bool IsInDarkTheme();
+}
 #endif // __sys__win32__dark_theme_h
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/include/time_utils.h	Sat Aug 12 03:16:26 2023 -0400
@@ -0,0 +1,20 @@
+#ifndef __duration_h
+#define __duration_h
+#include <string>
+#include <cstdint>
+namespace Time {
+	class Duration {
+		public:
+			Duration(int64_t l);
+			int64_t InSeconds();
+			int64_t InMinutes();
+			int64_t InHours();
+			int64_t InDays();
+			std::string AsRelativeString();
+
+		private:
+			int64_t length;
+	};
+	int64_t GetSystemTime();
+};
+#endif // __duration_h
\ No newline at end of file
--- a/src/include/ui_utils.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/ui_utils.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,14 +1,16 @@
-#ifndef __ui_utils_h
-#define __ui_utils_h
-namespace UiUtils {
-	/* Creates a text paragraph with a title header.
-	   Note: the `height` parameters for both of these functions is actually
-	   the height of the data on its own.
-	   Returns a pointer to the data wxStaticText element. */
-	wxStaticText* CreateTextParagraph(wxWindow* parent, const wchar_t* title, const wchar_t* data, int width, int height, int x = 0, int y = 0);
-
-	wxStaticText* CreateTextParagraphWithLabels(wxWindow* parent, const wchar_t* title, const wchar_t* label, const wchar_t* data, int width, int height, int x = 0, int y = 0);
-	wxTextCtrl* CreateSelectableTextParagraph(wxWindow* parent, const wchar_t* title, const wchar_t* data, int width, int height, int x = 0, int y = 0);
-	void CreateTextHeader(wxWindow* parent, const wchar_t* title, int width, int x = 0, int y = 0);
-};
+#ifndef __ui_utils_h
+#define __ui_utils_h
+#include <QWidget>
+#include <QString>
+#include <QPoint>
+#include <QSize>
+#include <QDateTime>
+namespace UiUtils {
+	bool IsInDarkMode();
+	std::string GetLengthFromQDateTime(QDateTime stamp);
+	QPlainTextEdit* CreateTextParagraph(QWidget* parent, QString title, QString data, QPoint point, QSize size);
+	QPlainTextEdit* CreateTextParagraphWithLabels(QWidget* parent, QString title, QString label, QString data, QPoint point, QSize size);
+	QPlainTextEdit* CreateSelectableTextParagraph(QWidget* parent, QString title, QString data, QPoint point, QSize size);
+	void CreateTextHeader(QWidget* parent, QString title, QPoint point, QSize size);
+};
 #endif // __ui_utils_h
\ No newline at end of file
--- a/src/include/window.h	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/include/window.h	Sat Aug 12 03:16:26 2023 -0400
@@ -1,41 +1,45 @@
-#ifndef __window_h
-# define __window_h
-/* FIXME: include these in specific .cpp files */
-# include <QApplication>
-# include <QMainWindow>
-# include <QToolBar>
-# include <QMenuBar>
-# include <QWidget>
-# include <QTreeView>
-# include <QMessageBox>
-# include <QDesktopServices>
-# include <QUrl>
-# include <QInputDialog>
-# include <QDate>
-# include <QHeaderView>
-# include <QShortcut>
-# include <QFile>
-# include <QTextStream>
-# include <QCloseEvent>
-# include "config.h"
-//# include "statistics.h"
-//# include "now_playing.h"
-
-class MainWindow : public QMainWindow {
-	public:
-		MainWindow(QWidget* parent = nullptr);
-		void SetActivePage(QWidget* page);
-		void SetStyleSheet(enum Themes theme);
-		void ThemeChanged();
-		void closeEvent(QCloseEvent* event);
-
-	private:
-		QWidget* anime_list_page;
-};
-
-struct Session {
-	Config config;
-};
-
-extern Session session;
-#endif // __window_h
+#ifndef __window_h
+# define __window_h
+/* FIXME: include these in specific .cpp files */
+# include <QApplication>
+# include <QMainWindow>
+# include <QToolBar>
+# include <QMenuBar>
+# include <QWidget>
+# include <QTreeView>
+# include <QMessageBox>
+# include <QDesktopServices>
+# include <QUrl>
+# include <QInputDialog>
+# include <QDate>
+# include <QHeaderView>
+# include <QShortcut>
+# include <QFile>
+# include <QTextStream>
+# include <QCloseEvent>
+# include <QPlainTextEdit>
+# include <QLabel>
+# include <QHBoxLayout>
+# include <QTextStream>
+# include "config.h"
+//# include "statistics.h"
+//# include "now_playing.h"
+
+class MainWindow : public QMainWindow {
+	public:
+		MainWindow(QWidget* parent = nullptr);
+		void SetActivePage(QWidget* page);
+		void SetStyleSheet(enum Themes theme);
+		void ThemeChanged();
+		void closeEvent(QCloseEvent* event);
+
+	private:
+		QWidget* anime_list_page;
+};
+
+struct Session {
+	Config config;
+};
+
+extern Session session;
+#endif // __window_h
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/json.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -0,0 +1,34 @@
+#include "json.h"
+
+namespace JSON {
+
+std::string GetString(nlohmann::json const& json, std::string const& key) {
+	auto item = json.find(key);
+	if (item != json.end() && item->is_string())
+		return item->get<std::string>();
+	else return "";
+}
+
+int GetInt(nlohmann::json const& json, std::string const& key) {
+	auto item = json.find(key);
+	if (item != json.end() && item->is_number())
+		return item->get<int>();
+	else return 0;
+}
+
+bool GetBoolean(nlohmann::json const& json, std::string const& key) {
+	auto item = json.find(key);
+	if (item != json.end() && item->is_boolean())
+		return item->get<bool>();
+	else return false;
+}
+
+double GetDouble(nlohmann::json const& json, std::string const& key) {
+	auto item = json.find(key);
+	if (item != json.end() && item->is_number())
+		return item->get<double>();
+	else return 0;
+}
+
+}
+
--- a/src/main.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/main.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,205 +1,205 @@
-#include "window.h"
-#include "config.h"
-#include "anime.h"
-#if APPLE
-#include "sys/osx/dark_theme.h"
-#elif WIN32
-#include "sys/win32/dark_theme.h"
-#endif
-
-Session session = {
-	.config = Config()
-};
-
-/* note that this code was originally created for use in 
-   wxWidgets, but I thought the API was a little meh, so
-   I switched to Qt. */
-
-MainWindow::MainWindow(QWidget* parent) :
-           QMainWindow(parent) {
-	/* Menu Bar */
-	QAction* action;
-	QMenuBar* menubar = new QMenuBar(parent);
-	QMenu* menu = menubar->addMenu("&File");
-	QMenu* submenu = menu->addMenu("&Library folders");
-	action = new QAction("&Add new folder...");
-	submenu->addAction(action);
-	action = new QAction("&Scan available episodes");
-	menu->addAction(action);
-
-	menu->addSeparator();
-
-	action = menu->addAction("Play &next episode");
-	action = menu->addAction("Play &random episode");
-	menu->addSeparator();
-	action = menu->addAction("E&xit", qApp, &QApplication::quit);
-
-	menu = menubar->addMenu("&Services");
-	action = new QAction("Synchronize &list");
-
-	menu->addSeparator();
-
-	submenu = menu->addMenu("&AniList");
-	action = menu->addAction("Go to my &profile");
-	action = menu->addAction("Go to my &stats");
-
-	submenu = menu->addMenu("&Kitsu");
-	action = submenu->addAction("Go to my &feed");
-	action = submenu->addAction("Go to my &library");
-	action = submenu->addAction("Go to my &profile");
-
-	submenu = menu->addMenu("&MyAnimeList");
-	action = submenu->addAction("Go to my p&anel");
-	action = submenu->addAction("Go to my &profile");
-	action = submenu->addAction("Go to my &history");
-
-	menu = menubar->addMenu("&Tools");
-	submenu = menu->addMenu("&Export anime list");
-	action = submenu->addAction("Export as &Markdown...");
-	action = submenu->addAction("Export as MyAnimeList &XML...");
-
-	menu->addSeparator();
-
-	action = menu->addAction("Enable anime &recognition");
-	action->setCheckable(true);
-	action = menu->addAction("Enable auto &sharing");
-	action->setCheckable(true);
-	action = menu->addAction("Enable &auto synchronization");
-	action->setCheckable(true);
-
-	menu->addSeparator();
-
-	action = menu->addAction("&Settings");
-
-	setMenuBar(menubar);
-	
-	/* Side toolbar */
-	QToolBar* toolbar = new QToolBar(parent);
-	QActionGroup* tb_action_group = new QActionGroup(toolbar);
-
-	action = toolbar->addAction("Now Playing");
-	action->setActionGroup(tb_action_group);
-	action->setCheckable(true);
-
-	toolbar->addSeparator();
-
-	action = toolbar->addAction("Anime List", [this]() {
-		setCentralWidget(anime_list_page);
-	});
-	action->setActionGroup(tb_action_group);
-	action->setCheckable(true);
-	action->setChecked(true);
-	anime_list_page = new AnimeListPage(parent);
-	SetActivePage(anime_list_page);
-	action = toolbar->addAction("History");
-	action->setActionGroup(tb_action_group);
-	action->setCheckable(true);
-	action = toolbar->addAction("Statistics");
-	action->setActionGroup(tb_action_group);
-	action->setCheckable(true);
-
-	toolbar->addSeparator();
-
-	action = toolbar->addAction("Search");
-	action->setActionGroup(tb_action_group);
-	action->setCheckable(true);
-	action = toolbar->addAction("Seasons");
-	action->setActionGroup(tb_action_group);
-	action->setCheckable(true);
-	action = toolbar->addAction("Torrents");
-	action->setActionGroup(tb_action_group);
-	action->setCheckable(true);
-
-	toolbar->setMovable(false);
-	toolbar->setFloatable(false);
-
-	addToolBar(Qt::LeftToolBarArea, toolbar);
-	
-	ThemeChanged();
-}
-
-void MainWindow::SetStyleSheet(enum Themes theme) {
-	switch (theme) {
-		case DARK: {
-			QFile f(":qdarkstyle/dark/darkstyle.qss");
-			if (!f.exists())
-				return; // fail
-			f.open(QFile::ReadOnly | QFile::Text);
-			QTextStream ts(&f);
-			setStyleSheet(ts.readAll());
-			break;
-		}
-		default:
-			setStyleSheet("");
-			break;
-	}
-}
-
-void MainWindow::ThemeChanged() {
-	switch (session.config.theme) {
-		case LIGHT: {
-#if APPLE
-			if (osx::DarkThemeAvailable())
-				osx::SetToLightTheme();
-			else
-				SetStyleSheet(LIGHT);
-#else
-			SetStyleSheet(LIGHT);
-#endif
-			break;
-		}
-		case DARK: {
-#if APPLE
-			if (osx::DarkThemeAvailable())
-				osx::SetToDarkTheme();
-			else
-				SetStyleSheet(DARK);
-#else
-			SetStyleSheet(DARK);
-#endif
-			break;
-		}
-		case OS: {
-#if APPLE
-			if (osx::DarkThemeAvailable())
-				osx::SetToAutoTheme();
-			else
-				SetStyleSheet(LIGHT);
-#elif defined(WIN32)
-			if (win32::DarkThemeAvailable()) {
-				if (win32::IsInDarkTheme()) {
-					SetStyleSheet(DARK);
-				} else {
-					SetStyleSheet(LIGHT);
-				}
-			}
-#else
-			SetStyleSheet(LIGHT);
-#endif
-			break;
-		}
-	}
-}
-
-void MainWindow::SetActivePage(QWidget* page) {
-	this->setCentralWidget(page);
-}
-
-void MainWindow::closeEvent(QCloseEvent* event) {
-	session.config.Save();
-	event->accept();
-}
-
-int main(int argc, char** argv) {
-	QApplication app(argc, argv);
-
-	session.config.Load();
-
-	MainWindow window;
-
-	window.resize(941, 750);
-	window.setWindowTitle("Weeaboo");
-	window.show();
-
-	return app.exec();
-}
+#include "window.h"
+#include "config.h"
+#include "anime.h"
+#if APPLE
+#include "sys/osx/dark_theme.h"
+#elif WIN32
+#include "sys/win32/dark_theme.h"
+#endif
+
+Session session = {
+	.config = Config()
+};
+
+/* note that this code was originally created for use in 
+   wxWidgets, but I thought the API was a little meh, so
+   I switched to Qt. */
+
+MainWindow::MainWindow(QWidget* parent) :
+           QMainWindow(parent) {
+	/* Menu Bar */
+	QAction* action;
+	QMenuBar* menubar = new QMenuBar(parent);
+	QMenu* menu = menubar->addMenu("&File");
+	QMenu* submenu = menu->addMenu("&Library folders");
+	action = new QAction("&Add new folder...");
+	submenu->addAction(action);
+	action = new QAction("&Scan available episodes");
+	menu->addAction(action);
+
+	menu->addSeparator();
+
+	action = menu->addAction("Play &next episode");
+	action = menu->addAction("Play &random episode");
+	menu->addSeparator();
+	action = menu->addAction("E&xit", qApp, &QApplication::quit);
+
+	menu = menubar->addMenu("&Services");
+	action = new QAction("Synchronize &list");
+
+	menu->addSeparator();
+
+	submenu = menu->addMenu("&AniList");
+	action = menu->addAction("Go to my &profile");
+	action = menu->addAction("Go to my &stats");
+
+	submenu = menu->addMenu("&Kitsu");
+	action = submenu->addAction("Go to my &feed");
+	action = submenu->addAction("Go to my &library");
+	action = submenu->addAction("Go to my &profile");
+
+	submenu = menu->addMenu("&MyAnimeList");
+	action = submenu->addAction("Go to my p&anel");
+	action = submenu->addAction("Go to my &profile");
+	action = submenu->addAction("Go to my &history");
+
+	menu = menubar->addMenu("&Tools");
+	submenu = menu->addMenu("&Export anime list");
+	action = submenu->addAction("Export as &Markdown...");
+	action = submenu->addAction("Export as MyAnimeList &XML...");
+
+	menu->addSeparator();
+
+	action = menu->addAction("Enable anime &recognition");
+	action->setCheckable(true);
+	action = menu->addAction("Enable auto &sharing");
+	action->setCheckable(true);
+	action = menu->addAction("Enable &auto synchronization");
+	action->setCheckable(true);
+
+	menu->addSeparator();
+
+	action = menu->addAction("&Settings");
+
+	setMenuBar(menubar);
+	
+	/* Side toolbar */
+	QToolBar* toolbar = new QToolBar(parent);
+	QActionGroup* tb_action_group = new QActionGroup(toolbar);
+
+	action = toolbar->addAction("Now Playing");
+	action->setActionGroup(tb_action_group);
+	action->setCheckable(true);
+
+	toolbar->addSeparator();
+
+	action = toolbar->addAction("Anime List", [this]() {
+		setCentralWidget(anime_list_page);
+	});
+	action->setActionGroup(tb_action_group);
+	action->setCheckable(true);
+	action->setChecked(true);
+	anime_list_page = new AnimeListPage(parent);
+	SetActivePage(anime_list_page);
+	action = toolbar->addAction("History");
+	action->setActionGroup(tb_action_group);
+	action->setCheckable(true);
+	action = toolbar->addAction("Statistics");
+	action->setActionGroup(tb_action_group);
+	action->setCheckable(true);
+
+	toolbar->addSeparator();
+
+	action = toolbar->addAction("Search");
+	action->setActionGroup(tb_action_group);
+	action->setCheckable(true);
+	action = toolbar->addAction("Seasons");
+	action->setActionGroup(tb_action_group);
+	action->setCheckable(true);
+	action = toolbar->addAction("Torrents");
+	action->setActionGroup(tb_action_group);
+	action->setCheckable(true);
+
+	toolbar->setMovable(false);
+	toolbar->setFloatable(false);
+
+	addToolBar(Qt::LeftToolBarArea, toolbar);
+	
+	ThemeChanged();
+}
+
+void MainWindow::SetStyleSheet(enum Themes theme) {
+	switch (theme) {
+		case DARK: {
+			QFile f(":qdarkstyle/dark/darkstyle.qss");
+			if (!f.exists())
+				return; // fail
+			f.open(QFile::ReadOnly | QFile::Text);
+			QTextStream ts(&f);
+			setStyleSheet(ts.readAll());
+			break;
+		}
+		default:
+			setStyleSheet("");
+			break;
+	}
+}
+
+void MainWindow::ThemeChanged() {
+	switch (session.config.theme) {
+		case LIGHT: {
+#if APPLE
+			if (osx::DarkThemeAvailable())
+				osx::SetToLightTheme();
+			else
+				SetStyleSheet(LIGHT);
+#else
+			SetStyleSheet(LIGHT);
+#endif
+			break;
+		}
+		case DARK: {
+#if APPLE
+			if (osx::DarkThemeAvailable())
+				osx::SetToDarkTheme();
+			else
+				SetStyleSheet(DARK);
+#else
+			SetStyleSheet(DARK);
+#endif
+			break;
+		}
+		case OS: {
+#if APPLE
+			if (osx::DarkThemeAvailable())
+				osx::SetToAutoTheme();
+			else
+				SetStyleSheet(LIGHT);
+#elif defined(WIN32)
+			if (win32::DarkThemeAvailable()) {
+				if (win32::IsInDarkTheme()) {
+					SetStyleSheet(DARK);
+				} else {
+					SetStyleSheet(LIGHT);
+				}
+			}
+#else
+			SetStyleSheet(LIGHT);
+#endif
+			break;
+		}
+	}
+}
+
+void MainWindow::SetActivePage(QWidget* page) {
+	this->setCentralWidget(page);
+}
+
+void MainWindow::closeEvent(QCloseEvent* event) {
+	session.config.Save();
+	event->accept();
+}
+
+int main(int argc, char** argv) {
+	QApplication app(argc, argv);
+
+	session.config.Load();
+
+	MainWindow window;
+
+	window.resize(941, 750);
+	window.setWindowTitle("Weeaboo");
+	window.show();
+
+	return app.exec();
+}
--- a/src/pages/now_playing.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/pages/now_playing.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,6 +1,6 @@
-#include "window.h"
-
-NowPlaying::NowPlaying(page_t* page, wxPanel* frame) {
-	page->panel = new wxPanel(frame, wxID_ANY, wxPoint(0, 0), wxSize(400, 600));
-	page->panel->Show(false);
-}
+#include "window.h"
+
+NowPlaying::NowPlaying(page_t* page, wxPanel* frame) {
+	page->panel = new wxPanel(frame, wxID_ANY, wxPoint(0, 0), wxSize(400, 600));
+	page->panel->Show(false);
+}
--- a/src/pages/statistics.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/pages/statistics.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,63 +1,63 @@
-#include "window.h"
-#include "ui_utils.h"
-#include <sstream>
-
-StatisticsTimer::StatisticsTimer(Statistics* caller) {
-	statistics = caller;
-}
-
-void StatisticsTimer::Notify() {
-	if (status.current_page == PAGE_STATISTICS)
-		statistics->UpdateStatistics();
-}
-
-Statistics::Statistics(page_t* page, wxPanel* frame) {
-	page->panel = new wxPanel(frame, wxID_ANY, wxPoint(0, 0), wxSize(400, 600));
-	page->panel->Show(false);
-	panel = new wxPanel(page->panel, wxID_ANY, wxPoint(12, 12), wxSize(376, 576));
-	anime_list = ((WeeabooFrame*)frame->GetParent())->GetAnimeList();
-
-	/* FIXME: this should be moved to a separate function, it's also used in information.cpp */
-	// wxWindow* parent, const char* title, const char* label, const char* data, int width, int height, int x = 0, int y = 0, int selectable = 0
-	anime_list_data = UiUtils::CreateTextParagraphWithLabels(panel, L"Anime list", L"Anime count:\nEpisode count:\nTime spent watching:\nTime to complete:\nAverage score:\nScore deviation:", L"", 376, 94);
-
-	UpdateStatistics(); // load in statistics as soon as possible
-	timer = new StatisticsTimer(this);
-	timer->Start(1000); // update statistics every second
-}
-
-std::string Statistics::MinutesToDateString(int minutes) {
-	/* NOTE: these duration_casts may not be needed... */
-	std::chrono::duration<int, std::ratio<60>> int_total_mins(minutes);
-	auto int_years = std::chrono::duration_cast<std::chrono::years>(int_total_mins);
-	auto int_months = std::chrono::duration_cast<std::chrono::months>(int_total_mins-int_years);
-	auto int_days = std::chrono::duration_cast<std::chrono::days>(int_total_mins-int_years-int_months);
-	auto int_hours = std::chrono::duration_cast<std::chrono::hours>(int_total_mins-int_years-int_months-int_days);
-	auto int_minutes = std::chrono::duration_cast<std::chrono::minutes>(int_total_mins-int_years-int_months-int_days-int_hours);
-	std::ostringstream return_stream;
-	if (int_years.count() > 0) {
-		return_stream << int_years.count() << " years ";
-	}
-	if (int_months.count() > 0) {
-		return_stream << int_months.count() << " months ";
-	}
-	if (int_days.count() > 0) {
-		return_stream << int_days.count() << " days ";
-	}
-	if (int_hours.count() > 0) {
-		return_stream << int_hours.count() << " hours ";
-	}
-	return_stream << int_minutes.count() << " minutes"; // return minutes anyway
-	return return_stream.str();
-}
-
-void Statistics::UpdateStatistics() {
-	wxString string = "";
-	string << anime_list->GetTotalAnimeAmount() << '\n';
-	string << anime_list->GetTotalEpisodeAmount() << '\n';
-	string << MinutesToDateString(anime_list->GetTotalWatchedAmount()) << '\n';
-	string << MinutesToDateString(anime_list->GetTotalPlannedAmount()) << '\n';
-	string << anime_list->GetAverageScore() << '\n';
-	string << anime_list->GetScoreDeviation() << '\n';
-	anime_list_data->SetLabel(string);
-}
+#include "window.h"
+#include "ui_utils.h"
+#include <sstream>
+
+StatisticsTimer::StatisticsTimer(Statistics* caller) {
+	statistics = caller;
+}
+
+void StatisticsTimer::Notify() {
+	if (status.current_page == PAGE_STATISTICS)
+		statistics->UpdateStatistics();
+}
+
+Statistics::Statistics(page_t* page, wxPanel* frame) {
+	page->panel = new wxPanel(frame, wxID_ANY, wxPoint(0, 0), wxSize(400, 600));
+	page->panel->Show(false);
+	panel = new wxPanel(page->panel, wxID_ANY, wxPoint(12, 12), wxSize(376, 576));
+	anime_list = ((WeeabooFrame*)frame->GetParent())->GetAnimeList();
+
+	/* FIXME: this should be moved to a separate function, it's also used in information.cpp */
+	// wxWindow* parent, const char* title, const char* label, const char* data, int width, int height, int x = 0, int y = 0, int selectable = 0
+	anime_list_data = UiUtils::CreateTextParagraphWithLabels(panel, L"Anime list", L"Anime count:\nEpisode count:\nTime spent watching:\nTime to complete:\nAverage score:\nScore deviation:", L"", 376, 94);
+
+	UpdateStatistics(); // load in statistics as soon as possible
+	timer = new StatisticsTimer(this);
+	timer->Start(1000); // update statistics every second
+}
+
+std::string Statistics::MinutesToDateString(int minutes) {
+	/* NOTE: these duration_casts may not be needed... */
+	std::chrono::duration<int, std::ratio<60>> int_total_mins(minutes);
+	auto int_years = std::chrono::duration_cast<std::chrono::years>(int_total_mins);
+	auto int_months = std::chrono::duration_cast<std::chrono::months>(int_total_mins-int_years);
+	auto int_days = std::chrono::duration_cast<std::chrono::days>(int_total_mins-int_years-int_months);
+	auto int_hours = std::chrono::duration_cast<std::chrono::hours>(int_total_mins-int_years-int_months-int_days);
+	auto int_minutes = std::chrono::duration_cast<std::chrono::minutes>(int_total_mins-int_years-int_months-int_days-int_hours);
+	std::ostringstream return_stream;
+	if (int_years.count() > 0) {
+		return_stream << int_years.count() << " years ";
+	}
+	if (int_months.count() > 0) {
+		return_stream << int_months.count() << " months ";
+	}
+	if (int_days.count() > 0) {
+		return_stream << int_days.count() << " days ";
+	}
+	if (int_hours.count() > 0) {
+		return_stream << int_hours.count() << " hours ";
+	}
+	return_stream << int_minutes.count() << " minutes"; // return minutes anyway
+	return return_stream.str();
+}
+
+void Statistics::UpdateStatistics() {
+	wxString string = "";
+	string << anime_list->GetTotalAnimeAmount() << '\n';
+	string << anime_list->GetTotalEpisodeAmount() << '\n';
+	string << MinutesToDateString(anime_list->GetTotalWatchedAmount()) << '\n';
+	string << MinutesToDateString(anime_list->GetTotalPlannedAmount()) << '\n';
+	string << anime_list->GetAverageScore() << '\n';
+	string << anime_list->GetScoreDeviation() << '\n';
+	anime_list_data->SetLabel(string);
+}
--- a/src/string_utils.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/string_utils.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,131 +1,131 @@
-/**
- * string_utils.cpp: Useful functions for manipulating strings
- *
- * Every function in here *should* have a working wstring equivalent.
-**/
-#include <vector>
-#include <string>
-#include <codecvt>
-#include <locale>
-#include "string_utils.h"
-
-/* It's actually pretty insane how the standard library still doesn't
-   have a function for this. Look at how simple this is. */
-std::string StringUtils::Implode(const std::vector<std::string>& vector,
-                                 const std::string& delimiter) {
-	std::string out = "";
-	for (int i = 0; i < vector.size(); i++) {
-		out.append(vector.at(i));
-		if (i < vector.size()-1)
-			out.append(delimiter);
-	}
-	return out;
-}
-
-std::wstring StringUtils::Implode(const std::vector<std::wstring>& vector,
-                                  const std::wstring& delimiter) {
-	std::wstring out = L"";
-	for (int i = 0; i < vector.size(); i++) {
-		out.append(vector.at(i));
-		if (i < vector.size()-1)
-			out.append(delimiter);
-	}
-	return out;
-}
-
-std::string StringUtils::WstrToUtf8(const std::wstring& string) {
-	std::wstring_convert<std::codecvt_utf8<wchar_t>> convert;
-	return convert.to_bytes(string);
-}
-
-std::wstring StringUtils::Utf8ToWstr(const std::string& string) {
-	std::wstring_convert<std::codecvt_utf8<wchar_t>> convert;
-	return convert.from_bytes(string);
-}
-
-std::string StringUtils::ReplaceAll(const std::string& string,
-                                    const std::string& find,
-									const std::string& replace) {
-    std::string result;
-    size_t pos, find_len = find.size(), from = 0;
-    while ((pos=string.find(find,from)) != std::string::npos) {
-        result.append(string, from, pos - from);
-        result.append(replace);
-        from = pos + find_len;
-    }
-    result.append(string, from, std::string::npos);
-    return result;
-}
-
-std::wstring StringUtils::ReplaceAll(const std::wstring& string,
-                                     const std::wstring& find,
-									 const std::wstring& replace) {
-    std::wstring result;
-    size_t pos, find_len = find.size(), from = 0;
-    while ((pos=string.find(find,from)) != std::wstring::npos) {
-        result.append(string, from, pos - from);
-        result.append(replace);
-        from = pos + find_len;
-    }
-    result.append(string, from, std::wstring::npos);
-    return result;
-}
-
-/* this function probably fucks your RAM but whatevs */
-std::string StringUtils::SanitizeLineEndings(const std::string& string) {
-	std::string result(string);
-	result = ReplaceAll(result, "\r\n",   "\n");
-	result = ReplaceAll(result, "<br>",   "\n");
-	result = ReplaceAll(result, "\n\n\n", "\n\n");
-	return result;
-}
-
-std::wstring StringUtils::SanitizeLineEndings(const std::wstring& string) {
-	std::wstring result(string);
-	result = ReplaceAll(result, L"\r\n",   L"\n");
-	result = ReplaceAll(result, L"<br>",   L"\n");
-	result = ReplaceAll(result, L"\n\n\n", L"\n\n");
-	return result;
-}
-
-std::string StringUtils::RemoveHtmlTags(const std::string& string) {
-	std::string html(string);
-    while (html.find("<") != std::string::npos)
-    {
-        auto startpos = html.find("<");
-        auto endpos = html.find(">") + 1;
-
-        if (endpos != std::string::npos)
-        {
-            html.erase(startpos, endpos - startpos);
-        }
-    }
-	return html;
-}
-
-std::wstring StringUtils::RemoveHtmlTags(const std::wstring& string) {
-	std::wstring html(string);
-    while (html.find(L"<") != std::wstring::npos)
-    {
-        auto startpos = html.find(L"<");
-        auto endpos = html.find(L">") + 1;
-
-        if (endpos != std::wstring::npos)
-        {
-            html.erase(startpos, endpos - startpos);
-        }
-    }
-	return html;
-}
-
-std::string StringUtils::TextifySynopsis(const std::string& string) {
-	std::string result = SanitizeLineEndings(string);
-	result = RemoveHtmlTags(string);
-	return result;
-}
-
-std::wstring StringUtils::TextifySynopsis(const std::wstring& string) {
-	std::wstring result = SanitizeLineEndings(string);
-	result = RemoveHtmlTags(string);
-	return result;
-}
+/**
+ * string_utils.cpp: Useful functions for manipulating strings
+ *
+ * Every function in here *should* have a working wstring equivalent.
+**/
+#include <vector>
+#include <string>
+#include <codecvt>
+#include <locale>
+#include "string_utils.h"
+
+/* It's actually pretty insane how the standard library still doesn't
+   have a function for this. Look at how simple this is. */
+std::string StringUtils::Implode(const std::vector<std::string>& vector,
+                                 const std::string& delimiter) {
+	std::string out = "";
+	for (int i = 0; i < vector.size(); i++) {
+		out.append(vector.at(i));
+		if (i < vector.size()-1)
+			out.append(delimiter);
+	}
+	return out;
+}
+
+std::wstring StringUtils::Implode(const std::vector<std::wstring>& vector,
+                                  const std::wstring& delimiter) {
+	std::wstring out = L"";
+	for (int i = 0; i < vector.size(); i++) {
+		out.append(vector.at(i));
+		if (i < vector.size()-1)
+			out.append(delimiter);
+	}
+	return out;
+}
+
+std::string StringUtils::WstrToUtf8(const std::wstring& string) {
+	std::wstring_convert<std::codecvt_utf8<wchar_t>> convert;
+	return convert.to_bytes(string);
+}
+
+std::wstring StringUtils::Utf8ToWstr(const std::string& string) {
+	std::wstring_convert<std::codecvt_utf8<wchar_t>> convert;
+	return convert.from_bytes(string);
+}
+
+std::string StringUtils::ReplaceAll(const std::string& string,
+                                    const std::string& find,
+									const std::string& replace) {
+    std::string result;
+    size_t pos, find_len = find.size(), from = 0;
+    while ((pos=string.find(find,from)) != std::string::npos) {
+        result.append(string, from, pos - from);
+        result.append(replace);
+        from = pos + find_len;
+    }
+    result.append(string, from, std::string::npos);
+    return result;
+}
+
+std::wstring StringUtils::ReplaceAll(const std::wstring& string,
+                                     const std::wstring& find,
+									 const std::wstring& replace) {
+    std::wstring result;
+    size_t pos, find_len = find.size(), from = 0;
+    while ((pos=string.find(find,from)) != std::wstring::npos) {
+        result.append(string, from, pos - from);
+        result.append(replace);
+        from = pos + find_len;
+    }
+    result.append(string, from, std::wstring::npos);
+    return result;
+}
+
+/* this function probably fucks your RAM but whatevs */
+std::string StringUtils::SanitizeLineEndings(const std::string& string) {
+	std::string result(string);
+	result = ReplaceAll(result, "\r\n",   "\n");
+	result = ReplaceAll(result, "<br>",   "\n");
+	result = ReplaceAll(result, "\n\n\n", "\n\n");
+	return result;
+}
+
+std::wstring StringUtils::SanitizeLineEndings(const std::wstring& string) {
+	std::wstring result(string);
+	result = ReplaceAll(result, L"\r\n",   L"\n");
+	result = ReplaceAll(result, L"<br>",   L"\n");
+	result = ReplaceAll(result, L"\n\n\n", L"\n\n");
+	return result;
+}
+
+std::string StringUtils::RemoveHtmlTags(const std::string& string) {
+	std::string html(string);
+    while (html.find("<") != std::string::npos)
+    {
+        auto startpos = html.find("<");
+        auto endpos = html.find(">") + 1;
+
+        if (endpos != std::string::npos)
+        {
+            html.erase(startpos, endpos - startpos);
+        }
+    }
+	return html;
+}
+
+std::wstring StringUtils::RemoveHtmlTags(const std::wstring& string) {
+	std::wstring html(string);
+    while (html.find(L"<") != std::wstring::npos)
+    {
+        auto startpos = html.find(L"<");
+        auto endpos = html.find(L">") + 1;
+
+        if (endpos != std::wstring::npos)
+        {
+            html.erase(startpos, endpos - startpos);
+        }
+    }
+	return html;
+}
+
+std::string StringUtils::TextifySynopsis(const std::string& string) {
+	std::string result = SanitizeLineEndings(string);
+	result = RemoveHtmlTags(string);
+	return result;
+}
+
+std::wstring StringUtils::TextifySynopsis(const std::wstring& string) {
+	std::wstring result = SanitizeLineEndings(string);
+	result = RemoveHtmlTags(string);
+	return result;
+}
--- a/src/sys/osx/dark_theme.mm	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/sys/osx/dark_theme.mm	Sat Aug 12 03:16:26 2023 -0400
@@ -1,44 +1,44 @@
-#include "sys/osx/dark_theme.h"
-#import <Cocoa/Cocoa.h>
-
-bool osx::DarkThemeAvailable()
-{
-	return (__builtin_available(macOS 10.14, *)) ? true : false;
-}
-
-bool osx::IsInDarkTheme()
-{
-    if (__builtin_available(macOS 10.14, *))
-    {
-        auto appearance = [NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:
-                @[ NSAppearanceNameAqua, NSAppearanceNameDarkAqua ]];
-        return [appearance isEqualToString:NSAppearanceNameDarkAqua];
-    }
-    return false;
-}
-
-void osx::SetToDarkTheme()
-{
-   // https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
-   if (__builtin_available(macOS 10.14, *))
-   {
-        [NSApp setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]];
-   }
-}
-
-void osx::SetToLightTheme()
-{
-    // https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
-    if (__builtin_available(macOS 10.14, *))
-    {
-        [NSApp setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameAqua]];
-    }
-}
-
-void osx::SetToAutoTheme()
-{
-    if (__builtin_available(macOS 10.14, *))
-    {
-        [NSApp setAppearance:nil];
-    }
-}
+#include "sys/osx/dark_theme.h"
+#import <Cocoa/Cocoa.h>
+
+bool osx::DarkThemeAvailable()
+{
+	return (__builtin_available(macOS 10.14, *)) ? true : false;
+}
+
+bool osx::IsInDarkTheme()
+{
+    if (__builtin_available(macOS 10.14, *))
+    {
+        auto appearance = [NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:
+                @[ NSAppearanceNameAqua, NSAppearanceNameDarkAqua ]];
+        return [appearance isEqualToString:NSAppearanceNameDarkAqua];
+    }
+    return false;
+}
+
+void osx::SetToDarkTheme()
+{
+   // https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
+   if (__builtin_available(macOS 10.14, *))
+   {
+        [NSApp setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]];
+   }
+}
+
+void osx::SetToLightTheme()
+{
+    // https://stackoverflow.com/questions/55925862/how-can-i-set-my-os-x-application-theme-in-code
+    if (__builtin_available(macOS 10.14, *))
+    {
+        [NSApp setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameAqua]];
+    }
+}
+
+void osx::SetToAutoTheme()
+{
+    if (__builtin_available(macOS 10.14, *))
+    {
+        [NSApp setAppearance:nil];
+    }
+}
--- a/src/sys/win32/dark_theme.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/sys/win32/dark_theme.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,26 +1,26 @@
-#include <QSettings>
-#include <QOperatingSystemVersion>
-#include "sys/win32/dark_theme.h"
-bool win32::DarkThemeAvailable()
-{
-    // dark mode supported Windows 10 1809 10.0.17763 onward
-    // https://stackoverflow.com/questions/53501268/win10-dark-theme-how-to-use-in-winapi
-    if ( QOperatingSystemVersion::current().majorVersion() == 10 )
-    {
-        return QOperatingSystemVersion::current().microVersion() >= 17763;
-    }
-    else if ( QOperatingSystemVersion::current().majorVersion() > 10 )
-    {
-        return true;
-    }
-    else
-    {
-        return false;
-    }
-}
-
-bool win32::IsInDarkTheme()
-{
-    QSettings settings( "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", QSettings::NativeFormat );
-    return settings.value( "AppsUseLightTheme", 1 ).toInt() == 0;
-}
+#include <QSettings>
+#include <QOperatingSystemVersion>
+#include "sys/win32/dark_theme.h"
+bool win32::DarkThemeAvailable()
+{
+    // dark mode supported Windows 10 1809 10.0.17763 onward
+    // https://stackoverflow.com/questions/53501268/win10-dark-theme-how-to-use-in-winapi
+    if ( QOperatingSystemVersion::current().majorVersion() == 10 )
+    {
+        return QOperatingSystemVersion::current().microVersion() >= 17763;
+    }
+    else if ( QOperatingSystemVersion::current().majorVersion() > 10 )
+    {
+        return true;
+    }
+    else
+    {
+        return false;
+    }
+}
+
+bool win32::IsInDarkTheme()
+{
+    QSettings settings( "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", QSettings::NativeFormat );
+    return settings.value( "AppsUseLightTheme", 1 ).toInt() == 0;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/time.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -0,0 +1,64 @@
+#include "time_utils.h"
+#include <string>
+#include <cstdint>
+#include <cmath>
+#include <ctime>
+#include <cassert>
+
+namespace Time {
+
+Duration::Duration(int64_t l) {
+	length = l;
+}
+
+std::string Duration::AsRelativeString() {
+	std::string result;
+	
+	auto get = [](int64_t val, const std::string& s, const std::string& p) {
+		return std::to_string(val) + " " + (val == 1 ? s : p);
+	};
+	
+	if (InSeconds() < 60)
+		result = get(InSeconds(), "second", "seconds");
+	else if (InMinutes() < 60)
+		result = get(InMinutes(), "minute", "minutes");
+	else if (InHours() < 24)
+		result = get(InHours(), "hour", "hours");
+	else if (InDays() < 28)
+		result = get(InDays(), "day", "days");
+	else if (InDays() < 365)
+		result = get(InDays()/30, "month", "months");
+	else
+		result = get(InDays()/365, "year", "years");
+
+	if (length < 0)
+		result = "In " + result;
+	else
+		result += " ago";
+
+	return result;
+}
+
+int64_t Duration::InSeconds() {
+	return length;
+}
+
+int64_t Duration::InMinutes() {
+	return std::llround((double)length / 60.0);
+}
+
+int64_t Duration::InHours() {
+	return std::llround((double)length / 3600.0);
+}
+
+int64_t Duration::InDays() {
+	return std::llround((double)length / 86400.0);
+}
+
+int64_t GetSystemTime() {
+	assert(sizeof(int64_t) >= sizeof(time_t));
+	time_t t = std::time(nullptr);
+	return *reinterpret_cast<int64_t*>(&t);
+}
+
+}
\ No newline at end of file
--- a/src/ui_utils.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/ui_utils.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,32 +1,88 @@
-#include "window.h"
-#include "ui_utils.h"
-
-void UiUtils::CreateTextHeader(wxWindow* parent, const wchar_t* title, int width, int x, int y) {
-	wxStaticText* static_text_title  = new wxStaticText(parent, wxID_ANY, title, wxPoint(x, y), wxSize(width, 15), wxALIGN_LEFT);
-	wxFont font = static_text_title->GetFont();
-	font.SetWeight(wxFONTWEIGHT_BOLD);
-	static_text_title->SetFont(font);
-	//wxStaticLine* line = new wxStaticLine(parent, wxID_ANY, wxPoint(x,y+15), wxSize(width, 2), wxLI_HORIZONTAL);
-	wxClientDC dc(parent);
-	dc.DrawRectangle(x, y+15, width, 2);
-}
-
-wxStaticText* UiUtils::CreateTextParagraph(wxWindow* parent, const wchar_t* title, const wchar_t* data, int width, int height, int x, int y) {
-	CreateTextHeader(parent, title, width, x, y);
-	return new wxStaticText(parent, wxID_ANY, data, wxPoint(x+23, y+30), wxSize(width-23, height), wxALIGN_LEFT);
-}
-
-wxStaticText* UiUtils::CreateTextParagraphWithLabels(wxWindow* parent, const wchar_t* title, const wchar_t* label, const wchar_t* data, int width, int height, int x, int y) {
-	CreateTextHeader(parent, title, width, x, y);
-	new wxStaticText(parent, wxID_ANY, label, wxPoint(x+23, y+30), wxSize(width-23, height), wxALIGN_LEFT);
-	return new wxStaticText(parent, wxID_ANY, data, wxPoint(x+157, y+30), wxSize(width-157, height), wxALIGN_LEFT);
-}
-
-/* As far as I can tell, this is identical to the way Taiga implements it.
-   Kind of cool, I didn't even look into the source code for it :p */
-wxTextCtrl* UiUtils::CreateSelectableTextParagraph(wxWindow* parent, const wchar_t* title, const wchar_t* data, int width, int height, int x, int y) {
-	CreateTextHeader(parent, title, width, x, y);
-	wxTextCtrl* textctrl = new wxTextCtrl(parent, wxID_ANY, "", wxPoint(x+23, y+30), wxSize(width-23, height), wxTE_LEFT | wxBORDER_NONE | wxTE_BESTWRAP | wxTE_READONLY | wxTE_MULTILINE | wxTE_NO_VSCROLL);
-	(*textctrl) << data;
-	return textctrl;
-}
+#include "window.h"
+#include "ui_utils.h"
+#ifdef MACOSX
+#include "sys/osx/dark_theme.h"
+#else
+#include "sys/win32/dark_theme.h"
+#endif
+
+bool UiUtils::IsInDarkMode() {
+	if (session.config.theme != OS)
+		return (session.config.theme == DARK);
+#ifdef MACOSX
+	if (osx::DarkThemeAvailable()) {
+		if (osx::IsInDarkTheme()) {
+			return true;
+		} else {
+			return false;
+		}
+	}
+#elif defined(WIN32)
+	if (win32::DarkThemeAvailable()) {
+		if (win32::IsInDarkTheme()) {
+			return true;
+		} else {
+			return false;
+		}
+	}
+#endif
+	return (session.config.theme == DARK);
+}
+
+void UiUtils::CreateTextHeader(QWidget* parent, QString title, QPoint point, QSize size) {
+	QLabel* static_text_title  = new QLabel(title, parent);
+	static_text_title->setTextFormat(Qt::PlainText);
+	static_text_title->setStyleSheet("font-weight: bold");
+	static_text_title->move(point.x(), point.y());
+	static_text_title->resize(size.width(), 16);
+
+	QFrame* static_text_line = new QFrame(parent);
+	static_text_line->setFrameShape(QFrame::HLine);
+	static_text_line->setFrameShadow(QFrame::Sunken);
+	static_text_line->resize(size.width(), 2);
+	static_text_line->move(point.x(), point.y()+18);
+}
+
+QPlainTextEdit* UiUtils::CreateTextParagraph(QWidget* parent, QString title, QString data, QPoint point, QSize size) {
+	CreateTextHeader(parent, title, point, size);
+
+	QPlainTextEdit* paragraph = new QPlainTextEdit(data, parent);
+	paragraph->setTextInteractionFlags(Qt::NoTextInteraction);
+	paragraph->setWordWrapMode(QTextOption::NoWrap);
+	paragraph->setFrameShape(QFrame::NoFrame);
+	paragraph->move(point.x()+12, point.y()+32);
+	paragraph->resize(size.width()-12, size.height()-32);
+	return paragraph;
+}
+
+QPlainTextEdit* UiUtils::CreateTextParagraphWithLabels(QWidget* parent, QString title, QString label, QString data, QPoint point, QSize size) {
+	CreateTextHeader(parent, title, point, size);
+
+	QPlainTextEdit* label_t = new QPlainTextEdit(label, parent);
+	label_t->setTextInteractionFlags(Qt::NoTextInteraction);
+	label_t->setWordWrapMode(QTextOption::NoWrap);
+	label_t->setFrameShape(QFrame::NoFrame);
+	label_t->move(point.x()+12, point.y()+32);
+	label_t->resize(90, size.height()-32);
+
+	QPlainTextEdit* paragraph = new QPlainTextEdit(data, parent);
+	paragraph->setTextInteractionFlags(Qt::NoTextInteraction);
+	paragraph->setWordWrapMode(QTextOption::NoWrap);
+	paragraph->setFrameShape(QFrame::NoFrame);
+	paragraph->move(point.x()+102, point.y()+32);
+	paragraph->resize(size.width()-102, size.height()-32);
+	return paragraph;
+}
+
+/* As far as I can tell, this is identical to the way Taiga implements it.
+   Kind of cool, I didn't even look into the source code for it :p */
+QPlainTextEdit* UiUtils::CreateSelectableTextParagraph(QWidget* parent, QString title, QString data, QPoint point, QSize size) {
+	CreateTextHeader(parent, title, point, size);
+
+	QPlainTextEdit* text_edit = new QPlainTextEdit(data, parent);
+	text_edit->setReadOnly(true);
+	text_edit->setFrameShape(QFrame::NoFrame);
+	text_edit->move(point.x()+12, point.y()+32);
+	text_edit->resize(size.width()-12, size.height()-32);
+	return text_edit;
+}
--- a/src/window.cpp	Tue Aug 08 19:49:15 2023 -0400
+++ b/src/window.cpp	Sat Aug 12 03:16:26 2023 -0400
@@ -1,158 +1,158 @@
-#include "window.h"
-#include <curl/curl.h>
-#include "page.h"
-#include "config.h"
-#include "anime.h"
-#include "statistics.h"
-#include "now_playing.h"
-#include "16x16/document-list.png.h"
-#include "16x16/film.png.h"
-#include "16x16/chart.png.h"
-#include "16x16/clock-history-frame.png.h"
-#include "16x16/magnifier.png.h"
-#include "16x16/calendar.png.h"
-#include "16x16/feed.png.h"
-#include "24x24/arrow-circle-double-135.png.h"
-#include "24x24/folder-open.png.h"
-#include "24x24/gear.png.h"
-
-Config Weeaboo::config = Config();
-
-WeeabooFrame::WeeabooFrame(const wxString& title, const wxPoint& pos, const wxSize& size)
-					: wxFrame(NULL, wxID_ANY, title, pos, size) {
-	/* ---- Menu Bar ---- */
-	wxMenu* library_folders_submenu = new wxMenu;
-	library_folders_submenu->Append(ID_AddLibraryFolder, "&Add library folder");
-	library_folders_submenu->Append(ID_ScanLibraryFolders, "&Rescan library folders");
-
-	wxMenu* file_menu = new wxMenu;
-	file_menu->AppendSubMenu(library_folders_submenu, "&Library folders");
-	file_menu->Append(ID_SyncAnimeList, "&Sync anime list\tCtrl+S");
-	file_menu->AppendSeparator();
-	file_menu->Append(ID_PlayNextEpisode, "Play &next episode\tCtrl+N");
-	file_menu->Append(ID_PlayRandomEpisode, "Play &random episode\tCtrl+R");
-	file_menu->AppendSeparator();
-	file_menu->Append(wxID_EXIT);
-
-	wxMenu* help_menu = new wxMenu;
-	help_menu->Append(wxID_ABOUT);
-
-	wxMenuBar* menu_bar = new wxMenuBar;
-	menu_bar->Append(file_menu, "&File");
-	menu_bar->Append(help_menu, "&Help");
-
-	SetMenuBar(menu_bar);
-
-	/* Toolbar */
-	wxToolBar* top_toolbar = CreateToolBar();
-	top_toolbar->SetToolBitmapSize(wxSize(24,24));
-	top_toolbar->AddTool(ID_ToolbarSync, wxT("Sync"), wxBITMAP_PNG_FROM_DATA(arrow_circle_double_135));
-	top_toolbar->Realize();
-
-	/* ---- Sidebar ---- */
-	/* This first panel is only for the sizer... */
-	wxPanel* left_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(140, 600), wxTAB_TRAVERSAL, wxPanelNameStr);
-	wxPanel* left_panel_inside = new wxPanel(left_panel, wxID_ANY, wxPoint(6, 6), wxSize(128, 588), wxTAB_TRAVERSAL);
-	wxToolBar* left_toolbar = new wxToolBar(left_panel_inside, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTB_FLAT | wxTB_VERTICAL | wxTB_HORZ_TEXT | wxTB_NODIVIDER);
-	left_toolbar->SetMargins(6, 6);
-	left_toolbar->SetToolBitmapSize(wxSize(16,16));
-	left_toolbar->AddRadioTool(ID_NowPlaying, "Now Playing", wxBITMAP_PNG_FROM_DATA(film));
-	left_toolbar->AddRadioTool(ID_AnimeList,  "Anime List",  wxBITMAP_PNG_FROM_DATA(document_list));
-	left_toolbar->AddRadioTool(ID_History,    "History",     wxBITMAP_PNG_FROM_DATA(clock_history_frame));
-	left_toolbar->AddRadioTool(ID_Statistics, "Statistics",  wxBITMAP_PNG_FROM_DATA(chart));
-	left_toolbar->AddRadioTool(ID_Search,     "Search",      wxBITMAP_PNG_FROM_DATA(magnifier));
-	left_toolbar->AddRadioTool(ID_Seasons,    "Seasons",     wxBITMAP_PNG_FROM_DATA(calendar));
-	left_toolbar->AddRadioTool(ID_Torrents,   "Torrents",    wxBITMAP_PNG_FROM_DATA(feed));
-
-	/* ---- Initialize our pages ---- */
-	wxPanel* right_panel = new wxPanel(this, wxID_ANY, wxPoint(140, 0), wxSize(460, 600), wxTAB_TRAVERSAL, wxPanelNameStr);
-	now_playing = new NowPlaying(&pages[PAGE_NOW_PLAYING], right_panel);
-	anime_list = new AnimeListPage(&pages[PAGE_ANIME_LIST], right_panel);
-	anime_list->SyncAnimeList();
-	anime_list->LoadAnimeList(this);
-	statistics = new Statistics(&pages[PAGE_STATISTICS], right_panel);
-	
-	status.current_page = PAGE_ANIME_LIST; // The below function depends on this value being set?
-	set_page(PAGE_ANIME_LIST);
-	left_toolbar->ToggleTool(ID_AnimeList, true);
-	left_toolbar->Realize();
-
-	wxSizer* sizer = new wxBoxSizer(wxHORIZONTAL);
-	sizer->Add(left_panel, 0, wxEXPAND, 10);
-	sizer->Add(right_panel, 1, wxEXPAND, 10);
-	sizer->SetMinSize(600, 600);
-	this->SetSizer(sizer);
-	sizer->SetSizeHints(this);
-
-}
-
-bool Weeaboo::OnInit() {
-	config.Load();
-	if (curl_global_init(CURL_GLOBAL_DEFAULT) != 0) {
-		wxMessageBox("libcurl failed to initialize!",
-				 "Error", wxOK | wxICON_ERROR);
-	}
-	wxSystemOptions::SetOption("msw.remap",
-		wxSystemOptions::HasOption("msw.remap")
-						 ? wxSystemOptions::GetOptionInt("msw.remap")
-						 : wxDisplayDepth() <= 8 ? 1 : 2
-	);
-	wxImage::AddHandler(new wxPNGHandler);
-	frame = new WeeabooFrame("Weeaboo", wxPoint(50, 50), wxSize(450, 340));
-	frame->Show(true);
-	return true;
-}
-
-#define TOOLBAR_HANDLER(name, page) \
-void WeeabooFrame::name(wxCommandEvent& event) { \
-	set_page(page); \
-}
-TOOLBAR_HANDLER(OnNowPlaying, PAGE_NOW_PLAYING)
-TOOLBAR_HANDLER(OnAnimeList,  PAGE_ANIME_LIST)
-TOOLBAR_HANDLER(OnHistory,    PAGE_HISTORY)
-TOOLBAR_HANDLER(OnStatistics, PAGE_STATISTICS)
-TOOLBAR_HANDLER(OnSearch,     PAGE_SEARCH)
-TOOLBAR_HANDLER(OnSeasons,    PAGE_SEASONS)
-TOOLBAR_HANDLER(OnTorrents,   PAGE_TORRENTS)
-#undef TOOLBAR_HANDLER
-
-void WeeabooFrame::OnClose(wxCloseEvent& event) {
-	Weeaboo::config.Save();
-	curl_global_cleanup();
-	delete anime_list;
-	event.Skip();
-}
-
-void WeeabooFrame::OnExit(wxCommandEvent& event) {
-	Close(true);
-}
-
-void WeeabooFrame::OnAbout(wxCommandEvent& event) {
-	wxMessageBox("To be written",
-				 "About Weeaboo", wxOK | wxICON_INFORMATION);
-}
-
-void WeeabooFrame::OnAddFolder(wxCommandEvent& event) {
-	wxLogMessage("OnAddFolder");
-}
-
-void WeeabooFrame::OnScanFolders(wxCommandEvent& event) {
-	wxLogMessage("OnScanFolders");
-}
-
-void WeeabooFrame::OnNextEpisode(wxCommandEvent& event) {
-	wxLogMessage("OnNextEpisode");
-}
-
-void WeeabooFrame::OnSyncList(wxCommandEvent& event) {
-	anime_list->SyncAnimeList();
-	anime_list->LoadAnimeList(this);
-}
-
-void WeeabooFrame::OnRandomEpisode(wxCommandEvent& event) {
-	wxLogMessage("OnRandomEpisode");
-}
-
-AnimeListPage* WeeabooFrame::GetAnimeList() {
-	return anime_list;
-}
+#include "window.h"
+#include <curl/curl.h>
+#include "page.h"
+#include "config.h"
+#include "anime.h"
+#include "statistics.h"
+#include "now_playing.h"
+#include "16x16/document-list.png.h"
+#include "16x16/film.png.h"
+#include "16x16/chart.png.h"
+#include "16x16/clock-history-frame.png.h"
+#include "16x16/magnifier.png.h"
+#include "16x16/calendar.png.h"
+#include "16x16/feed.png.h"
+#include "24x24/arrow-circle-double-135.png.h"
+#include "24x24/folder-open.png.h"
+#include "24x24/gear.png.h"
+
+Config Weeaboo::config = Config();
+
+WeeabooFrame::WeeabooFrame(const wxString& title, const wxPoint& pos, const wxSize& size)
+					: wxFrame(NULL, wxID_ANY, title, pos, size) {
+	/* ---- Menu Bar ---- */
+	wxMenu* library_folders_submenu = new wxMenu;
+	library_folders_submenu->Append(ID_AddLibraryFolder, "&Add library folder");
+	library_folders_submenu->Append(ID_ScanLibraryFolders, "&Rescan library folders");
+
+	wxMenu* file_menu = new wxMenu;
+	file_menu->AppendSubMenu(library_folders_submenu, "&Library folders");
+	file_menu->Append(ID_SyncAnimeList, "&Sync anime list\tCtrl+S");
+	file_menu->AppendSeparator();
+	file_menu->Append(ID_PlayNextEpisode, "Play &next episode\tCtrl+N");
+	file_menu->Append(ID_PlayRandomEpisode, "Play &random episode\tCtrl+R");
+	file_menu->AppendSeparator();
+	file_menu->Append(wxID_EXIT);
+
+	wxMenu* help_menu = new wxMenu;
+	help_menu->Append(wxID_ABOUT);
+
+	wxMenuBar* menu_bar = new wxMenuBar;
+	menu_bar->Append(file_menu, "&File");
+	menu_bar->Append(help_menu, "&Help");
+
+	SetMenuBar(menu_bar);
+
+	/* Toolbar */
+	wxToolBar* top_toolbar = CreateToolBar();
+	top_toolbar->SetToolBitmapSize(wxSize(24,24));
+	top_toolbar->AddTool(ID_ToolbarSync, wxT("Sync"), wxBITMAP_PNG_FROM_DATA(arrow_circle_double_135));
+	top_toolbar->Realize();
+
+	/* ---- Sidebar ---- */
+	/* This first panel is only for the sizer... */
+	wxPanel* left_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(140, 600), wxTAB_TRAVERSAL, wxPanelNameStr);
+	wxPanel* left_panel_inside = new wxPanel(left_panel, wxID_ANY, wxPoint(6, 6), wxSize(128, 588), wxTAB_TRAVERSAL);
+	wxToolBar* left_toolbar = new wxToolBar(left_panel_inside, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTB_FLAT | wxTB_VERTICAL | wxTB_HORZ_TEXT | wxTB_NODIVIDER);
+	left_toolbar->SetMargins(6, 6);
+	left_toolbar->SetToolBitmapSize(wxSize(16,16));
+	left_toolbar->AddRadioTool(ID_NowPlaying, "Now Playing", wxBITMAP_PNG_FROM_DATA(film));
+	left_toolbar->AddRadioTool(ID_AnimeList,  "Anime List",  wxBITMAP_PNG_FROM_DATA(document_list));
+	left_toolbar->AddRadioTool(ID_History,    "History",     wxBITMAP_PNG_FROM_DATA(clock_history_frame));
+	left_toolbar->AddRadioTool(ID_Statistics, "Statistics",  wxBITMAP_PNG_FROM_DATA(chart));
+	left_toolbar->AddRadioTool(ID_Search,     "Search",      wxBITMAP_PNG_FROM_DATA(magnifier));
+	left_toolbar->AddRadioTool(ID_Seasons,    "Seasons",     wxBITMAP_PNG_FROM_DATA(calendar));
+	left_toolbar->AddRadioTool(ID_Torrents,   "Torrents",    wxBITMAP_PNG_FROM_DATA(feed));
+
+	/* ---- Initialize our pages ---- */
+	wxPanel* right_panel = new wxPanel(this, wxID_ANY, wxPoint(140, 0), wxSize(460, 600), wxTAB_TRAVERSAL, wxPanelNameStr);
+	now_playing = new NowPlaying(&pages[PAGE_NOW_PLAYING], right_panel);
+	anime_list = new AnimeListPage(&pages[PAGE_ANIME_LIST], right_panel);
+	anime_list->SyncAnimeList();
+	anime_list->LoadAnimeList(this);
+	statistics = new Statistics(&pages[PAGE_STATISTICS], right_panel);
+	
+	status.current_page = PAGE_ANIME_LIST; // The below function depends on this value being set?
+	set_page(PAGE_ANIME_LIST);
+	left_toolbar->ToggleTool(ID_AnimeList, true);
+	left_toolbar->Realize();
+
+	wxSizer* sizer = new wxBoxSizer(wxHORIZONTAL);
+	sizer->Add(left_panel, 0, wxEXPAND, 10);
+	sizer->Add(right_panel, 1, wxEXPAND, 10);
+	sizer->SetMinSize(600, 600);
+	this->SetSizer(sizer);
+	sizer->SetSizeHints(this);
+
+}
+
+bool Weeaboo::OnInit() {
+	config.Load();
+	if (curl_global_init(CURL_GLOBAL_DEFAULT) != 0) {
+		wxMessageBox("libcurl failed to initialize!",
+				 "Error", wxOK | wxICON_ERROR);
+	}
+	wxSystemOptions::SetOption("msw.remap",
+		wxSystemOptions::HasOption("msw.remap")
+						 ? wxSystemOptions::GetOptionInt("msw.remap")
+						 : wxDisplayDepth() <= 8 ? 1 : 2
+	);
+	wxImage::AddHandler(new wxPNGHandler);
+	frame = new WeeabooFrame("Weeaboo", wxPoint(50, 50), wxSize(450, 340));
+	frame->Show(true);
+	return true;
+}
+
+#define TOOLBAR_HANDLER(name, page) \
+void WeeabooFrame::name(wxCommandEvent& event) { \
+	set_page(page); \
+}
+TOOLBAR_HANDLER(OnNowPlaying, PAGE_NOW_PLAYING)
+TOOLBAR_HANDLER(OnAnimeList,  PAGE_ANIME_LIST)
+TOOLBAR_HANDLER(OnHistory,    PAGE_HISTORY)
+TOOLBAR_HANDLER(OnStatistics, PAGE_STATISTICS)
+TOOLBAR_HANDLER(OnSearch,     PAGE_SEARCH)
+TOOLBAR_HANDLER(OnSeasons,    PAGE_SEASONS)
+TOOLBAR_HANDLER(OnTorrents,   PAGE_TORRENTS)
+#undef TOOLBAR_HANDLER
+
+void WeeabooFrame::OnClose(wxCloseEvent& event) {
+	Weeaboo::config.Save();
+	curl_global_cleanup();
+	delete anime_list;
+	event.Skip();
+}
+
+void WeeabooFrame::OnExit(wxCommandEvent& event) {
+	Close(true);
+}
+
+void WeeabooFrame::OnAbout(wxCommandEvent& event) {
+	wxMessageBox("To be written",
+				 "About Weeaboo", wxOK | wxICON_INFORMATION);
+}
+
+void WeeabooFrame::OnAddFolder(wxCommandEvent& event) {
+	wxLogMessage("OnAddFolder");
+}
+
+void WeeabooFrame::OnScanFolders(wxCommandEvent& event) {
+	wxLogMessage("OnScanFolders");
+}
+
+void WeeabooFrame::OnNextEpisode(wxCommandEvent& event) {
+	wxLogMessage("OnNextEpisode");
+}
+
+void WeeabooFrame::OnSyncList(wxCommandEvent& event) {
+	anime_list->SyncAnimeList();
+	anime_list->LoadAnimeList(this);
+}
+
+void WeeabooFrame::OnRandomEpisode(wxCommandEvent& event) {
+	wxLogMessage("OnRandomEpisode");
+}
+
+AnimeListPage* WeeabooFrame::GetAnimeList() {
+	return anime_list;
+}