aboutsummaryrefslogtreecommitdiff
/**
 * SPDX-License-Identifier: LicenseRef-GPL-3.0-or-later-WITH-js-exceptions
 *
 * Make folders on drive.google.com browsable without nonfree js
 *
 * Copyright (C) 2021 Wojtek Kosior <koszko@koszko.org>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * As additional permission under GNU GPL version 3 section 7, you
 * may distribute forms of that code without the copy of the GNU
 * GPL normally required by section 4, provided you include this
 * license notice and, in case of non-source distribution, a URL
 * through which recipients can access the Corresponding Source.
 * If you modify file(s) with this exception, you may extend this
 * exception to your version of the file(s), but you are not
 * obligated to do so. If you do not wish to do so, delete this
 * exception statement from your version.
 *
 * As a special exception to the GPL, any HTML file which merely
 * makes function calls to this code, and for that purpose
 * includes it by reference shall be deemed a separate work for
 * copyright law purposes. If you modify this code, you may extend
 * this exception to your version of the code, but you are not
 * obligated to do so. If you do not wish to do so, delete this
 * exception statement from your version.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * I, Wojtek Kosior, thereby promise not to sue for violation of this file's
 * license. Although I request that you do not make use of this code in a
 * proprietary program, I am not going to enforce this in court.
 */

/* Use with https://drive.google.com/drive/folders/*** */

/* Define how to handle various mime types used by Google. */

const known_mimes = {
    "application/vnd.google-apps.folder": {
	links: id => ({
	    view: `https://drive.google.com/drive/folders/${id}`
	}),
	type: "folder",
	is_folder: true
    },
    "application/vnd.google-apps.shortcut": {
	links: id => ({
	    view: `https://drive.google.com/drive/folders/${id}`
	}),
	type: "shortcut",
	is_folder: true
    },
    "application/vnd.google-apps.document": {
	links: id => ({
	    view: `https://docs.google.com/document/d/${id}`,
	    download: `https://docs.google.com/document/d/${id}/export?format=odt`,
	}),
	type: "Google text document",
	new_mime: "application/vnd.oasis.opendocument.text"
    },
    "application/vnd.google-apps.spreadsheet": {
	links: id => ({
	    view: `https://docs.google.com/spreadsheets/d/${id}`,
	    download: `https://docs.google.com/spreadsheets/d/${id}/export?format=ods`
	}),
	type: "Google spreadsheet",
	new_mime: "application/vnd.oasis.opendocument.spreadsheet"
    },
    "application/vnd.google-apps.presentation": {
	links: id => ({
	    view: `https://docs.google.com/presentation/d/${id}`,
	    download: `https://docs.google.com/presentation/d/${id}/export/pptx`
	}),
	type: "Google presentation",
	new_mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
    },
    "application/vnd.google-apps.drawing": {
	links: id => ({
	    view: `https://docs.google.com/drawings/d/${id}`,
	    download: `https://docs.google.com/drawings/d/${id}/export/jpeg`
	}),
	type: "Google drawing",
	new_mime: "image/jpeg"
    },
    "application/vnd.google-apps.script": {
	links: id => ({
	    download: `https://script.google.com/feeds/download/export?format=json&id=${id}`
	}),
	type: "Google script",
	new_mime: "application/vnd.google-apps.script+json"
    },
    "application/vnd.google-apps.jam": {
	links: id => ({
	    download: `https://jamboard.google.com/export?id=${id}`
	}),
	type: "Google jam",
	new_mime: "application/pdf"
    }
};

/*
 * Human-friendly names defined here will be displayed to the user instead of
 * raw mime types. Please add more here.
 */
let mime_display_overrides = {
    "image/jpeg": "JPEG image",
    "application/octet-stream": "binary data",
    "application/pdf": "PDF document",
    "application/rar": "RAR archive",
    "application/zip": "ZIP archive"
};

