DasherJava / src / dasherJava / DasherJava.java
DasherJava.java
Raw
package dasherJava;

import java.awt.AWTException;
import java.awt.Component;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map.Entry;
import java.util.stream.Stream;

import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import javax.xml.parsers.ParserConfigurationException;

import dasherJava.core.alphabets.GUIColors;
import dasherJava.core.alphabets.xml.Alphabet;
import dasherJava.core.alphabets.xml.AlphabetFileParser;
import dasherJava.core.alphabets.xml.Colors;
import dasherJava.core.alphabets.xml.ColorsFileParser;
import dasherJava.core.collections.NamedObject;
import dasherJava.core.input.GlobalMouseInput;
import dasherJava.core.input.InputProvider;
import dasherJava.core.input.JoystickInput;
import dasherJava.core.input.JoystickInput.JoystickNotFoundException;
import dasherJava.core.input.SocketInput;
import dasherJava.core.languageModeling.LanguageAlphabet;
import dasherJava.core.languageModeling.LanguageModel;
import dasherJava.core.languageModeling.LanguageModel.LanguageModelTrainingStats;
import dasherJava.core.languageModeling.TrainingFileReader;
import dasherJava.core.languageModeling.TrainingFileWriter;
import dasherJava.core.output.ATSPIImplementation;
import dasherJava.core.output.AccessibilityActionsListTarget;
import dasherJava.core.output.AccessibilityInterface;
import dasherJava.core.output.KeyboardOutput;
import dasherJava.core.output.PauseDasherTarget;
import dasherJava.core.output.RobotKeyboardOutput;
import dasherJava.core.output.SocketOutput;
import dasherJava.core.output.TextCharOutput;
import dasherJava.core.settings.Settings;
import dasherJava.core.settings.SettingsFileReader;
import dasherJava.core.settings.SettingsFileWriter;
import dasherJava.core.startStop.CircleStartStopHandler;
import dasherJava.core.startStop.StartStopHandler;
import dasherJava.core.startStop.TwoBoxesStartStopHandler;
import dasherJava.core.world.SquareWorldView;
import dasherJava.core.world.SquareWorldView.SquareWorldViewOrientation;
import dasherJava.core.world.WorldModel;
import dasherJava.gui.MainFrame;
import dasherJava.gui.SettingsConfigurationChooser;
import dasherJava.gui.SwingWorldGraphics;
import org.xml.sax.SAXException;

public class DasherJava {
	
	public static final String VERSION_STRING = "1.0.0";
	public static final String DATE_STRING = "September 17, 2024";
	public static final String USING_STRING = "Built with Java 17, using JInput 2.0.10 and JNA 5.14.0";
	public static final String WEBSITE_STRING = "https://github.com/janschu99/DasherJava";
	public static final String BASE_DIRECTORY = System.getProperty("user.home")+File.separator+".dasherJava";
	
	private static SettingsConfigurationChooser settingsConfigurationChooser = null;
	private static volatile String configurationName;
	private static volatile Settings settings = Settings.DEFAULT_SETTINGS;
	private static volatile MainFrame mainFrame = null;
	private static volatile GUIColors guiColors = null;
	private static volatile SocketInput socketInput = null;
	private static volatile ATSPIImplementation atspiImplementation = null;
	private static volatile KeyboardOutput keyboardOutput = null;
	private static volatile SocketOutput socketOutput = null;
	private static boolean initializedAccessibilityInterfaceToListActions = false;
	private static volatile TrainingFileWriter userTrainingFileWriter;
	
	private static volatile List<Alphabet> alphabets;
	private static volatile List<Colors> colors;
	
