import * as fflate from './vendor/fflate.js'; // Base class for custom errors export class LLError extends Error {} // Random choice export function random_range(a, b = null) { if (b === null) { b = a; a = 0; } return a + Math.floor(Math.random() * (b - a)); } export function random_choice(list) { return list[Math.floor(Math.random() * list.length)]; } export function random_shuffle(list) { // Knuth–Fisher–Yates, of course for (let i = list.length - 1; i > 0; i--) { let j = Math.floor(Math.random() * (i + 1)); [list[i], list[j]] = [list[j], list[i]]; } } export function setdefault(map, key, defaulter) { if (map.has(key)) { return map.get(key); } else { let value = defaulter(); map.set(key, value); return value; } } // DOM stuff function _mk(el, children) { if (children.length > 0) { if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") { let [attrs] = children.splice(0, 1); for (let [key, value] of Object.entries(attrs)) { el.setAttribute(key, value); } } el.append(...children); } return el; } export function mk(tag_selector, ...children) { let [tag, ...classes] = tag_selector.split('.'); let el = document.createElement(tag); if (classes.length > 0) { el.classList = classes.join(' '); } return _mk(el, children); } export function mk_button(label, onclick) { let el = mk('button', {type: 'button'}, label); el.addEventListener('click', onclick); return el; } export const SVG_NS = 'http://www.w3.org/2000/svg'; export function mk_svg(tag_selector, ...children) { let [tag, ...classes] = tag_selector.split('.'); let el = document.createElementNS(SVG_NS, tag); if (classes.length > 0) { el.classList = classes.join(' '); } return _mk(el, children); } export function trigger_local_download(filename, blob) { let url = URL.createObjectURL(blob); // To download a file, um, make an and click it. Not kidding let a = mk('a', { href: url, download: filename, }); document.body.append(a); a.click(); // Absolutely no idea when I'm allowed to revoke this, but surely a minute is safe window.setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 60 * 1000); } export function handle_drop(element, options) { let dropzone_class = options.dropzone_class ?? null; let on_drop = options.on_drop; let require_file = options.require_file ?? false; let is_valid = ev => { // TODO this requires files, should make some args for this if (options.require_file) { let dt = ev.dataTransfer; if (! dt || dt.items.length === 0) return false; // Only test the first item I guess? If it's a file then they should all be files if (dt.items[0].kind !== 'file') return false; } return true; }; let end_drop = () => { if (dropzone_class !== null) { element.classList.remove(dropzone_class); } }; // TODO should have a filter function for when a drag is valid but i forget which of these // should have that element.addEventListener('dragenter', ev => { if (! is_valid(ev)) return; ev.stopPropagation(); ev.preventDefault(); if (dropzone_class !== null) { element.classList.add(dropzone_class); } }); element.addEventListener('dragover', ev => { if (! is_valid(ev)) return; ev.stopPropagation(); ev.preventDefault(); }); element.addEventListener('dragleave', ev => { if (ev.relatedTarget && element.contains(ev.relatedTarget)) return; end_drop(); }); element.addEventListener('drop', ev => { if (! is_valid(ev)) return; ev.stopPropagation(); ev.preventDefault(); end_drop(); on_drop(ev); }); } export function sleep(t) { return new Promise(res => { setTimeout(res, t); }); } export function promise_event(element, success_event, failure_event) { let resolve, reject; let promise = new Promise((res, rej) => { resolve = res; reject = rej; }); let success_handler = ev => { element.removeEventListener(success_event, success_handler); if (failure_event) { element.removeEventListener(failure_event, failure_handler); } resolve(ev); }; let failure_handler = ev => { element.removeEventListener(success_event, success_handler); if (failure_event) { element.removeEventListener(failure_event, failure_handler); } reject(ev); }; element.addEventListener(success_event, success_handler); if (failure_event) { element.addEventListener(failure_event, failure_handler); } return promise; } export async function fetch(url, response_type = 'arraybuffer') { let xhr = new XMLHttpRequest; let promise = promise_event(xhr, 'load', 'error'); xhr.open('GET', url); xhr.responseType = response_type; xhr.send(); await promise; if (xhr.status !== 200) throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`); return xhr.response; } export function string_from_buffer_ascii(buf, start = 0, len) { if (ArrayBuffer.isView(buf)) { start += buf.byteOffset; buf = buf.buffer; } return String.fromCharCode.apply(null, new Uint8Array(buf, start, len)); } // Converts a string to a buffer, using NO ENCODING, assuming single-byte characters export function bytestring_to_buffer(bytestring) { return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer; } export function b64encode(value) { if (value instanceof ArrayBuffer || value instanceof Uint8Array) { value = string_from_buffer_ascii(value); } // Make URL-safe and strip trailing padding return btoa(value).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, ''); } export function b64decode(data) { return bytestring_to_buffer(atob(data.replace(/-/g, '+').replace(/_/g, '/'))); } export function format_duration(seconds, places = 0) { let sign = ''; if (seconds < 0) { seconds = -seconds; sign = '-'; } let mins = Math.floor(seconds / 60); let secs = seconds % 60; let rounded_secs = secs.toFixed(places); // TODO hours? return `${sign}${mins}:${parseFloat(rounded_secs) < 10 ? '0' : ''}${rounded_secs}`; } export class DelayTimer { constructor() { this.active = false; this._handle = null; this._bound_alarm = this._alarm.bind(this); } set(duration) { if (this._handle) { window.clearTimeout(this._handle); } this.active = true; this._handle = window.setTimeout(this._bound_alarm, duration); } _alarm() { this._handle = null; this.active = false; } } // Cast a line through a grid and yield every cell it touches export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) { // TODO if the ray starts outside the grid (extremely unlikely), we should // find the point where it ENTERS the grid, otherwise the 'while' // conditions below will stop immediately let a = Math.floor(x0); let b = Math.floor(y0); let dx = x1 - x0; let dy = y1 - y0; if (dx === 0 && dy === 0) { // Special case: the ray goes nowhere, so only return this block yield [a, b]; return; } let goal_x = Math.floor(x1); let goal_y = Math.floor(y1); // Use a modified Bresenham. Use mirroring to move everything into the // first quadrant, then split it into two octants depending on whether dx // or dy increases faster, and call that the main axis. Track an "error" // value, which is the (negative) distance between the ray and the next // grid line parallel to the main axis, but scaled up by dx. Every // iteration, we move one cell along the main axis and increase the error // value by dy (the ray's slope, scaled up by dx); when it becomes // positive, we can subtract dx (1) and move one cell along the minor axis // as well. Since the main axis is the faster one, we'll never traverse // more than one cell on the minor axis for one cell on the main axis, and // this readily provides every cell the ray hits in order. // Based on: http://www.idav.ucdavis.edu/education/GraphicsNotes/Bresenhams-Algorithm/Bresenhams-Algorithm.html // Setup: map to the first quadrant. The "offsets" are the distance // between the starting point and the next grid point. let step_a = 1; let offset_x = 1 - (x0 - a); if (dx < 0) { dx = -dx; step_a = -step_a; offset_x = 1 - offset_x; } else if (offset_x === 0) { // Zero offset means we're on a grid line, so we're a full cell away from the next grid line // (if we're moving forward; if we're moving backward, the next cell really is 0 away) offset_x = 1; } let step_b = 1; let offset_y = 1 - (y0 - b); if (dy < 0) { dy = -dy; step_b = -step_b; offset_y = 1 - offset_y; } else if (offset_y === 0) { offset_y = 1; } let err = dy * offset_x - dx * offset_y; if (dx > dy) { // Main axis is x/a while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) { yield [a, b]; if (a === goal_x && b === goal_y) return; if (err > 0) { err -= dx; b += step_b; yield [a, b]; if (a === goal_x && b === goal_y) return; } err += dy; a += step_a; } } else { err = -err; // Main axis is y/b while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) { yield [a, b]; if (a === goal_x && b === goal_y) return; if (err > 0) { err -= dy; a += step_a; yield [a, b]; if (a === goal_x && b === goal_y) return; } err += dx; b += step_b; } } } // Baby's first bit vector export class BitVector { constructor(size) { this.array = new Uint32Array(Math.ceil(size / 32)); } get(bit) { let i = Math.floor(bit / 32); let b = bit % 32; return (this.array[i] & (1 << b)) !== 0; } set(bit) { let i = Math.floor(bit / 32); let b = bit % 32; this.array[i] |= (1 << b); } clear(bit) { let i = Math.floor(bit / 32); let b = bit % 32; this.array[i] &= ~(1 << b); } } // Root class to indirect over where we might get files from // - a pool of uploaded in-memory files // - a single uploaded zip file // - a local directory provided via the webkit Entry api // - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS) // Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit // requirement that filenames are case-insensitive :/ export class FileSource { constructor() {} // Get a file's contents as an ArrayBuffer async get(path) {} // Get a list of all files under here, recursively // async *iter_all_files() {} } // Files we have had uploaded one at a time (note that each upload becomes its own source) export class FileFileSource extends FileSource { constructor(files) { super(); this.files = {}; for (let file of files) { this.files[(file.webkitRelativePath ?? file.name).toLowerCase()] = file; } } get(path) { let file = this.files[path.toLowerCase()]; if (file) { return file.arrayBuffer(); } else { return Promise.reject(new Error(`No such file was provided: ${path}`)); } } iter_all_files() { return Object.keys(this.files); } } // Regular HTTP fetch export class HTTPFileSource extends FileSource { // Should be given a URL object as a root constructor(root) { super(); this.root = root; } get(path) { let url = new URL(path, this.root); return fetch(url); } } // Regular HTTP fetch, but for a directory structure from nginx's index module export class HTTPNginxDirectorySource extends FileSource { // Should be given a URL object as a root constructor(root) { super(); this.root = root; if (! this.root.pathname.endsWith('/')) { this.root.pathname += '/'; } } get(path) { // TODO should strip off multiple of these // TODO and canonicalize, and disallow going upwards if (path.startsWith('/')) { path = path.substring(1); } let url = new URL(path, this.root); return fetch(url); } async *iter_all_files() { let fetch_count = 0; let paths = ['']; while (paths.length > 0) { let next_paths = []; for (let path of paths) { if (fetch_count >= 50) { throw new Error("Too many subdirectories to fetch one at a time; is this really a single CC2 set?"); } let response = await fetch(new URL(path, this.root), 'text'); fetch_count += 1; let doc = document.implementation.createHTMLDocument(); doc.write(response); doc.close(); for (let link of doc.querySelectorAll('a')) { let subpath = link.getAttribute('href'); if (subpath === '../') { continue; } else if (subpath.endsWith('/')) { next_paths.push(path + subpath); } else { yield path + subpath; } } } paths = next_paths; } } } // WebKit Entry interface // XXX this does not appear to work if you drag in a link to a directory but that is probably beyond // my powers to fix export class EntryFileSource extends FileSource { constructor(entries) { super(); this.files = {}; let file_count = 0; let read_directory = async (directory_entry, dir_prefix) => { let reader = directory_entry.createReader(); let all_entries = []; while (true) { let entries = await new Promise((res, rej) => reader.readEntries(res, rej)); all_entries.push.apply(all_entries, entries); if (entries.length === 0) break; } await handle_entries(all_entries, dir_prefix); }; let handle_entries = (entries, dir_prefix) => { file_count += entries.length; if (file_count > 4096) throw new LLError("Found way too many files; did you drag in the wrong directory?"); let dir_promises = []; for (let entry of entries) { if (entry.isDirectory) { dir_promises.push(read_directory(entry, dir_prefix + entry.name + '/')); } else { this.files[(dir_prefix + entry.name).toLowerCase()] = entry; } } return Promise.all(dir_promises); }; this._loaded_promise = handle_entries(entries, ''); } async get(path) { let entry = this.files[path.toLowerCase()]; if (! entry) throw new LLError(`No such file in local directory: ${path}`); let file = await new Promise((res, rej) => entry.file(res, rej)); return await file.arrayBuffer(); } async iter_all_files() { await this._loaded_promise; return Object.keys(this.files); } } // Zip files, using fflate // TODO somewhat unfortunately fflate only supports unzipping the whole thing at once, not // individual files as needed, but it's also pretty new so maybe later? export class ZipFileSource extends FileSource { constructor(buf) { super(); // TODO async? has some setup time but won't freeze browser let files = fflate.unzipSync(new Uint8Array(buf)); this.files = {}; for (let [path, file] of Object.entries(files)) { this.files['/' + path.toLowerCase()] = file; } } async get(path) { let file = this.files[path.toLowerCase()]; if (! file) throw new LLError(`No such file in zip: ${path}`); return file.buffer; } iter_all_files() { return Object.keys(this.files); } }