let default_link_producer = id => ({
    view: `https://drive.google.com/file/d/${id}`,
    download: `https://drive.google.com/uc?export=download&id=${id}`
});

for (const [mime, display_name] of Object.entries(mime_display_overrides)) {
    known_mimes[mime] = {
	links: default_link_producer,
	type: display_name,
	new_mime: mime
    }
}

delete mime_display_overrides;

function get_mime_info(mime) {
    return known_mimes[mime] || {
	links: default_link_producer,
	type: mime || "",
	new_mime: mime || "application/octet-stream"
    }
}

/* Prepare folder contents data as well as data regarding the folder itself. */

const content = new Map();

function add_content_item(item)
{
    const old_item = content.get(item.id) || {};
    Object.assign(old_item, item);
    content.set(item.id, old_item);
}

const this_folder = {};

function replace_string_escape(match, group)
{
    return String.fromCharCode(`0x${group}`);
}

function try_parse(data, replace_x_escapes)
{
    if (!data)
	return null;

    if (replace_x_escapes)
	data = data.replaceAll(/\\x([0-9a-f]{2})/g, replace_string_escape);

    try {
	return JSON.parse(data);
    } catch (e) {
	console.log(e);
    }

    return null;
}

function process_file_data(file_data, callback)
{
    if (!Array.isArray(file_data) || !typeof file_data[0] === "string") {
	console.log("cannot process the following file data object:",
		    file_data);
	return;
    }

    const result = {id: file_data[0], folders: []};

    if (Array.isArray(file_data[1])) {
	for (const item of file_data[1]) {
	    if (typeof item === "string" && item !== this_folder.id)
		result.folders.push(item);
	}
    }
    if (typeof file_data[2] === "string")
	result.filename = file_data[2];
    if (typeof file_data[3] === "string" && file_data[3].search("/") >= 0)
	result.mime = file_data[3];
    if (typeof file_data[9] === "number")
	result.date1 = new Date(file_data[9]);
    if (typeof file_data[10] === "number")
	result.date2 = new Date(file_data[10]);

    callback(result);
}

/*
 * By searching for scripts with calls to AF_initDataCallback we get about 7
 * matches. All arguments to this function seem to be arrays parseable as JSON,
 * but their contents are very different and only two of those ~7 arrays
 * actually hold some data useful to us. Here we try to filter out the other
 * cases and then extract the useful data.
 */
function process_af_init_data(data)
{
    if (!Array.isArray(data) || !/^driveweb/.test(data[0]) ||
	!Array.isArray(data[1]))
	return;

    /* First useful "kind" of object we can encounter is this folder's data. */
    if (typeof data[1][0] === "string") {
	process_file_data(data[1], item => Object.assign(this_folder, item));
	return;
    }

    /*
     * Second "kind" of object holds data about all items in the folder.
     * Folders and Files are stored in separate lists (doesn't matter to us,
     * since we distinguish them based on mime type anyway).
     */
    for (const data_sub of data[1]) {
	if (!Array.isArray(data_sub) || !/^driveweb/.test(data_sub[0]) ||
	    !Array.isArray(data_sub[1]))
	    continue;
	for (const item of data_sub[1])
	    process_file_data(item, add_content_item);
    }
}

/*
 * Folder items data actually exists in both of the 2 kinds of scripts we search
 * for. In case of both of the regexes below we call `process_file_data' in the
 * end. As a result we process the same file multiple time as it appears in 2
 * scripts. This is, however, a good thing, because it makes a change less
 * likely to break our fix.
 */

const ivd_data_regex = /\s*window\s*\['_DRIVE_ivd'\]\s*=\s*'(\\x5b[^']+)'(.*)$/;
const af_init_data_regex = /AF_initDataCallback\s*\(.+data\s*:\s*(\[.*\])[^\]]+$/;