	public static void main(String[] args) {
		System.out.println("DasherJava "+VERSION_STRING+" started");
		System.out.println("Released "+DATE_STRING+". See "+WEBSITE_STRING+" for more information");
		System.out.println(USING_STRING);
		installDefaultResources();
		alphabets=parseAlphabetsDirectory(BASE_DIRECTORY+File.separator+"alphabets");
		colors=parseColorsDirectory(BASE_DIRECTORY+File.separator+"colors");
		List<String> foundConfigurations = parseSettingsDirectory(BASE_DIRECTORY+File.separator+"settings");
		SwingUtilities.invokeLater(() ->
				settingsConfigurationChooser=new SettingsConfigurationChooser(foundConfigurations));
		boolean openConfigurationChooser = true;
		if (args.length>0) {
			try {
				initWithSettings(args[0]);
				openConfigurationChooser=false;
			} catch (IOException ex) {
				showErrorMessage(ex);
			}
		} else System.out.println("No configuration specified, opening configuration chooser");
		if (openConfigurationChooser) SwingUtilities.invokeLater(() -> settingsConfigurationChooser.setVisible(true));
	}
	
	public static void initWithSettings(String configurationName) throws IOException { //init using configuration name
		Settings settingsRead = readSettingsFile(configurationName);
		DasherJava.configurationName=configurationName;
		initWithSettings(settingsRead, false);
	}
	
