···1515import com.google.gson.reflect.TypeToken;
16161717import app.Environment;
1818+import dev.kdl.parse.KdlParseException;
1819import util.Logger;
19202021/**
···48494950 while (iter.hasNext()) {
5051 ProjectData data = iter.next();
5151- File path = new File(data.path);
52525353- // Remove invalid entries
5454- if (!path.exists()) {
5353+ try {
5454+ projects.add(new Project(new File(data.path), data.lastOpened));
5555+ } catch (IOException | KdlParseException e) {
5656+ Logger.logWarning("Ignoring invalid project: " + data.path);
5557 iter.remove();
5658 modified = true;
5759 continue;
5860 }
5959-6060- projects.add(new Project(path, data.lastOpened));
6161 }
62626363 // Save if we removed any invalid entries
···7676 List<ProjectData> dataList = loadProjectData();
77777878 // Remove existing entry with same path (will be re-added with new timestamp)
7979- String absolutePath = project.getPath().getAbsolutePath();
7979+ String absolutePath = project.getPath();
8080 dataList.removeIf(data -> data.path.equals(absolutePath));
81818282 // Add new entry
···8989 }
90909191 @Override
9292- public synchronized void removeProject(File projectPath)
9292+ public synchronized void removeProject(Project project)
9393 {
9494 List<ProjectData> dataList = loadProjectData();
9595- String absolutePath = projectPath.getAbsolutePath();
9595+ String absolutePath = project.getPath();
9696 dataList.removeIf(data -> data.path.equals(absolutePath));
9797 saveProjectData(dataList);
9898 }
9999100100 @Override
101101- public synchronized void updateLastOpened(File projectPath)
101101+ public synchronized void updateLastOpened(Project project)
102102 {
103103 List<ProjectData> dataList = loadProjectData();
104104- String absolutePath = projectPath.getAbsolutePath();
104104+ String absolutePath = project.getPath();
105105106106 for (ProjectData data : dataList) {
107107 if (data.path.equals(absolutePath)) {
+39
src/main/java/project/Manifest.java
···11+package project;
22+33+import java.io.File;
44+import java.io.IOException;
55+import java.nio.file.Path;
66+77+import dev.kdl.KdlDocument;
88+import dev.kdl.parse.KdlParseException;
99+import dev.kdl.parse.KdlParser;
1010+1111+/** A project.kdl file. Mutations are automatically saved to disk. */
1212+public class Manifest {
1313+ public static final String FILENAME = "project.kdl";
1414+1515+ private final File file;
1616+ private final KdlDocument doc;
1717+1818+ public Manifest(Project project) throws IOException, KdlParseException {
1919+ file = new File(project.getPath(), FILENAME);
2020+2121+ if (!file.exists()) {
2222+ throw new IOException(FILENAME + " does not exist");
2323+ }
2424+2525+ var parser = KdlParser.v2();
2626+ doc = parser.parse(Path.of(file.getAbsolutePath()));
2727+ }
2828+ public String toString() {
2929+ return "Manifest(" + file.getPath() + ")";
3030+ }
3131+3232+ public String getName() {
3333+ return doc.nodes().stream()
3434+ .filter(n -> n.name().equals("name"))
3535+ .findFirst()
3636+ .map(n -> n.arguments().get(0).value().toString())
3737+ .orElse(file.getParentFile().getName());
3838+ }
3939+}
+59-23
src/main/java/project/Project.java
···11package project;
2233+import static app.Directories.DATABASE_TEMPLATES;
44+35import java.io.File;
44-import java.util.Objects;
66+import java.io.IOException;
77+88+import org.apache.commons.io.FileUtils;
99+1010+import dev.kdl.parse.KdlParseException;
51166-/**
77- * Immutable data class representing a Star Rod project.
88- * Stores the project path and last opened timestamp.
99- */
1012public class Project implements Comparable<Project>
1113{
1212- private final File path;
1313- private final long lastOpened;
1414+ private final File directory;
1515+ private final long lastOpened; // TODO: move this, Comparable, and compareTo to a new class
1616+ private final Manifest manifest;
14171515- public Project(File path, long lastOpened)
1818+ /** Loads a project from a directory. */
1919+ public Project(File path, long lastOpened) throws IOException, KdlParseException
1620 {
1717- Objects.requireNonNull(path, "Project path cannot be null");
1818- this.path = path.getAbsoluteFile();
2121+ if (!path.isDirectory())
2222+ throw new IllegalArgumentException("Project path must be a directory: " + path);
2323+ this.directory = path.getAbsoluteFile();
1924 this.lastOpened = lastOpened;
2525+ this.manifest = new Manifest(this);
2026 }
21272222- public Project(File path)
2828+ /** Loads a project from a directory. */
2929+ public Project(File path) throws IOException, KdlParseException
2330 {
2431 this(path, System.currentTimeMillis());
2532 }
26332727- public File getPath()
3434+ public String getPath()
2835 {
2929- return path;
3636+ return directory.getPath();
3037 }
31383232- public String getName()
3939+ public File getDirectory()
3340 {
3434- return path.getName();
4141+ return directory;
3542 }
36433744 public long getLastOpened()
···3946 return lastOpened;
4047 }
41484242- /**
4343- * Creates a new Project instance with updated lastOpened timestamp.
4444- */
4545- public Project withLastOpened(long timestamp)
4949+ public String getName()
4650 {
4747- return new Project(path, timestamp);
5151+ return manifest.getName();
5252+ }
5353+5454+ public Manifest getManifest()
5555+ {
5656+ return manifest;
5757+ }
5858+5959+ /** Creates a new project from a template. */
6060+ public static Project create(File path, String template, String id, String name) throws IOException, KdlParseException
6161+ {
6262+ if (!path.exists())
6363+ path.mkdirs();
6464+ if (!path.isDirectory())
6565+ throw new IllegalArgumentException("Project path must be a directory: " + path);
6666+6767+ // Copy entire template directory here
6868+ File templateDir = DATABASE_TEMPLATES.file(template);
6969+ if (!templateDir.exists())
7070+ throw new IllegalArgumentException("Missing template: " + templateDir.getPath());
7171+ FileUtils.copyDirectory(templateDir, path);
7272+7373+ // Substitute placeholders in project.kdl
7474+ File manifestFile = new File(path, Manifest.FILENAME);
7575+ if (manifestFile.exists()) {
7676+ String content = FileUtils.readFileToString(manifestFile, "UTF-8");
7777+ content = content.replace("$PROJECT_ID", id);
7878+ content = content.replace("$PROJECT_NAME", name);
7979+ content = content.replace("$PROJECT_DESCRIPTION", "");
8080+ FileUtils.writeStringToFile(manifestFile, content, "UTF-8");
8181+ }
8282+8383+ return new Project(path);
4884 }
49855086 @Override
···6298 if (obj == null || getClass() != obj.getClass())
6399 return false;
64100 Project other = (Project) obj;
6565- return path.equals(other.path);
101101+ return directory.equals(other.directory);
66102 }
6710368104 @Override
69105 public int hashCode()
70106 {
7171- return path.hashCode();
107107+ return directory.hashCode();
72108 }
7310974110 @Override
75111 public String toString()
76112 {
7777- return getName() + " (" + path.getAbsolutePath() + ")";
113113+ return getName() + " (" + directory.getAbsolutePath() + ")";
78114 }
79115}
+11-41
src/main/java/project/ProjectManager.java
···6677import org.apache.commons.io.FileUtils;
8899-import app.Environment;
109import util.Logger;
11101211/**
···46454746 /**
4847 * Records that a project was opened (adds or updates its timestamp).
4949- * @param projectPath The path to the project
5048 */
5151- public void recordProjectOpened(File projectPath)
4949+ public void recordProjectOpened(Project project)
5250 {
5353- repository.updateLastOpened(projectPath);
5151+ repository.updateLastOpened(project);
5452 }
55535654 /**
···6058 */
6159 public void removeFromHistory(Project project)
6260 {
6363- repository.removeProject(project.getPath());
6161+ repository.removeProject(project);
6462 }
65636664 /**
···7068 */
7169 public boolean deleteFromDisk(Project project)
7270 {
7373- File projectDir = project.getPath();
7171+ File projectDir = new File(project.getPath());
74727575- // First remove from history
7676- repository.removeProject(projectDir);
7373+ repository.removeProject(project);
77747878- // Then delete from disk
7979- if (projectDir.exists()) {
8080- try {
8181- FileUtils.deleteDirectory(projectDir);
8282- Logger.log("Deleted project directory: " + projectDir.getAbsolutePath());
8383- return true;
8484- }
8585- catch (IOException e) {
8686- Logger.logError("Failed to delete project: " + e.getMessage());
8787- return false;
8888- }
7575+ try {
7676+ FileUtils.deleteDirectory(projectDir);
7777+ return true;
8978 }
9090- return true; // Already doesn't exist
9191- }
9292-9393- /**
9494- * Checks if a directory is a valid Star Rod project.
9595- */
9696- public boolean isValidProject(File dir)
9797- {
9898- return ProjectValidator.isValidProject(dir);
9999- }
100100-101101- /**
102102- * Loads a project using Environment.loadProject().
103103- * @param projectPath The path to the project
104104- * @return true if the project was loaded successfully
105105- */
106106- public boolean openProject(File projectPath) throws IOException
107107- {
108108- boolean success = Environment.loadProject(projectPath);
109109- if (success) {
110110- recordProjectOpened(projectPath);
7979+ catch (IOException e) {
8080+ Logger.logError("Failed to delete project: " + e.getMessage());
8181+ return false;
11182 }
112112- return success;
11383 }
11484}
+3-5
src/main/java/project/ProjectRepository.java
···22222323 /**
2424 * Removes a project from the repository.
2525- * @param projectPath The path of the project to remove
2625 */
2727- void removeProject(File projectPath);
2626+ void removeProject(Project project);
28272928 /**
3029 * Updates the last opened timestamp for a project.
3131- * @param projectPath The path of the project to update
3232- */
3333- void updateLastOpened(File projectPath);
3030+ */
3131+ void updateLastOpened(Project project);
3432}
-54
src/main/java/project/ProjectValidator.java
···11-package project;
22-33-import java.io.File;
44-55-import app.Environment;
66-import app.config.Options;
77-88-/**
99- * Validates whether a directory is a valid Star Rod project.
1010- * A valid project must have a splat.yaml file in ver/{gameVersion}/.
1111- */
1212-public class ProjectValidator
1313-{
1414- private static final String FN_SPLAT = "splat.yaml";
1515-1616- /**
1717- * Checks if a directory is a valid Star Rod project.
1818- * @param dir The directory to check
1919- * @return true if the directory contains a valid project structure
2020- */
2121- public static boolean isValidProject(File dir)
2222- {
2323- if (dir == null || !dir.exists() || !dir.isDirectory()) {
2424- return false;
2525- }
2626-2727- // Get game version from config (default "us")
2828- String gameVersion = "us";
2929- if (Environment.mainConfig != null) {
3030- String configVersion = Environment.mainConfig.getString(Options.GameVersion);
3131- if (configVersion != null && !configVersion.isEmpty()) {
3232- gameVersion = configVersion;
3333- }
3434- }
3535-3636- // Check for splat.yaml in ver/{gameVersion}/
3737- File versionDir = new File(dir, "ver/" + gameVersion);
3838- if (!versionDir.exists() || !versionDir.isDirectory()) {
3939- return false;
4040- }
4141-4242- File splatFile = new File(versionDir, FN_SPLAT);
4343- return splatFile.exists();
4444- }
4545-4646- /**
4747- * Checks if the current working directory is a valid Star Rod project.
4848- * @return true if cwd contains a valid project structure
4949- */
5050- public static boolean isCurrentDirectoryProject()
5151- {
5252- return isValidProject(new File("."));
5353- }
5454-}
+327
src/main/java/project/ui/CreateProjectDialog.java
···11+package project.ui;
22+33+import java.awt.event.WindowEvent;
44+import java.io.File;
55+import java.io.IOException;
66+77+import javax.swing.JButton;
88+import javax.swing.JDialog;
99+import javax.swing.JFrame;
1010+import javax.swing.JLabel;
1111+import javax.swing.JPanel;
1212+import javax.swing.JTextField;
1313+import javax.swing.SwingUtilities;
1414+import javax.swing.WindowConstants;
1515+import javax.swing.event.DocumentEvent;
1616+import javax.swing.event.DocumentListener;
1717+1818+import app.Environment;
1919+import app.SwingUtils;
2020+import app.config.Options;
2121+import dev.kdl.parse.KdlParseException;
2222+import game.map.editor.ui.dialogs.ChooseDialogResult;
2323+import game.map.editor.ui.dialogs.DirChooser;
2424+import net.miginfocom.swing.MigLayout;
2525+import project.Project;
2626+import util.Logger;
2727+2828+public class CreateProjectDialog extends JDialog
2929+{
3030+ private Project result = null;
3131+3232+ private JTextField nameField;
3333+ private JTextField idField;
3434+ private JTextField pathField;
3535+ private JButton createButton;
3636+3737+ private boolean idManuallyEdited = false;
3838+ private File browsedDir = null;
3939+4040+ /**
4141+ * Shows the dialog and returns the created project, or null if cancelled.
4242+ */
4343+ public static Project showDialog(JFrame parent)
4444+ {
4545+ CreateProjectDialog dialog = new CreateProjectDialog(parent);
4646+ dialog.setVisible(true);
4747+ return dialog.result;
4848+ }
4949+5050+ private CreateProjectDialog(JFrame parent)
5151+ {
5252+ super(parent);
5353+5454+ nameField = new JTextField();
5555+ nameField.setMargin(SwingUtils.TEXTBOX_INSETS);
5656+5757+ idField = new JTextField();
5858+ idField.setMargin(SwingUtils.TEXTBOX_INSETS);
5959+6060+ pathField = new JTextField();
6161+ pathField.setMargin(SwingUtils.TEXTBOX_INSETS);
6262+ pathField.setEditable(false);
6363+6464+ // Auto-generate ID from Name
6565+ nameField.getDocument().addDocumentListener(new DocumentListener() {
6666+ @Override
6767+ public void changedUpdate(DocumentEvent e)
6868+ {
6969+ onNameChanged();
7070+ }
7171+7272+ @Override
7373+ public void insertUpdate(DocumentEvent e)
7474+ {
7575+ onNameChanged();
7676+ }
7777+7878+ @Override
7979+ public void removeUpdate(DocumentEvent e)
8080+ {
8181+ onNameChanged();
8282+ }
8383+ });
8484+8585+ // Track manual edits to ID
8686+ idField.getDocument().addDocumentListener(new DocumentListener() {
8787+ @Override
8888+ public void changedUpdate(DocumentEvent e)
8989+ {
9090+ onIdChanged();
9191+ }
9292+9393+ @Override
9494+ public void insertUpdate(DocumentEvent e)
9595+ {
9696+ onIdChanged();
9797+ }
9898+9999+ @Override
100100+ public void removeUpdate(DocumentEvent e)
101101+ {
102102+ onIdChanged();
103103+ }
104104+ });
105105+106106+ // Browse button
107107+ JButton browseButton = new JButton("Browse...");
108108+ SwingUtils.addBorderPadding(browseButton);
109109+ browseButton.addActionListener(e -> browseForPath());
110110+111111+ // Create and Cancel buttons
112112+ createButton = new JButton("Create");
113113+ SwingUtils.addBorderPadding(createButton);
114114+ createButton.setEnabled(false);
115115+ createButton.addActionListener(e -> createProject());
116116+117117+ JButton cancelButton = new JButton("Cancel");
118118+ SwingUtils.addBorderPadding(cancelButton);
119119+ cancelButton.addActionListener(e -> setVisible(false));
120120+121121+ setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
122122+ addWindowListener(new java.awt.event.WindowAdapter() {
123123+ @Override
124124+ public void windowClosing(WindowEvent e)
125125+ {
126126+ setVisible(false);
127127+ }
128128+ });
129129+130130+ setLayout(new MigLayout("ins 16, wrap", "[grow]"));
131131+132132+ JLabel nameLabel = new JLabel("Name");
133133+ add(nameLabel, "");
134134+ add(nameField, "growx");
135135+136136+ JLabel idLabel = new JLabel("ID");
137137+ JLabel idDesc = new JLabel("The internal name of your mod. Must be unique between all mods.");
138138+ idDesc.setForeground(SwingUtils.getGrayTextColor());
139139+ SwingUtils.setFontSize(idDesc, 11);
140140+ add(idLabel, "split 2, gaptop 8");
141141+ add(idDesc, "ax right, pushx");
142142+ add(idField, "growx");
143143+144144+ JLabel pathLabel = new JLabel("Path");
145145+ add(pathLabel, "gaptop 8");
146146+ add(pathField, "split 2, growx");
147147+ add(browseButton, "");
148148+149149+ add(new JPanel(), "growx, sg but, split 3, gaptop 12");
150150+ add(createButton, "growx, sg but");
151151+ add(cancelButton, "growx, sg but");
152152+153153+ updatePath();
154154+ validate_();
155155+156156+ pack();
157157+ setResizable(false);
158158+159159+ setTitle("New Project");
160160+ setIconImage(Environment.getDefaultIconImage());
161161+ setLocationRelativeTo(parent);
162162+ setModal(true);
163163+ nameField.requestFocusInWindow();
164164+165165+ SwingUtilities.invokeLater(() -> {
166166+ getRootPane().setDefaultButton(createButton);
167167+ nameField.requestFocusInWindow();
168168+ });
169169+ }
170170+171171+ private boolean updatingId = false;
172172+173173+ private void onNameChanged()
174174+ {
175175+ if (!idManuallyEdited) {
176176+ updatingId = true;
177177+ idField.setText(toSnakeCase(nameField.getText()));
178178+ updatingId = false;
179179+ }
180180+ updatePath();
181181+ validate_();
182182+ }
183183+184184+ private void onIdChanged()
185185+ {
186186+ if (!updatingId) {
187187+ idManuallyEdited = !idField.getText().isEmpty();
188188+ }
189189+ updatePath();
190190+ validate_();
191191+ }
192192+193193+ private void updatePath()
194194+ {
195195+ String id = getEffectiveId();
196196+ File dir;
197197+198198+ if (browsedDir != null) {
199199+ dir = id.isEmpty() ? browsedDir : new File(browsedDir, id);
200200+ }
201201+ else {
202202+ File projectsDir = getDefaultProjectsDir();
203203+ dir = id.isEmpty() ? projectsDir : new File(projectsDir, id);
204204+ }
205205+206206+ pathField.setText(abbreviateHome(dir.getAbsolutePath()));
207207+ }
208208+209209+ private String getEffectiveId()
210210+ {
211211+ String id = idField.getText().trim();
212212+ if (id.isEmpty())
213213+ return toSnakeCase(nameField.getText());
214214+ return id;
215215+ }
216216+217217+ private File getProjectPath()
218218+ {
219219+ String id = getEffectiveId();
220220+ if (browsedDir != null) {
221221+ return id.isEmpty() ? browsedDir : new File(browsedDir, id);
222222+ }
223223+ File projectsDir = getDefaultProjectsDir();
224224+ return id.isEmpty() ? projectsDir : new File(projectsDir, id);
225225+ }
226226+227227+ private void browseForPath()
228228+ {
229229+ DirChooser dirChooser = new DirChooser(getDefaultProjectsDir(), "Select Project Location");
230230+ if (dirChooser.prompt() == ChooseDialogResult.APPROVE) {
231231+ File selected = dirChooser.getSelectedFile();
232232+ String[] contents = selected.list();
233233+ if (contents != null && contents.length > 0) {
234234+ // Directory has files, use it as parent
235235+ browsedDir = selected;
236236+ }
237237+ else {
238238+ // Empty directory, use it directly
239239+ browsedDir = selected.getParentFile();
240240+ // If the selected dir name matches the id, just use parent as browsedDir
241241+ String id = getEffectiveId();
242242+ if (!selected.getName().equals(id)) {
243243+ browsedDir = selected;
244244+ }
245245+ }
246246+ updatePath();
247247+ validate_();
248248+ }
249249+ }
250250+251251+ private void validate_()
252252+ {
253253+ String name = nameField.getText().trim();
254254+ String id = getEffectiveId();
255255+ File path = getProjectPath();
256256+257257+ String error = null;
258258+259259+ if (name.isEmpty()) {
260260+ error = "Enter a project name";
261261+ }
262262+ else if (id.isEmpty()) {
263263+ error = "Enter a project ID";
264264+ }
265265+ else if (!id.matches("[a-z]*[a-z0-9_]*")) {
266266+ error = "ID must contain only lowercase letters, digits, and underscores, and must start with a letter";
267267+ }
268268+ else if (new File(path, "project.kdl").exists()) {
269269+ error = "A project already exists at this location";
270270+ }
271271+272272+ if (error != null) {
273273+ createButton.setToolTipText(error);
274274+ createButton.setEnabled(false);
275275+ }
276276+ else {
277277+ createButton.setToolTipText(null);
278278+ createButton.setEnabled(true);
279279+ }
280280+ }
281281+282282+ private void createProject()
283283+ {
284284+ File path = getProjectPath();
285285+ String id = getEffectiveId();
286286+ String name = nameField.getText().trim();
287287+288288+ try {
289289+ result = Project.create(path, "blank", id, name);
290290+ setVisible(false);
291291+ }
292292+ catch (IOException | KdlParseException e) {
293293+ Logger.logError("Failed to create project: " + e.getMessage());
294294+ Environment.showErrorMessage("Failed to create project", "%s", e.getMessage());
295295+ }
296296+ }
297297+298298+ private static File getDefaultProjectsDir()
299299+ {
300300+ String configured = Environment.mainConfig.getString(Options.ProjectsDir);
301301+ if (configured != null && !configured.isEmpty()) {
302302+ return new File(configured);
303303+ }
304304+305305+ File docs = Environment.getUserDocumentsDir();
306306+ String subdir = Environment.isLinux() ? "starrod" : "Star Rod";
307307+ return new File(docs, subdir);
308308+ }
309309+310310+ static String toSnakeCase(String input)
311311+ {
312312+ return input.trim()
313313+ .toLowerCase()
314314+ .replaceAll("[^a-z0-9]+", "_")
315315+ .replaceAll("_+", "_")
316316+ .replaceAll("^_|_$", "");
317317+ }
318318+319319+ private static String abbreviateHome(String path)
320320+ {
321321+ String home = System.getProperty("user.home");
322322+ if (home != null && path.startsWith(home)) {
323323+ return "~" + path.substring(home.length());
324324+ }
325325+ return path;
326326+ }
327327+}