text: /**
   * Freshdesk ticket plugin. Drag tickets into the diagram. Tickets are
   * updated on file open, page select and via Extras, Update Tickets.
   * 
   * Drag freshdesk tickets into the diagram. Domain must match deskDomain.freshdesk.com.
   * 
   * Use #C to configure the client as follows:
   * 
   * https://www.draw.io/?p=tickets#C%7B"ticketsConfig"%3A %7B"deskApiKey"%3A"YOUR_API_KEY"%2C"deskDomain"%3A"YOUR_DOMAIN"%7D%7D
   * 
   * Use an additional "open" variable in the config JSON to open a file after parsing as follows:
   * 
   * ...#_TICKETS%7B"ticketsConfig"%3A %7B"deskApiKey"%3A"YOUR_API_KEY"%2C"deskDomain"%3A"YOUR_DOMAIN"%7D%2C"open"%3A"ID_WITH_PREFIX"%7D
   * 
   * Required JSON parameters:
   * - deskApiKey=api_key (see user profile)
   * - deskDomain=subdomain (subdomain.freshdesk.com)
   * 
   * Optional JSON parameters:
   * - deskStatus: Lookup for status codes (code => string)
   * - deskTypes: Lookup for ticket types (string => story, task, subTask, feature,
   * bug, techTask, epic, improvement, fault, change, access, purchase or itHelp)
   * 
   * The current configuration is stored in localStorage under ".tickets-config". Use
   * https://jgraph.github.io/drawio-tools/tools/convert.html for URI encoding.
   */
  Draw.loadPlugin(function(ui)
  {
  	var config = null;
  	var deskDomain = null;
  	var deskApiKey = null;
  	var graph = ui.editor.graph;
  	
  	var deskPriority = {'1': 'minor', '2': 'major',
  		'3': 'critical', '4': 'blocker'};
  	var deskTypes = {'Question': 'story', 'Incident': 'techTask', 'Problem': 'fault',
  		'Feature Request': 'feature', 'Lead': 'purchase'};
  	var deskStatus = {'2': 'Open', '3': 'Pending', '4': 'Resolved', '5': 'Closed',
  		'6': 'Waiting on Customer', '7': 'Waiting on Third Party',
  		'8': 'Resolved Internally'};
  	var deskStatusWidth = {};
  	
  	function configure()
  	{
  		deskDomain = 'https://' + config.deskDomain + '.freshdesk.com';
  		deskApiKey = config.deskApiKey;
  		
  		deskTypes = config.deskTypes || deskTypes;
  		deskStatus = config.deskStatus || deskStatus;
  		deskStatusWidth = {};
  
  		// Precomputes text widths for custom ticket status
  		var div = document.createElement('div');
  		div.style.fontFamily = 'Arial,Helvetica';
  		div.style.visibility = 'hidden';
  		div.style.position = 'absolute';
  		div.style.fontSize = '11px';
  		
  		document.body.appendChild(div);
  		
  		for (var key in deskStatus)
  		{
  			div.innerHTML = '';
  			mxUtils.write(div, deskStatus[key]);
  			deskStatusWidth[key] = div.clientWidth + 4;
  		}
  
  		document.body.removeChild(div);
  	};
  	
  	if (window.location.hash != null && window.location.hash.substring(0, 9) == '#_TICKETS')
  	{
  		try
  		{
  			var temp = JSON.parse(decodeURIComponent(
  				window.location.hash.substring(9)));
  			
  			if (temp != null && temp.ticketsConfig != null)
  			{
  				config = temp.ticketsConfig;
  				configure();
  				ui.fileLoaded(new LocalFile(ui, ui.emptyDiagramXml, this.defaultFilename, true));
  				ui.editor.setStatus('Drag tickets from <a href="' + deskDomain +
  					'/a/tickets/filters/all_tickets" target="_blank">' +
  					deskDomain + '</a>');
  			}
  		}
  		catch (e)
  		{
  			console.error(e);
  		}
  	}
  
  	function isDeskLink(link)
  	{
  		if (deskDomain != null)
  		{
  			var dl = deskDomain.length;
  			
  			return config != null && link.substring(0, dl) == deskDomain &&
  				(link.substring(dl, dl + 18) == '/helpdesk/tickets/' ||
  				link.substring(dl, dl + 11) == '/a/tickets/');
  		}
  		else
  		{
  			return false;
  		}
  	};
  	
  	function getIdForDeskLink(link)
  	{
  		return link.substring(link.lastIndexOf('/') + 1);
  	};
  	
  	function getDeskTicket(id, fn)
  	{
  		var xhr = new XMLHttpRequest();
  		xhr.open('GET', deskDomain + '/api/v2/tickets/' + id);
  		xhr.setRequestHeader('Authorization', 'Basic ' + btoa(deskApiKey + ':x'));
  
  		xhr.onload = function ()
  		{
  			if (xhr.status >= 200 && xhr.status <= 299)
  			{
  				fn(JSON.parse(xhr.responseText), xhr);
  			}
  			else
  			{
  				fn(null, xhr);
  			}
  		};
  		
  		xhr.onerror = function ()
  		{
  			fn(null, xhr);
  		};
  
  		xhr.send();
  	};
  	
  	function updateStyle(cell, ticket)
  	{
  		var type = (ticket.type != null) ? deskTypes[ticket.type] : 'bug';
  		var status = deskStatus[ticket.status] || 'Unknown';
  		var priority = deskPriority[ticket.priority];
  		var sw = deskStatusWidth[ticket.status];
  		var prev = cell.style;
  		
  		cell.style = mxUtils.setStyle(cell.style, 'issueType', type);
  		cell.style = mxUtils.setStyle(cell.style, 'issueStatus', status);
  		cell.style = mxUtils.setStyle(cell.style, 'issueStatusWidth', sw);
  		cell.style = mxUtils.setStyle(cell.style, 'issuePriority', priority);
  		
  		return prev != cell.style;
  	};
  	
  	function shortString(s, max)
  	{
  		if (s.length > max)
  		{
  			return s.substring(0, max) + '...';
  		}
  		else
  		{
  			return s;
  		}
  	}
  	
  	function updateData(cell, ticket)
  	{
  		var changed = false;
  		
  		function setAttr(key, value)
  		{
  			var prev = cell.value.getAttribute(key);
  			value = value || '';
  			
  			if (prev != value)
  			{
  				cell.value.setAttribute(key, value);
  				
  				return true;
  			}
  			else
  			{
  				return false;
  			}
  		};
  		
  		changed = setAttr('abstract', shortString(ticket.description_text, 600)) |
  			setAttr('email_config_id', ticket.email_config_id) |
  			setAttr('requester_id', ticket.requester_id) |
  			setAttr('group_id', ticket.group_id) |
  			setAttr('created_at', ticket.created_at) |
  			setAttr('updated_at', ticket.updated_at) |
  			setAttr('due_by', ticket.due_by) |
  			setAttr('tags', ticket.tags.join(' '));
  		
  		for (var key in ticket.custom_fields)
  		{
  			changed = changed | setAttr(key, ticket.custom_fields[key]);
  		}
  
  		return changed;
  	};
  	
  	function updateTickets(spin)
  	{
  		if (config != null && (!spin || ui.spinner.spin(document.body, mxResources.get('loading') + '...')))
  		{
  			var validate = false;
  			var pending = 0;
  			
  			graph.view.states.visit(function(id, state)
  			{
  				var link = graph.getLinkForCell(state.cell);
  				
  				if (link != null && isDeskLink(link))
  				{
  					var id = getIdForDeskLink(link);
  					pending++;
  					
  					getDeskTicket(id, function(ticket, req)
  					{
  						pending--;
  						
  						if (ticket != null)
  						{
  							// Expression must execute both calls
  							if (updateStyle(state.cell, ticket) |
  								updateData(state.cell, ticket))
  							{
  								graph.view.invalidate(state.cell, true, false);
  								state.style = null;
  								validate = true;
  							}
  						}
  						
  						if (pending == 0)
  						{
  							if (spin)
  							{
  								ui.spinner.stop();
  							}
  							
  							if (validate)
  							{
  								graph.view.validate();
  							}
  						}
  					})
  				}
  			});
  			
  			if (spin && pending == 0)
  			{
  				ui.spinner.stop();
  			}
  		}
  	};
  	
  	function getCellForLink(link)
  	{
  		for (var key in graph.view.states.map)
  		{
  			var cell = graph.view.states.map[key].cell;
  			
  			if (link == graph.getLinkForCell(cell))
  			{
  				return cell;
  			}
  		}
  	};
  
  	// Adds resource for action
  	mxResources.parse('updateTickets=Update Tickets...');
  
  	// Adds action
  	ui.actions.addAction('updateTickets', function()
  	{
  		updateTickets(true);
  	});
  	
  	// Updates tickets in opened files
  	ui.editor.addListener('fileLoaded', function()
  	{
  		updateTickets(false);
  	});
  
  	// Updates tickets when page changes
  	ui.editor.addListener('pageSelected', function()
  	{
  		updateTickets(false);
  	});
  
  	// Adds menu item
  	var menu = ui.menus.get('extras');
  	var oldFunct = menu.funct;
  	
  	menu.funct = function(menu, parent)
  	{
  		oldFunct.apply(this, arguments);
  		
  		ui.menus.addMenuItems(menu, ['-', 'updateTickets'], parent);
  	};
  
  	// Intercepts ticket URLs
  	var uiInsertTextAt = ui.insertTextAt;
  	
  	ui.insertTextAt = function(text, dx, dy, html, asImage, crop, resizeImages)
  	{
  		if (isDeskLink(text))
  		{
  			var cell = getCellForLink(text);
  			
  			if (cell != null)
  			{
  				// Selects existing ticket with same link
  				graph.setSelectionCell(cell);
  				graph.scrollCellToVisible(graph.getSelectionCell());
  			}
  			else if (ui.spinner.spin(document.body, mxResources.get('loading') + '...'))
  			{
  				// Creates new shape
  				var id = getIdForDeskLink(text);
  				
  				getDeskTicket(id, function(ticket, req)
  				{
  					ui.spinner.stop();
  					
  					if (ticket != null)
  					{
  						var cell = null;
  						
  				    	graph.getModel().beginUpdate();
  				    	try
  				    	{
  				    		cell = graph.insertVertex(graph.getDefaultParent(), null,
  				    			'%title%\n\n<b>Updated:</b> %updated_at% ' +
  				    			'(<a href="' + deskDomain + '/contacts/%requester_id%">From</a>)',
  								graph.snap(dx), graph.snap(dy), 200, 50,
  								'html=1;whiteSpace=wrap;overflow=hidden;shape=mxgraph.atlassian.issue;' +
  								'fontSize=12;verticalAlign=top;align=left;spacingTop=25;' +
  								'strokeColor=#A8ADB0;fillColor=#EEEEEE;backgroundOutline=1;');
  				    		
  				    		graph.setLinkForCell(cell, text);
  				    		cell.value.setAttribute('title', shortString(ticket.subject, 40));
  				    		cell.value.setAttribute('subject', ticket.subject);
  							cell.value.setAttribute('placeholders', '1');
  							cell.value.setAttribute('ticket_id', id);
  				    		updateData(cell, ticket);
  							updateStyle(cell, ticket);
  
  				    		// Adds ticket ID label
  				    		var label1 = new mxCell('%ticket_id%', new mxGeometry(0, 0, 60, 20),
  						   		'strokeColor=none;fillColor=none;part=1;resizable=0;align=left;' +
  						   		'autosize=1;points=[];deletable=0;editable=0;connectable=0;');
  						   	graph.setAttributeForCell(label1, 'placeholders', '1');
  						   	label1.geometry.relative = true;
  						   	label1.geometry.offset = new mxPoint(20, 0);
  						   	label1.vertex = true;
  						   	cell.insert(label1);
  				    		
  				    		graph.updateCellSize(cell);
  				    		cell.geometry.width = Math.max(220, cell.geometry.width);
  				    		cell.geometry.height += 10;
  				    	}
  				    	finally
  				    	{
  				    		graph.getModel().endUpdate();
  				    	}
  				    	
  						graph.setSelectionCell(cell);
  					}
  					else
  					{
  						var err = req.status
  						
  						try
  						{
  							err = JSON.parse(req.responseText);
  						}
  						catch (e)
  						{
  							// ignore
  						}
  						
  						ui.handleError({message: err.message});
  					}
  				});
  			}
  			
  	    	return null;
  		}
  		else
  		{
  			return uiInsertTextAt.apply(this, arguments);
  		}
  	};
  });
  