	public static void initWithSettings(Settings settings, boolean save) { //all initialization code that uses settings
		if (save) {
			try {
				writeSettingsFile(configurationName, settings, false);
			} catch (IOException ex) {
				showErrorMessage(mainFrame.getSettingsDialog(), ex);
			}
		}
		String alphabetName = settings.getAlphabetName();
		Alphabet selectedAlphabet = NamedObject.findObjectByName(alphabetName, alphabets);
		if (selectedAlphabet==null) {
			String message = alphabetName.isEmpty() ? "No alphabet selected, please select one"
					: "Alphabet \""+alphabetName+"\" not found, please select a different one";
			System.out.println(message);
			SwingUtilities.invokeLater(() -> {
				if (mainFrame!=null) mainFrame.setVisible(false);
				String newAlphabetName = (String) JOptionPane.showInputDialog(null, message, "Select Alphabet",
						JOptionPane.QUESTION_MESSAGE, null, NamedObject.buildNamesArray(alphabets), null);
				if (newAlphabetName==null) settingsConfigurationChooser.setVisible(true); //canceled by user
				else {
					settings.insertIntoAlphabetHistory(newAlphabetName);
					initWithSettings(settings, true);
				}
			});
			return;
		}
		String colorsName = settings.getColorPaletteNameOverride();
		if (colorsName.isEmpty()) colorsName=selectedAlphabet.getColorsName();
		Colors selectedColors = NamedObject.findObjectByName(colorsName, colors);
		if (selectedColors==null && !colorsName.equals(selectedAlphabet.getColorsName())) {
			showErrorMessage("Color palette \""+colorsName+"\" not found, switching to alphabet-default color "
					+"palette (\""+selectedAlphabet.getColorsName()+"\")");
			settings.setColorPaletteNameOverride(""); //alphabet-default
			colorsName=selectedAlphabet.getColorsName();
			selectedColors=NamedObject.findObjectByName(colorsName, colors);
		}
		if (selectedColors==null) {
			String message = colorsName.isEmpty() ? "No color palette selected, please select one"
					: "Color palette \""+colorsName+"\" not found, please select a different one";
			System.out.println(message);
			SwingUtilities.invokeLater(() -> {
				if (mainFrame!=null) mainFrame.setVisible(false);
				String newColorsName = (String) JOptionPane.showInputDialog(null, message, "Select Color Palette",
						JOptionPane.QUESTION_MESSAGE, null, NamedObject.buildNamesArray(colors), null);
				if (newColorsName==null) settingsConfigurationChooser.setVisible(true); //canceled by user
				else {
					settings.setColorPaletteNameOverride(newColorsName);
					initWithSettings(settings, true);
				}
			});
			return;
		}
		closeUserTrainingFileWriter();
		if (atspiImplementation!=null) atspiImplementation.terminate();
		terminateSockets();
		DasherJava.settings=settings;
		if (selectedAlphabet.usesKeyboardOutput()) {
			try {
				keyboardOutput=new RobotKeyboardOutput();
			} catch (AWTException ex) {
				showErrorMessage("DasherJava: Couldn't create RobotKeyboardOutput", ex);
			}
		} else keyboardOutput=null;
		if (settings.getTextOutputTarget().equals("external") || selectedAlphabet.usesAccessibilityInterface()) {
			try {
				atspiImplementation=new ATSPIImplementation();
			} catch (UnsatisfiedLinkError error) {
				showErrorMessage("DasherJava: Couldn't create ATSPIImplementation", error);
			}
		} else atspiImplementation=null;
		if (selectedAlphabet.usesSocketOutput()) socketOutput=new SocketOutput(settings.getSocketOutputPort());
		else socketOutput=null;
		InputProvider inputProvider = null; //use mouse input
		String inputProviderSetting = settings.getInputProvider();
		switch (inputProviderSetting) {
			case "mouse":
				break;
			case "globalMouse":
				inputProvider=new GlobalMouseInput();
				break;
			case "joystick":
				try {
					inputProvider=new JoystickInput(settings.getJoystickInputName(),
							settings.getJoystickInputComponentNameX(), settings.getJoystickInputComponentNameY(),
							settings.getJoystickInputComponentNameStartStop(), settings.getJoystickInputSensitivityX(),
							settings.getJoystickInputSensitivityY(), settings.getJoystickInputOffsetX(),
							settings.getJoystickInputOffsetY());
				} catch (JoystickNotFoundException ex) {
					showErrorMessage("DasherJava: Couldn't create JoystickInput", ex);
				}
				break;
			case "socket":
				socketInput=new SocketInput(settings.getSocketInputPort());
				inputProvider=socketInput;
				break;
			default:
				showErrorMessage("Invalid inputProvider setting: \""+inputProviderSetting+"\". Must be either "
						+"\"mouse\", \"globalMouse\", \"joystick\" or \"socket\", assuming \"mouse\"");
				break;
		}
		guiColors=selectedColors.getGUIColors();
		StartStopHandler<SquareWorldView> startStopHandler = null;
		String startStopHandlerSetting = settings.getStartStopHandler();
		switch (startStopHandlerSetting) {
			case "none":
				break;
			case "circle":
				startStopHandler=new CircleStartStopHandler();
				break;
			case "twoBoxes":
				startStopHandler=new TwoBoxesStartStopHandler();
				break;
			default:
				showErrorMessage("Invalid startStopHandler setting: \""+startStopHandlerSetting
						+"\". Must be either \"none\", \"circle\" or \"twoBoxes\", assuming \"none\"");
				break;
		}
		WorldModel worldModel = new WorldModel(createAndTrainLanguageModel(
				selectedAlphabet.getLanguageAlphabet(selectedColors), selectedAlphabet.getTrainingFilename()),
				settings.getMaxNumberOfNodes(), settings.getMinGainForNodeTrade(),
				settings.getMaxNumberOfOldRootNodes());
		SwingWorldGraphics worldGraphics = new SwingWorldGraphics();
		SquareWorldViewOrientation orientation = SquareWorldViewOrientation.getResultingOrientation(
				selectedAlphabet.getOrientation(), settings.getOrientationOverride());
		SquareWorldView worldView = new SquareWorldView(0, 0, settings.getSpaceBehindNodes(), orientation,
				worldModel, worldGraphics, startStopHandler); //no size known yet
		StartStopHandler<SquareWorldView> finalStartStopHandler = startStopHandler;
		InputProvider finalInputProvider = inputProvider;
		SwingUtilities.invokeLater(() -> {
			if (mainFrame==null) mainFrame=new MainFrame();
			mainFrame.setContent(worldView, worldGraphics, finalStartStopHandler, finalInputProvider);
		});
	}
	
	public static GUIColors getGUIColors() {
		return guiColors;
	}
	
	public static Settings getSettings() {
		return settings;
	}
	
	public static ViewPanelBounds getViewPanelBoundsOnScreen() {
		if (mainFrame!=null) return mainFrame.getViewPanelBoundsOnScreen();
		return null;
	}
	
	public static TextCharOutput getTextCharOutput() {
		if (settings.getTextOutputTarget().equals("external")) return atspiImplementation;
		return mainFrame.getInternalTextCharOutput();
	}
	
