.gitea
apps
admin
api
apps
blog
db
follow
identity
intro
issues
journal
room
sneaker
app.js
filesaver.min.js
index.html
jszip.min.js
lit-all.min.js
lit-all.min.js.map
script.js
ssb
storage
test
todo
web
welcome
wiki
admin.json
api.json
apps.json
blog.json
db.json
follow.json
identity.json
intro.json
issues.json
journal.json
room.json
sneaker.json
ssb.json
storage.json
test.json
todo.json
web.json
welcome.json
wiki.json
core
deps
docs
metadata
src
tools
.clang-format
.dockerignore
.git-blame-ignore-revs
.gitignore
.gitmodules
.prettierignore
.prettierrc.yaml
CONTRIBUTING.md
Dockerfile
Doxyfile
GNUmakefile
LICENSE
README.md
default.nix
flake.lock
flake.nix
package-lock.json
package.json
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);
|