Browse Source

Native 4chan Ext compatibility, and better error handling for catbox uploads

pull/46/head
coomdev 2 years ago
parent
commit
06df2659ce
  1. 4
      main.d.ts
  2. 2
      main.meta.js
  3. 1883
      main.user.js
  4. 78
      src/NotificationsHandler.svelte
  5. 3
      src/PostOptions.svelte
  6. 4
      src/global.css
  7. 117
      src/main.ts
  8. 20
      src/requests.ts
  9. 11
      src/utils.ts
  10. 55
      src/websites/index.ts

4
main.d.ts

@ -4,4 +4,6 @@ declare module '*.css' {
declare module '*.png' {
export default new Uint8Array;
}
}
declare const QR: any;

2
main.meta.js

@ -1,7 +1,7 @@
// ==UserScript==
// @name PNGExtraEmbed
// @namespace https://coom.tech/
// @version 0.140
// @version 0.142
// @description uhh
// @author You
// @match https://boards.4channel.org/*

1883
main.user.js

File diff suppressed because it is too large

78
src/NotificationsHandler.svelte

@ -0,0 +1,78 @@
<script lang="ts">
import type { fireNotification } from './utils'
type t = Parameters<typeof fireNotification>
type Notification = {
type: t[0]
content: t[1]
lifetime: t[2]
}
let nots: (Notification & { id: number })[] = []
const removeId = (id: number) => (nots = nots.filter((e) => e.id != id))
let gid = 0
document.addEventListener('CreateNotification', <any>((
e: CustomEvent<Notification>,
) => {
const id = gid++
nots = [...nots, { ...e.detail, id }]
setTimeout(() => removeId(id), (e.detail.lifetime || 3) * 1000)
}))
</script>
<div class="root">
{#each nots as not (not.id)}
<span class={not.type}
>{not.content}<span on:click={() => removeId(not.id)} class="clickable"
>X</span
></span
>
{/each}
</div>
<style scoped>
.clickable {
cursor: pointer;
float: right;
}
.root > span {
display: flex;
gap: 10px;
border: 1px solid;
padding: 10px;
border-radius: 5px;
font-weight: bolder;
color: white;
min-width: 45vw;
}
.root {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
}
.error {
background-color: /*KING*/ crimson;
}
.info {
background-color: cornflowerblue;
}
.warning {
background-color: darkgoldenrod;
}
.success {
background-color: green;
}
</style>

3
src/PostOptions.svelte

@ -101,6 +101,9 @@
</div>
<style scoped>
a i {
font-style: normal;
}
a {
cursor: pointer;
}

4
src/global.css

@ -10,6 +10,10 @@
cursor: pointer;
}
#delform .postContainer>div.embedfound {
border-right: 3px dashed green !important;
}
#delform .postContainer>div.hasembed {
border-right: 3px dashed deeppink !important;
}

117
src/main.ts