	public static PauseDasherTarget getPauseDasherTarget() {
		return mainFrame.getPauseDasherTarget();
	}
	
	public static KeyboardOutput getKeyboardOutput() {
		return keyboardOutput;
	}
	
	public static AccessibilityInterface getAccessibilityInterface() {
		return atspiImplementation;
	}
	
	public static SocketOutput getSocketOutput() {
		return socketOutput;
	}
	
	public static void setAccessibilityActionsListTarget(AccessibilityActionsListTarget target) {
		if (target==null) {
			if (atspiImplementation!=null) {
				atspiImplementation.setAccessibilityActionsListTarget(null);
				if (initializedAccessibilityInterfaceToListActions) {
					atspiImplementation.terminate();
					atspiImplementation=null;
					initializedAccessibilityInterfaceToListActions=false;
				}
			}
		} else {
			if (atspiImplementation==null) {
				try {
					atspiImplementation=new ATSPIImplementation();
					initializedAccessibilityInterfaceToListActions=true;
				} catch (UnsatisfiedLinkError error) {
					showErrorMessage("DasherJava: Couldn't create ATSPIImplementation", error);
				}
			}
			if (atspiImplementation!=null) atspiImplementation.setAccessibilityActionsListTarget(target);
		}
	}
	
	public static boolean isMovementBlocked() {
		return !mainFrame.isCurrentlyShowing() || mainFrame.getSettingsDialog().isCurrentlyShowing();
	}
	
	public static void showConfigurationChooser() {
		mainFrame.setVisible(false);
		mainFrame.writeWindowParameters(settings);
		try {
			writeSettingsFile(configurationName, settings, false);
		} catch (IOException ex) {
			showErrorMessage("showConfigurationChooser()", ex);
		}
		settingsConfigurationChooser.setVisible(true);
	}
	
	public static void showSettingsDialog() {
		mainFrame.writeWindowParameters(settings);
		mainFrame.getSettingsDialog().showSettingsDialog(settings, alphabets, colors, configurationName);
	}
	
	public static void changeAlphabet(String newAlphabetName) {
		if (doesAlphabetExist(newAlphabetName)) {
			settings.insertIntoAlphabetHistory(newAlphabetName);
			if (SwingUtilities.isEventDispatchThread()) {
				if (mainFrame!=null) mainFrame.writeWindowParameters(settings);
				initWithSettings(settings, true);
			} else SwingUtilities.invokeLater(() -> {
				if (mainFrame!=null) mainFrame.writeWindowParameters(settings);
				initWithSettings(settings, true);
			});
		}
	}
	
	public static void doExit(boolean save) { //May only be called on the EDT since it works with GUI components!
		boolean doExit = true;
		if (settings.getConfirmExit().equals("always") || settings.getConfirmExit().equals("whenTextEntered")
				&& mainFrame!=null && mainFrame.doesTextAreaContainText()) {
			doExit=JOptionPane.showConfirmDialog(mainFrame, "Really exit?", "Confirm Exit",
					JOptionPane.OK_CANCEL_OPTION)==JOptionPane.OK_OPTION;
		}
		if (doExit) {
			if (save) {
				if (mainFrame!=null) mainFrame.writeWindowParameters(settings);
				try {
					writeSettingsFile(configurationName, settings, false);
				} catch (IOException ex) {
					System.out.println(ex.getMessage()); //Cannot show a GUI dialog here since we are about to exit.
				}
			}
			closeUserTrainingFileWriter();
			if (atspiImplementation!=null) atspiImplementation.terminate();
			ATSPIImplementation.exit();
			terminateSockets();
			System.out.println("DasherJava "+VERSION_STRING+" exiting");
			System.exit(0);
		}
	}
	
	private static void closeUserTrainingFileWriter() {
		TrainingFileWriter writer = userTrainingFileWriter; //for atomicity
		if (writer!=null) {
			try {
				writer.close();
			} catch (IOException ex) {
				showErrorMessage("DasherJava: Couldn't close user training file writer", ex);
			}
			userTrainingFileWriter=null;
		}
	}
	
