Removed useless admin stuff, changed to save files as JSON, added image scaling for exports.
This commit is contained in:
		
							parent
							
								
									d63eb3345f
								
							
						
					
					
						commit
						bc464ba42b
					
				|  | @ -1,6 +1,11 @@ | ||||||
| # Entity-Relation Mapping Editor | # Entity-Relation Mapping Editor | ||||||
| A simple UI for editing entity-relation mapping diagrams. | A simple UI for editing entity-relation mapping diagrams. | ||||||
| 
 | 
 | ||||||
|  | ## Usage | ||||||
|  | This program is distributed as an executable **jar** file. You can find the latest release [here](https://github.com/andrewlalis/EntityRelationMappingEditor/releases). You will need Java installed on your computer (version 8 or higher). [You can install an OpenJDK version of Java here.](https://adoptium.net/) | ||||||
|  | 
 | ||||||
|  | Simply double-click on the jar file, or execute `java -jar <jar file>` from the command line (where `<jar file>` is replaced with the path to your actual jar file). | ||||||
|  | 
 | ||||||
|  |  | ||||||
| 
 | 
 | ||||||
| ## How to Use | ## How to Use | ||||||
|  |  | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 8.7 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										12
									
								
								pom.xml
								
								
								
								
							
							
						
						
									
										12
									
								
								pom.xml
								
								
								
								
							|  | @ -6,7 +6,7 @@ | ||||||
| 
 | 
 | ||||||
|     <groupId>nl.andrewlalis</groupId> |     <groupId>nl.andrewlalis</groupId> | ||||||
|     <artifactId>EntityRelationMappingEditor</artifactId> |     <artifactId>EntityRelationMappingEditor</artifactId> | ||||||
|     <version>1.5.0</version> |     <version>1.6.0</version> | ||||||
|     <build> |     <build> | ||||||
|         <plugins> |         <plugins> | ||||||
|             <plugin> |             <plugin> | ||||||
|  | @ -50,13 +50,19 @@ | ||||||
|         <dependency> |         <dependency> | ||||||
|             <groupId>com.formdev</groupId> |             <groupId>com.formdev</groupId> | ||||||
|             <artifactId>flatlaf</artifactId> |             <artifactId>flatlaf</artifactId> | ||||||
|             <version>1.0-rc3</version> |             <version>1.6.1</version> | ||||||
|         </dependency> |         </dependency> | ||||||
|         <dependency> |         <dependency> | ||||||
|             <groupId>org.projectlombok</groupId> |             <groupId>org.projectlombok</groupId> | ||||||
|             <artifactId>lombok</artifactId> |             <artifactId>lombok</artifactId> | ||||||
|             <version>1.18.16</version> |             <version>1.18.22</version> | ||||||
|             <scope>provided</scope> |             <scope>provided</scope> | ||||||
|         </dependency> |         </dependency> | ||||||
|  |         <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind --> | ||||||
|  |         <dependency> | ||||||
|  |             <groupId>com.fasterxml.jackson.core</groupId> | ||||||
|  |             <artifactId>jackson-databind</artifactId> | ||||||
|  |             <version>2.13.0</version> | ||||||
|  |         </dependency> | ||||||
|     </dependencies> |     </dependencies> | ||||||
| </project> | </project> | ||||||
|  | @ -1,31 +1,16 @@ | ||||||
| package nl.andrewlalis.erme; | package nl.andrewlalis.erme; | ||||||
| 
 | 
 | ||||||
| import com.formdev.flatlaf.FlatLightLaf; | import com.formdev.flatlaf.FlatLightLaf; | ||||||
| import nl.andrewlalis.erme.util.Hash; |  | ||||||
| import nl.andrewlalis.erme.view.EditorFrame; | import nl.andrewlalis.erme.view.EditorFrame; | ||||||
| 
 | 
 | ||||||
| import java.nio.charset.StandardCharsets; |  | ||||||
| 
 |  | ||||||
| public class EntityRelationMappingEditor { | public class EntityRelationMappingEditor { | ||||||
| 	public static final String VERSION = "1.5.0"; | 	public static final String VERSION = "1.6.0"; | ||||||
| 
 | 
 | ||||||
| 	public static void main(String[] args) { | 	public static void main(String[] args) { | ||||||
| 		if (!FlatLightLaf.install()) { | 		if (!FlatLightLaf.setup()) { | ||||||
| 			System.err.println("Could not install FlatLight Look and Feel."); | 			System.err.println("Could not install FlatLight Look and Feel."); | ||||||
| 		} | 		} | ||||||
| 		final boolean includeAdminActions = shouldIncludeAdminActions(args); | 		EditorFrame frame = new EditorFrame(); | ||||||
| 		if (includeAdminActions) { |  | ||||||
| 			System.out.println("Admin actions have been enabled."); |  | ||||||
| 		} |  | ||||||
| 		EditorFrame frame = new EditorFrame(includeAdminActions); |  | ||||||
| 		frame.setVisible(true); | 		frame.setVisible(true); | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	private static boolean shouldIncludeAdminActions(String[] args) { |  | ||||||
| 		if (args.length < 1) { |  | ||||||
| 			return false; |  | ||||||
| 		} |  | ||||||
| 		byte[] pw = args[0].getBytes(StandardCharsets.UTF_8); |  | ||||||
| 		return Hash.matches(pw, "admin_hash.txt"); |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,7 +4,6 @@ import lombok.Setter; | ||||||
| import nl.andrewlalis.erme.model.MappingModel; | import nl.andrewlalis.erme.model.MappingModel; | ||||||
| import nl.andrewlalis.erme.model.Relation; | import nl.andrewlalis.erme.model.Relation; | ||||||
| import nl.andrewlalis.erme.view.DiagramPanel; | import nl.andrewlalis.erme.view.DiagramPanel; | ||||||
| import nl.andrewlalis.erme.view.view_models.AttributeViewModel; |  | ||||||
| import nl.andrewlalis.erme.view.view_models.MappingModelViewModel; | import nl.andrewlalis.erme.view.view_models.MappingModelViewModel; | ||||||
| 
 | 
 | ||||||
| import javax.imageio.ImageIO; | import javax.imageio.ImageIO; | ||||||
|  | @ -77,9 +76,18 @@ public class ExportToImageAction extends AbstractAction { | ||||||
| 			} else { | 			} else { | ||||||
| 				chosenFile = new File(chosenFile.getParent(), chosenFile.getName() + '.' + extension); | 				chosenFile = new File(chosenFile.getParent(), chosenFile.getName() + '.' + extension); | ||||||
| 			} | 			} | ||||||
|  | 			String input = JOptionPane.showInputDialog(this.diagramPanel, "Choose a scale for the image.", "3.0"); | ||||||
|  | 			float scale; | ||||||
|  | 			try { | ||||||
|  | 				scale = Float.parseFloat(input); | ||||||
|  | 				if (scale <= 0.0f || scale > 64.0f) throw new IllegalArgumentException(); | ||||||
|  | 			} catch (Exception ex) { | ||||||
|  | 				JOptionPane.showMessageDialog(this.diagramPanel, "Invalid scale value. Should be a positive number less than 64.", "Invalid Scale", JOptionPane.WARNING_MESSAGE); | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
| 			try { | 			try { | ||||||
| 				long start = System.currentTimeMillis(); | 				long start = System.currentTimeMillis(); | ||||||
| 				BufferedImage render = this.renderModel(); | 				BufferedImage render = this.renderModel(scale); | ||||||
| 				double durationSeconds = (System.currentTimeMillis() - start) / 1000.0; | 				double durationSeconds = (System.currentTimeMillis() - start) / 1000.0; | ||||||
| 				ImageIO.write(render, extension, chosenFile); | 				ImageIO.write(render, extension, chosenFile); | ||||||
| 				prefs.put(LAST_EXPORT_LOCATION_KEY, chosenFile.getAbsolutePath()); | 				prefs.put(LAST_EXPORT_LOCATION_KEY, chosenFile.getAbsolutePath()); | ||||||
|  | @ -97,12 +105,19 @@ public class ExportToImageAction extends AbstractAction { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	private BufferedImage renderModel() { | 	/** | ||||||
|  | 	 * Renders the mapping model to an image with the given resolution. | ||||||
|  | 	 * @param scale The scale to use. Should be greater than zero. | ||||||
|  | 	 * @return The image which was rendered. | ||||||
|  | 	 */ | ||||||
|  | 	private BufferedImage renderModel(float scale) { | ||||||
| 		// Prepare a tiny sample image that we can use to determine the bounds of the model in a graphics context. | 		// Prepare a tiny sample image that we can use to determine the bounds of the model in a graphics context. | ||||||
| 		BufferedImage bufferedImage = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); | 		BufferedImage bufferedImage = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); | ||||||
| 		Graphics2D g2d = bufferedImage.createGraphics(); | 		Graphics2D g2d = bufferedImage.createGraphics(); | ||||||
| 		DiagramPanel.prepareGraphics(g2d); | 		DiagramPanel.prepareGraphics(g2d); | ||||||
| 		final Rectangle bounds = this.model.getViewModel().getBounds(g2d); | 		final Rectangle bounds = this.model.getViewModel().getBounds(g2d); | ||||||
|  | 		bounds.width *= scale; | ||||||
|  | 		bounds.height *= scale; | ||||||
| 
 | 
 | ||||||
| 		// Prepare the output image. | 		// Prepare the output image. | ||||||
| 		BufferedImage outputImage = new BufferedImage(bounds.width, bounds.height + 20, BufferedImage.TYPE_INT_RGB); | 		BufferedImage outputImage = new BufferedImage(bounds.width, bounds.height + 20, BufferedImage.TYPE_INT_RGB); | ||||||
|  | @ -112,7 +127,10 @@ public class ExportToImageAction extends AbstractAction { | ||||||
| 
 | 
 | ||||||
| 		// Transform the graphics space to account for the model's offset from origin. | 		// Transform the graphics space to account for the model's offset from origin. | ||||||
| 		AffineTransform originalTransform = g2d.getTransform(); | 		AffineTransform originalTransform = g2d.getTransform(); | ||||||
| 		g2d.setTransform(AffineTransform.getTranslateInstance(-bounds.x, -bounds.y)); | 		AffineTransform modelTransform = new AffineTransform(); | ||||||
|  | 		modelTransform.scale(scale, scale); | ||||||
|  | 		modelTransform.translate(-bounds.x, -bounds.y); | ||||||
|  | 		g2d.setTransform(modelTransform); | ||||||
| 		DiagramPanel.prepareGraphics(g2d); | 		DiagramPanel.prepareGraphics(g2d); | ||||||
| 
 | 
 | ||||||
| 		// Render the model. | 		// Render the model. | ||||||
|  | @ -124,9 +142,9 @@ public class ExportToImageAction extends AbstractAction { | ||||||
| 		this.model.getRelations().forEach(r -> r.setSelected(selectedRelations.contains(r))); | 		this.model.getRelations().forEach(r -> r.setSelected(selectedRelations.contains(r))); | ||||||
| 		LolcatAction.getInstance().setLolcatEnabled(lolcat); // revert previous lolcat mode | 		LolcatAction.getInstance().setLolcatEnabled(lolcat); // revert previous lolcat mode | ||||||
| 
 | 
 | ||||||
| 		// Revert back to the normal image space, and render a watermark. | 		// Revert to the normal image space, and render a watermark. | ||||||
| 		g2d.setTransform(originalTransform); | 		g2d.setTransform(originalTransform); | ||||||
| 		g2d.setColor(Color.LIGHT_GRAY); | 		g2d.setColor(Color.decode("#e8e8e8")); | ||||||
| 		g2d.setFont(g2d.getFont().deriveFont(10.0f)); | 		g2d.setFont(g2d.getFont().deriveFont(10.0f)); | ||||||
| 		g2d.drawString("Created by EntityRelationMappingEditor", 0, outputImage.getHeight() - 3); | 		g2d.drawString("Created by EntityRelationMappingEditor", 0, outputImage.getHeight() - 3); | ||||||
| 		return outputImage; | 		return outputImage; | ||||||
|  |  | ||||||
|  | @ -13,6 +13,9 @@ import java.io.InputStream; | ||||||
| import java.io.InputStreamReader; | import java.io.InputStreamReader; | ||||||
| import java.net.URISyntaxException; | import java.net.URISyntaxException; | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * An action which, when performed, opens a view that displays an HTML document. | ||||||
|  |  */ | ||||||
| public abstract class HtmlDocumentViewerAction extends AbstractAction { | public abstract class HtmlDocumentViewerAction extends AbstractAction { | ||||||
| 	private final String resourceFileName; | 	private final String resourceFileName; | ||||||
| 	private final Dialog.ModalityType modalityType; | 	private final Dialog.ModalityType modalityType; | ||||||
|  |  | ||||||
|  | @ -1,19 +1,22 @@ | ||||||
| package nl.andrewlalis.erme.control.actions; | package nl.andrewlalis.erme.control.actions; | ||||||
| 
 | 
 | ||||||
|  | import com.fasterxml.jackson.databind.DeserializationFeature; | ||||||
|  | import com.fasterxml.jackson.databind.JsonNode; | ||||||
|  | import com.fasterxml.jackson.databind.MapperFeature; | ||||||
|  | import com.fasterxml.jackson.databind.ObjectMapper; | ||||||
|  | import com.fasterxml.jackson.databind.json.JsonMapper; | ||||||
|  | import com.fasterxml.jackson.databind.node.ObjectNode; | ||||||
| import lombok.Setter; | import lombok.Setter; | ||||||
| import nl.andrewlalis.erme.model.MappingModel; | import nl.andrewlalis.erme.model.MappingModel; | ||||||
| import nl.andrewlalis.erme.view.DiagramPanel; | import nl.andrewlalis.erme.view.DiagramPanel; | ||||||
| 
 | 
 | ||||||
| import javax.swing.*; | import javax.swing.*; | ||||||
| import javax.swing.filechooser.FileNameExtensionFilter; | import javax.swing.filechooser.FileNameExtensionFilter; | ||||||
| import java.awt.*; |  | ||||||
| import java.awt.event.ActionEvent; | import java.awt.event.ActionEvent; | ||||||
| import java.awt.event.InputEvent; | import java.awt.event.InputEvent; | ||||||
| import java.awt.event.KeyEvent; | import java.awt.event.KeyEvent; | ||||||
| import java.io.File; | import java.io.File; | ||||||
| import java.io.FileInputStream; | import java.io.FileInputStream; | ||||||
| import java.io.IOException; |  | ||||||
| import java.io.ObjectInputStream; |  | ||||||
| import java.util.prefs.Preferences; | import java.util.prefs.Preferences; | ||||||
| 
 | 
 | ||||||
| public class LoadAction extends AbstractAction { | public class LoadAction extends AbstractAction { | ||||||
|  | @ -40,8 +43,8 @@ public class LoadAction extends AbstractAction { | ||||||
| 	public void actionPerformed(ActionEvent e) { | 	public void actionPerformed(ActionEvent e) { | ||||||
| 		JFileChooser fileChooser = new JFileChooser(); | 		JFileChooser fileChooser = new JFileChooser(); | ||||||
| 		FileNameExtensionFilter filter = new FileNameExtensionFilter( | 		FileNameExtensionFilter filter = new FileNameExtensionFilter( | ||||||
| 				"ERME Serialized Files", | 				"JSON Files", | ||||||
| 				"erme" | 				"json" | ||||||
| 		); | 		); | ||||||
| 		fileChooser.setFileFilter(filter); | 		fileChooser.setFileFilter(filter); | ||||||
| 		Preferences prefs = Preferences.userNodeForPackage(LoadAction.class); | 		Preferences prefs = Preferences.userNodeForPackage(LoadAction.class); | ||||||
|  | @ -56,11 +59,15 @@ public class LoadAction extends AbstractAction { | ||||||
| 				JOptionPane.showMessageDialog(fileChooser, "The selected file cannot be read.", "Invalid File", JOptionPane.WARNING_MESSAGE); | 				JOptionPane.showMessageDialog(fileChooser, "The selected file cannot be read.", "Invalid File", JOptionPane.WARNING_MESSAGE); | ||||||
| 				return; | 				return; | ||||||
| 			} | 			} | ||||||
| 			try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(chosenFile))) { | 			try (FileInputStream fis = new FileInputStream(chosenFile)) { | ||||||
| 				MappingModel loadedModel = (MappingModel) ois.readObject(); | 				ObjectMapper mapper = JsonMapper.builder() | ||||||
| 				this.diagramPanel.setModel(loadedModel); | 						.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) | ||||||
|  | 						.configure(MapperFeature.PROPAGATE_TRANSIENT_MARKER, true) | ||||||
|  | 						.build(); | ||||||
|  | 				JsonNode data = mapper.readValue(fis, JsonNode.class); | ||||||
|  | 				this.diagramPanel.setModel(MappingModel.fromJson((ObjectNode) data)); | ||||||
| 				prefs.put(LAST_LOAD_LOCATION_KEY, chosenFile.getAbsolutePath()); | 				prefs.put(LAST_LOAD_LOCATION_KEY, chosenFile.getAbsolutePath()); | ||||||
| 			} catch (IOException | ClassNotFoundException | ClassCastException ex) { | 			} catch (Exception ex) { | ||||||
| 				ex.printStackTrace(); | 				ex.printStackTrace(); | ||||||
| 				JOptionPane.showMessageDialog(fileChooser, "An error occurred and the file could not be read:\n" + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); | 				JOptionPane.showMessageDialog(fileChooser, "An error occurred and the file could not be read:\n" + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
|  | @ -1,19 +1,21 @@ | ||||||
| package nl.andrewlalis.erme.control.actions; | package nl.andrewlalis.erme.control.actions; | ||||||
| 
 | 
 | ||||||
|  | import com.fasterxml.jackson.databind.MapperFeature; | ||||||
|  | import com.fasterxml.jackson.databind.ObjectMapper; | ||||||
|  | import com.fasterxml.jackson.databind.SerializationFeature; | ||||||
|  | import com.fasterxml.jackson.databind.json.JsonMapper; | ||||||
| import lombok.Setter; | import lombok.Setter; | ||||||
| import nl.andrewlalis.erme.model.MappingModel; | import nl.andrewlalis.erme.model.MappingModel; | ||||||
| import nl.andrewlalis.erme.view.DiagramPanel; | import nl.andrewlalis.erme.view.DiagramPanel; | ||||||
| 
 | 
 | ||||||
| import javax.swing.*; | import javax.swing.*; | ||||||
| import javax.swing.filechooser.FileNameExtensionFilter; | import javax.swing.filechooser.FileNameExtensionFilter; | ||||||
| import java.awt.*; |  | ||||||
| import java.awt.event.ActionEvent; | import java.awt.event.ActionEvent; | ||||||
| import java.awt.event.InputEvent; | import java.awt.event.InputEvent; | ||||||
| import java.awt.event.KeyEvent; | import java.awt.event.KeyEvent; | ||||||
| import java.io.File; | import java.io.File; | ||||||
| import java.io.FileOutputStream; | import java.io.FileOutputStream; | ||||||
| import java.io.IOException; | import java.io.IOException; | ||||||
| import java.io.ObjectOutputStream; |  | ||||||
| import java.util.prefs.Preferences; | import java.util.prefs.Preferences; | ||||||
| 
 | 
 | ||||||
| public class SaveAction extends AbstractAction { | public class SaveAction extends AbstractAction { | ||||||
|  | @ -29,6 +31,7 @@ public class SaveAction extends AbstractAction { | ||||||
| 
 | 
 | ||||||
| 	@Setter | 	@Setter | ||||||
| 	private MappingModel model; | 	private MappingModel model; | ||||||
|  | 
 | ||||||
| 	@Setter | 	@Setter | ||||||
| 	private DiagramPanel diagramPanel; | 	private DiagramPanel diagramPanel; | ||||||
| 
 | 
 | ||||||
|  | @ -42,8 +45,8 @@ public class SaveAction extends AbstractAction { | ||||||
| 	public void actionPerformed(ActionEvent e) { | 	public void actionPerformed(ActionEvent e) { | ||||||
| 		JFileChooser fileChooser = new JFileChooser(); | 		JFileChooser fileChooser = new JFileChooser(); | ||||||
| 		FileNameExtensionFilter filter = new FileNameExtensionFilter( | 		FileNameExtensionFilter filter = new FileNameExtensionFilter( | ||||||
| 				"ERME Serialized Files", | 				"JSON Files", | ||||||
| 				"erme" | 				"json" | ||||||
| 		); | 		); | ||||||
| 		fileChooser.setFileFilter(filter); | 		fileChooser.setFileFilter(filter); | ||||||
| 		Preferences prefs = Preferences.userNodeForPackage(SaveAction.class); | 		Preferences prefs = Preferences.userNodeForPackage(SaveAction.class); | ||||||
|  | @ -55,15 +58,23 @@ public class SaveAction extends AbstractAction { | ||||||
| 		if (choice == JFileChooser.APPROVE_OPTION) { | 		if (choice == JFileChooser.APPROVE_OPTION) { | ||||||
| 			File chosenFile = fileChooser.getSelectedFile(); | 			File chosenFile = fileChooser.getSelectedFile(); | ||||||
| 			if (chosenFile == null || chosenFile.isDirectory()) { | 			if (chosenFile == null || chosenFile.isDirectory()) { | ||||||
| 				JOptionPane.showMessageDialog(fileChooser, "The selected file cannot be written to.", "Invalid File", JOptionPane.WARNING_MESSAGE); | 				JOptionPane.showMessageDialog(this.diagramPanel, "The selected file cannot be written to.", "Invalid File", JOptionPane.WARNING_MESSAGE); | ||||||
| 				return; | 				return; | ||||||
| 			} | 			} | ||||||
| 			if (!chosenFile.exists() && !chosenFile.getName().endsWith(".erme")) { | 			if (!chosenFile.exists() && !chosenFile.getName().endsWith(".json")) { | ||||||
| 				chosenFile = new File(chosenFile.getParent(), chosenFile.getName() + ".erme"); | 				chosenFile = new File(chosenFile.getParent(), chosenFile.getName() + ".json"); | ||||||
|  | 			} else if (chosenFile.exists()) { | ||||||
|  | 				int result = JOptionPane.showConfirmDialog(this.diagramPanel, "Are you sure you want overwrite this file?", "Overwrite", JOptionPane.YES_NO_OPTION); | ||||||
|  | 				if (result == JOptionPane.NO_OPTION) { | ||||||
|  | 					return; | ||||||
| 				} | 				} | ||||||
| 			// TODO: Check for confirm before overwriting. | 			} | ||||||
| 			try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(chosenFile))) { | 			try (FileOutputStream fos = new FileOutputStream(chosenFile)) { | ||||||
| 				oos.writeObject(this.model); | 				ObjectMapper mapper = JsonMapper.builder() | ||||||
|  | 						.configure(SerializationFeature.INDENT_OUTPUT, true) | ||||||
|  | 						.configure(MapperFeature.PROPAGATE_TRANSIENT_MARKER, true) | ||||||
|  | 						.build(); | ||||||
|  | 				mapper.writeValue(fos, this.model.toJson(mapper)); | ||||||
| 				prefs.put(LAST_SAVE_LOCATION_KEY, chosenFile.getAbsolutePath()); | 				prefs.put(LAST_SAVE_LOCATION_KEY, chosenFile.getAbsolutePath()); | ||||||
| 				JOptionPane.showMessageDialog(fileChooser, "File saved successfully.", "Success", JOptionPane.INFORMATION_MESSAGE); | 				JOptionPane.showMessageDialog(fileChooser, "File saved successfully.", "Success", JOptionPane.INFORMATION_MESSAGE); | ||||||
| 			} catch (IOException ex) { | 			} catch (IOException ex) { | ||||||
|  |  | ||||||
|  | @ -3,14 +3,13 @@ package nl.andrewlalis.erme.model; | ||||||
| import lombok.Getter; | import lombok.Getter; | ||||||
| import nl.andrewlalis.erme.view.view_models.AttributeViewModel; | import nl.andrewlalis.erme.view.view_models.AttributeViewModel; | ||||||
| 
 | 
 | ||||||
| import java.io.Serializable; |  | ||||||
| import java.util.Objects; | import java.util.Objects; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * A single value that belongs to a relation. |  * A single value that belongs to a relation. | ||||||
|  */ |  */ | ||||||
| @Getter | @Getter | ||||||
| public class Attribute implements Serializable { | public class Attribute { | ||||||
| 	private final Relation relation; | 	private final Relation relation; | ||||||
| 	private AttributeType type; | 	private AttributeType type; | ||||||
| 	private String name; | 	private String name; | ||||||
|  |  | ||||||
|  | @ -1,29 +1,27 @@ | ||||||
| package nl.andrewlalis.erme.model; | package nl.andrewlalis.erme.model; | ||||||
| 
 | 
 | ||||||
|  | import com.fasterxml.jackson.databind.JsonNode; | ||||||
|  | import com.fasterxml.jackson.databind.ObjectMapper; | ||||||
|  | import com.fasterxml.jackson.databind.node.ArrayNode; | ||||||
|  | import com.fasterxml.jackson.databind.node.ObjectNode; | ||||||
| import lombok.Getter; | import lombok.Getter; | ||||||
| import nl.andrewlalis.erme.view.OrderableListPanel; |  | ||||||
| import nl.andrewlalis.erme.view.view_models.MappingModelViewModel; | import nl.andrewlalis.erme.view.view_models.MappingModelViewModel; | ||||||
| import nl.andrewlalis.erme.view.view_models.ViewModel; | import nl.andrewlalis.erme.view.view_models.ViewModel; | ||||||
| 
 | 
 | ||||||
| import java.awt.*; | import java.awt.*; | ||||||
| import java.io.Serializable; |  | ||||||
| import java.util.HashSet; |  | ||||||
| import java.util.List; | import java.util.List; | ||||||
| import java.util.Objects; | import java.util.*; | ||||||
| import java.util.Set; |  | ||||||
| import java.util.stream.Collectors; | import java.util.stream.Collectors; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * This model contains all the information about a single mapping diagram, |  * This model contains all the information about a single mapping diagram, | ||||||
|  * including each mapped table and the links between them. |  * including each mapped table and the links between them. | ||||||
|  */ |  */ | ||||||
| public class MappingModel implements Serializable, Viewable { | public class MappingModel implements Viewable { | ||||||
| 	@Getter | 	@Getter | ||||||
| 	private final Set<Relation> relations; | 	private final Set<Relation> relations; | ||||||
| 
 | 
 | ||||||
| 	private transient Set<ModelChangeListener> changeListeners; | 	private transient final Set<ModelChangeListener> changeListeners; | ||||||
| 
 |  | ||||||
| 	private final static long serialVersionUID = 6153776381873250304L; |  | ||||||
| 
 | 
 | ||||||
| 	public MappingModel() { | 	public MappingModel() { | ||||||
| 		this.relations = new HashSet<>(); | 		this.relations = new HashSet<>(); | ||||||
|  | @ -42,10 +40,20 @@ public class MappingModel implements Serializable, Viewable { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Gets the list of relations which are currently selected. | ||||||
|  | 	 * @return The list of relations which are selected. | ||||||
|  | 	 */ | ||||||
| 	public List<Relation> getSelectedRelations() { | 	public List<Relation> getSelectedRelations() { | ||||||
| 		return this.relations.stream().filter(Relation::isSelected).collect(Collectors.toList()); | 		return this.relations.stream().filter(Relation::isSelected).collect(Collectors.toList()); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Finds an attribute in this model, or returns null otherwise. | ||||||
|  | 	 * @param relationName The name of the relation the attribute is in. | ||||||
|  | 	 * @param attributeName The name of the attribute. | ||||||
|  | 	 * @return The attribute which was found, or null if none was found. | ||||||
|  | 	 */ | ||||||
| 	public Attribute findAttribute(String relationName, String attributeName) { | 	public Attribute findAttribute(String relationName, String attributeName) { | ||||||
| 		for (Relation r : this.getRelations()) { | 		for (Relation r : this.getRelations()) { | ||||||
| 			if (!r.getName().equals(relationName)) continue; | 			if (!r.getName().equals(relationName)) continue; | ||||||
|  | @ -58,6 +66,11 @@ public class MappingModel implements Serializable, Viewable { | ||||||
| 		return null; | 		return null; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Removes all attributes from any relation in the model which reference the | ||||||
|  | 	 * given attribute. | ||||||
|  | 	 * @param referenced The attribute to remove references from. | ||||||
|  | 	 */ | ||||||
| 	public void removeAllReferencingAttributes(Attribute referenced) { | 	public void removeAllReferencingAttributes(Attribute referenced) { | ||||||
| 		for (Relation r : this.getRelations()) { | 		for (Relation r : this.getRelations()) { | ||||||
| 			Set<Attribute> removalSet = new HashSet<>(); | 			Set<Attribute> removalSet = new HashSet<>(); | ||||||
|  | @ -73,6 +86,10 @@ public class MappingModel implements Serializable, Viewable { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Gets the bounding rectangle around all relations of the model. | ||||||
|  | 	 * @return The bounding rectangle around all relations in this model. | ||||||
|  | 	 */ | ||||||
| 	public Rectangle getRelationBounds() { | 	public Rectangle getRelationBounds() { | ||||||
| 		if (this.getRelations().isEmpty()) { | 		if (this.getRelations().isEmpty()) { | ||||||
| 			return new Rectangle(0, 0, 0, 0); | 			return new Rectangle(0, 0, 0, 0); | ||||||
|  | @ -90,14 +107,20 @@ public class MappingModel implements Serializable, Viewable { | ||||||
| 		return new Rectangle(minX, minY, maxX - minX, maxY - minY); | 		return new Rectangle(minX, minY, maxX - minX, maxY - minY); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Adds a listener to this model, which will be notified of changes to the | ||||||
|  | 	 * model. | ||||||
|  | 	 * @param listener The listener to add. | ||||||
|  | 	 */ | ||||||
| 	public void addChangeListener(ModelChangeListener listener) { | 	public void addChangeListener(ModelChangeListener listener) { | ||||||
| 		if (this.changeListeners == null) { |  | ||||||
| 			this.changeListeners = new HashSet<>(); |  | ||||||
| 		} |  | ||||||
| 		this.changeListeners.add(listener); | 		this.changeListeners.add(listener); | ||||||
| 		listener.onModelChanged(); | 		listener.onModelChanged(); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Fires an all-purpose event which notifies all listeners that the model | ||||||
|  | 	 * has changed. | ||||||
|  | 	 */ | ||||||
| 	public final void fireChangedEvent() { | 	public final void fireChangedEvent() { | ||||||
| 		this.changeListeners.forEach(ModelChangeListener::onModelChanged); | 		this.changeListeners.forEach(ModelChangeListener::onModelChanged); | ||||||
| 	} | 	} | ||||||
|  | @ -146,4 +169,106 @@ public class MappingModel implements Serializable, Viewable { | ||||||
| 		this.getRelations().forEach(r -> c.addRelation(r.copy(c))); | 		this.getRelations().forEach(r -> c.addRelation(r.copy(c))); | ||||||
| 		return c; | 		return c; | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	public ObjectNode toJson(ObjectMapper mapper) { | ||||||
|  | 		ObjectNode node = mapper.createObjectNode(); | ||||||
|  | 		ArrayNode relationsArray = node.withArray("relations"); | ||||||
|  | 		for (Relation r : this.relations) { | ||||||
|  | 			ObjectNode relationNode = mapper.createObjectNode() | ||||||
|  | 					.put("name", r.getName()); | ||||||
|  | 			ObjectNode positionNode = mapper.createObjectNode() | ||||||
|  | 					.put("x", r.getPosition().x) | ||||||
|  | 					.put("y", r.getPosition().y); | ||||||
|  | 			relationNode.set("position", positionNode); | ||||||
|  | 			ArrayNode attributesArray = relationNode.withArray("attributes"); | ||||||
|  | 			for (Attribute a : r.getAttributes()) { | ||||||
|  | 				ObjectNode attributeNode = mapper.createObjectNode() | ||||||
|  | 						.put("name", a.getName()) | ||||||
|  | 						.put("type", a.getType().name()); | ||||||
|  | 				if (a instanceof ForeignKeyAttribute) { | ||||||
|  | 					ForeignKeyAttribute fk = (ForeignKeyAttribute) a; | ||||||
|  | 					ObjectNode referenceNode = mapper.createObjectNode() | ||||||
|  | 							.put("relation", fk.getReference().getRelation().getName()) | ||||||
|  | 							.put("attribute", fk.getReference().getName()); | ||||||
|  | 					attributeNode.set("references", referenceNode); | ||||||
|  | 				} | ||||||
|  | 				attributesArray.add(attributeNode); | ||||||
|  | 			} | ||||||
|  | 			relationsArray.add(relationNode); | ||||||
|  | 		} | ||||||
|  | 		return node; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public static MappingModel fromJson(ObjectNode node) { | ||||||
|  | 		MappingModel model = new MappingModel(); | ||||||
|  | 		for (JsonNode relationNodeRaw : node.withArray("relations")) { | ||||||
|  | 			if (!relationNodeRaw.isObject()) throw new IllegalArgumentException(); | ||||||
|  | 			ObjectNode relationNode = (ObjectNode) relationNodeRaw; | ||||||
|  | 			String name = relationNode.get("name").asText(); | ||||||
|  | 			int x = relationNode.get("position").get("x").asInt(); | ||||||
|  | 			int y = relationNode.get("position").get("y").asInt(); | ||||||
|  | 			Point position = new Point(x, y); | ||||||
|  | 			Relation relation = new Relation(model, position, name); | ||||||
|  | 			for (JsonNode attributeNodeRaw : relationNode.withArray("attributes")) { | ||||||
|  | 				if (!attributeNodeRaw.isObject()) throw new IllegalArgumentException(); | ||||||
|  | 				ObjectNode attributeNode = (ObjectNode) attributeNodeRaw; | ||||||
|  | 				String attributeName = attributeNode.get("name").asText(); | ||||||
|  | 				AttributeType type = AttributeType.valueOf(attributeNode.get("type").asText().toUpperCase()); | ||||||
|  | 				Attribute attribute = new Attribute(relation, type, attributeName); | ||||||
|  | 				relation.addAttribute(attribute); | ||||||
|  | 			} | ||||||
|  | 			model.addRelation(relation); | ||||||
|  | 		} | ||||||
|  | 		addForeignKeys(model, node); | ||||||
|  | 		return model; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	private static void addForeignKeys(MappingModel model, ObjectNode node) { | ||||||
|  | 		Map<Attribute, ObjectNode> references = buildReferenceMap(model, node); | ||||||
|  | 		while (!references.isEmpty()) { | ||||||
|  | 			boolean workDone = false; | ||||||
|  | 			for (Map.Entry<Attribute, ObjectNode> entry : references.entrySet()) { | ||||||
|  | 				Attribute attribute = entry.getKey(); | ||||||
|  | 				String referencedName = entry.getValue().get("attribute").asText(); | ||||||
|  | 				String referencedRelation = entry.getValue().get("relation").asText(); | ||||||
|  | 				Attribute referencedAttribute = model.findAttribute(referencedRelation, referencedName); | ||||||
|  | 				if (referencedAttribute == null) throw new IllegalArgumentException("Foreign key referenced unknown attribute."); | ||||||
|  | 				if (!references.containsKey(referencedAttribute)) { | ||||||
|  | 					ForeignKeyAttribute fk = new ForeignKeyAttribute(attribute.getRelation(), attribute.getType(), attribute.getName(), referencedAttribute); | ||||||
|  | 					attribute.getRelation().removeAttribute(attribute); | ||||||
|  | 					attribute.getRelation().addAttribute(fk); | ||||||
|  | 					references.remove(attribute); | ||||||
|  | 					workDone = true; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			if (!workDone) { | ||||||
|  | 				throw new IllegalArgumentException("Invalid foreign key structure. Possible cyclic references."); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/** | ||||||
|  | 	 * Builds a map that contains the set of foreign key references, indexed by | ||||||
|  | 	 * the primitive attribute that is referencing another. | ||||||
|  | 	 * @param model The model to lookup attributes from. | ||||||
|  | 	 * @param node The raw JSON data for the model. | ||||||
|  | 	 * @return A map containing foreign key references, to be used to build a | ||||||
|  | 	 * complete model with foreign key attributes. | ||||||
|  | 	 */ | ||||||
|  | 	private static Map<Attribute, ObjectNode> buildReferenceMap(MappingModel model, ObjectNode node) { | ||||||
|  | 		Map<Attribute, ObjectNode> references = new HashMap<>(); | ||||||
|  | 		for (JsonNode r : node.withArray("relations")) { | ||||||
|  | 			for (JsonNode a : r.withArray("attributes")) { | ||||||
|  | 				if (a.has("references") && a.get("references").isObject()) { | ||||||
|  | 					ObjectNode referenceNode = (ObjectNode) a.get("references"); | ||||||
|  | 					String attributeName = a.get("name").asText(); | ||||||
|  | 					String relationName = r.get("name").asText(); | ||||||
|  | 					Attribute attribute = model.findAttribute(relationName, attributeName); | ||||||
|  | 					if (attribute == null) throw new IllegalArgumentException("Mapping model is not complete. Missing attribute " + attributeName + " in relation " + relationName + "."); | ||||||
|  | 					references.put(attribute, referenceNode); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		return references; | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -5,7 +5,6 @@ import nl.andrewlalis.erme.view.view_models.RelationViewModel; | ||||||
| import nl.andrewlalis.erme.view.view_models.ViewModel; | import nl.andrewlalis.erme.view.view_models.ViewModel; | ||||||
| 
 | 
 | ||||||
| import java.awt.*; | import java.awt.*; | ||||||
| import java.io.Serializable; |  | ||||||
| import java.util.ArrayList; | import java.util.ArrayList; | ||||||
| import java.util.List; | import java.util.List; | ||||||
| import java.util.Objects; | import java.util.Objects; | ||||||
|  | @ -15,7 +14,7 @@ import java.util.stream.Collectors; | ||||||
|  * Represents a single "relation" or table in the diagram. |  * Represents a single "relation" or table in the diagram. | ||||||
|  */ |  */ | ||||||
| @Getter | @Getter | ||||||
| public class Relation implements Serializable, Viewable, Comparable<Relation> { | public class Relation implements Viewable, Comparable<Relation> { | ||||||
| 	private final MappingModel model; | 	private final MappingModel model; | ||||||
| 	private Point position; | 	private Point position; | ||||||
| 	private String name; | 	private String name; | ||||||
|  | @ -24,11 +23,15 @@ public class Relation implements Serializable, Viewable, Comparable<Relation> { | ||||||
| 	private transient boolean selected; | 	private transient boolean selected; | ||||||
| 	private transient RelationViewModel viewModel; | 	private transient RelationViewModel viewModel; | ||||||
| 
 | 
 | ||||||
| 	public Relation(MappingModel model, Point position, String name) { | 	public Relation(MappingModel model, Point position, String name, List<Attribute> attributes) { | ||||||
| 		this.model = model; | 		this.model = model; | ||||||
| 		this.position = position; | 		this.position = position; | ||||||
| 		this.name = name; | 		this.name = name; | ||||||
| 		this.attributes = new ArrayList<>(); | 		this.attributes = attributes; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public Relation(MappingModel model, Point position, String name) { | ||||||
|  | 		this(model, position, name, new ArrayList<>()); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	public void setPosition(Point position) { | 	public void setPosition(Point position) { | ||||||
|  |  | ||||||
|  | @ -1,50 +0,0 @@ | ||||||
| package nl.andrewlalis.erme.util; |  | ||||||
| 
 |  | ||||||
| import java.io.BufferedReader; |  | ||||||
| import java.io.IOException; |  | ||||||
| import java.io.InputStream; |  | ||||||
| import java.io.InputStreamReader; |  | ||||||
| import java.security.MessageDigest; |  | ||||||
| import java.security.NoSuchAlgorithmException; |  | ||||||
| import java.util.Arrays; |  | ||||||
| 
 |  | ||||||
| public class Hash { |  | ||||||
| 	public static boolean matches(byte[] password, String resourceFile) { |  | ||||||
| 		MessageDigest md; |  | ||||||
| 		try { |  | ||||||
| 			md = MessageDigest.getInstance("SHA-256"); |  | ||||||
| 		} catch (NoSuchAlgorithmException e) { |  | ||||||
| 			e.printStackTrace(); |  | ||||||
| 			return false; |  | ||||||
| 		} |  | ||||||
| 		byte[] passwordHash = md.digest(password); |  | ||||||
| 		InputStream is = Hash.class.getClassLoader().getResourceAsStream(resourceFile); |  | ||||||
| 		if (is == null) { |  | ||||||
| 			System.err.println("Could not obtain input stream to admin_hash.txt"); |  | ||||||
| 			return false; |  | ||||||
| 		} |  | ||||||
| 		char[] buffer = new char[64]; |  | ||||||
| 		try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) { |  | ||||||
| 			if (br.read(buffer) != buffer.length) { |  | ||||||
| 				System.err.println("Incorrect number of characters read from hash file."); |  | ||||||
| 				return false; |  | ||||||
| 			} |  | ||||||
| 		} catch (IOException e) { |  | ||||||
| 			e.printStackTrace(); |  | ||||||
| 			return false; |  | ||||||
| 		} |  | ||||||
| 		String hashHex = String.valueOf(buffer); |  | ||||||
| 		byte[] hash = hexStringToByteArray(hashHex); |  | ||||||
| 		return Arrays.equals(passwordHash, hash); |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	private static byte[] hexStringToByteArray(String s) { |  | ||||||
| 		int len = s.length(); |  | ||||||
| 		byte[] data = new byte[len / 2]; |  | ||||||
| 		for (int i = 0; i < len; i += 2) { |  | ||||||
| 			data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) |  | ||||||
| 					+ Character.digit(s.charAt(i+1), 16)); |  | ||||||
| 		} |  | ||||||
| 		return data; |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  | @ -117,6 +117,7 @@ public class DiagramPanel extends JPanel implements ModelChangeListener { | ||||||
| 
 | 
 | ||||||
| 	/** | 	/** | ||||||
| 	 * Updates all the action singletons with the latest model information. | 	 * Updates all the action singletons with the latest model information. | ||||||
|  | 	 * TODO: Clean this up somehow! | ||||||
| 	 */ | 	 */ | ||||||
| 	private void updateActionModels() { | 	private void updateActionModels() { | ||||||
| 		NewModelAction.getInstance().setDiagramPanel(this); | 		NewModelAction.getInstance().setDiagramPanel(this); | ||||||
|  |  | ||||||
|  | @ -2,17 +2,30 @@ package nl.andrewlalis.erme.view; | ||||||
| 
 | 
 | ||||||
| import nl.andrewlalis.erme.model.MappingModel; | import nl.andrewlalis.erme.model.MappingModel; | ||||||
| 
 | 
 | ||||||
|  | import javax.imageio.ImageIO; | ||||||
| import javax.swing.*; | import javax.swing.*; | ||||||
| import java.awt.*; | import java.awt.*; | ||||||
|  | import java.io.IOException; | ||||||
|  | import java.io.InputStream; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * The main JFrame for the editor. |  * The main JFrame for the editor. | ||||||
|  */ |  */ | ||||||
| public class EditorFrame extends JFrame { | public class EditorFrame extends JFrame { | ||||||
| 	public EditorFrame(boolean includeAdminActions) { | 	public EditorFrame() { | ||||||
| 		super("ER-Mapping Editor"); | 		super("ER-Mapping Editor"); | ||||||
| 		this.setContentPane(new DiagramPanel(new MappingModel())); | 		this.setContentPane(new DiagramPanel(new MappingModel())); | ||||||
| 		this.setJMenuBar(new EditorMenuBar(includeAdminActions)); | 		this.setJMenuBar(new EditorMenuBar()); | ||||||
|  | 		try { | ||||||
|  | 			InputStream is = getClass().getClassLoader().getResourceAsStream("icon.png"); | ||||||
|  | 			if (is == null) { | ||||||
|  | 				System.err.println("Could not load application icon."); | ||||||
|  | 			} else { | ||||||
|  | 				this.setIconImage(ImageIO.read(is)); | ||||||
|  | 			} | ||||||
|  | 		} catch (IOException e) { | ||||||
|  | 			e.printStackTrace(); | ||||||
|  | 		} | ||||||
| 		this.setMinimumSize(new Dimension(400, 400)); | 		this.setMinimumSize(new Dimension(400, 400)); | ||||||
| 		this.setPreferredSize(new Dimension(800, 800)); | 		this.setPreferredSize(new Dimension(800, 800)); | ||||||
| 		this.pack(); | 		this.pack(); | ||||||
|  |  | ||||||
|  | @ -12,10 +12,7 @@ import javax.swing.*; | ||||||
|  * The menu bar that's visible atop the application. |  * The menu bar that's visible atop the application. | ||||||
|  */ |  */ | ||||||
| public class EditorMenuBar extends JMenuBar { | public class EditorMenuBar extends JMenuBar { | ||||||
| 	private final boolean includeAdminActions; | 	public EditorMenuBar() { | ||||||
| 
 |  | ||||||
| 	public EditorMenuBar(boolean includeAdminActions) { |  | ||||||
| 		this.includeAdminActions = includeAdminActions; |  | ||||||
| 		this.add(this.buildFileMenu()); | 		this.add(this.buildFileMenu()); | ||||||
| 		this.add(this.buildEditMenu()); | 		this.add(this.buildEditMenu()); | ||||||
| 		this.add(this.buildHelpMenu()); | 		this.add(this.buildHelpMenu()); | ||||||
|  |  | ||||||
|  | @ -1 +0,0 @@ | ||||||
| cfdabe75d984e5a92fb491dadc9091419d9587c049246356a488e83a75505bce |  | ||||||
|  | @ -12,7 +12,7 @@ | ||||||
| <p><em>A simple UI for editing entity-relation mapping diagrams.</em></p> | <p><em>A simple UI for editing entity-relation mapping diagrams.</em></p> | ||||||
| 
 | 
 | ||||||
| <p> | <p> | ||||||
|     Have you noticed any unexpected behavior? Is there something you thing would make a good addition to this application? |     Have you noticed any unexpected behavior? Is there something you think would make a good addition to this application? | ||||||
|     <a href="https://github.com/andrewlalis/EntityRelationMappingEditor/issues">Create a new issue on GitHub!</a> |     <a href="https://github.com/andrewlalis/EntityRelationMappingEditor/issues">Create a new issue on GitHub!</a> | ||||||
| </p> | </p> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.1 KiB | 
		Loading…
	
		Reference in New Issue