351 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			351 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import {LitElement, html} from './lit-all.min.js';
 | 
						|
import * as tfrpc from '/static/tfrpc.js';
 | 
						|
 | 
						|
class TfSneakerAppElement extends LitElement {
 | 
						|
	static get properties() {
 | 
						|
		return {
 | 
						|
			feeds: {type: Object},
 | 
						|
			progress: {type: Object},
 | 
						|
			result: {type: String},
 | 
						|
		};
 | 
						|
	}
 | 
						|
 | 
						|
	constructor() {
 | 
						|
		super();
 | 
						|
		this.feeds = [];
 | 
						|
		this.progress = undefined;
 | 
						|
		this.result = undefined;
 | 
						|
	}
 | 
						|
 | 
						|
	async search() {
 | 
						|
		let q = this.renderRoot.getElementById('search').value;
 | 
						|
		let result = await tfrpc.rpc.query(
 | 
						|
			`
 | 
						|
			SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name
 | 
						|
			FROM messages_fts(?)
 | 
						|
			JOIN messages ON messages.rowid = messages_fts.rowid
 | 
						|
			WHERE
 | 
						|
				json_extract(messages.content, '$.type') = 'about' AND
 | 
						|
				json_extract(messages.content, '$.about') = messages.author AND
 | 
						|
				json_extract(messages.content, '$.name') IS NOT NULL
 | 
						|
			GROUP BY messages.author
 | 
						|
			HAVING MAX(messages.sequence)
 | 
						|
			ORDER BY COUNT(*) DESC
 | 
						|
			`,
 | 
						|
			[`"${q.replaceAll('"', '""')}"`]
 | 
						|
		);
 | 
						|
		this.feeds = Object.fromEntries(result.map((x) => [x.id, x.name]));
 | 
						|
	}
 | 
						|
 | 
						|
	format_message(message) {
 | 
						|
		const k_flag_sequence_before_author = 1;
 | 
						|
		let out = {
 | 
						|
			previous: message.previous ?? null,
 | 
						|
		};
 | 
						|
		if (message.flags & k_flag_sequence_before_author) {
 | 
						|
			out.sequence = message.sequence;
 | 
						|
			out.author = message.author;
 | 
						|
		} else {
 | 
						|
			out.author = message.author;
 | 
						|
			out.sequence = message.sequence;
 | 
						|
		}
 | 
						|
		out.timestamp = message.timestamp;
 | 
						|
		out.hash = message.hash;
 | 
						|
		out.content = JSON.parse(message.content);
 | 
						|
		out.signature = message.signature;
 | 
						|
		return {key: message.id, value: out};
 | 
						|
	}
 | 
						|
 | 
						|
	sanitize(value) {
 | 
						|
		return value.replaceAll('/', '_').replaceAll('+', '-');
 | 
						|
	}
 | 
						|
 | 
						|
	guess_ext(data) {
 | 
						|
		function startsWith(prefix) {
 | 
						|
			if (data.length < prefix.length) {
 | 
						|
				return false;
 | 
						|
			}
 | 
						|
			for (let i = 0; i < prefix.length; i++) {
 | 
						|
				if (prefix[i] !== null && data[i] !== prefix[i]) {
 | 
						|
					return false;
 | 
						|
				}
 | 
						|
			}
 | 
						|
			return true;
 | 
						|
		}
 | 
						|
 | 
						|
		if (
 | 
						|
			startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) ||
 | 
						|
			startsWith(
 | 
						|
				data,
 | 
						|
				[0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]
 | 
						|
			) ||
 | 
						|
			startsWith(data, [0xff, 0xd8, 0xff, 0xee]) ||
 | 
						|
			startsWith(data, [
 | 
						|
				0xff,
 | 
						|
				0xd8,
 | 
						|
				0xff,
 | 
						|
				0xe1,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				0x45,
 | 
						|
				0x78,
 | 
						|
				0x69,
 | 
						|
				0x66,
 | 
						|
				0x00,
 | 
						|
				0x00,
 | 
						|
			])
 | 
						|
		) {
 | 
						|
			return '.jpg';
 | 
						|
		} else if (
 | 
						|
			startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
 | 
						|
		) {
 | 
						|
			return '.png';
 | 
						|
		} else if (
 | 
						|
			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
 | 
						|
			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
 | 
						|
		) {
 | 
						|
			return '.gif';
 | 
						|
		} else if (
 | 
						|
			startsWith(data, [
 | 
						|
				0x52,
 | 
						|
				0x49,
 | 
						|
				0x46,
 | 
						|
				0x46,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				0x57,
 | 
						|
				0x45,
 | 
						|
				0x42,
 | 
						|
				0x50,
 | 
						|
			])
 | 
						|
		) {
 | 
						|
			return '.webp';
 | 
						|
		} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) {
 | 
						|
			return '.svg';
 | 
						|
		} else if (
 | 
						|
			startsWith(data, [
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				0x66,
 | 
						|
				0x74,
 | 
						|
				0x79,
 | 
						|
				0x70,
 | 
						|
				0x6d,
 | 
						|
				0x70,
 | 
						|
				0x34,
 | 
						|
				0x32,
 | 
						|
			])
 | 
						|
		) {
 | 
						|
			return '.mp3';
 | 
						|
		} else if (
 | 
						|
			startsWith(data, [
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				0x66,
 | 
						|
				0x74,
 | 
						|
				0x79,
 | 
						|
				0x70,
 | 
						|
				0x69,
 | 
						|
				0x73,
 | 
						|
				0x6f,
 | 
						|
				0x6d,
 | 
						|
			]) ||
 | 
						|
			startsWith(data, [
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				null,
 | 
						|
				0x66,
 | 
						|
				0x74,
 | 
						|
				0x79,
 | 
						|
				0x70,
 | 
						|
				0x6d,
 | 
						|
				0x70,
 | 
						|
				0x34,
 | 
						|
				0x32,
 | 
						|
			])
 | 
						|
		) {
 | 
						|
			return '.mp4';
 | 
						|
		} else {
 | 
						|
			return '.bin';
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	async export(id) {
 | 
						|
		let all_messages = '';
 | 
						|
		let sequence = -1;
 | 
						|
		let messages_done = 0;
 | 
						|
		let messages_max = (
 | 
						|
			await tfrpc.rpc.query(
 | 
						|
				'SELECT MAX(sequence) AS total FROM messages WHERE author = ?',
 | 
						|
				[id]
 | 
						|
			)
 | 
						|
		)[0].total;
 | 
						|
		while (true) {
 | 
						|
			let messages = await tfrpc.rpc.query(
 | 
						|
				`
 | 
						|
				SELECT author, id, sequence, timestamp, hash, json(content) AS content, signature, flags
 | 
						|
				FROM messages
 | 
						|
				WHERE author = ? AND SEQUENCE > ?
 | 
						|
				ORDER BY sequence LIMIT 100
 | 
						|
				`,
 | 
						|
				[id, sequence]
 | 
						|
			);
 | 
						|
			if (messages?.length) {
 | 
						|
				all_messages +=
 | 
						|
					messages
 | 
						|
						.map((x) => JSON.stringify(this.format_message(x)))
 | 
						|
						.join('\n') + '\n';
 | 
						|
				sequence = messages[messages.length - 1].sequence;
 | 
						|
				messages_done += messages.length;
 | 
						|
				this.progress = {
 | 
						|
					name: 'messages',
 | 
						|
					value: messages_done,
 | 
						|
					max: messages_max,
 | 
						|
				};
 | 
						|
			} else {
 | 
						|
				break;
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		let zip = new JSZip();
 | 
						|
		zip.file(`message/classic/${this.sanitize(id)}.ndjson`, all_messages);
 | 
						|
 | 
						|
		let blobs = await tfrpc.rpc.query(
 | 
						|
			`SELECT messages_refs.ref AS id
 | 
						|
			FROM messages
 | 
						|
			JOIN messages_refs ON messages.id = messages_refs.message
 | 
						|
			WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`,
 | 
						|
			[id]
 | 
						|
		);
 | 
						|
		let blobs_done = 0;
 | 
						|
		for (let row of blobs) {
 | 
						|
			this.progress = {name: 'blobs', value: blobs_done, max: blobs.length};
 | 
						|
			let blob;
 | 
						|
			try {
 | 
						|
				blob = await tfrpc.rpc.get_blob(row.id);
 | 
						|
			} catch (e) {
 | 
						|
				console.log(`Failed to get ${row.id}: ${e.message}`);
 | 
						|
			}
 | 
						|
			if (blob) {
 | 
						|
				zip.file(
 | 
						|
					`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`,
 | 
						|
					new Uint8Array(blob)
 | 
						|
				);
 | 
						|
			}
 | 
						|
			blobs_done++;
 | 
						|
		}
 | 
						|
 | 
						|
		this.progress = {name: 'saving'};
 | 
						|
		let blob = await zip.generateAsync({type: 'blob'});
 | 
						|
		saveAs(blob, `${this.sanitize(id)}.zip`);
 | 
						|
		this.progress = null;
 | 
						|
	}
 | 
						|
 | 
						|
	keypress(event) {
 | 
						|
		if (event.key == 'Enter') {
 | 
						|
			this.search();
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	async import(event) {
 | 
						|
		let file = event.target.files[0];
 | 
						|
		if (!file) {
 | 
						|
			return;
 | 
						|
		}
 | 
						|
 | 
						|
		this.progress = {name: 'loading'};
 | 
						|
		let zip = new JSZip();
 | 
						|
		file = await zip.loadAsync(file);
 | 
						|
		let messages = [];
 | 
						|
		let blobs = [];
 | 
						|
		file.forEach(function (path, entry) {
 | 
						|
			if (!entry.dir) {
 | 
						|
				if (path.startsWith('message/classic/')) {
 | 
						|
					messages.push(entry);
 | 
						|
				} else {
 | 
						|
					blobs.push(entry);
 | 
						|
				}
 | 
						|
			}
 | 
						|
		});
 | 
						|
		let success = {messages: 0, blobs: 0};
 | 
						|
		let progress = 0;
 | 
						|
		let total_messages = 0;
 | 
						|
		for (let entry of messages) {
 | 
						|
			let lines = (await entry.async('string')).split('\n');
 | 
						|
			total_messages += lines.length;
 | 
						|
			for (let line of lines) {
 | 
						|
				if (!line.length) {
 | 
						|
					continue;
 | 
						|
				}
 | 
						|
				let message = JSON.parse(line);
 | 
						|
				this.progress = {
 | 
						|
					name: 'messages',
 | 
						|
					value: progress++,
 | 
						|
					max: total_messages,
 | 
						|
				};
 | 
						|
				if (await tfrpc.rpc.store_message(message.value)) {
 | 
						|
					success.messages++;
 | 
						|
				}
 | 
						|
			}
 | 
						|
		}
 | 
						|
		progress = 0;
 | 
						|
		for (let blob of blobs) {
 | 
						|
			this.progress = {name: 'blobs', value: progress++, max: blobs.length};
 | 
						|
			if (await tfrpc.rpc.store_blob(await blob.async('arraybuffer'))) {
 | 
						|
				success.blobs++;
 | 
						|
			}
 | 
						|
		}
 | 
						|
		this.progress = undefined;
 | 
						|
		this.result = `imported ${success.messages} messages and ${success.blobs} blobs`;
 | 
						|
	}
 | 
						|
 | 
						|
	render() {
 | 
						|
		let progress;
 | 
						|
		if (this.progress) {
 | 
						|
			if (this.progress.max) {
 | 
						|
				progress = html`<div>
 | 
						|
					<label for="progress">${this.progress.name}</label
 | 
						|
					><progress
 | 
						|
						value=${this.progress.value}
 | 
						|
						max=${this.progress.max}
 | 
						|
					></progress>
 | 
						|
				</div>`;
 | 
						|
			} else {
 | 
						|
				progress = html`<div><span>${this.progress.name}</span></div>`;
 | 
						|
			}
 | 
						|
		}
 | 
						|
		return html`<h1>SSB 👟net</h1>
 | 
						|
			<code>${this.result}</code>
 | 
						|
			${progress}
 | 
						|
 | 
						|
			<h2>Import</h2>
 | 
						|
			<input type="file" id="import" @change=${this.import}></input>
 | 
						|
 | 
						|
			<h2>Export</h2>
 | 
						|
			<input type="text" id="search" @keypress=${this.keypress}></input>
 | 
						|
			<input type="button" value="Search Users" @click=${this.search}></input>
 | 
						|
			<ul>
 | 
						|
				${Object.entries(this.feeds).map(
 | 
						|
					([id, name]) => html`
 | 
						|
						<li>
 | 
						|
							${this.progress
 | 
						|
								? undefined
 | 
						|
								: html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`}
 | 
						|
							${name}
 | 
						|
							<code style="color: #ccc">${id}</code>
 | 
						|
						</li>
 | 
						|
					`
 | 
						|
				)}
 | 
						|
			</ul>
 | 
						|
		`;
 | 
						|
	}
 | 
						|
}
 | 
						|
customElements.define('tf-sneaker-app', TfSneakerAppElement);
 |