	private static void terminateSockets() {
		if (socketInput!=null) socketInput.terminate();
		if (socketOutput!=null) socketOutput.terminate();
	}
	
	private static Settings readSettingsFile(String configurationName) throws IOException {
		String settingsFilename = BASE_DIRECTORY+File.separator+"settings"+File.separator+configurationName+".txt";
		try {
			return SettingsFileReader.readSettingsFile(settingsFilename);
		} catch (IOException ex) {
			throw new IOException("Couldn't read settings file \""+settingsFilename+"\": "+ex.getMessage(), ex);
		}
	}
	
	public static void writeSettingsFile(String configurationName, Settings settingsToWrite, boolean failIfExisting)
			throws IOException {
		String settingsFilename = BASE_DIRECTORY+File.separator+"settings"+File.separator+configurationName+".txt";
		try {
			SettingsFileWriter.writeSettingsFile(settingsFilename, settingsToWrite, failIfExisting);
		} catch (IOException ex) {
			throw new IOException("Couldn't write settings file \""+settingsFilename+"\": "+ex.getMessage(), ex);
		}
	}
	
	public static void duplicateSettingsFile(String configurationName, String newConfigurationName) throws IOException {
		String originalName = BASE_DIRECTORY+File.separator+"settings"+File.separator+configurationName+".txt";
		String duplicatedName = BASE_DIRECTORY+File.separator+"settings"+File.separator+newConfigurationName+".txt";
		Path original = Path.of(originalName);
		Path duplicated = Path.of(duplicatedName);
		try {
			Files.copy(original, duplicated, StandardCopyOption.COPY_ATTRIBUTES);
		} catch (IOException ex) {
			throw new IOException("Couldn't duplicate settings file \""+originalName+"\" to \""+duplicatedName+"\": "
					+ex.getMessage(), ex);
		}
		//Copying silently does nothing when the source and target file are the same, but we still want to inform the
		//user that no duplication has happened, so we manually check after copying.
		if (Files.isSameFile(original, duplicated)) throw new FileAlreadyExistsException("Couldn't duplicate settings "
				+"file \""+originalName+"\" to \""+duplicatedName+"\"");
	}
	
	public static void renameSettingsFile(String configurationName, String newConfigurationName) throws IOException {
		String originalName = BASE_DIRECTORY+File.separator+"settings"+File.separator+configurationName+".txt";
		String newName = BASE_DIRECTORY+File.separator+"settings"+File.separator+newConfigurationName+".txt";
		Path original = Path.of(originalName);
		Path newPath = Path.of(newName);
		try {
			Files.move(original, newPath);
		} catch (IOException ex) {
			throw new IOException("Couldn't rename settings file \""+originalName+"\" to \""+newName+"\": "
					+ex.getMessage(), ex);
		}
	}
	
	public static void deleteSettingsFile(String configurationName) throws IOException {
		String settingsFilename = BASE_DIRECTORY+File.separator+"settings"+File.separator+configurationName+".txt";
		Path path = Path.of(settingsFilename);
		try {
			Files.delete(path);
		} catch (IOException ex) {
			throw new IOException("Couldn't delete settings file \""+settingsFilename+"\": "+ex.getMessage(), ex);
		}
	}
	
	private static List<Alphabet> parseAlphabetsDirectory(String directoryName) {
		List<Alphabet> foundAlphabets = new ArrayList<>();
		File[] alphabetFiles = new File(directoryName).listFiles();
		if (alphabetFiles==null) {
			showErrorMessage("Couldn't read alphabets directory \""+directoryName+"\"");
			return foundAlphabets; //empty list
		}
		for (File alphabetFile : alphabetFiles) {
			if (alphabetFile.isFile()) {
				try {
					foundAlphabets.add(new AlphabetFileParser(alphabetFile.getAbsolutePath()).getAlphabet());
				} catch (ParserConfigurationException|SAXException|IOException ex) {
					showErrorMessage("Couldn't read alphabet file \""+alphabetFile.getAbsolutePath()+"\"", ex);
				}
			}
		}
		List<String> duplicatedNames = NamedObject.removeDuplicates(foundAlphabets);
		for (String duplicatedName : duplicatedNames) {
			showErrorMessage("Found multiple alphabets with name \""+duplicatedName+"\", ignoring all of them");
		}
		NamedObject.sortCaseInsensitive(foundAlphabets); //sort alphabetically
		return foundAlphabets;
	}
	
