Compare commits
56 Commits
Author | SHA1 | Date | |
---|---|---|---|
b7362dd84d | |||
01637b31e1 | |||
0e9a39608a | |||
79404e4d41 | |||
35c21fbdaf | |||
8c7bd7dc11 | |||
09ad4f0320 | |||
d96b836bef | |||
59b2ffaf95 | |||
f1b55ddd64 | |||
85acac3a30 | |||
befff5c1e5 | |||
d72ba81a67 | |||
fef88e2032 | |||
20557e8ce4 | |||
99c905e908 | |||
d7b58ee2c5 | |||
faca2d387b | |||
358d02d97f | |||
b66dac7465 | |||
f7d201859a | |||
61d2ef5469 | |||
ac994b9c62 | |||
264dcbc331 | |||
e5425c0ffb | |||
e10803de68 | |||
07b1a0e403 | |||
6ed2c702d8 | |||
5c1c33d33e | |||
70d37c88b5 | |||
1ba37d95b5 | |||
0d82198849 | |||
39927e75f2 | |||
e6fd33b969 | |||
e8fe32d5af | |||
bfc8bb864d | |||
9179746763 | |||
d0177d24cb | |||
0573008c9c | |||
9506f518c2 | |||
0f0ae9153b | |||
09c7c8ac64 | |||
5e2dfff148 | |||
958b47548d | |||
16155ef746 | |||
5755b61ea6 | |||
353847a77f | |||
bdf64edeb8 | |||
b5768dd927 | |||
3e5abf3a4d | |||
d3029639de | |||
d21d7e4add | |||
afde69b5d9 | |||
3319df3df0 | |||
1102feaac3 | |||
deede728be |
57
Makefile
57
Makefile
@ -3,9 +3,9 @@
|
||||
MAKEFLAGS += --warn-undefined-variables
|
||||
MAKEFLAGS += --no-builtin-rules
|
||||
|
||||
VERSION_CODE := 9
|
||||
VERSION_NUMBER := 0.0.9
|
||||
VERSION_NAME := Failure is the only opportunity to begin again.
|
||||
VERSION_CODE := 10
|
||||
VERSION_NUMBER := 0.0.10
|
||||
VERSION_NAME := Pride is not the opposite of shame but its source.
|
||||
|
||||
PROJECT = tildefriends
|
||||
BUILD_DIR ?= out
|
||||
@ -28,8 +28,7 @@ ANDROID_SDK ?= ~/Android/Sdk
|
||||
ANDROID_BUILD_TOOLS := $(ANDROID_SDK)/build-tools/33.0.1
|
||||
ANDROID_PLATFORM := $(ANDROID_SDK)/platforms/android-33
|
||||
ANDROID_NDK ?= $(ANDROID_SDK)/ndk/23.1.7779620
|
||||
ANDROID_NDK_API_VERSION := 31
|
||||
ANDROID_MIN_SDK_VERSION := 26
|
||||
ANDROID_MIN_SDK_VERSION := 28
|
||||
|
||||
ANDROID_ARM64_TARGETS := \
|
||||
out/androiddebug/tildefriends \
|
||||
@ -62,7 +61,8 @@ $(ANDROID_TARGETS): CFLAGS += \
|
||||
-fPIC \
|
||||
-fdebug-compilation-dir . \
|
||||
-fomit-frame-pointer \
|
||||
-fno-asynchronous-unwind-tables
|
||||
-fno-asynchronous-unwind-tables \
|
||||
-funwind-tables
|
||||
$(ANDROID_TARGETS): LDFLAGS += --sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot -fPIC
|
||||
$(DEBUG_TARGETS): CFLAGS += -DDEBUG -Og
|
||||
$(RELEASE_TARGETS): CFLAGS += -DNDEBUG
|
||||
@ -81,10 +81,10 @@ windebug winrelease: LDFLAGS += \
|
||||
-Ldeps/openssl/mingw64/lib
|
||||
$(ANDROID_X86_64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := x86_64-linux-android
|
||||
$(ANDROID_ARM64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := aarch64-linux-android
|
||||
$(ANDROID_TARGETS): CC = $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/clang
|
||||
$(ANDROID_TARGETS): CC = $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/$(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION)-clang
|
||||
$(ANDROID_TARGETS): AS = $(CC)
|
||||
$(ANDROID_TARGETS): CFLAGS += \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_NDK_API_VERSION) \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION) \
|
||||
-Wno-unknown-warning-option
|
||||
$(ANDROID_ARM64_TARGETS): CFLAGS += -Ideps/openssl/android/arm64-v8a/usr/local/include
|
||||
$(ANDROID_ARM64_TARGETS): LDFLAGS += -Ldeps/openssl/android/arm64-v8a/usr/local/lib
|
||||
@ -270,7 +270,7 @@ $(SQLITE_OBJS): CFLAGS += \
|
||||
-DSQLITE_ENABLE_FTS5 \
|
||||
-DSQLITE_ENABLE_JSON1 \
|
||||
-DSQLITE_LIKE_DOESNT_MATCH_BLOBS \
|
||||
-DSQLITE_MAX_ATTACHED=0 \
|
||||
-DSQLITE_MAX_ATTACHED=1 \
|
||||
-DSQLITE_MAX_COLUMN=100 \
|
||||
-DSQLITE_MAX_COMPOUND_SELECT=300 \
|
||||
-DSQLITE_MAX_EXPR_DEPTH=40 \
|
||||
@ -391,7 +391,7 @@ windebug winrelease: LDFLAGS += \
|
||||
-lws2_32 \
|
||||
-lwsock32
|
||||
$(ANDROID_TARGETS): LDFLAGS += \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_NDK_API_VERSION) \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION) \
|
||||
-ldl \
|
||||
-llog \
|
||||
-lssl \
|
||||
@ -481,9 +481,7 @@ PACKAGE_DIRS := \
|
||||
apps/ \
|
||||
core/ \
|
||||
deps/codemirror/ \
|
||||
deps/lit/ \
|
||||
deps/split/ \
|
||||
deps/smoothie/
|
||||
deps/lit/
|
||||
|
||||
RAW_FILES := $(shell find $(PACKAGE_DIRS) -type f)
|
||||
|
||||
@ -524,3 +522,36 @@ apklog:
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
.PHONY: clean
|
||||
|
||||
dist: apk
|
||||
@echo "[export] $$(svn info --show-item url)"
|
||||
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||
@svn export -q . tildefriends-$(VERSION_NUMBER)
|
||||
@echo "tildefriends-$(VERSION_NUMBER): $(VERSION_NAME)" > tildefriends-$(VERSION_NUMBER)/VERSION
|
||||
@echo "[tar] tildefriends-$(VERSION_NUMBER).tar.xz"
|
||||
@tar \
|
||||
--exclude=apps/gg* \
|
||||
--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \
|
||||
--exclude=deps/libsodium/builds/msvc/vs* \
|
||||
--exclude=deps/libsodium/builds/msvc/build \
|
||||
--exclude=deps/libsodium/builds/msvc/properties \
|
||||
--exclude=deps/libsodium/configure \
|
||||
--exclude=deps/libsodium/test \
|
||||
--exclude=deps/libuv/docs \
|
||||
--exclude=deps/libuv/test \
|
||||
--exclude=deps/openssl \
|
||||
--exclude=deps/speedscope/*.map \
|
||||
--exclude=deps/sqlite/shell.c \
|
||||
--exclude=deps/zlib/contrib/vstudio \
|
||||
--exclude=deps/zlib/doc \
|
||||
-caf tildefriends-$(VERSION_NUMBER).tar.xz tildefriends-$(VERSION_NUMBER)
|
||||
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||
@echo "[cp] TildeFriends-$(VERSION_NUMBER).apk"
|
||||
@cp out/TildeFriends-release.apk TildeFriends-$(VERSION_NUMBER).apk
|
||||
.PHONY: dist
|
||||
|
||||
dist-test: dist
|
||||
@tar -xf tildefriends-$(VERSION_NUMBER).tar.xz
|
||||
@$(MAKE) -C tildefriends-$(VERSION_NUMBER)/ debug release
|
||||
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||
.PHONY: dist-test
|
||||
|
@ -9,14 +9,18 @@ tfrpc.register(function global_settings_set(key, value) {
|
||||
});
|
||||
|
||||
async function main() {
|
||||
let data = {
|
||||
users: {},
|
||||
granted: await core.allPermissionsGranted(),
|
||||
settings: await core.globalSettingsDescriptions(),
|
||||
};
|
||||
for (let user of await core.users()) {
|
||||
data.users[user] = await core.permissionsForUser(user);
|
||||
try {
|
||||
let data = {
|
||||
users: {},
|
||||
granted: await core.allPermissionsGranted(),
|
||||
settings: await core.globalSettingsDescriptions(),
|
||||
};
|
||||
for (let user of await core.users()) {
|
||||
data.users[user] = await core.permissionsForUser(user);
|
||||
}
|
||||
await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)));
|
||||
} catch {
|
||||
await app.setDocument('<span style="color: #f00">Only an administrator can modify these settings.</span>');
|
||||
}
|
||||
await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)));
|
||||
}
|
||||
main();
|
@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html style="width: 100%">
|
||||
<head>
|
||||
<script>const g_data = $data;</script>
|
||||
</head>
|
||||
<body style="color: #fff">
|
||||
<body style="color: #fff; width: 100%">
|
||||
<h1>Tilde Friends Administration</h1>
|
||||
</body>
|
||||
<script type="module" src="script.js"></script>
|
||||
|
@ -25,29 +25,37 @@ window.addEventListener('load', function() {
|
||||
function input_template(key, description) {
|
||||
if (description.type === 'boolean') {
|
||||
return html`
|
||||
<label ?for=${'gs_' + key} style="grid-column: 1">${key}: </label>
|
||||
<input type="checkbox" ?checked=${description.value} ?id=${'gs_' + key} style="grid-column: 2"></input>
|
||||
<div style="grid-column: 3">
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.checked)}>Set</button>
|
||||
<span>${description.description}</span>
|
||||
<div style="margin-top: 1em">
|
||||
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||
<div>
|
||||
<input type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input>
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.checked)}>Set</button>
|
||||
<div>${description.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (description.type === 'textarea') {
|
||||
return html`
|
||||
<label ?for=${'gs_' + key} style="grid-column: 1">${key}: </label>
|
||||
<textarea style="vertical-align: top" rows=20 cols=80 ?id=${'gs_' + key}>${description.value}</textarea>
|
||||
<div style="grid-column: 3">
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.value)}>Set</button>
|
||||
<span>${description.description}</span>
|
||||
<div style="margin-top: 1em"">
|
||||
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||
<div style="width: 100%; padding: 0; margin: 0">
|
||||
<div style="width: 90%; padding: 0 margin: 0">
|
||||
<textarea style="vertical-align: top; width: 100%" rows=20 cols=80 id=${'gs_' + key}>${description.value}</textarea>
|
||||
</div>
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.value)}>Set</button>
|
||||
<div>${description.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return html`
|
||||
<label ?for=${'gs_' + key} style="grid-column: 1">${key}: </label>
|
||||
<input type="text" value="${description.value}" ?id=${'gs_' + key}></input>
|
||||
<div style="grid-column: 3">
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.value)}>Set</button>
|
||||
<span>${description.description}</span>
|
||||
<div style="margin-top: 1em">
|
||||
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||
<div>
|
||||
<input type="text" value="${description.value}" id=${'gs_' + key}></input>
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.value)}>Set</button>
|
||||
<div>${description.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -67,12 +75,13 @@ window.addEventListener('load', function() {
|
||||
${Object.entries(users).map(u => user_template(u[0], u[1]))}
|
||||
</ul>`;
|
||||
const page_template = (data) =>
|
||||
html`<div>
|
||||
<h2>Global Settings</h2>
|
||||
<div style="display: grid">
|
||||
${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)}
|
||||
html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%">
|
||||
<h2>Global Settings</h2>
|
||||
<div>
|
||||
${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)}
|
||||
</div>
|
||||
${users_template(data.users)}
|
||||
</div>
|
||||
${users_template(data.users)}
|
||||
</div>`;
|
||||
`;
|
||||
render(page_template(g_data), document.body);
|
||||
});
|
@ -1,73 +1,163 @@
|
||||
var g_following_cache = {};
|
||||
var g_following_deep_cache = {};
|
||||
var g_about_cache = {};
|
||||
let g_about_cache = {};
|
||||
|
||||
async function following(db, id) {
|
||||
if (g_following_cache[id]) {
|
||||
return g_following_cache[id];
|
||||
}
|
||||
var o = await db.get(id + ":following");
|
||||
const k_version = 5;
|
||||
var f = o ? JSON.parse(o) : o;
|
||||
if (!f || f.version != k_version) {
|
||||
f = {users: [], sequence: 0, version: k_version};
|
||||
}
|
||||
f.users = new Set(f.users);
|
||||
await ssb.sqlAsync(
|
||||
"SELECT "+
|
||||
" sequence, "+
|
||||
" json_extract(content, '$.contact') AS contact, "+
|
||||
" json_extract(content, '$.following') AS following "+
|
||||
"FROM messages "+
|
||||
"WHERE "+
|
||||
" author = ?1 AND "+
|
||||
" sequence > ?2 AND "+
|
||||
" json_extract(content, '$.type') = 'contact' "+
|
||||
"UNION SELECT MAX(sequence) AS sequence, NULL, NULL FROM messages WHERE author = ?1 "+
|
||||
"ORDER BY sequence",
|
||||
[id, f.sequence],
|
||||
function(row) {
|
||||
if (row.following) {
|
||||
f.users.add(row.contact);
|
||||
} else {
|
||||
f.users.delete(row.contact);
|
||||
}
|
||||
f.sequence = row.sequence;
|
||||
});
|
||||
var as_set = f.users;
|
||||
f.users = Array.from(f.users).sort();
|
||||
var j = JSON.stringify(f);
|
||||
if (o != j) {
|
||||
await db.set(id + ":following", j);
|
||||
}
|
||||
f.users = as_set;
|
||||
g_following_cache[id] = f.users;
|
||||
return f.users;
|
||||
async function query(sql, args) {
|
||||
let result = [];
|
||||
await ssb.sqlAsync(sql, args, function(row) {
|
||||
result.push(row);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function followingDeep(db, seed_ids, depth) {
|
||||
if (depth <= 0) {
|
||||
return seed_ids;
|
||||
async function contacts_internal(id, last_row_id, following, max_row_id) {
|
||||
let result = Object.assign({}, following[id] || {});
|
||||
result.following = result.following || {};
|
||||
result.blocking = result.blocking || {};
|
||||
let contacts = await query(
|
||||
`
|
||||
SELECT content FROM messages
|
||||
WHERE author = ? AND
|
||||
rowid > ? AND
|
||||
rowid <= ? AND
|
||||
json_extract(content, '$.type') = 'contact'
|
||||
ORDER BY sequence
|
||||
`,
|
||||
[id, last_row_id, max_row_id]);
|
||||
for (let row of contacts) {
|
||||
let contact = JSON.parse(row.content);
|
||||
if (contact.following === true) {
|
||||
result.following[contact.contact] = true;
|
||||
} else if (contact.following === false) {
|
||||
delete result.following[contact.contact];
|
||||
} else if (contact.blocking === true) {
|
||||
result.blocking[contact.contact] = true;
|
||||
} else if (contact.blocking === false) {
|
||||
delete result.blocking[contact.contact];
|
||||
}
|
||||
}
|
||||
var key = JSON.stringify([seed_ids, depth]);
|
||||
if (g_following_deep_cache[key]) {
|
||||
return g_following_deep_cache[key];
|
||||
following[id] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function contact(id, last_row_id, following, max_row_id) {
|
||||
return await contacts_internal(id, last_row_id, following, max_row_id);
|
||||
}
|
||||
|
||||
async function following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) {
|
||||
let contacts = await Promise.all([...new Set(ids)].map(x => contact(x, last_row_id, following, max_row_id)));
|
||||
let result = {};
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i];
|
||||
let contact = contacts[i];
|
||||
let all_blocking = Object.assign({}, contact.blocking, blocking);
|
||||
let found = Object.keys(contact.following).filter(y => !all_blocking[y]);
|
||||
let deeper = depth > 1 ? await following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : [];
|
||||
result[id] = [id, ...found, ...deeper];
|
||||
}
|
||||
var f = await Promise.all(seed_ids.map(x => following(db, x).then(x => [...x])));
|
||||
var ids = [].concat(...f);
|
||||
var x = await followingDeep(db, [...new Set(ids)].sort(), depth - 1);
|
||||
x = [...new Set([].concat(...x, ...seed_ids))].sort();
|
||||
g_following_deep_cache[key] = x;
|
||||
return x;
|
||||
return [...new Set(Object.values(result).flat())];
|
||||
}
|
||||
|
||||
async function following_deep(ids, depth, blocking) {
|
||||
let db = await database('cache');
|
||||
const k_cache_version = 5;
|
||||
let cache = await db.get('following');
|
||||
cache = cache ? JSON.parse(cache) : {};
|
||||
if (cache.version !== k_cache_version) {
|
||||
cache = {
|
||||
version: k_cache_version,
|
||||
following: {},
|
||||
last_row_id: 0,
|
||||
};
|
||||
}
|
||||
let max_row_id = (await query(`
|
||||
SELECT MAX(rowid) AS max_row_id FROM messages
|
||||
`, []))[0].max_row_id;
|
||||
let result = await following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id);
|
||||
cache.last_row_id = max_row_id;
|
||||
let store = JSON.stringify(cache);
|
||||
await db.set('following', store);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetch_about(db, ids, users) {
|
||||
const k_cache_version = 1;
|
||||
let cache = await db.get('about');
|
||||
cache = cache ? JSON.parse(cache) : {};
|
||||
if (cache.version !== k_cache_version) {
|
||||
cache = {
|
||||
version: k_cache_version,
|
||||
about: {},
|
||||
last_row_id: 0,
|
||||
};
|
||||
}
|
||||
let max_row_id = 0;
|
||||
await ssb.sqlAsync(`
|
||||
SELECT MAX(rowid) AS max_row_id FROM messages
|
||||
`,
|
||||
[],
|
||||
function(row) {
|
||||
max_row_id = row.max_row_id;
|
||||
});
|
||||
for (let id of Object.keys(cache.about)) {
|
||||
if (ids.indexOf(id) == -1) {
|
||||
delete cache.about[id];
|
||||
}
|
||||
}
|
||||
|
||||
let abouts = [];
|
||||
await ssb.sqlAsync(
|
||||
`
|
||||
SELECT
|
||||
messages.*
|
||||
FROM
|
||||
messages,
|
||||
json_each(?1) AS following
|
||||
WHERE
|
||||
messages.author = following.value AND
|
||||
messages.rowid > ?3 AND
|
||||
messages.rowid <= ?4 AND
|
||||
json_extract(messages.content, '$.type') = 'about'
|
||||
UNION
|
||||
SELECT
|
||||
messages.*
|
||||
FROM
|
||||
messages,
|
||||
json_each(?2) AS following
|
||||
WHERE
|
||||
messages.author = following.value AND
|
||||
messages.rowid <= ?4 AND
|
||||
json_extract(messages.content, '$.type') = 'about'
|
||||
ORDER BY messages.author, messages.sequence
|
||||
`,
|
||||
[
|
||||
JSON.stringify(ids.filter(id => cache.about[id])),
|
||||
JSON.stringify(ids.filter(id => !cache.about[id])),
|
||||
cache.last_row_id,
|
||||
max_row_id,
|
||||
]);
|
||||
for (let about of abouts) {
|
||||
let content = JSON.parse(about.content);
|
||||
if (content.about === about.author) {
|
||||
delete content.type;
|
||||
delete content.about;
|
||||
cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content);
|
||||
}
|
||||
}
|
||||
cache.last_row_id = max_row_id;
|
||||
await db.set('about', JSON.stringify(cache));
|
||||
users = users || {};
|
||||
for (let id of Object.keys(cache.about)) {
|
||||
users[id] = Object.assign(users[id] || {}, cache.about[id]);
|
||||
}
|
||||
return Object.assign({}, users);
|
||||
}
|
||||
|
||||
async function getAbout(db, id) {
|
||||
if (g_about_cache[id]) {
|
||||
return g_about_cache[id];
|
||||
}
|
||||
var o = await db.get(id + ":about");
|
||||
let o = await db.get(id + ":about");
|
||||
const k_version = 4;
|
||||
var f = o ? JSON.parse(o) : o;
|
||||
let f = o ? JSON.parse(o) : o;
|
||||
if (!f || f.version != k_version) {
|
||||
f = {about: {}, sequence: 0, version: k_version};
|
||||
}
|
||||
@ -87,7 +177,7 @@ async function getAbout(db, id) {
|
||||
function(row) {
|
||||
f.sequence = row.sequence;
|
||||
if (row.content) {
|
||||
var about = {};
|
||||
let about = {};
|
||||
try {
|
||||
about = JSON.parse(row.content);
|
||||
} catch {
|
||||
@ -97,7 +187,7 @@ async function getAbout(db, id) {
|
||||
f.about = Object.assign(f.about, about);
|
||||
}
|
||||
});
|
||||
var j = JSON.stringify(f);
|
||||
let j = JSON.stringify(f);
|
||||
if (o != j) {
|
||||
await db.set(id + ":about", j);
|
||||
}
|
||||
@ -108,7 +198,7 @@ async function getAbout(db, id) {
|
||||
async function getSize(db, id) {
|
||||
let size = 0;
|
||||
await ssb.sqlAsync(
|
||||
"SELECT (SUM(LENGTH(content)) + SUM(LENGTH(author)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1",
|
||||
"SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1",
|
||||
[id],
|
||||
function (row) {
|
||||
size += row.size;
|
||||
@ -116,6 +206,25 @@ async function getSize(db, id) {
|
||||
return size;
|
||||
}
|
||||
|
||||
|
||||
async function getSizes(ids) {
|
||||
let sizes = {};
|
||||
await ssb.sqlAsync(
|
||||
`
|
||||
SELECT
|
||||
author,
|
||||
(SUM(LENGTH(content)) + SUM(LENGTH(author)) + SUM(LENGTH(messages.id))) AS size
|
||||
FROM messages
|
||||
JOIN json_each(?) AS ids ON author = ids.value
|
||||
GROUP BY author
|
||||
`,
|
||||
[JSON.stringify(ids)],
|
||||
function (row) {
|
||||
sizes[row.author] = row.size;
|
||||
});
|
||||
return sizes;
|
||||
}
|
||||
|
||||
function niceSize(bytes) {
|
||||
let value = bytes;
|
||||
let unit = 'B';
|
||||
@ -131,27 +240,28 @@ function niceSize(bytes) {
|
||||
return Math.round(value * 10) / 10 + ' ' + unit;
|
||||
}
|
||||
|
||||
async function buildTree(db, root, indent, depth) {
|
||||
var f = await following(db, root);
|
||||
var result = indent + '[' + f.size + '] ' + '<a target="_top" href="../index/#' + root + '">' + ((await getAbout(db, root)).name || root) + '</a> ' + niceSize(await getSize(db, root)) + '\n';
|
||||
if (depth > 0) {
|
||||
for (let next of f) {
|
||||
result += await buildTree(db, next, indent + ' ', depth - 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
function escape(value) {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await app.setDocument('<pre style="color: #fff">building...</pre>');
|
||||
var db = await database('ssb');
|
||||
var whoami = await ssb.getIdentities();
|
||||
var tree = '';
|
||||
for (let id of whoami) {
|
||||
await app.setDocument(`<pre style="color: #fff">building... ${id}</pre>`);
|
||||
tree += await buildTree(db, id, '', 2);
|
||||
let db = await database('ssb');
|
||||
let whoami = await ssb.getIdentities();
|
||||
let tree = '';
|
||||
await app.setDocument(`<pre style="color: #fff">Enumerating followed users...</pre>`);
|
||||
let following = await following_deep(whoami, 2, {});
|
||||
await app.setDocument(`<pre style="color: #fff">Getting names and sizes...</pre>`);
|
||||
let [about, sizes] = await Promise.all([
|
||||
fetch_about(db, following, {}),
|
||||
getSizes(following),
|
||||
]);
|
||||
await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`);
|
||||
following.sort((a, b) => ((sizes[b] ?? 0) - (sizes[a] ?? 0)));
|
||||
for (let id of following) {
|
||||
tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`;
|
||||
}
|
||||
await app.setDocument('<pre style="color: #fff">FOLLOWING:\n' + tree + '</pre>');
|
||||
await app.setDocument('<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + tree + '</ul>\n</body>\n</html>');
|
||||
}
|
||||
|
||||
main();
|
4
apps/gg.json
Normal file
4
apps/gg.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🗺"
|
||||
}
|
79
apps/gg/app.js
Normal file
79
apps/gg/app.js
Normal file
@ -0,0 +1,79 @@
|
||||
import * as tfrpc from '/tfrpc.js';
|
||||
import * as strava from './strava.js';
|
||||
|
||||
let g_database;
|
||||
let g_shared_database;
|
||||
|
||||
tfrpc.register(async function createIdentity() {
|
||||
return ssb.createIdentity();
|
||||
});
|
||||
tfrpc.register(async function appendMessage(id, message) {
|
||||
return ssb.appendMessageWithIdentity(id, message);
|
||||
});
|
||||
tfrpc.register(function url() {
|
||||
return core.url;
|
||||
});
|
||||
tfrpc.register(async function getUser() {
|
||||
return core.user;
|
||||
});
|
||||
tfrpc.register(function getIdentities() {
|
||||
return ssb.getIdentities();
|
||||
});
|
||||
tfrpc.register(async function databaseGet(key) {
|
||||
return g_database ? g_database.get(key) : undefined;
|
||||
});
|
||||
tfrpc.register(async function databaseSet(key, value) {
|
||||
return g_database ? g_database.set(key, value) : undefined;
|
||||
});
|
||||
tfrpc.register(async function databaseRemove(key, value) {
|
||||
return g_database ? g_database.remove(key, value) : undefined;
|
||||
});
|
||||
tfrpc.register(async function sharedDatabaseGet(key) {
|
||||
return g_shared_database ? g_shared_database.get(key) : undefined;
|
||||
});
|
||||
tfrpc.register(async function sharedDatabaseSet(key, value) {
|
||||
return g_shared_database ? g_shared_database.set(key, value) : undefined;
|
||||
});
|
||||
tfrpc.register(async function sharedDatabaseRemove(key, value) {
|
||||
return g_shared_database ? g_shared_database.remove(key, value) : undefined;
|
||||
});
|
||||
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 (typeof(blob) == 'string') {
|
||||
blob = utf8Encode(blob);
|
||||
}
|
||||
if (Array.isArray(blob)) {
|
||||
blob = Uint8Array.from(blob);
|
||||
}
|
||||
return await ssb.blobStore(blob);
|
||||
});
|
||||
|
||||
tfrpc.register(async function get_blob(id) {
|
||||
return utf8Decode(await ssb.blobGet(id));
|
||||
});
|
||||
tfrpc.register(strava.refresh_token);
|
||||
|
||||
async function main() {
|
||||
g_shared_database = await shared_database('state');
|
||||
if (core.user.credentials?.session?.name) {
|
||||
g_database = await database('state');
|
||||
}
|
||||
|
||||
let attempt;
|
||||
if (core.user.credentials?.session?.name) {
|
||||
let shared_db = await shared_database('state');
|
||||
attempt = await shared_db.get(core.user.credentials.session.name);
|
||||
}
|
||||
app.setDocument(utf8Decode(getFile('index.html')).replace('${data}', JSON.stringify({
|
||||
attempt: attempt,
|
||||
state: core.user?.credentials?.session?.name,
|
||||
})));
|
||||
}
|
||||
|
||||
main();
|
81
apps/gg/gpx.js
Normal file
81
apps/gg/gpx.js
Normal file
@ -0,0 +1,81 @@
|
||||
function xml_parse(xml) {
|
||||
let result;
|
||||
let path = [];
|
||||
let tag_begin;
|
||||
let text_begin;
|
||||
for (let i = 0; i < xml.length; i++) {
|
||||
let c = xml.charAt(i);
|
||||
if (!tag_begin && c == '<') {
|
||||
if (i > text_begin && path.length) {
|
||||
let value = xml.substring(text_begin, i);
|
||||
if (!/^\s*$/.test(value)) {
|
||||
path[path.length - 1].value = value;
|
||||
}
|
||||
}
|
||||
tag_begin = i + 1;
|
||||
} else if (tag_begin && c == '>') {
|
||||
let tag = xml.substring(tag_begin, i).trim();
|
||||
if (tag.startsWith('?') && tag.endsWith('?')) {
|
||||
/* Ignore directives. */
|
||||
} else if (tag.startsWith('/')) {
|
||||
path.pop();
|
||||
} else {
|
||||
let parts = tag.split(' ');
|
||||
let attributes = {};
|
||||
for (let j = 1; j < parts.length; j++) {
|
||||
let eq = parts[j].indexOf('=');
|
||||
let value = parts[j].substring(eq + 1);
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
attributes[parts[j].substring(0, eq)] = value;
|
||||
}
|
||||
let next = {name: parts[0], children: [], attributes: attributes};
|
||||
if (path.length) {
|
||||
path[path.length - 1].children.push(next);
|
||||
} else {
|
||||
result = next;
|
||||
}
|
||||
if (!tag.endsWith('/')) {
|
||||
path.push(next);
|
||||
}
|
||||
}
|
||||
tag_begin = undefined;
|
||||
text_begin = i + 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function* xml_each(node, name) {
|
||||
for (let child of node.children) {
|
||||
if (child.name == name) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function gpx_parse(xml) {
|
||||
let result = {segments: []};
|
||||
let tree = xml_parse(xml);
|
||||
if (tree?.name == 'gpx') {
|
||||
for (let trk of xml_each(tree, 'trk')) {
|
||||
for (let trkseg of xml_each(trk, 'trkseg')) {
|
||||
let segment = [];
|
||||
for (let trkpt of xml_each(trkseg, 'trkpt')) {
|
||||
segment.push({lat: parseFloat(trkpt.attributes.lat), lon: parseFloat(trkpt.attributes.lon)});
|
||||
}
|
||||
result.segments.push(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let metadata of xml_each(tree, 'metadata')) {
|
||||
for (let link of xml_each(metadata, 'link')) {
|
||||
result.link = link.attributes.href;
|
||||
}
|
||||
for (let time of xml_each(metadata, 'time')) {
|
||||
result.time = time.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
20
apps/gg/handler.js
Normal file
20
apps/gg/handler.js
Normal file
@ -0,0 +1,20 @@
|
||||
import * as strava from './strava.js';
|
||||
|
||||
async function main() {
|
||||
let r = await strava.authorization_code(request.query.code);
|
||||
print('state =', request.query.state);
|
||||
print('body = ', r.body);
|
||||
if (request.query.state && r.body) {
|
||||
let shared_db = await shared_database('state');
|
||||
await shared_db.set(request.query.state, r.body);
|
||||
}
|
||||
await respond({
|
||||
data: r.body,
|
||||
content_type: 'text/plain',
|
||||
headers: {
|
||||
Location: 'https://tildefriends.net/~cory/gg/',
|
||||
},
|
||||
status_code: 307,
|
||||
});
|
||||
}
|
||||
main();
|
16
apps/gg/index.html
Normal file
16
apps/gg/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html style="width: 100%; height: 100%; margin: 0; padding: 0">
|
||||
<head>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script>
|
||||
let g_data = ${data};
|
||||
</script>
|
||||
<script src="script.js" type="module"></script>
|
||||
<link rel="stylesheet" href="leaflet.css"/>
|
||||
<script src="leaflet.js"></script>
|
||||
</head>
|
||||
<body style="color: #fff; display: flex; flex-flow: column; height: 100%; width: 100%; margin: 0; padding: 0">
|
||||
<gg-app style="flex: 0 1 auto; overflow: scroll"></gg-app>
|
||||
<div id="map" style="flex: 1 0"></div>
|
||||
</body>
|
||||
</html>
|
661
apps/gg/leaflet.css
Normal file
661
apps/gg/leaflet.css
Normal file
@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
6
apps/gg/leaflet.js
Normal file
6
apps/gg/leaflet.js
Normal file
File diff suppressed because one or more lines are too long
126
apps/gg/lit-all.min.js
vendored
Normal file
126
apps/gg/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/gg/lit-all.min.js.map
Normal file
1
apps/gg/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
158
apps/gg/polyline.js
Normal file
158
apps/gg/polyline.js
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Based off of [the offical Google document](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
|
||||
*
|
||||
* Some parts from [this implementation](http://facstaff.unca.edu/mcmcclur/GoogleMaps/EncodePolyline/PolylineEncoder.js)
|
||||
* by [Mark McClure](http://facstaff.unca.edu/mcmcclur/)
|
||||
*
|
||||
* @module polyline
|
||||
*/
|
||||
|
||||
var polyline = {};
|
||||
|
||||
function py2_round(value) {
|
||||
// Google's polyline algorithm uses the same rounding strategy as Python 2, which is different from JS for negative values
|
||||
return Math.floor(Math.abs(value) + 0.5) * (value >= 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
function encode(current, previous, factor) {
|
||||
current = py2_round(current * factor);
|
||||
previous = py2_round(previous * factor);
|
||||
var coordinate = (current - previous) * 2;
|
||||
if (coordinate < 0) {
|
||||
coordinate = -coordinate - 1
|
||||
}
|
||||
var output = '';
|
||||
while (coordinate >= 0x20) {
|
||||
output += String.fromCharCode((0x20 | (coordinate & 0x1f)) + 63);
|
||||
coordinate /= 32;
|
||||
}
|
||||
output += String.fromCharCode((coordinate | 0) + 63);
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes to a [latitude, longitude] coordinates array.
|
||||
*
|
||||
* This is adapted from the implementation in Project-OSRM.
|
||||
*
|
||||
* @param {String} str
|
||||
* @param {Number} precision
|
||||
* @returns {Array}
|
||||
*
|
||||
* @see https://github.com/Project-OSRM/osrm-frontend/blob/master/WebContent/routing/OSRM.RoutingGeometry.js
|
||||
*/
|
||||
polyline.decode = function(str, precision) {
|
||||
var index = 0,
|
||||
lat = 0,
|
||||
lng = 0,
|
||||
coordinates = [],
|
||||
shift = 0,
|
||||
result = 0,
|
||||
byte = null,
|
||||
latitude_change,
|
||||
longitude_change,
|
||||
factor = Math.pow(10, Number.isInteger(precision) ? precision : 5);
|
||||
|
||||
// Coordinates have variable length when encoded, so just keep
|
||||
// track of whether we've hit the end of the string. In each
|
||||
// loop iteration, a single coordinate is decoded.
|
||||
while (index < str.length) {
|
||||
|
||||
// Reset shift, result, and byte
|
||||
byte = null;
|
||||
shift = 1;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = str.charCodeAt(index++) - 63;
|
||||
result += (byte & 0x1f) * shift;
|
||||
shift *= 32;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
latitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
|
||||
|
||||
shift = 1;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = str.charCodeAt(index++) - 63;
|
||||
result += (byte & 0x1f) * shift;
|
||||
shift *= 32;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
longitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
|
||||
|
||||
lat += latitude_change;
|
||||
lng += longitude_change;
|
||||
|
||||
coordinates.push([lat / factor, lng / factor]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encodes the given [latitude, longitude] coordinates array.
|
||||
*
|
||||
* @param {Array.<Array.<Number>>} coordinates
|
||||
* @param {Number} precision
|
||||
* @returns {String}
|
||||
*/
|
||||
polyline.encode = function(coordinates, precision) {
|
||||
if (!coordinates.length) { return ''; }
|
||||
|
||||
var factor = Math.pow(10, Number.isInteger(precision) ? precision : 5),
|
||||
output = encode(coordinates[0][0], 0, factor) + encode(coordinates[0][1], 0, factor);
|
||||
|
||||
for (var i = 1; i < coordinates.length; i++) {
|
||||
var a = coordinates[i], b = coordinates[i - 1];
|
||||
output += encode(a[0], b[0], factor);
|
||||
output += encode(a[1], b[1], factor);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
function flipped(coords) {
|
||||
var flipped = [];
|
||||
for (var i = 0; i < coords.length; i++) {
|
||||
var coord = coords[i].slice();
|
||||
flipped.push([coord[1], coord[0]]);
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a GeoJSON LineString feature/geometry.
|
||||
*
|
||||
* @param {Object} geojson
|
||||
* @param {Number} precision
|
||||
* @returns {String}
|
||||
*/
|
||||
polyline.fromGeoJSON = function(geojson, precision) {
|
||||
if (geojson && geojson.type === 'Feature') {
|
||||
geojson = geojson.geometry;
|
||||
}
|
||||
if (!geojson || geojson.type !== 'LineString') {
|
||||
throw new Error('Input must be a GeoJSON LineString');
|
||||
}
|
||||
return polyline.encode(flipped(geojson.coordinates), precision);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes to a GeoJSON LineString geometry.
|
||||
*
|
||||
* @param {String} str
|
||||
* @param {Number} precision
|
||||
* @returns {Object}
|
||||
*/
|
||||
polyline.toGeoJSON = function(str, precision) {
|
||||
var coords = polyline.decode(str, precision);
|
||||
return {
|
||||
type: 'LineString',
|
||||
coordinates: flipped(coords)
|
||||
};
|
||||
};
|
||||
|
||||
let polyline_decode = polyline.decode;
|
||||
export { polyline_decode as decode };
|
530
apps/gg/script.js
Normal file
530
apps/gg/script.js
Normal file
@ -0,0 +1,530 @@
|
||||
import {LitElement, html, unsafeHTML, css, guard, until} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import * as polyline from './polyline.js';
|
||||
import {gpx_parse} from './gpx.js';
|
||||
|
||||
const k_client_id = '28276';
|
||||
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
|
||||
|
||||
const k_color_snow = [128, 128, 255, 255];
|
||||
const k_color_ice = [160, 160, 255, 255];
|
||||
const k_color_water = [0, 0, 255, 255];
|
||||
const k_color_dirt = [128, 129, 130, 255];
|
||||
const k_color_pavement = [32, 32, 32, 255];
|
||||
const k_color_grass = [0, 255, 0, 255];
|
||||
const k_color_default = [128, 128, 128, 255];
|
||||
|
||||
class GgAppElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
user: {type: Object},
|
||||
strava: {type: Object},
|
||||
activities: {type: Array},
|
||||
activity: {type: Object},
|
||||
world: {type: Object},
|
||||
id: {type: String},
|
||||
status: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.activities = [];
|
||||
this.activity = {};
|
||||
this.loaded_activities = [];
|
||||
this.strava = {};
|
||||
this.min_lat = Number.MAX_VALUE;
|
||||
this.min_lon = Number.MAX_VALUE;
|
||||
this.max_lat = -Number.MAX_VALUE;
|
||||
this.max_lon = -Number.MAX_VALUE;
|
||||
this.status = undefined;
|
||||
this.load().catch(function(e) {
|
||||
console.log('load error', e);
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
console.log('load');
|
||||
this.user = await tfrpc.rpc.getUser();
|
||||
try {
|
||||
await this.update_credentials();
|
||||
} catch (e) {
|
||||
console.log('update_credentials failed', e);
|
||||
}
|
||||
try {
|
||||
await this.update_activities();
|
||||
} catch (e) {
|
||||
console.log('update_activities failed', e);
|
||||
}
|
||||
await this.acquire_ssb_identity();
|
||||
if (this.id && this.activities?.length) {
|
||||
await this.sync_activities();
|
||||
}
|
||||
await this.get_activities_from_ssb();
|
||||
}
|
||||
|
||||
async get_activities_from_ssb() {
|
||||
this.status = {text: 'loading activities'};
|
||||
this.loaded_activities = [];
|
||||
let blob_ids = await tfrpc.rpc.query(`
|
||||
SELECT json_extract(mention.value, '$.link') AS blob_id
|
||||
FROM messages_fts('"gg-activity"')
|
||||
JOIN messages ON messages.rowid = messages_fts.rowid,
|
||||
json_each(messages.content, '$.mentions') as mention
|
||||
WHERE json_extract(messages.content, '$.type') = 'gg-activity' AND
|
||||
json_extract(mention.value, '$.name') = 'activity_data'
|
||||
ORDER BY messages.timestamp DESC
|
||||
`, []);
|
||||
for (let [index, row] of blob_ids.entries()) {
|
||||
this.status = {text: 'loading activity data', value: index, max: blob_ids.length};
|
||||
let blob = await tfrpc.rpc.get_blob(row.blob_id);
|
||||
try {
|
||||
this.loaded_activities.push(JSON.parse(blob));
|
||||
} catch {
|
||||
this.loaded_activities.push(gpx_parse(blob));
|
||||
}
|
||||
}
|
||||
this.status = undefined;
|
||||
this.update_map();
|
||||
}
|
||||
|
||||
async sync_activities() {
|
||||
let ids = this.activities.map(x => `https://www.strava.com/activities/${x.id}`);
|
||||
let missing = await tfrpc.rpc.query(`
|
||||
WITH my_activities AS (
|
||||
SELECT json_extract(mention.value, '$.link') AS url
|
||||
FROM messages, json_each(messages.content, '$.mentions') AS mention
|
||||
WHERE
|
||||
author = ? AND
|
||||
json_extract(messages.content, '$.type') = 'gg-activity' AND
|
||||
json_extract(mention.value, '$.name') = 'activity_url')
|
||||
SELECT from_strava.value FROM json_each(?) AS from_strava
|
||||
LEFT OUTER JOIN my_activities ON from_strava.value = my_activities.url
|
||||
WHERE my_activities.url IS NULL
|
||||
`, [this.id, JSON.stringify(ids)]);
|
||||
console.log('missing = ', missing);
|
||||
for (let [index, row] of missing.entries()) {
|
||||
this.status = {text: 'syncing from strava', value: index, max: missing.length};
|
||||
let url = row.value;
|
||||
let id = url.match(/.*\/(\d+)/)[1];
|
||||
let response = await fetch(`https://www.strava.com/api/v3/activities/${id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.strava.access_token}`,
|
||||
},
|
||||
});
|
||||
let activity = await response.json();
|
||||
let blob_id = await tfrpc.rpc.store_blob(JSON.stringify(activity));
|
||||
let message = {
|
||||
type: 'gg-activity',
|
||||
mentions: [
|
||||
{
|
||||
link: url,
|
||||
name: 'activity_url',
|
||||
},
|
||||
{
|
||||
link: blob_id,
|
||||
name: 'activity_data',
|
||||
}
|
||||
],
|
||||
};
|
||||
await tfrpc.rpc.appendMessage(this.id, message);
|
||||
}
|
||||
this.status = undefined;
|
||||
}
|
||||
|
||||
async acquire_ssb_identity() {
|
||||
let user = await tfrpc.rpc.getUser();
|
||||
if (!user?.credentials?.session?.name) {
|
||||
return;
|
||||
}
|
||||
let ids = await tfrpc.rpc.getIdentities();
|
||||
let players = ids.length ? (await tfrpc.rpc.query(`
|
||||
SELECT author FROM messages JOIN json_each(?) ON messages.author = json_each.value
|
||||
WHERE
|
||||
json_extract(messages.content, '$.type') = 'gg-player' AND
|
||||
json_extract(messages.content, '$.active')
|
||||
ORDER BY timestamp DESC limit 1
|
||||
`, [JSON.stringify(ids)])).map(row => row.author) : [];
|
||||
if (!players.length) {
|
||||
this.id = await tfrpc.rpc.createIdentity();
|
||||
if (this.id) {
|
||||
await tfrpc.rpc.appendMessage(this.id, {
|
||||
type: 'gg-player',
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
players.sort();
|
||||
this.id = players[0];
|
||||
}
|
||||
}
|
||||
|
||||
async update_credentials() {
|
||||
let name = this.user?.credentials?.session?.name;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
let shared = await tfrpc.rpc.sharedDatabaseGet(name);
|
||||
if (shared) {
|
||||
await tfrpc.rpc.databaseSet('strava', shared);
|
||||
await tfrpc.rpc.sharedDatabaseRemove(name);
|
||||
}
|
||||
this.strava = JSON.parse(await tfrpc.rpc.databaseGet('strava') || '{}');
|
||||
if (new Date().valueOf() / 1000 > this.strava.expires_at) {
|
||||
console.log('this looks expired', new Date().valueOf() / 1000, '>', this.strava.expires_at);
|
||||
let x = await tfrpc.rpc.refresh_token(this.strava);
|
||||
if (x) {
|
||||
this.strava = x;
|
||||
await tfrpc.rpc.databaseSet('strava', JSON.stringify(x));
|
||||
} else {
|
||||
this.strava = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async update_activities() {
|
||||
if (this?.strava?.access_token) {
|
||||
let response = await fetch('https://www.strava.com/api/v3/athlete/activities', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.strava.access_token}`,
|
||||
},
|
||||
});
|
||||
this.activities = await response.json();
|
||||
this.activities.sort((a, b) => (a.id - b.id));
|
||||
}
|
||||
}
|
||||
|
||||
color_to_emoji(color) {
|
||||
const k_map = [
|
||||
[k_color_snow, '⬜'],
|
||||
[k_color_ice, '🟦'],
|
||||
[k_color_water, '🟦'],
|
||||
[k_color_dirt, '🟫'],
|
||||
[k_color_pavement, '⬛'],
|
||||
[k_color_grass, '🟩'],
|
||||
[k_color_default, '🟧'],
|
||||
];
|
||||
for (let m of k_map) {
|
||||
if (m[0][0] == color[0] &&
|
||||
m[0][1] == color[1] &&
|
||||
m[0][2] == color[2] &&
|
||||
m[0][3] == color[3]) {
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity_bounds(activity) {
|
||||
let min_lat = Number.MAX_VALUE;
|
||||
let min_lon = Number.MAX_VALUE;
|
||||
let max_lat = -Number.MAX_VALUE;
|
||||
let max_lon = -Number.MAX_VALUE;
|
||||
if (activity?.map?.polyline) {
|
||||
for (let pt of polyline.decode(activity.map.polyline)) {
|
||||
min_lat = Math.min(min_lat, pt[0]);
|
||||
min_lon = Math.min(min_lon, pt[1]);
|
||||
max_lat = Math.max(max_lat, pt[0]);
|
||||
max_lon = Math.max(max_lon, pt[1]);
|
||||
}
|
||||
}
|
||||
if (activity?.segments) {
|
||||
for (let segment of activity.segments) {
|
||||
for (let pt of segment) {
|
||||
min_lat = Math.min(min_lat, pt.lat);
|
||||
min_lon = Math.min(min_lon, pt.lon);
|
||||
max_lat = Math.max(max_lat, pt.lat);
|
||||
max_lon = Math.max(max_lon, pt.lon);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
min: {
|
||||
lat: min_lat,
|
||||
lng: min_lon,
|
||||
},
|
||||
max: {
|
||||
lat: max_lat,
|
||||
lng: max_lon,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async update_map() {
|
||||
if (!this.leaflet) {
|
||||
this.leaflet = L.map('map', {attributionControl: false, maxZoom: 16, bounceAtZoomLimits: false});
|
||||
}
|
||||
let self = this;
|
||||
let grid_layer = L.GridLayer.extend({
|
||||
createTile: function(coords) {
|
||||
var tile = L.DomUtil.create('canvas', 'leaflet-tile');
|
||||
var size = this.getTileSize();
|
||||
tile.width = size.x;
|
||||
tile.height = size.y;
|
||||
var context = tile.getContext('2d');
|
||||
context.font = '10pt sans';
|
||||
let bounds = this._tileCoordsToBounds(coords);
|
||||
let degrees = 360.0 / (2 ** coords.z);
|
||||
let ul = bounds.getNorthWest();
|
||||
let lr = bounds.getSouthEast();
|
||||
//context.fillText(JSON.stringify(coords), 0, 12);
|
||||
//context.fillText(`${Math.round(ul.lat * 100) / 100} ${Math.round(ul.lng * 100) / 100}`, 0, 24);
|
||||
//context.fillText(`${Math.round(lr.lat * 100) / 100} ${Math.round(lr.lng * 100) / 100}`, 0, 36);
|
||||
|
||||
let mini = document.createElement('canvas');
|
||||
mini.width = Math.floor(size.x / 16.0);
|
||||
mini.height = Math.floor(size.y / 16.0);
|
||||
let mini_context = mini.getContext('2d');
|
||||
let image_data = context.getImageData(0, 0, mini.width, mini.height);
|
||||
for (let activity of self.loaded_activities) {
|
||||
self.draw_activity_to_tile(image_data, mini.width, mini.height, ul, lr, activity);
|
||||
}
|
||||
//mini_context.putImageData(image_data, 0, 0);
|
||||
for (let x = 0; x < mini.width; x++) {
|
||||
for (let y = 0; y < mini.height; y++) {
|
||||
let start = (y * mini.width + x) * 4;
|
||||
let pixel = self.color_to_emoji(image_data.data.slice(start, start + 4));
|
||||
if (pixel) {
|
||||
context.fillText(pixel, x * size.x / mini.width, y * size.y / mini.height + 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
});
|
||||
if (this.grid_layer) {
|
||||
this.grid_layer.redraw();
|
||||
} else {
|
||||
this.grid_layer = new grid_layer();
|
||||
this.grid_layer.addTo(this.leaflet);
|
||||
}
|
||||
for (let activity of this.loaded_activities) {
|
||||
let bounds = this.activity_bounds(activity);
|
||||
this.min_lat = Math.min(this.min_lat, bounds.min.lat);
|
||||
this.min_lon = Math.min(this.min_lon, bounds.min.lng);
|
||||
this.max_lat = Math.max(this.max_lat, bounds.max.lat);
|
||||
this.max_lon = Math.max(this.max_lon, bounds.max.lng);
|
||||
}
|
||||
this.leaflet.fitBounds([
|
||||
[this.min_lat, this.min_lon],
|
||||
[this.max_lat, this.max_lon],
|
||||
]);
|
||||
}
|
||||
|
||||
activity_to_color(activity) {
|
||||
let color = [0, 0, 0, 255];
|
||||
switch (activity.sport_type) {
|
||||
/* Implies snow. */
|
||||
case 'AlpineSki':
|
||||
case 'BackcountrySki':
|
||||
case 'NordicSki':
|
||||
case 'Snowshoe':
|
||||
case 'Snowboard':
|
||||
color = k_color_snow;
|
||||
break;
|
||||
|
||||
/* Implies ice. */
|
||||
case 'IceSkate':
|
||||
case 'InlineSkate':
|
||||
color = k_color_ice;
|
||||
break;
|
||||
|
||||
/* Implies water. */
|
||||
case 'Canoeing':
|
||||
case 'Kayaking':
|
||||
case 'Kitesurf':
|
||||
case 'Rowing':
|
||||
case 'Sail':
|
||||
case 'StandUpPaddling':
|
||||
case 'Surfing':
|
||||
case 'Swim':
|
||||
case 'Windsurf':
|
||||
color = k_color_water;
|
||||
break;
|
||||
|
||||
/* Implies dirt. */
|
||||
case 'EMountainBikeRide':
|
||||
case 'Hike':
|
||||
case 'MountainBikeRide':
|
||||
case 'RockClimbing':
|
||||
case 'TrailRun':
|
||||
color = k_color_dirt;
|
||||
break;
|
||||
|
||||
/* Implies pavement. */
|
||||
case 'EBikeRide':
|
||||
case 'GravelRide':
|
||||
case 'Handcycle':
|
||||
case 'Ride':
|
||||
case 'RollerSki':
|
||||
case 'Run':
|
||||
case 'Skateboard':
|
||||
case 'Badminton':
|
||||
case 'Tennis':
|
||||
case 'Velomobile':
|
||||
case 'Walk':
|
||||
case 'Wheelchair':
|
||||
color = k_color_pavement;
|
||||
break;
|
||||
|
||||
/* Grass, maybe? */
|
||||
case 'Golf':
|
||||
case 'Soccer':
|
||||
case 'Squash':
|
||||
color = k_color_grass;
|
||||
break;
|
||||
|
||||
// Crossfit,
|
||||
// Elliptical
|
||||
// HighIntensityIntervalTraining
|
||||
// Pickleball
|
||||
// Pilates
|
||||
// Racquetball
|
||||
// StairStepper
|
||||
// TableTennis,
|
||||
// VirtualRide
|
||||
// VirtualRow
|
||||
// VirtualRun
|
||||
// WeightTraining
|
||||
// Workout
|
||||
// Yoga
|
||||
default:
|
||||
color = k_color_default;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
line(image_data, x0, y0, x1, y1, value) {
|
||||
/* <3 https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm */
|
||||
let dx = Math.abs(x1 - x0);
|
||||
let sx = x0 < x1 ? 1 : -1;
|
||||
let dy = -Math.abs(y1 - y0);
|
||||
let sy = y0 < y1 ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
if (x0 >= 0 && y0 >= 0 && x0 < image_data.width && y0 < image_data.height) {
|
||||
let base = (y0 * image_data.width + x0) * 4;
|
||||
image_data.data[base + 0] = value[0];
|
||||
image_data.data[base + 1] = value[1];
|
||||
image_data.data[base + 2] = value[2];
|
||||
image_data.data[base + 3] = value[3];
|
||||
}
|
||||
|
||||
if (x0 == x1 && y0 == y1) {
|
||||
break;
|
||||
}
|
||||
let e2 = 2 * error;
|
||||
if (e2 >= dy) {
|
||||
if (x0 == x1) {
|
||||
break;
|
||||
}
|
||||
error += dy;
|
||||
x0 = Math.round(x0 + sx);
|
||||
}
|
||||
if (e2 <= dx) {
|
||||
if (y0 == y1) {
|
||||
break;
|
||||
}
|
||||
error += dx;
|
||||
y0 = Math.round(y0 + sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw_activity_to_tile(image_data, width, height, ul, lr, activity) {
|
||||
let color = this.activity_to_color(activity);
|
||||
if (activity?.map?.polyline) {
|
||||
let last;
|
||||
for (let pt of polyline.decode(activity.map.polyline)) {
|
||||
let px = [
|
||||
Math.floor(width * (pt[1] - ul.lng) / (lr.lng - ul.lng)),
|
||||
Math.floor(height * (pt[0] - ul.lat) / (lr.lat - ul.lat)),
|
||||
];
|
||||
if (last) {
|
||||
this.line(image_data, last[0], last[1], px[0], px[1], color);
|
||||
}
|
||||
last = px;
|
||||
}
|
||||
}
|
||||
if (activity?.segments) {
|
||||
for (let segment of activity.segments) {
|
||||
let last;
|
||||
for (let pt of segment) {
|
||||
let px = [
|
||||
Math.floor(width * (pt.lon - ul.lng) / (lr.lng - ul.lng)),
|
||||
Math.floor(height * (pt.lat - ul.lat) / (lr.lat - ul.lat)),
|
||||
];
|
||||
if (last) {
|
||||
this.line(image_data, last[0], last[1], px[0], px[1], color);
|
||||
}
|
||||
last = px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async on_upload(event) {
|
||||
try {
|
||||
let file = event.srcElement.files[0];
|
||||
let xml = await file.text();
|
||||
let gpx = gpx_parse(xml);
|
||||
let blob_id = await tfrpc.rpc.store_blob(xml);
|
||||
console.log('blob_id = ', blob_id);
|
||||
console.log(gpx);
|
||||
let message = {
|
||||
type: 'gg-activity',
|
||||
mentions: [
|
||||
{
|
||||
link: `https://${gpx.link}/activity/${gpx.time}`,
|
||||
name: 'activity_url',
|
||||
},
|
||||
{
|
||||
link: blob_id,
|
||||
name: 'activity_data',
|
||||
}
|
||||
],
|
||||
};
|
||||
console.log('id =', this.id, 'message = ', message);
|
||||
let id = await tfrpc.rpc.appendMessage(this.id, message);
|
||||
console.log('appended message', id);
|
||||
alert('Activity uploaded.');
|
||||
await this.get_activities_from_ssb();
|
||||
} catch (e) {
|
||||
alert(`Error: ${JSON.stringify(e, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
upload() {
|
||||
let input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = (event) => this.on_upload(event);
|
||||
input.click();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.user?.credentials?.session?.name) {
|
||||
return html`<div>Please <a target="_top" href="/login">login</a> to Tilde Friends, first.</div>`;
|
||||
}
|
||||
if (!this.strava?.access_token) {
|
||||
let strava_url = `https://www.strava.com/oauth/authorize?client_id=${k_client_id}&redirect_uri=${k_redirect_url}&response_type=code&approval_prompt=auto&scope=activity%3Aread&state=${g_data.state}`;
|
||||
return html`
|
||||
<div style="display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
|
||||
<div style="flex: 1 1">Please <a target="_top" href=${strava_url}>login</a> to Strava.</div>
|
||||
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.id}</span>
|
||||
<input type="button" value="📁" @click=${this.upload}></input>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
|
||||
<h1>Welcome, ${this.user.credentials.session.name}</h1>
|
||||
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.id}</span>
|
||||
<input type="button" value="📁" @click=${this.upload}></input>
|
||||
</div>
|
||||
<h3 ?hidden=${!this.status?.text}>${this.status?.text} <progress ?hidden=${!this.status?.max} value=${this.status?.value} max=${this.status?.max}>${this.status?.value}</progress></h3>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('gg-app', GgAppElement);
|
20
apps/gg/strava.js
Normal file
20
apps/gg/strava.js
Normal file
@ -0,0 +1,20 @@
|
||||
const k_client_id = '28276';
|
||||
const k_client_secret = '3123f1f5afe132d9731111066d1d17bdb22ef27e';
|
||||
const k_access_token = 'f753e77764c26252bd2d80e7c5cc17ace51a8864';
|
||||
const k_refresh_token = 'f58d8e1b5a3ec3bf96e681589d5014f9a294f5a4';
|
||||
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
|
||||
|
||||
export async function refresh_token(token) {
|
||||
let r = await fetch('https://www.strava.com/api/v3/oauth/token', {
|
||||
method: 'POST',
|
||||
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&refresh_token=${token.refresh_token}&grant_type=refresh_token`,
|
||||
});
|
||||
return r?.body ? JSON.parse(utf8Decode(r.body)) : undefined;
|
||||
}
|
||||
|
||||
export async function authorization_code(code) {
|
||||
return await fetch('https://www.strava.com/api/v3/oauth/token', {
|
||||
method: 'POST',
|
||||
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&code=${code}&grant_type=authorization_code`,
|
||||
});
|
||||
}
|
48
apps/sneaker/lit-all.min.js
vendored
48
apps/sneaker/lit-all.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -118,17 +118,23 @@ class TfSneakerAppElement extends LitElement {
|
||||
zip.file(`message/classic/${this.sanitize(id)}.ndjson`, all_messages);
|
||||
|
||||
let blobs = await tfrpc.rpc.query(
|
||||
`SELECT blobs.id
|
||||
`SELECT messages_refs.ref AS id
|
||||
FROM messages
|
||||
JOIN messages_refs ON messages.id = messages_refs.message
|
||||
JOIN blobs ON messages_refs.ref = blobs.id
|
||||
WHERE messages.author = ?`,
|
||||
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 = await tfrpc.rpc.get_blob(row.id);
|
||||
zip.file(`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, new Uint8Array(blob));
|
||||
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++;
|
||||
}
|
||||
|
||||
|
48
apps/ssb/lit-all.min.js
vendored
48
apps/ssb/lit-all.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -13,4 +13,5 @@ import * as tf_tab_news from './tf-tab-news.js';
|
||||
import * as tf_tab_news_feed from './tf-tab-news-feed.js';
|
||||
import * as tf_tab_search from './tf-tab-search.js';
|
||||
import * as tf_tab_connections from './tf-tab-connections.js';
|
||||
import * as tf_tab_query from './tf-tab-query.js';
|
||||
import * as tf_tag from './tf-tag.js';
|
@ -68,6 +68,8 @@ class TfElement extends LitElement {
|
||||
this.tab = 'connections';
|
||||
} else if (this.hash === '#mentions') {
|
||||
this.tab = 'mentions';
|
||||
} else if (this.hash.startsWith('#sql=')) {
|
||||
this.tab = 'query';
|
||||
} else {
|
||||
this.tab = 'news';
|
||||
}
|
||||
@ -245,13 +247,16 @@ class TfElement extends LitElement {
|
||||
if (confirm("Are you sure you want to create a new identity?")) {
|
||||
await tfrpc.rpc.createIdentity();
|
||||
this.ids = (await tfrpc.rpc.getIdentities()) || [];
|
||||
if (this.ids && !this.whoami) {
|
||||
this.whoami = this.ids[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_id_picker() {
|
||||
return html`
|
||||
<tf-id-picker id="picker" selected=${this.whoami} .ids=${this.ids} @change=${this._handle_whoami_changed}></tf-id-picker>
|
||||
<button @click=${this.create_identity}>Create Identity</button>
|
||||
<button @click=${this.create_identity} id="create_identity">Create Identity</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -293,7 +298,7 @@ class TfElement extends LitElement {
|
||||
let users = this.users;
|
||||
if (this.tab === 'news') {
|
||||
return html`
|
||||
<tf-tab-news .following=${this.following} whoami=${this.whoami} .users=${this.users} hash=${this.hash} .unread=${this.unread} @refresh=${() => this.unread = []}></tf-tab-news>
|
||||
<tf-tab-news id="tf-tab-news" .following=${this.following} whoami=${this.whoami} .users=${this.users} hash=${this.hash} .unread=${this.unread} @refresh=${() => this.unread = []}></tf-tab-news>
|
||||
`;
|
||||
} else if (this.tab === 'connections') {
|
||||
return html`
|
||||
@ -307,6 +312,10 @@ class TfElement extends LitElement {
|
||||
return html`
|
||||
<tf-tab-search .following=${this.following} whoami=${this.whoami} .users=${this.users} query=${this.hash?.startsWith('#q=') ? decodeURIComponent(this.hash.substring(3)) : null}></tf-tab-search>
|
||||
`;
|
||||
} else if (this.tab === 'query') {
|
||||
return html`
|
||||
<tf-tab-query .following=${this.following} whoami=${this.whoami} .users=${this.users} query=${this.hash?.startsWith('#sql=') ? decodeURIComponent(this.hash.substring(5)) : null}></tf-tab-query>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -318,6 +327,8 @@ class TfElement extends LitElement {
|
||||
await tfrpc.rpc.setHash('#connections');
|
||||
} else if (tab === 'mentions') {
|
||||
await tfrpc.rpc.setHash('#mentions');
|
||||
} else if (tab === 'query') {
|
||||
await tfrpc.rpc.setHash('#sql=');
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,7 +336,6 @@ class TfElement extends LitElement {
|
||||
let self = this;
|
||||
|
||||
if (!this.loading && this.whoami && this.loaded !== this.whoami) {
|
||||
console.log(`starting loading ${this.whoami} ${this.loaded}`);
|
||||
this.loading = true;
|
||||
this.load().finally(function() {
|
||||
self.loading = false;
|
||||
@ -338,6 +348,7 @@ class TfElement extends LitElement {
|
||||
<input type="button" class="tab" value="Connections" ?disabled=${self.tab == 'connections'} @click=${() => self.set_tab('connections')}></input>
|
||||
<input type="button" class="tab" value="Mentions" ?disabled=${self.tab == 'mentions'} @click=${() => self.set_tab('mentions')}></input>
|
||||
<input type="button" class="tab" value="Search" ?disabled=${self.tab == 'search'} @click=${() => self.set_tab('search')}></input>
|
||||
<input type="button" class="tab" value="Query" ?disabled=${self.tab == 'query'} @click=${() => self.set_tab('query')}></input>
|
||||
</div>
|
||||
`;
|
||||
let contents =
|
||||
@ -355,4 +366,4 @@ class TfElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-app', TfElement);
|
||||
customElements.define('tf-app', TfElement);
|
||||
|
@ -372,7 +372,7 @@ class TfComposeElement extends LitElement {
|
||||
${Object.values(draft.mentions || {}).map(x => self.render_mention(x))}
|
||||
${this.render_content_warning()}
|
||||
${this.render_attach_app()}
|
||||
<input type="button" value="Submit" @click=${this.submit}></input>
|
||||
<input type="button" id="submit" value="Submit" @click=${this.submit}></input>
|
||||
<input type="button" value="Attach" @click=${this.attach}></input>
|
||||
${this.render_attach_app_button()}
|
||||
<input type="button" value="Discard" @click=${this.discard}></input>
|
||||
@ -381,4 +381,4 @@ class TfComposeElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-compose', TfComposeElement);
|
||||
customElements.define('tf-compose', TfComposeElement);
|
||||
|
@ -14,7 +14,6 @@ class TfIdentityPickerElement extends LitElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.ids = [];
|
||||
}
|
||||
|
||||
@ -34,4 +33,4 @@ class TfIdentityPickerElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-id-picker', TfIdentityPickerElement);
|
||||
customElements.define('tf-id-picker', TfIdentityPickerElement);
|
||||
|
@ -11,7 +11,7 @@ class TfMessageElement extends LitElement {
|
||||
message: {type: Object},
|
||||
users: {type: Object},
|
||||
drafts: {type: Object},
|
||||
raw: {type: Boolean},
|
||||
format: {type: String},
|
||||
blog_data: {type: String},
|
||||
expanded: {type: Object},
|
||||
decrypted: {type: Object},
|
||||
@ -27,7 +27,7 @@ class TfMessageElement extends LitElement {
|
||||
this.message = {};
|
||||
this.users = {};
|
||||
this.drafts = {};
|
||||
this.raw = false;
|
||||
this.format = 'message';
|
||||
this.expanded = {};
|
||||
this.decrypted = undefined;
|
||||
}
|
||||
@ -255,16 +255,30 @@ class TfMessageElement extends LitElement {
|
||||
content = this.decrypted;
|
||||
}
|
||||
let self = this;
|
||||
let raw_button = this.raw ?
|
||||
html`<input type="button" value="Message" @click=${() => self.raw = false}></input>` :
|
||||
html`<input type="button" value="Raw" @click=${() => self.raw = true}></input>`;
|
||||
let raw_button;
|
||||
switch (this.format) {
|
||||
case 'raw':
|
||||
if (content?.type == 'post' || content?.type == 'blog') {
|
||||
raw_button = html`<input type="button" value="Markdown" @click=${() => self.format = 'md'}></input>`;
|
||||
} else {
|
||||
raw_button = html`<input type="button" value="Message" @click=${() => self.format = 'message'}></input>`;
|
||||
}
|
||||
break;
|
||||
case 'md':
|
||||
raw_button = html`<input type="button" value="Message" @click=${() => self.format = 'message'}></input>`;
|
||||
break;
|
||||
default:
|
||||
raw_button = html`<input type="button" value="Raw" @click=${() => self.format = 'raw'}></input>`;
|
||||
break;
|
||||
}
|
||||
function small_frame(inner) {
|
||||
let body;
|
||||
return html`
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere">
|
||||
<tf-user id=${self.message.author} .users=${self.users}></tf-user>
|
||||
<span style="padding-right: 8px"><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date(self.message.timestamp).toLocaleString()}</span>
|
||||
${raw_button}
|
||||
${self.raw ? self.render_raw() : inner}
|
||||
${self.format == 'raw' ? self.render_raw() : inner}
|
||||
${self.render_votes()}
|
||||
</div>
|
||||
`;
|
||||
@ -342,9 +356,18 @@ class TfMessageElement extends LitElement {
|
||||
<input type="button" value="Reply" @click=${this.show_reply}></input>
|
||||
`;
|
||||
let self = this;
|
||||
let body = this.raw ?
|
||||
this.render_raw() :
|
||||
unsafeHTML(tfutils.markdown(content.text));
|
||||
let body;
|
||||
switch (this.format) {
|
||||
case 'raw':
|
||||
body = this.render_raw();
|
||||
break;
|
||||
case 'md':
|
||||
body = html`<code style="white-space: pre-wrap; overflow-wrap: anywhere">${content.text}</code>`;
|
||||
break;
|
||||
case 'message':
|
||||
body = unsafeHTML(tfutils.markdown(content.text));
|
||||
break;
|
||||
}
|
||||
let content_warning = html`
|
||||
<div class="content_warning" @click=${x => this.toggle_expanded(':cw')}>${content.contentWarning}</div>
|
||||
`;
|
||||
@ -406,9 +429,16 @@ class TfMessageElement extends LitElement {
|
||||
this.expanded[(this.message.id || '') + ':blog'] ?
|
||||
html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` :
|
||||
undefined;
|
||||
let body = this.raw ?
|
||||
this.render_raw() :
|
||||
html`
|
||||
let body;
|
||||
switch (this.format) {
|
||||
case 'raw':
|
||||
body = this.render_raw();
|
||||
break;
|
||||
case 'md':
|
||||
body = content.summary;
|
||||
break;
|
||||
case 'message':
|
||||
body = html`
|
||||
<div
|
||||
style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer"
|
||||
@click=${x => self.toggle_expanded(':blog')}>
|
||||
@ -420,6 +450,8 @@ class TfMessageElement extends LitElement {
|
||||
</div>
|
||||
${payload}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
return html`
|
||||
<style>
|
||||
code {
|
||||
|
@ -70,7 +70,7 @@ class TfTabNewsFeedElement extends LitElement {
|
||||
WITH news AS (SELECT messages.*
|
||||
FROM messages
|
||||
JOIN json_each(?) AS following ON messages.author = following.value
|
||||
WHERE messages.timestamp > ?
|
||||
WHERE messages.timestamp > ? AND messages.timestamp < ?
|
||||
ORDER BY messages.timestamp DESC)
|
||||
SELECT messages.*
|
||||
FROM news
|
||||
@ -87,6 +87,11 @@ class TfTabNewsFeedElement extends LitElement {
|
||||
[
|
||||
JSON.stringify(this.following),
|
||||
this.start_time,
|
||||
/*
|
||||
** Don't show messages more than a day into the future to prevent
|
||||
** messages with far-future timestamps from staying at the top forever.
|
||||
*/
|
||||
new Date().valueOf() + 24 * 60 * 60 * 1000,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -109,11 +109,11 @@ class TfTabNewsElement extends LitElement {
|
||||
<div><input type="button" value=${this.new_messages_text()} @click=${this.show_more}></input></div>
|
||||
<a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a>
|
||||
<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div>
|
||||
<div><tf-compose whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} @tf-draft=${this.draft}></tf-compose></div>
|
||||
<div><tf-compose id="tf-compose" whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} @tf-draft=${this.draft}></tf-compose></div>
|
||||
${profile}
|
||||
<tf-tab-news-feed id="news" whoami=${this.whoami} .users=${this.users} .following=${this.following} hash=${this.hash} .drafts=${this.drafts} .expanded=${this.expanded} @tf-draft=${this.draft} @tf-expand=${this.on_expand}></tf-tab-news-feed>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-tab-news', TfTabNewsElement);
|
||||
customElements.define('tf-tab-news', TfTabNewsElement);
|
||||
|
114
apps/ssb/tf-tab-query.js
Normal file
114
apps/ssb/tf-tab-query.js
Normal file
@ -0,0 +1,114 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfTabQueryElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
following: {type: Array},
|
||||
query: {type: String},
|
||||
expanded: {type: Object},
|
||||
results: {type: Array},
|
||||
error: {type: Object},
|
||||
duration: {type: Number},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.following = [];
|
||||
this.expanded = {};
|
||||
this.duration = undefined;
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
console.log('Searching...', this.whoami, query);
|
||||
this.results = [];
|
||||
this.error = undefined;
|
||||
this.duration = undefined;
|
||||
let search = this.renderRoot.getElementById('search');
|
||||
if (search) {
|
||||
search.value = query;
|
||||
search.focus();
|
||||
}
|
||||
await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query));
|
||||
let start_time = new Date();
|
||||
try {
|
||||
this.results = await tfrpc.rpc.query(query, [])
|
||||
} catch (error) {
|
||||
this.error = error;
|
||||
}
|
||||
let end_time = new Date();
|
||||
this.duration = (end_time - start_time).valueOf();
|
||||
console.log('Done.');
|
||||
search = this.renderRoot.getElementById('search');
|
||||
if (search) {
|
||||
search.value = query;
|
||||
search.focus();
|
||||
}
|
||||
}
|
||||
|
||||
search_keydown(event) {
|
||||
if (event.keyCode == 13 && event.ctrlKey) {
|
||||
this.query = this.renderRoot.getElementById('search').value;
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
on_expand(event) {
|
||||
if (event.detail.expanded) {
|
||||
let expand = {};
|
||||
expand[event.detail.id] = true;
|
||||
this.expanded = Object.assign({}, this.expanded, expand);
|
||||
} else {
|
||||
delete this.expanded[event.detail.id];
|
||||
this.expanded = Object.assign({}, this.expanded);
|
||||
}
|
||||
}
|
||||
|
||||
render_results() {
|
||||
if (!this.results?.length) {
|
||||
return html`<div>No results.</div>`;
|
||||
} else {
|
||||
let keys = Object.keys(this.results[0]).sort();
|
||||
return html`<table style="width: 100%; max-width: 100%">
|
||||
<tr>${keys.map(key => html`<th>${key}</th>`)}</tr>
|
||||
${this.results.map(row => html`<tr>${keys.map(key => html`<td>${row[key]}</td>`)}</tr>`)}
|
||||
</table>`;
|
||||
}
|
||||
}
|
||||
|
||||
render_error() {
|
||||
if (this.error) {
|
||||
return html`<h2 style="color: red">${this.error.message}</h2>
|
||||
<pre style="color: red">${this.error.stack}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.query !== this.last_query) {
|
||||
this.last_query = this.query;
|
||||
this.search(this.query);
|
||||
}
|
||||
let self = this;
|
||||
return html`
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<textarea id="search" rows=8 style="flex: 1" @keydown=${this.search_keydown}>${this.query}</textarea>
|
||||
<input type="button" value="Execute" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}></input>
|
||||
</div>
|
||||
<div ?hidden=${this.duration === undefined}>Took ${this.duration / 1000.0} seconds.</div>
|
||||
<div ?hidden=${this.duration !== undefined}>Executing...</div>
|
||||
${this.render_error()}
|
||||
${this.render_results()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-tab-query', TfTabQueryElement);
|
@ -124,9 +124,14 @@ function socket(request, response, client) {
|
||||
options.credentials = credentials;
|
||||
options.packageOwner = packageOwner;
|
||||
options.packageName = packageName;
|
||||
options.url = message.url;
|
||||
let sessionId = makeSessionId();
|
||||
if (blobId) {
|
||||
process = await core.getSessionProcessBlob(blobId, sessionId, options);
|
||||
if (message.edit_only) {
|
||||
response.send(JSON.stringify({action: 'ready', edit_only: true}), 0x1);
|
||||
} else {
|
||||
process = await core.getSessionProcessBlob(blobId, sessionId, options);
|
||||
}
|
||||
}
|
||||
if (process) {
|
||||
process.app.readOutput(function(message) {
|
||||
|
126
core/auth.html
126
core/auth.html
@ -2,18 +2,130 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Tilde Friends Sign-in</title>
|
||||
<script>
|
||||
function showHideConfirm() {
|
||||
document.getElementById("confirmPassword").style.display = document.getElementById("register").checked ? "block" : "none";
|
||||
}
|
||||
</script>
|
||||
<link type="text/css" rel="stylesheet" href="/static/style.css">
|
||||
<link type="image/png" rel="shortcut icon" href="/static/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--HEAD-->
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="text-align: center">Tilde Friends Sign-in</h1>
|
||||
<div id="content"><!--SESSION--></div>
|
||||
<tf-auth id="auth"></tf-auth>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script type="module">
|
||||
import {LitElement, html} from '/static/lit/lit-all.min.js';
|
||||
let g_data = $AUTH_DATA;
|
||||
let app = document.getElementById('auth');
|
||||
Object.assign(app, g_data);
|
||||
|
||||
class TfAuthElement extends LitElement {
|
||||
static get_properties() {
|
||||
return {
|
||||
name: {type: String},
|
||||
tab: {type: String},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.tab = 'login';
|
||||
}
|
||||
|
||||
tab_changed(name) {
|
||||
this.tab = name;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
return html`
|
||||
<style>
|
||||
[name="tab"] {
|
||||
display: none;
|
||||
}
|
||||
[name="tab"]+label {
|
||||
background-color: #586e75;
|
||||
padding: 1em;
|
||||
display: inline-block;
|
||||
flex: 1 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
[name="tab"]+label:hover {
|
||||
background-color: #dc322f;
|
||||
}
|
||||
[name="tab"]:checked+label {
|
||||
background-color: #93a1a1;
|
||||
border: 2px solid #eee8d5;
|
||||
}
|
||||
.error {
|
||||
color: #f00;
|
||||
border: 1px solid #f00;
|
||||
margin: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
form label {
|
||||
padding-top: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
form input {
|
||||
font-size: x-large;
|
||||
padding: 4px;
|
||||
}
|
||||
input[type="submit"] {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
padding: 1em;
|
||||
}
|
||||
</style>
|
||||
<div style="display: flex; flex-direction: column; max-width: 1280px; margin: auto">
|
||||
<h1 ?hidden=${this.name}>Welcome.</h1>
|
||||
<h1 ?hidden=${this.name === undefined}>Welcome, ${this.name}.</h1>
|
||||
|
||||
<div style="display: flex; flex-direction: row; width: 100%">
|
||||
<input type="radio" name="tab" id="login" value="Login" ?checked=${this.tab == 'login'} @change=${() => self.tab_changed('login')}></input>
|
||||
<label for="login" id="login_label">Login</label>
|
||||
|
||||
<input type="radio" name="tab" id="register" value="Register" ?checked=${this.tab == 'register'} @change=${() => self.tab_changed('register')}></input>
|
||||
<label for="register" id="register_label">Register</label>
|
||||
|
||||
<input type="radio" name="tab" id="guest" value="Guest" ?checked=${this.tab == 'guest'} @change=${() => self.tab_changed('guest')}></input>
|
||||
<label for="guest" id="guest_label">Guest</label>
|
||||
</div>
|
||||
|
||||
<div ?hidden=${this.tab != 'login' && this.tab != 'register'}>
|
||||
<div id="error" ?hidden=${this.error === undefined} class="error">
|
||||
${this.error}
|
||||
</div>
|
||||
<form method="POST" style="display: flex; flex-direction: column">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name"></input>
|
||||
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password"></input>
|
||||
|
||||
<label ?hidden=${this.tab != 'register'} for="confirm">Confirm Password:</label>
|
||||
<input ?hidden=${this.tab != 'register'} type="password" id="confirm" name="confirm"></input>
|
||||
|
||||
<input id="loginButton" type="submit" name="submit" value="Login"></input>
|
||||
<input type="hidden" name="register" value="${this.tab == 'register' ? 1 : 0}"></input>
|
||||
</form>
|
||||
</div>
|
||||
<div ?hidden=${this.tab != 'guest'}>
|
||||
<form method="POST" style="display: flex; flex-direction: column">
|
||||
<input type="submit" id="guestButton" name="guestButton" value="Proceed as Guest"></input>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div ?hidden=${this.have_administrator} class="notice">
|
||||
There is currently no administrator. You will be made administrator.
|
||||
</div>
|
||||
|
||||
<h2>Code of Conduct</h2>
|
||||
<textarea readonly rows="20" cols="80" style="resize: none">${this.code_of_conduct}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-auth', TfAuthElement);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
51
core/auth.js
51
core/auth.js
@ -121,6 +121,7 @@ function handler(request, response) {
|
||||
|
||||
let formData = form.decodeForm(request.query);
|
||||
|
||||
print(request.method, utf8Decode(request.body), JSON.stringify(formData));
|
||||
if (request.method == "POST" || formData.submit) {
|
||||
sessionIsNew = true;
|
||||
formData = form.decodeForm(utf8Decode(request.body), formData);
|
||||
@ -178,46 +179,16 @@ function handler(request, response) {
|
||||
} else {
|
||||
File.readFile("core/auth.html").then(function(data) {
|
||||
let html = utf8Decode(data);
|
||||
let contents = "";
|
||||
|
||||
if (entry) {
|
||||
if (sessionIsNew) {
|
||||
contents += '<div>Welcome back, ' + entry.name + '.</div>\n';
|
||||
} else {
|
||||
contents += '<div>You are already logged in, ' + entry.name + '.</div>\n';
|
||||
}
|
||||
contents += '<div><a href="/login/logout">Logout</a></div>\n';
|
||||
} else {
|
||||
contents += '<form method="POST">\n';
|
||||
if (loginError) {
|
||||
contents += "<p>" + loginError + "</p>\n";
|
||||
}
|
||||
contents += '<div id="auth_greeting"><b>Halt. Who goes there?</b></div>\n'
|
||||
contents += '<div id="auth">\n';
|
||||
contents += '<div id="auth_login">\n'
|
||||
if (noAdministrator()) {
|
||||
contents += '<div class="notice">There is currently no administrator. You will be made administrator.</div>\n';
|
||||
}
|
||||
contents += '<div><label for="name">Name:</label> <input type="text" id="name" name="name" value=""></div>\n';
|
||||
contents += '<div><label for="password">Password:</label> <input type="password" id="password" name="password" value=""></div>\n';
|
||||
contents += '<div id="confirmPassword" style="display: none"><label for="confirm">Confirm:</label> <input type="password" id="confirm" name="confirm" value=""></div>\n';
|
||||
contents += '<div><input type="checkbox" id="register" name="register" value="1" onchange="showHideConfirm()"> <label for="register">Register a new account</label></div>\n';
|
||||
contents += '<div><input id="loginButton" type="submit" name="submit" value="Login"></div>\n';
|
||||
contents += '</div>';
|
||||
contents += '<div class="auth_or"> - or - </div>';
|
||||
contents += '<div id="auth_guest">\n';
|
||||
contents += '<input id="guestButton" type="submit" name="submit" value="Proceeed as Guest">\n';
|
||||
contents += '</div>\n';
|
||||
contents += '</div>\n';
|
||||
contents += '<div style="text-align: center">\n';
|
||||
contents += '<h2>Code of Conduct</h2>\n';
|
||||
contents += `<div><textarea readonly rows=20 cols=80>${core.globalSettings.code_of_conduct}</textarea></div>\n`;
|
||||
contents += '</div>\n';
|
||||
contents += '</form>';
|
||||
}
|
||||
let text = html.replace("<!--SESSION-->", contents);
|
||||
response.writeHead(200, {"Content-Type": "text/html; charset=utf-8", "Set-Cookie": cookie, "Content-Length": text.length});
|
||||
response.end(text);
|
||||
let auth_data = {
|
||||
session_is_new: sessionIsNew,
|
||||
name: entry?.name,
|
||||
error: loginError,
|
||||
code_of_conduct: core.globalSettings.code_of_conduct,
|
||||
have_administrator: !noAdministrator(),
|
||||
};
|
||||
html = utf8Encode(html.replace('$AUTH_DATA', JSON.stringify(auth_data)));
|
||||
response.writeHead(200, {"Content-Type": "text/html; charset=utf-8", "Set-Cookie": cookie, "Content-Length": html.length});
|
||||
response.end(html);
|
||||
}).catch(function(error) {
|
||||
response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Connection": "close"});
|
||||
response.end("404 File not found");
|
||||
|
170
core/client.js
170
core/client.js
@ -6,9 +6,6 @@ let gCurrentFile;
|
||||
let gFiles = {};
|
||||
let gApp = {files: {}, emoji: '📦'};
|
||||
let gEditor;
|
||||
let gSplit;
|
||||
let gGraphs = {};
|
||||
let gTimeSeries = {};
|
||||
let gOriginalInput;
|
||||
|
||||
let kErrorColor = "#dc322f";
|
||||
@ -98,9 +95,9 @@ class TfNavigationElement extends LitElement {
|
||||
|
||||
render_login() {
|
||||
if (this?.credentials?.session?.name) {
|
||||
return html`<a href="/login/logout?return=${url() + hash()}">logout ${this.credentials.session.name}</a>`;
|
||||
return html`<a id="login" href="/login/logout?return=${url() + hash()}">logout ${this.credentials.session.name}</a>`;
|
||||
} else {
|
||||
return html`<a href="/login?return=${url() + hash()}">login</a>`;
|
||||
return html`<a id="login" href="/login?return=${url() + hash()}">login</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -385,18 +382,18 @@ function editing() {
|
||||
return document.getElementById("editPane").style.display != 'none';
|
||||
}
|
||||
|
||||
function is_edit_only() {
|
||||
return window.location.search == '?editonly=1' || window.innerWidth < 1024;
|
||||
}
|
||||
|
||||
function edit() {
|
||||
if (editing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem('editing', '1');
|
||||
if (gSplit) {
|
||||
gSplit.destroy();
|
||||
gSplit = undefined;
|
||||
}
|
||||
gSplit = Split(['#editPane', '#viewPane'], {minSize: 0});
|
||||
document.getElementById("editPane").style.display = 'flex';
|
||||
document.getElementById('viewPane').style.display = is_edit_only() ? 'none' : 'flex';
|
||||
|
||||
ensureLoaded([
|
||||
{tagName: "script", attributes: {src: "/codemirror/codemirror.min.js"}},
|
||||
@ -431,24 +428,6 @@ function trace() {
|
||||
window.open(`/speedscope/#profileURL=${encodeURIComponent('/trace')}`);
|
||||
}
|
||||
|
||||
function stats() {
|
||||
window.localStorage.setItem('stats', '1');
|
||||
document.getElementById("statsPane").style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeStats() {
|
||||
window.localStorage.setItem('stats', '0');
|
||||
document.getElementById("statsPane").style.display = 'none';
|
||||
}
|
||||
|
||||
function toggleStats() {
|
||||
if (document.getElementById("statsPane").style.display == 'none') {
|
||||
stats();
|
||||
} else {
|
||||
closeStats();
|
||||
}
|
||||
}
|
||||
|
||||
function guessMode(name) {
|
||||
return name.endsWith(".js") ? "javascript" :
|
||||
name.endsWith(".html") ? "htmlmixed" :
|
||||
@ -533,10 +512,7 @@ function load(path) {
|
||||
function closeEditor() {
|
||||
window.localStorage.setItem('editing', '0');
|
||||
document.getElementById("editPane").style.display = 'none';
|
||||
if (gSplit) {
|
||||
gSplit.destroy();
|
||||
gSplit = undefined;
|
||||
}
|
||||
document.getElementById('viewPane').style.display = 'flex';
|
||||
}
|
||||
|
||||
function explodePath() {
|
||||
@ -724,11 +700,13 @@ function api_requestPermission(permission, id) {
|
||||
|
||||
const k_options = [
|
||||
{
|
||||
id: 'allow',
|
||||
text: '✅ Allow',
|
||||
grant: ['allow once', 'allow'],
|
||||
|
||||
},
|
||||
{
|
||||
id: 'deny',
|
||||
text: '❌ Deny',
|
||||
grant: ['deny once', 'deny'],
|
||||
},
|
||||
@ -739,6 +717,7 @@ function api_requestPermission(permission, id) {
|
||||
for (let option of k_options) {
|
||||
let button = document.createElement('button');
|
||||
button.innerText = option.text;
|
||||
button.id = option.id;
|
||||
button.onclick = function() {
|
||||
resolve(option.grant[check.checked ? 1 : 0]);
|
||||
document.body.removeChild(outer);
|
||||
@ -772,6 +751,7 @@ function _receive_websocket_message(message) {
|
||||
send({event: "hashChange", hash: window.location.hash});
|
||||
}
|
||||
document.getElementsByTagName('tf-navigation')[0].version = message.version;
|
||||
document.getElementById('viewPane').style.display = message.edit_only ? 'none' : 'flex';
|
||||
send({action: 'enableStats', enabled: true});
|
||||
} else if (message && message.action == "ping") {
|
||||
send({action: "pong"});
|
||||
@ -804,57 +784,6 @@ function _receive_websocket_message(message) {
|
||||
};
|
||||
const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
|
||||
let graph_key = k_groups[key]?.group || key;
|
||||
let graph = gGraphs[graph_key];
|
||||
if (!graph) {
|
||||
graph = {
|
||||
chart: new SmoothieChart({
|
||||
millisPerPixel: 100,
|
||||
minValue: 0,
|
||||
grid: {
|
||||
millisPerLine: 1000,
|
||||
verticalSections: 10,
|
||||
},
|
||||
tooltip: true,
|
||||
}),
|
||||
canvas: document.createElement('canvas'),
|
||||
title: document.createElement('div'),
|
||||
series: [],
|
||||
};
|
||||
gGraphs[graph_key] = graph;
|
||||
graph.canvas.width = 240;
|
||||
graph.canvas.height = 64;
|
||||
graph.title.innerText = graph_key;
|
||||
graph.title.style.flex = '0';
|
||||
document.getElementById('graphs').appendChild(graph.title);
|
||||
document.getElementById('graphs').appendChild(graph.canvas);
|
||||
graph.chart.streamTo(graph.canvas, 1000);
|
||||
}
|
||||
|
||||
let timeseries = gTimeSeries[key];
|
||||
if (!timeseries) {
|
||||
let is_multi = key != graph_key || graph.series.length > 1;
|
||||
timeseries = new TimeSeries();
|
||||
gTimeSeries[key] = timeseries;
|
||||
graph.chart.addTimeSeries(timeseries, {lineWidth: 2, strokeStyle: is_multi ? k_colors[graph.series.length] : '#fff'});
|
||||
graph.series.push(k_groups[key]?.name || key);
|
||||
if (is_multi) {
|
||||
while (graph.title.firstChild) {
|
||||
graph.title.removeChild(graph.title.firstChild);
|
||||
}
|
||||
function makeColoredText(text, color) {
|
||||
let element = document.createElement('span');
|
||||
element.style.color = color;
|
||||
element.innerText = text;
|
||||
return element;
|
||||
}
|
||||
graph.title.appendChild(makeColoredText(graph_key + ':', '#fff'));
|
||||
for (let series of graph.series) {
|
||||
graph.title.appendChild(makeColoredText(' ' + series, k_colors[graph.series.indexOf(series)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
timeseries.append(now, message.stats[key]);
|
||||
|
||||
if (graph_key == 'cpu' || graph_key == 'rpc' || graph_key == 'store') {
|
||||
let line = document.getElementsByTagName('tf-navigation')[0].get_spark_line(graph_key, { max: 100 });
|
||||
line.dataset.emoji = {
|
||||
@ -903,22 +832,6 @@ function send(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function dragHover(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
let input = document.getElementById("input");
|
||||
if (event.type == "dragover") {
|
||||
if (!input.classList.contains("drop")) {
|
||||
input.classList.add("drop");
|
||||
gOriginalInput = input.value;
|
||||
input.value = "drop file to upload";
|
||||
}
|
||||
} else {
|
||||
input.classList.remove("drop");
|
||||
input.value = gOriginalInput;
|
||||
}
|
||||
}
|
||||
|
||||
function fixImage(sourceData, maxWidth, maxHeight, callback) {
|
||||
let result = sourceData;
|
||||
let image = new Image();
|
||||
@ -948,52 +861,6 @@ function sendImage(image) {
|
||||
});
|
||||
}
|
||||
|
||||
function fileDropRead(event) {
|
||||
sendImage(event.target.result);
|
||||
}
|
||||
|
||||
function fileDrop(event) {
|
||||
dragHover(event);
|
||||
|
||||
let done = false;
|
||||
if (!done) {
|
||||
let files = event.target.files || event.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let file = files[i];
|
||||
if (file.type.substring(0, "image/".length) == "image/") {
|
||||
let reader = new FileReader();
|
||||
reader.onloadend = fileDropRead;
|
||||
reader.readAsDataURL(file);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
let html = event.dataTransfer.getData("text/html");
|
||||
let match = /<img.*src="([^"]+)"/.exec(html);
|
||||
if (match) {
|
||||
sendImage(match[1]);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
let text = event.dataTransfer.getData("text/plain");
|
||||
if (text) {
|
||||
send(text);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enableDragDrop() {
|
||||
let body = document.body;
|
||||
body.addEventListener("dragover", dragHover);
|
||||
body.addEventListener("dragleave", dragHover);
|
||||
body.addEventListener("drop", fileDrop);
|
||||
}
|
||||
|
||||
function hashChange() {
|
||||
send({event: 'hashChange', hash: window.location.hash});
|
||||
}
|
||||
@ -1072,6 +939,8 @@ function connectSocket(path) {
|
||||
gSocket.send(JSON.stringify({
|
||||
action: "hello",
|
||||
path: connect_path,
|
||||
url: window.location.href,
|
||||
edit_only: editing() && is_edit_only(),
|
||||
api: Object.entries(k_api).map(([key, value]) => [].concat([key], value.args)),
|
||||
}));
|
||||
}
|
||||
@ -1153,7 +1022,6 @@ window.addEventListener("load", function() {
|
||||
window.addEventListener("message", message, false);
|
||||
window.addEventListener("online", connectSocket);
|
||||
document.getElementById("name").value = window.location.pathname;
|
||||
document.getElementById('closeStats').addEventListener('click', () => closeStats());
|
||||
document.getElementById('closeEditor').addEventListener('click', () => closeEditor());
|
||||
document.getElementById('save').addEventListener('click', () => save());
|
||||
document.getElementById('icon').addEventListener('click', () => changeIcon());
|
||||
@ -1162,10 +1030,6 @@ window.addEventListener("load", function() {
|
||||
event.preventDefault();
|
||||
trace();
|
||||
});
|
||||
document.getElementById('stats_button').addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
toggleStats();
|
||||
});
|
||||
for (let tag of document.getElementsByTagName('a')) {
|
||||
if (tag.accessKey) {
|
||||
tag.classList.add('tooltip_parent');
|
||||
@ -1190,7 +1054,6 @@ window.addEventListener("load", function() {
|
||||
tag.appendChild(tooltip);
|
||||
}
|
||||
}
|
||||
enableDragDrop();
|
||||
connectSocket(window.location.pathname);
|
||||
|
||||
if (window.localStorage.getItem('editing') == '1') {
|
||||
@ -1198,9 +1061,4 @@ window.addEventListener("load", function() {
|
||||
} else {
|
||||
closeEditor();
|
||||
}
|
||||
if (window.localStorage.getItem('stats') == '1') {
|
||||
stats();
|
||||
} else {
|
||||
closeStats();
|
||||
}
|
||||
});
|
||||
|
19
core/core.js
19
core/core.js
@ -72,6 +72,16 @@ const k_global_settings = {
|
||||
default_value: undefined,
|
||||
description: 'Comma-separated list of host names to which HTTP fetch requests are allowed. None if empty.',
|
||||
},
|
||||
blob_fetch_age_seconds: {
|
||||
type: 'integer',
|
||||
default_value: (platform() == 'android' ? 0.5 * 365 * 24 * 60 * 60 : undefined),
|
||||
description: 'Only blobs mentioned more recently than this age will be automatically fetched.',
|
||||
},
|
||||
blob_expire_age_seconds: {
|
||||
type: 'integer',
|
||||
default_value: (platform() == 'android' ? 1.0 * 365 * 24 * 60 * 60 : undefined),
|
||||
description: 'Blobs older than this will be automatically deleted.',
|
||||
},
|
||||
};
|
||||
|
||||
let gGlobalSettings = {
|
||||
@ -305,6 +315,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
throw Error(`Permission denied: ${permission}.`);
|
||||
}
|
||||
},
|
||||
url: options?.url,
|
||||
}
|
||||
};
|
||||
if (process.credentials?.permissions?.administration) {
|
||||
@ -327,7 +338,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
print('Done.');
|
||||
};
|
||||
imports.core.deleteUser = function(user) {
|
||||
return imports.core.permissionTest('delete_user').then(function() {
|
||||
return Promise.resolve(imports.core.permissionTest('delete_user')).then(function() {
|
||||
let db = new Database('auth');
|
||||
|
||||
db.remove('user:' + user);
|
||||
@ -391,7 +402,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
if (process.credentials &&
|
||||
process.credentials.session &&
|
||||
process.credentials.session.name) {
|
||||
return imports.core.permissionTest('ssb_append').then(function() {
|
||||
return Promise.resolve(imports.core.permissionTest('ssb_append')).then(function() {
|
||||
return ssb.appendMessageWithIdentity(process.credentials.session.name, id, message);
|
||||
});
|
||||
}
|
||||
@ -923,10 +934,6 @@ loadSettings().then(function() {
|
||||
return staticDirectoryHandler(request, response, 'deps/codemirror/', match[1]);
|
||||
} else if (match = /^\/speedscope\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/speedscope/', match[1]);
|
||||
} else if (match = /^\/split\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/split/', match[1]);
|
||||
} else if (match = /^\/smoothie\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/smoothie/', match[1]);
|
||||
} else if (match = /^\/static(\/.*)/.exec(request.uri)) {
|
||||
return staticFileHandler(request, response, null, match[1]);
|
||||
} else if (request.uri == "/robots.txt") {
|
||||
|
@ -9,13 +9,7 @@
|
||||
<body style="display: flex; flex-flow: column">
|
||||
<tf-navigation></tf-navigation>
|
||||
<div id="content" class="hbox" style="flex: 1 1; width: 100%">
|
||||
<div id="statsPane" class="vbox" style="display: none; flex 1 1">
|
||||
<div class="hbox">
|
||||
<input type="button" id="closeStats" name="closeStats" value="Close">
|
||||
</div>
|
||||
<div id="graphs" class="vbox" style="height: 100%"></div>
|
||||
</div>
|
||||
<div id="editPane" class="vbox" style="display: none">
|
||||
<div id="editPane" class="vbox" style="flex: 1 1; display: none">
|
||||
<div class="navigation hbox">
|
||||
<input type="button" id="closeEditor" name="closeEditor" value="Close">
|
||||
<input type="button" id="save" name="save" value="Save">
|
||||
@ -23,7 +17,6 @@
|
||||
<input type="text" id="name" name="name" style="flex: 1 1; min-width: 1em"></input>
|
||||
<input type="button" id="delete" name="delete" value="Delete">
|
||||
<input type="button" id="trace_button" value="Trace">
|
||||
<input type="button" id="stats_button" value="Stats">
|
||||
</div>
|
||||
<div class="hbox" style="height: 100%">
|
||||
<tf-files-pane></tf-files-pane>
|
||||
@ -34,13 +27,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="viewPane" class="vbox" style="flex: 1 0; overflow: auto">
|
||||
<div id="viewPane" class="vbox" style="flex: 1 1; overflow: auto">
|
||||
<iframe id="document" sandbox="allow-forms allow-scripts allow-top-navigation allow-modals allow-downloads" style="width: 100%; height: 100%; border: 0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script src="/split/split.min.js"></script>
|
||||
<script src="/smoothie/smoothie.js"></script>
|
||||
<script src="/static/client.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -64,11 +64,6 @@ a:active {
|
||||
color: #eee8d5;
|
||||
}
|
||||
|
||||
#input.drop {
|
||||
border: 2px solid;
|
||||
color: #cb4b16;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
@ -105,33 +100,6 @@ a:active {
|
||||
float: right;
|
||||
}
|
||||
|
||||
#auth_greeting {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#auth {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#auth_login {
|
||||
flex: 0 1 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.auth_or {
|
||||
flex: 0 1 auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
#auth_guest {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: #cb4b16;
|
||||
margin: 1em;
|
||||
|
2
deps/codemirror/annotatescrollbar.min.js
vendored
2
deps/codemirror/annotatescrollbar.min.js
vendored
@ -1 +1 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(t){"use strict";function e(t,e){function i(t){clearTimeout(n.doRedraw),n.doRedraw=setTimeout(function(){n.redraw()},t)}this.cm=t,this.options=e,this.buttonHeight=e.scrollButtonHeight||t.getOption("scrollButtonHeight"),this.annotations=[],this.doRedraw=this.doUpdate=null,this.div=t.getWrapperElement().appendChild(document.createElement("div")),this.div.style.cssText="position: absolute; right: 0; top: 0; z-index: 7; pointer-events: none",this.computeScale();var n=this;t.on("refresh",this.resizeHandler=function(){clearTimeout(n.doUpdate),n.doUpdate=setTimeout(function(){n.computeScale()&&i(20)},100)}),t.on("markerAdded",this.resizeHandler),t.on("markerCleared",this.resizeHandler),!1!==e.listenForChanges&&t.on("changes",this.changeHandler=function(){i(250)})}t.defineExtension("annotateScrollbar",function(t){return new e(this,t="string"==typeof t?{className:t}:t)}),t.defineOption("scrollButtonHeight",0),e.prototype.computeScale=function(){var t=this.cm,t=(t.getWrapperElement().clientHeight-t.display.barHeight-2*this.buttonHeight)/t.getScrollerElement().scrollHeight;if(t!=this.hScale)return this.hScale=t,!0},e.prototype.update=function(t){this.annotations=t,this.redraw()},e.prototype.redraw=function(t){!1!==t&&this.computeScale();var n=this.cm,e=this.hScale,i=document.createDocumentFragment(),o=this.annotations,r=n.getOption("lineWrapping"),a=r&&1.5*n.defaultTextHeight(),s=null,h=null;function l(t,e){var i;return s!=t.line&&(s=t.line,h=n.getLineHandle(t.line),(i=n.getLineHandleVisualStart(h))!=h&&(s=n.getLineNumber(i),h=i)),h.widgets&&h.widgets.length||r&&h.height>a?n.charCoords(t,"local")[e?"top":"bottom"]:n.heightAtLine(h,"local")+(e?0:h.height)}var d=n.lastLine();if(n.display.barWidth)for(var c,p=0;p<o.length;p++){var u=o[p];if(!(u.to.line>d)){for(var m,f,g=c||l(u.from,!0)*e,H=l(u.to,!1)*e;p<o.length-1&&!(o[p+1].to.line>d)&&!(H+.9<(c=l(o[p+1].from,!0)*e));)H=l((u=o[++p]).to,!1)*e;H!=g&&(m=Math.max(H-g,3),(f=i.appendChild(document.createElement("div"))).style.cssText="position: absolute; right: 0px; width: "+Math.max(n.display.barWidth-1,2)+"px; top: "+(g+this.buttonHeight)+"px; height: "+m+"px",f.className=this.options.className,u.id&&f.setAttribute("annotation-id",u.id))}}this.div.textContent="",this.div.appendChild(i)},e.prototype.clear=function(){this.cm.off("refresh",this.resizeHandler),this.cm.off("markerAdded",this.resizeHandler),this.cm.off("markerCleared",this.resizeHandler),this.changeHandler&&this.cm.off("changes",this.changeHandler),this.div.parentNode.removeChild(this.div)}});
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(t){"use strict";function e(t,e){function i(t){clearTimeout(n.doRedraw),n.doRedraw=setTimeout(function(){n.redraw()},t)}this.cm=t,this.options=e,this.buttonHeight=e.scrollButtonHeight||t.getOption("scrollButtonHeight"),this.annotations=[],this.doRedraw=this.doUpdate=null,this.div=t.getWrapperElement().appendChild(document.createElement("div")),this.div.style.cssText="position: absolute; right: 0; top: 0; z-index: 7; pointer-events: none",this.computeScale();var n=this;t.on("refresh",this.resizeHandler=function(){clearTimeout(n.doUpdate),n.doUpdate=setTimeout(function(){n.computeScale()&&i(20)},100)}),t.on("markerAdded",this.resizeHandler),t.on("markerCleared",this.resizeHandler),!1!==e.listenForChanges&&t.on("changes",this.changeHandler=function(){i(250)})}t.defineExtension("annotateScrollbar",function(t){return new e(this,t="string"==typeof t?{className:t}:t)}),t.defineOption("scrollButtonHeight",0),e.prototype.computeScale=function(){var t=this.cm,t=(t.getWrapperElement().clientHeight-t.display.barHeight-2*this.buttonHeight)/t.getScrollerElement().scrollHeight;if(t!=this.hScale)return this.hScale=t,!0},e.prototype.update=function(t){this.annotations=t,this.redraw()},e.prototype.redraw=function(t){!1!==t&&this.computeScale();var n=this.cm,e=this.hScale,i=document.createDocumentFragment(),o=this.annotations,r=n.getOption("lineWrapping"),a=r&&1.5*n.defaultTextHeight(),s=null,h=null;function l(t,e){var i;return s!=t.line&&(s=t.line,h=n.getLineHandle(t.line),(i=n.getLineHandleVisualStart(h))!=h)&&(s=n.getLineNumber(i),h=i),h.widgets&&h.widgets.length||r&&h.height>a?n.charCoords(t,"local")[e?"top":"bottom"]:n.heightAtLine(h,"local")+(e?0:h.height)}var d=n.lastLine();if(n.display.barWidth)for(var c,p=0;p<o.length;p++){var u=o[p];if(!(u.to.line>d)){for(var m,f,g=c||l(u.from,!0)*e,H=l(u.to,!1)*e;p<o.length-1&&!(o[p+1].to.line>d)&&!(H+.9<(c=l(o[p+1].from,!0)*e));)H=l((u=o[++p]).to,!1)*e;H!=g&&(m=Math.max(H-g,3),(f=i.appendChild(document.createElement("div"))).style.cssText="position: absolute; right: 0px; width: "+Math.max(n.display.barWidth-1,2)+"px; top: "+(g+this.buttonHeight)+"px; height: "+m+"px",f.className=this.options.className,u.id)&&f.setAttribute("annotation-id",u.id)}}this.div.textContent="",this.div.appendChild(i)},e.prototype.clear=function(){this.cm.off("refresh",this.resizeHandler),this.cm.off("markerAdded",this.resizeHandler),this.cm.off("markerCleared",this.resizeHandler),this.changeHandler&&this.cm.off("changes",this.changeHandler),this.div.parentNode.removeChild(this.div)}});
|
2
deps/codemirror/codemirror.min.js
vendored
2
deps/codemirror/codemirror.min.js
vendored
File diff suppressed because one or more lines are too long
2
deps/codemirror/css.min.js
vendored
2
deps/codemirror/css.min.js
vendored
File diff suppressed because one or more lines are too long
2
deps/codemirror/dialog.min.js
vendored
2
deps/codemirror/dialog.min.js
vendored
@ -1 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){function f(e,o,n){var e=e.getWrapperElement(),t=e.appendChild(document.createElement("div"));return t.className=n?"CodeMirror-dialog CodeMirror-dialog-bottom":"CodeMirror-dialog CodeMirror-dialog-top","string"==typeof o?t.innerHTML=o:t.appendChild(o),s.addClass(e,"dialog-opened"),t}function p(e,o){e.state.currentNotificationClose&&e.state.currentNotificationClose(),e.state.currentNotificationClose=o}s.defineExtension("openDialog",function(e,o,n){n=n||{},p(this,null);var t=f(this,e,n.bottom),i=!1,r=this;function u(e){"string"==typeof e?l.value=e:i||(i=!0,s.rmClass(t.parentNode,"dialog-opened"),t.parentNode.removeChild(t),r.focus(),n.onClose&&n.onClose(t))}var l=t.getElementsByTagName("input")[0];return l?(l.focus(),n.value&&(l.value=n.value,!1!==n.selectValueOnOpen&&l.select()),n.onInput&&s.on(l,"input",function(e){n.onInput(e,l.value,u)}),n.onKeyUp&&s.on(l,"keyup",function(e){n.onKeyUp(e,l.value,u)}),s.on(l,"keydown",function(e){n&&n.onKeyDown&&n.onKeyDown(e,l.value,u)||((27==e.keyCode||!1!==n.closeOnEnter&&13==e.keyCode)&&(l.blur(),s.e_stop(e),u()),13==e.keyCode&&o(l.value,e))}),!1!==n.closeOnBlur&&s.on(t,"focusout",function(e){null!==e.relatedTarget&&u()})):(e=t.getElementsByTagName("button")[0])&&(s.on(e,"click",function(){u(),r.focus()}),!1!==n.closeOnBlur&&s.on(e,"blur",u),e.focus()),u}),s.defineExtension("openConfirm",function(e,o,n){p(this,null);var t=f(this,e,n&&n.bottom),i=t.getElementsByTagName("button"),r=!1,u=this,l=1;function a(){r||(r=!0,s.rmClass(t.parentNode,"dialog-opened"),t.parentNode.removeChild(t),u.focus())}i[0].focus();for(var c=0;c<i.length;++c){var d=i[c];!function(o){s.on(d,"click",function(e){s.e_preventDefault(e),a(),o&&o(u)})}(o[c]),s.on(d,"blur",function(){--l,setTimeout(function(){l<=0&&a()},200)}),s.on(d,"focus",function(){++l})}}),s.defineExtension("openNotification",function(e,o){p(this,r);var n,t=f(this,e,o&&o.bottom),i=!1,e=o&&void 0!==o.duration?o.duration:5e3;function r(){i||(i=!0,clearTimeout(n),s.rmClass(t.parentNode,"dialog-opened"),t.parentNode.removeChild(t))}return s.on(t,"click",function(e){s.e_preventDefault(e),r()}),e&&(n=setTimeout(r,e)),r})});
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(s){function f(e,o,n){var e=e.getWrapperElement(),t=e.appendChild(document.createElement("div"));return t.className=n?"CodeMirror-dialog CodeMirror-dialog-bottom":"CodeMirror-dialog CodeMirror-dialog-top","string"==typeof o?t.innerHTML=o:t.appendChild(o),s.addClass(e,"dialog-opened"),t}function p(e,o){e.state.currentNotificationClose&&e.state.currentNotificationClose(),e.state.currentNotificationClose=o}s.defineExtension("openDialog",function(e,o,n){n=n||{},p(this,null);var t=f(this,e,n.bottom),i=!1,r=this;function u(e){"string"==typeof e?l.value=e:i||(i=!0,s.rmClass(t.parentNode,"dialog-opened"),t.parentNode.removeChild(t),r.focus(),n.onClose&&n.onClose(t))}var l=t.getElementsByTagName("input")[0];return l?(l.focus(),n.value&&(l.value=n.value,!1!==n.selectValueOnOpen)&&l.select(),n.onInput&&s.on(l,"input",function(e){n.onInput(e,l.value,u)}),n.onKeyUp&&s.on(l,"keyup",function(e){n.onKeyUp(e,l.value,u)}),s.on(l,"keydown",function(e){n&&n.onKeyDown&&n.onKeyDown(e,l.value,u)||((27==e.keyCode||!1!==n.closeOnEnter&&13==e.keyCode)&&(l.blur(),s.e_stop(e),u()),13==e.keyCode&&o(l.value,e))}),!1!==n.closeOnBlur&&s.on(t,"focusout",function(e){null!==e.relatedTarget&&u()})):(e=t.getElementsByTagName("button")[0])&&(s.on(e,"click",function(){u(),r.focus()}),!1!==n.closeOnBlur&&s.on(e,"blur",u),e.focus()),u}),s.defineExtension("openConfirm",function(e,o,n){p(this,null);var t=f(this,e,n&&n.bottom),i=t.getElementsByTagName("button"),r=!1,u=this,l=1;function a(){r||(r=!0,s.rmClass(t.parentNode,"dialog-opened"),t.parentNode.removeChild(t),u.focus())}i[0].focus();for(var c=0;c<i.length;++c){var d=i[c];!function(o){s.on(d,"click",function(e){s.e_preventDefault(e),a(),o&&o(u)})}(o[c]),s.on(d,"blur",function(){--l,setTimeout(function(){l<=0&&a()},200)}),s.on(d,"focus",function(){++l})}}),s.defineExtension("openNotification",function(e,o){p(this,r);var n,t=f(this,e,o&&o.bottom),i=!1,e=o&&void 0!==o.duration?o.duration:5e3;function r(){i||(i=!0,clearTimeout(n),s.rmClass(t.parentNode,"dialog-opened"),t.parentNode.removeChild(t))}return s.on(t,"click",function(e){s.e_preventDefault(e),r()}),e&&(n=setTimeout(r,e)),r})});
|
2
deps/codemirror/htmlmixed.min.js
vendored
2
deps/codemirror/htmlmixed.min.js
vendored
@ -1 +1 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol()&&(e.inTag+=" ")),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}(function(m){"use strict";var l={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function d(t,e){e=t.match(a[t=e]||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*")));return e?/^\s*(.*?)\s*$/.exec(e[2])[1]:""}function g(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function o(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],o=l.length-1;0<=o;o--)n.unshift(l[o])}m.defineMode("htmlmixed",function(i,t){var c=m.getMode(i,{name:"xml",htmlMode:!0,multilineTagIndentFactor:t.multilineTagIndentFactor,multilineTagIndentPastTag:t.multilineTagIndentPastTag,allowMissingTagName:t.allowMissingTagName}),s={},e=t&&t.tags,a=t&&t.scriptTypes;if(o(l,s),e&&o(e,s),a)for(var n=a.length-1;0<=n;n--)s.script.unshift(["type",a[n].matches,a[n].mode]);function u(t,e){var a,o,r,n=c.token(t,e.htmlState),l=/\btag\b/.test(n);return l&&!/[<>\s\/]/.test(t.current())&&(a=e.htmlState.tagName&&e.htmlState.tagName.toLowerCase())&&s.hasOwnProperty(a)?e.inTag=a+" ":e.inTag&&l&&/>$/.test(t.current())?(a=/^([\S]+) (.*)/.exec(e.inTag),e.inTag=null,l=">"==t.current()&&function(t,e){for(var a=0;a<t.length;a++){var n=t[a];if(!n[0]||n[1].test(d(e,n[0])))return n[2]}}(s[a[1]],a[2]),l=m.getMode(i,l),o=g(a[1],!0),r=g(a[1],!1),e.token=function(t,e){return t.match(o,!1)?(e.token=u,e.localState=e.localMode=null):(a=t,n=r,t=e.localMode.token(t,e.localState),e=a.current(),-1<(l=e.search(n))?a.backUp(e.length-l):e.match(/<\/?$/)&&(a.backUp(e.length),a.match(n,!1)||a.match(e)),t);var a,n,l},e.localMode=l,e.localState=m.startState(l,c.indent(e.htmlState,"",""))):e.inTag&&(e.inTag+=t.current(),t.eol())&&(e.inTag+=" "),n}return{startState:function(){return{token:u,inTag:null,localMode:null,localState:null,htmlState:m.startState(c)}},copyState:function(t){var e;return t.localState&&(e=m.copyState(t.localMode,t.localState)),{token:t.token,inTag:t.inTag,localMode:t.localMode,localState:e,htmlState:m.copyState(c,t.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(t,e,a){return!t.localMode||/^\s*<\//.test(e)?c.indent(t.htmlState,e,a):t.localMode.indent?t.localMode.indent(t.localState,e,a):m.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||c}}}},"xml","javascript","css"),m.defineMIME("text/html","htmlmixed")});
|
2
deps/codemirror/javascript.min.js
vendored
2
deps/codemirror/javascript.min.js
vendored
File diff suppressed because one or more lines are too long
2
deps/codemirror/search.min.js
vendored
2
deps/codemirror/search.min.js
vendored
File diff suppressed because one or more lines are too long
2
deps/codemirror/searchcursor.min.js
vendored
2
deps/codemirror/searchcursor.min.js
vendored
File diff suppressed because one or more lines are too long
32
deps/codemirror/update.sh
vendored
32
deps/codemirror/update.sh
vendored
@ -1,20 +1,20 @@
|
||||
LINKS="
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/dialog/dialog.min.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/dialog/dialog.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/edit/trailingspace.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/lint/javascript-lint.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/scroll/annotatescrollbar.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/search/matchesonscrollbar.min.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/search/matchesonscrollbar.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/search/search.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/addon/search/searchcursor.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.min.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/mode/css/css.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/mode/htmlmixed/htmlmixed.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/mode/javascript/javascript.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/mode/xml/xml.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/theme/base16-dark.min.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/dialog/dialog.min.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/dialog/dialog.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/edit/trailingspace.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/lint/javascript-lint.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/scroll/annotatescrollbar.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/search/matchesonscrollbar.min.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/search/matchesonscrollbar.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/search/search.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/addon/search/searchcursor.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/codemirror.min.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/codemirror.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/mode/css/css.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/mode/htmlmixed/htmlmixed.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/mode/javascript/javascript.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/mode/xml/xml.min.js
|
||||
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.14/theme/base16-dark.min.css
|
||||
"
|
||||
|
||||
for link in $LINKS; do
|
||||
|
2
deps/codemirror/xml.min.js
vendored
2
deps/codemirror/xml.min.js
vendored
File diff suppressed because one or more lines are too long
48
deps/lit/lit-all.min.js
vendored
48
deps/lit/lit-all.min.js
vendored
File diff suppressed because one or more lines are too long
2
deps/lit/lit-all.min.js.map
vendored
2
deps/lit/lit-all.min.js.map
vendored
File diff suppressed because one or more lines are too long
968
deps/smoothie/smoothie.js
vendored
968
deps/smoothie/smoothie.js
vendored
@ -1,968 +0,0 @@
|
||||
// MIT License:
|
||||
//
|
||||
// Copyright (c) 2010-2013, Joe Walnes
|
||||
// 2013-2017, Drew Noakes
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
/**
|
||||
* Smoothie Charts - http://smoothiecharts.org/
|
||||
* (c) 2010-2013, Joe Walnes
|
||||
* 2013-2017, Drew Noakes
|
||||
*
|
||||
* v1.0: Main charting library, by Joe Walnes
|
||||
* v1.1: Auto scaling of axis, by Neil Dunn
|
||||
* v1.2: fps (frames per second) option, by Mathias Petterson
|
||||
* v1.3: Fix for divide by zero, by Paul Nikitochkin
|
||||
* v1.4: Set minimum, top-scale padding, remove timeseries, add optional timer to reset bounds, by Kelley Reynolds
|
||||
* v1.5: Set default frames per second to 50... smoother.
|
||||
* .start(), .stop() methods for conserving CPU, by Dmitry Vyal
|
||||
* options.interpolation = 'bezier' or 'line', by Dmitry Vyal
|
||||
* options.maxValue to fix scale, by Dmitry Vyal
|
||||
* v1.6: minValue/maxValue will always get converted to floats, by Przemek Matylla
|
||||
* v1.7: options.grid.fillStyle may be a transparent color, by Dmitry A. Shashkin
|
||||
* Smooth rescaling, by Kostas Michalopoulos
|
||||
* v1.8: Set max length to customize number of live points in the dataset with options.maxDataSetLength, by Krishna Narni
|
||||
* v1.9: Display timestamps along the bottom, by Nick and Stev-io
|
||||
* (https://groups.google.com/forum/?fromgroups#!topic/smoothie-charts/-Ywse8FCpKI%5B1-25%5D)
|
||||
* Refactored by Krishna Narni, to support timestamp formatting function
|
||||
* v1.10: Switch to requestAnimationFrame, removed the now obsoleted options.fps, by Gergely Imreh
|
||||
* v1.11: options.grid.sharpLines option added, by @drewnoakes
|
||||
* Addressed warning seen in Firefox when seriesOption.fillStyle undefined, by @drewnoakes
|
||||
* v1.12: Support for horizontalLines added, by @drewnoakes
|
||||
* Support for yRangeFunction callback added, by @drewnoakes
|
||||
* v1.13: Fixed typo (#32), by @alnikitich
|
||||
* v1.14: Timer cleared when last TimeSeries removed (#23), by @davidgaleano
|
||||
* Fixed diagonal line on chart at start/end of data stream, by @drewnoakes
|
||||
* v1.15: Support for npm package (#18), by @dominictarr
|
||||
* Fixed broken removeTimeSeries function (#24) by @davidgaleano
|
||||
* Minor performance and tidying, by @drewnoakes
|
||||
* v1.16: Bug fix introduced in v1.14 relating to timer creation/clearance (#23), by @drewnoakes
|
||||
* TimeSeries.append now deals with out-of-order timestamps, and can merge duplicates, by @zacwitte (#12)
|
||||
* Documentation and some local variable renaming for clarity, by @drewnoakes
|
||||
* v1.17: Allow control over font size (#10), by @drewnoakes
|
||||
* Timestamp text won't overlap, by @drewnoakes
|
||||
* v1.18: Allow control of max/min label precision, by @drewnoakes
|
||||
* Added 'borderVisible' chart option, by @drewnoakes
|
||||
* Allow drawing series with fill but no stroke (line), by @drewnoakes
|
||||
* v1.19: Avoid unnecessary repaints, and fixed flicker in old browsers having multiple charts in document (#40), by @asbai
|
||||
* v1.20: Add SmoothieChart.getTimeSeriesOptions and SmoothieChart.bringToFront functions, by @drewnoakes
|
||||
* v1.21: Add 'step' interpolation mode, by @drewnoakes
|
||||
* v1.22: Add support for different pixel ratios. Also add optional y limit formatters, by @copacetic
|
||||
* v1.23: Fix bug introduced in v1.22 (#44), by @drewnoakes
|
||||
* v1.24: Fix bug introduced in v1.23, re-adding parseFloat to y-axis formatter defaults, by @siggy_sf
|
||||
* v1.25: Fix bug seen when adding a data point to TimeSeries which is older than the current data, by @Nking92
|
||||
* Draw time labels on top of series, by @comolosabia
|
||||
* Add TimeSeries.clear function, by @drewnoakes
|
||||
* v1.26: Add support for resizing on high device pixel ratio screens, by @copacetic
|
||||
* v1.27: Fix bug introduced in v1.26 for non whole number devicePixelRatio values, by @zmbush
|
||||
* v1.28: Add 'minValueScale' option, by @megawac
|
||||
* Fix 'labelPos' for different size of 'minValueString' 'maxValueString', by @henryn
|
||||
* v1.29: Support responsive sizing, by @drewnoakes
|
||||
* v1.29.1: Include types in package, and make property optional, by @TrentHouliston
|
||||
* v1.30: Fix inverted logic in devicePixelRatio support, by @scanlime
|
||||
* v1.31: Support tooltips, by @Sly1024 and @drewnoakes
|
||||
* v1.32: Support frame rate limit, by @dpuyosa
|
||||
* v1.33: Use Date static method instead of instance, by @nnnoel
|
||||
* Fix bug with tooltips when multiple charts on a page, by @jpmbiz70
|
||||
*/
|
||||
|
||||
;(function(exports) {
|
||||
|
||||
// Date.now polyfill
|
||||
Date.now = Date.now || function() { return new Date().getTime(); };
|
||||
|
||||
var Util = {
|
||||
extend: function() {
|
||||
arguments[0] = arguments[0] || {};
|
||||
for (var i = 1; i < arguments.length; i++)
|
||||
{
|
||||
for (var key in arguments[i])
|
||||
{
|
||||
if (arguments[i].hasOwnProperty(key))
|
||||
{
|
||||
if (typeof(arguments[i][key]) === 'object') {
|
||||
if (arguments[i][key] instanceof Array) {
|
||||
arguments[0][key] = arguments[i][key];
|
||||
} else {
|
||||
arguments[0][key] = Util.extend(arguments[0][key], arguments[i][key]);
|
||||
}
|
||||
} else {
|
||||
arguments[0][key] = arguments[i][key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return arguments[0];
|
||||
},
|
||||
binarySearch: function(data, value) {
|
||||
var low = 0,
|
||||
high = data.length;
|
||||
while (low < high) {
|
||||
var mid = (low + high) >> 1;
|
||||
if (value < data[mid][0])
|
||||
high = mid;
|
||||
else
|
||||
low = mid + 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialises a new <code>TimeSeries</code> with optional data options.
|
||||
*
|
||||
* Options are of the form (defaults shown):
|
||||
*
|
||||
* <pre>
|
||||
* {
|
||||
* resetBounds: true, // enables/disables automatic scaling of the y-axis
|
||||
* resetBoundsInterval: 3000 // the period between scaling calculations, in millis
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* Presentation options for TimeSeries are specified as an argument to <code>SmoothieChart.addTimeSeries</code>.
|
||||
*
|
||||
* @constructor
|
||||
*/
|
||||
function TimeSeries(options) {
|
||||
this.options = Util.extend({}, TimeSeries.defaultOptions, options);
|
||||
this.clear();
|
||||
}
|
||||
|
||||
TimeSeries.defaultOptions = {
|
||||
resetBoundsInterval: 3000,
|
||||
resetBounds: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all data and state from this TimeSeries object.
|
||||
*/
|
||||
TimeSeries.prototype.clear = function() {
|
||||
this.data = [];
|
||||
this.maxValue = Number.NaN; // The maximum value ever seen in this TimeSeries.
|
||||
this.minValue = Number.NaN; // The minimum value ever seen in this TimeSeries.
|
||||
};
|
||||
|
||||
/**
|
||||
* Recalculate the min/max values for this <code>TimeSeries</code> object.
|
||||
*
|
||||
* This causes the graph to scale itself in the y-axis.
|
||||
*/
|
||||
TimeSeries.prototype.resetBounds = function() {
|
||||
if (this.data.length) {
|
||||
// Walk through all data points, finding the min/max value
|
||||
this.maxValue = this.data[0][1];
|
||||
this.minValue = this.data[0][1];
|
||||
for (var i = 1; i < this.data.length; i++) {
|
||||
var value = this.data[i][1];
|
||||
if (value > this.maxValue) {
|
||||
this.maxValue = value;
|
||||
}
|
||||
if (value < this.minValue) {
|
||||
this.minValue = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No data exists, so set min/max to NaN
|
||||
this.maxValue = Number.NaN;
|
||||
this.minValue = Number.NaN;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new data point to the <code>TimeSeries</code>, preserving chronological order.
|
||||
*
|
||||
* @param timestamp the position, in time, of this data point
|
||||
* @param value the value of this data point
|
||||
* @param sumRepeatedTimeStampValues if <code>timestamp</code> has an exact match in the series, this flag controls
|
||||
* whether it is replaced, or the values summed (defaults to false.)
|
||||
*/
|
||||
TimeSeries.prototype.append = function(timestamp, value, sumRepeatedTimeStampValues) {
|
||||
// Rewind until we hit an older timestamp
|
||||
var i = this.data.length - 1;
|
||||
while (i >= 0 && this.data[i][0] > timestamp) {
|
||||
i--;
|
||||
}
|
||||
|
||||
if (i === -1) {
|
||||
// This new item is the oldest data
|
||||
this.data.splice(0, 0, [timestamp, value]);
|
||||
} else if (this.data.length > 0 && this.data[i][0] === timestamp) {
|
||||
// Update existing values in the array
|
||||
if (sumRepeatedTimeStampValues) {
|
||||
// Sum this value into the existing 'bucket'
|
||||
this.data[i][1] += value;
|
||||
value = this.data[i][1];
|
||||
} else {
|
||||
// Replace the previous value
|
||||
this.data[i][1] = value;
|
||||
}
|
||||
} else if (i < this.data.length - 1) {
|
||||
// Splice into the correct position to keep timestamps in order
|
||||
this.data.splice(i + 1, 0, [timestamp, value]);
|
||||
} else {
|
||||
// Add to the end of the array
|
||||
this.data.push([timestamp, value]);
|
||||
}
|
||||
|
||||
this.maxValue = isNaN(this.maxValue) ? value : Math.max(this.maxValue, value);
|
||||
this.minValue = isNaN(this.minValue) ? value : Math.min(this.minValue, value);
|
||||
};
|
||||
|
||||
TimeSeries.prototype.dropOldData = function(oldestValidTime, maxDataSetLength) {
|
||||
// We must always keep one expired data point as we need this to draw the
|
||||
// line that comes into the chart from the left, but any points prior to that can be removed.
|
||||
var removeCount = 0;
|
||||
while (this.data.length - removeCount >= maxDataSetLength && this.data[removeCount + 1][0] < oldestValidTime) {
|
||||
removeCount++;
|
||||
}
|
||||
if (removeCount !== 0) {
|
||||
this.data.splice(0, removeCount);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialises a new <code>SmoothieChart</code>.
|
||||
*
|
||||
* Options are optional, and should be of the form below. Just specify the values you
|
||||
* need and the rest will be given sensible defaults as shown:
|
||||
*
|
||||
* <pre>
|
||||
* {
|
||||
* minValue: undefined, // specify to clamp the lower y-axis to a given value
|
||||
* maxValue: undefined, // specify to clamp the upper y-axis to a given value
|
||||
* maxValueScale: 1, // allows proportional padding to be added above the chart. for 10% padding, specify 1.1.
|
||||
* minValueScale: 1, // allows proportional padding to be added below the chart. for 10% padding, specify 1.1.
|
||||
* yRangeFunction: undefined, // function({min: , max: }) { return {min: , max: }; }
|
||||
* scaleSmoothing: 0.125, // controls the rate at which y-value zoom animation occurs
|
||||
* millisPerPixel: 20, // sets the speed at which the chart pans by
|
||||
* enableDpiScaling: true, // support rendering at different DPI depending on the device
|
||||
* yMinFormatter: function(min, precision) { // callback function that formats the min y value label
|
||||
* return parseFloat(min).toFixed(precision);
|
||||
* },
|
||||
* yMaxFormatter: function(max, precision) { // callback function that formats the max y value label
|
||||
* return parseFloat(max).toFixed(precision);
|
||||
* },
|
||||
* maxDataSetLength: 2,
|
||||
* interpolation: 'bezier' // one of 'bezier', 'linear', or 'step'
|
||||
* timestampFormatter: null, // optional function to format time stamps for bottom of chart
|
||||
* // you may use SmoothieChart.timeFormatter, or your own: function(date) { return ''; }
|
||||
* scrollBackwards: false, // reverse the scroll direction of the chart
|
||||
* horizontalLines: [], // [ { value: 0, color: '#ffffff', lineWidth: 1 } ]
|
||||
* grid:
|
||||
* {
|
||||
* fillStyle: '#000000', // the background colour of the chart
|
||||
* lineWidth: 1, // the pixel width of grid lines
|
||||
* strokeStyle: '#777777', // colour of grid lines
|
||||
* millisPerLine: 1000, // distance between vertical grid lines
|
||||
* sharpLines: false, // controls whether grid lines are 1px sharp, or softened
|
||||
* verticalSections: 2, // number of vertical sections marked out by horizontal grid lines
|
||||
* borderVisible: true // whether the grid lines trace the border of the chart or not
|
||||
* },
|
||||
* labels
|
||||
* {
|
||||
* disabled: false, // enables/disables labels showing the min/max values
|
||||
* fillStyle: '#ffffff', // colour for text of labels,
|
||||
* fontSize: 15,
|
||||
* fontFamily: 'sans-serif',
|
||||
* precision: 2
|
||||
* },
|
||||
* tooltip: false // show tooltip when mouse is over the chart
|
||||
* tooltipLine: { // properties for a vertical line at the cursor position
|
||||
* lineWidth: 1,
|
||||
* strokeStyle: '#BBBBBB'
|
||||
* },
|
||||
* tooltipFormatter: SmoothieChart.tooltipFormatter, // formatter function for tooltip text
|
||||
* responsive: false, // whether the chart should adapt to the size of the canvas
|
||||
* limitFPS: 0 // maximum frame rate the chart will render at, in FPS (zero means no limit)
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* @constructor
|
||||
*/
|
||||
function SmoothieChart(options) {
|
||||
this.options = Util.extend({}, SmoothieChart.defaultChartOptions, options);
|
||||
this.seriesSet = [];
|
||||
this.currentValueRange = 1;
|
||||
this.currentVisMinValue = 0;
|
||||
this.lastRenderTimeMillis = 0;
|
||||
|
||||
this.mousemove = this.mousemove.bind(this);
|
||||
this.mouseout = this.mouseout.bind(this);
|
||||
}
|
||||
|
||||
/** Formats the HTML string content of the tooltip. */
|
||||
SmoothieChart.tooltipFormatter = function (timestamp, data) {
|
||||
var timestampFormatter = this.options.timestampFormatter || SmoothieChart.timeFormatter,
|
||||
lines = [timestampFormatter(new Date(timestamp))];
|
||||
|
||||
for (var i = 0; i < data.length; ++i) {
|
||||
lines.push('<span style="color:' + data[i].series.options.strokeStyle + '">' +
|
||||
this.options.yMaxFormatter(data[i].value, this.options.labels.precision) + '</span>');
|
||||
}
|
||||
|
||||
return lines.join('<br>');
|
||||
};
|
||||
|
||||
SmoothieChart.defaultChartOptions = {
|
||||
millisPerPixel: 20,
|
||||
enableDpiScaling: true,
|
||||
yMinFormatter: function(min, precision) {
|
||||
return parseFloat(min).toFixed(precision);
|
||||
},
|
||||
yMaxFormatter: function(max, precision) {
|
||||
return parseFloat(max).toFixed(precision);
|
||||
},
|
||||
maxValueScale: 1,
|
||||
minValueScale: 1,
|
||||
interpolation: 'bezier',
|
||||
scaleSmoothing: 0.125,
|
||||
maxDataSetLength: 2,
|
||||
scrollBackwards: false,
|
||||
grid: {
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#777777',
|
||||
lineWidth: 1,
|
||||
sharpLines: false,
|
||||
millisPerLine: 1000,
|
||||
verticalSections: 2,
|
||||
borderVisible: true
|
||||
},
|
||||
labels: {
|
||||
fillStyle: '#ffffff',
|
||||
disabled: false,
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
precision: 2
|
||||
},
|
||||
horizontalLines: [],
|
||||
tooltip: false,
|
||||
tooltipLine: {
|
||||
lineWidth: 1,
|
||||
strokeStyle: '#BBBBBB'
|
||||
},
|
||||
tooltipFormatter: SmoothieChart.tooltipFormatter,
|
||||
responsive: false,
|
||||
limitFPS: 0
|
||||
};
|
||||
|
||||
// Based on http://inspirit.github.com/jsfeat/js/compatibility.js
|
||||
SmoothieChart.AnimateCompatibility = (function() {
|
||||
var requestAnimationFrame = function(callback, element) {
|
||||
var requestAnimationFrame =
|
||||
window.requestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame ||
|
||||
window.mozRequestAnimationFrame ||
|
||||
window.oRequestAnimationFrame ||
|
||||
window.msRequestAnimationFrame ||
|
||||
function(callback) {
|
||||
return window.setTimeout(function() {
|
||||
callback(Date.now());
|
||||
}, 16);
|
||||
};
|
||||
return requestAnimationFrame.call(window, callback, element);
|
||||
},
|
||||
cancelAnimationFrame = function(id) {
|
||||
var cancelAnimationFrame =
|
||||
window.cancelAnimationFrame ||
|
||||
function(id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
return cancelAnimationFrame.call(window, id);
|
||||
};
|
||||
|
||||
return {
|
||||
requestAnimationFrame: requestAnimationFrame,
|
||||
cancelAnimationFrame: cancelAnimationFrame
|
||||
};
|
||||
})();
|
||||
|
||||
SmoothieChart.defaultSeriesPresentationOptions = {
|
||||
lineWidth: 1,
|
||||
strokeStyle: '#ffffff'
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a <code>TimeSeries</code> to this chart, with optional presentation options.
|
||||
*
|
||||
* Presentation options should be of the form (defaults shown):
|
||||
*
|
||||
* <pre>
|
||||
* {
|
||||
* lineWidth: 1,
|
||||
* strokeStyle: '#ffffff',
|
||||
* fillStyle: undefined
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
SmoothieChart.prototype.addTimeSeries = function(timeSeries, options) {
|
||||
this.seriesSet.push({timeSeries: timeSeries, options: Util.extend({}, SmoothieChart.defaultSeriesPresentationOptions, options)});
|
||||
if (timeSeries.options.resetBounds && timeSeries.options.resetBoundsInterval > 0) {
|
||||
timeSeries.resetBoundsTimerId = setInterval(
|
||||
function() {
|
||||
timeSeries.resetBounds();
|
||||
},
|
||||
timeSeries.options.resetBoundsInterval
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the specified <code>TimeSeries</code> from the chart.
|
||||
*/
|
||||
SmoothieChart.prototype.removeTimeSeries = function(timeSeries) {
|
||||
// Find the correct timeseries to remove, and remove it
|
||||
var numSeries = this.seriesSet.length;
|
||||
for (var i = 0; i < numSeries; i++) {
|
||||
if (this.seriesSet[i].timeSeries === timeSeries) {
|
||||
this.seriesSet.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If a timer was operating for that timeseries, remove it
|
||||
if (timeSeries.resetBoundsTimerId) {
|
||||
// Stop resetting the bounds, if we were
|
||||
clearInterval(timeSeries.resetBoundsTimerId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets render options for the specified <code>TimeSeries</code>.
|
||||
*
|
||||
* As you may use a single <code>TimeSeries</code> in multiple charts with different formatting in each usage,
|
||||
* these settings are stored in the chart.
|
||||
*/
|
||||
SmoothieChart.prototype.getTimeSeriesOptions = function(timeSeries) {
|
||||
// Find the correct timeseries to remove, and remove it
|
||||
var numSeries = this.seriesSet.length;
|
||||
for (var i = 0; i < numSeries; i++) {
|
||||
if (this.seriesSet[i].timeSeries === timeSeries) {
|
||||
return this.seriesSet[i].options;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Brings the specified <code>TimeSeries</code> to the top of the chart. It will be rendered last.
|
||||
*/
|
||||
SmoothieChart.prototype.bringToFront = function(timeSeries) {
|
||||
// Find the correct timeseries to remove, and remove it
|
||||
var numSeries = this.seriesSet.length;
|
||||
for (var i = 0; i < numSeries; i++) {
|
||||
if (this.seriesSet[i].timeSeries === timeSeries) {
|
||||
var set = this.seriesSet.splice(i, 1);
|
||||
this.seriesSet.push(set[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Instructs the <code>SmoothieChart</code> to start rendering to the provided canvas, with specified delay.
|
||||
*
|
||||
* @param canvas the target canvas element
|
||||
* @param delayMillis an amount of time to wait before a data point is shown. This can prevent the end of the series
|
||||
* from appearing on screen, with new values flashing into view, at the expense of some latency.
|
||||
*/
|
||||
SmoothieChart.prototype.streamTo = function(canvas, delayMillis) {
|
||||
this.canvas = canvas;
|
||||
this.delay = delayMillis;
|
||||
this.start();
|
||||
};
|
||||
|
||||
SmoothieChart.prototype.getTooltipEl = function () {
|
||||
// Create the tool tip element lazily
|
||||
if (!this.tooltipEl) {
|
||||
this.tooltipEl = document.createElement('div');
|
||||
this.tooltipEl.className = 'smoothie-chart-tooltip';
|
||||
this.tooltipEl.style.position = 'absolute';
|
||||
this.tooltipEl.style.display = 'none';
|
||||
document.body.appendChild(this.tooltipEl);
|
||||
}
|
||||
return this.tooltipEl;
|
||||
};
|
||||
|
||||
SmoothieChart.prototype.updateTooltip = function () {
|
||||
var el = this.getTooltipEl();
|
||||
|
||||
if (!this.mouseover || !this.options.tooltip) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
var time = this.lastRenderTimeMillis - (this.delay || 0);
|
||||
|
||||
// Round time down to pixel granularity, so motion appears smoother.
|
||||
time -= time % this.options.millisPerPixel;
|
||||
|
||||
// x pixel to time
|
||||
var t = this.options.scrollBackwards
|
||||
? time - this.mouseX * this.options.millisPerPixel
|
||||
: time - (this.canvas.offsetWidth - this.mouseX) * this.options.millisPerPixel;
|
||||
|
||||
var data = [];
|
||||
|
||||
// For each data set...
|
||||
for (var d = 0; d < this.seriesSet.length; d++) {
|
||||
var timeSeries = this.seriesSet[d].timeSeries,
|
||||
// find datapoint closest to time 't'
|
||||
closeIdx = Util.binarySearch(timeSeries.data, t);
|
||||
|
||||
if (closeIdx > 0 && closeIdx < timeSeries.data.length) {
|
||||
data.push({ series: this.seriesSet[d], index: closeIdx, value: timeSeries.data[closeIdx][1] });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length) {
|
||||
el.innerHTML = this.options.tooltipFormatter.call(this, t, data);
|
||||
el.style.display = 'block';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
SmoothieChart.prototype.mousemove = function (evt) {
|
||||
this.mouseover = true;
|
||||
this.mouseX = evt.offsetX;
|
||||
this.mouseY = evt.offsetY;
|
||||
this.mousePageX = evt.pageX;
|
||||
this.mousePageY = evt.pageY;
|
||||
|
||||
var el = this.getTooltipEl();
|
||||
el.style.top = Math.round(this.mousePageY) + 'px';
|
||||
el.style.left = Math.round(this.mousePageX) + 'px';
|
||||
this.updateTooltip();
|
||||
};
|
||||
|
||||
SmoothieChart.prototype.mouseout = function () {
|
||||
this.mouseover = false;
|
||||
this.mouseX = this.mouseY = -1;
|
||||
if (SmoothieChart.tooltipEl)
|
||||
SmoothieChart.tooltipEl.style.display = 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure the canvas has the optimal resolution for the device's pixel ratio.
|
||||
*/
|
||||
SmoothieChart.prototype.resize = function () {
|
||||
var dpr = !this.options.enableDpiScaling || !window ? 1 : window.devicePixelRatio,
|
||||
width, height;
|
||||
if (this.options.responsive) {
|
||||
// Newer behaviour: Use the canvas's size in the layout, and set the internal
|
||||
// resolution according to that size and the device pixel ratio (eg: high DPI)
|
||||
width = this.canvas.offsetWidth;
|
||||
height = this.canvas.offsetHeight;
|
||||
|
||||
if (width !== this.lastWidth) {
|
||||
this.lastWidth = width;
|
||||
this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString());
|
||||
}
|
||||
if (height !== this.lastHeight) {
|
||||
this.lastHeight = height;
|
||||
this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString());
|
||||
}
|
||||
} else if (dpr !== 1) {
|
||||
// Older behaviour: use the canvas's inner dimensions and scale the element's size
|
||||
// according to that size and the device pixel ratio (eg: high DPI)
|
||||
width = parseInt(this.canvas.getAttribute('width'));
|
||||
height = parseInt(this.canvas.getAttribute('height'));
|
||||
|
||||
if (!this.originalWidth || (Math.floor(this.originalWidth * dpr) !== width)) {
|
||||
this.originalWidth = width;
|
||||
this.canvas.setAttribute('width', (Math.floor(width * dpr)).toString());
|
||||
this.canvas.style.width = width + 'px';
|
||||
this.canvas.getContext('2d').scale(dpr, dpr);
|
||||
}
|
||||
|
||||
if (!this.originalHeight || (Math.floor(this.originalHeight * dpr) !== height)) {
|
||||
this.originalHeight = height;
|
||||
this.canvas.setAttribute('height', (Math.floor(height * dpr)).toString());
|
||||
this.canvas.style.height = height + 'px';
|
||||
this.canvas.getContext('2d').scale(dpr, dpr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts the animation of this chart.
|
||||
*/
|
||||
SmoothieChart.prototype.start = function() {
|
||||
if (this.frame) {
|
||||
// We're already running, so just return
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvas.addEventListener('mousemove', this.mousemove);
|
||||
this.canvas.addEventListener('mouseout', this.mouseout);
|
||||
|
||||
// Renders a frame, and queues the next frame for later rendering
|
||||
var animate = function() {
|
||||
this.frame = SmoothieChart.AnimateCompatibility.requestAnimationFrame(function() {
|
||||
this.render();
|
||||
animate();
|
||||
}.bind(this));
|
||||
}.bind(this);
|
||||
|
||||
animate();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the animation of this chart.
|
||||
*/
|
||||
SmoothieChart.prototype.stop = function() {
|
||||
if (this.frame) {
|
||||
SmoothieChart.AnimateCompatibility.cancelAnimationFrame(this.frame);
|
||||
delete this.frame;
|
||||
this.canvas.removeEventListener('mousemove', this.mousemove);
|
||||
this.canvas.removeEventListener('mouseout', this.mouseout);
|
||||
}
|
||||
};
|
||||
|
||||
SmoothieChart.prototype.updateValueRange = function() {
|
||||
// Calculate the current scale of the chart, from all time series.
|
||||
var chartOptions = this.options,
|
||||
chartMaxValue = Number.NaN,
|
||||
chartMinValue = Number.NaN;
|
||||
|
||||
for (var d = 0; d < this.seriesSet.length; d++) {
|
||||
// TODO(ndunn): We could calculate / track these values as they stream in.
|
||||
var timeSeries = this.seriesSet[d].timeSeries;
|
||||
if (!isNaN(timeSeries.maxValue)) {
|
||||
chartMaxValue = !isNaN(chartMaxValue) ? Math.max(chartMaxValue, timeSeries.maxValue) : timeSeries.maxValue;
|
||||
}
|
||||
|
||||
if (!isNaN(timeSeries.minValue)) {
|
||||
chartMinValue = !isNaN(chartMinValue) ? Math.min(chartMinValue, timeSeries.minValue) : timeSeries.minValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Scale the chartMaxValue to add padding at the top if required
|
||||
if (chartOptions.maxValue != null) {
|
||||
chartMaxValue = chartOptions.maxValue;
|
||||
} else {
|
||||
chartMaxValue *= chartOptions.maxValueScale;
|
||||
}
|
||||
|
||||
// Set the minimum if we've specified one
|
||||
if (chartOptions.minValue != null) {
|
||||
chartMinValue = chartOptions.minValue;
|
||||
} else {
|
||||
chartMinValue -= Math.abs(chartMinValue * chartOptions.minValueScale - chartMinValue);
|
||||
}
|
||||
|
||||
// If a custom range function is set, call it
|
||||
if (this.options.yRangeFunction) {
|
||||
var range = this.options.yRangeFunction({min: chartMinValue, max: chartMaxValue});
|
||||
chartMinValue = range.min;
|
||||
chartMaxValue = range.max;
|
||||
}
|
||||
|
||||
if (!isNaN(chartMaxValue) && !isNaN(chartMinValue)) {
|
||||
var targetValueRange = chartMaxValue - chartMinValue;
|
||||
var valueRangeDiff = (targetValueRange - this.currentValueRange);
|
||||
var minValueDiff = (chartMinValue - this.currentVisMinValue);
|
||||
this.isAnimatingScale = Math.abs(valueRangeDiff) > 0.1 || Math.abs(minValueDiff) > 0.1;
|
||||
this.currentValueRange += chartOptions.scaleSmoothing * valueRangeDiff;
|
||||
this.currentVisMinValue += chartOptions.scaleSmoothing * minValueDiff;
|
||||
}
|
||||
|
||||
this.valueRange = { min: chartMinValue, max: chartMaxValue };
|
||||
};
|
||||
|
||||
SmoothieChart.prototype.render = function(canvas, time) {
|
||||
var nowMillis = Date.now();
|
||||
|
||||
// Respect any frame rate limit.
|
||||
if (this.options.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/this.options.limitFPS))
|
||||
return;
|
||||
|
||||
if (!this.isAnimatingScale) {
|
||||
// We're not animating. We can use the last render time and the scroll speed to work out whether
|
||||
// we actually need to paint anything yet. If not, we can return immediately.
|
||||
|
||||
// Render at least every 1/6th of a second. The canvas may be resized, which there is
|
||||
// no reliable way to detect.
|
||||
var maxIdleMillis = Math.min(1000/6, this.options.millisPerPixel);
|
||||
|
||||
if (nowMillis - this.lastRenderTimeMillis < maxIdleMillis) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.resize();
|
||||
this.updateTooltip();
|
||||
|
||||
this.lastRenderTimeMillis = nowMillis;
|
||||
|
||||
canvas = canvas || this.canvas;
|
||||
time = time || nowMillis - (this.delay || 0);
|
||||
|
||||
// Round time down to pixel granularity, so motion appears smoother.
|
||||
time -= time % this.options.millisPerPixel;
|
||||
|
||||
var context = canvas.getContext('2d'),
|
||||
chartOptions = this.options,
|
||||
dimensions = { top: 0, left: 0, width: canvas.clientWidth, height: canvas.clientHeight },
|
||||
// Calculate the threshold time for the oldest data points.
|
||||
oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel),
|
||||
valueToYPixel = function(value) {
|
||||
var offset = value - this.currentVisMinValue;
|
||||
return this.currentValueRange === 0
|
||||
? dimensions.height
|
||||
: dimensions.height - (Math.round((offset / this.currentValueRange) * dimensions.height));
|
||||
}.bind(this),
|
||||
timeToXPixel = function(t) {
|
||||
if(chartOptions.scrollBackwards) {
|
||||
return Math.round((time - t) / chartOptions.millisPerPixel);
|
||||
}
|
||||
return Math.round(dimensions.width - ((time - t) / chartOptions.millisPerPixel));
|
||||
};
|
||||
|
||||
this.updateValueRange();
|
||||
|
||||
context.font = chartOptions.labels.fontSize + 'px ' + chartOptions.labels.fontFamily;
|
||||
|
||||
// Save the state of the canvas context, any transformations applied in this method
|
||||
// will get removed from the stack at the end of this method when .restore() is called.
|
||||
context.save();
|
||||
|
||||
// Move the origin.
|
||||
context.translate(dimensions.left, dimensions.top);
|
||||
|
||||
// Create a clipped rectangle - anything we draw will be constrained to this rectangle.
|
||||
// This prevents the occasional pixels from curves near the edges overrunning and creating
|
||||
// screen cheese (that phrase should need no explanation).
|
||||
context.beginPath();
|
||||
context.rect(0, 0, dimensions.width, dimensions.height);
|
||||
context.clip();
|
||||
|
||||
// Clear the working area.
|
||||
context.save();
|
||||
context.fillStyle = chartOptions.grid.fillStyle;
|
||||
context.clearRect(0, 0, dimensions.width, dimensions.height);
|
||||
context.fillRect(0, 0, dimensions.width, dimensions.height);
|
||||
context.restore();
|
||||
|
||||
// Grid lines...
|
||||
context.save();
|
||||
context.lineWidth = chartOptions.grid.lineWidth;
|
||||
context.strokeStyle = chartOptions.grid.strokeStyle;
|
||||
// Vertical (time) dividers.
|
||||
if (chartOptions.grid.millisPerLine > 0) {
|
||||
context.beginPath();
|
||||
for (var t = time - (time % chartOptions.grid.millisPerLine);
|
||||
t >= oldestValidTime;
|
||||
t -= chartOptions.grid.millisPerLine) {
|
||||
var gx = timeToXPixel(t);
|
||||
if (chartOptions.grid.sharpLines) {
|
||||
gx -= 0.5;
|
||||
}
|
||||
context.moveTo(gx, 0);
|
||||
context.lineTo(gx, dimensions.height);
|
||||
}
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
// Horizontal (value) dividers.
|
||||
for (var v = 1; v < chartOptions.grid.verticalSections; v++) {
|
||||
var gy = Math.round(v * dimensions.height / chartOptions.grid.verticalSections);
|
||||
if (chartOptions.grid.sharpLines) {
|
||||
gy -= 0.5;
|
||||
}
|
||||
context.beginPath();
|
||||
context.moveTo(0, gy);
|
||||
context.lineTo(dimensions.width, gy);
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
}
|
||||
// Bounding rectangle.
|
||||
if (chartOptions.grid.borderVisible) {
|
||||
context.beginPath();
|
||||
context.strokeRect(0, 0, dimensions.width, dimensions.height);
|
||||
context.closePath();
|
||||
}
|
||||
context.restore();
|
||||
|
||||
// Draw any horizontal lines...
|
||||
if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) {
|
||||
for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) {
|
||||
var line = chartOptions.horizontalLines[hl],
|
||||
hly = Math.round(valueToYPixel(line.value)) - 0.5;
|
||||
context.strokeStyle = line.color || '#ffffff';
|
||||
context.lineWidth = line.lineWidth || 1;
|
||||
context.beginPath();
|
||||
context.moveTo(0, hly);
|
||||
context.lineTo(dimensions.width, hly);
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
}
|
||||
}
|
||||
|
||||
// For each data set...
|
||||
for (var d = 0; d < this.seriesSet.length; d++) {
|
||||
context.save();
|
||||
var timeSeries = this.seriesSet[d].timeSeries,
|
||||
dataSet = timeSeries.data,
|
||||
seriesOptions = this.seriesSet[d].options;
|
||||
|
||||
// Delete old data that's moved off the left of the chart.
|
||||
timeSeries.dropOldData(oldestValidTime, chartOptions.maxDataSetLength);
|
||||
|
||||
// Set style for this dataSet.
|
||||
context.lineWidth = seriesOptions.lineWidth;
|
||||
context.strokeStyle = seriesOptions.strokeStyle;
|
||||
// Draw the line...
|
||||
context.beginPath();
|
||||
// Retain lastX, lastY for calculating the control points of bezier curves.
|
||||
var firstX = 0, lastX = 0, lastY = 0;
|
||||
for (var i = 0; i < dataSet.length && dataSet.length !== 1; i++) {
|
||||
var x = timeToXPixel(dataSet[i][0]),
|
||||
y = valueToYPixel(dataSet[i][1]);
|
||||
|
||||
if (i === 0) {
|
||||
firstX = x;
|
||||
context.moveTo(x, y);
|
||||
} else {
|
||||
switch (chartOptions.interpolation) {
|
||||
case "linear":
|
||||
case "line": {
|
||||
context.lineTo(x,y);
|
||||
break;
|
||||
}
|
||||
case "bezier":
|
||||
default: {
|
||||
// Great explanation of Bezier curves: http://en.wikipedia.org/wiki/Bezier_curve#Quadratic_curves
|
||||
//
|
||||
// Assuming A was the last point in the line plotted and B is the new point,
|
||||
// we draw a curve with control points P and Q as below.
|
||||
//
|
||||
// A---P
|
||||
// |
|
||||
// |
|
||||
// |
|
||||
// Q---B
|
||||
//
|
||||
// Importantly, A and P are at the same y coordinate, as are B and Q. This is
|
||||
// so adjacent curves appear to flow as one.
|
||||
//
|
||||
context.bezierCurveTo( // startPoint (A) is implicit from last iteration of loop
|
||||
Math.round((lastX + x) / 2), lastY, // controlPoint1 (P)
|
||||
Math.round((lastX + x)) / 2, y, // controlPoint2 (Q)
|
||||
x, y); // endPoint (B)
|
||||
break;
|
||||
}
|
||||
case "step": {
|
||||
context.lineTo(x,lastY);
|
||||
context.lineTo(x,y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastX = x; lastY = y;
|
||||
}
|
||||
|
||||
if (dataSet.length > 1) {
|
||||
if (seriesOptions.fillStyle) {
|
||||
// Close up the fill region.
|
||||
context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, lastY);
|
||||
context.lineTo(dimensions.width + seriesOptions.lineWidth + 1, dimensions.height + seriesOptions.lineWidth + 1);
|
||||
context.lineTo(firstX, dimensions.height + seriesOptions.lineWidth);
|
||||
context.fillStyle = seriesOptions.fillStyle;
|
||||
context.fill();
|
||||
}
|
||||
|
||||
if (seriesOptions.strokeStyle && seriesOptions.strokeStyle !== 'none') {
|
||||
context.stroke();
|
||||
}
|
||||
context.closePath();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
if (chartOptions.tooltip && this.mouseX >= 0) {
|
||||
// Draw vertical bar to show tooltip position
|
||||
context.lineWidth = chartOptions.tooltipLine.lineWidth;
|
||||
context.strokeStyle = chartOptions.tooltipLine.strokeStyle;
|
||||
context.beginPath();
|
||||
context.moveTo(this.mouseX, 0);
|
||||
context.lineTo(this.mouseX, dimensions.height);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
this.updateTooltip();
|
||||
}
|
||||
|
||||
// Draw the axis values on the chart.
|
||||
if (!chartOptions.labels.disabled && !isNaN(this.valueRange.min) && !isNaN(this.valueRange.max)) {
|
||||
var maxValueString = chartOptions.yMaxFormatter(this.valueRange.max, chartOptions.labels.precision),
|
||||
minValueString = chartOptions.yMinFormatter(this.valueRange.min, chartOptions.labels.precision),
|
||||
maxLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(maxValueString).width - 2,
|
||||
minLabelPos = chartOptions.scrollBackwards ? 0 : dimensions.width - context.measureText(minValueString).width - 2;
|
||||
context.fillStyle = chartOptions.labels.fillStyle;
|
||||
context.fillText(maxValueString, maxLabelPos, chartOptions.labels.fontSize);
|
||||
context.fillText(minValueString, minLabelPos, dimensions.height - 2);
|
||||
}
|
||||
|
||||
// Display timestamps along x-axis at the bottom of the chart.
|
||||
if (chartOptions.timestampFormatter && chartOptions.grid.millisPerLine > 0) {
|
||||
var textUntilX = chartOptions.scrollBackwards
|
||||
? context.measureText(minValueString).width
|
||||
: dimensions.width - context.measureText(minValueString).width + 4;
|
||||
for (var t = time - (time % chartOptions.grid.millisPerLine);
|
||||
t >= oldestValidTime;
|
||||
t -= chartOptions.grid.millisPerLine) {
|
||||
var gx = timeToXPixel(t);
|
||||
// Only draw the timestamp if it won't overlap with the previously drawn one.
|
||||
if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX)) {
|
||||
// Formats the timestamp based on user specified formatting function
|
||||
// SmoothieChart.timeFormatter function above is one such formatting option
|
||||
var tx = new Date(t),
|
||||
ts = chartOptions.timestampFormatter(tx),
|
||||
tsWidth = context.measureText(ts).width;
|
||||
|
||||
textUntilX = chartOptions.scrollBackwards
|
||||
? gx + tsWidth + 2
|
||||
: gx - tsWidth - 2;
|
||||
|
||||
context.fillStyle = chartOptions.labels.fillStyle;
|
||||
if(chartOptions.scrollBackwards) {
|
||||
context.fillText(ts, gx, dimensions.height - 2);
|
||||
} else {
|
||||
context.fillText(ts, gx - tsWidth, dimensions.height - 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.restore(); // See .save() above.
|
||||
};
|
||||
|
||||
// Sample timestamp formatting function
|
||||
SmoothieChart.timeFormatter = function(date) {
|
||||
function pad2(number) { return (number < 10 ? '0' : '') + number }
|
||||
return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds());
|
||||
};
|
||||
|
||||
exports.TimeSeries = TimeSeries;
|
||||
exports.SmoothieChart = SmoothieChart;
|
||||
|
||||
})(typeof exports === 'undefined' ? this : exports);
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3
deps/split/split.min.js
vendored
3
deps/split/split.min.js
vendored
File diff suppressed because one or more lines are too long
1
deps/split/split.min.js.map
vendored
1
deps/split/split.min.js.map
vendored
File diff suppressed because one or more lines are too long
@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.unprompted.tildefriends"
|
||||
versionCode="9"
|
||||
versionName="0.0.9">
|
||||
<uses-sdk android:minSdkVersion="26"/>
|
||||
versionCode="10"
|
||||
versionName="0.0.10">
|
||||
<uses-sdk android:minSdkVersion="28"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application android:label="Tilde Friends" android:usesCleartextTraffic="true" android:debuggable="true">
|
||||
<meta-data android:name="android.max_aspect" android:value="2.1"/>
|
||||
|
@ -1,5 +1,7 @@
|
||||
package com.unprompted.tildefriends;
|
||||
|
||||
import android.os.StrictMode;
|
||||
import android.os.strictmode.Violation;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.DialogInterface;
|
||||
@ -39,9 +41,14 @@ public class MainActivity extends Activity {
|
||||
WebView web_view;
|
||||
String base_url;
|
||||
Process process;
|
||||
Thread thread;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
|
||||
.detectLeakedClosableObjects()
|
||||
.penaltyLog()
|
||||
.build());
|
||||
super.onCreate(savedInstanceState);
|
||||
this.requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
setContentView(R.layout.activity_main);
|
||||
@ -52,22 +59,22 @@ public class MainActivity extends Activity {
|
||||
Log.w("tildefriends", String.format("getPackageResourcePath() is %s", getPackageResourcePath().toString()));
|
||||
Log.w("tildefriends", String.format("os.arch is %s", arch));
|
||||
|
||||
try {
|
||||
ZipInputStream zip = new ZipInputStream(new BufferedInputStream(new FileInputStream(getPackageResourcePath().toString())));
|
||||
try (ZipInputStream zip = new ZipInputStream(new BufferedInputStream(new FileInputStream(getPackageResourcePath().toString())))) {
|
||||
ZipEntry entry = null;
|
||||
String lookup = String.format("bin/%s/tildefriends", arch);
|
||||
Log.w("tildefriends", "Looking for " + lookup);
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
if (entry.getName().equals(lookup)) {
|
||||
Log.w("tildefriends", "Extracting " + entry.getName());
|
||||
FileOutputStream out = new FileOutputStream(getFilesDir().toString().concat("/tildefriends"));
|
||||
byte[] buffer = new byte[32768];
|
||||
int count;
|
||||
while ((count = zip.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, count);
|
||||
try (FileOutputStream out = new FileOutputStream(getFilesDir().toString().concat("/tildefriends"))) {
|
||||
byte[] buffer = new byte[32768];
|
||||
int count;
|
||||
while ((count = zip.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, count);
|
||||
}
|
||||
out.close();
|
||||
new File(getFilesDir().toString() + "/tildefriends").setExecutable(true);
|
||||
}
|
||||
out.close();
|
||||
new File(getFilesDir().toString() + "/tildefriends").setExecutable(true);
|
||||
}
|
||||
zip.closeEntry();
|
||||
}
|
||||
@ -84,12 +91,11 @@ public class MainActivity extends Activity {
|
||||
|
||||
MainActivity activity = this;
|
||||
|
||||
new Thread(new Runnable() {
|
||||
thread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
Log.w("tildefriends", "Watching for changes in: " + getFilesDir().toString());
|
||||
WatchService watcher = FileSystems.getDefault().newWatchService();
|
||||
Log.w("tildefriends", "Watching for changes in: " + getFilesDir().toString());
|
||||
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
|
||||
Paths.get(getFilesDir().toString()).register(
|
||||
watcher,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
@ -103,7 +109,6 @@ public class MainActivity extends Activity {
|
||||
activity.runOnUiThread(() -> {
|
||||
web_view.loadUrl(base_url);
|
||||
});
|
||||
watcher.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -118,7 +123,8 @@ public class MainActivity extends Activity {
|
||||
Log.w("tildefriends", "InterruptedException: " + e.toString());
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
});
|
||||
thread.start();
|
||||
|
||||
String exe = getFilesDir().toString() + "/tildefriends";
|
||||
ProcessBuilder builder = new ProcessBuilder(exe, "run", "-z", getPackageResourcePath().toString(), "-a", "out_http_port_file=" + port_file_path, "-p", "0");
|
||||
@ -231,21 +237,12 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private int read_port(String path) {
|
||||
BufferedReader reader = null;
|
||||
try {
|
||||
reader = new BufferedReader(new FileReader(path));
|
||||
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
|
||||
return Integer.parseInt(reader.readLine());
|
||||
} catch (java.io.FileNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
} catch (java.io.IOException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
try {
|
||||
if (reader != null) {
|
||||
reader.close();
|
||||
}
|
||||
} catch (java.io.IOException e) {
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
39
src/main.c
39
src/main.c
@ -353,16 +353,13 @@ static int _tf_run_task(const tf_run_args_t* args, int index)
|
||||
}
|
||||
tf_task_set_db_path(task, db_path);
|
||||
tf_task_activate(task);
|
||||
if (args->ssb_port)
|
||||
if (args->zip)
|
||||
{
|
||||
if (args->zip)
|
||||
{
|
||||
tf_ssb_import_from_zip(tf_task_get_ssb(task), args->zip, "core", "apps");
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_ssb_import(tf_task_get_ssb(task), "core", "apps");
|
||||
}
|
||||
tf_ssb_import_from_zip(tf_task_get_ssb(task), args->zip, "core", "apps");
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_ssb_import(tf_task_get_ssb(task), "core", "apps");
|
||||
}
|
||||
if (tf_task_execute(task, args->script))
|
||||
{
|
||||
@ -589,6 +586,14 @@ static void _backtrace_error(void* data, const char* message, int errnum)
|
||||
tf_printf("libbacktrace error %d: %s\n", errnum, message);
|
||||
}
|
||||
|
||||
static void _error_handler(int sig)
|
||||
{
|
||||
const char* stack = tf_util_backtrace_string();
|
||||
tf_printf("ERROR:\n%s\n", stack);
|
||||
tf_free((void*)stack);
|
||||
_exit(1);
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
bool tracking = false;
|
||||
@ -627,6 +632,22 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
#endif
|
||||
|
||||
bool use_error_handler = false;
|
||||
#if defined(__ANDROID__)
|
||||
use_error_handler = true;
|
||||
#endif
|
||||
if (use_error_handler)
|
||||
{
|
||||
if (
|
||||
#if !defined(_WIN32)
|
||||
signal(SIGSYS, _error_handler) == SIG_ERR ||
|
||||
#endif
|
||||
signal(SIGSEGV, _error_handler) == SIG_ERR)
|
||||
{
|
||||
perror("signal");
|
||||
}
|
||||
}
|
||||
|
||||
int result = 0;
|
||||
if (argc >= 2)
|
||||
{
|
||||
|
220
src/ssb.c
220
src/ssb.c
@ -73,8 +73,8 @@ typedef enum {
|
||||
enum {
|
||||
k_connections_changed_callbacks_max = 8,
|
||||
k_tf_ssb_rpc_message_body_length_max = 1 * 1024 * 1024,
|
||||
k_debug_close_message_count = 32,
|
||||
k_debug_close_connection_count = 8,
|
||||
k_debug_close_message_count = 256,
|
||||
k_debug_close_connection_count = 32,
|
||||
};
|
||||
|
||||
typedef struct _tf_ssb_broadcast_t tf_ssb_broadcast_t;
|
||||
@ -86,6 +86,7 @@ typedef struct _tf_ssb_debug_message_t
|
||||
bool outgoing;
|
||||
int flags;
|
||||
int32_t request_number;
|
||||
time_t timestamp;
|
||||
size_t size;
|
||||
uint8_t data[];
|
||||
} tf_ssb_debug_message_t;
|
||||
@ -232,13 +233,14 @@ typedef struct _tf_ssb_t
|
||||
|
||||
tf_ssb_debug_close_t debug_close[k_debug_close_connection_count];
|
||||
|
||||
tf_thread_work_time_t* thread_time;
|
||||
int thread_time_count;
|
||||
int32_t thread_busy_count;
|
||||
int32_t thread_busy_max;
|
||||
|
||||
void (*hitch_callback)(const char* name, uint64_t duration, void* user_data);
|
||||
void* hitch_user_data;
|
||||
|
||||
tf_ssb_store_queue_t store_queue;
|
||||
int ref_count;
|
||||
} tf_ssb_t;
|
||||
|
||||
typedef struct _tf_ssb_connection_message_request_t
|
||||
@ -376,6 +378,7 @@ static void _tf_ssb_connection_add_debug_message(tf_ssb_connection_t* connection
|
||||
.outgoing = outgoing,
|
||||
.flags = flags,
|
||||
.request_number = request_number,
|
||||
.timestamp = time(NULL),
|
||||
.size = size,
|
||||
};
|
||||
memcpy(message + 1, data, size);
|
||||
@ -544,7 +547,7 @@ static void _tf_ssb_nonce_inc(uint8_t* nonce)
|
||||
|
||||
static void _tf_ssb_connection_box_stream_send(tf_ssb_connection_t* connection, const uint8_t* message, size_t size)
|
||||
{
|
||||
const size_t k_send_max = 65535;
|
||||
const size_t k_send_max = 4096;
|
||||
for (size_t offset = 0; offset < size; offset += k_send_max)
|
||||
{
|
||||
size_t send_size = size - offset > k_send_max ? k_send_max : size - offset;
|
||||
@ -621,25 +624,40 @@ static bool _tf_ssb_connection_get_request_callback(tf_ssb_connection_t* connect
|
||||
|
||||
void tf_ssb_connection_add_request(tf_ssb_connection_t* connection, int32_t request_number, tf_ssb_rpc_callback_t* callback, tf_ssb_callback_cleanup_t* cleanup, void* user_data, tf_ssb_connection_t* dependent_connection)
|
||||
{
|
||||
tf_ssb_connection_remove_request(connection, request_number);
|
||||
tf_ssb_request_t request =
|
||||
tf_ssb_request_t* existing = connection->requests_count ? bsearch(&request_number, connection->requests, connection->requests_count, sizeof(tf_ssb_request_t), _request_compare) : NULL;
|
||||
if (existing)
|
||||
{
|
||||
.request_number = request_number,
|
||||
.callback = callback,
|
||||
.cleanup = cleanup,
|
||||
.user_data = user_data,
|
||||
.dependent_connection = dependent_connection,
|
||||
};
|
||||
int index = tf_util_insert_index(&request_number, connection->requests, connection->requests_count, sizeof(tf_ssb_request_t), _request_compare);
|
||||
connection->requests = tf_resize_vec(connection->requests, sizeof(tf_ssb_request_t) * (connection->requests_count + 1));
|
||||
if (connection->requests_count - index)
|
||||
{
|
||||
memmove(connection->requests + index + 1, connection->requests + index, sizeof(tf_ssb_request_t) * (connection->requests_count - index));
|
||||
assert(!existing->callback);
|
||||
assert(!existing->cleanup);
|
||||
assert(!existing->user_data);
|
||||
assert(!existing->dependent_connection);
|
||||
existing->callback = callback;
|
||||
existing->cleanup = cleanup;
|
||||
existing->user_data = user_data;
|
||||
existing->dependent_connection = dependent_connection;
|
||||
}
|
||||
connection->requests[index] = request;
|
||||
connection->requests_count++;
|
||||
else
|
||||
{
|
||||
tf_ssb_connection_remove_request(connection, request_number);
|
||||
tf_ssb_request_t request =
|
||||
{
|
||||
.request_number = request_number,
|
||||
.callback = callback,
|
||||
.cleanup = cleanup,
|
||||
.user_data = user_data,
|
||||
.dependent_connection = dependent_connection,
|
||||
};
|
||||
int index = tf_util_insert_index(&request_number, connection->requests, connection->requests_count, sizeof(tf_ssb_request_t), _request_compare);
|
||||
connection->requests = tf_resize_vec(connection->requests, sizeof(tf_ssb_request_t) * (connection->requests_count + 1));
|
||||
if (connection->requests_count - index)
|
||||
{
|
||||
memmove(connection->requests + index + 1, connection->requests + index, sizeof(tf_ssb_request_t) * (connection->requests_count - index));
|
||||
}
|
||||
connection->requests[index] = request;
|
||||
connection->requests_count++;
|
||||
|
||||
connection->ssb->request_count++;
|
||||
connection->ssb->request_count++;
|
||||
}
|
||||
}
|
||||
|
||||
static int _message_request_compare(const void* a, const void* b)
|
||||
@ -711,8 +729,19 @@ void tf_ssb_connection_rpc_send(tf_ssb_connection_t* connection, uint8_t flags,
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (flags & k_ssb_rpc_flag_new_request)
|
||||
{
|
||||
assert(request_number > 0);
|
||||
assert(!_tf_ssb_connection_get_request_callback(connection, request_number, NULL, NULL));
|
||||
}
|
||||
else if (!_tf_ssb_connection_get_request_callback(connection, request_number, NULL, NULL))
|
||||
{
|
||||
tf_printf("Dropping message with no active request (%d): %.*s\n", request_number, (int)size, message);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t* combined = tf_malloc(9 + size);
|
||||
*combined = flags;
|
||||
*combined = flags & k_ssb_rpc_mask_send;
|
||||
uint32_t u32size = htonl((uint32_t)size);
|
||||
memcpy(combined + 1, &u32size, sizeof(u32size));
|
||||
uint32_t rn = htonl((uint32_t)request_number);
|
||||
@ -720,20 +749,20 @@ void tf_ssb_connection_rpc_send(tf_ssb_connection_t* connection, uint8_t flags,
|
||||
memcpy(combined + 1 + 2 * sizeof(uint32_t), message, size);
|
||||
if (connection->ssb->verbose)
|
||||
{
|
||||
tf_printf(MAGENTA "%s RPC SEND" RESET " flags=%x RN=%d: [%zd B] %.*s\n", connection->name, flags, request_number, size, (flags & k_ssb_rpc_mask_type) == k_ssb_rpc_flag_binary ? 0 : (int)size, message);
|
||||
tf_printf(MAGENTA "%s RPC SEND" RESET " flags=%x RN=%d: [%zd B] %.*s\n", connection->name, flags & k_ssb_rpc_mask_send, request_number, size, (flags & k_ssb_rpc_mask_type) == k_ssb_rpc_flag_binary ? 0 : (int)size, message);
|
||||
}
|
||||
_tf_ssb_connection_add_debug_message(connection, true, flags, request_number, message, size);
|
||||
_tf_ssb_connection_add_debug_message(connection, true, flags & k_ssb_rpc_mask_send, request_number, message, size);
|
||||
_tf_ssb_connection_box_stream_send(connection, combined, 1 + 2 * sizeof(uint32_t) + size);
|
||||
tf_free(combined);
|
||||
connection->ssb->rpc_out++;
|
||||
if (request_number > 0 && callback)
|
||||
if (flags & k_ssb_rpc_flag_end_error)
|
||||
{
|
||||
tf_ssb_connection_remove_request(connection, request_number);
|
||||
}
|
||||
else if (flags & k_ssb_rpc_flag_new_request)
|
||||
{
|
||||
tf_ssb_connection_add_request(connection, request_number, callback, cleanup, user_data, NULL);
|
||||
}
|
||||
else if (cleanup)
|
||||
{
|
||||
cleanup(connection->ssb, user_data);
|
||||
}
|
||||
}
|
||||
|
||||
void tf_ssb_connection_rpc_send_json(tf_ssb_connection_t* connection, uint8_t flags, int32_t request_number, JSValue message, tf_ssb_rpc_callback_t* callback, tf_ssb_callback_cleanup_t* cleanup, void* user_data)
|
||||
@ -744,7 +773,7 @@ void tf_ssb_connection_rpc_send_json(tf_ssb_connection_t* connection, uint8_t fl
|
||||
const char* json_string = JS_ToCStringLen(context, &size, json);
|
||||
tf_ssb_connection_rpc_send(
|
||||
connection,
|
||||
k_ssb_rpc_flag_json | (flags & k_ssb_rpc_flag_stream) | (flags & k_ssb_rpc_flag_end_error),
|
||||
k_ssb_rpc_flag_json | (flags & ~k_ssb_rpc_mask_type),
|
||||
request_number,
|
||||
(const uint8_t*)json_string,
|
||||
size,
|
||||
@ -761,9 +790,9 @@ void tf_ssb_connection_rpc_send_error(tf_ssb_connection_t* connection, uint8_t f
|
||||
JSValue message = JS_NewObject(context);
|
||||
const char* stack = tf_util_backtrace_string();
|
||||
JS_SetPropertyStr(context, message, "name", JS_NewString(context, "Error"));
|
||||
JS_SetPropertyStr(context, message, "stack", JS_NewString(context, stack));
|
||||
JS_SetPropertyStr(context, message, "stack", JS_NewString(context, stack ? stack : "stack unavailable"));
|
||||
JS_SetPropertyStr(context, message, "message", JS_NewString(context, error));
|
||||
tf_ssb_connection_rpc_send_json(connection, ((flags & k_ssb_rpc_flag_stream) ? (k_ssb_rpc_flag_stream | k_ssb_rpc_flag_end_error) : 0), request_number, message, NULL, NULL, NULL);
|
||||
tf_ssb_connection_rpc_send_json(connection, ((flags & k_ssb_rpc_flag_stream) ? (k_ssb_rpc_flag_stream) : 0) | k_ssb_rpc_flag_end_error, request_number, message, NULL, NULL, NULL);
|
||||
JS_FreeValue(context, message);
|
||||
tf_free((void*)stack);
|
||||
}
|
||||
@ -1471,6 +1500,7 @@ static void _tf_ssb_connection_rpc_recv(tf_ssb_connection_t* connection, uint8_t
|
||||
{
|
||||
connection->ssb->rpc_in++;
|
||||
_tf_ssb_connection_add_debug_message(connection, false, flags, request_number, message, size);
|
||||
bool close_connection = false;
|
||||
if (size == 0)
|
||||
{
|
||||
_tf_ssb_connection_close(connection, "rpc recv zero");
|
||||
@ -1489,9 +1519,25 @@ static void _tf_ssb_connection_rpc_recv(tf_ssb_connection_t* connection, uint8_t
|
||||
|
||||
if (!JS_IsUndefined(val))
|
||||
{
|
||||
bool found = false;
|
||||
if (JS_IsObject(val))
|
||||
tf_ssb_rpc_callback_t* callback = NULL;
|
||||
void* user_data = NULL;
|
||||
if (_tf_ssb_connection_get_request_callback(connection, -request_number, &callback, &user_data))
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
char buffer[64];
|
||||
snprintf(buffer, sizeof(buffer), "request %d", request_number);
|
||||
tf_trace_begin(connection->ssb->trace, buffer);
|
||||
PRE_CALLBACK(connection->ssb, callback);
|
||||
callback(connection, flags, request_number, val, message, size, user_data);
|
||||
POST_CALLBACK(connection->ssb, callback);
|
||||
tf_trace_end(connection->ssb->trace);
|
||||
}
|
||||
}
|
||||
else if (JS_IsObject(val))
|
||||
{
|
||||
bool found = false;
|
||||
tf_ssb_connection_add_request(connection, -request_number, NULL, NULL, NULL, NULL);
|
||||
for (tf_ssb_rpc_callback_node_t* it = connection->ssb->rpc; it; it = it->next)
|
||||
{
|
||||
if (_tf_ssb_name_equals(context, val, it->name))
|
||||
@ -1505,35 +1551,19 @@ static void _tf_ssb_connection_rpc_recv(tf_ssb_connection_t* connection, uint8_t
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
{
|
||||
tf_ssb_rpc_callback_t* callback = NULL;
|
||||
void* user_data = NULL;
|
||||
if (_tf_ssb_connection_get_request_callback(connection, -request_number, &callback, &user_data))
|
||||
{
|
||||
if (callback)
|
||||
{
|
||||
char buffer[64];
|
||||
snprintf(buffer, sizeof(buffer), "request %d", request_number);
|
||||
tf_trace_begin(connection->ssb->trace, buffer);
|
||||
PRE_CALLBACK(connection->ssb, callback);
|
||||
callback(connection, flags, request_number, val, message, size, user_data);
|
||||
POST_CALLBACK(connection->ssb, callback);
|
||||
tf_trace_end(connection->ssb->trace);
|
||||
}
|
||||
}
|
||||
else if (!_tf_ssb_name_equals(context, val, (const char*[]) { "Error", NULL }))
|
||||
if (!found)
|
||||
{
|
||||
char buffer[256];
|
||||
_tf_ssb_name_to_string(context, val, buffer, sizeof(buffer));
|
||||
tf_ssb_connection_rpc_send_error_method_not_allowed(connection, flags, -request_number, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("Failed to parse %.*s\n", (int)size, message);
|
||||
close_connection = true;
|
||||
}
|
||||
|
||||
JS_FreeValue(context, val);
|
||||
@ -1565,9 +1595,9 @@ static void _tf_ssb_connection_rpc_recv(tf_ssb_connection_t* connection, uint8_t
|
||||
}
|
||||
}
|
||||
|
||||
if (flags & k_ssb_rpc_flag_end_error)
|
||||
if (close_connection)
|
||||
{
|
||||
tf_ssb_connection_remove_request(connection, -request_number);
|
||||
tf_ssb_connection_close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2278,7 +2308,8 @@ void tf_ssb_destroy(tf_ssb_t* ssb)
|
||||
ssb->broadcast_timer.data ||
|
||||
ssb->broadcast_cleanup_timer.data ||
|
||||
ssb->trace_timer.data ||
|
||||
ssb->server.data)
|
||||
ssb->server.data ||
|
||||
ssb->ref_count)
|
||||
{
|
||||
uv_run(ssb->loop, UV_RUN_ONCE);
|
||||
}
|
||||
@ -2341,7 +2372,11 @@ void tf_ssb_destroy(tf_ssb_t* ssb)
|
||||
}
|
||||
if (ssb->loop == &ssb->own_loop)
|
||||
{
|
||||
uv_loop_close(ssb->loop);
|
||||
int r = uv_loop_close(ssb->loop);
|
||||
if (r != 0)
|
||||
{
|
||||
tf_printf("uv_loop_close: %s\n", uv_strerror(r));
|
||||
}
|
||||
}
|
||||
if (ssb->own_context)
|
||||
{
|
||||
@ -2471,17 +2506,27 @@ static void _tf_ssb_connection_tunnel_callback(
|
||||
void* user_data)
|
||||
{
|
||||
tf_ssb_connection_t* tunnel = user_data;
|
||||
_tf_ssb_connection_on_tcp_recv_internal(tunnel, message, size);
|
||||
if (flags & k_ssb_rpc_flag_end_error)
|
||||
{
|
||||
tf_ssb_connection_rpc_send(
|
||||
connection,
|
||||
flags,
|
||||
-request_number,
|
||||
(const uint8_t*)"false",
|
||||
strlen("false"),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL);
|
||||
tf_ssb_connection_close(tunnel);
|
||||
}
|
||||
else
|
||||
{
|
||||
_tf_ssb_connection_on_tcp_recv_internal(tunnel, message, size);
|
||||
}
|
||||
}
|
||||
|
||||
tf_ssb_connection_t* tf_ssb_connection_tunnel_create(tf_ssb_t* ssb, const char* portal_id, int32_t request_number, const char* target_id)
|
||||
{
|
||||
if (tf_ssb_connection_get(ssb, target_id))
|
||||
{
|
||||
/* Already have a possibly more direct connection to target. */
|
||||
return NULL;
|
||||
}
|
||||
|
||||
tf_ssb_connection_t* connection = tf_ssb_connection_get(ssb, portal_id);
|
||||
|
||||
JSContext* context = ssb->context;
|
||||
@ -2969,6 +3014,11 @@ tf_ssb_connection_t* tf_ssb_connection_get(tf_ssb_t* ssb, const char* id)
|
||||
tf_ssb_id_str_to_bin(pub, id);
|
||||
for (tf_ssb_connection_t* connection = ssb->connections; connection; connection = connection->next)
|
||||
{
|
||||
if (connection->tunnel_connection)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (memcmp(connection->serverpub, pub, k_id_bin_len) == 0)
|
||||
{
|
||||
return connection;
|
||||
@ -3512,6 +3562,7 @@ JSValue tf_ssb_get_disconnection_debug(tf_ssb_t* ssb, JSContext* context)
|
||||
JS_SetPropertyStr(context, message, "direction", JS_NewString(context, ssb->debug_close[i].messages[j]->outgoing ? "out" : "in"));
|
||||
JS_SetPropertyStr(context, message, "flags", JS_NewInt32(context, ssb->debug_close[i].messages[j]->flags));
|
||||
JS_SetPropertyStr(context, message, "request_number", JS_NewInt32(context, ssb->debug_close[i].messages[j]->request_number));
|
||||
JS_SetPropertyStr(context, message, "timestamp", JS_NewFloat64(context, ssb->debug_close[i].messages[j]->timestamp));
|
||||
JS_SetPropertyStr(context, message, "payload", JS_NewStringLen(context, (const char*)ssb->debug_close[i].messages[j]->data, ssb->debug_close[i].messages[j]->size));
|
||||
JS_SetPropertyUint32(context, messages, j, message);
|
||||
}
|
||||
@ -3524,37 +3575,24 @@ JSValue tf_ssb_get_disconnection_debug(tf_ssb_t* ssb, JSContext* context)
|
||||
return result;
|
||||
}
|
||||
|
||||
void tf_ssb_record_thread_time(tf_ssb_t* ssb, int64_t thread_id, uint64_t hrtime)
|
||||
void tf_ssb_record_thread_busy(tf_ssb_t* ssb, bool busy)
|
||||
{
|
||||
for (int i = 0; i < ssb->thread_time_count; i++)
|
||||
int32_t busy_value = __atomic_add_fetch(&ssb->thread_busy_count, busy ? 1 : -1, __ATOMIC_RELAXED);
|
||||
int32_t current = ssb->thread_busy_max;
|
||||
while (busy_value > current && !__atomic_compare_exchange_n(&ssb->thread_busy_max, ¤t, busy_value, false, __ATOMIC_RELAXED, __ATOMIC_RELAXED))
|
||||
{
|
||||
if (ssb->thread_time[i].thread_id == thread_id)
|
||||
{
|
||||
ssb->thread_time[i].hrtime += hrtime;
|
||||
return;
|
||||
}
|
||||
current = ssb->thread_busy_max;
|
||||
}
|
||||
ssb->thread_time = tf_resize_vec(ssb->thread_time, sizeof(tf_thread_work_time_t) * (ssb->thread_time_count + 1));
|
||||
ssb->thread_time[ssb->thread_time_count++] = (tf_thread_work_time_t)
|
||||
{
|
||||
.thread_id = thread_id,
|
||||
.hrtime = hrtime,
|
||||
};
|
||||
}
|
||||
|
||||
uint64_t tf_ssb_get_average_thread_time(tf_ssb_t* ssb)
|
||||
float tf_ssb_get_average_thread_percent(tf_ssb_t* ssb)
|
||||
{
|
||||
if (!ssb)
|
||||
if (!ssb || !ssb->thread_busy_max)
|
||||
{
|
||||
return 0;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
uint64_t total = 0;
|
||||
for (int i = 0; i < ssb->thread_time_count; i++)
|
||||
{
|
||||
total += ssb->thread_time[i].hrtime;
|
||||
}
|
||||
return ssb->thread_time_count ? total / ssb->thread_time_count : 0;
|
||||
return 100.0f * ssb->thread_busy_count / ssb->thread_busy_max;
|
||||
}
|
||||
|
||||
void tf_ssb_set_hitch_callback(tf_ssb_t* ssb, void (*callback)(const char* name, uint64_t duration_ns, void* user_data), void* user_data)
|
||||
@ -3567,3 +3605,13 @@ tf_ssb_store_queue_t* tf_ssb_get_store_queue(tf_ssb_t* ssb)
|
||||
{
|
||||
return &ssb->store_queue;
|
||||
}
|
||||
|
||||
void tf_ssb_ref(tf_ssb_t* ssb)
|
||||
{
|
||||
ssb->ref_count++;
|
||||
}
|
||||
|
||||
void tf_ssb_unref(tf_ssb_t* ssb)
|
||||
{
|
||||
ssb->ref_count--;
|
||||
}
|
||||
|
34
src/ssb.db.c
34
src/ssb.db.c
@ -77,6 +77,25 @@ void tf_ssb_db_init(tf_ssb_t* ssb)
|
||||
{
|
||||
sqlite3* db = tf_ssb_acquire_db_writer(ssb);
|
||||
_tf_ssb_db_init_internal(db);
|
||||
|
||||
sqlite3_stmt* statement = NULL;
|
||||
int auto_vacuum = 0;
|
||||
if (sqlite3_prepare(db, "PRAGMA auto_vacuum", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
auto_vacuum = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
if (auto_vacuum != 1 /* FULL */)
|
||||
{
|
||||
tf_printf("Enabling auto-vacuum and performing full vacuum.\n");
|
||||
_tf_ssb_db_exec(db, "PRAGMA auto_vacuum = FULL");
|
||||
_tf_ssb_db_exec(db, "VACUUM main");
|
||||
tf_printf("All clean.\n");
|
||||
}
|
||||
|
||||
_tf_ssb_db_exec(db,
|
||||
"CREATE TABLE IF NOT EXISTS messages ("
|
||||
" author TEXT,"
|
||||
@ -93,6 +112,7 @@ void tf_ssb_db_init(tf_ssb_t* ssb)
|
||||
_tf_ssb_db_exec(db, "CREATE INDEX IF NOT EXISTS messages_author_id_index ON messages (author, id)");
|
||||
_tf_ssb_db_exec(db, "CREATE INDEX IF NOT EXISTS messages_author_sequence_index ON messages (author, sequence)");
|
||||
_tf_ssb_db_exec(db, "CREATE INDEX IF NOT EXISTS messages_author_timestamp_index ON messages (author, timestamp)");
|
||||
_tf_ssb_db_exec(db, "CREATE INDEX IF NOT EXISTS messages_timestamp_index ON messages (timestamp)");
|
||||
_tf_ssb_db_exec(db,
|
||||
"CREATE TABLE IF NOT EXISTS blobs ("
|
||||
" id TEXT PRIMARY KEY,"
|
||||
@ -184,9 +204,10 @@ void tf_ssb_db_init(tf_ssb_t* ssb)
|
||||
_tf_ssb_db_exec(db, "CREATE INDEX IF NOT EXISTS messages_refs_ref_idx ON messages_refs (ref)");
|
||||
_tf_ssb_db_exec(db, "DROP VIEW IF EXISTS blob_wants_view");
|
||||
_tf_ssb_db_exec(db,
|
||||
"CREATE VIEW IF NOT EXISTS blob_wants_view (id) AS "
|
||||
" SELECT messages_refs.ref AS id "
|
||||
"CREATE VIEW IF NOT EXISTS blob_wants_view (id, timestamp) AS "
|
||||
" SELECT messages_refs.ref AS id, messages.timestamp AS timestamp "
|
||||
" FROM messages_refs "
|
||||
" JOIN messages ON messages.id = messages_refs.message "
|
||||
" LEFT OUTER JOIN blobs ON messages_refs.ref = blobs.id "
|
||||
" WHERE blobs.id IS NULL "
|
||||
" AND LENGTH(messages_refs.ref) = 52 "
|
||||
@ -195,7 +216,6 @@ void tf_ssb_db_init(tf_ssb_t* ssb)
|
||||
bool need_add_sequence_before_author = true;
|
||||
bool need_convert_timestamp_to_real = false;
|
||||
|
||||
sqlite3_stmt* statement = NULL;
|
||||
if (sqlite3_prepare(db, "PRAGMA table_info(messages)", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
int result = SQLITE_OK;
|
||||
@ -389,6 +409,7 @@ typedef struct _message_store_t
|
||||
static void _tf_ssb_db_store_message_work(uv_work_t* work)
|
||||
{
|
||||
message_store_t* store = work->data;
|
||||
tf_ssb_record_thread_busy(store->ssb, true);
|
||||
tf_trace_t* trace = tf_ssb_get_trace(store->ssb);
|
||||
tf_trace_begin(trace, "message_store_work");
|
||||
int64_t last_row_id = _tf_ssb_db_store_message_raw(store->ssb, store->id, *store->previous ? store->previous : NULL, store->author, store->sequence, store->timestamp, store->content, store->length, store->signature, store->sequence_before_author);
|
||||
@ -398,6 +419,7 @@ static void _tf_ssb_db_store_message_work(uv_work_t* work)
|
||||
store->out_blob_wants = _tf_ssb_db_get_message_blob_wants(store->ssb, last_row_id);
|
||||
}
|
||||
tf_trace_end(trace);
|
||||
tf_ssb_record_thread_busy(store->ssb, false);
|
||||
}
|
||||
|
||||
static void _wake_up_queue(tf_ssb_t* ssb, tf_ssb_store_queue_t* queue)
|
||||
@ -627,10 +649,12 @@ typedef struct _blob_store_work_t
|
||||
static void _tf_ssb_db_blob_store_work(uv_work_t* work)
|
||||
{
|
||||
blob_store_work_t* blob_work = work->data;
|
||||
tf_ssb_record_thread_busy(blob_work->ssb, true);
|
||||
tf_trace_t* trace = tf_ssb_get_trace(blob_work->ssb);
|
||||
tf_trace_begin(trace, "blob_store_work");
|
||||
tf_ssb_db_blob_store(blob_work->ssb, blob_work->blob, blob_work->size, blob_work->id, sizeof(blob_work->id), &blob_work->is_new);
|
||||
tf_trace_end(trace);
|
||||
tf_ssb_record_thread_busy(blob_work->ssb, false);
|
||||
}
|
||||
|
||||
static void _tf_ssb_db_blob_store_after_work(uv_work_t* work, int status)
|
||||
@ -893,7 +917,7 @@ static JSValue _tf_ssb_sqlite_row_to_json(JSContext* context, sqlite3_stmt* row)
|
||||
return result;
|
||||
}
|
||||
|
||||
static int _tf_ssb_sqlite_authorizer(void* user_data, int action_code, const char* arg0, const char* arg1, const char* arg2, const char* arg3)
|
||||
int tf_ssb_sqlite_authorizer(void* user_data, int action_code, const char* arg0, const char* arg1, const char* arg2, const char* arg3)
|
||||
{
|
||||
int result = SQLITE_DENY;
|
||||
switch (action_code)
|
||||
@ -939,7 +963,7 @@ JSValue tf_ssb_db_visit_query(tf_ssb_t* ssb, const char* query, const JSValue bi
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(ssb);
|
||||
JSContext* context = tf_ssb_get_context(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
sqlite3_set_authorizer(db, _tf_ssb_sqlite_authorizer, ssb);
|
||||
sqlite3_set_authorizer(db, tf_ssb_sqlite_authorizer, ssb);
|
||||
if (sqlite3_prepare(db, query, -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
JSValue bind_result = _tf_ssb_sqlite_bind_json(context, db, statement, binds);
|
||||
|
@ -58,3 +58,5 @@ typedef struct _tf_ssb_db_stored_connection_t
|
||||
|
||||
tf_ssb_db_stored_connection_t* tf_ssb_db_get_stored_connections(tf_ssb_t* ssb, int* out_count);
|
||||
void tf_ssb_db_forget_stored_connection(tf_ssb_t* ssb, const char* address, int port, const char* pubkey);
|
||||
|
||||
int tf_ssb_sqlite_authorizer(void* user_data, int action_code, const char* arg0, const char* arg1, const char* arg2, const char* arg3);
|
||||
|
12
src/ssb.h
12
src/ssb.h
@ -15,6 +15,11 @@ enum
|
||||
|
||||
k_ssb_rpc_flag_end_error = 0x4,
|
||||
k_ssb_rpc_flag_stream = 0x8,
|
||||
k_ssb_rpc_mask_message = 0xC,
|
||||
|
||||
k_ssb_rpc_mask_send = 0xf,
|
||||
|
||||
k_ssb_rpc_flag_new_request = 0x10,
|
||||
|
||||
k_ssb_blob_bytes_max = 5 * 1024 * 1024,
|
||||
};
|
||||
@ -194,9 +199,12 @@ tf_ssb_blob_wants_t* tf_ssb_connection_get_blob_wants_state(tf_ssb_connection_t*
|
||||
|
||||
JSValue tf_ssb_get_disconnection_debug(tf_ssb_t* ssb, JSContext* context);
|
||||
|
||||
void tf_ssb_record_thread_time(tf_ssb_t* ssb, int64_t thread_id, uint64_t hrtime);
|
||||
uint64_t tf_ssb_get_average_thread_time(tf_ssb_t* ssb);
|
||||
void tf_ssb_record_thread_busy(tf_ssb_t* ssb, bool busy);
|
||||
float tf_ssb_get_average_thread_percent(tf_ssb_t* ssb);
|
||||
|
||||
void tf_ssb_set_hitch_callback(tf_ssb_t* ssb, void (*callback)(const char* name, uint64_t duration_ns, void* user_data), void* user_data);
|
||||
|
||||
tf_ssb_store_queue_t* tf_ssb_get_store_queue(tf_ssb_t* ssb);
|
||||
|
||||
void tf_ssb_ref(tf_ssb_t* ssb);
|
||||
void tf_ssb_unref(tf_ssb_t* ssb);
|
||||
|
13
src/ssb.js.c
13
src/ssb.js.c
@ -382,9 +382,6 @@ typedef struct _sql_work_t
|
||||
{
|
||||
uv_work_t request;
|
||||
tf_ssb_t* ssb;
|
||||
uv_thread_t thread_id;
|
||||
uint64_t start_time;
|
||||
uint64_t end_time;
|
||||
const char* query;
|
||||
uint8_t* binds;
|
||||
size_t binds_count;
|
||||
@ -406,11 +403,11 @@ static void _tf_ssb_sql_append(uint8_t** rows, size_t* rows_count, const void* d
|
||||
static void _tf_ssb_sqlAsync_work(uv_work_t* work)
|
||||
{
|
||||
sql_work_t* sql_work = work->data;
|
||||
tf_ssb_record_thread_busy(sql_work->ssb, true);
|
||||
tf_trace_t* trace = tf_ssb_get_trace(sql_work->ssb);
|
||||
tf_trace_begin(trace, "sql_async_work");
|
||||
sql_work->start_time = uv_hrtime();
|
||||
sql_work->thread_id = uv_thread_self();
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(sql_work->ssb);
|
||||
sqlite3_set_authorizer(db, tf_ssb_sqlite_authorizer, sql_work->ssb);
|
||||
sqlite3_stmt* statement = NULL;
|
||||
sql_work->result = sqlite3_prepare(db, sql_work->query, -1, &statement, NULL);
|
||||
if (sql_work->result == SQLITE_OK)
|
||||
@ -504,8 +501,9 @@ static void _tf_ssb_sqlAsync_work(uv_work_t* work)
|
||||
{
|
||||
sql_work->error = tf_strdup(sqlite3_errmsg(db));
|
||||
}
|
||||
sqlite3_set_authorizer(db, NULL, NULL);
|
||||
tf_ssb_release_db_reader(sql_work->ssb, db);
|
||||
sql_work->end_time = uv_hrtime();
|
||||
tf_ssb_record_thread_busy(sql_work->ssb, false);
|
||||
tf_trace_end(trace);
|
||||
}
|
||||
|
||||
@ -514,7 +512,6 @@ static void _tf_ssb_sqlAsync_after_work(uv_work_t* work, int status)
|
||||
sql_work_t* sql_work = work->data;
|
||||
tf_trace_t* trace = tf_ssb_get_trace(sql_work->ssb);
|
||||
tf_trace_begin(trace, "sql_async_after_work");
|
||||
tf_ssb_record_thread_time(sql_work->ssb, (int64_t)sql_work->thread_id, sql_work->end_time - sql_work->start_time);
|
||||
JSContext* context = tf_ssb_get_context(sql_work->ssb);
|
||||
uint8_t* p = sql_work->rows;
|
||||
while (p < sql_work->rows + sql_work->rows_count)
|
||||
@ -1099,7 +1096,7 @@ static JSValue _tf_ssb_createTunnel(JSContext* context, JSValueConst this_val, i
|
||||
JS_SetPropertyUint32(context, args, 0, arg);
|
||||
JS_SetPropertyStr(context, message, "args", args);
|
||||
JS_SetPropertyStr(context, message, "type", JS_NewString(context, "duplex"));
|
||||
tf_ssb_connection_rpc_send_json(connection, k_ssb_rpc_flag_stream, request_number, message, NULL, NULL, NULL);
|
||||
tf_ssb_connection_rpc_send_json(connection, k_ssb_rpc_flag_stream | k_ssb_rpc_flag_new_request, request_number, message, NULL, NULL, NULL);
|
||||
JS_FreeValue(context, message);
|
||||
|
||||
tf_ssb_connection_tunnel_create(ssb, portal_id, request_number, target_id);
|
||||
|
295
src/ssb.rpc.c
295
src/ssb.rpc.c
@ -10,6 +10,7 @@
|
||||
#include "uv.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
@ -19,6 +20,80 @@
|
||||
|
||||
static void _tf_ssb_connection_send_history_stream(tf_ssb_connection_t* connection, int32_t request_number, const char* author, int64_t sequence, bool keys, bool live);
|
||||
static void _tf_ssb_connection_send_history_stream_internal(tf_ssb_connection_t* connection, int32_t request_number, const char* author, int64_t sequence, bool keys, bool live);
|
||||
static void _tf_ssb_rpc_start_delete_blobs(tf_ssb_t* ssb, int delay_ms);
|
||||
|
||||
static int64_t _get_global_setting_int64(tf_ssb_t* ssb, const char* name, int64_t default_value)
|
||||
{
|
||||
int64_t result = default_value;
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
if (sqlite3_prepare(db, "SELECT json_extract(value, '$.' || ?) FROM properties WHERE id = 'core' AND key = 'settings'", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_bind_text(statement, 1, name, -1, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
result = sqlite3_column_int64(statement, 0);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
}
|
||||
tf_ssb_release_db_reader(ssb, db);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool _get_global_setting_bool(tf_ssb_t* ssb, const char* name, bool default_value)
|
||||
{
|
||||
bool result = default_value;
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
if (sqlite3_prepare(db, "SELECT json_extract(value, '$.' || ?) FROM properties WHERE id = 'core' AND key = 'settings'", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_bind_text(statement, 1, name, -1, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
result = sqlite3_column_int(statement, 0) != 0;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
}
|
||||
tf_ssb_release_db_reader(ssb, db);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool _get_global_setting_string(tf_ssb_t* ssb, const char* name, char* out_value, size_t size)
|
||||
{
|
||||
bool result = false;
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
if (sqlite3_prepare(db, "SELECT json_extract(value, '$.' || ?) FROM properties WHERE id = 'core' AND key = 'settings'", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_bind_text(statement, 1, name, -1, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
snprintf(out_value, size, "%s", sqlite3_column_text(statement, 0));
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
}
|
||||
tf_ssb_release_db_reader(ssb, db);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_gossip_ping_callback(tf_ssb_connection_t* connection, uint8_t flags, int32_t request_number, JSValue args, const uint8_t* message, size_t size, void* user_data)
|
||||
{
|
||||
@ -44,6 +119,10 @@ static void _tf_ssb_rpc_gossip_ping(tf_ssb_connection_t* connection, uint8_t fla
|
||||
tf_ssb_connection_add_request(connection, -request_number, _tf_ssb_rpc_gossip_ping_callback, NULL, NULL, NULL);
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_blobs_get_callback(tf_ssb_connection_t* connection, uint8_t flags, int32_t request_number, JSValue args, const uint8_t* message, size_t size, void* user_data)
|
||||
{
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_blobs_get(tf_ssb_connection_t* connection, uint8_t flags, int32_t request_number, JSValue args, const uint8_t* message, size_t size, void* user_data)
|
||||
{
|
||||
if (flags & k_ssb_rpc_flag_end_error)
|
||||
@ -51,6 +130,7 @@ static void _tf_ssb_rpc_blobs_get(tf_ssb_connection_t* connection, uint8_t flags
|
||||
return;
|
||||
}
|
||||
|
||||
tf_ssb_connection_add_request(connection, -request_number, _tf_ssb_rpc_blobs_get_callback, NULL, NULL, NULL);
|
||||
tf_ssb_t* ssb = tf_ssb_connection_get_ssb(connection);
|
||||
JSContext* context = tf_ssb_connection_get_context(connection);
|
||||
JSValue ids = JS_GetPropertyStr(context, args, "args");
|
||||
@ -143,11 +223,25 @@ static void _tf_ssb_rpc_request_more_blobs(tf_ssb_connection_t* connection)
|
||||
JSContext* context = tf_ssb_connection_get_context(connection);
|
||||
tf_ssb_blob_wants_t* blob_wants = tf_ssb_connection_get_blob_wants_state(connection);
|
||||
tf_ssb_t* ssb = tf_ssb_connection_get_ssb(connection);
|
||||
int64_t age = _get_global_setting_int64(ssb, "blob_fetch_age_seconds", -1);
|
||||
int64_t timestamp = -1;
|
||||
if (age == 0)
|
||||
{
|
||||
/* Don't fetch any blobs. */
|
||||
return;
|
||||
}
|
||||
else if (age > 0)
|
||||
{
|
||||
int64_t now = (int64_t)time(NULL) * 1000ULL;
|
||||
timestamp = now - age * 1000ULL;
|
||||
}
|
||||
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
if (sqlite3_prepare(db, "SELECT id FROM blob_wants_view WHERE id > ? ORDER BY id LIMIT 32", -1, &statement, NULL) == SQLITE_OK)
|
||||
if (sqlite3_prepare(db, "SELECT id FROM blob_wants_view WHERE id > ? AND timestamp > ? ORDER BY id LIMIT 32", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_bind_text(statement, 1, blob_wants->last_id, -1, NULL) == SQLITE_OK)
|
||||
if (sqlite3_bind_text(statement, 1, blob_wants->last_id, -1, NULL) == SQLITE_OK &&
|
||||
sqlite3_bind_int64(statement, 2, timestamp) == SQLITE_OK)
|
||||
{
|
||||
while (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
@ -187,7 +281,23 @@ typedef struct tunnel_t
|
||||
void _tf_ssb_rpc_tunnel_callback(tf_ssb_connection_t* connection, uint8_t flags, int32_t request_number, JSValue args, const uint8_t* message, size_t size, void* user_data)
|
||||
{
|
||||
tunnel_t* tun = user_data;
|
||||
tf_ssb_connection_rpc_send(tun->connection, flags, tun->request_number, message, size, NULL, NULL, NULL);
|
||||
if (flags & k_ssb_rpc_flag_end_error)
|
||||
{
|
||||
tf_ssb_connection_rpc_send(
|
||||
connection,
|
||||
flags,
|
||||
-request_number,
|
||||
(const uint8_t*)"false",
|
||||
strlen("false"),
|
||||
NULL,
|
||||
NULL,
|
||||
NULL);
|
||||
tf_ssb_connection_close(tun->connection);
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_ssb_connection_rpc_send(tun->connection, flags, tun->request_number, message, size, NULL, NULL, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
void _tf_ssb_rpc_tunnel_cleanup(tf_ssb_t* ssb, void* user_data)
|
||||
@ -195,67 +305,6 @@ void _tf_ssb_rpc_tunnel_cleanup(tf_ssb_t* ssb, void* user_data)
|
||||
tf_free(user_data);
|
||||
}
|
||||
|
||||
static bool _get_global_setting_bool(tf_ssb_t* ssb, const char* name, bool default_value)
|
||||
{
|
||||
bool result = default_value;
|
||||
JSContext* context = tf_ssb_get_context(ssb);
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
if (sqlite3_prepare(db, "SELECT value FROM properties WHERE id = 'core' AND key = 'settings'", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
JSValue value = JS_ParseJSON(context, (const char*)sqlite3_column_text(statement, 0), sqlite3_column_bytes(statement, 0), NULL);
|
||||
JSValue property = JS_GetPropertyStr(context, value, name);
|
||||
if (JS_IsBool(property))
|
||||
{
|
||||
result = JS_ToBool(context, property);
|
||||
}
|
||||
JS_FreeValue(context, property);
|
||||
JS_FreeValue(context, value);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
}
|
||||
tf_ssb_release_db_reader(ssb, db);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool _get_global_setting_string(tf_ssb_t* ssb, const char* name, char* out_value, size_t size)
|
||||
{
|
||||
bool result = false;
|
||||
JSContext* context = tf_ssb_get_context(ssb);
|
||||
sqlite3* db = tf_ssb_acquire_db_reader(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
if (sqlite3_prepare(db, "SELECT value FROM properties WHERE id = 'core' AND key = 'settings'", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
JSValue value = JS_ParseJSON(context, (const char*)sqlite3_column_text(statement, 0), sqlite3_column_bytes(statement, 0), NULL);
|
||||
JSValue property = JS_GetPropertyStr(context, value, name);
|
||||
const char* value_string = JS_ToCString(context, property);
|
||||
if (value_string)
|
||||
{
|
||||
snprintf(out_value, size, "%s", value_string);
|
||||
result = true;
|
||||
JS_FreeCString(context, value_string);
|
||||
}
|
||||
JS_FreeValue(context, property);
|
||||
JS_FreeValue(context, value);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
}
|
||||
tf_ssb_release_db_reader(ssb, db);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_tunnel_connect(tf_ssb_connection_t* connection, uint8_t flags, int32_t request_number, JSValue args, const uint8_t* message, size_t size, void* user_data)
|
||||
{
|
||||
tf_ssb_t* ssb = tf_ssb_connection_get_ssb(connection);
|
||||
@ -273,7 +322,7 @@ static void _tf_ssb_rpc_tunnel_connect(tf_ssb_connection_t* connection, uint8_t
|
||||
JSValue target = JS_GetPropertyStr(context, arg, "target");
|
||||
|
||||
if (JS_IsUndefined(origin) &&
|
||||
!JS_IsUndefined(portal) &&
|
||||
!JS_IsUndefined(portal) &&
|
||||
!JS_IsUndefined(target))
|
||||
{
|
||||
const char* target_str = JS_ToCString(context, target);
|
||||
@ -302,7 +351,7 @@ static void _tf_ssb_rpc_tunnel_connect(tf_ssb_connection_t* connection, uint8_t
|
||||
|
||||
tf_ssb_connection_rpc_send_json(
|
||||
target_connection,
|
||||
k_ssb_rpc_flag_stream,
|
||||
k_ssb_rpc_flag_stream | k_ssb_rpc_flag_new_request,
|
||||
tunnel_request_number,
|
||||
message,
|
||||
NULL,
|
||||
@ -327,10 +376,14 @@ static void _tf_ssb_rpc_tunnel_connect(tf_ssb_connection_t* connection, uint8_t
|
||||
JS_FreeValue(context, message);
|
||||
JS_FreeCString(context, portal_str);
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_ssb_connection_rpc_send_error(connection, flags, -request_number, "Connection not found.");
|
||||
}
|
||||
JS_FreeCString(context, target_str);
|
||||
}
|
||||
else if (!JS_IsUndefined(origin) &&
|
||||
!JS_IsUndefined(portal) &&
|
||||
!JS_IsUndefined(portal) &&
|
||||
!JS_IsUndefined(target))
|
||||
{
|
||||
const char* origin_str = JS_ToCString(context, origin);
|
||||
@ -512,7 +565,7 @@ static void _tf_ssb_rpc_connection_blobs_get(tf_ssb_connection_t* connection, co
|
||||
|
||||
tf_ssb_connection_rpc_send_json(
|
||||
connection,
|
||||
k_ssb_rpc_flag_stream,
|
||||
k_ssb_rpc_flag_stream | k_ssb_rpc_flag_new_request,
|
||||
tf_ssb_connection_next_request_number(connection),
|
||||
message,
|
||||
_tf_ssb_rpc_connection_blobs_get_callback,
|
||||
@ -661,7 +714,9 @@ static void _tf_ssb_rpc_connection_room_attendants_callback(tf_ssb_connection_t*
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_ssb_connection_rpc_send_error(connection, flags, -request_number, "Unexpected room.attendants response type.");
|
||||
char buffer[256];
|
||||
snprintf(buffer, sizeof(buffer), "Unexpected room.attendants response type: '%s'.", type_string);
|
||||
tf_ssb_connection_rpc_send_error(connection, flags, -request_number, buffer);
|
||||
}
|
||||
JS_FreeCString(context, type_string);
|
||||
JS_FreeValue(context, type);
|
||||
@ -701,7 +756,7 @@ static void _tf_ssb_rpc_connection_tunnel_isRoom_callback(tf_ssb_connection_t* c
|
||||
JS_SetPropertyStr(context, message, "args", JS_NewArray(context));
|
||||
tf_ssb_connection_rpc_send_json(
|
||||
connection,
|
||||
k_ssb_rpc_flag_stream,
|
||||
k_ssb_rpc_flag_stream | k_ssb_rpc_flag_new_request,
|
||||
tf_ssb_connection_next_request_number(connection),
|
||||
message,
|
||||
_tf_ssb_rpc_connection_room_attendants_callback,
|
||||
@ -1019,7 +1074,7 @@ static void _tf_ssb_rpc_send_ebt_replicate(tf_ssb_connection_t* connection)
|
||||
int32_t request_number = tf_ssb_connection_next_request_number(connection);
|
||||
tf_ssb_connection_rpc_send_json(
|
||||
connection,
|
||||
k_ssb_rpc_flag_stream,
|
||||
k_ssb_rpc_flag_stream | k_ssb_rpc_flag_new_request,
|
||||
request_number,
|
||||
message,
|
||||
_tf_ssb_rpc_ebt_replicate_client,
|
||||
@ -1056,7 +1111,7 @@ static void _tf_ssb_rpc_connections_changed_callback(tf_ssb_t* ssb, tf_ssb_chang
|
||||
JS_SetPropertyStr(context, message, "args", JS_NewArray(context));
|
||||
tf_ssb_connection_rpc_send_json(
|
||||
connection,
|
||||
k_ssb_rpc_flag_stream,
|
||||
k_ssb_rpc_flag_stream | k_ssb_rpc_flag_new_request,
|
||||
tf_ssb_connection_next_request_number(connection),
|
||||
message,
|
||||
_tf_ssb_rpc_connection_blobs_createWants_callback,
|
||||
@ -1074,7 +1129,7 @@ static void _tf_ssb_rpc_connections_changed_callback(tf_ssb_t* ssb, tf_ssb_chang
|
||||
JS_SetPropertyStr(context, message, "args", JS_NewArray(context));
|
||||
tf_ssb_connection_rpc_send_json(
|
||||
connection,
|
||||
0,
|
||||
k_ssb_rpc_flag_new_request,
|
||||
tf_ssb_connection_next_request_number(connection),
|
||||
message,
|
||||
_tf_ssb_rpc_connection_tunnel_isRoom_callback,
|
||||
@ -1116,6 +1171,99 @@ static void _tf_ssb_rpc_connections_changed_callback(tf_ssb_t* ssb, tf_ssb_chang
|
||||
}
|
||||
}
|
||||
|
||||
typedef struct _delete_blobs_work_t
|
||||
{
|
||||
uv_work_t work;
|
||||
tf_ssb_t* ssb;
|
||||
} delete_blobs_work_t;
|
||||
|
||||
static void _tf_ssb_rpc_delete_blobs_work(uv_work_t* work)
|
||||
{
|
||||
delete_blobs_work_t* delete = work->data;
|
||||
tf_ssb_t* ssb = delete->ssb;
|
||||
tf_ssb_record_thread_busy(ssb, true);
|
||||
int64_t age = _get_global_setting_int64(ssb, "blob_expire_age_seconds", -1);
|
||||
if (age <= 0)
|
||||
{
|
||||
tf_ssb_record_thread_busy(ssb, false);
|
||||
return;
|
||||
}
|
||||
int64_t start_ns = uv_hrtime();
|
||||
sqlite3* db = tf_ssb_acquire_db_writer(ssb);
|
||||
sqlite3_stmt* statement;
|
||||
int64_t now = (int64_t)time(NULL) * 1000ULL;
|
||||
int64_t timestamp = now - age * 1000ULL;
|
||||
const int k_limit = 256;
|
||||
int deleted = 0;
|
||||
if (sqlite3_prepare(db,
|
||||
"DELETE FROM blobs WHERE blobs.id IN ("
|
||||
" SELECT blobs.id FROM blobs "
|
||||
" JOIN messages_refs ON blobs.id = messages_refs.ref "
|
||||
" JOIN messages ON messages.id = messages_refs.message "
|
||||
" GROUP BY blobs.id HAVING MAX(messages.timestamp) < ? LIMIT ?)", -1, &statement, NULL) == SQLITE_OK)
|
||||
{
|
||||
if (sqlite3_bind_int64(statement, 1, timestamp) == SQLITE_OK &&
|
||||
sqlite3_bind_int(statement, 2, k_limit) == SQLITE_OK)
|
||||
{
|
||||
int r = sqlite3_step(statement);
|
||||
if (r != SQLITE_DONE)
|
||||
{
|
||||
tf_printf("_tf_ssb_rpc_delete_blobs_work: %s\n", sqlite3_errmsg(db));
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("_tf_ssb_rpc_delete_blobs_work: %d rows\n", sqlite3_changes(db));
|
||||
}
|
||||
deleted = sqlite3_changes(db);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_printf("prepare failed: %s\n", sqlite3_errmsg(db));
|
||||
}
|
||||
tf_ssb_release_db_writer(ssb, db);
|
||||
int64_t duration_ms = (uv_hrtime() - start_ns) / 1000000LL;
|
||||
tf_printf("Deleted %d blobs in %d ms.\n", deleted, (int)duration_ms);
|
||||
_tf_ssb_rpc_start_delete_blobs(ssb, deleted ? (int)duration_ms : (15 * 60 * 1000));
|
||||
tf_ssb_record_thread_busy(ssb, false);
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_delete_blobs_after_work(uv_work_t* work, int status)
|
||||
{
|
||||
delete_blobs_work_t* delete = work->data;
|
||||
tf_ssb_unref(delete->ssb);
|
||||
tf_free(delete);
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_timer_on_close(uv_handle_t* handle)
|
||||
{
|
||||
tf_free(handle);
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_start_delete_timer(uv_timer_t* timer)
|
||||
{
|
||||
tf_ssb_t* ssb = timer->data;
|
||||
delete_blobs_work_t* work = tf_malloc(sizeof(delete_blobs_work_t));
|
||||
*work = (delete_blobs_work_t) { .work = { .data = work}, .ssb = ssb };
|
||||
int r = uv_queue_work(tf_ssb_get_loop(ssb), &work->work, _tf_ssb_rpc_delete_blobs_work, _tf_ssb_rpc_delete_blobs_after_work);
|
||||
if (r)
|
||||
{
|
||||
tf_printf("uv_queue_work: %s\n", uv_strerror(r));
|
||||
tf_free(work);
|
||||
}
|
||||
uv_close((uv_handle_t*)timer, _tf_ssb_rpc_timer_on_close);
|
||||
}
|
||||
|
||||
static void _tf_ssb_rpc_start_delete_blobs(tf_ssb_t* ssb, int delay_ms)
|
||||
{
|
||||
tf_printf("will delete more blobs in %d ms\n", delay_ms);
|
||||
uv_timer_t* timer = tf_malloc(sizeof(uv_timer_t));
|
||||
*timer = (uv_timer_t) { .data = ssb };
|
||||
uv_timer_init(tf_ssb_get_loop(ssb), timer);
|
||||
uv_timer_start(timer, _tf_ssb_rpc_start_delete_timer, delay_ms, 0);
|
||||
tf_ssb_ref(ssb);
|
||||
}
|
||||
|
||||
void tf_ssb_rpc_register(tf_ssb_t* ssb)
|
||||
{
|
||||
tf_ssb_add_connections_changed_callback(ssb, _tf_ssb_rpc_connections_changed_callback, NULL, NULL);
|
||||
@ -1129,4 +1277,5 @@ void tf_ssb_rpc_register(tf_ssb_t* ssb)
|
||||
tf_ssb_add_rpc_callback(ssb, (const char*[]) { "room", "attendants", NULL }, _tf_ssb_rpc_room_attendants, NULL, NULL); /* SOURCE */
|
||||
tf_ssb_add_rpc_callback(ssb, (const char*[]) { "createHistoryStream", NULL }, _tf_ssb_rpc_createHistoryStream, NULL, NULL); /* SOURCE */
|
||||
tf_ssb_add_rpc_callback(ssb, (const char*[]) { "ebt", "replicate", NULL }, _tf_ssb_rpc_ebt_replicate_server, NULL, NULL); /* DUPLEX */
|
||||
_tf_ssb_rpc_start_delete_blobs(ssb, 0);
|
||||
}
|
||||
|
@ -443,7 +443,7 @@ void tf_ssb_test_rooms(const tf_test_options_t* options)
|
||||
|
||||
tf_ssb_connection_rpc_send_json(
|
||||
connections[0],
|
||||
k_ssb_rpc_flag_stream,
|
||||
k_ssb_rpc_flag_stream | k_ssb_rpc_flag_new_request,
|
||||
tunnel_request_number,
|
||||
message,
|
||||
NULL,
|
||||
|
20
src/task.c
20
src/task.c
@ -114,7 +114,6 @@ typedef struct _tf_task_t
|
||||
uv_timer_t trace_timer;
|
||||
uint64_t last_hrtime;
|
||||
uint64_t last_idle_time;
|
||||
uint64_t last_thread_time;
|
||||
float idle_percent;
|
||||
float thread_percent;
|
||||
|
||||
@ -175,6 +174,7 @@ static bool _export_record_release(tf_task_t* task, export_record_t** export)
|
||||
}
|
||||
|
||||
static JSValue _tf_task_version(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv);
|
||||
static JSValue _tf_task_platform(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv);
|
||||
static JSValue _tf_task_get_parent(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv);
|
||||
static JSValue _tf_task_exit(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv);
|
||||
static JSValue _tf_task_trace(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv);
|
||||
@ -694,6 +694,19 @@ static JSValue _tf_task_version(JSContext* context, JSValueConst this_val, int a
|
||||
return version;
|
||||
}
|
||||
|
||||
static JSValue _tf_task_platform(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv)
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
return JS_NewString(context, "windows");
|
||||
#elif defined(__ANDROID__)
|
||||
return JS_NewString(context, "android");
|
||||
#elif defined(__linux__)
|
||||
return JS_NewString(context, "linux");
|
||||
#else
|
||||
return JS_NewString(context, "other");
|
||||
#endif
|
||||
}
|
||||
|
||||
exportid_t tf_task_export_function(tf_task_t* task, tf_taskstub_t* to, JSValue function)
|
||||
{
|
||||
export_record_t* export = NULL;
|
||||
@ -1448,12 +1461,10 @@ static void _tf_task_trace_timer(uv_timer_t* timer)
|
||||
tf_task_t* task = timer->data;
|
||||
uint64_t hrtime = uv_hrtime();
|
||||
uint64_t idle_time = uv_metrics_idle_time(&task->_loop);
|
||||
uint64_t thread_time = tf_ssb_get_average_thread_time(task->_ssb);
|
||||
task->idle_percent = (hrtime - task->last_hrtime) ? 100.0f * (idle_time - task->last_idle_time) / (hrtime - task->last_hrtime) : 0.0f;
|
||||
task->thread_percent = (hrtime - task->last_hrtime) ? 100.0f * (thread_time - task->last_thread_time) / (hrtime - task->last_hrtime) : 0.0f;
|
||||
task->thread_percent = tf_ssb_get_average_thread_percent(task->_ssb);
|
||||
task->last_hrtime = hrtime;
|
||||
task->last_idle_time = idle_time;
|
||||
task->last_thread_time = thread_time;
|
||||
const char* k_names[] =
|
||||
{
|
||||
"child_tasks",
|
||||
@ -1725,6 +1736,7 @@ void tf_task_activate(tf_task_t* task)
|
||||
tf_util_register(context);
|
||||
JS_SetPropertyStr(context, global, "exit", JS_NewCFunction(context, _tf_task_exit, "exit", 1));
|
||||
JS_SetPropertyStr(context, global, "version", JS_NewCFunction(context, _tf_task_version, "version", 0));
|
||||
JS_SetPropertyStr(context, global, "platform", JS_NewCFunction(context, _tf_task_platform, "platform", 0));
|
||||
JS_SetPropertyStr(context, global, "getFile", JS_NewCFunction(context, _tf_task_getFile, "getFile", 1));
|
||||
JS_FreeValue(context, global);
|
||||
}
|
||||
|
@ -233,10 +233,9 @@ static void _test_database(const tf_test_options_t* options)
|
||||
"}\n");
|
||||
fclose(file);
|
||||
|
||||
unlink("out/testdb.sqlite");
|
||||
|
||||
char command[256];
|
||||
snprintf(command, sizeof(command), "%s run --ssb-port=0 --db-path=file:db?mode=memory\\&cache=shared -s out/test.js", options->exe_path);
|
||||
unlink("out/test_db0.sqlite");
|
||||
snprintf(command, sizeof(command), "%s run --ssb-port=0 --db-path=out/test_db0.sqlite -s out/test.js", options->exe_path);
|
||||
tf_printf("%s\n", command);
|
||||
int result = system(command);
|
||||
tf_printf("returned %d\n", WEXITSTATUS(result));
|
||||
@ -244,7 +243,7 @@ static void _test_database(const tf_test_options_t* options)
|
||||
assert(WEXITSTATUS(result) == 0);
|
||||
|
||||
unlink("out/test.js");
|
||||
unlink("out/testdb.sqlite");
|
||||
unlink("out/test_db0.sqlite");
|
||||
}
|
||||
|
||||
static void _test_this(const tf_test_options_t* options)
|
||||
|
@ -1,3 +1,3 @@
|
||||
#define VERSION_NUMBER "0.0.9"
|
||||
#define VERSION_NAME "Failure is the only opportunity to begin again."
|
||||
#define VERSION_NUMBER "0.0.10"
|
||||
#define VERSION_NAME "Pride is not the opposite of shame but its source."
|
||||
|
||||
|
88
tools/autotest.py
Executable file
88
tools/autotest.py
Executable file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
for path in ('out/selenium.sqlite', 'out/selenium.sqlite-shm', 'out/selenium.sqlite-wal'):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except:
|
||||
pass
|
||||
tf = subprocess.Popen(['out/debug/tildefriends', 'run', '-d', 'out/selenium.sqlite', '-b', '0', '-p', '8888'])
|
||||
|
||||
def exists_in_shadow_root(shadow_root, by, value):
|
||||
return lambda driver: shadow_root.find_element(by, value)
|
||||
|
||||
try:
|
||||
options = webdriver.FirefoxOptions()
|
||||
#options.add_argument('--headless')
|
||||
driver = webdriver.Firefox(options = options)
|
||||
wait = WebDriverWait(driver, 10)
|
||||
driver.get('http://localhost:8888')
|
||||
driver.find_element(By.TAG_NAME, 'tf-navigation').shadow_root.find_element(By.LINK_TEXT, 'login').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'register_label').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'name').send_keys('test_user')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'password').send_keys('test_password')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'confirm').send_keys('test_password')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'loginButton').click()
|
||||
|
||||
wait.until(expected_conditions.presence_of_element_located((By.ID, 'document')))
|
||||
driver.switch_to.frame(driver.find_element(By.ID, 'document'))
|
||||
wait.until(expected_conditions.presence_of_element_located((By.LINK_TEXT, 'ssb'))).click()
|
||||
driver.switch_to.default_content()
|
||||
|
||||
wait.until(expected_conditions.presence_of_element_located((By.ID, 'content')))
|
||||
driver.switch_to.frame(wait.until(expected_conditions.presence_of_element_located((By.ID, 'document'))))
|
||||
tf_app = wait.until(expected_conditions.presence_of_element_located((By.TAG_NAME, 'tf-app'))).shadow_root
|
||||
wait.until(expected_conditions.element_to_be_clickable(tf_app.find_element(By.ID, 'create_identity'))).click()
|
||||
wait.until(expected_conditions.alert_is_present()).accept()
|
||||
|
||||
tf_tab_news = wait.until(exists_in_shadow_root(tf_app, By.ID, 'tf-tab-news')).shadow_root
|
||||
tf_tab_news.find_element(By.ID, 'tf-compose').shadow_root.find_element(By.ID, 'edit').send_keys('Hello, world!')
|
||||
tf_tab_news.find_element(By.ID, 'tf-compose').shadow_root.find_element(By.ID, 'submit').click()
|
||||
|
||||
driver.switch_to.default_content()
|
||||
driver.find_element(By.ID, 'allow').click()
|
||||
|
||||
driver.find_element(By.TAG_NAME, 'tf-navigation').shadow_root.find_element(By.LINK_TEXT, 'logout test_user').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'login_label').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'name').send_keys('test_user')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'password').send_keys('test_password')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'loginButton').click()
|
||||
|
||||
wait.until(expected_conditions.presence_of_element_located((By.ID, 'content')))
|
||||
|
||||
driver.find_element(By.TAG_NAME, 'tf-navigation').shadow_root.find_element(By.LINK_TEXT, 'logout test_user').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'guest_label').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'guestButton').click()
|
||||
|
||||
wait.until(expected_conditions.presence_of_element_located((By.ID, 'content')))
|
||||
driver.switch_to.frame(wait.until(expected_conditions.presence_of_element_located((By.ID, 'document'))))
|
||||
wait.until(expected_conditions.presence_of_element_located((By.TAG_NAME, 'tf-app'))).shadow_root
|
||||
driver.switch_to.default_content()
|
||||
|
||||
driver.find_element(By.TAG_NAME, 'tf-navigation').shadow_root.find_element(By.LINK_TEXT, 'logout guest').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'login_label').click()
|
||||
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'name').send_keys('test_user')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'password').send_keys('wrong_password')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'loginButton').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'error')
|
||||
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'name').send_keys('wrong_user')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'password').send_keys('test_password')
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'loginButton').click()
|
||||
driver.find_element(By.TAG_NAME, 'tf-auth').shadow_root.find_element(By.ID, 'error')
|
||||
|
||||
print('SUCCESS.')
|
||||
finally:
|
||||
driver.close()
|
||||
driver.quit()
|
||||
|
||||
tf.terminate()
|
@ -1,23 +0,0 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
VERSION_NUMBER=`sed -n -e 's/^VERSION_NUMBER := //p' Makefile`
|
||||
VERSION_NAME=`sed -n -e 's/^VERSION_NAME := //p' Makefile`
|
||||
rm -rfv tildefriends-$VERSION_NUMBER
|
||||
svn export . tildefriends-$VERSION_NUMBER
|
||||
echo "tildefriends-$VERSION_NUMBER: $VERSION_NAME" > tildefriends-$VERSION_NUMBER/VERSION
|
||||
tar \
|
||||
--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \
|
||||
--exclude=deps/libsodium/builds \
|
||||
--exclude=deps/libsodium/configure \
|
||||
--exclude=deps/libsodium/test \
|
||||
--exclude=deps/libuv/docs \
|
||||
--exclude=deps/libuv/test \
|
||||
--exclude=deps/openssl \
|
||||
--exclude=deps/speedscope/*.map \
|
||||
--exclude=deps/sqlite/shell.c \
|
||||
--exclude=deps/zlib/contrib/vstudio \
|
||||
--exclude=deps/zlib/doc \
|
||||
-caf tildefriends-$VERSION_NUMBER.tar.xz tildefriends-$VERSION_NUMBER
|
||||
rm -rfv tildefriends-$VERSION_NUMBER
|
||||
make apk
|
||||
cp out/TildeFriends-release.apk TildeFriends-$VERSION_NUMBER.apk
|
@ -1,4 +1,4 @@
|
||||
VERSION=2.7.5
|
||||
VERSION=2.8.0
|
||||
wget https://cdn.jsdelivr.net/gh/lit/dist@$VERSION/all/lit-all.min.js -O deps/lit/lit-all.min.js
|
||||
wget https://cdn.jsdelivr.net/gh/lit/dist@$VERSION/all/lit-all.min.js.map -O deps/lit/lit-all.min.js.map
|
||||
cp -fv deps/lit/* apps/ssb/
|
||||
|
Reference in New Issue
Block a user