/** * 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);