	private static List<Colors> parseColorsDirectory(String directoryName) {
		List<Colors> foundColors = new ArrayList<>();
		File[] colorsFiles = new File(directoryName).listFiles();
		if (colorsFiles==null) {
			showErrorMessage("Couldn't read colors directory \""+directoryName+"\"");
			return foundColors; //empty list
		}
		for (File colorsFile : colorsFiles) {
			if (colorsFile.isFile()) {
				try {
					foundColors.add(new ColorsFileParser(colorsFile.getAbsolutePath()).getColors());
				} catch (ParserConfigurationException|SAXException|IOException ex) {
					showErrorMessage("Couldn't read colors file \""+colorsFile.getAbsolutePath()+"\"", ex);
				}
			}
		}
		List<String> duplicatedNames = NamedObject.removeDuplicates(foundColors);
		for (String duplicatedName : duplicatedNames) {
			showErrorMessage("Found multiple color palettes with name \""+duplicatedName+"\", ignoring all of them");
		}
		//now handle inheritance
		for (int i = 0; i<foundColors.size(); i++) {
			Colors c = foundColors.get(i);
			List<Colors> usedColors = new ArrayList<>(foundColors.size()); //to detect cyclic inheritance
			usedColors.add(c);
			while (true) {
				String parentName = c.getParentName();
				if (parentName.isEmpty()) break;
				Colors parent = NamedObject.findObjectByName(parentName, foundColors);
				if (parent==null) {
					showErrorMessage("Color palette \""+parentName+"\" (parent of \""+c.getName()+"\") not found");
					break;
				}
				if (usedColors.contains(parent)) {
					showErrorMessage("Cyclic inheritance detected for color palette \""+foundColors.get(i).getName()
							+"\"");
					break;
				}
				usedColors.add(parent);
				c=c.combineWithParent(parent);
			}
			foundColors.set(i, c);
		}
		for (int i = 0; i<foundColors.size(); i++) {
			Colors c = foundColors.get(i);
			if (!c.isComplete()) {
				showErrorMessage("Removed incomplete color palette \""+c.getName()+"\"");
				foundColors.remove(i);
				i--;
			}
		}
		NamedObject.sortCaseInsensitive(foundColors); //sort alphabetically
		return foundColors;
	}
	
	private static List<String> parseSettingsDirectory(String directoryName) {
		List<String> foundConfigurations = new ArrayList<>();
		File[] settingsFiles = new File(directoryName).listFiles();
		if (settingsFiles==null) {
			showErrorMessage("Couldn't read settings directory \""+directoryName+"\"");
			return foundConfigurations; //empty list
		}
		for (File settingsFile : settingsFiles) {
			if (settingsFile.isFile()) {
				String fileName = settingsFile.getName();
				if (fileName.endsWith(".txt") && fileName.length()>4) //remove .txt extension
					fileName=fileName.substring(0, fileName.length()-4);
				foundConfigurations.add(fileName);
			}
		}
		return foundConfigurations;
	}
	
	private static void installDefaultResources() {
		try {
			//Note: For resources inside the JAR we always have to use a forward slash as path separator
			replaceDirectoryIfMissingOrEmpty("resources/alphabets", BASE_DIRECTORY+File.separator+"alphabets");
			replaceDirectoryIfMissingOrEmpty("resources/colors", BASE_DIRECTORY+File.separator+"colors");
			replaceDirectoryIfMissingOrEmpty("resources/libs", BASE_DIRECTORY+File.separator+"libs");
			replaceDirectoryIfMissingOrEmpty("resources/settings", BASE_DIRECTORY+File.separator+"settings");
			replaceDirectoryIfMissingOrEmpty("resources/systemTrainingTexts", BASE_DIRECTORY+File.separator
					+"systemTrainingTexts");
			Files.createDirectories(Path.of(BASE_DIRECTORY+File.separator+"userTrainingTexts"));
		} catch (IOException|URISyntaxException ex) {
			showErrorMessage("installDefaultResources()", ex);
		}
	}
	
