Sneakernet, here we come.
git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@4292 ed5197a5-7fde-0310-b194-c3ffbd925b24
This commit is contained in:
parent
1324afb459
commit
251556ebed
4
apps/sneaker.json
Normal file
4
apps/sneaker.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "👟"
|
||||||
|
}
|
30
apps/sneaker/app.js
Normal file
30
apps/sneaker/app.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import * as tfrpc from '/tfrpc.js';
|
||||||
|
|
||||||
|
tfrpc.register(async function getAllIdentities() {
|
||||||
|
return ssb.getAllIdentities();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function query(sql, args) {
|
||||||
|
let result = [];
|
||||||
|
await ssb.sqlAsync(sql, args, function callback(row) {
|
||||||
|
result.push(row);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
tfrpc.register(async function store_blob(blob) {
|
||||||
|
if (Array.isArray(blob)) {
|
||||||
|
blob = Uint8Array.from(blob);
|
||||||
|
}
|
||||||
|
return await ssb.blobStore(blob);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function get_blob(id) {
|
||||||
|
return Array.from(new Uint8Array(await ssb.blobGet(id)));
|
||||||
|
});
|
||||||
|
tfrpc.register(async function store_message(message) {
|
||||||
|
return await ssb.storeMessage(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await app.setDocument(utf8Decode(await getFile('index.html')));
|
||||||
|
}
|
||||||
|
main();
|
3
apps/sneaker/filesaver.min.js
vendored
Normal file
3
apps/sneaker/filesaver.min.js
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)});
|
||||||
|
|
||||||
|
//# sourceMappingURL=FileSaver.min.js.map
|
14
apps/sneaker/index.html
Normal file
14
apps/sneaker/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="color: #fff">
|
||||||
|
<head>
|
||||||
|
<title>Tilde Friends</title>
|
||||||
|
<base target="_top">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<tf-sneaker-app/>
|
||||||
|
<script>window.litDisableBundleWarning = true;</script>
|
||||||
|
<script src="filesaver.min.js"></script>
|
||||||
|
<script src="jszip.min.js"></script>
|
||||||
|
<script src="script.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
13
apps/sneaker/jszip.min.js
vendored
Normal file
13
apps/sneaker/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
126
apps/sneaker/lit-all.min.js
vendored
Normal file
126
apps/sneaker/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
226
apps/sneaker/script.js
Normal file
226
apps/sneaker/script.js
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
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 blobs.id
|
||||||
|
FROM messages
|
||||||
|
JOIN messages_refs ON messages.id = messages_refs.message
|
||||||
|
JOIN blobs ON messages_refs.ref = blobs.id
|
||||||
|
WHERE messages.author = ?`,
|
||||||
|
[id]);
|
||||||
|
let blobs_done = 0;
|
||||||
|
for (let row of blobs) {
|
||||||
|
this.progress = {name: 'blobs', value: blobs_done, max: blobs.length};
|
||||||
|
let blob = await tfrpc.rpc.get_blob(row.id);
|
||||||
|
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);
|
Loading…
Reference in New Issue
Block a user