tildefriends/apps/sneaker/script.js

232 lines
6.8 KiB
JavaScript
Raw Permalink Normal View History

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) {
let out = {
previous: message.previous ?? null,
};
if (message.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 * 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);