	private static void replaceDirectoryIfMissingOrEmpty(String sourceDirectoryName, String targetDirectoryName)
			throws IOException, URISyntaxException {
		Files.createDirectories(Path.of(targetDirectoryName));
		File[] targetFiles = new File(targetDirectoryName).listFiles();
		if (targetFiles==null)
			throw new IOException("Couldn't read target directory \""+targetDirectoryName+"\"");
		if (targetFiles.length>0) return; //target directory not empty, don't copy
		URL resourcesBase = DasherJava.class.getResource("");
		if (resourcesBase==null) throw new IOException("Couldn't determine resources base");
		if (resourcesBase.toString().startsWith("jar")) { //extract files from jar file
		                                                  //adapted from https://stackoverflow.com/a/24316335
			try (FileSystem fileSystem = FileSystems.newFileSystem(resourcesBase.toURI(),
					Collections.<String, String>emptyMap())) {
				Path jarPath = fileSystem.getPath(sourceDirectoryName);
				if (!Files.exists(jarPath)) {
					throw new IOException("Couldn't read resource directory \""+sourceDirectoryName
							+"\" inside JAR file");
				}
				try (Stream<Path> sourceFiles = Files.list(jarPath)) {
					for (Path sourceFile : sourceFiles.toList()) {
						Files.copy(sourceFile, Path.of(targetDirectoryName+File.separator+sourceFile.getFileName()),
								StandardCopyOption.COPY_ATTRIBUTES);
					}
				}
			}
		} else { //copy files from resource directory
			File[] sourceFiles = new File(sourceDirectoryName).listFiles();
			if (sourceFiles==null)
				throw new IOException("Couldn't read resource directory \""+sourceDirectoryName+"\"");
			for (File sourceFile : sourceFiles) {
				Files.copy(sourceFile.toPath(), Path.of(targetDirectoryName+File.separator+sourceFile.getName()),
						StandardCopyOption.COPY_ATTRIBUTES);
			}
		}
	}
	
	private static LanguageModel createAndTrainLanguageModel(LanguageAlphabet languageAlphabet,
	                                                         String trainingFilename) {
		LanguageModel languageModel = new LanguageModel(languageAlphabet, getSettings().getPPMMaxOrder(),
				getSettings().getPPMAlpha(), getSettings().getPPMBeta(), getSettings().getPPMUniform());
		LanguageModelTrainingStats trainingStats = new LanguageModelTrainingStats();
		trainingStats.incrementNumOfContextTrieNodes(); //language model root
		if (trainingFilename!=null && !trainingFilename.isEmpty()) {
			trainLanguageModel(languageModel, languageAlphabet, trainingFilename, false, trainingStats);
			trainLanguageModel(languageModel, languageAlphabet, trainingFilename, true, trainingStats);
			String userTrainingFilePath = BASE_DIRECTORY+File.separator+"userTrainingTexts"+File.separator
					+trainingFilename;
			try {
				userTrainingFileWriter=new TrainingFileWriter(userTrainingFilePath);
			} catch (IOException ex) {
				showErrorMessage("DasherJava: Couldn't open user training file \""+userTrainingFilePath
						+"\" for writing", ex);
			}
		} else if (languageAlphabet.getFixedProbabilityCharacters().size()!=languageAlphabet.getNumOfCharacters())
				//don't need a training file if all characters have a fixed probability anyway
			showErrorMessage("No training file specified, assuming uniform distribution");
		return languageModel;
	}
	
