diff --git a/src/main/gameStorage.js b/src/main/gameStorage.js
new file mode 100644
index 00000000..a52e53d0
--- /dev/null
+++ b/src/main/gameStorage.js
@@ -0,0 +1,81 @@
+import fs from 'fs'
+import path from 'path'
+import { app } from 'electron'
+
+export function clearAllGamePaths () {
+ try {
+ fs.writeFileSync(savedGamesPath, JSON.stringify({ games: [] }, null, 2), 'utf8')
+ return true
+ } catch (error) {
+ console.error('Error clearing all game paths:', error)
+ return false
+ }
+}
+const appDataPath = app.getPath('userData')
+const savedGamesPath = path.join(appDataPath, 'savedGames.json')
+
+/**
+ * Load all saved game file paths
+ */
+export function loadSavedGamePaths () {
+ try {
+ if (fs.existsSync(savedGamesPath)) {
+ const data = fs.readFileSync(savedGamesPath, 'utf8')
+ return JSON.parse(data)
+ }
+ } catch (error) {
+ console.error('Error loading saved game paths:', error)
+ }
+ return { games: [] }
+}
+/**
+ * Save a game file path to the registry
+ */
+export function addGamePath (filePath) {
+ try {
+ const data = loadSavedGamePaths()
+ // Check if path already exists
+ if (!data.games.includes(filePath)) {
+ data.games.push(filePath)
+ fs.writeFileSync(savedGamesPath, JSON.stringify(data, null, 2), 'utf8')
+ }
+ return true
+ } catch (error) {
+ console.error('Error adding game path:', error)
+ return false
+ }
+}
+/**
+ * Remove a game file path from the registry
+ */
+export function removeGamePath (filePath) {
+ try {
+ const data = loadSavedGamePaths()
+ data.games = data.games.filter(p => p !== filePath)
+ fs.writeFileSync(savedGamesPath, JSON.stringify(data, null, 2), 'utf8')
+ return true
+ } catch (error) {
+ console.error('Error removing game path:', error)
+ return false
+ }
+}
+/**
+ * Get all saved game file paths
+ */
+export function getAllSavedGamePaths () {
+ const data = loadSavedGamePaths()
+ const existingPaths = []
+ for (const filePath of data.games) {
+ try {
+ if (fs.existsSync(filePath)) {
+ existingPaths.push(filePath)
+ } else {
+ // File no longer exists, remove it from the list
+ removeGamePath(filePath)
+ }
+ } catch (error) {
+ console.error(`Error checking game file ${filePath}:`, error)
+ }
+ }
+ return existingPaths
+}
diff --git a/src/main/index.js b/src/main/index.js
index 872ebb7d..af3c30e0 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -1,7 +1,17 @@
'use strict'
import { app, BrowserWindow, dialog, ipcMain, Menu } from 'electron'
+import { addGamePath, removeGamePath, getAllSavedGamePaths, clearAllGamePaths } from './gameStorage'
import { createSchema, insertEval, getEvals } from './evalCache'
+// IPC handler to clear all saved game paths
+ipcMain.handle('clear-all-game-paths', async () => {
+ try {
+ const success = clearAllGamePaths()
+ return { success }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+})
/**
* Set `__static` path to static files in production
@@ -90,6 +100,28 @@ ipcMain.handle('show-open-dialog', async (event, options) => {
}
})
+// IPC handler for save-as dialog
+ipcMain.handle('show-save-dialog', async (event, options) => {
+ const browserWindow = mainWindow || null
+ try {
+ const res = await dialog.showSaveDialog(browserWindow, options || {})
+ return res
+ } catch (err) {
+ return { canceled: true, filePath: '' }
+ }
+})
+
+// IPC handler for writing files
+ipcMain.handle('write-file', async (event, filePath, content) => {
+ const fs = require('fs')
+ try {
+ fs.writeFileSync(filePath, content, 'utf8')
+ return { success: true }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+})
+
// Build and show a context menu requested from renderer. The renderer
// sends a simplified template (no functions) and we build native menu
// items whose click handlers forward a message back to the renderer.
@@ -120,6 +152,48 @@ ipcMain.handle('show-context-menu', async (event, template) => {
return false
}
})
+
+// IPC handler to load all saved game paths
+ipcMain.handle('load-saved-games', async (event) => {
+ try {
+ const paths = getAllSavedGamePaths()
+ return { success: true, paths }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+})
+
+// IPC handler to add a game path to the saved games list
+ipcMain.handle('add-game-path', async (event, filePath) => {
+ try {
+ const success = addGamePath(filePath)
+ return { success }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+})
+
+// IPC handler to remove a game path from the saved games list
+ipcMain.handle('remove-game-path', async (event, filePath) => {
+ try {
+ const success = removeGamePath(filePath)
+ return { success }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+})
+
+// IPC handler to read a PGN file content
+ipcMain.handle('read-pgn-file', async (event, filePath) => {
+ const fs = require('fs')
+ try {
+ const content = fs.readFileSync(filePath, 'utf8')
+ return { success: true, content }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+})
+
/**
* Auto Updater
*
diff --git a/src/renderer/components/AddCommentModal.vue b/src/renderer/components/AddCommentModal.vue
new file mode 100644
index 00000000..90bef748
--- /dev/null
+++ b/src/renderer/components/AddCommentModal.vue
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/components/AddPgnModal.vue b/src/renderer/components/AddPgnModal.vue
index b09729f2..fef69a70 100644
--- a/src/renderer/components/AddPgnModal.vue
+++ b/src/renderer/components/AddPgnModal.vue
@@ -85,7 +85,6 @@ export default {
// Try IPC fallback: ask main process to show dialog (matches EngineModal pattern)
let ipcRenderer
try {
- // eslint-disable-next-line
ipcRenderer = (typeof window !== 'undefined' && window.require) ? window.require('electron').ipcRenderer : require('electron').ipcRenderer
} catch (e) {
ipcRenderer = null
@@ -113,16 +112,29 @@ export default {
console.log(err)
}
},
- openPGNFromPath (path) {
- fs.readFile(path, 'utf8', (err, data) => {
+ async openPGNFromPath (path) {
+ fs.readFile(path, 'utf8', async (err, data) => {
if (err) {
return console.log(err)
}
-
// convert CRLF to LF
data = data.replace(/\r\n/g, '\n')
this.convertAndStorePgn(data)
this.close()
+ // Add the file path to saved games
+ let ipcRenderer
+ try {
+ ipcRenderer = (typeof window !== 'undefined' && window.require) ? window.require('electron').ipcRenderer : require('electron').ipcRenderer
+ } catch (e) {
+ ipcRenderer = null
+ }
+ if (ipcRenderer) {
+ try {
+ await ipcRenderer.invoke('add-game-path', path)
+ } catch (error) {
+ console.error('Error adding game path:', error)
+ }
+ }
})
},
openPGNFromString () {
@@ -136,7 +148,7 @@ export default {
}
},
convertAndStorePgn (data) {
- const regex = /(?:\[.+ ".*"\]\r?\n)+\r?\n+(?:.+\r?\n)*/gm
+ const regex = /((?:\[[^\]]+\][\r\n]+)+[\r\n]+(?:[^[][\s\S]*)?(?=(?:\[[^\]]+\][\r\n]+)|$))/g
let games = []
if (this.$store.getters.loadedGames) { // keep already loaded pgns
games = this.$store.getters.loadedGames
@@ -149,17 +161,17 @@ export default {
if (m.index === regex.lastIndex) {
regex.lastIndex++
}
- m.forEach((match, groupIndex) => {
- let game
- try {
- game = ffish.readGamePGN(match)
- } catch (error) {
- numOfUnparseableGames = numOfUnparseableGames + 1
- return
- }
- currentGameCount++
- games.push(game)
- })
+ let game
+ try {
+ game = ffish.readGamePGN(m[0])
+ } catch (error) {
+ numOfUnparseableGames = numOfUnparseableGames + 1
+ continue
+ }
+ currentGameCount++
+ // Store the original PGN text for comment extraction
+ game.originalPGN = m[0]
+ games.push(game)
}
if (numOfUnparseableGames !== 0) {
diff --git a/src/renderer/components/EvalBar.vue b/src/renderer/components/EvalBar.vue
index f6e29687..76166dc4 100644
--- a/src/renderer/components/EvalBar.vue
+++ b/src/renderer/components/EvalBar.vue
@@ -106,6 +106,6 @@ export default {
transition: height .25s ease;
}
.wdl-win { background: #4caf50; }
-.wdl-draw { background: #f4c542; }
+.wdl-draw { background: #5f5e5c; }
.wdl-loss { background: #e84c3d; }
diff --git a/src/renderer/components/LandingPage.vue b/src/renderer/components/LandingPage.vue
index 34de9183..8b01cfe8 100644
--- a/src/renderer/components/LandingPage.vue
+++ b/src/renderer/components/LandingPage.vue
@@ -6,6 +6,8 @@
+
+
diff --git a/src/renderer/store.js b/src/renderer/store.js
index 5896ef9d..eb6fa7c2 100644
--- a/src/renderer/store.js
+++ b/src/renderer/store.js
@@ -157,6 +157,47 @@ function checkOption (options, name, value) {
const filteredSettings = ['UCI_Variant', 'UCI_Chess960']
+/**
+ * Extract comments from PGN text
+ * Parses PGN format comments like {this is a comment} and maps them to move indices
+ * @param {string} pgnText The full PGN text
+ * @returns {Object} Map of move index to comment text
+ */
+function extractCommentsFromPGN (pgnText) {
+ const commentMap = {}
+ // Skip header section and get moves section
+ const headerEndIndex = pgnText.indexOf('\n\n')
+ if (headerEndIndex === -1) {
+ return commentMap
+ }
+ const movesSection = pgnText.substring(headerEndIndex + 2)
+ // Split moves section into tokens
+ const tokens = movesSection.split(/(\{[^}]*\}|\S+)/g).filter(t => t && t.trim())
+ let moveIndex = 0
+ let lastWasMove = false
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i]
+ // Skip move numbers and empty tokens
+ if (token.match(/^\d+\.\.?$/) || !token.trim() || token === '*') {
+ continue
+ }
+ // Check if this is a comment
+ if (token.match(/^\{[^}]*\}$/)) {
+ // Extract comment text without braces
+ const commentText = token.replace(/^\{/, '').replace(/\}$/, '')
+ // Associate comment with the current move (just played)
+ if (lastWasMove) {
+ commentMap[moveIndex - 1] = commentText
+ lastWasMove = false
+ }
+ } else if (token.trim()) {
+ // This is a move
+ moveIndex++
+ lastWasMove = true
+ }
+ }
+ return commentMap
+}
/* Helper to produce a `go` command from limiter configuration
** @param {Object} limiter Limiter configuration
*/
@@ -594,7 +635,12 @@ export const store = new Vuex.Store({
const sanMove = state.board.sanMove(curVal)
state.board.push(curVal)
this.commit('playAudio', sanMove)
- return { ply: ply, name: sanMove, fen: state.board.fen(), uci: curVal, whitePocket: state.board.pocket(true), blackPocket: state.board.pocket(false), main: undefined, next: [], prev: prev }
+ const moveObj = { ply: ply, name: sanMove, fen: state.board.fen(), uci: curVal, whitePocket: state.board.pocket(true), blackPocket: state.board.pocket(false), main: undefined, next: [], prev: prev }
+ // Add comment if provided (only for the first move in the sequence)
+ if (idx === 0 && payload.comment) {
+ moveObj.comment = payload.comment
+ }
+ return moveObj
}))
if (payload.prev) { // if the move is not a starting move
prev.next.push(state.moves[state.moves.length - 1]) // the last entry in moves is the move object of the current move
@@ -1731,15 +1777,19 @@ export const store = new Vuex.Store({
context.commit('newBoard', { fen: fen, is960: is960 })
}
await context.dispatch('fen', fen)
-
context.commit('selectedGame', payload.game)
context.commit('gameInfo', gameInfo)
const moves = payload.game.mainlineMoves().split(' ')
+ // Parse comments from the original PGN if available
+ let commentMap = {}
+ if (payload.game.originalPGN) {
+ commentMap = extractCommentsFromPGN(payload.game.originalPGN)
+ }
for (const num in moves) {
if (num === 0) {
- context.commit('appendMoves', { move: moves[num], prev: undefined })
+ context.commit('appendMoves', { move: moves[num], prev: undefined, comment: commentMap[0] })
} else {
- context.commit('appendMoves', { move: moves[num], prev: context.state.moves[num - 1] }) // TODO differentiate between alternative lines
+ context.commit('appendMoves', { move: moves[num], prev: context.state.moves[num - 1], comment: commentMap[num] }) // TODO differentiate between alternative lines
}
}
context.dispatch('updateBoard')