import { Buffer } from "buffer"; import { appState, settings } from "./stores"; import globalCss from './global.css'; import png from "./png"; import pngv3 from "./pngv3"; import webm from "./webm"; import gif from "./gif"; import thirdeye from "./thirdeye"; import pomf from "./pomf"; import { GM_fetch, GM_head, headerStringToObject } from "./requests"; import App from "./App.svelte"; import ScrollHighlighter from "./ScrollHighlighter.svelte"; import SettingsButton from './SettingsButton.svelte'; //import Embedding from './Embedding.svelte'; import Embeddings from './Embeddings.svelte'; import EyeButton from './EyeButton.svelte'; import { buildPeeFile, fireNotification } from "./utils"; import { fileTypeFromBuffer } from "file-type"; export interface ImageProcessor { skip?: true; match(fn: string): boolean; has_embed(b: Buffer, fn?: string): EMBED_STATUS | Promise; extract(b: Buffer, fn?: string): EmbeddedFile[] | Promise; inject?(b: File, c: File[]): Buffer | Promise; } export let csettings: Parameters[0]; let processors: ImageProcessor[] = [thirdeye, pomf, png, pngv3, webm, gif]; let cappState: Parameters[0]; settings.subscribe(b => { csettings = b; processors = [...(!csettings.te ? [thirdeye] : []), png, pngv3, pomf, webm, gif ]; }); appState.subscribe(v => { cappState = v; }); // most pngs are encoded with 65k idat chunks async function* streamRemote(url: string, chunkSize = 72 * 1024, fetchRestOnNonCanceled = true) { const headers = await GM_head(url); const h = headerStringToObject(headers); const size = +h['content-length']; let ptr = 0; let fetchSize = chunkSize; while (ptr != size) { //console.log('doing a fetch of ', url, ptr, ptr + fetchSize - 1); const res = await GM_fetch(url, { headers: { range: `bytes=${ptr}-${ptr + fetchSize - 1}` } }) as any as Tampermonkey.Response; const obj = headerStringToObject(res.responseHeaders); if (!('content-length' in obj)) { console.warn("no content lenght???", url); break; } const len = +obj['content-length']; ptr += len; if (fetchRestOnNonCanceled) fetchSize = size; const val = Buffer.from(await (res as any).arrayBuffer()); const e = (yield val) as boolean; //console.log('yeieledd, a', e); if (e) { break; } } //console.log("streaming ended, ", ptr, size); } export enum EMBED_TYPES { EXTERNAL_BOORU, EXTERNAL_POMF, MEDIA_EMBED, } export enum EMBED_STATUS { SUCCESS, TE_BLACKLISTED, PEE_UNDEFINED, NONE, } type EmbeddedFileWithPreview = { embed_type: EMBED_TYPES; tags?: string[], isBlacklisted?: boolean; // third-eye only page?: { title: string, url: string }; // can be a booru page source?: string; // can be like a twitter post this was posted in originally filename: string; thumbnail: undefined | Buffer | (() => Promise); data: string | ((lisn?: EventTarget) => Promise); }; type EmbeddedFileWithoutPreview = { embed_type: EMBED_TYPES; tags?: string[], isBlacklisted: boolean; // third-eye only page: undefined; source: undefined; thumbnail: undefined; filename: string; data: string | Buffer; }; export type EmbeddedFile = EmbeddedFileWithPreview | EmbeddedFileWithoutPreview; const processImage = async (src: string, fn: string, hex: string): Promise<([EmbeddedFile[], EMBED_TYPES] | undefined)[]> => { return Promise.all(processors.filter(e => e.match(fn)).map(async proc => { /* external: booru or pomf */ 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'); const embed_status = await proc.has_embed(md5, fn) const isBlacklisted = (embed_status === EMBED_STATUS.TE_BLACKLISTED); if (embed_status === EMBED_STATUS.SUCCESS || isBlacklisted){ const file = await proc.extract(md5, fn); let type; file?.forEach(e=>{ type = e.embed_type if(isBlacklisted) e.isBlacklisted = true; }) if(type == null) {debugger; return}; return [file, type] as [EmbeddedFile[], EMBED_TYPES]; } // if (await proc.has_embed(md5, fn) === true) // return [await proc.extract(md5, fn), true] as [EmbeddedFile[], boolean]; return; } /* media-embed */ const iter = streamRemote(src); if (!iter) return; let cumul = Buffer.alloc(0); let found: boolean | undefined; let chunk: ReadableStreamDefaultReadResult = { done: true }; do { const { value, done } = await iter.next(found === false); if (done) { chunk = { done: true } as ReadableStreamDefaultReadDoneResult; } else { chunk = { done: false, value } as ReadableStreamDefaultReadValueResult; } if (!done) cumul = Buffer.concat([cumul, value!]); const embed_status = await proc.has_embed(cumul); found = (embed_status === EMBED_STATUS.SUCCESS); } while (found !== false && !chunk.done); await iter.next(true); if (found === false) { //console.log(`Gave up on ${src} after downloading ${cumul.byteLength} bytes...`); return; } const file = await proc.extract(cumul); return [file, EMBED_TYPES.MEDIA_EMBED] as [EmbeddedFile[], EMBED_TYPES]; // return [await proc.extract(cumul), false] as [EmbeddedFile[], boolean]; })); }; const textToElement = (s: string) => document.createRange().createContextualFragment(s).children[0] as any as T; const processPost = async (post: HTMLDivElement) => { const thumb = post.querySelector("a.fileThumb") as HTMLAnchorElement; const origlink = post.querySelector('.file-info > a[target*="_blank"]') as HTMLAnchorElement; if (!thumb || !origlink) return; let res2 = await processImage(origlink.href, (origlink.querySelector('.fnfull') || origlink).textContent || '', post.querySelector("[data-md5]")?.getAttribute('data-md5') || ''); res2 = res2?.filter(e => e); if (!res2 || res2.length == 0) return; // processAttachments(post, res2?.flatMap(e => e![0].map(k => [k, e![1]] as [EmbeddedFile, boolean]))); let filtered = res2?.filter(e => (e != null && e[0] != null)) as [EmbeddedFile[], EMBED_TYPES][] if(filtered.length === 0){ console.debug(`ImageProcessors returned empty for post: '${post.dataset.fullID}'`); return; } processAttachments(post, filtered.flatMap(e => e[0].map(k => { if(k?.isBlacklisted === true){ post.querySelector('.reply')?.classList.add('hasblack'); } return [k, e![1]] as [EmbeddedFile, EMBED_TYPES] }) ) as [EmbeddedFile, EMBED_TYPES][]); }; const startup = async () => { if (typeof (window as any)['FCX'] != "undefined") appState.set({ ...cappState, is4chanX: true }); //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 const mo = new MutationObserver(reco => { for (const rec of reco) if (rec.type == "childList") rec.addedNodes.forEach(e => { 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"])'); if (!el && e.classList.contains('postContainer')) el = e; if (el) [...el].map(el => processPost(el as any)); }); }); document.querySelectorAll('.board').forEach(e => { mo.observe(e!, { childList: true, subtree: true }); }); const posts = [...document.querySelectorAll('.postContainer:not([class*="noFile"])')]; const scts = document.getElementById('shortcuts'); const button = textToElement(``); const settingsButton = new SettingsButton({ target: button }); scts?.appendChild(button); const appHost = textToElement(`
`); const appInstance = new App({ target: appHost }); document.body.append(appHost); const scrollHost = textToElement(`
`); new ScrollHighlighter({ target: scrollHost }); document.body.append(scrollHost); appState.set({ ...cappState, isCatalog: !!document.querySelector('.catalog-small') || !!location.pathname.match(/\/catalog$/), }); /* Temporary change for better performance */ const obs = new IntersectionObserver((entries, obs) => { setTimeout(()=>{ for(const item of entries) { if(!item.isIntersecting) continue; const target = item.target; if(target instanceof HTMLDivElement){ processPost(target); }else{ debugger; } obs.unobserve(target); } }, 1); }, {root:null, rootMargin: '3200px', threshold: 0.01}); posts.map(e => obs.observe(e as any)); // for (let i = 159; i < 160 ;++i) // await processPost(posts[i] as any); // const n = 7; // const range = ~~(posts.length / n) + 1; // await Promise.all([...new Array(n + 1)].map(async (e, i) => { // const postsslice = posts.slice(i * range, (i + 1) * range); // for (const post of postsslice) { // await processPost(post as any); // } // })); //await Promise.all(posts.map(e => processPost(e as any))); }; const getSelectedFile = () => { return new Promise(res => { document.addEventListener('QRFile', e => res((e as any).detail), { once: true }); document.dispatchEvent(new CustomEvent('QRGetFile')); }); }; //if (cappState!.is4chanX) document.addEventListener('4chanXInitFinished', startup); /*else { document.addEventListener("QRGetFile", (e) => { const qr = document.getElementById('qrFile') as HTMLInputElement | null; document.dispatchEvent(new CustomEvent("QRFile", { detail: (qr?.files || [])[0] })); }); startup(); }*/ // Basically this is a misnommer: fires even when inlining existings posts, also posts are inlined through some kind of dom projection document.addEventListener('ThreadUpdate', (async (e: CustomEvent) => { const newPosts = e.detail.newPosts; for (const post of newPosts) { const postContainer = document.getElementById("pc" + post.substring(post.indexOf(".") + 1)) as HTMLDivElement; processPost(postContainer); } })); 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', ((e: CustomEvent) => { const a = document.createElement('a'); const i = document.createElement('i'); i.className = "fa fa-magnet"; a.appendChild(i); a.title = "Embed File (Select a file before...)"; let target; if (cappState.is4chanX) { i.innerText = "🧲"; target = e.detail; target.querySelector("input[type=submit]")?.insertAdjacentElement("beforebegin", a); } else { target = e.target as HTMLDivElement; target.querySelector('#qr-filename-container')?.appendChild(a); } a.onclick = async (e) => { const file = await getSelectedFile(); if (!file) return; const input = document.createElement('input') as HTMLInputElement; input.setAttribute("type", "file"); const type = file.type; input.multiple = true; input.onchange = (async ev => { if (input.files) { try { const proc = processors.filter(e => e.inject).find(e => e.match(file.name)); if (!proc) throw new Error("Container filetype not supported"); const buff = await proc.inject!(file, [...input.files].slice(0, 5)); document.dispatchEvent(new CustomEvent('QRSetFile', { //detail: { file: new Blob([buff]), name: file.name, type: file.type } detail: { file: new Blob([buff], { type }), name: file.name } })); fireNotification('success', `File${input.files.length > 1 ? 's' : ''} successfully embedded!`); } catch (err) { const e = err as Error; fireNotification('error', "Couldn't embed file: " + e.message); } } }); input.click(); }; }), { once: !cappState!.is4chanX }); // 4chan's normal extension destroys the QR form everytime const customStyles = document.createElement('style'); customStyles.appendChild(document.createTextNode(globalCss)); document.documentElement.insertBefore(customStyles, null); function processAttachments(post: HTMLDivElement, ress: [EmbeddedFile, EMBED_TYPES][]) { if (ress.length == 0) return; const replyBox = post.querySelector('.post'); const embed_status = ress[0][1]; if (embed_status === EMBED_TYPES.EXTERNAL_BOORU || embed_status === EMBED_TYPES.EXTERNAL_POMF) replyBox?.classList.add('hasext'); /* ext(ernal) */ else if (embed_status === EMBED_TYPES.MEDIA_EMBED) replyBox?.classList.add('hasembed'); /* media-embed */ if (ress.length > 1) replyBox?.classList.add('hasmultiple'); if (!cappState.foundPosts.includes(replyBox as HTMLElement)) cappState.foundPosts.push(replyBox as HTMLElement); appState.set(cappState); const isCatalog = replyBox?.classList.contains('catalog-post'); // add buttons if (!isCatalog) { const ft = post.querySelector('div.file') as HTMLDivElement; const info = post.querySelector("span.file-info") as HTMLSpanElement; const filehost: HTMLElement | null = ft.querySelector('.filehost'); const eyehost: HTMLElement | null = info.querySelector('.eyehost'); const imgcont = filehost || document.createElement('div'); const eyecont = eyehost || document.createElement('span'); if (!filehost) { ft.append(imgcont); imgcont.classList.add("fileThumb"); imgcont.classList.add("filehost"); } else { imgcont.innerHTML = ''; } if (!eyehost) { info.append(eyecont); eyecont.classList.add("eyehost"); } else { eyecont.innerHTML = ''; } const id = ~~(Math.random() * 20000000); const emb = new Embeddings({ target: imgcont, props: { files: ress.map(e => e[0]), // EMBED_TYPES: ress.map(e => e[1]), id: '' + id } }); new EyeButton({ target: eyecont, props: { files: ress.map(e => e[0]), inst: emb, id: '' + id } }); } else { const opFile = post.querySelector('.catalog-link'); const ahem = opFile?.querySelector('.catalog-host'); const imgcont = ahem || document.createElement('div'); imgcont.className = "catalog-host"; if (ahem) { imgcont.innerHTML = ''; } const emb = new Embeddings({ target: imgcont, props: { files: ress.map(e => e[0]), // EMBED_TYPES: ress.map(e => e[1]), } }); if (!ahem) opFile?.append(imgcont); } 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()); // const container = document.getElementById("container") as HTMLInputElement; // const injection = document.getElementById("injection") as HTMLInputElement; // container.onchange = async () => { // const ret = await fetch("https://catbox.moe/user/api.php", { // method: 'POST', // body: parseForm({ // reqtype: 'fileupload', // fileToUpload: container.files![0] // }) // }); // console.log(ret); // console.log(await ret.text()); // }; // }; // } // if ((window as any)['pagemode']) { // onload = () => { // const extraction = document.getElementById("extraction") as HTMLInputElement; // /* extraction.onchange = async () => { // const pee = await buildPeeFile(extraction.files![0]); // const dlr = document.getElementById("dlr") as HTMLAnchorElement; // dlr.href = URL.createObjectURL(pee); // };*/ // document.addEventListener("CreateNotification", (e: any) => console.log(e.detail)); // console.log("loaded"); // //const resbuf = async (s: any) => ((Buffer.isBuffer(s) ? s : await s())); // const container = document.getElementById("container") as HTMLInputElement; // const injection = document.getElementById("injection") as HTMLInputElement; // injection.multiple = true; // extraction.onchange = async () => { // const embedded = await pngv3.extract(Buffer.from(await extraction.files![0].arrayBuffer())); // const d = document.createElement('div'); // new Embeddings({ // target: d, // props: { files: embedded } // }); // document.body.append(d); // }; // container.onchange = injection.onchange = async () => { // console.log('eval changed'); // if (container.files?.length && injection.files?.length) { // const dlr = document.getElementById("dlr") as HTMLAnchorElement; // //const dle = document.getElementById("dle") as HTMLAnchorElement; // const res = await pngv3.inject!(container.files[0], [...injection.files]); // const result = document.getElementById("result") as HTMLImageElement; // //const extracted = document.getElementById("extracted") as HTMLImageElement; // const res2 = new Blob([res], { type: (await fileTypeFromBuffer(res))?.mime }); // result.src = URL.createObjectURL(res2); // dlr.href = result.src; // console.log('url created'); // //const embedded = await pngv3.extract(res); // //if (!embedded) { // // debugger; // return; // //} // //extracted.src = URL.createObjectURL(new Blob([await resbuf(embedded.data!)])); // //dle.href = extracted.src; // } // }; // }; // }