XT 2.2.0: ability to restore posts from external archives

This commit is contained in:
Tuxedo Takodachi 2023-10-27 16:16:19 +02:00
parent f5ba6a0941
commit b047925392
19 changed files with 26022 additions and 25561 deletions

View File

@ -3,8 +3,10 @@
4chan XT uses a different user script namespace, so to migrate you need to export settings from 4chan X, and import them
in XT.
### Unreleased
### XT v2.2.0 (2023-10-27)
- Added ability to restore deleted posts from an external archive. This can be found in the drop down menu at the top
right. [#8](https://github.com/TuxedoTako/4chan-xt/issues/8)
- Also minify css in the minified build.
### XT v2.1.4 (2023-09-02)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "4chan XT",
"version": "XT 2.1.4",
"version": "XT 2.2.0",
"manifest_version": 2,
"description": "4chan XT is a script that adds various features to anonymous imageboards.",
"icons": {

File diff suppressed because it is too large Load Diff

176
src/Archive/Parse.ts Normal file
View File

@ -0,0 +1,176 @@
import Redirect from './Redirect';
import { isEscaped } from '../globals/jsx';
import Main from '../main/Main';
import ImageHost from '../Images/ImageHost';
import Board from '../classes/Board';
import Fetcher from '../classes/Fetcher';
import Post, { type File } from '../classes/Post';
import Thread from '../classes/Thread';
import { E, g } from '../globals/globals';
import { dict } from '../platform/helpers';
import $ from '../platform/$';
// Got this from just putting a response in a json to ts converter, it might be incomplete.
export interface RawArchivePost {
doc_id: string;
num: string;
subnum: string;
thread_num: string;
op: string;
timestamp: number;
timestamp_expired: string;
capcode: string;
email: any;
name: string;
trip: any;
title: any;
comment: string;
poster_hash: any;
poster_country?: string;
troll_country_code?: string;
sticky: string;
locked: string;
deleted: string;
nreplies: any;
nimages: any;
fourchan_date: string;
comment_sanitized: string;
comment_processed: string;
formatted: boolean;
title_processed: any;
name_processed: string;
email_processed: any;
trip_processed: any;
poster_hash_processed: any;
poster_country_name: boolean;
poster_country_name_processed: string;
extra_data: any[];
exif?: string;
media: {
media_id: string;
spoiler: string;
preview_orig: string;
media: string;
preview_op: any;
preview_reply: string;
preview_w: string;
preview_h: string;
media_filename: string;
media_w: string;
media_h: string;
media_size: string;
media_hash: string;
media_orig: string;
exif: any;
total: string;
banned: string;
media_status: string;
safe_media_hash: string;
remote_media_link: string;
media_link: string;
thumb_link: string;
media_filename_processed: string;
};
board: {
name: string;
shortname: string;
};
}
export const parseArchivePost = (data: RawArchivePost) => {
// https://github.com/eksopl/asagi/blob/v0.4.0b74/src/main/java/net/easymodo/asagi/YotsubaAbstract.java#L82-L129
// https://github.com/FoolCode/FoolFuuka/blob/800bd090835489e7e24371186db6e336f04b85c0/src/Model/Comment.php#L368-L428
// https://github.com/bstats/b-stats/blob/6abe7bffaf6e5f523498d760e54b110df5331fbb/inc/classes/Yotsuba.php#L157-L168
let comment = (data.comment || '').split(/(\n|\[\/?(?:b|spoiler|code|moot|banned|fortune(?: color="#\w+")?|i|red|green|blue)\])/);
comment = comment.map((text, i) => {
if ((i % 2) === 1) {
var tag = Fetcher.archiveTags[text.replace(/\ .*\]/, ']')];
return (typeof tag === 'function') ? tag(text) : tag;
} else {
var greentext = text[0] === '>';
text = text
.replace(/(\[\/?[a-z]+):lit(\])/g, '$1$2')
.split(/(>>(?:>\/[a-z\d]+\/)?\d+)/g)
.map((text2, j) => ((j % 2) ? `<span class="deadlink">${E(text2)}</span>` : E(text2)))
.join('');
return { innerHTML: (greentext ? `<span class="quote">${text}</span>` : text) };
}
});
comment = { innerHTML: E.cat(comment), [isEscaped]: true };
const o = {
ID: data.num,
threadID: data.thread_num,
boardID: data.board.shortname,
isReply: data.num !== data.thread_num,
fileDeleted: false,
info: {
subject: data.title,
email: data.email,
name: data.name || '',
tripcode: data.trip,
capcode: (() => {
switch (data.capcode) {
// https://github.com/pleebe/FoolFuuka/blob/bf4224eed04637a4d0bd4411c2bf5f9945dfec0b/assets/themes/foolz/foolfuuka-theme-fuuka/src/Partial/Board.php#L77
case 'M': return 'Mod';
case 'A': return 'Admin';
case 'D': return 'Developer';
case 'V': return 'Verified';
case 'F': return 'Founder';
case 'G': return 'Manager';
}
})(),
uniqueID: data.poster_hash,
flagCode: data.poster_country,
flagCodeTroll: data.troll_country_code,
flag: data.poster_country_name || data.troll_country_name,
dateUTC: data.timestamp,
dateText: data.fourchan_date,
commentHTML: comment,
},
file: null as File,
extra: null as any,
};
if (o.info.capcode) { delete o.info.uniqueID; }
if (data.media && !!+data.media.banned) {
o.fileDeleted = true;
} else if (data.media?.media_filename) {
let { thumb_link } = data.media;
// Fix URLs missing origin
if (thumb_link?.[0] === '/') { thumb_link = url.split('/', 3).join('/') + thumb_link; }
if (!Redirect.securityCheck(thumb_link)) { thumb_link = ''; }
let media_link = Redirect.to('file', { boardID: o.boardID, filename: data.media.media_orig });
if (!Redirect.securityCheck(media_link)) { media_link = ''; }
o.file = {
name: data.media.media_filename,
url: media_link ||
(o.boardID === 'f' ?
`${location.protocol}//${ImageHost.flashHost()}/${o.boardID}/${encodeURIComponent(E(data.media.media_filename))}`
:
`${location.protocol}//${ImageHost.host()}/${o.boardID}/${data.media.media_orig}`),
height: data.media.media_h,
width: data.media.media_w,
MD5: data.media.media_hash,
size: $.bytesToString(data.media.media_size),
thumbURL: thumb_link || `${location.protocol}//${ImageHost.thumbHost()}/${o.boardID}/${data.media.preview_orig}`,
theight: data.media.preview_h,
twidth: data.media.preview_w,
isSpoiler: data.media.spoiler === '1'
};
if (!/\.pdf$/.test(o.file.url)) { o.file.dimensions = `${o.file.width}x${o.file.height}`; }
if ((o.boardID === 'f') && data.media.exif) { o.file.tag = JSON.parse(data.media.exif).Tag; }
}
o.extra = dict();
const board = g.boards[o.boardID] ||
new Board(o.boardID);
const thread = g.threads.get(`${o.boardID}.${o.threadID}`) ||
new Thread(o.threadID, board);
const post = new Post(g.SITE.Build.post(o), thread, board, { isFetchedQuote: true });
post.kill();
if (post.file) { post.file.thumbURL = o.file.thumbURL; }
Main.callbackNodes('Post', [post]);
return post;
};
export default parseArchivePost;

View File

@ -1,4 +1,6 @@
import Notice from '../classes/Notice.js';
import type { default as Post, File } from '../classes/Post.js';
import type Thread from '../classes/Thread.js';
import { Conf } from '../globals/globals.js';
import $ from '../platform/$.js';
import CrossOrigin from '../platform/CrossOrigin.js';
@ -12,6 +14,11 @@ import archives from './archives.json';
var Redirect = {
archives,
data: null as {
thread: Record<any, Thread>,
post: Record<any, Post>,
file: Record<any, File>,
},
init() {
this.selectArchives();
@ -128,8 +135,12 @@ var Redirect = {
return cb?.();
},
to(dest, data) {
const archive = (['search', 'board'].includes(dest) ? Redirect.data.thread : Redirect.data[dest])[data.boardID];
to(
dest: 'post' | 'thread' | 'threadJSON' | 'file' | 'board' | 'search',
data: { boardID: string, threadID?: string | number, postID?: string | number }
): string {
const archive =
(['search', 'board', 'threadJSON'].includes(dest) ? Redirect.data.thread : Redirect.data[dest])[data.boardID];
if (!archive) { return ''; }
return Redirect[dest](archive, data);
},
@ -162,6 +173,10 @@ var Redirect = {
return `${Redirect.protocol(archive)}${archive.domain}/${path}`;
},
threadJSON(archive, { boardID, threadID }) {
return `${Redirect.protocol(archive)}${archive.domain}/_/api/chan/thread/?board=${boardID}&num=${threadID}`;
},
post(archive, {boardID, postID}) {
// For fuuka-based archives:
// https://github.com/eksopl/fuuka/issues/27

View File

@ -0,0 +1,80 @@
import Redirect from './Redirect';
import Notice from '../classes/Notice';
import { Conf, g } from '../globals/globals';
import CrossOrigin from '../platform/CrossOrigin';
import $ from '../platform/$';
import Header from '../General/Header';
import { type RawArchivePost, parseArchivePost } from './Parse';
import QuoteThreading from '../Quotelinks/QuoteThreading';
const RestoreDeletedFromArchive = {
restore() {
console.log(g);
const url = Redirect.to('threadJSON', { boardID: g.boardID, threadID: g.threadID });
console.log(url);
if (!url) {
new Notice('warning', 'No archive found', 3);
return;
}
const encryptionOK = url.startsWith('https://');
if (encryptionOK || Conf['Exempt Archives from Encryption']) {
CrossOrigin.cache(url, function (this: XMLHttpRequest) {
console.log(this);
let nrRestored = 0;
const archivePosts = this.response[g.threadID.toString()].posts as Record<string, RawArchivePost>;
for (const [postID, raw] of Object.entries(archivePosts)) {
const key = `${g.boardID}.${postID}`
if (!g.posts.keys.includes(key)) {
const postIdNr = +postID;
let indexOfNext = g.posts.keys.findIndex(key => +(key.split('.')[1]) > postIdNr);
if (indexOfNext === -1) {
indexOfNext = g.posts.keys.length;
};
const newPost = parseArchivePost(raw);
newPost.kill()
g.posts.push(key, newPost);
// move key to right position
g.posts.keys.pop();
g.posts.keys.splice(indexOfNext, 0, key);
if (!QuoteThreading.insert(newPost)) {
g.posts.get(g.posts.keys[indexOfNext - 1]).root.insertAdjacentElement('afterend', newPost.root);
}
++nrRestored;
}
}
let msg: string;
if (nrRestored === 0) {
msg = 'No removed posts found';
} else if (nrRestored === 1) {
msg = '1 post restored';
} else {
msg = `${nrRestored} posts restored`;
}
new Notice('info', msg, 3);
});
}
},
init() {
if (g.VIEW !== 'thread') return;
const menuEntry = $.el('a', {
href: 'javascript:;',
textContent: 'Restore from archive',
});
$.on(menuEntry, 'click', () => {
RestoreDeletedFromArchive.restore();
Header.menu.close();
});
Header.menu.addEntry({
el: menuEntry,
order: 10,
});
},
}
export default RestoreDeletedFromArchive;

View File

@ -12,6 +12,7 @@ import Header from '../General/Header';
import { g, Conf, d, doc } from '../globals/globals';
import UI from '../General/UI';
import { MINUTE, SECOND } from '../platform/helpers';
import type Thread from '../classes/Thread';
/*
* decaffeinate suggestions:
@ -22,8 +23,8 @@ import { MINUTE, SECOND } from '../platform/helpers';
*/
var ThreadUpdater = {
init() {
let el, name, sc;
init(this: typeof ThreadUpdater) {
let sc;
if ((g.VIEW !== 'thread') || !Conf['Thread Updater']) { return; }
this.enabled = true;
@ -63,9 +64,9 @@ var ThreadUpdater = {
$.on(updateLink.firstElementChild, 'click', this.update);
const subEntries = [];
for (name in Config.updater.checkbox) {
for (const name in Config.updater.checkbox) {
var conf = Config.updater.checkbox[name];
el = UI.checkbox(name, name);
const el = UI.checkbox(name, name);
el.title = conf[1];
var input = el.firstElementChild;
$.on(input, 'change', $.cb.checked);
@ -170,7 +171,7 @@ var ThreadUpdater = {
if (e) { return $.cb.value.call(this); }
},
load() {
load(this: XMLHttpRequest) {
if (this !== ThreadUpdater.req) { return; } // aborted
switch (this.status) {
case 200:
@ -198,13 +199,12 @@ var ThreadUpdater = {
confirmed = false;
}
if (confirmed) {
return ThreadUpdater.kill();
ThreadUpdater.kill();
} else {
return ThreadUpdater.error(this);
ThreadUpdater.error(this);
}
}
}
);
});
default:
return ThreadUpdater.error(this);
}
@ -334,11 +334,11 @@ var ThreadUpdater = {
return new Notice('info', `The thread is ${change}.`, 30);
},
parse(req) {
parse(req: XMLHttpRequest) {
let ID, ipCountEl, post;
const postObjects = req.response.posts;
const OP = postObjects[0];
const {thread} = ThreadUpdater;
const thread: Thread = ThreadUpdater.thread;
const {board} = thread;
const lastPost = ThreadUpdater.postIDs[ThreadUpdater.postIDs.length - 1];

View File

@ -161,9 +161,9 @@ var Unread = {
},
addPost() {
if (this.isFetchedQuote || this.isClone) { return; }
if (this.isFetchedQuote || this.isClone || (this.ID <= Unread.lastReadPost)) return;
Unread.order.push(this);
if ((this.ID <= Unread.lastReadPost) || this.isHidden || QuoteYou.isYou(this)) { return; }
if (this.isHidden || QuoteYou.isYou(this)) return;
Unread.posts.add((Unread.posts.last = this.ID));
Unread.addPostQuotingYou(this);
return Unread.position != null ? Unread.position : (Unread.position = Unread.order[this.ID]);

View File

@ -1,6 +1,8 @@
import BoardConfig from "../General/BoardConfig";
import { d, g } from "../globals/globals";
import SimpleDict from "./SimpleDict";
import type Post from "./Post";
import type Thread from "./Thread";
/*
* decaffeinate suggestions:
@ -8,6 +10,13 @@ import SimpleDict from "./SimpleDict";
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
export default class Board {
declare ID: string;
declare boardID: string;
declare siteID: string;
declare threads: SimpleDict<Thread>;
declare posts: SimpleDict<Post>;
declare config: any;
toString() { return this.ID; }
constructor(ID) {

View File

@ -6,11 +6,9 @@ import $ from "../platform/$";
import Main from "../main/Main";
import Index from "../General/Index";
import { E, g, Conf, d } from "../globals/globals";
import ImageHost from "../Images/ImageHost";
import CrossOrigin from "../platform/CrossOrigin";
import Get from "../General/Get";
import { dict } from "../platform/helpers";
import { isEscaped } from "../globals/jsx";
import parseArchivePost from "../Archive/Parse";
/*
* decaffeinate suggestions:
@ -45,8 +43,14 @@ export default class Fetcher {
'[/blue]': {innerHTML: "</span>"}
};
constructor(boardID, threadID, postID, root, quoter) {
let post, thread;
declare boardID: string;
declare threadID: number;
declare postID: string;
declare root: HTMLElement;
declare quoter: Post;
constructor(boardID: string, threadID: number, postID: string, root: HTMLElement, quoter: Post) {
let post: Post, thread: Thread;
this.boardID = boardID;
this.threadID = threadID;
this.postID = postID;
@ -175,7 +179,7 @@ export default class Fetcher {
}
archivedPost() {
let url;
let url: string;
if (!Conf['Resurrect Quotes']) { return false; }
if (!(url = Redirect.to('post', {boardID: this.boardID, postID: this.postID}))) { return false; }
const archive = Redirect.data.post[this.boardID];
@ -203,7 +207,7 @@ export default class Fetcher {
parseArchivedPost(data, url, archive) {
// In case of multiple callbacks for the same request,
// don't parse the same original post more than once.
let post;
let post: Post;
if (post = g.posts.get(`${this.boardID}.${this.postID}`)) {
this.insert(post);
return;
@ -221,94 +225,8 @@ export default class Fetcher {
return;
}
// https://github.com/eksopl/asagi/blob/v0.4.0b74/src/main/java/net/easymodo/asagi/YotsubaAbstract.java#L82-L129
// https://github.com/FoolCode/FoolFuuka/blob/800bd090835489e7e24371186db6e336f04b85c0/src/Model/Comment.php#L368-L428
// https://github.com/bstats/b-stats/blob/6abe7bffaf6e5f523498d760e54b110df5331fbb/inc/classes/Yotsuba.php#L157-L168
let comment = (data.comment || '').split(/(\n|\[\/?(?:b|spoiler|code|moot|banned|fortune(?: color="#\w+")?|i|red|green|blue)\])/);
comment = comment.map((text, i) => {
if ((i % 2) === 1) {
var tag = Fetcher.archiveTags[text.replace(/\ .*\]/, ']')];
return (typeof tag === 'function') ? tag(text) : tag;
} else {
var greentext = text[0] === '>';
text = text
.replace(/(\[\/?[a-z]+):lit(\])/g, '$1$2')
.split(/(>>(?:>\/[a-z\d]+\/)?\d+)/g)
.map((text2, j) => ((j % 2) ? `<span class="deadlink">${E(text2)}</span>`: E(text2)))
.join('');
return {innerHTML: (greentext ? `<span class="quote">${text}</span>` : text)};
}
});
comment = { innerHTML: E.cat(comment), [isEscaped]: true };
this.threadID = +data.thread_num;
const o = {
ID: this.postID,
threadID: this.threadID,
boardID: this.boardID,
isReply: this.postID !== this.threadID
};
o.info = {
subject: data.title,
email: data.email,
name: data.name || '',
tripcode: data.trip,
capcode: (() => { switch (data.capcode) {
// https://github.com/pleebe/FoolFuuka/blob/bf4224eed04637a4d0bd4411c2bf5f9945dfec0b/assets/themes/foolz/foolfuuka-theme-fuuka/src/Partial/Board.php#L77
case 'M': return 'Mod';
case 'A': return 'Admin';
case 'D': return 'Developer';
case 'V': return 'Verified';
case 'F': return 'Founder';
case 'G': return 'Manager';
} })(),
uniqueID: data.poster_hash,
flagCode: data.poster_country,
flagCodeTroll: data.troll_country_code,
flag: data.poster_country_name || data.troll_country_name,
dateUTC: data.timestamp,
dateText: data.fourchan_date,
commentHTML: comment
};
if (o.info.capcode) { delete o.info.uniqueID; }
if (data.media && !!+data.media.banned) {
o.fileDeleted = true;
} else if (data.media?.media_filename) {
let {thumb_link} = data.media;
// Fix URLs missing origin
if (thumb_link?.[0] === '/') { thumb_link = url.split('/', 3).join('/') + thumb_link; }
if (!Redirect.securityCheck(thumb_link)) { thumb_link = ''; }
let media_link = Redirect.to('file', {boardID: this.boardID, filename: data.media.media_orig});
if (!Redirect.securityCheck(media_link)) { media_link = ''; }
o.file = {
name: data.media.media_filename,
url: media_link ||
(this.boardID === 'f' ?
`${location.protocol}//${ImageHost.flashHost()}/${this.boardID}/${encodeURIComponent(E(data.media.media_filename))}`
:
`${location.protocol}//${ImageHost.host()}/${this.boardID}/${data.media.media_orig}`),
height: data.media.media_h,
width: data.media.media_w,
MD5: data.media.media_hash,
size: $.bytesToString(data.media.media_size),
thumbURL: thumb_link || `${location.protocol}//${ImageHost.thumbHost()}/${this.boardID}/${data.media.preview_orig}`,
theight: data.media.preview_h,
twidth: data.media.preview_w,
isSpoiler: data.media.spoiler === '1'
};
if (!/\.pdf$/.test(o.file.url)) { o.file.dimensions = `${o.file.width}x${o.file.height}`; }
if ((this.boardID === 'f') && data.media.exif) { o.file.tag = JSON.parse(data.media.exif).Tag; }
}
o.extra = dict();
const board = g.boards[this.boardID] ||
new Board(this.boardID);
const thread = g.threads.get(`${this.boardID}.${this.threadID}`) ||
new Thread(this.threadID, board);
post = new Post(g.SITE.Build.post(o), thread, board, {isFetchedQuote: true});
post.kill();
if (post.file) { post.file.thumbURL = o.file.thumbURL; }
Main.callbackNodes('Post', [post]);
post = parseArchivePost(data);
return this.insert(post);
}
}

View File

@ -16,6 +16,7 @@ export interface File {
sizeInBytes: number,
isDead: boolean,
url: string,
thumbURL?: string,
name: string,
isImage: boolean,
isVideo: boolean,
@ -24,6 +25,13 @@ export interface File {
fullImage?: HTMLImageElement | HTMLVideoElement,
audio?: HTMLAudioElement,
audioSlider?:HTMLSpanElement,
dimensions?: string,
height?: string,
width?: string,
theight: string,
twidth: string,
MD5?: string,
isSpoiler?: boolean,
};
export default class Post {
@ -336,7 +344,7 @@ export default class Post {
return file as File;
}
kill(file, index=0) {
kill(file = false, index = 0) {
let strong;
if (file) {
if (this.isDead || this.files[index].isDead) { return; }

View File

@ -1,6 +1,8 @@
import SimpleDict from "./SimpleDict";
import $ from "../platform/$";
import { g } from "../globals/globals";
import type Board from "./Board";
import type Post from "./Post";
/*
* decaffeinate suggestions:
@ -8,9 +10,30 @@ import { g } from "../globals/globals";
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
export default class Thread {
declare board: Board;
declare ID: number;
declare threadID: number;
declare boardID: string | number;
declare siteID: string;
declare fullID: string;
declare posts: SimpleDict<Post>;
declare isDead: boolean;
declare isHidden: boolean;
declare isSticky: boolean;
declare isClosed: boolean;
declare isArchived: boolean;
declare postLimit: boolean;
declare fileLimit: boolean;
declare lastPost: number;
declare ipCount: number;
declare json: any;
declare OP: any;
declare catalogView: any
declare nodes: any
toString() { return this.ID; }
constructor(ID, board) {
constructor(ID: string, board: Board) {
this.board = board;
this.ID = +ID;
this.threadID = this.ID;

View File

@ -44,9 +44,11 @@ export const g: {
VERSION: string,
NAMESPACE: string,
sites: (typeof SWTinyboard)[],
boardID?: string,
boards: Board[],
posts?: SimpleDict<Post>,
threads?: SimpleDict<Thread>
threads?: SimpleDict<Thread>,
threadID?: number,
THREADID?: number,
SITE?: typeof SWTinyboard,
BOARD?: Board,

View File

@ -88,9 +88,9 @@ import Menu from "../Menu/Menu";
import BoardConfig from "../General/BoardConfig";
import CaptchaReplace from "../Posting/Captcha.replace";
import Get from "../General/Get";
import Captcha from "../Posting/Captcha";
import { dict, platform } from "../platform/helpers";
import Polyfill from "../General/Polyfill";
import RestoreDeletedFromArchive from "../Archive/RestoreDeletedFromArchive";
// import Test from "../General/Test";
/*
@ -952,7 +952,8 @@ User agent: ${navigator.userAgent}\
['Announcements', PSA],
['Flash Features', Flash],
['Reply Pruning', ReplyPruning],
['Mod Contact Links', ModContact]
['Mod Contact Links', ModContact],
['Restore deleted posts from archive', RestoreDeletedFromArchive],
]
};
export default Main;

View File

@ -389,14 +389,14 @@ $.before = (root, el) => root.parentNode.insertBefore($.nodes(el), root);
$.replace = (root, el) => root.parentNode.replaceChild($.nodes(el), root);
$.el = function(tag, properties, properties2) {
$.el = function (tag: string, properties?: Record<string, any>, properties2?: Record<string, any>) {
const el = d.createElement(tag);
if (properties) { $.extend(el, properties); }
if (properties2) { $.extend(el, properties2); }
return el;
};
$.on = function(el, events, handler) {
$.on = function (el: Element, events: string, handler: (event: Event) => void) {
for (var event of events.split(' ')) {
el.addEventListener(event, handler, false);
}

View File

@ -1,4 +1,4 @@
{
"version": "XT 2.1.4",
"date": "2023-09-02T15:03:39.080Z"
"version": "XT 2.2.0",
"date": "2023-10-27T13:58:44.136Z"
}