forked from cory/tildefriends
232 lines
6.8 KiB
JavaScript
232 lines
6.8 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) {
|
|
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); |