forked from cadence/out-of-your-element
		
	Finally get the embed tests passing
This commit is contained in:
		
							parent
							
								
									d5013dd7ea
								
							
						
					
					
						commit
						a56942cf14
					
				
					 4 changed files with 184 additions and 55 deletions
				
			
		| 
						 | 
				
			
			@ -34,7 +34,68 @@ test("message2event embeds: nothing but a field", async t => {
 | 
			
		|||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "Amanda"
 | 
			
		||||
		msgtype: "m.notice",
 | 
			
		||||
		body: "**Amanda 🎵#2192 :online:"
 | 
			
		||||
			+ "\nwillow tree, branch 0**"
 | 
			
		||||
			+ "\n**❯ Uptime:**\n3m 55s\n**❯ Memory:**\n64.45MB",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
		formatted_body: '<strong>Amanda 🎵#2192 <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ\" title=\":online:\" alt=\":online:\">'
 | 
			
		||||
			+ '<br>willow tree, branch 0</strong>'
 | 
			
		||||
			+ '<br><strong>❯ Uptime:</strong><br>3m 55s'
 | 
			
		||||
			+ '<br><strong>❯ Memory:</strong><br>64.45MB'
 | 
			
		||||
	}])
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test("message2event embeds: reply with just an embed", async t => {
 | 
			
		||||
	const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {})
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		msgtype: "m.notice",
 | 
			
		||||
		"m.mentions": {},
 | 
			
		||||
		body: "[**⏺️ dynastic (@dynastic)**](https://twitter.com/i/user/719631291747078145)"
 | 
			
		||||
			+ "\n\n**https://twitter.com/i/status/1707484191963648161**"
 | 
			
		||||
			+ "\n\ndoes anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
 | 
			
		||||
			+ "\n\n**Retweets**"
 | 
			
		||||
			+ "\n119"
 | 
			
		||||
			+ "\n\n**Likes**"
 | 
			
		||||
			+ "\n5581"
 | 
			
		||||
			+ "\n\n— Twitter",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
		formatted_body: '<a href="https://twitter.com/i/user/719631291747078145"><strong>⏺️ dynastic (@dynastic)</strong></a>'
 | 
			
		||||
			+ '<br><br><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
 | 
			
		||||
			+ '<br><br>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
 | 
			
		||||
			+ '<br><br><strong>Retweets</strong><br>119<br><br><strong>Likes</strong><br>5581<br><br>— Twitter'
 | 
			
		||||
	}])
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test("message2event embeds: image embed and attachment", async t => {
 | 
			
		||||
	const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, {
 | 
			
		||||
		api: {
 | 
			
		||||
			async getJoinedMembers(roomID) {
 | 
			
		||||
				return {joined: []}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	t.deepEqual(events, [{
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		msgtype: "m.text",
 | 
			
		||||
		body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner",
 | 
			
		||||
		format: "org.matrix.custom.html",
 | 
			
		||||
		formatted_body: '<a href="https://tootsuite.net/Warp-Gate2.gif">https://tootsuite.net/Warp-Gate2.gif</a><br>tanget: @ monster spawner',
 | 
			
		||||
		"m.mentions": {}
 | 
			
		||||
	}, {
 | 
			
		||||
		$type: "m.room.message",
 | 
			
		||||
		msgtype: "m.image",
 | 
			
		||||
		url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
 | 
			
		||||
		body: "Screenshot_20231001_034036.jpg",
 | 
			
		||||
		external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
 | 
			
		||||
		filename: "Screenshot_20231001_034036.jpg",
 | 
			
		||||
		info: {
 | 
			
		||||
			h: 1170,
 | 
			
		||||
			w: 1080,
 | 
			
		||||
			size: 51981,
 | 
			
		||||
			mimetype: "image/jpeg"
 | 
			
		||||
		},
 | 
			
		||||
		"m.mentions": {}
 | 
			
		||||
	}])
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -136,16 +136,7 @@ async function messageToEvent(message, guild, options = {}, di) {
 | 
			
		|||
		addMention(repliedToEventSenderMxid)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	let msgtype = "m.text"
 | 
			
		||||
	// Handle message type 4, channel name changed
 | 
			
		||||
	if (message.type === DiscordTypes.MessageType.ChannelNameChange) {
 | 
			
		||||
		msgtype = "m.emote"
 | 
			
		||||
		message.content = "changed the channel name to **" + message.content + "**"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Text content appears first
 | 
			
		||||
	if (message.content) {
 | 
			
		||||
		let content = message.content
 | 
			
		||||
	async function addTextEvent(content, msgtype, {scanMentions}) {
 | 
			
		||||
		content = content.replace(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/([0-9]+)\/([0-9]+)\/([0-9]+)/, (whole, guildID, channelID, messageID) => {
 | 
			
		||||
			const eventID = select("event_message", "event_id", "WHERE message_id = ?").pluck().get(messageID)
 | 
			
		||||
			const roomID = select("channel_room", "room_id", "WHERE channel_id = ?").pluck().get(channelID)
 | 
			
		||||
| 
						 | 
				
			
			@ -186,44 +177,21 @@ async function messageToEvent(message, guild, options = {}, di) {
 | 
			
		|||
			escapeHTML: false,
 | 
			
		||||
		}, null, null)
 | 
			
		||||
 | 
			
		||||
		for (const embed of message.embeds || []) {
 | 
			
		||||
			// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
 | 
			
		||||
			let repParagraphs = []
 | 
			
		||||
			if (embed.author?.name) repParagraphs.push(`**${embed.author.name}**`)
 | 
			
		||||
			if (embed.title && embed.url) repParagraphs.push(`[**${embed.title}**](${embed.url})`)
 | 
			
		||||
			else if (embed.title) repParagraphs.push(`**${embed.title}**`)
 | 
			
		||||
			else if (embed.url) repParagraphs.push(`**${embed.url}**`)
 | 
			
		||||
			if (embed.description) repParagraphs.push(embed.description)
 | 
			
		||||
			for (const field of embed.fields || []) {
 | 
			
		||||
				repParagraphs.push(`**${field.name}**\n${field.value}`)
 | 
			
		||||
			}
 | 
			
		||||
			if (embed.footer?.text) repParagraphs.push(embed.footer.text)
 | 
			
		||||
			const repContent = repParagraphs.join("\n\n")
 | 
			
		||||
 | 
			
		||||
			html += "<blockquote>" + markdown.toHTML(repContent, {
 | 
			
		||||
				discordCallback: getDiscordParseCallbacks(message, true)
 | 
			
		||||
			}, null, null) + "</blockquote>"
 | 
			
		||||
 | 
			
		||||
			body += "\n\n" + markdown.toHTML(repContent, {
 | 
			
		||||
				discordCallback: getDiscordParseCallbacks(message, false),
 | 
			
		||||
				discordOnly: true,
 | 
			
		||||
				escapeHTML: false
 | 
			
		||||
			}, null, null)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
 | 
			
		||||
		const matches = [...content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
 | 
			
		||||
		if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
 | 
			
		||||
			const writtenMentionsText = matches.map(m => m[1].toLowerCase())
 | 
			
		||||
			const roomID = select("channel_room", "room_id", "WHERE channel_id = ?").pluck().get(message.channel_id)
 | 
			
		||||
			assert(roomID)
 | 
			
		||||
			const {joined} = await di.api.getJoinedMembers(roomID)
 | 
			
		||||
			for (const [mxid, member] of Object.entries(joined)) {
 | 
			
		||||
				if (!userRegex.some(rx => mxid.match(rx))) {
 | 
			
		||||
					const localpart = mxid.match(/@([^:]*)/)
 | 
			
		||||
					assert(localpart)
 | 
			
		||||
					const displayName = member.displayname || localpart[1]
 | 
			
		||||
					if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
 | 
			
		||||
		if (scanMentions) {
 | 
			
		||||
			const matches = [...content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
 | 
			
		||||
			if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
 | 
			
		||||
				const writtenMentionsText = matches.map(m => m[1].toLowerCase())
 | 
			
		||||
				const roomID = select("channel_room", "room_id", "WHERE channel_id = ?").pluck().get(message.channel_id)
 | 
			
		||||
				assert(roomID)
 | 
			
		||||
				const {joined} = await di.api.getJoinedMembers(roomID)
 | 
			
		||||
				for (const [mxid, member] of Object.entries(joined)) {
 | 
			
		||||
					if (!userRegex.some(rx => mxid.match(rx))) {
 | 
			
		||||
						const localpart = mxid.match(/@([^:]*)/)
 | 
			
		||||
						assert(localpart)
 | 
			
		||||
						const displayName = member.displayname || localpart[1]
 | 
			
		||||
						if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -286,6 +254,19 @@ async function messageToEvent(message, guild, options = {}, di) {
 | 
			
		|||
		events.push(newTextMessageEvent)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	let msgtype = "m.text"
 | 
			
		||||
	// Handle message type 4, channel name changed
 | 
			
		||||
	if (message.type === DiscordTypes.MessageType.ChannelNameChange) {
 | 
			
		||||
		msgtype = "m.emote"
 | 
			
		||||
		message.content = "changed the channel name to **" + message.content + "**"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Text content appears first
 | 
			
		||||
	if (message.content) {
 | 
			
		||||
		await addTextEvent(message.content, msgtype, {scanMentions: true})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Then attachments
 | 
			
		||||
	const attachmentEvents = await Promise.all(message.attachments.map(async attachment => {
 | 
			
		||||
		const emoji =
 | 
			
		||||
| 
						 | 
				
			
			@ -381,6 +362,39 @@ async function messageToEvent(message, guild, options = {}, di) {
 | 
			
		|||
	}))
 | 
			
		||||
	events.push(...attachmentEvents)
 | 
			
		||||
 | 
			
		||||
	// Then embeds
 | 
			
		||||
	for (const embed of message.embeds || []) {
 | 
			
		||||
		if (embed.type === "image") {
 | 
			
		||||
			continue // Matrix already does a fine enough job of providing image embeds.
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
 | 
			
		||||
		let repParagraphs = []
 | 
			
		||||
		const makeUrlTitle = (text, url) =>
 | 
			
		||||
			( text && url ? `[**${text}**](${url})`
 | 
			
		||||
			: text ? `**${text}**`
 | 
			
		||||
			: url ? `**${url}**`
 | 
			
		||||
			: "")
 | 
			
		||||
 | 
			
		||||
		let authorNameText = embed.author?.name || ""
 | 
			
		||||
		if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // not using the real image
 | 
			
		||||
		let authorTitle = makeUrlTitle(authorNameText, embed.author?.url)
 | 
			
		||||
		if (authorTitle) repParagraphs.push(authorTitle)
 | 
			
		||||
 | 
			
		||||
		let title = makeUrlTitle(embed.title, embed.url)
 | 
			
		||||
		if (title) repParagraphs.push(title)
 | 
			
		||||
 | 
			
		||||
		if (embed.description) repParagraphs.push(embed.description)
 | 
			
		||||
		for (const field of embed.fields || []) {
 | 
			
		||||
			repParagraphs.push(`**${field.name}**\n${field.value}`)
 | 
			
		||||
		}
 | 
			
		||||
		if (embed.footer?.text) repParagraphs.push(`— ${embed.footer.text}`)
 | 
			
		||||
		const repContent = repParagraphs.join("\n\n")
 | 
			
		||||
 | 
			
		||||
		// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
 | 
			
		||||
		await addTextEvent(repContent, "m.notice", {scanMentions: false})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Then stickers
 | 
			
		||||
	if (message.sticker_items) {
 | 
			
		||||
		const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										60
									
								
								test/data.js
									
										
									
									
									
								
							
							
						
						
									
										60
									
								
								test/data.js
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1167,7 +1167,7 @@ module.exports = {
 | 
			
		|||
				message_reference: {
 | 
			
		||||
					message_id: "1157413453921787924",
 | 
			
		||||
					guild_id: "1150201337112449045",
 | 
			
		||||
					channel_id: "1150208267285434429"
 | 
			
		||||
					channel_id: "1100319550446252084"
 | 
			
		||||
				},
 | 
			
		||||
				mentions: [
 | 
			
		||||
					{
 | 
			
		||||
| 
						 | 
				
			
			@ -1212,7 +1212,7 @@ module.exports = {
 | 
			
		|||
				edited_timestamp: null,
 | 
			
		||||
				content: "https://twitter.com/dynastic/status/1707484191963648161",
 | 
			
		||||
				components: [],
 | 
			
		||||
				channel_id: "1150208267285434429",
 | 
			
		||||
				channel_id: "1100319550446252084",
 | 
			
		||||
				author: {
 | 
			
		||||
					username: "pokemongod",
 | 
			
		||||
					public_flags: 0,
 | 
			
		||||
| 
						 | 
				
			
			@ -1228,7 +1228,7 @@ module.exports = {
 | 
			
		|||
			message_reference: {
 | 
			
		||||
				message_id: "1157417694728044624",
 | 
			
		||||
				guild_id: "1150201337112449045",
 | 
			
		||||
				channel_id: "1150208267285434429"
 | 
			
		||||
				channel_id: "1100319550446252084"
 | 
			
		||||
			},
 | 
			
		||||
			mentions: [],
 | 
			
		||||
			mention_roles: [],
 | 
			
		||||
| 
						 | 
				
			
			@ -1274,7 +1274,7 @@ module.exports = {
 | 
			
		|||
			edited_timestamp: null,
 | 
			
		||||
			content: "",
 | 
			
		||||
			components: [],
 | 
			
		||||
			channel_id: "1150208267285434429",
 | 
			
		||||
			channel_id: "1100319550446252084",
 | 
			
		||||
			author: {
 | 
			
		||||
				username: "Twitter Video Embeds",
 | 
			
		||||
				public_flags: 65536,
 | 
			
		||||
| 
						 | 
				
			
			@ -1287,6 +1287,58 @@ module.exports = {
 | 
			
		|||
			},
 | 
			
		||||
			attachments: [],
 | 
			
		||||
			guild_id: "1150201337112449045"
 | 
			
		||||
		},
 | 
			
		||||
		image_embed_and_attachment: {
 | 
			
		||||
			id: "1157854642810654821",
 | 
			
		||||
			type: 0,
 | 
			
		||||
			content: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner",
 | 
			
		||||
			channel_id: "112760669178241024",
 | 
			
		||||
			author: {
 | 
			
		||||
				id: "113340068197859328",
 | 
			
		||||
				username: "kumaccino",
 | 
			
		||||
				avatar: "b48302623a12bc7c59a71328f72ccb39",
 | 
			
		||||
				discriminator: "0",
 | 
			
		||||
				public_flags: 128,
 | 
			
		||||
				flags: 128,
 | 
			
		||||
				banner: null,
 | 
			
		||||
				accent_color: null,
 | 
			
		||||
				global_name: "kumaccino",
 | 
			
		||||
				avatar_decoration_data: null,
 | 
			
		||||
				banner_color: null
 | 
			
		||||
			},
 | 
			
		||||
			attachments: [
 | 
			
		||||
				{
 | 
			
		||||
					id: "1157854643037163610",
 | 
			
		||||
					filename: "Screenshot_20231001_034036.jpg",
 | 
			
		||||
					size: 51981,
 | 
			
		||||
					url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
 | 
			
		||||
					proxy_url: "https://media.discordapp.net/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
 | 
			
		||||
					width: 1080,
 | 
			
		||||
					height: 1170,
 | 
			
		||||
					content_type: "image/jpeg"
 | 
			
		||||
				}
 | 
			
		||||
			],
 | 
			
		||||
			embeds: [
 | 
			
		||||
				{
 | 
			
		||||
					type: "image",
 | 
			
		||||
					url: "https://tootsuite.net/Warp-Gate2.gif",
 | 
			
		||||
					thumbnail: {
 | 
			
		||||
						url: "https://tootsuite.net/Warp-Gate2.gif",
 | 
			
		||||
						proxy_url: "https://images-ext-1.discordapp.net/external/Sy1ETGflxjW3iklbLgxP-Me2BXD7pMsAX2XrJ7ttaS4/https/tootsuite.net/Warp-Gate2.gif",
 | 
			
		||||
						width: 258,
 | 
			
		||||
						height: 213
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			],
 | 
			
		||||
			mentions: [],
 | 
			
		||||
			mention_roles: [],
 | 
			
		||||
			pinned: false,
 | 
			
		||||
			mention_everyone: false,
 | 
			
		||||
			tts: false,
 | 
			
		||||
			timestamp: "2023-10-01T01:40:58.745000+00:00",
 | 
			
		||||
			edited_timestamp: "2023-10-01T01:42:05.631000+00:00",
 | 
			
		||||
			flags: 0,
 | 
			
		||||
			components: []
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	message_update: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -68,14 +68,16 @@ INSERT INTO file (discord_url, mxc_url) VALUES
 | 
			
		|||
('https://cdn.discordapp.com/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024', 'mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF'),
 | 
			
		||||
('https://cdn.discordapp.com/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024', 'mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL'),
 | 
			
		||||
('https://cdn.discordapp.com/emojis/230201364309868544.png', 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
 | 
			
		||||
('https://cdn.discordapp.com/emojis/393635038903926784.gif', 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc');
 | 
			
		||||
('https://cdn.discordapp.com/emojis/393635038903926784.gif', 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
 | 
			
		||||
('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR');
 | 
			
		||||
 | 
			
		||||
INSERT INTO emoji (id, name, animated, mxc_url) VALUES
 | 
			
		||||
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
 | 
			
		||||
('393635038903926784', 'hipposcope', 1, 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
 | 
			
		||||
('362741439211503616', 'bn_re', 0, 'mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT'),
 | 
			
		||||
('551636841284108289', 'ae_botrac4r', 0, 'mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs'),
 | 
			
		||||
('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik');
 | 
			
		||||
('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik'),
 | 
			
		||||
('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ');
 | 
			
		||||
 | 
			
		||||
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES
 | 
			
		||||
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue