text: /* exported Y S N addScript addStyleSheet array2ul dataFiles dataFilesDefault encodeQueryData getPastMonth removeStyleSheet selectOptionByText showError sortCol sortColTag tagClicked toggleClass toggleRowHighlight wrapme */
  
  // moneylog.js
  // https://aurelio.net/moneylog/
  
  // I hope someday I can move everything under this namespace.
  // Until then, MoneyLog is a globals festival :(
  var ml = {};
  
  // shortcuts for config
  var Y = true;
  var S = true;
  var N = false;
  
  
  // -------------------------------------------------------------------
  // User Config
  // -----------
  //
  // There is no need to change *anything*, but if you have special
  // needs, here is the place to customize this script.
  //
  // Better yet, use a config.js file on the same folder as this one.
  //
  // Learn more about MoneyLog configuration:
  // https://aurelio.net/moneylog/config/
  //  -------------------------------------------------------------------
  
  var myPassword = '';              // Set up an access password
  var lang = 'pt';                  // pt:Portuguese, en:English, ca:Catalan, es:Spanish (Argentina)
  var reportType = 'd';             // Initial report type: d m y (daily, monthly, yearly)
  var initFullScreen = false;       // Start app in Full Screen mode?
  var checkMonthPartials = true;    // Monthly checkbox inits checked?
  var showRowCount = true;          // Show the row numbers at left?
  var monthlyRowCount = true;       // The row numbers are reset each month?
  var highlightWords = '';          // The words you may want to highlight (ie: 'XXX TODO')
  var showBalance = true;           // Show the Balance column in reports?
  
  // Search
  var defaultSearch = '';           // Search for this text on init
  var checkRegex = false;           // Search regex checkbox inits checked?
  var checkNegate = false;          // Search negate checkbox inits checked?
  
  // Date
  var showLocaleDate = true;        // Show dates in the regional format? (ie: 12/31/2009)
  var checkDateFrom = true;         // Date filter From: checkbox inits checked?
  var checkDateUntil = true;        // Date filter To: checkbox inits checked?
  var initMonthOffsetFrom = -2;     // From: month will be N months from now
  var initMonthOffsetUntil = 0;     // To:   month will be N months from now
  
  // Widgets
  var initStorageWidgetOpen = true; // Start app with the Storage widget opened?
  var initViewWidgetOpen = true;    // Start app with the View widget opened?
  var initTagCloudOpen = true;      // Start app with the Tag Cloud widget opened?
  var showStorageWidget = true;     // Show Storage widget in the sidebar?
  var showViewWidget = true;        // Show View widget in the sidebar?
  var showTagCloud = true;          // Show Tag Cloud widget in the sidebar?
  
  // Tags
  var highlightTags = '';           // The tags you may want to highlight (ie: 'work kids')
  var ignoreTags = '';              // Ignore all entries that have one of these tags
  var initSelectedTags = '';        // Tag Cloud: start app with these tags already selected
  var initExcludedTags = '';        // Tag Cloud: start app with these tags already excluded
  var checkHideRelatedTags = false; // Tag Report option [ ] Hide related inits checked?
  var showTagReport = true;         // Show the Tag Report after monthly/yearly reports?
  
  // Charts
  var showMiniBars = true;          // Show the percentage bars in monthly/yearly reports?
  var showMiniBarsLabels = true;    // Show the labels inside the bars?
  var miniBarWidth = 70;            // The percentage bar width, in pixels
  var showCharts = true;            // Show the bar chart after the report?
  var showChartBarLabel = true;     // Show the labels above the bars?
  var initChartDaily = 3;           // Initial selected item for the daily chart [1-4]
  var initChartMonthly = 1;         // Initial selected item for the monthly chart [1-4]
  var initChartYearly = 1;          // Initial selected item for the yearly chart [1-4]
  
  // Legacy config for external TXT files. Will be kept here for some time.
  // Now use:
  //   ml.storage.drivers.filesystem.dataFiles = [];
  //   ml.storage.drivers.filesystem.defaultFile = '';
  var dataFiles = [];               // The paths for the TXT data files
  var dataFilesDefault = '';        // Default selected file at init when using multiple TXT
  
  // Ignore old or future data - Use with care!
  var ignoreDataOlderThan = '';     // Ignore entries older than this date (ie: 2010-01-01)
  var ignoreDataNewerThan = '';     // Ignore entries newer than this date (ie: 2020-12-31)
  
  // Legacy options
  var useLegacyDataFormat = false;  // Use v4-style TAB-only as separator?
  
  // Default sort configuration for all tables:
  //
  //   index      sort index column for reports (one-based)
  //   min        min value for index
  //   max        max value for index
  //   rev        reverse the sort order?
  //
  //   indexTag   sort index column for Tag Report (one-based)
  //   minTag     min value for indexTag
  //   maxTag     -- not available, since it is dynamic
  //   revTag     reverse the sort order in Tag Report?
  //
  var sortData = {
  
  	// Daily Report
  	d: {
  		index: 1,
  		min: 1,
  		max: 4,
  		rev: false
  	},
  
  	// Monthly Report
  	m: {
  		index: 1,
  		min: 1,
  		max: 5,
  		rev: false,
  
  		indexTag: 1,
  		minTag: 1,
  		revTag: false
  	},
  
  	// Yearly Report
  	y: {
  		index: 1,
  		rev: false,
  		min: 1,
  		max: 5,
  
  		indexTag: 1,
  		minTag: 1,
  		revTag: false
  	}
  };
  
  // Data format
  var dataFieldSeparator = '\t';  // Only used if useLegacyDataFormat=true
  var dataRecordSeparator = /\r?\n/;  // \r\n Windows, \n Linux/Mac
  var dataTagTerminator = '|';
  var dataTagSeparator = ',';
  var commentChar = '#';   // Must be at line start (column 1)
  var dataPatterns = {
  	rowBlankSeparated:
  		// Uses multiple TAB and spaces as field separators
  		/^(\d{4}-\d\d-\d\d)[ \t]+([+-]?[0-9.,*/]+)[ \t]*(.*)$/,
  	date:
  		// YYYY-MM-DD
  		/^ *(\d{4}-\d\d-\d\d) *$/,
  	amountNumber:
  		// 7  +7  -7  7.00  7,00  1234567,89  1.234.567,89  1,234,567.89 1234567,89
  		/^ *([+-]? *(\d+|\d{1,3}([.,]\d{3})*)([.,]\d\d)?) *$/,
  	amountCents:
  		// .12  ,12
  		/[.,](\d\d) *$/,
  	amountRecurrent:
  		// *N or /N where N>0
  		/([*/])([1-9][0-9]*) *$/
  };
  
  // Internationalisation (i18n) - Screen Labels and formatting
  var i18nDatabase = {
  	defaultLanguage: 'en',
  	en: {
  		centsSeparator: '.',
  		thousandSeparator: ',',
  		dateFormat: 'b d, Y',
  		dateFormatMonth: 'B Y',
  		dateFormatYear: 'Y',
  		monthNames: ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
  		labelNoData: 'No data.',
  		labelTotal: 'Total',
  		labelAverage: 'Average',
  		labelMinimum: 'Min',
  		labelMaximum: 'Max',
  		labelCount: 'Count',
  		labelMonths: ['month', 'months'],
  		// Report headers
  		labelsDetailed: ['Date', 'Amount', 'Tags', 'Description', 'Balance'],
  		labelsOverview: ['Period', 'Incoming', 'Expense', 'Partial', 'Balance'],
  		// Full Screen
  		helpFullScreen: 'Turns ON/OFF the Full Screen mode: only the report is shown, with no toolbar.',
  		// Storage
  		labelStorage: 'Storage',
  		labelReload: 'Reload',
  		labelLocateAppFolder: 'Where is your MoneyLog folder?',
  		helpStorage: 'Choose the storage for your data.',
  		helpReload: 'Reload only the data, keeping the current view untouched.',
  		// Report types
  		labelReports: 'Reports',
  		labelDaily: 'daily',
  		labelMonthly: 'monthly',
  		labelYearly: 'yearly',
  		helpReports: 'Daily, monthly and yearly reports, with charts, balance and totals.',
  		// Search
  		labelSearch: 'Search',
  		labelSearchRegex: 'regex',
  		labelSearchNegate: 'negate',
  		helpSearch: 'Filter the reports in real time, as you type.',
  		helpSearchRegex: 'Use regular expressions on the search field.',
  		helpSearchNegate: 'Remove the search results from the report.',
  		// View Options
  		labelViewOptions: 'View',
  		labelDateFrom: 'From',
  		labelDateUntil: 'Until',
  		labelMonthPartials: 'Monthly Partials',
  		labelValueFilter: 'Filter Values',
  		labelPositive: 'positive',
  		labelNegative: 'negative',
  		labelGreaterThan: 'greater than',
  		labelLessThan: 'less than',
  		helpViewOptions: 'Show/hide the view options.',
  		helpMonthPartials: 'Shows the monthly balance, with sums of your incoming and expenses on the period.',
  		helpValueFilter: 'See only positive or negative values, or greater/lesser than some value.',
  		// Tag Cloud
  		labelTagCloud: 'Tag Cloud',
  		labelTagCloudEmpty: '(no tag)',
  		labelTagCloudReset: 'Reset',
  		labelTagCloudGroup: 'Group selected tags',
  		helpTagCloud: 'Show/hide the tag cloud.',
  		helpTagCloudReset: 'Undo all the selections you have made in the Tag Cloud.',
  		helpTagCloudGroup: 'Only match if the entry has ALL the selected tags.',
  		// Tag Report
  		labelTagReportRelated: 'Hide related tags',
  		helpTagReportRelated: 'For entries with multiple tags, ignore the tags that are not explicitly selected in Tag Cloud.',
  		// Rows Summary
  		labelRowsSummaryReset: 'Reset',
  		helpRowsSummaryReset: 'Undo all the selections you have made in the report.',
  		// Editor
  		labelEditorOpen: 'Edit',
  		labelEditorClose: 'Close',
  		labelEditorCancel: 'Cancel',
  		labelEditorSave: 'Save',
  		helpEditorOpen: 'Open the editor, for you to add/remove/edit your data.',
  		helpEditorClose: 'Close the editor (without saving!)',
  		helpEditorCancel: 'Discard changes and close the editor.',
  		helpEditorSave: 'Save your data.',
  		// Error
  		errorInvalidData: 'Invalid data at line ',
  		errorNoFieldSeparator: 'No separator found:',
  		errorTooManySeparators: 'Too many separators',
  		errorInvalidDate: 'Invalid date:',
  		errorInvalidAmount: 'Invalid amount:',
  		errorNoLocalStorage: 'Sorry, your browser does not have localStorage support. %s will not work.',
  		errorRequirements: 'Minimum requirements:',
  		// Message
  		msgLoading: 'Loading %s...',
  		msgSaving: 'Saving...',
  		msgTypePassword: 'Type your password:',
  		msgWrongPassword: 'Wrong password.',
  		// App
  		appUrl: 'https://aurelio.net/soft/moneylog/',
  		appUrlOnline: 'https://aurelio.net/soft/moneylog/online/',
  		appDescription: 'Track your finances the practical way. Think simple!',
  		helpWebsite: 'Go to the MoneyLog website.'
  	},
  	pt: {
  		centsSeparator: ',',
  		thousandSeparator: '.',
  		dateFormat: 'd b Y',
  		dateFormatMonth: 'B Y',
  		dateFormatYear: 'Y',
  		monthNames: ['', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'],
  		labelNoData: 'Nenhum lançamento.',
  		labelTotal: 'Total',
  		labelAverage: 'Média',
  		labelMinimum: 'Mínimo',
  		labelMaximum: 'Máximo',
  		labelCount: 'Linhas',
  		labelMonths: ['mês', 'meses'],
  		// Report headers
  		labelsDetailed: ['Data', 'Valor', 'Tags', 'Descrição', 'Acumulado'],
  		labelsOverview: ['Período', 'Ganhos', 'Gastos', 'Saldo', 'Acumulado'],
  		// Full Screen
  		helpFullScreen: 'Liga/desliga o modo tela cheia: aparece somente o extrato, sem a barra de ferramentas.',
  		// Storage
  		labelStorage: 'Lançamentos',
  		labelReload: 'Recarregar',
  		labelLocateAppFolder: 'Cadê a pasta do MoneyLog?',
  		helpStorage: 'Escolha a fonte dos seus dados (lançamentos).',
  		helpReload: 'Recarrega somente os dados, sem perder as opções de visualização.',
  		// Report types
  		labelReports: 'Extratos',
  		labelDaily: 'diário',
  		labelMonthly: 'mensal',
  		labelYearly: 'anual',
  		helpReports: 'Extratos diário, mensal e anual, com gráficos, somatório, médias, mínimo, máximo e acumulado.',
  		// Search
  		labelSearch: 'Pesquisar',
  		labelSearchRegex: 'regex',
  		labelSearchNegate: 'excluir',
  		helpSearch: 'Filtra os relatórios em tempo real, de acordo com o que você digita.',
  		helpSearchRegex: 'Usa expressões regulares na caixa de pesquisa.',
  		helpSearchNegate: 'Inverte o filtro, escondendo as transações pesquisadas.',
  		// View Options
  		labelViewOptions: 'Visualizar',
  		labelDateFrom: 'De',
  		labelDateUntil: 'Até',
  		labelMonthPartials: 'Parciais Mensais',
  		labelValueFilter: 'Somente Valores',
  		labelPositive: 'positivos',
  		labelNegative: 'negativos',
  		labelGreaterThan: 'maiores que',
  		labelLessThan: 'menores que',
  		helpViewOptions: 'Mostra e esconde as opções de visualização.',
  		helpMonthPartials: 'Resumo do mês, com saldo mensal e acumulado, e totais de ganhos e gastos.',
  		helpValueFilter: 'Veja somente valores positivos, negativos ou maiores/menores que um valor específico.',
  		// Tag Cloud
  		labelTagCloud: 'Tags',
  		labelTagCloudEmpty: '(sem tag)',
  		labelTagCloudReset: 'Desmarcar todas',
  		labelTagCloudGroup: 'Combinar',
  		helpTagCloud: 'Mostra e esconde a nuvem de tags.',
  		helpTagCloudReset: 'Desmarca todas as tags que você selecionou, voltando ao estado inicial.',
  		helpTagCloudGroup: 'Cada lançamento deve possuir TODAS as tags selecionadas, simultaneamente.',
  		// Tag Report
  		labelTagReportRelated: 'Esconder as tags relacionadas',
  		helpTagReportRelated: 'Para os lançamentos com múltiplas tags, ignore as tags que não estejam explicitamente selecionadas.',
  		// Rows Summary
  		labelRowsSummaryReset: 'Limpar',
  		helpRowsSummaryReset: 'Desmarca todas as linhas que você selecionou no extrato.',
  		// Editor
  		labelEditorOpen: 'Editar',
  		labelEditorClose: 'Fechar',
  		labelEditorCancel: 'Cancelar',
  		labelEditorSave: 'Salvar',
  		helpEditorOpen: 'Abre o editor de lançamentos, para você incluir/remover/alterar os dados do extrato.',
  		helpEditorClose: 'Fecha o editor de lançamentos (apenas fecha, não salva o texto!).',
  		helpEditorCancel: 'Descarta as alterações e fecha o editor sem salvar nada.',
  		helpEditorSave: 'Salva os lançamentos que você alterou.',
  		// Error
  		errorInvalidData: 'Lançamento inválido na linha ',
  		errorNoFieldSeparator: 'Separador não encontrado:',
  		errorTooManySeparators: 'Há mais de 2 separadores',
  		errorInvalidDate: 'Data inválida:',
  		errorInvalidAmount: 'Valor inválido:',
  		errorNoLocalStorage: 'Ops, seu navegador não tem localStorage. O %s não vai funcionar.',
  		errorRequirements: 'Os requisitos mínimos são:',
  		// Message
  		msgLoading: 'Carregando %s...',
  		msgSaving: 'Salvando...',
  		msgTypePassword: 'Digite a sua senha:',
  		msgWrongPassword: 'Senha errada.',
  		// App
  		appUrl: 'https://aurelio.net/moneylog/beta/',
  		appUrlOnline: 'https://aurelio.net/moneylog/browser/app/',
  		appDescription: 'Acompanhe suas finanças de maneira simples e prática. Descomplique!',
  		helpWebsite: 'Visite o website do MoneyLog.'
  	},
  	ca: {
  		centsSeparator: ',',
  		thousandSeparator: '.',
  		dateFormat: 'd-m-y',
  		appDescription: 'Seguiu les vostres finances de manera pràctica. De forma simple!',
  		labelReports: 'Informes',
  		labelDaily: 'diari',
  		labelMonthly: 'mensual',
  		labelYearly: 'anual',
  		labelValueFilter: 'Valors Filtrats',
  		labelPositive: 'positiu',
  		labelNegative: 'negatiu',
  		labelGreaterThan: 'més gran que',
  		labelLessThan: 'més petit que',
  		labelMonthPartials: 'Mostra els Parcials Mensuals',
  		labelSearchRegex: 'regex',
  		labelSearchNegate: 'nega-ho',
  		labelReload: 'Carrega',
  		labelNoData: 'No hi ha dades.',
  		labelsDetailed: ['Data', 'Import', 'Etiquetes', 'Descripció', 'Balanç'],
  		labelsOverview: ['Període', 'Ingressos', 'Despeses', 'Parcials', 'Balanç'],
  		labelTotal: 'Total',
  		labelAverage: 'Mitja',
  		labelMinimum: 'Min',
  		labelMaximum: 'Max',
  		labelMonths: ['mes', 'mesos'],
  		labelTagCloudEmpty: 'BUIT',
  		labelTagCloudGroup: 'Etiquetes de grup triades',
  		errorInvalidData: 'Hi ha un adada no vàlida a la línia ',
  		errorNoFieldSeparator: 'No separator found:',
  		errorTooManySeparators: 'Hi ha masses separadors',
  		errorInvalidDate: 'La data no és vàlida:',
  		errorInvalidAmount: 'L\'import no és vàlid:',
  		msgLoading: 'S\'està carregant %s...',
  		helpReports: 'Informes: diari, mensual i anual, amb gràfics, balanç i totals.',
  		helpValueFilter: 'Mostra només els valors positius o negatius, o major / menor que un cert valor.',
  		helpMonthPartials: 'Mostra el saldo mensual, amb sumes dels vostres ingressos i despeses del període.',
  		helpSearch: 'Filtre dels informes en temps real, a mesura que escriu.',
  		helpSearchRegex: 'Utilitza expressions regulars en el camp de cerca.',
  		helpSearchNegate: 'Eliminar els resultats de cerca de l\'informe.',
  		helpReload: 'Actualitza només les dades, no la pàgina sencera.',
  		helpTagCloudGroup: 'Mostra només les entrades que tenen totes les etiquetes triades.'
  	},
  	es: {
  		centsSeparator: ',',
  		thousandSeparator: '.',
  		dateFormat: 'd/m/Y',
  		monthNames: ['', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Setiembre', 'Octubre', 'Noviembre', 'Diciembre'],
  		labelNoData: 'Sin movimientos.',
  		labelTotal: 'Total',
  		labelAverage: 'Promedio',
  		labelMinimum: 'Min',
  		labelMaximum: 'Max',
  		labelCount: 'Cuenta',
  		labelMonths: ['mes', 'meses'],
  		// Report headers
  		labelsDetailed: ['Fecha', 'Monto', 'Concepto', 'Descripción', 'Balance'],
  		labelsOverview: ['Período', 'Ingresos', 'Egresos', 'Saldo', 'Balance'],
  		// Full Screen
  		helpFullScreen: 'Activa/Desactiva el modo Pantalla Completa: solo se muestra el reporte, sin la barra de herramientas.',
  		// Datafile
  		labelReload: 'Recargar',
  		helpReload: 'Recargar solo la información y no la página entera.',
  		// Report types
  		labelReports: 'Reportes',
  		labelDaily: 'diario',
  		labelMonthly: 'mensual',
  		labelYearly: 'anual',
  		helpReports: 'Reporte diario, mensual o anual, con gráficas, promedios, balances y totales.',
  		// Search
  		labelSearch: 'Caja de búsqueda',
  		labelSearchRegex: 'regex',
  		labelSearchNegate: 'negar',
  		helpSearch: 'Evalue los reportes en tiempo real a medida que los va tipeando.',
  		helpSearchRegex: 'Usar expresiones regulares en la caja de búsqueda.',
  		helpSearchNegate: 'Invertir el criterio de búsqueda.',
  		// View Options
  		labelViewOptions: 'Ver',
  		labelDateFrom: 'Desde',
  		labelDateUntil: 'Hasta',
  		labelMonthPartials: 'Mostrar parcial mensual',
  		labelValueFilter: 'Mostrar solo montos',
  		labelPositive: 'positivos',
  		labelNegative: 'negativos',
  		labelGreaterThan: 'mayores a',
  		labelLessThan: 'menores a',
  		helpViewOptions: 'Mostrar/esconder opciones.',
  		helpMonthPartials: 'Vea el balance con el saldo mensual, el acumulado y totales de ingresos y egresos.',
  		helpValueFilter: 'Vea solo los montos positivos o los negativos, o los "mayores a" o los "menores a" cualquier valor dado.',
  		// Tag Cloud
  		labelTagCloud: 'Conceptos',
  		labelTagCloudEmpty: '(vacío)',
  		labelTagCloudReset: 'Limpiar',
  		labelTagCloudGroup: 'Unir conceptos seleccionados',
  		helpTagCloud: 'Mostrar/esconder Conceptos.',
  		helpTagCloudReset: 'Deshacer selección de Conceptos.',
  		helpTagCloudGroup: 'Mostrar solo los movimientos rotulados con los conceptos seleccionados.',
  		// Tag Report
  		labelTagReportRelated: 'Esconder conceptos relacionados',
  		helpTagReportRelated: 'Para opciones con vários conceptos, ignorar conceptos que no estén explícitamente seleccionados.',
  		// Rows Summary
  		labelRowsSummaryReset: 'Limpiar',
  		helpRowsSummaryReset: 'Deshacer selección en el reporte.',
  		// Editor
  		labelEditorOpen: 'Editar',
  		labelEditorClose: 'Cerrar',
  		labelEditorCancel: 'Cancelar',
  		labelEditorSave: 'Guardar',
  		helpEditorOpen: 'Abre el editor de movimientos para incluir/eliminar/editar los datos de un extracto.',
  		helpEditorClose: 'Cierra el editor de movimientos (solo cierra, no guarda los cambios!).',
  		helpEditorCancel: 'Cerrar el editor sin guardar los cambios.',
  		helpEditorSave: 'Guarda las movimientos editados.',
  		// Error
  		errorInvalidData: 'Información invalida en la línea ',
  		errorNoFieldSeparator: 'No se encontraron separadores:',
  		errorTooManySeparators: 'Demasiados separadores',
  		errorInvalidDate: 'Fecha invalida:',
  		errorInvalidAmount: 'Cantidad invalida:',
  		errorNoLocalStorage: 'Lo sentimos, su navegador no tiene soporte para localStorage. %s no funcionará.',
  		errorRequirements: 'Requisitos mínimos:',
  		// Message
  		msgLoading: 'Cargando %s...',
  		msgSaving: 'Guardando ...',
  		msgTypePassword: 'Ingrese su contraseña:',
  		msgWrongPassword: 'Contraseña incorrecta.',
  		// App
  		appDescription: 'Controle sus finanzas de forma práctica. Simple!',
  		helpWebsite: 'Ir al sitio web de MoneyLog.'
  	},
  	getLanguage: function (langCode) {
  		var phrase;
  		var defaultLang = this.defaultLanguage;
  
  		if (defaultLang !== langCode) {
  			if (this[langCode]) {
  				// check if all attributes from 'defaultLang' are in 'langCode'
  				// if not, copy from 'defaultLang'
  				for (phrase in this[defaultLang]) {
  					if (!this[langCode][phrase] || this[langCode][phrase].length === 0) {
  						this[langCode][phrase] = this[defaultLang][phrase];
  					}
  				}
  			} else {
  				// unknown lang, show default instead
  				langCode = defaultLang;
  			}
  		}
  		return this[langCode];
  	}
  };
  
  // -------------------------------------------------------------------
  // End of user config
  // -------------------------------------------------------------------
  
  
  // Global vars
  var appVersion = '6β';
  var appYear = '2014';  // only used in official releases
  var appName = 'MoneyLog';
  var appCommit = '';  // set by util/gen-* scripts
  var appRepository = 'https://github.com/aureliojargas/moneylog';
  var highlightRegex = null;
  var i18n = {};
  var rawData = '';
  var parsedData = [];
  var reportData = [];  // filtered by applyTags(filterData())
  var selectedRows = [];
  var savedDateRangeIndexes = [];  // used in the reload process
  var isFullScreen = false;
  var isBeta = (/β$/).test(appVersion);  // beta if version ends with 'β'
  
  // Widgets
  var TagSummary = {};
  var AboutWidget = {};
  
  // We have special rules for tiny screens (480px or less)
  var isMobile = (document.documentElement.clientWidth && document.documentElement.clientWidth < 481);
  
  // Change some defaults for the mobile version.
  // User can still overwrite those.
  if (isMobile) {
  	// Init with all widgets closed
  	initStorageWidgetOpen = true;  // exception
  	initViewWidgetOpen = false;
  	initTagCloudOpen = false;
  	// Note: Tag Summary is closed at Widget definition. Search for isMobile.
  	// Save horizontal space in report table
  	showRowCount = false;
  	showMiniBars = false;
  	// Use short month names in monthly report
  	i18nDatabase.pt.dateFormatMonth = i18nDatabase.pt.dateFormatMonth.replace('B', 'b');
  	i18nDatabase.en.dateFormatMonth = i18nDatabase.en.dateFormatMonth.replace('B', 'b');
  }
  
  
  // ------------------------------------------------------------------
  //                              PROTOTYPES
  // ------------------------------------------------------------------
  
  if (!Array.prototype.push) { // IE5...
  	Array.prototype.push = function (item) {
  		this[this.length] = item;
  		return this.length;
  	};
  }
  
  if (!Number.prototype.toFixed) { // IE5...
  	// eslint-disable-next-line no-unused-vars
  	Number.prototype.toFixed = function (n) { // precision hardcoded to 2
  		var k = (Math.round(this * 100) / 100).toString();
  		k += (k.indexOf('.') === -1) ? '.00' : '00';
  		return k.substring(0, k.indexOf('.') + 3);
  	};
  }
  
  // http://ejohn.org/blog/fast-javascript-maxmin/
  if (!Array.prototype.max) {
  	Array.prototype.max = function () {
  		return Math.max.apply(null, this);
  	};
  }
  if (!Array.prototype.min) {
  	Array.prototype.min = function () {
  		return Math.min.apply(null, this);
  	};
  }
  // http://snippets.dzone.com/posts/show/769
  // http://jsfromhell.com/array/sum
  if (!Array.prototype.sum) {
  	Array.prototype.sum = function () {
  		var i, sum;
  		// for (i = 0, sum = 0; i < this.length ; sum += this[i++]);
  		for (sum = 0, i = this.length; i; sum += this[--i]);  // eslint-disable-line curly, no-plusplus
  		return sum;
  	};
  }
  if (!Array.prototype.avg) {
  	Array.prototype.avg = function () {
  		return this.sum() / this.length;
  	};
  }
  // [[0,1,2], [3,4,5], [6,7,8]].getColumn(1) -> [1, 4, 7]
  Array.prototype.getColumn = function (n) {
  	var i, leni;
  	var results = [];
  	for (i = 0, leni = this.length; i < leni; i++) {
  		results.push(this[i][n]);
  	}
  	return results;
  };
  
  String.prototype.lstrip = function () {
  	return this.replace(/^\s+/, '');
  };
  String.prototype.rstrip = function () {
  	return this.replace(/\s+$/, '');
  };
  String.prototype.strip = function () {
  	return this.lstrip().rstrip();
  };
  
  String.prototype.replaceAll = function (from, to) {
  	return this.split(from).join(to);
  	// http://stackoverflow.com/a/542305
  };
  
  String.prototype.unacccent = function () {
  	if (!this.match(/[^a-z0-9 ]/)) { // no accented char
  		return this;
  	}
  	return this
  		.replace(/[àáâãäå]/g, 'a')
  		.replace(/[èéêë]/g,   'e')
  		.replace(/[ìíîï]/g,   'i')
  		.replace(/[òóôõö]/g,  'o')
  		.replace(/[ùúûü]/g,   'u')
  		.replace(/[ýÿ]/g,     'y')
  		.replace(/ç/g,        'c')
  		.replace(/ñ/g,        'n');
  };
  
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith#polyfill
  if (!String.prototype.endsWith) {
  	String.prototype.endsWith = function (search, thisLen) {
  		if (thisLen === undefined || thisLen > this.length) {
  			thisLen = this.length;
  		}
  		return this.substring(thisLen - search.length, thisLen) === search;
  	};
  }
  
  Array.prototype.clone = function () {
  	return [].concat(this);
  };
  
  Array.prototype.hasItem = function (item) {
  	var i, leni;
  	for (i = 0, leni = this.length; i < leni; i++) {
  		if (item == this[i]) {  // using ==
  			return true;
  		}
  	}
  	return false;
  };
  
  Array.prototype.hasArrayItem = function (arr) {
  	var i, leni, items;
  	items = arr.clone();
  	for (i = 0, leni = items.length; i < leni; i++) {
  		if (this.hasItem(items[i])) {
  			return true;
  		}
  	}
  	return false;
  };
  
  Array.prototype.hasAllArrayItems = function (arr) {
  	var i, leni, items;
  	items = arr.clone();
  	leni = items.length;
  	if (leni === 0) {  // empty arr
  		return false;
  	}
  	for (i = 0; i < leni; i++) {
  		if (!this.hasItem(items[i])) {
  			return false;
  		}
  	}
  	return true;
  };
  
  // http://www.shamasis.net/2009/09/fast-algorithm-to-find-unique-items-in-javascript-array/
  // I choose the "classic" version, it's more reliable.
  Array.prototype.unique = function () {
  	var i, j;
  	var a = [];
  	var l = this.length;
  	for (i = 0; i < l; i++) {
  		for (j = i + 1; j < l; j++) {
  			if (this[i] === this[j]) { j = ++i; }  // eslint-disable-line no-plusplus
  		}
  		a.push(this[i]);
  	}
  	return a;
  };
  
  Array.prototype.removePattern = function (patt, n) { // n = number of removes
  	var i, leni;
  	var count = 0;
  	var cleaned = [];
  
  	if (!n) {
  		n = Infinity;
  	}
  
  	for (i = 0, leni = this.length; i < leni; i++) {
  		if (this[i] == patt && count < n) { // must remove
  			count += 1;
  		} else {
  			cleaned.push(this[i]);          // otherwise save
  		}
  	}
  	return cleaned;
  };
  
  // https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/indexOf
  if (!Array.prototype.indexOf) {
  	Array.prototype.indexOf = function (searchElement /* , fromIndex */) {
  		'use strict';
  		var t, len, n, k;
  		if (this == null) {  // eslint-disable-line no-eq-null
  			throw new TypeError();
  		}
  		t = Object(this);
  		len = t.length >>> 0;  // eslint-disable-line no-bitwise
  		if (len === 0) {
  			return -1;
  		}
  		n = 0;
  		if (arguments.length > 0) {
  			n = Number(arguments[1]);
  			// eslint-disable-next-line no-self-compare
  			if (n != n) { // shortcut for verifying if it's NaN
  				n = 0;
  			} else if (n != 0 && n != Infinity && n != -Infinity) {
  				n = (n > 0 || -1) * Math.floor(Math.abs(n));
  			}
  		}
  		if (n >= len) {
  			return -1;
  		}
  		k = n >= 0 ? n : Math.max(len - Math.abs(n), 0);
  		for (; k < len; k++) {
  			if (k in t && t[k] === searchElement) {
  				return k;
  			}
  		}
  		return -1;
  	};
  }
  
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter#polyfill
  /* eslint-disable */
  if (!Array.prototype.filter)
  	Array.prototype.filter = function(func, thisArg) {
  		'use strict';
  		if ( ! ((typeof func === 'Function' || typeof func === 'function') && this) )
  			throw new TypeError();
  
  		var len = this.length >>> 0,
  			res = new Array(len), // preallocate array
  			t = this, c = 0, i = -1;
  		if (thisArg === undefined)
  			while (++i !== len)
  				// checks to see if the key was set
  				if (i in this)
  					if (func(t[i], i, t))
  						res[c++] = t[i];
  		else
  			while (++i !== len)
  				// checks to see if the key was set
  				if (i in this)
  					if (func.call(thisArg, t[i], i, t))
  						res[c++] = t[i];
  
  		res.length = c; // shrink down array to proper size
  		return res;
  	};
  /* eslint-enable */
  
  RegExp.escape = function (str) {
  	var specials = new RegExp('[.*+?|\\^$()\\[\\]{}\\\\]', 'g');
  	return str.replace(specials, '\\$&');
  };
  
  // Date helpers
  Date.prototype.getYearML = function () {  // Returns as string
  	return this.getFullYear().toString();
  };
  Date.prototype.getMonthML = function () {  // Returns as string and zero padded
  	var m = this.getMonth() + 1;  // zero based
  	return (m < 10) ? '0' + m : m.toString();
  };
  Date.prototype.getDateML = function () {  // Returns as string and zero padded
  	var d = this.getDate();
  	return (d < 10) ? '0' + d : d.toString();
  };
  Date.prototype.getFullMonthML = function () {  // Returns "YYYY-MM"
  	return this.getYearML() + '-' + this.getMonthML();
  };
  Date.prototype.getMonthName = function () {
  	return i18n.monthNames[this.getMonth() + 1];  // zero based
  };
  Date.prototype.getMonthShortName = function () {
  	return this.getMonthName().slice(0, 3);
  };
  Date.prototype.getMonthDays = function () {  // How many days in this month?
  	// http://stackoverflow.com/a/1185804
  	var d = new Date(this.getFullYear(), this.getMonth() + 1, 0);
  	return d.getDate();
  };
  Date.prototype.fromML = function (str) {  // str: YYYY-MM-DD
  	// In MoneyLog we can have special dates with days like: 00 and 99.
  	//   2000-01-00 turns to 2000-01-01 (first day)
  	//   2000-01-99 turns to 2000-01-31 (last day)
  	// So we always create the date with day=1, then set the day.
  	var y, m, d, max;
  	y = parseInt(str.slice(0,  4), 10);
  	m = parseInt(str.slice(5,  7), 10) - 1;   // month is zero-based
  	d = parseInt(str.slice(8, 10), 10) || 1;  // day zero -> day 1
  	this.setFullYear(y, m, 1);                // create date
  	max = this.getMonthDays();
  	this.setDate((d > max) ? max : d);        // huge day -> last day
  };
  Date.prototype.toML = function () {  // Returns "YYYY-MM-DD"
  	return this.getYearML() + '-' + this.getMonthML() + '-' + this.getDateML();
  };
  Date.prototype.format = function (fmt) {
  	// Available tokens (i.e. for 1999-12-31):
  	// Y=1999, y=99, m=12, d=31, b=Dec, B=December
  
  	var that = this;
  	// http://code.google.com/p/datejs/source/browse/trunk/src/core.js?spec=svn197&r=194#810
  	return fmt.replace(
  		/(\\)?[YymdBb]/g,
  		function (m) {
  			// Ignore escaped chars as \Y, \b, ...
  			if (m.charAt(0) === '\\') {
  				return m.replace('\\', '');
  			}
  			switch (m) {
  			case 'Y':
  				return that.getYearML() || 'Y';
  			case 'y':
  				return that.getYearML().slice(2, 4) || 'y';
  			case 'm':
  				return that.getMonthML() || 'm';
  			case 'd':
  				return that.getDateML() || 'd';
  			case 'B':
  				return that.getMonthName() || 'B';
  			case 'b':
  				return that.getMonthShortName() || 'b';
  			default:
  				return m;
  			}
  		}
  	);
  };
  Date.prototype.setMonthOffset = function (n) {  // negative n is ok
  	// Beware: 2010-01-31 + 1 = 2010-03-03
  	// Set day to 1 to make month-related operations
  	this.setMonth(this.getMonth() + (n || 0));
  };
  String.prototype.toDate = function () {
  	var z = new Date();
  	z.fromML(this);
  	return z;
  };
  
  
  // ------------------------------------------------------------------
  //                              TOOLS
  // ------------------------------------------------------------------
  
  function showError(title, msg) {
  	document.getElementById('error').style.display = 'block';
  	document.getElementById('error').innerHTML = '<h2>' + title + '</h2>' + msg;
  }
  
  function invalidData(lineno, message) {
  	// eslint-disable-next-line no-alert
  	alert(i18n.errorInvalidData + lineno + '\n' + message.replace(/\t/g, '<TAB>'));
  }
  
  // Inspired by http://stackoverflow.com/a/979325
  function sortByIndex(index, type) {
  	switch (type) {
  	case 'n':
  		// number: int or float
  		return function (a, b) {
  			return a[index] - b[index];
  		};
  	case 'd':
  		// date: 'YYYY-MM-DD'
  		return function (a, b) {
  			a = a[index];
  			b = b[index];
  			return ((a < b) ? -1 : ((a > b) ? 1 : 0));
  		};
  	case 't':
  		// tags: ['tag1', 'tag2', ...]
  		return function (a, b) {
  			a = a[index].join(',').toLowerCase().unacccent();
  			b = b[index].join(',').toLowerCase().unacccent();
  			return ((a < b) ? -1 : ((a > b) ? 1 : 0));
  		};
  	default:
  		// string: 'á'
  		return function (a, b) {
  			a = a[index].toLowerCase().unacccent();
  			b = b[index].toLowerCase().unacccent();
  			return ((a < b) ? -1 : ((a > b) ? 1 : 0));
  		};
  	}
  }
  
  function sortIgnoreCase(a, b) {
  	try {
  		a = a.toLowerCase().unacccent();
  		b = b.toLowerCase().unacccent();
  	} catch (e1) { }
  	try { // IE6...
  		if (a < b) {
  			return -1;
  		} else if (a > b) {
  			return 1;
  		}
  	} catch (e2) { }
  	return 0;
  }
  
  function getCurrentDate() {
  	return new Date().toML();
  }
  
  function formatDate(date, fmt) {
  	// date: YYYY-MM-DD, fmt: see Date.prototype.format()
  	return date.toDate().format(fmt);
  }
  
  function formatReportDate(date) {
  	// This key controls if the report date should be formatted
  	if (!showLocaleDate) {
  		return date;  // nothing to do
  	}
  
  	switch (date.length) {
  	case 10:  // YYYY-MM-DD
  		return formatDate(date, i18n.dateFormat);
  	case 7:  // YYYY-MM
  		return formatDate(date + '-01', i18n.dateFormatMonth);
  	case 4:  // YYYY
  		return formatDate(date + '-01-01', i18n.dateFormatYear);
  	default:  // unknown format
  		return date;
  	}
  }
  
  function addMonths(yyyymmdd, n) {
  	// It's all about months, not taking days into account.
  	// addMonths("2010-01-31", 1)  -> "2010-02-31"
  	// addMonths("2010-01-31", -2) -> "2009-11-31"
  
  	var z = yyyymmdd.toDate();
  	z.setDate(1);  // set day 1st
  	z.setMonthOffset(n);  // add
  	return z.toML().slice(0, 8) + yyyymmdd.slice(8, 10);  // restore original day
  }
  
  function getPastMonth(months) {
  	// months=0 means current month
  	// Returns: YYYY-MM-00
  	return formatDate(addMonths(getCurrentDate(), -months), 'Y-m-00');
  }
  
  function getDataUniqueDates(periodType) {  // periodType: d, m, y
  	// Returns array with unique (dates|months|years) in parsedData.
  	// Note: parsedData is already sorted ASC by date.
  	var i, leni, theData, item, last, slices;
  	var results = [];
  
  	theData = parsedData.clone();
  	slices = {y: 4, m: 7, d: 10};
  	for (i = 0, leni = theData.length; i < leni; i++) {
  		item = theData[i][0].slice(0, slices[periodType]);  // get date
  		if (item !== last) {
  			results.push(item);
  			last = item;
  		}
  	}
  	return results;
  }
  
  function getYearRange(date1, date2) {
  	// Given two dates, returns array with all the years between them, inclusive.
  	// Dates are strings formatted as YYYY-MM-DD.
  	var y, y1, y2;
  	var results = [];
  
  	if (date1 > date2) {  // no deal
  		return results;
  	}
  
  	y1 = date1.toDate().getFullYear();
  	y2 = date2.toDate().getFullYear();
  
  	for (y = y1; y <= y2; y++) {  // from year1 to year2, inclusive
  		results.push(y.toString());
  	}
  	return results;
  }
  
  function getMonthRange(date1, date2) {
  	// Given two dates, returns array with all the months between them, inclusive.
  	// Dates are strings formatted as YYYY-MM-DD.
  	var y, y1, y2, m, m1, m2, ini, end;
  	var results = [];
  
  	if (date1 > date2) {  // no deal
  		return results;
  	}
  
  	// str to Date
  	date1 = date1.toDate();
  	date2 = date2.toDate();
  
  	y1 = date1.getFullYear();
  	y2 = date2.getFullYear();
  	m1 = date1.getMonth() + 1;  // zero based
  	m2 = date2.getMonth() + 1;
  
  	for (y = y1; y <= y2; y++) {  // from year1 to year2, inclusive
  
  		// First year: start from month1
  		// Last year: end in month2
  		ini = (y === y1) ? m1 : 1;
  		end = (y === y2) ? m2 : 12;
  
  		for (m = ini; m <= end; m++) {  // months loop
  			results.push(y + '-' + ((m < 10) ? '0' + m : m));  // add leading zero
  		}
  	}
  	return results;
  }
  
  function prettyFloat(num, noHtml) {
  	var myClass = (num < 0) ? 'neg' : 'pos';
  	num = num.toFixed(2).replace('.', i18n.centsSeparator);
  	while (i18n.thousandSeparator && num.search(/[0-9]{4}/) > -1) {
  		num = num.replace(
  			/([0-9])([0-9]{3})([^0-9])/,
  			'$1' + i18n.thousandSeparator + '$2$3'
  		);
  	}
  	return (noHtml) ? num : '<span class="' + myClass + '">' + num + '</span>';
  }
  
  function prettyFloatUndo(str) {
  	str = str.replace(/<[^>]*>/g, '');              // remove HTML
  	str = str.replace(i18n.thousandSeparator, '');  // remove separator chars
  	str = str.replace(i18n.centsSeparator, '.');    // restore floating point
  	return parseFloat(str);                         // str2float
  }
  
  function prettyBarLabel(n) { // Convert float to short strings: 1k2, 1m2, ...
  	var negative;
  
  	// Remove the signal for easier calculation
  	negative = (n < 0);
  	if (negative) {
  		n = Math.abs(n);
  	}
  
  	// Less than 1,000: discard the cents and make the value integer
  	if (n < 1000) {
  		n = n.toString().replace(/\.(\d).*/, ''); // 123.45 > 123
  
  	// From 1,000 to 999,999: discard the cents, then round the last 3 digits to 1 digit
  	} else if (n >= 1000 && n < 1000000) {
  		n = (n / 1000).toString();
  		if (n.indexOf('.') !== -1) {
  			n = n.replace(/\.(\d).*/, 'k$1');     // 1234.45 > 1k2
  		} else {
  			n = n + 'k';                          // 1000 > 1k
  		}
  
  	// From 1,000,000: discard the cents, then round the last 6 digits to 1 digit
  	} else if (n >= 1000000) {
  		n = (n / 1000000).toString();
  		if (n.indexOf('.') !== -1) {
  			n = n.replace(/\.(\d).*/, 'm$1');     // 1234567.89 > 1m2
  		} else {
  			n = n + 'm';                          // 1000000 > 1m
  		}
  	}
  
  	// Maybe we have a (undesired) trailing zero?
  	n = n.replace(/([km])0/, '$1'); // 2k0 > 2k
  
  	// Restore signal (if needed)
  	if (negative) {
  		n = '-' + n;
  	}
  
  	return n;
  }
  
  function array2ul(a) {
  	return '<ul><li>' + a.join('</li><li>') + '</li></ul>';
  }
  
  function wrapme(tag, text) {
  	return '<' + tag + '>' + text + '</' + tag + '>';
  }
  
  function linkme(url, text) {
  	return '<a href="' + url + '">' + text + '</a>';
  }
  
  function selectOptionByText(combo, optionText) {
  	var i;
  	for (i = 0; i < combo.options.length; i++) {
  		if (combo.options[i].text === optionText) {
  			combo.selectedIndex = i;
  			break;
  		}
  	}
  }
  
  // DOM Class helpers
  function getClass(el) {
  	if (el.className) {
  		return el.className.strip().split(' ');
  	} else {
  		return [];
  	}
  }
  
  function setClass(el, arr) {
  	el.className = arr.join(' ');
  }
  
  function hasClass(el, klass) {
  	return getClass(el).hasItem(klass);
  }
  
  function addClass(el, klass) {
  	var arr = getClass(el);
  	if (!arr.hasItem(klass)) {
  		arr.push(klass);
  		setClass(el, arr);
  	}
  }
  
  function removeClass(el, klass) {
  	var arr = getClass(el);
  	if (arr.hasItem(klass)) {
  		setClass(el, arr.removePattern(klass));
  	}
  }
  
  function toggleClass(el, klass) {
  	var arr = getClass(el);
  	if (arr.hasItem(klass)) {
  		setClass(el, arr.removePattern(klass));
  		return false;
  	} else {
  		arr.push(klass);
  		setClass(el, arr);
  		return true;
  	}
  }
  
  // Compose URL query string from a data object
  // https://stackoverflow.com/a/111545/1623438
  function encodeQueryData(data) {
  	var name;
  	var result = [];
  	for (name in data) {
  		if (Object.prototype.hasOwnProperty.call(data, name)) {
  			result.push(encodeURIComponent(name) + '=' + encodeURIComponent(data[name]));
  		}
  	}
  	return result.join('&');
  }
  
  // DO NOT append elements using el.innerHTML += "foo";
  // This is extremely inefficient and removes previous onclick handlers.
  // http://stackoverflow.com/a/1387475
  // http://stackoverflow.com/a/6304927
  //
  function appendHTML(el, html) {
  	var tempDiv = document.createElement('div');
  	tempDiv.innerHTML = html;
  
  	while (tempDiv.firstChild) {
  		el.appendChild(tempDiv.firstChild);
  	}
  }
  
  // http://unixpapa.com/js/dyna.html
  // https://stackoverflow.com/a/22534608/1623438
  function addScript(scriptUrl, callback) {
  	var head = document.getElementsByTagName('head')[0];
  	var script = document.createElement('script');
  	script.type = 'text/javascript';
  	script.onload = callback;
  	script.src = scriptUrl;
  	head.appendChild(script);
  }
  
  // CSS stylesheet add/remove
  //
  // http://stackoverflow.com/q/524696
  // http://dev.opera.com/articles/view/dynamic-style-css-javascript/
  //
  function addStyleSheet(elementId, contents) {
  	var head, style, rules;
  
  	head = document.getElementsByTagName('head')[0];
  	style = document.createElement('style');
  	rules = document.createTextNode(contents);
  
  	style.id = elementId;
  	style.type = 'text/css';
  	if (style.styleSheet) {
  		style.styleSheet.cssText = rules.nodeValue;
  	} else {
  		style.appendChild(rules);
  	}
  	head.appendChild(style);
  }
  
  function removeStyleSheet(elementId) {
  	var el = document.getElementById(elementId);
  	el.parentNode.removeChild(el);
  }
  
  function drawChart(values, labels) {
  	var i, leni, label, height, value, valueShort, roof, chart, chartData, barType;
  
  	chart = [];
  	chartData = [];
  
  	// Get the maximum absolute value
  	// That will be the highest chart bar, using 100px
  	// Other bars will be lower, proportionally
  	roof = Math.max(
  		Math.abs(values.max()),
  		Math.abs(values.min())
  	);
  
  	// Calculate and format chart data
  	for (i = 0, leni = values.length; i < leni; i++) {
  
  		// This bar height = percentage of roof (but in pixels)
  		height = parseInt(Math.abs(values[i]) * 100 / roof, 10);
  
  		// Format the float value. For example 1234.56 turns to...
  		value = prettyFloat(values[i], true);   // 1.234,56
  		valueShort = prettyBarLabel(values[i]); // 1k2
  
  		// Date: 2010-12 -> 2010<br>12
  		label = labels[i].replace('-', '<br>');
  
  		chartData.push([label, height, value, valueShort]);
  	}
  
  	// Compose the chart table
  	chart.push('<table class="chart">');
  
  	// First line: the bars (label at top)
  	chart.push('<tr>');
  
  	// Compose each table column (== chart bar)
  	for (i = 0, leni = chartData.length; i < leni; i++) {
  
  		chart.push('<td class="bar">');
  
  		// Will show bar value at top?
  		if (showChartBarLabel) {
  			// Showing short value. Real value is stored as a tooltip.
  			chart.push('<span class="label" title="' + chartData[i][2] + '">' + chartData[i][3] + '</span>');
  		}
  
  		// The bar, a painted div with exact height
  		barType = (chartData[i][3].substring(0, 1) === '-') ? 'negbar' : 'posbar';
  		chart.push('<div class="bar ' + barType + '" style="height:' + chartData[i][1] + 'px"></div>');
  
  		chart.push('</td>');
  	}
  	chart.push('</tr>');
  
  	// Second line: the labels
  	chart.push('<tr class="label">');
  	for (i = 0, leni = chartData.length; i < leni; i++) {
  		chart.push('<td>' + chartData[i][0] + '</td>');
  	}
  	chart.push('</tr>');
  
  	// And we're done
  	chart.push('</table>');
  	chart = chart.join('\n');
  
  	return chart;
  }
  
  function computeTotals(arr) {  // arr = [1,2,3,4,5]
  	var i, leni, n;
  	var o = {};
  
  	if (!arr.length) { return arr; }
  
  	o.min = 0;
  	o.max = 0;
  	o.sum = 0;
  	o.average = 0;
  	o.sumPositive = 0;
  	o.sumNegative = 0;
  	o.balance = [];
  
  	for (i = 0, leni = arr.length; i < leni; i++) {
  		n = arr[i];
  
  		// sum
  		if (n < 0) {
  			o.sumNegative += n;
  		} else {
  			o.sumPositive += n;
  		}
  		o.sum += n;
  
  		// balance
  		if (i === 0) {
  			o.balance.push(n);
  		} else {
  			o.balance.push(o.balance[o.balance.length - 1] + n);
  		}
  
  		// min/max
  		if (i === 0) {
  			// First value, store it as max and min
  			o.min = n;
  			o.max = n;
  		} else {
  			o.min = (n < o.min) ? n : o.min;
  			o.max = (n > o.max) ? n : o.max;
  		}
  	}
  	o.average = o.sum / arr.length;
  	o.avg = o.average;  // alias
  	return o;
  }
  
  
  // ------------------------------------------------------------------
  //                         TAGS
  // ------------------------------------------------------------------
  
  function createTagCloud(names) {
  	// Create all the <a> elements for the Tag Cloud
  	var i, leni;
  	var results = [];
  
  	for (i = 0, leni = names.length; i < leni; i++) {
  		results.push('<a class="trigger unselected" href="#" onClick="return tagClicked(this);">' + names[i] + '</a>');
  	}
  
  	document.getElementById('tag-cloud-tags').innerHTML = results.join('\n');
  }
  
  function resetTagCloud() {
  	var i, leni, els, el;
  
  	els = document.getElementById('tag-cloud-tags').getElementsByTagName('a');
  	for (i = 0, leni = els.length; i < leni; i++) {
  		el = els[i];
  
  		removeClass(el, 'selected');
  		removeClass(el, 'excluded');
  		addClass(el, 'unselected');
  	}
  
  	// The calling checkbox acts like a button, with temporary ON state
  	this.checked = false;
  
  	showReport();
  }
  
  function updateTagCloud(visibleTags) {
  	// Show/hide the Tag Cloud elements, and set classes
  	var i, leni, el, els;
  
  	els = document.getElementById('tag-cloud-tags').getElementsByTagName('a');
  	for (i = 0, leni = els.length; i < leni; i++) {
  		el = els[i];
  
  		if (visibleTags.hasItem(el.innerHTML)) {
  			if (el.style.display === 'none') {
  				// unhide element
  				el.style.display = '';
  			}
  		} else {
  			if (el.style.display !== 'none') {
  				// hide element and reset classes
  				el.style.display = 'none';
  				removeClass(el, 'selected');
  				removeClass(el, 'excluded');
  				addClass(el, 'unselected');
  			}
  		}
  	}
  }
  
  function getSelectedTags() {
  	// Get currently selected tags (from interface)
  	var i, leni, el, els;
  	var results = [];
  
  	els = document.getElementById('tag-cloud-tags').getElementsByTagName('a');
  	for (i = 0, leni = els.length; i < leni; i++) {
  		el = els[i];
  
  		if (hasClass(el, 'selected')) {
  			results.push(el.innerHTML);
  		}
  	}
  	return results;
  }
  
  function getExcludedTags() {
  	// Get currently excluded tags (from interface)
  	var i, leni, el, els;
  	var results = [];
  
  	els = document.getElementById('tag-cloud-tags').getElementsByTagName('a');
  	for (i = 0, leni = els.length; i < leni; i++) {
  		el = els[i];
  
  		if (hasClass(el, 'excluded')) {
  			results.push(el.innerHTML);
  		}
  	}
  	return results;
  }
  
  function setSelectedTags(tags) {
  	// Force select some tags, used at start up or reload
  	var i, leni, el, els;
  
  	els = document.getElementById('tag-cloud-tags').getElementsByTagName('a');
  	for (i = 0, leni = els.length; i < leni; i++) {
  		el = els[i];
  
  		if (tags.hasItem(el.innerHTML)) {
  			// select
  			removeClass(el, 'unselected');
  			removeClass(el, 'excluded');
  			addClass(el, 'selected');
  			// force show
  			el.style.display = '';
  		}
  	}
  }
  
  function setExcludedTags(tags) {
  	// Force select some tags, used at start up or reload
  	var i, leni, el, els;
  
  	els = document.getElementById('tag-cloud-tags').getElementsByTagName('a');
  	for (i = 0, leni = els.length; i < leni; i++) {
  		el = els[i];
  
  		if (tags.hasItem(el.innerHTML)) {
  			// select
  			removeClass(el, 'unselected');
  			removeClass(el, 'selected');
  			addClass(el, 'excluded');
  			// force show
  			el.style.display = '';
  		}
  	}
  }
  
  function tagClicked(el) {
  	// Swap class: unselected -> selected -> excluded -> unselected
  	if (hasClass(el, 'unselected')) {
  		removeClass(el, 'unselected');
  		addClass(el, 'selected');
  
  	} else if (hasClass(el, 'selected')) {
  		removeClass(el, 'selected');
  		addClass(el, 'excluded');
  
  	} else if (hasClass(el, 'excluded')) {
  		removeClass(el, 'excluded');
  		addClass(el, 'unselected');
  	}
  	// Update report
  	showReport();
  	return false;  // cancel link action
  }
  
  
  // ------------------------------------------------------------------
  //                         REPORT HELPERS
  // ------------------------------------------------------------------
  
  function getMiniBar(pos, neg) {
  	var roof, posPx, negPx, posLabel, negLabel, posMargin, negMargin, labels, labelTemplate;
  
  	// The total amount for this period
  	roof = pos + Math.abs(neg);
  	// The size of each bar (pixels)
  	posPx = parseInt(pos * miniBarWidth / roof, 10);
  	negPx = miniBarWidth - posPx;
  	// The percentage of each bar (%)
  	posLabel = parseInt(pos * 100 / roof, 10);
  	negLabel = 100 - posLabel;
  
  	// Labels
  	labels = '';
  	if (showMiniBarsLabels) {
  		labelTemplate = '<span class="label" style="margin-left:-{margin}">{label}</span>';
  
  		// The label positioning (negative margin)
  		posMargin = (miniBarWidth - 2) + 'px';                // full bar width
  		negMargin = ((negLabel == 100) ? '2' : '1.4') + 'em'; // 1.4em or 2em
  
  		// Hide label when it's zero
  		if (posLabel > 0) {
  			labels += labelTemplate.replace('{margin}', posMargin).replace('{label}', posLabel);
  		}
  		if (negLabel > 0) {
  			labels += labelTemplate.replace('{margin}', negMargin).replace('{label}', negLabel);
  		}
  	}
  
  	// Kinda complicated layout
  	// DIVs and SPANs are float:left to be aligned in order, at the same line
  	// SPANs (labels) are moved left (negative margin) precisely to "enter" the bars
  	// The TD width *must* be set to accomodate these tricks
  	//
  	return '<td class="minibar" style="width:' + (miniBarWidth + 2) + 'px">' +
  		'<div class="minibar posbar" style="width:' + posPx + 'px">&nbsp;</div>' +
  		'<div class="minibar negbar" style="width:' + negPx + 'px">&nbsp;</div>' +
  		labels +
  		'</td>';
  }
  
  function getTotalsRow(total, monthTotal, monthNeg, monthPos) {
  	var partial = [];
  	var results = [];
  
  	partial.push('<table class="posneg number"><tr>');
  	partial.push('<td> +');
  	partial.push(prettyFloat(monthPos, true) + '<br>');
  	partial.push(prettyFloat(monthNeg, true));
  	partial.push('</td></tr></table>');
  	partial = partial.join('');
  
  	// Show month total?
  	if (monthTotal !== '') {
  		monthTotal = '<span class="arrow">→</span>' + prettyFloat(monthTotal);
  	}
  
  	results.push('<tr class="totals">');
  	if (showRowCount) {
  		results.push('<td class="row-count"></td>');
  	}
  	results.push('<td></td>');
  	results.push('<td>' + partial + '</td>');
  	results.push('<td class="monthtotal" colspan="2">' + monthTotal + '</td>');
  	if (showBalance) {
  		results.push('<td class="number">' + prettyFloat(total) + '</td>');
  	}
  	results.push('</tr>');
  
  	return results.join('');
  }
  
  function getOverviewRow(theMonth, monthPos, monthNeg, monthTotal, theTotal, rowCount) {
  	var theRow = [];
  
  	theRow.push((theMonth <= getCurrentDate().slice(0, 7)) ?
  		'<tr onClick="toggleRowHighlight(this)">' :
  		'<tr onClick="toggleRowHighlight(this)" class="future">');
  	if (showRowCount) {
  		theRow.push('<td class="row-count">' + rowCount + '</td>');
  	}
  	theRow.push('<td>' + formatReportDate(theMonth) + '</td>');
  	theRow.push('<td class="number">' + prettyFloat(monthPos) + '</td>');
  	theRow.push('<td class="number">' + prettyFloat(monthNeg) + '</td>');
  	theRow.push('<td class="number">' + prettyFloat(monthTotal) + '</td>');
  	if (showBalance) {
  		theRow.push('<td class="number">' + prettyFloat(theTotal) + '</td>');
  	}
  
  	if (showMiniBars) {
  		theRow.push(getMiniBar(monthPos, monthNeg));
  	}
  
  	theRow.push('</tr>');
  	return theRow.join('\n');
  }
  
  function getOverviewTotalsRow(label, n1, n2, n3, extraClass) {
  	var theRow = [];
  	if (extraClass) {
  		theRow.push('<tr class="totals ' + extraClass + '">');
  	} else {
  		theRow.push('<tr class="totals">');
  	}
  	if (showRowCount) {
  		theRow.push('<td class="row-count"></td>');
  	}
  	theRow.push('<td class="rowlabel">' + label + '</td>');
  	theRow.push('<td class="number">' + prettyFloat(n1) + '</td>');
  	theRow.push('<td class="number">' + prettyFloat(n2) + '</td>');
  	theRow.push('<td class="number">' + prettyFloat(n3) + '</td>');
  	if (showBalance) {
  		theRow.push('<td></td>');
  	}
  	theRow.push('</tr>');
  	return theRow.join('\n');
  }
  
  
  // ------------------------------------------------------------------
  //                       INTERFACE UPDATE
  // ------------------------------------------------------------------
  
  function populateChartColsCombo() {
  	var el = document.getElementById('chart-selector');
  	el.options[0] = new Option(i18n.labelsOverview[1], 1);  // Incoming
  	el.options[1] = new Option(i18n.labelsOverview[2], 2);  // Expense
  	el.options[2] = new Option(i18n.labelsOverview[3], 3);  // Partial
  	if (showBalance) {
  		el.options[3] = new Option(i18n.labelsOverview[4], 4);  // Balance
  	}
  }
  
  function populateRowsSummaryCombo() {
  	var el = document.getElementById('rows-summary-index');
  	el.options[0] = new Option(i18n.labelsOverview[1], 2);  // Incoming
  	el.options[1] = new Option(i18n.labelsOverview[2], 3);  // Expense
  	el.options[2] = new Option(i18n.labelsOverview[3], 4);  // Partial
  }
  
  function populateDateRangeCombos() {
  	var el1, el2, i, leni, my, range, fmt, offset1, offset2, index1, index2;
  
  	el1 = document.getElementById('opt-date-1-month-combo');
  	el2 = document.getElementById('opt-date-2-month-combo');
  	offset1 = initMonthOffsetFrom;
  	offset2 = initMonthOffsetUntil;
  	fmt = 'Y-m';
  	range = getDataUniqueDates('m');
  
  	// Let's choose which items to select by default.
  	//
  	// Get user defaults and apply the offsets
  	if (typeof offset1 === 'number') {
  		index1 = range.indexOf(formatDate(addMonths(getCurrentDate(), offset1), fmt));
  	}
  	if (typeof offset2 === 'number') {
  		index2 = range.indexOf(formatDate(addMonths(getCurrentDate(), offset2), fmt));
  	}
  	//
  	// Wait! Maybe we're just reloading?
  	// Then we need to keep the currently selected items
  	if (savedDateRangeIndexes.length === 2) {
  		index1 = savedDateRangeIndexes[0];
  		index2 = savedDateRangeIndexes[1];
  		savedDateRangeIndexes = [];  // reset
  	}
  	//
  	// If unset or out of range, we'll select the oldest and newer
  	if (index1 === -1) {
  		index1 = 0;
  	}
  	if (index2 === -1) {
  		index2 = range.length - 1;
  	}
  
  	// First, make sure the combo is empty
  	el1.options.length = 0;
  	el2.options.length = 0;
  
  	// Both combos 1 and 2 will have the same items
  	for (i = 0, leni = range.length; i < leni; i++) {
  		my = (range[i] + '-01').toDate().format('b Y');  // short month name
  		el1.options[i] = new Option(my, range[i]);
  		el2.options[i] = new Option(my, range[i]);
  	}
  
  	// Set selected items
  	el1.selectedIndex = index1;
  	el2.selectedIndex = index2;
  }
  
  function populateValueFilterCombo() {
  	var el;
  	el = document.getElementById('opt-value-filter-combo');
  	el.options[0] = new Option('+ ' + i18n.labelPositive, '+');
  	el.options[1] = new Option('- ' + i18n.labelNegative, '-');
  	el.options[2] = new Option('> ' + i18n.labelGreaterThan, '>');
  	el.options[3] = new Option('< ' + i18n.labelLessThan, '<');
  }
  
  function updateToolbar() {
  	var i, leni, add, remove, hide, unhide;
  
  	// Visibility On/Off
  	// Monthly/Yearly report hides some controls from the toolbar.
  	//
  	// Some fields are just hidden to preserve the page layout.
  	// Others must be removed to free vertical space.
  
  	add = [];
  	remove = [];
  	hide = [];
  	unhide = [];
  
  	// Daily
  	if (reportType === 'd') {
  		unhide = ['opt-monthly-box'];
  
  	// Monthly
  	} else if (reportType === 'm') {
  		hide = ['opt-monthly-box'];
  
  	// Yearly
  	} else if (reportType === 'y') {
  		hide = ['opt-monthly-box'];
  	}
  
  	// In Mobile toolbar we always add/remove, there's no hide.
  	// We save vertical space, and the report jump is not an issue.
  	if (isMobile) {
  		add = add.concat(unhide);
  		remove = remove.concat(hide);
  		hide = [];
  		unhide = [];
  	}
  
  	// Show/hide toolbar elements
  	for (i = 0, leni = add.length; i < leni; i++) {
  		document.getElementById(add[i]).style.display = 'block';
  	}
  	for (i = 0, leni = remove.length; i < leni; i++) {
  		document.getElementById(remove[i]).style.display = 'none';
  	}
  	for (i = 0, leni = hide.length; i < leni; i++) {
  		document.getElementById(hide[i]).style.visibility = 'hidden';
  	}
  	for (i = 0, leni = unhide.length; i < leni; i++) {
  		document.getElementById(unhide[i]).style.visibility = 'visible';
  	}
  }
  
  
  // ------------------------------------------------------------------
  //                        DATA HANDLERS
  // ------------------------------------------------------------------
  
  function resetData() {
  	reportData = [];
  	parsedData = [];
  	rawData = '';
  }
  
  function reloadData() {
  	// Save currently selected tags
  	initSelectedTags = getSelectedTags();
  	initExcludedTags = getExcludedTags();
  
  	// Save currently selected date range
  	savedDateRangeIndexes = [
  		document.getElementById('opt-date-1-month-combo').selectedIndex,
  		document.getElementById('opt-date-2-month-combo').selectedIndex
  	];
  
  	loadData();
  
  	return false;  // cancel link action
  }
  
  function loadData() {
  	var messageBoard;
  
  	// Hide charts and Tag Report when loading data
  	document.getElementById('charts').style.display = 'none';
  	document.getElementById('tag-report').style.display = 'none';
  
  	// Where to show the "loading..." message
  	messageBoard = document.getElementById('report');
  
  	resetData();
  
  	// Read user data, process it and show results
  	if (ml.storage.driver.config.isFileBased) {
  		messageBoard.innerText = i18n.msgLoading.replace('%s', getSelectedFile().name);
  		showHideEditButton();
  		ml.storage.readAsyncMulti(getActiveDataFiles(), function (contents) {
  			rawData = contents;
  			parseData();
  			showReport();
  		});
  	} else {
  		messageBoard.innerText = i18n.msgLoading.replace('%s', '');
  		rawData = ml.storage.driver.read();
  		parseData();
  		showReport();
  	}
  }
  
  function getSelectedFile() {
  	var combo = document.getElementById('source-file');
  	return {
  		id: combo.options[combo.selectedIndex].value,
  		name: combo.options[combo.selectedIndex].text
  	};
  }
  
  function getActiveDataFiles() {
  	if (getSelectedFile().name === '*') {
  		return ml.storage.driver.userFiles;  // all files
  	} else {
  		return [getSelectedFile()];
  	}
  }
  
  function showHideEditButton() {
  	// Hide Edit button when current file is '*'
  	if (ml.storage.driver.config.isEditable && ml.storage.driver.config.isFileBased) {
  		document.getElementById('editor-open').style.visibility = (getSelectedFile().name === '*') ? 'hidden' : 'visible';
  	}
  }
  
  function parseData() {
  	var i, j, lenj, rows, thisRow, rowDate, rowAmount, rowText, rowTagsDescription, rowTags, rowDescription, recurrentAmount, recValue, recTimes, recOperator, lineno, fields, rowAmountErrorMsg, tagNames;
  
  	// Reset the data holders
  	parsedData = [];
  	tagNames = [];
  
  	// Split lines
  	rows = rawData.split(dataRecordSeparator);
  
  	// Scan data rows
  	for (i = 0; i < rows.length; i++) {
  		lineno = i + 1;
  		thisRow = rows[i].lstrip();  // Ignore left spacing
  		rowDate = '';
  		rowAmount = '';
  		rowText = '';
  
  		// ----------------------------------------------------------- Firewall
  
  		// Skip commented rows
  		if (thisRow.indexOf(commentChar) === 0) {
  			continue;
  		}
  		// Skip blank lines
  		if (!thisRow) {
  			continue;
  		}
  
  		// New style matching method: regex
  		if (!useLegacyDataFormat) {
  
  			fields = thisRow.match(dataPatterns.rowBlankSeparated);
  
  			if (!fields) {
  				invalidData(lineno, thisRow);
  				return;  // abort at first error
  			}
  
  			fields.shift();  // discard field[0], the full match
  
  		// Old style matching method: split
  		} else {
  
  			// Separate fields
  			fields = thisRow.split(dataFieldSeparator);
  
  			// Error: rows with no separator
  			if (fields.length === 1) {
  				invalidData(
  					lineno,
  					i18n.errorNoFieldSeparator + ' "' + dataFieldSeparator + '"\n\n' + thisRow
  				);
  				return;  // abort at first error
  
  			// Error: too much separators
  			} else if (fields.length - 1 > 2) {
  				invalidData(
  					lineno,
  					i18n.errorTooManySeparators + ' "' + dataFieldSeparator + '"\n\n' + thisRow
  				);
  				return;  // abort at first error
  			}
  		}
  
  		// At this point we have:
  		//
  		// fields[0] -> date
  		// fields[1] -> amount
  		// fields[2] -> text (tags + description)
  		//
  		// The contents will be validated in the following lines.
  
  		// ----------------------------------------------------------- Text
  
  		rowText = (fields.length > 2) ? fields[2].strip() : '';
  
  		// ----------------------------------------------------------- Date
  
  		rowDate = fields[0].match(dataPatterns.date);
  		if (rowDate) {
  			rowDate = rowDate[1]; // group 1
  		} else {
  			invalidData(lineno, i18n.errorInvalidDate + ' ' + fields[0] + '\n\n' + thisRow);
  		}
  
  		// ----------------------------------------------------------- Amount
  
  		rowAmountErrorMsg = i18n.errorInvalidAmount + ' ' + fields[1] + '\n\n' + thisRow;
  
  		// Extract (and remove) recurrent information from the amount (if any)
  		recurrentAmount = fields[1].match(dataPatterns.amountRecurrent);
  		if (recurrentAmount) {
  			recTimes = parseInt(recurrentAmount[2], 10);
  			recOperator = recurrentAmount[1];
  			fields[1] = fields[1].replace(dataPatterns.amountRecurrent, '');
  		}
  
  		// Validade the amount value
  		rowAmount = fields[1].match(dataPatterns.amountNumber);
  		if (rowAmount) {
  			rowAmount = rowAmount[1].replace(/\s+/g, ''); // group 1, no blanks
  
  			// Normalize Value
  			// Force '.' as internal cents separator, remove other punctuation
  			// Ex.: 1.234,56 > 1.234@56 > 1234@56 > 1234.56
  			rowAmount = rowAmount
  				.replace(dataPatterns.amountCents, '@$1')
  				.replace(/[.,]/g, '')
  				.replace('@', '.');
  
  			// Now we can validate the number (str2float)
  			rowAmount = parseFloat(rowAmount);
  
  			// Ops, we don't have a valid number
  			if (isNaN(rowAmount)) {
  				invalidData(lineno, rowAmountErrorMsg);
  				return;
  			}
  		} else {
  			invalidData(lineno, rowAmountErrorMsg);
  			return;
  		}
  
  		// ----------------------------------------------------------- Recurrent Value
  
  		// A value of -100/10 means I've spent 100 and will pay it in 10x
  		// A value of -100*10 means I'll spent 100/month in the next 10 months
  		// This idea came from myMoneyLog. Thanks Nishimura!
  		//
  		// Compose each payment row, changing: date, value and description
  		// 2009-12-25  -90/3  Foo        |    2009-12-25  -90*3  Foo
  		// turns to                      |    turns to
  		// 2009-12-25  -30    Foo 1/3    |    2009-12-25  -90    Foo 1/3
  		// 2010-01-25  -30    Foo 2/3    |    2010-01-25  -90    Foo 2/3
  		// 2010-02-25  -30    Foo 3/3    |    2010-02-25  -90    Foo 3/3
  		//
  		// Note: It doesn't fix end-of-month day. Uses 2009-02-31 instead of 2009-02-28.
  		//       But it's OK.
  		//
  		// Note: the date/value filters must appear *after* the recurrent processing
  		//
  		if (recurrentAmount) {
  			recValue = rowAmount;
  
  			if (recOperator === '/') {
  				recValue = recValue / recTimes;
  			}
  
  			// Make sure we have a valid money value (not float)
  			recValue = recValue.toFixed(2);
  
  			// Compose and append each new row
  			for (j = 1; j <= recTimes; j++) {
  				rows.push([
  					addMonths(rowDate, j - 1),
  					recValue,
  					rowText + ' ' + j + '/' + recTimes
  				].join(dataFieldSeparator));
  			}
  
  			// Ignore the original recurring row
  			continue;
  		}
  
  		// ----------------------------------------------------------- Tags + Description
  
  		// Parse tags
  		if (rowText.indexOf(dataTagTerminator) !== -1) {
  
  			// Get tags
  			// Note: tag terminator in description is allowed
  			rowTagsDescription = rowText.split(dataTagTerminator);
  			rowTags = rowTagsDescription.shift().split(dataTagSeparator);
  			rowDescription = rowTagsDescription.join(dataTagTerminator).strip();
  
  			// Strip all tags
  			for (j = 0, lenj = rowTags.length; j < lenj; j++) {
  				rowTags[j] = rowTags[j].strip();
  			}
  			// Remove empty tags
  			rowTags = rowTags.removePattern('');
  
  			// Save to tag holder
  			tagNames = tagNames.concat(rowTags);
  
  		// No tags
  		} else {
  			rowTags = [];
  			rowDescription = rowText || '&nbsp;';
  		}
  
  		// -----------------------------------------------------------
  
  		// Ignore old or future data?
  		// Note: This code *must* be here at the end of the loop,
  		//       specially after the recurring data code. If not,
  		//       recurring data could be lost.
  		if (ignoreDataOlderThan && rowDate < ignoreDataOlderThan) {
  			continue;
  		}
  		if (ignoreDataNewerThan && rowDate > ignoreDataNewerThan) {
  			continue;
  		}
  
  		// Ignore tags?
  		if (ignoreTags.length > 0 && rowTags.hasArrayItem(ignoreTags)) {
  			continue;
  		}
  
  		// Save the validated data
  		parsedData.push([rowDate, rowAmount, rowTags, rowDescription]);
  	}
  
  	// Sort by date
  	parsedData.sort(sortByIndex(0, 'd'));
  
  	// Update the date range combos
  	if (parsedData.length > 0) {
  		populateDateRangeCombos();
  	}
  
  	// Compose the tag cloud: sorted, append (no tag) item
  	tagNames = tagNames.sort(sortIgnoreCase).unique();
  	tagNames.push(i18n.labelTagCloudEmpty);
  	createTagCloud(tagNames);
  
  	// Already select some tags now?
  	if (initSelectedTags.length > 0) {
  		setSelectedTags(initSelectedTags);
  	}
  	// Already exclude some tags now?
  	if (initExcludedTags.length > 0) {
  		setExcludedTags(initExcludedTags);
  	}
  }
  
  function filterData() {
  	var i, leni, temp, theData, isRegex, isNegated, filter, filterPassed, firstDate, lastDate, filteredData, thisDate, thisValue, thisTags, thisDescription, valueFilter, valueFilterArg;
  
  	theData = parsedData.clone();
  	isRegex = false;
  	isNegated = false;
  	filter = '';
  	firstDate = 0;
  	lastDate = '9999-99-99';
  	filteredData = [];
  
  	// Date filters
  	if (document.getElementById('opt-date-1-check').checked) {
  		firstDate = document.getElementById('opt-date-1-month-combo').value + '-00';
  	}
  	if (document.getElementById('opt-date-2-check').checked) {
  		lastDate = document.getElementById('opt-date-2-month-combo').value + '-99';
  	}
  
  	// Get filters data for the report
  	filter = document.getElementById('filter').value;
  	isRegex = document.getElementById('opt-regex-check').checked;
  	isNegated = document.getElementById('opt-negate-check').checked;
  
  	if (document.getElementById('opt-value-filter-check').checked) {
  		valueFilter = document.getElementById('opt-value-filter-combo').value;
  		valueFilterArg = parseInt(document.getElementById('opt-value-filter-number').value, 10) || 0;
  	}
  
  	// Hack: Value filtering on the search box!
  	// Examples: v:+  v:-  v:=50  v:>100  v:<=-100
  	temp = filter.match(/^v:([-+>=<][=]?)([+-]?\d*)$/);
  	if (temp) {
  		valueFilter = temp[1];
  		valueFilterArg = temp[2] || 0;
  		filter = ''; // The filter was (ab)used, now we can discard it
  	}
  
  	// Prepare filter contents as /regex/ or string, always ignore case
  	if (filter) {
  		if (isRegex) {
  			filter = new RegExp(filter.replace('/', '\\/'), 'i');
  		} else {
  			filter = filter.toLowerCase();
  		}
  	}
  
  	// Scan data rows
  	for (i = 0, leni = theData.length; i < leni; i++) {
  
  		// date value [tags] description
  		thisDate = theData[i][0];
  		thisValue = theData[i][1];
  		thisTags = theData[i][2].clone();
  		thisDescription = theData[i][3];
  
  		// ----------------------------------------------------------- Filters
  
  		// Apply date filter
  		if (thisDate < firstDate) {
  			continue;
  		}
  		if (thisDate > lastDate) {
  			break;  // theData is ordered by date, we can safely break here
  		}
  
  		// Apply value filter
  		if (valueFilter) {
  			if (valueFilter === '+' && thisValue < 0) { continue; }
  			if (valueFilter === '-' && thisValue >= 0) { continue; }
  			if (valueFilter === '>' && thisValue <= valueFilterArg) { continue; }
  			if (valueFilter === '<' && thisValue >= valueFilterArg) { continue; }
  			if (valueFilter === '=' && thisValue != valueFilterArg) { continue; }
  			if (valueFilter === '>=' && thisValue < valueFilterArg) { continue; }
  			if (valueFilter === '<=' && thisValue > valueFilterArg) { continue; }
  		}
  
  		// Search filter firewall - Will this line pass it?
  		if (filter) {
  			if (isRegex) {
  				filterPassed = filter.test(theData[i].join('\t'));
  			} else {
  				filterPassed = (theData[i].join('\t').toLowerCase().indexOf(filter) !== -1);
  			}
  			if ((!filterPassed && !isNegated) || (filterPassed && isNegated)) {
  				continue;
  			}
  		}
  
  		// Save the results
  		filteredData.push([thisDate, thisValue, thisTags, thisDescription]);
  	}
  
  	return filteredData;
  }
  
  function applyTags(theData) {
  	// Filter theData for the selected and excluded tags in Tag Cloud.
  	// Also updates the Tag Cloud widget: tags and options.
  
  	var i, leni, rowTags, tagCloud, dataTags, selectedTags, excludedTags, activeTags, filteredData, matchSelected, matchExcluded, groupSelected, groupExcluded;
  
  	dataTags = [];
  	tagCloud = [];
  	selectedTags = [];
  	excludedTags = [];
  	filteredData = [];
  	theData = theData.clone();
  
  	// Get currently active tags (from interface)
  	selectedTags = getSelectedTags();
  	excludedTags = getExcludedTags();
  	activeTags = selectedTags.concat(excludedTags);
  
  	// These are the match conditions
  	matchSelected = (selectedTags.length > 0);
  	matchExcluded = (excludedTags.length > 0);
  	groupSelected = document.getElementById('tag-cloud-opt-group-check').checked;
  	groupExcluded = false;  // more intuitive and simple this way
  
  	// Filter data to match current tags
  	for (i = 0, leni = theData.length; i < leni; i++) {
  
  		// Array order: date, amount, tags, desc
  		rowTags = theData[i][2].clone();
  
  		// No tag, set the "empty" tag so we can match it and add it to dataTags
  		if (!rowTags.length) {
  			rowTags = [i18n.labelTagCloudEmpty];
  		}
  
  		// Save all the rows tags for later
  		dataTags = dataTags.concat(rowTags);
  
  		// Ignore this row if it matches the excluded tags
  		if (matchExcluded && (
  			//  !group && matchedOne  ||  group && matchedAll
  			(!groupExcluded && rowTags.hasArrayItem(excludedTags)) ||
  				(groupExcluded && rowTags.hasAllArrayItems(excludedTags))
  		)) {
  			continue;
  		}
  
  		// Ignore this row if it does not match the selected tags
  		if (matchSelected && (
  			//  !group && !matchedOne  ||  group && !matchedAll
  			(!groupSelected && !rowTags.hasArrayItem(selectedTags)) ||
  				(groupSelected && !rowTags.hasAllArrayItems(selectedTags))
  		)) {
  			continue;
  		}
  
  		// Not ignored? Great, you rule.
  		filteredData.push(theData[i]);
  	}
  
  	// The Tag Cloud is composed by all the tags in the current report
  	// view AND the user selected tags.
  	tagCloud = tagCloud.concat(dataTags, selectedTags);
  
  	// Any tag? So let's unique them and update the cloud
  	if (tagCloud.length > 0) {
  		tagCloud = tagCloud.sort(sortIgnoreCase).unique();
  		updateTagCloud(tagCloud);
  	}
  
  	// The options box is only shown if we have at least 1 active tag
  	document.getElementById('tag-cloud-options').style.display = (activeTags.length > 0) ? 'block' : 'none';
  
  	// The reset option is only shown if we have at least 1 active tag
  	document.getElementById('tag-cloud-opt-reset-box').style.display = (activeTags.length > 0) ? 'block' : 'none';
  
  	// The group option is only shown if we have at least 2 selected tags
  	document.getElementById('tag-cloud-opt-group-box').style.display = (selectedTags.length > 1) ? 'block' : 'none';
  
  	// Tag filter was active?
  	if (matchSelected || matchExcluded) {
  		return filteredData;
  	} else {
  		return theData;
  	}
  }
  
  function groupByPeriod(arr, periodType) {  // m, y
  	// Group data by months/years, returning an object.
  	// Note: the input array may be unsorted
  	//
  	// groupByPeriod(
  	//     [
  	//         ['2012-02-15', '1.50', 'foo1'],
  	//         ['2012-02-18', '7.00', 'foo2'],
  	//         ['2012-03-04', '-4.44', 'bar1'],
  	//         ['2012-03-18', '-5.55', 'bar2'],
  	//     ], 'm')
  	//
  	// returns:
  	// {
  	//     '2012-02': [
  	//         ['2012-02-15', '1.50', 'foo1'],
  	//         ['2012-02-18', '7.00', 'foo2'],
  	//     ],
  	//     '2012-03': [
  	//         ['2012-03-04', '-4.44', 'bar1'],
  	//         ['2012-03-18', '-5.55', 'bar2']
  	//     ],
  	//     keys: ['2012-02', '2012-03']  // always sorted
  	// }
  
  	var i, leni, item, slices;
  	var results = {};
  
  	results.keys = [];
  	slices = {y: 4, m: 7, d: 10};
  	for (i = 0, leni = arr.length; i < leni; i++) {
  		item = arr[i][0].slice(0, slices[periodType]);  // get date
  		if (!results[item]) {
  			results[item] = [];
  			results.keys.push(item);
  		}
  		results[item].push(arr[i]);
  	}
  	results.keys.sort();
  	return results;
  }
  
  
  // ------------------------------------------------------------------
  //                          REPORTS
  // ------------------------------------------------------------------
  
  function updateSelectedRowsSummary() {
  	var i, leni, data, arr, table, label, value, colNumber, colIndex, trElement, tdElement;
  
  	data = [];
  	arr = [];
  	table = [];
  
  	if (selectedRows.length > 1) {  // Summary for 2 or more rows
  
  		// Ok. We have a selectedRows array filled with all the selected TR elements.
  		// Now we will scan these elements to extract data and calculate everything.
  
  		// First we need to find out which column we will get of each of this rows.
  		// Diary report: when only have the (2) Amount column to query
  		// Monthly/Yearly: we have (2) Incoming, (3) Expense, (4) Partial
  		//
  		if (reportType === 'd') {
  			colNumber = 2;
  		} else {
  			colNumber = parseInt(document.getElementById('rows-summary-index').value, 10);
  		}
  
  		// The column number is affected by the presence of the row count column
  		if (showRowCount) {
  			colNumber += 1;
  		}
  
  		// We will soon query each TR element for a specific child TD.
  		// Our column count is one-based, but the TR children array is ZERO-based.
  		colIndex = colNumber - 1;
  
  		for (i = 0, leni = selectedRows.length; i < leni; i++) {
  			trElement = selectedRows[i];
  			tdElement = trElement.getElementsByTagName('td')[colIndex];
  
  			// Inside the TD, the number is inside a SPAN tag, examples:
  			//     <td class="number"><span class="neg">-123,45</span></td>
  			//     <td class="number"><span class="pos">123,45</span></td>
  			//
  			value = tdElement.getElementsByTagName('span')[0].firstChild.nodeValue;
  
  			// The value is a formatted string, we need to convert it to float
  			value = prettyFloatUndo(value);
  
  			data.push(value);
  		}
  
  		// Now the 'data' array is filled. We can proceed to the math.
  
  		// Calculate
  		arr.push([i18n.labelTotal,   prettyFloat(data.sum())]);
  		arr.push([i18n.labelAverage, prettyFloat(data.avg())]);
  		arr.push([i18n.labelMinimum, prettyFloat(data.min())]);
  		arr.push([i18n.labelMaximum, prettyFloat(data.max())]);
  		arr.push([i18n.labelCount,   data.length]);
  
  		// Compose the HTML table
  		table.push('<table>');
  		for (i = 0, leni = arr.length; i < leni; i++) {
  			label = arr[i][0];
  			value = arr[i][1];
  
  			table.push('<tr>');
  			table.push('<td>' + label + '</td>');
  			table.push('<td class="number"> ' + value + '</td>');
  			table.push('</tr>');
  		}
  		table.push('</table>');
  
  		// Show summary
  		document.getElementById('rows-summary-content').innerHTML = table.join('\n');
  		document.getElementById('rows-summary').style.display = 'block';
  
  		// Hide combo in diary report, since there's only one available column
  		document.getElementById('rows-summary-index').style.display = (reportType === 'd') ? 'none' : 'inline';
  
  	} else {
  		// No data, hide summary
  		document.getElementById('rows-summary').style.display = 'none';
  	}
  }
  
  function periodReport() {
  	var i, leni, z, len, theData, overviewData, groupedData, balance, period, periods, periodValues, totals, results, allPos, allNeg, allTotal, sortIndex, sortRev, chart, chartCol, chartValues, chartLabels, colTypes;
  
  	results = [];
  	overviewData = [];
  	balance = 0;
  	allNeg = [];
  	allPos = [];
  	allTotal = [];
  	theData = applyTags(filterData());
  	reportData = theData.clone();
  	sortIndex = sortData[reportType].index - 1;  // config is one-based
  	sortRev = sortData[reportType].rev;
  
  	if (theData.length) {
  
  		// Group report data by period (month or year), to make things easier
  		groupedData = groupByPeriod(theData, reportType);
  		periods = groupedData.keys.clone();
  
  		// Scan and calculate everything
  		for (i = 0, leni = periods.length; i < leni; i++) {
  			period = periods[i];
  			periodValues = groupedData[period].getColumn(1);  // date, amount, tags, desc
  			totals = computeTotals(periodValues);
  			balance += totals.sum;
  
  			// Save period totals for the grand total rows
  			allNeg.push(totals.sumNegative);
  			allPos.push(totals.sumPositive);
  			allTotal.push(totals.sum);
  
  			overviewData.push([period, totals.sumPositive, totals.sumNegative, totals.sum, balance]);
  		}
  
  		// Report data is OK inside overviewData array
  		// Now we must compose the report table
  
  		// Perform the user-selected sorting column and order
  		colTypes = ['d', 'n', 'n', 'n', 'n'];
  		overviewData.sort(sortByIndex(sortIndex, colTypes[sortIndex]));
  		if (sortRev) {
  			overviewData.reverse();
  		}
  
  		results.push('<table class="report overview">');
  
  		// Table headings
  		results.push('<tr>');
  		if (showRowCount) {
  			results.push('<th class="row-count"></th>');
  		}
  		results.push('<th onClick="sortCol(0)">' + i18n.labelsOverview[0] + '</th>');
  		results.push('<th onClick="sortCol(1)">' + i18n.labelsOverview[1] + '</th>');
  		results.push('<th onClick="sortCol(2)">' + i18n.labelsOverview[2] + '</th>');
  		results.push('<th onClick="sortCol(3)">' + i18n.labelsOverview[3] + '</th>');
  		if (showBalance) {
  			results.push('<th onClick="sortCol(4)">' + i18n.labelsOverview[4] + '</th>');
  		}
  		if (showMiniBars) {
  			results.push('<th class="percent">%</th>');
  		}
  		results.push('</tr>');
  
  		// Array2Html
  		for (i = 0, leni = overviewData.length; i < leni; i++) {
  			z = overviewData[i].clone();
  			// Save this row to the report table
  			results.push(getOverviewRow(z[0], z[1], z[2], z[3], z[4], i + 1));
  		}
  
  		// Compose the final rows: total, avg, min, max
  		len = overviewData.length;
  		if (len > 1) {
  			allNeg = computeTotals(allNeg);
  			allPos = computeTotals(allPos);
  			allTotal = computeTotals(allTotal);
  			results.push(getOverviewTotalsRow(i18n.labelTotal, allPos.sum, allNeg.sum, allTotal.sum, 'total'));
  			results.push(getOverviewTotalsRow(i18n.labelAverage, allPos.avg, allNeg.avg, allTotal.avg));
  			results.push(getOverviewTotalsRow(i18n.labelMinimum, allPos.min, allNeg.max, allTotal.min));
  			results.push(getOverviewTotalsRow(i18n.labelMaximum, allPos.max, allNeg.min, allTotal.max));
  			// Note: Yes, allNeg.max and allNeg.min are swapped for better reading
  		}
  
  		// And we're done on the report table
  		results.push('</table>');
  		results = results.join('\n');
  
  		// Always reset Rows Summary when generating reports
  		selectedRows = [];
  		updateSelectedRowsSummary();
  
  		// Now charts!
  		if (showCharts) {
  			// Get all values for the selected column
  			chartCol = parseInt(document.getElementById('chart-selector').value, 10) || 1;
  			chartValues = overviewData.getColumn(chartCol);
  			chartLabels = overviewData.getColumn(0);  // month or year
  
  			// Get chart and show it
  			chart = drawChart(chartValues, chartLabels);
  			document.getElementById('chart-content').innerHTML = chart;
  			document.getElementById('charts').style.display = 'block';
  		} else {
  			document.getElementById('charts').style.display = 'none';
  		}
  
  	} else {
  		results = '<p>' + i18n.labelNoData + '</p>';
  
  		// Hide charts when there's no data
  		document.getElementById('charts').style.display = 'none';
  	}
  	document.getElementById('report').innerHTML = results;
  }
  
  function dailyReport() {
  	var i, leni, j, lenj, k, lenk, rowDate, rowAmount, rowTags, rowDescription, monthTotal, monthPos, monthNeg, rowCount, results, monthPartials, theData, sumPos, sumNeg, sumTotal, chart, chartCol, chartLabels, chartValues, chartValuesSelected, currentDate, colTypes, sortIndex, sortRev;
  
  	sumTotal = 0;
  	sumPos = 0;
  	sumNeg = 0;
  	monthTotal = 0;
  	monthPos = 0;
  	monthNeg = 0;
  	rowCount = 0;
  	results = [];
  	chartValues = [];
  	chartLabels = [];
  	sortIndex = sortData.d.index - 1;  // config is one-based
  	sortRev = sortData.d.rev;
  	monthPartials = document.getElementById('opt-monthly-check');
  
  	theData = applyTags(filterData());
  	reportData = theData.clone();
  
  	if (theData.length > 0) {
  
  		currentDate = getCurrentDate();
  
  		// If not the default sort (0, asc), turn OFF partials
  		if (sortIndex !== 0 || sortRev) {
  			monthPartials.checked = false;
  		}
  
  		// Perform the user-selected sorting column and order
  		colTypes = ['d', 'n', 't', 's', 'n'];
  		theData.sort(sortByIndex(sortIndex, colTypes[sortIndex]));
  		if (sortRev) {
  			theData.reverse();
  		}
  
  		results.push('<table class="report daily">');
  
  		// Compose table headings
  		results.push('<tr>');
  		if (showRowCount) {
  			results.push('<th class="row-count"></th>');
  		}
  		results.push('<th onClick="sortCol(0)">' + i18n.labelsDetailed[0] + '</th>');
  		results.push('<th onClick="sortCol(1)">' + i18n.labelsDetailed[1] + '</th>');
  		results.push('<th onClick="sortCol(2)" class="tags">' + i18n.labelsDetailed[2] + '</th>');
  		results.push('<th onClick="sortCol(3)">' + i18n.labelsDetailed[3] + '</th>');
  		if (showBalance) {
  			results.push('<th class="balance">' + i18n.labelsDetailed[4] + '</th>');
  		}
  		results.push('</tr>');
  
  		// Compose table rows
  		for (i = 0, leni = theData.length; i < leni; i++) {
  
  			rowDate        = theData[i][0];
  			rowAmount      = theData[i][1];
  			rowTags        = theData[i][2].clone();
  			rowDescription = theData[i][3];
  			rowCount      += 1;
  
  			// This row starts a new month? Must we show the partials?
  			if (monthPartials.checked && i > 0 &&
  					rowDate.slice(0, 7) !=
  					theData[i - 1][0].slice(0, 7)) {
  				results.push(getTotalsRow(sumTotal, monthTotal, monthNeg, monthPos));
  				// Save month data for the chart (-1 is a fake entry, one-based array)
  				chartValues.push([-1, monthPos, monthNeg, monthTotal, sumTotal]);
  				chartLabels.push(theData[i - 1][0].slice(0, 7)); // month
  				// Partials row shown, reset month totals
  				monthTotal = 0;
  				monthPos = 0;
  				monthNeg = 0;
  				if (monthlyRowCount) {
  					rowCount = 1;
  				}
  			}
  
  			// Update totals
  			sumTotal += rowAmount;
  			monthTotal += rowAmount;
  			if (rowAmount < 0) {
  				monthNeg += rowAmount;
  				sumNeg += rowAmount;
  			} else {
  				monthPos += rowAmount;
  				sumPos += rowAmount;
  			}
  
  			// There are some words to highlight on the Description?
  			if (highlightRegex) {
  				rowDescription = rowDescription.replace(
  					highlightRegex,
  					'<span class="hl">$&</span>'
  				);
  			}
  
  			// There are some tags to highlight?
  			for (j = 0, lenj = highlightTags.length; j < lenj; j++) {
  				for (k = 0, lenk = rowTags.length; k < lenk; k++) {
  					if (rowTags[k] === highlightTags[j]) {
  						rowTags[k] = '<span class="hl">' + rowTags[k] + '</span>';
  						break;
  					}
  				}
  			}
  
  			// This row is in the future?
  			if (rowDate <= currentDate) {
  				results.push('<tr onClick="toggleRowHighlight(this)">');
  			} else {
  				results.push('<tr onClick="toggleRowHighlight(this)" class="future">');
  			}
  
  			if (showRowCount) {
  				results.push('<td class="row-count">' + rowCount + '</td>');
  			}
  
  			results.push('<td class="date">'   + formatReportDate(rowDate) + '</td>');
  			results.push('<td class="number">' + prettyFloat(rowAmount)    + '</td>');
  			results.push('<td class="tags">'   + rowTags.join(', ')        + '</td>');
  			results.push('<td>'                + rowDescription            + '</td>');
  			if (showBalance) {
  				results.push('<td class="number">' + prettyFloat(sumTotal) + '</td>');
  			}
  			results.push('</tr>');
  
  		}
  
  		// Should we show the full month partials at the last row?
  		if (monthPartials.checked || !showBalance) {
  			results.push(getTotalsRow(sumTotal, monthTotal, monthNeg, monthPos));
  		} else {
  			results.push(getTotalsRow(sumTotal, '', sumNeg, sumPos));
  		}
  
  		// Save chart data for this last month
  		chartValues.push([-1, monthPos, monthNeg, monthTotal, sumTotal]);
  		chartLabels.push(theData[theData.length - 1][0].slice(0, 7)); // month
  
  		results.push('</table>');
  		results = results.join('\n');
  
  		// Real dirty hack to insert totals row at the table beginning (UGLY!)
  		// results = results.replace('</th></tr>', '</th></tr>' + getTotalsRow(sumTotal, '', sumNeg, sumPos));
  
  		// Always reset Rows Summary when generating reports
  		selectedRows = [];
  		updateSelectedRowsSummary();
  
  		// Now charts!
  		// Note: monthPartials option is required to be ON
  		if (showCharts && monthPartials.checked) {
  
  			// Get all values for the selected column
  			chartCol = parseInt(document.getElementById('chart-selector').value, 10) || 1;
  			chartValuesSelected = chartValues.getColumn(chartCol);
  
  			// Get chart and show it
  			chart = drawChart(chartValuesSelected, chartLabels);
  			document.getElementById('chart-content').innerHTML = chart;
  			document.getElementById('charts').style.display = 'block';
  		} else {
  			document.getElementById('charts').style.display = 'none';
  		}
  
  	} else {
  		results = '<p>' + i18n.labelNoData + '</p>';
  
  		// Hide charts when there's no data
  		document.getElementById('charts').style.display = 'none';
  
  	}
  	document.getElementById('report').innerHTML = results;
  }
  
  function tagReport() {
  	var i, leni, j, lenj, results, hasRelated, hideRelated, tagNames, tagName, selectedTags, tagData, tableData, theData, groupedData, content, options, firstDate, lastDate, allDates, nDates, period, periodData, periodName, rowAmount, rowTags, index, total, tagless, tdClass, sortIndex, sortRev;
  
  	results = [];
  	tagNames = [];
  	tagData = {};
  	tableData = [];
  	theData = reportData.clone();
  	sortIndex = sortData[reportType].indexTag - 1;  // config is one-based
  	sortRev = sortData[reportType].revTag;
  	selectedTags = getSelectedTags();
  	hasRelated = false;
  	content = document.getElementById('tag-report-content');
  	options = document.getElementById('tag-report-options');
  	hideRelated = document.getElementById('tag-report-opt-related-check').checked;
  
  	// Hide the Tag report when in Daily report
  	if (!showTagReport || reportType === 'd') {
  		document.getElementById('tag-report').style.display = 'none';
  		return;  // abort report generation
  	} else {
  		document.getElementById('tag-report').style.display = 'block';
  	}
  
  	// Group report data by period (month or year), to make things easier
  	groupedData = groupByPeriod(theData, reportType);
  
  	// ----------------------------------------------------------- MONTHS / YEARS
  
  	// Compose the list of all months/years in the period
  	// Note: can't use groupedData.keys because there may be gaps
  	firstDate = groupedData.keys[0];
  	lastDate = groupedData.keys[groupedData.keys.length - 1];
  
  	if (reportType === 'm') {
  		allDates = getMonthRange(firstDate + '-01', lastDate + '-01');
  	} else {
  		allDates = getYearRange(firstDate + '-01-01', lastDate + '-01-01');
  	}
  	nDates = allDates.length;
  
  	// ----------------------------------------------------------- TAG TOTALS
  
  	// tagData = {
  	//     tag1: {
  	//         2012-01: 55,
  	//         2012-02: 26,
  	//         total: 81
  	//     },
  	//     tag2: {
  	//         2012-01: 34,
  	//         2012-02: 27,
  	//         2012-05: 25,
  	//         total: 86
  	//     }
  	// }
  
  	// Loop each month/year
  	for (period in groupedData) {
  		periodData = groupedData[period];
  
  		if (period === 'keys') { continue; }  // ignore
  
  		// Scan report rows for this period
  		for (i = 0, leni = periodData.length; i < leni; i++) {
  			// rowDate        = periodData[i][0];
  			rowAmount         = periodData[i][1];
  			rowTags           = periodData[i][2].clone();
  			// rowDescription = periodData[i][3];
  
  			// No tags in this row? Add a fake one
  			if (rowTags.length === 0) {
  				rowTags = [i18n.labelTagCloudEmpty];
  			}
  
  			// Scan tags and sum
  			for (j = 0, lenj = rowTags.length; j < lenj; j++) {
  				tagName = rowTags[j];
  
  				// New tag?
  				if (!tagNames.hasItem(tagName)) {
  
  					// Is this a related tag?
  					if (selectedTags.length > 0 && !selectedTags.hasItem(tagName)) {
  						hasRelated = true;
  						if (hideRelated) {
  							continue;  // ignore
  						}
  					}
  
  					tagNames.push(tagName);
  					tagData[tagName] = {};
  					tagData[tagName].total = 0;
  				}
  
  				// First tag in this period?
  				if (!tagData[tagName][period]) {
  					tagData[tagName][period] = 0;
  				}
  
  				// Sum
  				tagData[tagName][period] += rowAmount;
  				tagData[tagName].total += rowAmount;
  			}
  		}
  	}
  
  	// ----------------------------------------------------------- TABLE DATA
  
  	// tableData = [
  	//     ['tag1', 55, 26, 0, 0, 123, ...],
  	//     ['tag2', 34, 27, 0, 0, 25, ...]
  	// ]
  
  	// Save the table array, grouped by tag names
  	// This array will become the HTML table
  	for (i = 0, leni = tagNames.length; i < leni; i++) {
  		tagName = tagNames[i];
  		tableData.push([tagName]);
  		index = tableData.length - 1;
  		for (j = 0, lenj = nDates; j < lenj; j++) {
  			period = allDates[j];
  			total = tagData[tagName][period];
  			tableData[index].push(total || 0);
  		}
  		if (nDates > 1) {
  			// total & average
  			tableData[index].push(tagData[tagName].total);
  			tableData[index].push(tagData[tagName].total / nDates);
  		}
  	}
  
  	// ----------------------------------------------------------- SORT
  
  	if (sortIndex === 0) {
  		// Sort by tag name, ignoring case and accents
  		tableData.sort(sortByIndex(0, 's'));
  
  		// Make sure the (no tag) row is always the last
  		if (tableData.length > 1 && tableData[0][0] === i18n.labelTagCloudEmpty) {
  			tagless = tableData.shift();
  			tableData.push(tagless);
  		}
  	} else {
  		// Sort by value
  		tableData.sort(sortByIndex(sortIndex, 'n'));
  	}
  	if (sortRev) {
  		tableData.reverse();
  	}
  
  	// ----------------------------------------------------------- HTML
  
  	results.push('<table class="report">');
  
  	// Table heading
  
  	results.push('<tr>');
  
  	// tag column
  	results.push('<th class="tagname" onClick="sortColTag(0)">' + i18n.labelsDetailed[2] + '</th>');
  
  	// dates
  	for (i = 0, leni = nDates; i < leni; i++) {
  		periodName = allDates[i];
  
  		// Month names get special formatting
  		if (reportType === 'm') {
  			periodName = (allDates[i] + '-01').toDate().format('Y-b');
  			periodName = periodName
  				.replace(/^(....)/, '<i>$1</i>')
  				.replace('-', '<br>');
  		}
  
  		results.push('<th onClick="sortColTag(' + (i + 1) + ')">' + periodName + '</th>');
  	}
  
  	// total & average
  	if (nDates > 1) {
  		results.push('<th onClick="sortColTag(' + (i + 1) + ')" class="totals">' + i18n.labelTotal + '</th>');
  		results.push('<th onClick="sortColTag(' + (i + 2) + ')" class="totals">' + i18n.labelAverage + '</th>');
  	}
  
  	results.push('</tr>');
  
  	// Compose table body, one tag per row
  	for (i = 0, leni = tableData.length; i < leni; i++) {
  		results.push('<tr>');
  		results.push('<td>' + tableData[i][0] + '</td>');  // tag name
  
  		// Now the numbers (Note: j=1)
  		for (j = 1, lenj = tableData[i].length; j < lenj; j++) {
  
  			// Mark the Totals columns
  			if (nDates > 1 && j === lenj - 2) {  // penultimate
  				tdClass = 'number totals total';
  			} else if (nDates > 1 && j === lenj - 1) {  // last
  				tdClass = 'number totals average';
  			} else {
  				tdClass = 'number';
  			}
  
  			results.push('<td class="' + tdClass + '">' + ((tableData[i][j]) ? prettyFloat(tableData[i][j]) : '0') + '</td>');
  			// Note: empty cells become 0 and not 0.00 to make the report less polluted
  		}
  		results.push('</tr>');
  	}
  
  	results.push('</table>');
  
  	// Show report (if we have tags)
  	content.innerHTML = (tagNames.length > 0) ? results.join('\n') : '';
  
  	// Show/hide options
  	options.style.display = (hasRelated) ? 'block' : 'none';
  }
  
  function showReport() {
  	var i;
  
  	if (reportType === 'd') {
  		dailyReport();
  	} else {
  		periodReport();
  	}
  	tagReport();
  
  	// Widget hook: showReportPost
  	for (i = 0; i < Widget.instances.length; i++) {
  		Widget.instances[i].showReportPost();
  	}
  }
  
  
  // ------------------------------------------------------------------
  //                        DATA EDITOR
  // ------------------------------------------------------------------
  
  function editorOn() {
  	var filepath;
  
  	// Load the current data to the editor
  	document.getElementById('editor-data').value = rawData;
  
  	// Hide content to avoid scroll bars
  	document.getElementById('content').style.display = 'none';
  
  	// Set file name
  	if (ml.storage.driver.config.isFileBased) {
  		filepath = getSelectedFile().name;
  	} else {
  		filepath = ml.storage.driver.name;
  	}
  	document.getElementById('editor-file-name').innerHTML = filepath;
  
  	// Show editor
  	document.getElementById('editor').style.display = 'block';
  
  	return false;  // cancel link action
  }
  
  function editorOff() {
  
  	// Hide editor
  	document.getElementById('editor').style.display = 'none';
  
  	// Restore content
  	document.getElementById('content').style.display = 'block';
  
  	return false;  // cancel link action
  }
  
  function saveLocalData() {
  	var editButton = document.getElementById('editor-open');
  
  	editButton.innerHTML = i18n.msgSaving;
  	ml.storage.driver.write(document.getElementById('editor-data').value);
  
  	// Reload report
  	reloadData();
  
  	editButton.innerHTML = i18n.labelEditorOpen;
  }
  
  function editorSave() {
  	editorOff();
  	saveLocalData();
  	return false;  // cancel link action
  }
  
  
  // ------------------------------------------------------------------
  //                         EVENT HANDLERS
  // ------------------------------------------------------------------
  
  function sortCol(index) {
  	// Note: sortData config is one-based, sortCol() is zero-based
  
  	// If the same index, flip current reverse state, else reverse=false
  	sortData[reportType].rev =
  		(sortData[reportType].index == (index + 1)) ?
  			!sortData[reportType].rev :
  			false;
  	// Save new index
  	sortData[reportType].index = index + 1;
  	// Refresh table
  	showReport();
  }
  
  function sortColTag(index) {
  	// Note: sortData config is one-based, sortColTag() is zero-based
  
  	// If the same index, flip current reverse state, else reverse=false
  	sortData[reportType].revTag =
  		(sortData[reportType].indexTag === (index + 1)) ?
  			!sortData[reportType].revTag :
  			false;
  	// Save new index
  	sortData[reportType].indexTag = index + 1;
  	// Refresh table
  	tagReport();
  }
  
  function changeReport() {  // this == <a class="button"> element
  	var oldType, newType;
  
  	oldType = reportType;
  	newType = this.id;
  
  	// Deactivate old report, activate new
  	removeClass(document.getElementById(oldType), 'active');
  	addClass(this, 'active');
  
  	// Always reset Rows Summary when changing reports
  	selectedRows = [];
  	updateSelectedRowsSummary();
  
  	reportType = newType;
  	updateToolbar();
  	showReport();
  
  	return false;  // cancel link action
  }
  
  function dateRangeComboChanged() {
  	document.getElementById(this.id.replace(/(month|year)-combo/, 'check')).checked = true;
  	showReport();
  }
  
  function toggleFullScreen() {
  	var toolbar, logo, content;
  
  	toolbar = document.getElementById('toolbar-controls-wrapper');
  	logo = document.getElementById('logo');
  	content = document.getElementById('content');
  
  	if (isFullScreen) {
  		toolbar.style.display = 'block';
  		logo.style.display = 'block';
  		content.style.marginLeft = (isMobile) ? 0 : '217px';  // #toolbar width
  		isFullScreen = false;
  	} else {
  		toolbar.style.display = 'none';
  		logo.style.display = 'none';
  		content.style.marginLeft = 0;
  		isFullScreen = true;
  	}
  	return false;  // cancel link action
  }
  
  function toggleToolbarBox(headerId, contentId, options) {
  	// Handle toolbar box header clicking: show/hide contents
  	var header, content;
  	header = document.getElementById(headerId);
  	content = document.getElementById(contentId);
  
  	function open() {
  		content.style.display = 'block';
  		addClass(header, 'active');
  	}
  
  	function close() {
  		content.style.display = 'none';
  		removeClass(header, 'active');
  	}
  
  	// Force state
  	if (options.open === true) {
  		open();
  	} else if (options.open === false) {
  		close();
  
  	// Toggle state
  	} else if (content.style.display === 'none') {
  		open();
  	} else {
  		close();
  	}
  	return false;  // cancel link action
  }
  
  function toggleCheckboxOptionExtra(checkbox) {
  	// Show/hide the "*-extra" DIV with aditional options
  	var extra = document.getElementById(checkbox.id.replace('-check', '-extra'));
  	if (hasClass(extra, 'auto-hide')) {
  		extra.style.display = (checkbox.checked) ? 'block' : 'none';
  	}
  }
  
  function toggleStorage(options) {
  	return toggleToolbarBox('storage-header', 'storage-content', options);
  }
  
  function toggleViewOptions(options) {
  	return toggleToolbarBox('view-options-header', 'view-options-content', options);
  }
  
  function toggleTagCloud(options) {
  	return toggleToolbarBox('tag-cloud-header', 'tag-cloud-content', options);
  }
  
  function toggleValueFilter() {
  	toggleCheckboxOptionExtra(this);
  	showReport();
  }
  
  function toggleMonthly() {
  	if (document.getElementById('opt-monthly-check').checked === true) {
  		// Restore default sort when activating Partials
  		sortData.d.index = 1;
  		sortData.d.rev = false;
  	}
  	showReport();
  }
  
  function toggleRowHighlight(el) {
  	// This function is called when user clicks a report row.
  	// The visual highlight is turned ON by the class 'selected'.
  	// The selected clicked rows are saved to the global holder selectedRows.
  
  	if (hasClass(el, 'selected')) {
  		// turn OFF
  		removeClass(el, 'selected');
  		selectedRows = selectedRows.removePattern(el, 1);
  	} else {
  		// turn ON
  		addClass(el, 'selected');
  		selectedRows.push(el);
  	}
  
  	// Refresh the summary
  	updateSelectedRowsSummary();
  }
  
  function valueFilterChanged() {
  	// autocheck checkbox
  	document.getElementById('opt-value-filter-check').checked = true;
  
  	// show/hide the filter argument textbox
  	if (document.getElementById('opt-value-filter-combo').value.match(/[+-]/)) {
  		document.getElementById('opt-value-filter-number').style.display = 'none';
  	} else {
  		document.getElementById('opt-value-filter-number').style.display = 'inline';
  	}
  
  	showReport();
  }
  
  function resetRowsSummary() {
  	showReport();
  	return false;  // cancel link action
  }
  
  
  // ------------------------------------------------------------------
  //                            Widgets
  // ------------------------------------------------------------------
  
  // Widget object, all widgets must use this type.
  // Ex.:
  //     var HelloWorld = new Widget('hello-world', 'Hello World', 'HelloWorld');
  //     HelloWorld.populate = function () { this.content.innerHTML = 'Hellooo!'; };
  //     console.log(HelloWorld);
  //
  function Widget(widgetId, widgetName, instanceName) {
  	this.id = widgetId;
  	this.widgetName = widgetName;
  	this.instanceName = instanceName;
  	this.initDone = false;
  	this.created = false;
  
  	// Save the instance into a global holder
  	this.constructor.instances.push(this);
  	// this.instanceIndex = this.constructor.instances.length - 1;
  
  	// Holders for the widget's DOM elements
  	this.box = undefined;     // #widgetId-box
  	this.header = undefined;  // #widgetId-header
  	this.content = undefined; // #widgetId-content
  
  	// Widget Config
  	this.config = {};
  	this.config.active = true;
  	this.config.opened = true;
  	// Active and opened by default, so the user notices it
  
  	// Note: using inline onclick="" in templates to avoid 'this' scope problems
  	this.boxTemplate = [
  		'<div id="{widgetId}-box" class="widget-box">',
  		'	<a id="{widgetId}-header" class="button" href="#"',
  		'		onclick="return Widget.toggle(\'{widgetId}\')">{widgetName}</a>',
  		'	<div id="{widgetId}-content" class="widget-content" style="display:none;">',
  		'	</div>',
  		'</div>'
  	].join('\n');
  	this.checkboxTemplate = [
  		'	<div class="checkbox-option">',
  		'		<input id="{widgetId}-{idSuffix}" class="trigger" type="checkbox"',
  		'			onclick="{instanceName}.checkboxClicked(this)">',
  		'		<label id="{widgetId}-{idSuffix}-label" for="{widgetId}-{idSuffix}">{label}</label>',
  		'	</div>'
  	].join('\n');
  }
  
  // Global holder that automatically save all instances
  Widget.instances = [];
  
  Widget.tidyInstances = function () {
  	// Remove inactive widgets from global namespace and Widget.instances array
  	var i;
  	for (i = 0; i < Widget.instances.length;) {
  		if (!Widget.instances[i].config.active) {
  			window[Widget.instances[i].instanceName] = undefined;
  			Widget.instances.splice(i, 1);
  		} else {
  			i += 1;
  		}
  	}
  };
  
  Widget.toggle = function (widgetId) {
  	// Show/hide Widget contents
  	// Static, not available in instances
  
  	var header, content;
  
  	header = document.getElementById(widgetId + '-header');
  	content = document.getElementById(widgetId + '-content');
  
  	if (content.style.display === 'block') {
  		content.style.display = 'none';
  		removeClass(header, 'active');
  	} else {
  		content.style.display = 'block';
  		addClass(header, 'active');
  	}
  	return false;  // cancel link action
  };
  
  Widget.prototype.toggle = function () {
  	// Show/hide Widget contents
  	//
  	// this = Widget instance when called directly: InstanceName.toggle();
  	// this = DOM element <a> when used in onclick: element.onclick = InstanceName.toggle;
  	//
  	// Do not use the second form. If you must, you can use this instead:
  	// element.onclick = (function () { return InstanceName.toggle(); });
  
  	return Widget.toggle(this.id);
  };
  
  Widget.prototype.init = function () {
  	// Called automatically at the end of MoneyLog start up. At his point,
  	// user config is applied and app UI is ready. But user data was not
  	// read/parsed yet.
  
  	if (this.initDone) { return; }  // already done
  
  	// Create widget DIV if config allows
  	this.create();
  	if (!this.created) { return; }
  
  	// Populate widget contents
  	this.populate();
  
  	// Open the widget, if necessary
  	if (this.config.opened && this.config.active) {
  		this.toggle();
  	}
  
  	this.initDone = true;
  };
  
  Widget.prototype.create = function () {
  	// Append a new widget-box to the #widgets area
  
  	if (!this.config.active) { return; }
  	if (this.created) { return; }  // already done
  
  	// Append elements to #widgets
  	appendHTML(
  		document.getElementById('widgets'),
  		this.boxTemplate
  			.replace(/\{widgetId\}/g, this.id)
  			.replace(/\{widgetName\}/g, i18n[this.instanceName + 'HeaderLabel'] || this.widgetName)
  	);
  
  	this.box = document.getElementById(this.id + '-box');
  	this.header = document.getElementById(this.id + '-header');
  	this.content = document.getElementById(this.id + '-content');
  	this.created = Boolean(this.box && this.header && this.content);
  
  	// Set header tooltip
  	this.header.title = i18n[this.instanceName + 'HeaderHelp'];
  };
  
  Widget.prototype.populate = function () {
  	// Called right after create(), here you create the widget contents.
  	// You must implement this function in your widget.
  };
  
  Widget.prototype.addCheckbox = function (idSuffix, label, checked) {
  	// Append a new checkbox to the widget-box contents
  
  	var template = this.checkboxTemplate;
  	if (checked) {
  		template = template.replace('<input', '<input checked="checked"');
  	}
  
  	appendHTML(
  		this.content,
  		template
  			.replace(/\{widgetId\}/g, this.id)
  			.replace(/\{idSuffix\}/g, idSuffix)
  			.replace(/\{label\}/g, label)
  			.replace(/\{instanceName\}/g, this.instanceName)
  	);
  
  	return document.getElementById(this.id + '-' + idSuffix);  // return the element
  };
  
  Widget.prototype.checkboxClicked = function (element) {  // Event handler
  	// Called when a checkbox made with addCheckbox() is clicked.
  	//   element = clicked checkbox
  	//   this = Widget instance
  	// You must implement this function in your widget.
  	return element;
  };
  
  // Hooks
  Widget.prototype.showReportPost = function () {};  // eslint-disable-line no-empty-function
  
  
  // ------------------------------------------------------------------
  // Tag Summary Widget
  // ------------------------------------------------------------------
  
  TagSummary = new Widget('tag-summary', 'Tag Summary', 'TagSummary');
  
  // Widget config
  TagSummary.config.active = true;       // Is this widget active?
  TagSummary.config.opened = false;      // Start app with this widget opened?
  TagSummary.config.checkSort = false;   // [X] Sort by value checkbox inits checked?
  TagSummary.config.showTagless = true;  // The (no tag) sum should appear?
  
  // In mobile, the widget starts closed (we need vertical space)
  if (isMobile) {
  	TagSummary.config.opened = false;
  }
  
  // UI strings
  i18nDatabase.en.TagSummaryHeaderLabel = 'Tag Summary';
  i18nDatabase.en.TagSummaryHeaderHelp = 'Show/hide the tag summary.';
  i18nDatabase.en.TagSummarySortLabel = 'Sort by value';
  i18nDatabase.en.TagSummarySortHelp = 'Order the Tag Summary by values instead tag names.';
  //
  i18nDatabase.pt.TagSummaryHeaderLabel = 'Somatório de tags';
  i18nDatabase.pt.TagSummaryHeaderHelp = 'Mostra e esconde o somatório das tags.';
  i18nDatabase.pt.TagSummarySortLabel = 'Ordenar por valor';
  i18nDatabase.pt.TagSummarySortHelp = 'Ordena o sumário de tags pelos valores, não pelos nomes.';
  //
  i18nDatabase.es.TagSummaryHeaderLabel = 'Sumario';
  i18nDatabase.es.TagSummaryHeaderHelp = 'Mostrar/esconder Sumario.';
  i18nDatabase.es.TagSummarySortLabel = 'Ordenar por valor';
  i18nDatabase.es.TagSummarySortHelp = 'Ordenar Sumario por valor en lugar de Concepto.';
  
  // Create elements
  TagSummary.populate = function () {
  
  	this.content.innerHTML = [
  		'<div id="tag-summary-data"></div>',
  		'',
  		'<div id="tag-summary-options" class="widget-options">',
  		'	<hr />',
  		'	<div id="tag-summary-opt-nsort-box" class="checkbox-option">',
  		'		<input id="tag-summary-opt-nsort-check" type="checkbox" class="trigger">',
  		'		<label id="tag-summary-opt-nsort-label" for="tag-summary-opt-nsort-check"></label>',
  		'	</div>',
  		'</div>'
  	].join('\n');
  
  	// Set option label & tooltip
  	document.getElementById('tag-summary-opt-nsort-label').title = i18n.TagSummarySortHelp;
  	document.getElementById('tag-summary-opt-nsort-label').innerHTML = i18n.TagSummarySortLabel;
  
  	// Save checkbox element for later use
  	this.sortCheckbox = document.getElementById('tag-summary-opt-nsort-check');
  
  	// Set checkbox action handler
  	this.sortCheckbox.onclick = showReport;
  
  	// Maybe the option should init checked?
  	if (this.config.checkSort) {
  		this.sortCheckbox.checked = true;
  	}
  };
  
  // hook
  TagSummary.showReportPost = function () {
  	this.update();
  };
  
  // Updates the summary with the current report information
  TagSummary.update = function () {
  	var i, leni, j, lenj, tag, results, tagNames, tagData, theData, tableData, rowAmount, rowTags, noTagSum, valueSort;
  
  	results = [];
  	tagNames = [];
  	tagData = {};
  	tableData = [];
  	valueSort = this.sortCheckbox.checked;
  	noTagSum = undefined;  // Do not use 0. The final result may be zero.
  	theData = reportData.clone();
  
  	// Scan report rows
  	for (i = 0, leni = theData.length; i < leni; i++) {
  		// rowDate        = theData[i][0];
  		rowAmount      = theData[i][1];
  		rowTags        = theData[i][2].clone();
  		// rowDescription = theData[i][3];
  
  		if (rowTags.length === 0) {
  			// No tag in this row
  			if (noTagSum === undefined) {
  				noTagSum = 0;
  			}
  			noTagSum += rowAmount;
  
  		} else {
  			// Sum all values for the same tag
  			for (j = 0, lenj = rowTags.length; j < lenj; j++) {
  				tag = rowTags[j];
  
  				// New tag?
  				if (!tagNames.hasItem(tag)) {
  					tagData[tag] = 0;
  					tagNames.push(tag);
  				}
  				tagData[tag] += rowAmount;
  			}
  		}
  	}
  
  	// We have tags?
  	if (tagNames.length || noTagSum !== undefined) {
  
  		// Sort tag names
  		tagNames.sort(sortIgnoreCase);
  
  		// Append no-tag data to the end of the table
  		if (noTagSum !== undefined && this.config.showTagless) {
  			tagNames.push(i18n.labelTagCloudEmpty);
  			tagData[i18n.labelTagCloudEmpty] = noTagSum;
  		}
  
  		// Save table data, sorted by tag name
  		for (i = 0, leni = tagNames.length; i < leni; i++) {
  			tag = tagNames[i];
  			tableData.push([tag, tagData[tag]]);
  		}
  
  		// Sort by value?
  		if (valueSort) {
  			tableData.sort(sortByIndex(1, 'n'));
  		}
  
  		// Compose the HTML table
  		results.push('<table>');
  		for (i = 0, leni = tableData.length; i < leni; i++) {
  			results.push('<tr>');
  			results.push('<td>' + tableData[i][0] + '</td>');
  			results.push('<td class="number"> ' + prettyFloat(tableData[i][1]) + '</td>');
  			results.push('</tr>');
  		}
  		results.push('</table>');
  	}
  
  	// Save results to the respective DIV
  	results = results.join('\n');
  	document.getElementById('tag-summary-data').innerHTML = results;
  
  	// The options box is only shown if we have at least 2 tags
  	document.getElementById('tag-summary-options').style.display = (tableData.length > 1) ? 'block' : 'none';
  };
  
  
  // ------------------------------------------------------------------
  // About Widget
  // ------------------------------------------------------------------
  
  AboutWidget = new Widget('about', 'About', 'AboutWidget');
  
  // Widget config
  AboutWidget.config.active = true;       // Is this widget active?
  AboutWidget.config.opened = false;      // Start app with this widget opened?
  
  // UI strings
  i18nDatabase.en.AboutWidgetHeaderLabel = 'About';
  i18nDatabase.en.AboutWidgetHeaderHelp = 'Show/hide the about box.';
  //
  i18nDatabase.pt.AboutWidgetHeaderLabel = 'Sobre';
  i18nDatabase.pt.AboutWidgetHeaderHelp = 'Mostra e esconde a caixa Sobre.';
  //
  i18nDatabase.es.AboutWidgetHeaderLabel = 'Acerca de';
  i18nDatabase.es.AboutWidgetHeaderHelp = 'Mostrar/esconder Acerca de.';
  
  
  // Create elements
  AboutWidget.populate = function () {
  	var version, commit;
  	var html = [];
  
  	// When in β: always show the commit ID
  	if (isBeta) {
  		version = 'v' + appVersion;
  		commit = linkme(appRepository + '/commit/' + appCommit, appCommit);
  
  	// When stable: hide commit, version links to website
  	} else {
  		version = linkme('https://aurelio.net/moneylog/v' + appVersion + '/', 'v' + appVersion);
  		commit = '';
  	}
  
  	// When in txt mode: link to repository since we can't get the commit hash
  	if (ml.storage.driver.id === 'filesystem') {
  		version = linkme(appRepository, 'v' + appVersion);
  	}
  
  	html.push('<div id="about-app">');
  	html.push(linkme('https://aurelio.net/moneylog/', appName));
  	html.push('<span id="app-version">' + version + '</span>');
  	if (isBeta && appCommit !== '') {
  		html.push('<div>commit: ' + commit + '</div>');
  	}
  	html.push('</div>');
  
  	html.push('<div id="about-copyright">');
  	html.push(linkme('http://en.wikipedia.org/wiki/MIT_license', '©') + (isBeta ? new Date().getYearML() : appYear) + ',');
  	html.push(linkme('http://twitter.com/oreio', '@oreio'));
  	html.push('</div>');
  
  	html.push('<div id="about-donate">');
  	html.push(linkme('https://aurelio.net/moneylog/donate/', '♥'));
  	html.push('</div>');
  
  	html.push('<hr>');
  	html.push('<div id="about-credits">');
  	html.push('UI design:');
  	html.push(linkme('http://twitter.com/xupisco', '@xupisco'));
  	html.push('<br>');
  	html.push('i18n ca:');
  	html.push(linkme('http://twitter.com/pacoriviere', '@pacoriviere'));
  	html.push('<br>');
  	html.push('i18n es:');
  	html.push(linkme('http://twitter.com/g_nemmi', '@g_nemmi'));
  	html.push('</div>');
  
  	this.content.innerHTML = html.join('\n');
  };
  
  
  // ------------------------------------------------------------------
  //                             INIT
  // ------------------------------------------------------------------
  
  function sanitizeConfig() {
  	// Regexize user words: 'Foo Bar+' turns to 'Foo|Bar\+'
  	// Note: Using regex to allow ignorecase and global *atomic* replace
  	if (highlightWords) {
  		highlightRegex = new RegExp(
  			RegExp.escape(highlightWords).replace(/\s+/g, '|'),
  			'ig'
  		);
  	}
  
  	// Some configs may be set as strings or arrays.
  	// If user choose string, let's convert it to an array now.
  	if (typeof highlightTags === 'string') {
  		highlightTags = (highlightTags) ? highlightTags.strip().split(/\s+/) : [];
  	}
  	if (typeof ignoreTags === 'string') {
  		ignoreTags = (ignoreTags) ? ignoreTags.strip().split(/\s+/) : [];
  	}
  	if (typeof initSelectedTags === 'string') {
  		initSelectedTags = (initSelectedTags) ? initSelectedTags.strip().split(/\s+/) : [];
  	}
  	if (typeof initExcludedTags === 'string') {
  		initExcludedTags = (initExcludedTags) ? initExcludedTags.strip().split(/\s+/) : [];
  	}
  
  	// Make sure sort data do not cross min/max limits
  	// max limit
  	sortData.d.index = Math.min(sortData.d.max, sortData.d.index);
  	sortData.m.index = Math.min(sortData.m.max, sortData.m.index);
  	sortData.y.index = Math.min(sortData.y.max, sortData.y.index);
  	// min limit
  	sortData.d.index = Math.max(sortData.d.min, sortData.d.index);
  	sortData.m.index = Math.max(sortData.m.min, sortData.m.index);
  	sortData.y.index = Math.max(sortData.y.min, sortData.y.index);
  	sortData.m.indexTag = Math.max(sortData.m.minTag, sortData.m.indexTag);
  	sortData.y.indexTag = Math.max(sortData.y.minTag, sortData.y.indexTag);
  }
  
  function initUI() {
  	var i;
  
  	// Load the i18n messages (must be the first)
  	i18n = i18nDatabase.getLanguage(lang);
  
  	// Set app URL
  	i18n.appUrl = 'https://moneylog.aurelio.net';
  
  	// Password protected?
  	if (myPassword) {
  		// Prompt user and check
  		// eslint-disable-next-line no-alert
  		if (myPassword != prompt(appName + ' — ' + i18n.msgTypePassword)) {
  			// Destroy full interface and show error
  			document.getElementById('container').innerHTML = '<h2 style="padding:30px;">' + i18n.msgWrongPassword + '</h2>';
  			return;  // abort
  		}
  	}
  
  	// Prepare UI elements
  	populateChartColsCombo();
  	populateRowsSummaryCombo();
  	populateValueFilterCombo();
  
  	// Set interface labels
  	document.getElementById('d'                        ).innerHTML = i18n.labelDaily;
  	document.getElementById('m'                        ).innerHTML = i18n.labelMonthly;
  	document.getElementById('y'                        ).innerHTML = i18n.labelYearly;
  	document.getElementById('opt-value-filter-label'   ).innerHTML = i18n.labelValueFilter + ':';
  	document.getElementById('opt-monthly-label'        ).innerHTML = i18n.labelMonthPartials;
  	document.getElementById('opt-regex-label'          ).innerHTML = i18n.labelSearchRegex;
  	document.getElementById('opt-negate-label'         ).innerHTML = i18n.labelSearchNegate;
  	document.getElementById('tag-cloud-opt-group-label').innerHTML = i18n.labelTagCloudGroup;
  	document.getElementById('tag-cloud-opt-reset-label').innerHTML = i18n.labelTagCloudReset;
  	document.getElementById('storage-header'           ).innerHTML = i18n.labelStorage;
  	document.getElementById('source-reload'            ).innerHTML = i18n.labelReload;
  	document.getElementById('editor-open'              ).innerHTML = i18n.labelEditorOpen;
  	document.getElementById('editor-close'             ).innerHTML = i18n.labelEditorCancel;
  	document.getElementById('editor-save'              ).innerHTML = i18n.labelEditorSave;
  	document.getElementById('view-options-header'      ).innerHTML = i18n.labelViewOptions;
  	document.getElementById('opt-date-1-label'         ).innerHTML = i18n.labelDateFrom + ':';
  	document.getElementById('opt-date-2-label'         ).innerHTML = i18n.labelDateUntil + ':';
  	document.getElementById('tag-cloud-header'         ).innerHTML = i18n.labelTagCloud;
  	document.getElementById('tag-report-opt-related-label').innerHTML = i18n.labelTagReportRelated;
  	document.getElementById('rows-summary-reset'       ).innerHTML = i18n.labelRowsSummaryReset;
  
  	// Set interface tooltips
  	document.getElementById('fullscreen'               ).title = i18n.helpFullScreen;
  	document.getElementById('website'                  ).title = i18n.helpWebsite;
  	document.getElementById('report-nav'               ).title = i18n.helpReports;
  	document.getElementById('opt-value-filter-label'   ).title = i18n.helpValueFilter;
  	document.getElementById('opt-monthly-label'        ).title = i18n.helpMonthPartials;
  	document.getElementById('filter'                   ).title = i18n.helpSearch;
  	document.getElementById('opt-regex-label'          ).title = i18n.helpSearchRegex;
  	document.getElementById('opt-negate-label'         ).title = i18n.helpSearchNegate;
  	document.getElementById('storage-header'           ).title = i18n.helpStorage;
  	document.getElementById('source-reload'            ).title = i18n.helpReload;
  	document.getElementById('tag-cloud-opt-group-label').title = i18n.helpTagCloudGroup;
  	document.getElementById('tag-cloud-opt-reset-label').title = i18n.helpTagCloudReset;
  	document.getElementById('view-options-header'      ).title = i18n.helpViewOptions;
  	document.getElementById('tag-cloud-header'         ).title = i18n.helpTagCloud;
  	document.getElementById('tag-report-opt-related-label').title = i18n.helpTagReportRelated;
  	document.getElementById('rows-summary-reset'       ).title = i18n.helpRowsSummaryReset;
  	document.getElementById('editor-open'              ).title = i18n.helpEditorOpen;
  	document.getElementById('editor-close'             ).title = i18n.helpEditorCancel;
  	document.getElementById('editor-save'              ).title = i18n.helpEditorSave;
  
  	// Mark current report as active (CSS)
  	addClass(document.getElementById(reportType), 'active');
  
  	// Notice the user that we're ignoring some data
  	if (ignoreDataOlderThan) {
  		document.getElementById('footer-message').innerHTML += 'ignoreDataOlderThan = ' + ignoreDataOlderThan + '<br>';
  	}
  	if (ignoreDataNewerThan) {
  		document.getElementById('footer-message').innerHTML += 'ignoreDataNewerThan = ' + ignoreDataNewerThan + '<br>';
  	}
  	if (ignoreTags.length > 0) {
  		document.getElementById('footer-message').innerHTML += 'ignoreTags = ' + ignoreTags.join(', ') + '<br>';
  	}
  
  	// Set initial chart type for the reports (before event handlers)
  	if (reportType === 'd') {
  		document.getElementById('chart-selector').selectedIndex = initChartDaily - 1;
  	} else if (reportType === 'm') {
  		document.getElementById('chart-selector').selectedIndex = initChartMonthly - 1;
  	} else {
  		document.getElementById('chart-selector').selectedIndex = initChartYearly - 1;
  	}
  
  	// Add event handlers
  	document.getElementById('fullscreen'             ).onclick  = toggleFullScreen;
  	document.getElementById('d'                      ).onclick  = changeReport;
  	document.getElementById('m'                      ).onclick  = changeReport;
  	document.getElementById('y'                      ).onclick  = changeReport;
  	document.getElementById('opt-value-filter-check' ).onclick  = toggleValueFilter;
  	document.getElementById('opt-value-filter-combo' ).onchange = valueFilterChanged;
  	document.getElementById('opt-value-filter-number').onkeyup  = showReport;
  	document.getElementById('opt-monthly-check'      ).onclick  = toggleMonthly;
  	document.getElementById('filter'                 ).onkeyup  = showReport;
  	document.getElementById('opt-regex-check'        ).onclick  = showReport;
  	document.getElementById('opt-negate-check'       ).onclick  = showReport;
  	document.getElementById('storage-driver'         ).onchange = ml.storage.driversComboChanged;
  	document.getElementById('source-file'            ).onchange = loadData;
  	document.getElementById('source-reload'          ).onclick  = reloadData;
  	document.getElementById('opt-date-1-check'       ).onclick  = showReport;
  	document.getElementById('opt-date-2-check'       ).onclick  = showReport;
  	document.getElementById('opt-date-1-month-combo' ).onchange = dateRangeComboChanged;
  	document.getElementById('opt-date-2-month-combo' ).onchange = dateRangeComboChanged;
  	document.getElementById('tag-cloud-opt-group-check' ).onclick  = showReport;
  	document.getElementById('tag-cloud-opt-reset-check' ).onclick  = resetTagCloud;
  	document.getElementById('chart-selector'         ).onchange = showReport;
  	document.getElementById('tag-report-opt-related-check').onclick = tagReport;
  	document.getElementById('rows-summary-index'     ).onchange = updateSelectedRowsSummary;
  	document.getElementById('rows-summary-reset'     ).onclick  = resetRowsSummary;
  	document.getElementById('storage-header'         ).onclick  = toggleStorage;
  	document.getElementById('view-options-header'    ).onclick  = toggleViewOptions;
  	document.getElementById('tag-cloud-header'       ).onclick  = toggleTagCloud;
  	document.getElementById('editor-open'            ).onclick  = editorOn;
  	document.getElementById('editor-close'           ).onclick  = editorOff;
  	document.getElementById('editor-save'            ).onclick  = editorSave;
  
  	// Apply user defaults (this code must be after event handlers adding)
  	if (initFullScreen) { toggleFullScreen(); }
  	document.getElementById('opt-regex-check' ).checked = checkRegex;
  	document.getElementById('opt-negate-check').checked = checkNegate;
  	document.getElementById('opt-date-1-check').checked = checkDateFrom;
  	document.getElementById('opt-date-2-check').checked = checkDateUntil;
  	document.getElementById('opt-monthly-check').checked = checkMonthPartials;
  	document.getElementById('tag-report-opt-related-check').checked = checkHideRelatedTags;
  	document.getElementById('filter').value = defaultSearch;
  
  	// These toolbar boxes must be opened at init?
  	toggleStorage(    {open: initStorageWidgetOpen});
  	toggleViewOptions({open: initViewWidgetOpen});
  	toggleTagCloud(   {open: initTagCloudOpen});
  
  	// Maybe hide some widgets?
  	if (!showStorageWidget) {
  		document.getElementById('storage-box').style.display = 'none';
  	}
  	if (!showViewWidget) {
  		document.getElementById('view-options-box').style.display = 'none';
  	}
  	if (!showTagCloud) {
  		document.getElementById('tag-cloud-box').style.display = 'none';
  	}
  
  	// Init all available Widgets one by one
  	Widget.tidyInstances();
  	for (i = 0; i < Widget.instances.length; i++) {
  		Widget.instances[i].init();
  	}
  
  	// User choose other default report, let's update the toolbar accordingly
  	if (reportType !== 'd') {
  		updateToolbar();
  	}
  
  	// Uncomment this line to focus the search box at init
  	// document.getElementById('filter').focus();
  }
  
  function init() {
  
  	sanitizeConfig();
  	initUI();
  
  	// UI is ok, so now let's setup storage and (maybe) load user data
  	// Exception: some cloud storages defer user data loading after the file picker
  	ml.storage.init();
  	ml.storage.setDriver();
  }
  window.onload = init;
  