// Copyright 2006 Mihai Parparita. All Rights Reserved.
// ==UserScript==
// @name Gmail Macros
// @namespace http://persistent.info/greasemonkey
// @description Extra (customizable) keyboard shortcuts and macros.
// @include http://mail.google.com/*
// @include https://mail.google.com/*
// ==/UserScript==
// Constants
const LABEL_PREFIX = "sc_";
const SELECT_PREFIX = "sl_";
const SAVED_SEARCH_PREFIX = "savedsearch_";
// Maps human readable names to DOM node IDs
const SPECIAL_LABELS = {
"Inbox": "ds_inbox",
"Starred": "ds_starred",
"Chats": "ds_chats",
"Sent Mail": "ds_sent",
"Drafts": "ds_drafts",
"All Mail": "ds_all",
"Spam": "ds_spam",
"Trash": "ds_trash",
"Contacts": "cont"
};
// Command Names
const MARK_AS_READ = "rd";
const MARK_AS_UNREAD = "ur";
const ARCHIVE = "rc_^i";
const MOVE_TO_INBOX = "ib";
const ADD_STAR = "st";
const REMOVE_STAR = "xst";
const APPLY_LABEL = "ac_"; // Followed by label name
const REMOVE_LABEL = "rc_"; // Followed by label name
const MOVE_TO_TRASH = "tr";
const DELETE_FOREVER = "dl"; // Only works when in trash and spam views
const REPORT_SPAM = "sp";
const NOT_SPAM = "us";
const HANDLERS_TABLE = {
68: [MARK_AS_READ, ARCHIVE], // D: Discard
69: [ARCHIVE], // E: always archivE (Y's context-dependent behavior is annoying)
82: [MARK_AS_READ], // R: mark as Read
84: [MOVE_TO_TRASH],// T: move to Trash
90: [MARK_AS_UNREAD] // Z: mark as Unread (undo read similar to Ctrl+Z)
};
const LABEL_ACTIONS = {
// g: go to label
71: function(labelName) {
var labelDiv = getLabelNode(labelName);
var eventType = labelName == "Contacts" ? "click" : "mousedown";
simulateClick(labelDiv, eventType);
},
// l: apply label
76: function (labelName) {
// we don't do special labels (there's other commands, like "archive" for
// that)
if (labelName in SPECIAL_LABELS) {
return;
}
runCommands([APPLY_LABEL + labelName]);
},
// b: remove label
66: function (labelName) {
// we don't do special labels (there's other commands, like "archive" for
// that)
if (labelName in SPECIAL_LABELS) {
return;
}
runCommands([REMOVE_LABEL + labelName]);
}
};
const SELECT_KEY_VALUES = {
65: ['a','All'],
78: ['n','None'],
82: ['r','Read'],
83: ['s','Starred'],
84: ['t','Unstarred'],
85: ['u','Unread']
};
const SELECT_ACTIONS = {
// shift-x: select
88: function(selectionName) {
var selectDiv = getNode(SELECT_PREFIX + selectionName);
simulateClick(selectDiv, "mousedown");
},
// h: show help
72: function() {
banner.show(true);
banner.update(getHelpHtml());
}
};
const SIMPLE_ACTIONS = {
// o: expand/collapse all
79: function(selectionName) {
if(getNode("ec")){
simulateClick(getNode("ec"), "mousedown");
}
if(getNode("ind")){
simulateClick(getNode("ind"), "mousedown");
}
}
};
const BUILTIN_KEYS_HELP = {
"C*" : "Compose",
"/" : "Search",
"Q" : "Quick contacts",
"J/K" : "Move to an older/newer conversation",
"N/P" : "Next/Previous message",
"<Enter>" : "Open*, expand/collapse, press button",
"U" : "Return to the conversation list",
"Y" : "Archive/remove from current view",
"X" : "Select a conversation",
"S" : "Star a message or conversation",
"!" : "Report Spam!",
"R*" : "Reply",
"A*" : "Reply All",
"F*" : "Forward"
};
const ADDED_KEYS_HELP = {
"H" : "What are the keyboard commands?",
"T" : "Trash conversation(s)",
"E" : "ArchivE conversations(s) (always)",
"R" : "Mark conversation(s) as Read",
"Z" : "Mark conversation(s) as unread (vs. Ctrl+Z undo)",
"D" : "Discard (read & archive) conversation(s)",
"O" : "Expand/collapse all messages in a conversation",
" " : " ",
"V" : "PreView a conversation
(requires Gmail Conversation Preview)",
" " : " ",
"G+label" : "Go to label (including inbox/starred/trash/etc.)",
"L+label" : "Label conversation(s) as label",
"B+label" : "Remove label from conversation(s)",
"<Shift>-X+key" : "Select " +
"A - All, " +
"N - None, " +
"R - Read,
" +
"U - Unread, " +
"S - Starred, " +
"T - UnsTarred"
};
// Utility functions
function simulateClick(node, eventType) {
var event = node.ownerDocument.createEvent("MouseEvents");
event.initMouseEvent("mousedown",
true, // can bubble
true, // cancellable
window,
1, // clicks
50, 50, // screen coordinates
50, 50, // client coordinates
false, false, false, false, // control/alt/shift/meta
0, // button,
node);
node.dispatchEvent(event);
}
function getHelpHtml() {
var html =
'
' +
'' +
'Available Keyboard Commands' +
'' +
'' +
'| Standard | Extended | ' +
'
';
var base = [];
for (var key in BUILTIN_KEYS_HELP) {
base.push("" + key + " | " + BUILTIN_KEYS_HELP[key] + " | ");
}
var added = [];
for (var key in ADDED_KEYS_HELP) {
added.push("" + key + " | " + ADDED_KEYS_HELP[key] + " | ");
}
for(var i = 0; i < base.length; i++) {
html += "" + base[i] + added[i] + "
";
}
html +=
'' +
'| ' +
'* Hold <Shift> for action in a new window' +
' | ' +
'
' +
'
';
return html;
}
// Shorthand
function bind(func, thisObject) {
return function() {
return func.apply(thisObject, arguments);
}
}
var newNode = bind(unsafeWindow.document.createElement, unsafeWindow.document);
var getNode = bind(unsafeWindow.document.getElementById, unsafeWindow.document);
// Globals
var banner;
var dispatchedActionTimeout = null;
var activeLabelAction = null;
var activeSelectAction = null;
var labels = new Array();
var selectedLabels = new Array();
var labelInput = null;
var labelsBoxWasClosed = false;
if (isLoaded()) {
banner = new Banner();
window.addEventListener('keydown', keyHandler, false);
GM_addStyle(".banner b {font-weight: normal; color: yellow;}");
}
function isLoaded() {
// Action or contacts menus is present
return (getActionMenu() != null) || (getNode("co") != null);
}
function getActionMenu() {
const ACTION_MENU_IDS = ["tam", "ctam", "tamu", "ctamu"];
for (var i = 0, id; id = ACTION_MENU_IDS[i]; i++) {
if (getNode(id) != null) {
return getNode(id);
}
}
return null;
}
function keyHandler(event) {
// Apparently we still see Firefox shortcuts like control-T for a new tab -
// checking for modifiers lets us ignore those
if (event.altKey || event.ctrlKey || event.metaKey) {
return false;
}
// We also don't want to interfere with regular user typing
if (event.target && event.target.nodeName) {
var targetNodeName = event.target.nodeName.toLowerCase();
if (targetNodeName == "textarea" ||
(targetNodeName == "input" && event.target.type &&
event.target.type.toLowerCase() == "text")) {
return false;
}
}
var k = event.keyCode;
if (k in SIMPLE_ACTIONS) {
SIMPLE_ACTIONS[k]();
return true;
}
if (k in LABEL_ACTIONS) {
if (activeLabelAction) {
endLabelAction();
return false
} else {
activeLabelAction = LABEL_ACTIONS[k];
beginLabelAction();
return true;
}
}
if ((k in SELECT_ACTIONS) && (k != 88 || event.shiftKey)) {
if (activeSelectAction) {
endSelectAction();
return false;
} else {
activeSelectAction = SELECT_ACTIONS[k];
beginSelectAction();
return true;
}
}
if (k in HANDLERS_TABLE) {
runCommands(HANDLERS_TABLE[k]);
return true;
}
return false;
}
function beginLabelAction() {
// Make sure the labels box is open
var labelsHeaderNode = getNode("nt_0");
if (labelsHeaderNode.nextSibling == null) {
labelsBoxWasClosed = true;
simulateClick(labelsHeaderNode, "click");
}
var divs = getNode("nb_0").getElementsByTagName("div");
labels = new Array();
for (var i=0; i < divs.length; i++) {
if (divs[i].className.indexOf("cs") != -1 &&
divs[i].id.indexOf(LABEL_PREFIX) == 0) {
labels.push(divs[i].id.substring(LABEL_PREFIX.length));
}
}
var searchesDiv = getNode("nb_9");
if (searchesDiv != null) {
var divs = searchesDiv.getElementsByTagName("div");
for (var i=0; i < divs.length; i++) {
if (divs[i].className.indexOf("cs") != -1 &&
divs[i].id.indexOf(SAVED_SEARCH_PREFIX) == 0) {
labels.push(divs[i].id.substring(SAVED_SEARCH_PREFIX.length));
}
}
}
for (var specialLabel in SPECIAL_LABELS) {
labels.push(specialLabel);
}
banner.show();
dispatchedActionTimeout = null;
labelInput = makeLabelInput();
labelInput.addEventListener("keyup", updateLabelAction, false);
// we want escape, clicks, etc. to cancel, which seems to be equivalent to the
// field losing focus
labelInput.addEventListener("blur", endLabelAction, false);
}
function beginSelectAction(){
labelInput = makeLabelInput();
labelInput.addEventListener("keyup", updateSelectAction, false);
// we want escape, clicks, etc. to cancel, which seems to be equivalent to the
// field losing focus
labelInput.addEventListener("blur", endSelectAction, false);
}
function makeLabelInput(){
labelInput = newNode("input");
labelInput.type = "text";
labelInput.setAttribute("autocomplete", "off");
with (labelInput.style) {
position = "fixed"; // We need to use fixed positioning since we have to ensure
// that the input is not scrolled out of view (since
// Gecko will scroll for us if it is).
top = "0";
left = "-300px";
width = "200px";
height = "20px";
zIndex = "1000";
}
unsafeWindow.document.body.appendChild(labelInput);
labelInput.focus();
labelInput.value = "";
return labelInput;
}
function endAction() {
banner.hide();
if (labelInput) {
labelInput.parentNode.removeChild(labelInput);
labelInput = null;
}
}
function endLabelAction(){
if (labelsBoxWasClosed) {
labelsBoxWasClosed = false;
simulateClick(getNode("nt_0"), "click");
}
endAction();
activeLabelAction = null;
}
function endSelectAction(){
endAction();
activeSelectAction = null;
}
function updateLabelAction(event) {
// We've already dispatched the action, the user is just typing away
if (dispatchedActionTimeout) {
return;
}
selectedLabels = new Array();
// We need to skip the label shortcut that got us here
var labelPrefix = labelInput.value.substring(1).toLowerCase();
banner.update(labelPrefix);
if (labelPrefix.length == 0) {
return;
}
for (var i=0; i < labels.length; i++) {
if (labels[i].toLowerCase().indexOf(labelPrefix) == 0) {
selectedLabels.push(labels[i]);
}
}
if (event.keyCode == 13 || selectedLabels.length == 1) {
// Tell the user what we picked
banner.update(selectedLabels[0]);
// Invoke the action straight away, but keep the banner up so the user can
// see what was picked, and so that extra typing is caught.
activeLabelAction(selectedLabels[0]);
dispatchedActionTimeout = window.setTimeout(
function () {
endLabelAction();
}, 400);
}
}
function updateSelectAction(event) {
if (event.keyCode == 88 || event.keyCode == 16) return true;
if (event.keyCode in SELECT_KEY_VALUES) {
activeSelectAction(SELECT_KEY_VALUES[event.keyCode][0]);
} else if (event.keyCode == 72) {
activeSelectAction();
return true;
}
endSelectAction();
}
function getLabelNode(labelName) {
if (labelName in SPECIAL_LABELS) {
return getNode(SPECIAL_LABELS[labelName]);
} else {
return getNode(LABEL_PREFIX + labelName) ||
getNode(SAVED_SEARCH_PREFIX + labelName);
}
}
function runCommands(commands) {
for (var i=0; i < commands.length; i++) {
var command = commands[i];
// A one second pause between commands seems to be enough for LAN/broadband
// connections
setTimeout(getCommandClosure(commands[i]), 100 + 1000 * i);
}
}
function getCommandClosure(command) {
return function() {
// We create a fake action menu, add our command to it, and then pretend to
// select something from it. This is easier than dealing with the real
// action menu, since some commands may be disabled and others may be
// present as buttons instead
var actionMenu = newNode("select");
var commandOption = newNode("option");
commandOption.value = command;
commandOption.innerHTML = command;
actionMenu.appendChild(commandOption);
actionMenu.selectedIndex = 0;
var actionMenuNode = getActionMenu();
if (actionMenuNode) {
var onchangeHandler = actionMenuNode.onchange;
onchangeHandler.apply(actionMenu, null);
} else {
GM_log("Not able to find a 'More Actions...' menu");
return;
}
}
}
function Banner() {
this.backgroundNode = getNodeSet();
this.backgroundNode.style.background = "#000";
this.backgroundNode.style.MozOpacity = "0.70";
this.backgroundNode.style.zIndex = 100;
for (var child = this.backgroundNode.firstChild;
child;
child = child.nextSibling) {
child.style.visibility = "hidden";
}
this.foregroundNode = getNodeSet();
this.foregroundNode.style.zIndex = 101;
}
function getNodeSet() {
var boxNode = newNode("div");
boxNode.className = "banner";
with (boxNode.style) {
display = "none";
position = "fixed";
left = "10%";
margin = "0 10% 0 10%";
width = "60%";
textAlign = "center";
MozBorderRadius = "10px";
padding = "10px";
color = "#fff";
}
var messageNode = newNode("div");
with (messageNode.style) {
fontSize = "24px";
fontWeight = "bold";
fontFamily = "Lucida Grande, Trebuchet MS, sans-serif";
margin = "0 0 10px 0";
}
boxNode.appendChild(messageNode);
var taglineNode = newNode("div");
with (taglineNode.style) {
fontSize = "13px";
margin = "0";
}
taglineNode.innerHTML = 'LabelSelector9000';
boxNode.appendChild(taglineNode);
return boxNode;
}
Banner.prototype.hide = function() {
this.backgroundNode.style.display =
this.foregroundNode.style.display = "none";
}
Banner.prototype.show = function(opt_isBottomAnchored) {
this.update("");
document.body.appendChild(this.backgroundNode);
document.body.appendChild(this.foregroundNode);
this.backgroundNode.style.bottom = this.foregroundNode.style.bottom =
opt_isBottomAnchored ? "10%" : "";
this.backgroundNode.style.top = this.foregroundNode.style.top =
opt_isBottomAnchored ? "" : "50%";
this.backgroundNode.style.display =
this.foregroundNode.style.display = "block";
}
Banner.prototype.update = function(message) {
if (message.length) {
this.backgroundNode.firstChild.style.display =
this.foregroundNode.firstChild.style.display = "inline";
} else {
this.backgroundNode.firstChild.style.display =
this.foregroundNode.firstChild.style.display = "none";
}
this.backgroundNode.firstChild.innerHTML =
this.foregroundNode.firstChild.innerHTML = message;
}