	private static void trainLanguageModel(LanguageModel languageModel, LanguageAlphabet languageAlphabet,
	                                       String trainingFilename, boolean user,
	                                       LanguageModelTrainingStats trainingStats) {
		String fullTrainingFilePath = BASE_DIRECTORY+File.separator+(user ? "userTrainingTexts" : "systemTrainingTexts")
				+File.separator+trainingFilename;
		try (TrainingFileReader trainingFileReader = new TrainingFileReader(fullTrainingFilePath, languageAlphabet)) {
			languageModel.train(trainingFileReader, trainingStats);
			System.out.println("Language model training with "+(user ? "user" : "system")
					+" training text done, read "+trainingStats.getNumOfSymbolsRead()
					+" symbols from the training text and created "+trainingStats.getNumOfContextTrieNodes()
					+" context trie nodes"+(user ? " (statistics accumulated with system training text)" : "")
					+". Skipped symbols are listed below, if any"
					+(user ? " (also accumulated with system training text)" : "")+".");
			for (Entry<Integer, Integer> entry : trainingStats.getSkippedSymbols().entrySet()) {
				System.out.println("  Skipped "+entry.getValue()+" occurrence(s) of Unicode value "+entry.getKey()
						+" because the current alphabet does not contain such character");
			}
		} catch (IOException ex) {
			showErrorMessage("Couldn't read "+(user ? "user" : "system")+" training text", ex);
		}
	}
	
	public static void appendToUserTrainingFile(int unicode) {
		TrainingFileWriter writer = userTrainingFileWriter; //for atomicity
		if (writer!=null) {
			try {
				writer.writeCodePoint(unicode);
			} catch (IOException ex) {
				showErrorMessage("DasherJava: Couldn't write to user training file", ex);
			}
		}
	}
	
	public static boolean doesAlphabetExist(String alphabetName) {
		for (Alphabet alphabet : alphabets) {
			if (alphabet.getName().equals(alphabetName)) return true;
		}
		return false;
	}
	
	public static void showErrorMessage(String message) {
		showErrorMessage(mainFrame, message, null);
	}
	
	public static void showErrorMessage(Throwable throwable) {
		showErrorMessage(mainFrame, null, throwable);
	}
	
	public static void showErrorMessage(String message, Throwable throwable) {
		showErrorMessage(mainFrame, message, throwable);
	}
	
	public static void showErrorMessage(Component parent, Throwable throwable) {
		showErrorMessage(parent, null, throwable);
	}
	
	public static void showErrorMessage(Component parent, String message, Throwable throwable) {
		StringBuilder fullMessageBuilder = new StringBuilder();
		if (message!=null) fullMessageBuilder.append(message);
		if (throwable!=null) {
			if (message!=null) fullMessageBuilder.append(": ");
			fullMessageBuilder.append(throwable.getClass());
			fullMessageBuilder.append(": ");
			fullMessageBuilder.append(throwable.getMessage());
			Throwable cause = throwable.getCause();
			if (cause!=null) {
				fullMessageBuilder.append(": ");
				fullMessageBuilder.append(cause.getClass());
				fullMessageBuilder.append(": ");
				fullMessageBuilder.append(cause.getMessage());
			}
		}
		String fullMessage = fullMessageBuilder.toString();
		System.out.println(fullMessage);
		if (SwingUtilities.isEventDispatchThread()) {
			JOptionPane.showMessageDialog(parent, fullMessage, "Error", JOptionPane.ERROR_MESSAGE);
		} else {
			try {
				SwingUtilities.invokeAndWait(() -> JOptionPane.showMessageDialog(parent, fullMessage, "Error",
						JOptionPane.ERROR_MESSAGE));
			} catch (InterruptedException ex) {
				System.out.println("showError(): InterruptedException: "+ex.getMessage());
			} catch (InvocationTargetException ex) {
				System.out.println("showError(): InvocationTargetException: "+ex.getMessage());
			}
		}
	}
	
	public static class ViewPanelBounds { //simple rectangle class
		private final int x;
		private final int y;
		private final int width;
		private final int height;
		public ViewPanelBounds(int x, int y, int width, int height) {
			this.x=x;
			this.y=y;
			this.width=width;
			this.height=height;
		}
		public int getX() {
			return x;
		}
		public int getY() {
			return y;
		}
		public int getWidth() {
			return width;
		}
		public int getHeight() {
			return height;
		}
	}
}