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);
 |