@ -18,8 +18,10 @@ import SettingsButton from './SettingsButton.svelte';
//import Embedding from './Embedding.svelte';
import Embeddings from './Embeddings.svelte';
import EyeButton from './EyeButton.svelte';
import NotificationsHandler from './NotificationsHandler.svelte';
import { buildPeeFile, fireNotification } from "./utils";
import { fileTypeFromBuffer } from "file-type";
import { getQueryProcessor, QueryProcessor } from "./websites";
export interface ImageProcessor {
skip?: true;
@ -28,6 +30,7 @@ export interface ImageProcessor {
extract(b: Buffer, fn?: string): EmbeddedFile[] | Promise<EmbeddedFile[]>;
inject?(b: File, c: File[]): Buffer | Promise<Buffer>;
}
let qp: QueryProcessor;
export let csettings: Parameters<typeof settings['set']>[0];
let processors: ImageProcessor[] =
@ -91,15 +94,16 @@ type EmbeddedFileWithoutPreview = {
export type EmbeddedFile = EmbeddedFileWithPreview | EmbeddedFileWithoutPreview;
const processImage = async (src: string, fn: string, hex: string): Promise<([EmbeddedFile[], boolean] | undefined)[]> => {
const processImage = async (src: string, fn: string, hex: string, onfound: () => void): Promise<([EmbeddedFile[], boolean] | undefined)[]> => {
return Promise.all(processors.filter(e => e.match(fn)).map(async proc => {
if (proc.skip) {
// skip file downloading, file is referenced from the filename
// basically does things like filtering out blacklisted tags
const md5 = Buffer.from(hex, 'base64');
if (await proc.has_embed(md5, fn) === true)
if (await proc.has_embed(md5, fn) === true) {
onfound();
return [await proc.extract(md5, fn), true] as [EmbeddedFile[], boolean];
return;
} return;
}
// TODO: Move this outside the loop?
const iter = streamRemote(src);
@ -124,6 +128,7 @@ const processImage = async (src: string, fn: string, hex: string): Promise<([Emb
//console.log(`Gave up on ${src} after downloading ${cumul.byteLength} bytes...`);
return;
}
onfound();
return [await proc.extract(cumul), false] as [EmbeddedFile[], boolean];
}));
};
@ -133,12 +138,13 @@ const textToElement = <T = HTMLElement>(s: string) =>
const processPost = async (post: HTMLDivElement) => {
const thumb = post.querySelector("a.fileThumb") as HTMLAnchorElement;
const origlink = post.querySelector('.file-info > a[target*="_blank"]') as HTMLAnchorElement;
const origlink = qp.getImageLink(post);
if (!thumb || !origlink)
return;
let res2 = await processImage(origlink.href,
(origlink.querySelector('.fnfull') || origlink).textContent || '',
post.querySelector("[data-md5]")?.getAttribute('data-md5') || '');
let res2 = await processImage(origlink, qp.getFilename(post), qp.getMD5(post),
() => {
post.querySelector('.post')?.classList.add("embedfound");
});
res2 = res2?.filter(e => e);
if (!res2 || res2.length == 0)
return;
@ -247,9 +253,44 @@ const scrapeBoard = async (self: HTMLButtonElement) => {
const startup = async (is4chanX = true) => {
appState.set({ ...cappState, is4chanX });
const lqp = getQueryProcessor(is4chanX);
if (!lqp)
return;
else
qp = lqp;
if (csettings.vercheck)
versionCheck();
if (!is4chanX) {
const qr = QR;
const show = qr.show.bind(qr);
qr.show = (...args: any[]) => {
show(...args);
document.dispatchEvent(new CustomEvent("QRDialogCreation", {
detail: document.getElementById('quickReply')
}));
};
document.addEventListener("QRGetFile", (e) => {
const qr = document.getElementById('qrFile') as HTMLInputElement | null;
document.dispatchEvent(new CustomEvent("QRFile", { detail: (qr?.files || [])[0] }));
});
document.addEventListener("QRSetFile", ((e: CustomEvent<{ file: Blob, name: string }>) => {
const qr = document.getElementById('qrFile') as HTMLInputElement | null;
if (!qr) return;
const dt = new DataTransfer();
dt.items.add(new File([e.detail.file], e.detail.name));
qr.files = dt.files;
}) as any);
const notificationHost = document.createElement('span');
new NotificationsHandler({
target: notificationHost
});
document.body.append(notificationHost);
}
//await Promise.all([...document.querySelectorAll('.postContainer')].filter(e => e.textContent?.includes("191 KB")).map(e => processPost(e as any)));
// keep this to handle posts getting inlined
@ -260,9 +301,9 @@ const startup = async (is4chanX = true) => {
if (!(e instanceof HTMLElement))
return;
// apparently querySelector cannot select the root element if it matches
let el = (e as any).querySelectorAll('.postContainer:not([class*="noFile"])');
let el = qp.postsWithFiles(e);
if (!el && e.classList.contains('postContainer'))
el = e;
el = [e];
if (el)
[...el].map(el => processPost(el as any));
});
@ -271,9 +312,9 @@ const startup = async (is4chanX = true) => {
document.querySelectorAll('.board').forEach(e => {
mo.observe(e!, { childList: true, subtree: true });
});
const posts = [...document.querySelectorAll('.postContainer:not([class*="noFile"])')];
const posts = qp.postsWithFiles();
const scts = document.getElementById('shortcuts');
const scts = qp.settingsHost();
const button = textToElement(`<span></span>`);
const settingsButton = new SettingsButton({
target: button
@ -295,7 +336,7 @@ const startup = async (is4chanX = true) => {
//await processPost(posts[0] as any);
if (cappState.isCatalog) {
const opts = document.getElementById('index-options') as HTMLDivElement;
const opts = qp.catalogControlHost() as HTMLDivElement;
if (opts) {
const button = document.createElement('button');
button.textContent = "おもらし";
@ -316,17 +357,17 @@ const startup = async (is4chanX = true) => {
//await Promise.all(posts.map(e => processPost(e as any)));
};
//if (cappState!.is4chanX)
document.addEventListener('4chanXInitFinished', () => startup(true));
/*else {
document.addEventListener("QRGetFile", (e) => {
const qr = document.getElementById('qrFile') as HTMLInputElement | null;
document.dispatchEvent(new CustomEvent("QRFile", { detail: (qr?.files || [])[0] }));
});
startup();
}*/
document.addEventListener('4chanParsingDone', () => startup(false), { once: true });
document.addEventListener('4chanThreadUpdated', ((e: CustomEvent<{ count: number }>) => {
document.dispatchEvent(new CustomEvent("ThreadUpdate", {
detail: {
newPosts: [...document.querySelector(".thread")!.children].slice(-e.detail.count).map(e => 'b.' + e.id.slice(2))
}
}));
}) as any);
// Basically this is a misnommer: fires even when inlining existings posts, also posts are inlined through some kind of dom projection
document.addEventListener('ThreadUpdate', <any>(async (e: CustomEvent<any>) => {
const newPosts = e.detail.newPosts;
for (const post of newPosts) {
@ -335,31 +376,22 @@ document.addEventListener('ThreadUpdate', <any>(async (e: CustomEvent<any>) => {
}
}));
if (cappState!.is4chanX) {
const qr = (window as any)['QR'];
const show = qr.show.bind(qr);
qr.show = (...args: any[]) => {
show(...args);
document.dispatchEvent(new CustomEvent("QRDialogCreation", {
detail: document.getElementById('quickReply')
}));
};
}
document.addEventListener('QRDialogCreation', <any>((e: CustomEvent<HTMLElement>) => {
const a = document.createElement('span');
new PostOptions({
target: a,
props: { processors, textinput: (e.detail || e.target).querySelector('textarea')! }
});
let target;
if (!cappState.is4chanX) {
target = e.detail;
a.style.display = "inline-block";
target.querySelector("input[type=submit]")?.insertAdjacentElement("beforebegin", a);
}
else {
target = e.target as HTMLDivElement;
new PostOptions({
target: a,
props: { processors, textinput: target.querySelector('textarea')! }
});
target.querySelector('#qr-filename-container')?.appendChild(a);
}
@ -391,8 +423,7 @@ function processAttachments(post: HTMLDivElement, ress: [EmbeddedFile, boolean][
// add buttons
if (!isCatalog) {
const ft = post.querySelector('div.file') as HTMLDivElement;
const info = post.querySelector("span.file-info") as HTMLSpanElement;
const info = qp.getInfoBox(post);
const filehost: HTMLElement | null = ft.querySelector('.filehost');
const eyehost: HTMLElement | null = info.querySelector('.eyehost');
const imgcont = filehost || document.createElement('div');
@ -448,16 +479,6 @@ function processAttachments(post: HTMLDivElement, ress: [EmbeddedFile, boolean][
post.setAttribute('data-processed', "true");
}
function parseForm(data: object) {
const form = new FormData();
Object.entries(data)
.filter(([key, value]) => value !== null)
.map(([key, value]) => form.append(key, value));
return form;
}
//if ((window as any)['pagemode']) {
// onload = () => {
// const resbuf = async (s: EmbeddedFile['data']) => typeof s != "string" && (Buffer.isBuffer(s) ? s : await s());

20
src/requests.ts

@ -21,13 +21,13 @@ export function GM_head(...[url, opt]: Parameters<typeof fetch>) {
method: "HEAD",
onload: (resp) => {
if ((resp.status / 100) >= 4)
reject("response error");
reject(new Error("response error"));
else
resolve(resp.responseHeaders);
},
ontimeout: () => reject("fetch timeout"),
onerror: () => reject("fetch error"),
onabort: () => reject("fetch abort")
ontimeout: () => reject(new Error("fetch timeout")),
onerror: () => reject(new Error("fetch error")),
onabort: () => reject(new Error("fetch abort"))
};
xmlhttprequest(gmopt);
});
@ -49,7 +49,7 @@ export let GM_fetch = (...[url, opt, lisn]: [...Parameters<typeof fetch>, EventT
if (to == "arrayBuffer") fileReader.readAsArrayBuffer(blob);
else if (to == "base64") fileReader.readAsDataURL(blob); // "data:*/*;base64,......"
else if (to == "text") fileReader.readAsText(blob, "utf-8");
else reject("unknown to");
else reject(new Error("unknown to"));
});
}
return new Promise<Awaited<ReturnType<typeof fetch>>>((resolve, reject) => {
@ -67,6 +67,10 @@ export let GM_fetch = (...[url, opt, lisn]: [...Parameters<typeof fetch>, EventT
},
} : {}),
onload: (resp) => {
if ((resp.status / 100) >= 4) {
reject(new Error("Server Error: " + resp.status));
return;
}
const blob = resp.response as Blob;
const ref = resp as any as Awaited<ReturnType<typeof fetch>>;
ref.blob = () => Promise.resolve(blob);
@ -75,11 +79,11 @@ export let GM_fetch = (...[url, opt, lisn]: [...Parameters<typeof fetch>, EventT
ref.json = async () => JSON.parse(await (blobTo("text", blob) as Promise<any>));
resolve(resp as any);
},
ontimeout: () => reject("fetch timeout"),
ontimeout: () => reject(new Error("fetch timeout")),
onerror: (...args) => {
reject("fetch error");
reject(new Error("fetch error"));
},
onabort: () => reject("fetch abort")
onabort: () => reject(new Error("fetch abort"))
};
xmlhttprequest(gmopt);
});

11
src/utils.ts

@ -145,12 +145,10 @@ export const decodeCoom3Payload = async (buff: Buffer) => {
}))).filter(e => e);
};
export const fireNotification = (level: 'success' | 'error' | 'info' | 'warning', text: string, lifetime = 3) => {
export const fireNotification = (type: 'success' | 'error' | 'info' | 'warning', content: string, lifetime = 3) => {
document.dispatchEvent(new CustomEvent("CreateNotification", {
detail: {
type: level,
content: text,
lifetime
type, content, lifetime
}
}));
};
@ -169,13 +167,14 @@ export const uploadFiles = async (injs: File[]) => {
let total = 0;
fireNotification('info', `Uploading ${injs.length} files...`);
return await Promise.all(injs.map(async inj => {
const ret = await (await GM_fetch("https://catbox.moe/user/api.php", {
const resp = await GM_fetch("https://catbox.moe/user/api.php", {
method: 'POST',
body: parseForm({
reqtype: 'fileupload',
fileToUpload: await buildPeeFile(inj)
})
})).text();
});
const ret = await resp.text();
fireNotification('info', `Uploaded files [${++total}/${injs.length}] ${ret}`);
return ret;
}));

55
src/websites/index.ts

@ -3,7 +3,60 @@ export type QueryProcessor = {
md5Selector: string;
filenameSelector: string;
linkSelector: string;
postWithFileSelector: string;
postsWithFiles: (host?: HTMLElement) => HTMLElement[];
postContainerSelector: string;
controlHostSelector: string;
settingsHost: () => HTMLSpanElement;
catalogControlHost: () => HTMLDivElement;
getImageLink: (post: HTMLElement) => string;
getFilename: (post: HTMLElement) => string;
getMD5: (post: HTMLElement) => string;
getInfoBox: (post: HTMLElement) => HTMLElement;
};
export const V4chan: QueryProcessor = {
thumbnailSelector: "",
md5Selector: "",
filenameSelector: "",
linkSelector: "",
postsWithFiles: (h) => [...(h || document).querySelectorAll('.file')].map(e => e.closest('.postContainer')) as any,
postContainerSelector: ".postContainer",
controlHostSelector: "",
settingsHost: () => document.getElementById("navtopright") as any,
catalogControlHost: () => document.getElementById("settings") as HTMLDivElement,
getImageLink: (post: HTMLElement) => post.querySelector('a[target="_blank"]')?.getAttribute('href') || '',
getFilename: (post: HTMLElement) => {
const a = post.querySelector('a[target="_blank"]') as (HTMLAnchorElement | null);
if (a && a.title)
return a.title;
return a?.textContent || '';
},
getMD5: (post: HTMLElement) => post.querySelector("img[data-md5]")?.getAttribute("data-md5") || '',
getInfoBox: post => post.querySelector("div.fileText")!
};
export const X4chan: QueryProcessor = {
thumbnailSelector: "",
md5Selector: "",
filenameSelector: "",
linkSelector: "",
postsWithFiles: (h) => [...(h || document).querySelectorAll('.postContainer:not([class*="noFile"])')] as HTMLElement[],
postContainerSelector: ".postContainer",
controlHostSelector: "",
settingsHost: () => document.getElementById("shortcuts") as any,
catalogControlHost: () => document.getElementById("index-options") as HTMLDivElement,
getImageLink: (post: HTMLElement) => post.querySelector('a[target="_blank"]')?.getAttribute('href') || '',
getFilename: (post: HTMLElement) => {
const a = post.querySelector('a[target="_blank"]') as (HTMLAnchorElement | null);
if (a && a.title)
return a.title;
return a?.textContent || '';
},
getMD5: (post: HTMLElement) => post.querySelector("img[data-md5]")?.getAttribute("data-md5") || '',
getInfoBox: post => post.querySelector("span.file-info")!
};
export const getQueryProcessor = (is4chanX: boolean) => {
if (['boards.4chan.org', 'boards.4channel.org'].includes(location.host))
return is4chanX ? X4chan : V4chan;
};
Loading…
Cancel
Save