for (const script of document.scripts) {
    const ivd_data_match = ivd_data_regex.exec(script.textContent);
    if (ivd_data_match) {
	const ivd_data = try_parse(ivd_data_match[1], true);
	if (ivd_data && Array.isArray(ivd_data) && Array.isArray(ivd_data[0])) {
	    for (const item of ivd_data[0])
		process_file_data(item, add_content_item);
	}
    }

    const af_init_data_match = af_init_data_regex.exec(script.textContent);
    if (af_init_data_match) {
	const af_init_data = try_parse(af_init_data_match[1], false);
	if (af_init_data)
	    process_af_init_data(af_init_data);
    }
}

/* Construct our own user interface. */

const body = document.createElement("body");
const folders = document.createElement("div");
const files = document.createElement("div");
const folders_heading = document.createElement("h2");
const files_heading = document.createElement("h2");

folders_heading.textContent = "Folders";
files_heading.textContent = "Files";

let has_folders = false;
let has_files = false;

folders.appendChild(folders_heading);
files.appendChild(files_heading);

body.setAttribute("style", "width: 100vw; height: 100vh; overflow: scroll; color: #555; margin: 15px; -webkit-user-select: initial;");

const drive_folder_regex = /application\/vnd.google-apps.(folder|shortcut)/;

function add_item_to_view(item_data)
{
    const item_div = document.createElement("div");
    const item_heading = document.createElement("h4");

    item_div.setAttribute("style", "border: 2px solid #999; border-radius: 8px; padding: 10px; display: inline-block; margin: 2px;");

    let item_heading_style = "margin: 8px 4px;";

    if (item_data.filename) {
	item_heading.textContent = item_data.filename;
    } else {
	item_heading.textContent = "(no name)";
	item_heading_style += " font-style:italic;";
    }
    item_heading.setAttribute("style", item_heading_style);
    item_div.appendChild(item_heading);

    const mime_info = get_mime_info(item_data.mime);

    if (mime_info.type) {
	const type_div = document.createElement("div");
	type_div.setAttribute("style", "margin-bottom: 5px;");
	type_div.textContent = mime_info.type;

	item_div.appendChild(type_div);
    }

    if (mime_info.is_folder)
	has_folders = true;
    else
	has_files = true;

    const links = {};
    if (item_data.id)
	Object.assign(links, mime_info.links(item_data.id));

    if (links.view) {
	const view_button = document.createElement("a");
	view_button.setAttribute("style", "border-radius: 5px; padding: 10px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block; margin: 5px;");
	view_button.textContent = "view";
	view_button.href = links.view;

	item_div.appendChild(view_button);
    }

    if (links.download) {
	const download_button = document.createElement("a");
	download_button.setAttribute("style", "border-radius: 5px; padding: 10px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block; margin: 5px;");
	download_button.textContent = "download";
	download_button.href = links.download;
	download_button.type = mime_info.new_mime;

	item_div.appendChild(download_button);
    }

    (mime_info.is_folder ? folders : files).appendChild(item_div);
}

for (const item of content.values())
    add_item_to_view(item);

if (this_folder.filename) {
    const heading = document.createElement("h1");
    heading.textContent = this_folder.filename;
    body.appendChild(heading);
}
if (this_folder.folders && this_folder.folders.length > 0) {
    for (const parent_id of this_folder.folders) {
	const up_button = document.createElement("a");
	let text = "go up";

	up_button.setAttribute("style", "border-radius: 5px; padding: 10px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block; margin: 5px;");
	up_button.href = `https://drive.google.com/drive/folders/${parent_id}`;
	if (this_folder.folders.length > 1)
	    text = `${text} (${parent_id})`;
	up_button.textContent = text;

	body.appendChild(up_button);
    }
}
if (has_folders)
    body.appendChild(folders);
if (has_files)
    body.appendChild(files);
if (!(has_files || has_folders)) {
    const no_files_message = document.createElement("h3");
    no_files_message.textContent = "No files found.";
    body.appendChild(no_files_message);
}

if (document.body.firstChild.id !== "af-error-container")
    document.documentElement.replaceChild(body, document.body);