From 864b818fba0a48232efa6ccd78e41be02254ac88 Mon Sep 17 00:00:00 2001 From: Dance Chess Date: Sun, 6 Sep 2026 23:31:23 -0700 Subject: [PATCH] Copy games between tabs, and notice when the file changes under us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second half of the multi-file work. **Copy to.** Right-click ▸ Copy to ▸ another open tab appends the selected games to that file and writes it back once. "Already there" is decided by the moves, not the headers: a `signature` column hashes the start position and the main line (check and mate suffixes stripped), because two files of one tournament disagree about the event's name, how a player is spelled and sometimes who had White, and the moves are the one thing both copies got from the same scoresheet. Copying the same games again lands nothing and says so. The column means a schema change. `PRAGMA user_version` now carries the schema version; an older cache is upgraded in place so it opens and reports `needs_rebuild`, which the app treats as stale — a two-second re-import against the alternative of query paths that tolerate every past layout. The index on the new column has to be created after the upgrade step, not in the CREATE batch: on an old cache that batch ran first and failed on "no such column". **The file is watched.** A `DispatchSource` on the path; the app's own writes are told apart by the modification date recorded after each one, and an editor's save-by-rename — which swaps the inode under the descriptor — re-arms the watch. The banner offers Reload (take the file as it is now; this file's sessions are detached first, since a re-import renumbers the games and a session keeping an old id could save into a different one) or Keep Mine (carry on; the next save replaces the file, with the .pgn.bak kept). Checked with the hooks: copying two games lands the one that is not a duplicate; copying again lands none; an external append raises the banner and leaves the count alone; Reload takes it to the new count; the app's own save raises nothing. Rust: 37 tests. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01F9o9DSzZas4GsekCpG5M8T --- README.md | 2 +- app/Studio/Database/GameListView.swift | 43 ++++++- app/Studio/MainWindow.swift | 64 +++++++++++ app/Studio/Model/DatabaseStore.swift | 148 ++++++++++++++++++++++++- app/Studio/Model/GameSession.swift | 8 ++ core/src/db.rs | 139 ++++++++++++++++++++++- docs/ARCHITECTURE.md | 21 +++- docs/ROADMAP.md | 24 ++-- 8 files changed, 432 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1bd1190..dfcb420 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ coaches on macOS. Part of the DanceChess family. ## Features -- **One window per PGN, as tabs** — board + notation on top, that file's game list below; open files come back on launch. +- **One window per PGN, as tabs** — board + notation on top, that file's game list below; open files come back on launch. Copy games between tabs (duplicates skipped); a file changed by another program is flagged. Arrow keys browse games and step through moves without ever touching the mouse; `Enter` dives into a game, `Esc` comes back. - **PGN is the source of truth** — open any .pgn and its games *are* the diff --git a/app/Studio/Database/GameListView.swift b/app/Studio/Database/GameListView.swift index d4f3e2f..12d587a 100644 --- a/app/Studio/Database/GameListView.swift +++ b/app/Studio/Database/GameListView.swift @@ -45,6 +45,8 @@ struct GameListView: NSViewRepresentable { let onDeleteRequest: ((Int64) -> Void)? /// Right-click with several rows selected → Merge Selected Games. var onMergeRequest: (([Int64]) -> Void)? = nil + /// Right-click → Copy to ▸ another open file. + var onCopyRequest: (([Int64], DatabaseStore) -> Void)? = nil /// The game to land on when the list first fills (the one last viewed /// in this file); nil = row 0. var initialSelection: Int64? = nil @@ -85,6 +87,11 @@ struct GameListView: NSViewRepresentable { keyEquivalent: "") merge.target = context.coordinator menu.addItem(merge) + // filled in when the menu opens: the other files open right now + let copy = NSMenuItem(title: "Copy to", action: nil, keyEquivalent: "") + copy.submenu = NSMenu(title: "Copy to") + menu.addItem(copy) + menu.delegate = context.coordinator table.menu = menu // initial sort indicator mirrors the store (file order) table.sortDescriptors = [NSSortDescriptor(key: "number", ascending: true)] @@ -118,7 +125,7 @@ struct GameListView: NSViewRepresentable { } @MainActor - final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { struct ColumnSpec { let id: String let title: String @@ -373,6 +380,39 @@ struct GameListView: NSViewRepresentable { return table.selectedRowIndexes.compactMap { summary(at: $0)?.id } } + /// The context menu is opening: list every other open file under + /// "Copy to". Built each time, since tabs come and go. + func menuNeedsUpdate(_ menu: NSMenu) { + guard let item = menu.items.first(where: { $0.title == "Copy to" }), + let sub = item.submenu else { return } + sub.removeAllItems() + let targets = OpenStores.shared.all.filter { $0 !== view.store && $0.canWriteBack } + for target in targets { + let entry = NSMenuItem(title: target.sourceName ?? "?", + action: #selector(copyClicked(_:)), keyEquivalent: "") + entry.target = self + entry.representedObject = target + sub.addItem(entry) + } + if targets.isEmpty { + let none = NSMenuItem(title: "No other file open", action: nil, keyEquivalent: "") + none.isEnabled = false + sub.addItem(none) + } + item.isEnabled = view.onCopyRequest != nil && !targets.isEmpty + && (!selectedIds.isEmpty || (table?.clickedRow ?? -1) >= 0) + } + + @objc func copyClicked(_ sender: NSMenuItem) { + guard let target = sender.representedObject as? DatabaseStore else { return } + var ids = selectedIds + if ids.isEmpty, let table, table.clickedRow >= 0, let g = summary(at: table.clickedRow) { + ids = [g.id] + } + guard !ids.isEmpty else { return } + view.onCopyRequest?(ids, target) + } + @objc func mergeClicked(_ sender: Any?) { let ids = selectedIds guard ids.count >= 2 else { return } @@ -389,6 +429,7 @@ struct GameListView: NSViewRepresentable { if item.action == #selector(mergeClicked(_:)) { return view.onMergeRequest != nil && selectedIds.count >= 2 } + if item.action == #selector(copyClicked(_:)) { return true } return view.onDeleteRequest != nil && (table?.clickedRow ?? -1) >= 0 } } diff --git a/app/Studio/MainWindow.swift b/app/Studio/MainWindow.swift index 5694828..afdb648 100644 --- a/app/Studio/MainWindow.swift +++ b/app/Studio/MainWindow.swift @@ -73,6 +73,7 @@ struct MainWindow: View { .frame(minHeight: 380) VStack(spacing: 0) { + if store.externallyChanged { changedOnDiskBanner } searchBar Divider() GameListView( @@ -95,6 +96,7 @@ struct MainWindow: View { }, onDeleteRequest: { confirmAndDelete($0) }, onMergeRequest: { mergeGames($0) }, + onCopyRequest: { ids, target in copyGames(ids, to: target) }, initialSelection: store.lastSelectedGameId ) Divider() @@ -165,6 +167,30 @@ struct MainWindow: View { NSApp.sendAction(#selector(NSResponder.newWindowForTab(_:)), to: nil, from: nil) } } + // dev hook: copy games 1–2 of THIS window's file into the other + // open file, then report (run twice to see duplicates skipped) + if let which = ProcessInfo.processInfo.environment["DCS_AUTO_COPY"], + url?.lastPathComponent == which { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + guard let target = OpenStores.shared.all.first(where: { $0 !== store }) else { return } + let before = target.gameCount + copyGames([1, 2], to: target) + let out = ProcessInfo.processInfo.environment["DCS_AUTO_COPY_OUT"] ?? "/tmp/dcs-copy.txt" + try? "target before: \(before) after: \(target.gameCount)\nsource status: \(store.statusText ?? "-")\ntarget status: \(target.statusText ?? "-")\n" + .write(toFile: out, atomically: true, encoding: .utf8) + } + } + // dev hook: the file-changed banner — report after a delay, + // optionally reloading first + if let out = ProcessInfo.processInfo.environment["DCS_AUTO_WATCH_OUT"], url != nil { + if ProcessInfo.processInfo.environment["DCS_AUTO_WATCH_RELOAD"] != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { store.reloadFromDisk() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 7) { + try? "changed: \(store.externallyChanged)\ngames: \(store.gameCount)\nstatus: \(store.statusText ?? "-")\n" + .write(toFile: out, atomically: true, encoding: .utf8) + } + } // dev hook: report the windows/tabs and engine states if let out = ProcessInfo.processInfo.environment["DCS_AUTO_TABS_OUT"], url != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { @@ -428,6 +454,44 @@ struct MainWindow: View { .frame(minWidth: 860, minHeight: 600) } + /// The source file changed under us. Reload throws this cache away — + /// including unsaved edits to this file's games — and takes the file as + /// it is now; Keep Mine carries on, and the next save replaces the + /// file (with a .pgn.bak kept). + private var changedOnDiskBanner: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + Text("“\(store.sourceURL?.lastPathComponent ?? "This file")” was changed by another program.") + .font(.system(size: 12)) + Spacer() + Button("Reload") { store.reloadFromDisk() } + .help("Take the file as it is on disk now. Unsaved edits to its games are lost.") + Button("Keep Mine") { store.keepMine() } + .help("Keep what this window has; the next save replaces the file (a .pgn.bak is kept).") + } + .controlSize(.small) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.yellow.opacity(0.18)) + } + + /// Copy to ▸ another open file: whole games, duplicates (same moves) + /// skipped, the target written back once. Both tabs say what happened. + private func copyGames(_ ids: [Int64], to target: DatabaseStore) { + let pgns = ids.compactMap { store.pgn(for: $0) } + guard !pgns.isEmpty else { return } + do { + let r = try target.copyGames(pgns) + let what = r.duplicates == 0 + ? "\(r.copied) game\(r.copied == 1 ? "" : "s")" + : "\(r.copied) game\(r.copied == 1 ? "" : "s") (\(r.duplicates) already there)" + store.setStatus("copied \(what) to “\(target.sourceName ?? "?")”") + target.setStatus("\(what) copied in from “\(store.sourceName ?? "?")”") + } catch { + store.setStatus("copy failed: \(error.localizedDescription)") + } + } + private var searchBar: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") diff --git a/app/Studio/Model/DatabaseStore.swift b/app/Studio/Model/DatabaseStore.swift index 88dba97..40da57f 100644 --- a/app/Studio/Model/DatabaseStore.swift +++ b/app/Studio/Model/DatabaseStore.swift @@ -254,6 +254,10 @@ final class DatabaseStore { try? FileManager.default.setAttributes( [.modificationDate: Date()], ofItemAtPath: cacheURL.path) } + // our own write; the watcher must not read it as somebody else's + noteSourceDate() + // the rename swapped the inode under the descriptor + startWatching() } /// Loads one PGN file into this (empty) store. Reuses the file's cache @@ -286,8 +290,9 @@ final class DatabaseStore { private func open(url: URL, cacheURL: URL) async throws { let scoped = url.startAccessingSecurityScopedResource() defer { if scoped { url.stopAccessingSecurityScopedResource() } } - let fresh = Self.cacheIsFresh(cacheURL, source: url) let db = try Database.open(path: cacheURL.path) + // newer than the file, AND laid out the way this version expects + let fresh = Self.cacheIsFresh(cacheURL, source: url) && !db.needsRebuild() if fresh { self.db = db gameCount = (try? db.gameCount()) ?? 0 @@ -307,6 +312,147 @@ final class DatabaseStore { filteredCount = gameCount generation += 1 revision += 1 + noteSourceDate() + startWatching() + } + + // MARK: copying games in from another file + + /// Appends games (as PGN text) that are not already here — "already" + /// meaning the same start position and main line, whatever the + /// headers say — then writes the file back once. Returns how many + /// landed and how many were duplicates. + func copyGames(_ pgns: [String]) throws -> (copied: Int, duplicates: Int) { + guard let db, canWriteBack else { + throw ChessError.Database(reason: "no PGN file to copy into") + } + var copied = 0, duplicates = 0 + for pgn in pgns { + if (try? db.findDuplicate(pgn: pgn)) ?? nil != nil { + duplicates += 1 + continue + } + _ = try db.addGame(pgn: pgn) + copied += 1 + } + if copied > 0 { + try writeBack() + gameCount = (try? db.gameCount()) ?? gameCount + recount() + revision += 1 + } + return (copied, duplicates) + } + + // MARK: the source file changing under us + + /// Another program wrote the file since it was loaded (or since we + /// last wrote it). The window shows a banner; `reloadFromDisk` or + /// `keepMine` clears it. Until one is chosen, saving would overwrite + /// the other program's work with this cache — which is also what + /// `keepMine` chooses, deliberately, with the .bak as the safety net. + private(set) var externallyChanged = false + private var watcher: DispatchSourceFileSystemObject? + private var watchedFD: Int32 = -1 + /// The file's modification date as we last knew it — after loading it + /// and after each write of our own, which is how our own writes are + /// told apart from somebody else's. + private var knownSourceDate: Date? + + private func sourceDate() -> Date? { + guard let path = sourceURL?.path, + let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { return nil } + return attrs[.modificationDate] as? Date + } + + private func noteSourceDate() { knownSourceDate = sourceDate() } + + private func startWatching() { + stopWatching() + guard let path = sourceURL?.path else { return } + let fd = Darwin.open(path, O_EVTONLY) + guard fd >= 0 else { return } + watchedFD = fd + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, eventMask: [.write, .rename, .delete, .attrib, .extend], queue: .main) + source.setEventHandler { [weak self] in + guard let self else { return } + let flags = source.data + MainActor.assumeIsolated { self.fileEvent(flags) } + } + source.setCancelHandler { close(fd) } + source.resume() + watcher = source + } + + private func stopWatching() { + watcher?.cancel() + watcher = nil + watchedFD = -1 + } + + private func fileEvent(_ flags: DispatchSource.FileSystemEvent) { + // editors save by writing a new file and renaming it over ours: + // the descriptor now points at the old inode, so watch the path + // again once the dust settles + if flags.contains(.rename) || flags.contains(.delete) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self else { return } + MainActor.assumeIsolated { + self.startWatching() + self.checkForExternalChange() + } + } + return + } + checkForExternalChange() + } + + private func checkForExternalChange() { + guard !externallyChanged, let now = sourceDate() else { return } + if let known = knownSourceDate, now.timeIntervalSince(known) <= 0.5 { return } + externallyChanged = true + } + + /// Throw this cache away and import the file as it is now. Games of + /// this file open in a session are detached first: after a re-import + /// the ids are file order again, and a session still holding an old + /// id could save into a different game. + func reloadFromDisk() { + guard let url = sourceURL, let db, !importing else { return } + externallyChanged = false + importing = true + GameSession.SessionRegistry.shared.detach(store: self) + Task { + do { + statusText = "Reloading…" + try await Self.runClear(db: db) + let stats = try await Self.runImport(db: db, path: url.path) + gameCount = (try? db.gameCount()) ?? 0 + statusText = String(format: "reloaded: %d games (%d skipped) in %.1fs", + stats.imported, stats.skipped, Double(stats.millis) / 1000) + if let cacheURL { + try? FileManager.default.setAttributes( + [.modificationDate: Date()], ofItemAtPath: cacheURL.path) + } + filter = GameFilter(text: nil, result: nil, dateFrom: nil, dateTo: nil, + minElo: nil, maxElo: nil, fen: nil) + filteredCount = gameCount + generation += 1 + revision += 1 + noteSourceDate() + } catch { + errorText = "Reload failed — \(error.localizedDescription)" + } + importing = false + } + } + + /// Dismiss the banner and carry on with this cache; the next save + /// replaces the file (a .pgn.bak of the current file is kept). + func keepMine() { + externallyChanged = false + noteSourceDate() } /// One cache db per source file, keyed by its canonical path. diff --git a/app/Studio/Model/GameSession.swift b/app/Studio/Model/GameSession.swift index 3d7741b..e0ce9fa 100644 --- a/app/Studio/Model/GameSession.swift +++ b/app/Studio/Model/GameSession.swift @@ -619,6 +619,14 @@ final class GameSession { boxes.compactMap(\.session) .filter { $0.sourceGameId >= 0 && $0.isModified && $0.store != nil } } + + /// The file is being re-imported: ids of its games are about to + /// change, so no session may keep one. + func detach(store: DatabaseStore) { + for s in boxes.compactMap(\.session) where s.store === store { + s.detachFromDatabase() + } + } } private func applyFen() { diff --git a/core/src/db.rs b/core/src/db.rs index 9f8e186..f3547de 100644 --- a/core/src/db.rs +++ b/core/src/db.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use std::time::Instant; use pgn_reader::BufferedReader; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use shakmaty::fen::Fen; use shakmaty::san::San; use shakmaty::zobrist::{Zobrist64, ZobristHash}; @@ -14,6 +14,12 @@ use crate::{ChessError, START_FEN}; /// Opening-tree positions are indexed for the first N plies of each game. const TREE_MAX_PLY: usize = 60; +/// Bumped when the cache's shape changes. A cache built by an older +/// version reports `needs_rebuild`, and the app re-imports the PGN — two +/// seconds for 100k games, against the alternative of code paths that +/// tolerate every past layout. +const SCHEMA_VERSION: i64 = 2; + const RESULT_WHITE: i64 = 0; const RESULT_DRAW: i64 = 1; const RESULT_BLACK: i64 = 2; @@ -93,6 +99,26 @@ pub struct GameFilter { #[derive(uniffi::Object)] pub struct Database { conn: Mutex, + /// The file predates the current schema: rows lack what the new + /// columns hold, so the caller should clear and re-import. + stale: bool, +} + +/// What makes two games "the same game": the start position and the +/// moves of the main line, nothing else. Two files of one tournament +/// disagree about the event's name, how a name is spelled and sometimes +/// who had White; the moves are the one thing both copies got from the +/// same scoresheet. Check and mate suffixes are stripped, since one copy +/// may write them and another not. +fn signature(game: &crate::game::GameInner) -> i64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + let root: String = game.root_fen.split_whitespace().take(4).collect::>().join(" "); + root.hash(&mut h); + for san in game.mainline_sans() { + san.trim_end_matches(['+', '#']).hash(&mut h); + } + h.finish() as i64 } fn db_err(e: impl std::fmt::Display) -> ChessError { @@ -220,7 +246,8 @@ impl Database { round TEXT NOT NULL DEFAULT '', eco TEXT NOT NULL DEFAULT '', ply_count INTEGER NOT NULL DEFAULT 0, - pgn TEXT NOT NULL + pgn TEXT NOT NULL, + signature INTEGER ); CREATE TABLE IF NOT EXISTS positions ( zobrist INTEGER NOT NULL, @@ -231,11 +258,65 @@ impl Database { CREATE INDEX IF NOT EXISTS idx_positions_zobrist ON positions(zobrist);", ) .map_err(db_err)?; + let version: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .map_err(db_err)?; + let games: i64 = conn + .query_row("SELECT count(*) FROM games", [], |r| r.get(0)) + .map_err(db_err)?; + // a cache from before the signature column: add it so the file + // opens, and say the rows need re-importing to fill it + let stale = version < SCHEMA_VERSION && games > 0; + if version < SCHEMA_VERSION { + let has_signature: bool = conn + .prepare("PRAGMA table_info(games)") + .map_err(db_err)? + .query_map([], |r| r.get::<_, String>(1)) + .map_err(db_err)? + .filter_map(Result::ok) + .any(|c| c == "signature"); + if !has_signature { + conn.execute_batch("ALTER TABLE games ADD COLUMN signature INTEGER;") + .map_err(db_err)?; + } + conn.pragma_update(None, "user_version", SCHEMA_VERSION) + .map_err(db_err)?; + } + // only once the column is certain to exist + conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_games_signature ON games(signature);") + .map_err(db_err)?; Ok(Database { conn: Mutex::new(conn), + stale, }) } + /// True when this cache was written by an older schema and should be + /// cleared and re-imported before use. + pub fn needs_rebuild(&self) -> bool { + self.stale + } + + /// The id of a game already here with the same start position and + /// main line as `pgn`, if any — what Copy Games To checks so a game + /// copied twice lands once. + pub fn find_duplicate(&self, pgn: String) -> Result, ChessError> { + let mut reader = BufferedReader::new(pgn.as_bytes()); + let mut builder = GameBuilder::default(); + let game = reader + .read_game(&mut builder) + .map_err(db_err)? + .ok_or_else(|| db_err("empty PGN"))?; + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id FROM games WHERE signature = ?1 ORDER BY id LIMIT 1", + [signature(&game)], + |r| r.get::<_, i64>(0), + ) + .optional() + .map_err(db_err) + } + /// Streams every game of a PGN file into the database. /// Unparsable games are skipped, not fatal. pub fn import_pgn_file(&self, path: String) -> Result { @@ -508,7 +589,8 @@ impl Database { let updated = tx .execute( "UPDATE games SET white=?1, black=?2, white_elo=?3, black_elo=?4, result=?5, - event=?6, site=?7, date=?8, round=?9, eco=?10, ply_count=?11, pgn=?12 + event=?6, site=?7, date=?8, round=?9, eco=?10, ply_count=?11, pgn=?12, + signature=?14 WHERE id=?13", params![ h("White"), @@ -524,6 +606,7 @@ impl Database { game.mainline_sans().len() as i64, game.write_pgn(), id, + signature(&game), ], ) .map_err(db_err)?; @@ -628,8 +711,8 @@ fn insert_game(tx: &rusqlite::Transaction, game: &crate::game::GameInner) -> Res let elo = |key: &str| -> Option { h(key).parse().ok() }; let result = h("Result"); tx.execute( - "INSERT INTO games (white, black, white_elo, black_elo, result, event, site, date, round, eco, ply_count, pgn) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "INSERT INTO games (white, black, white_elo, black_elo, result, event, site, date, round, eco, ply_count, pgn, signature) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ h("White"), h("Black"), @@ -643,6 +726,7 @@ fn insert_game(tx: &rusqlite::Transaction, game: &crate::game::GameInner) -> Res h("ECO"), game.mainline_sans().len() as i64, game.write_pgn(), + signature(game), ], ) .map_err(db_err)?; @@ -1028,4 +1112,49 @@ mod tests { } let _ = std::fs::remove_file(path); } + + #[test] + fn a_duplicate_is_the_same_moves_not_the_same_headers() { + let (db, path) = temp_db(); + import_str(&db, TWO_GAMES); + // same moves, every header different, a mate suffix written differently + let copy = "[Event \"elsewhere\"]\n[White \"A. Lice\"]\n[Black \"B\"]\n[Result \"*\"]\n\n1. e4 e5 2. Nf3 Nc6 *"; + assert_eq!(db.find_duplicate(copy.into()).unwrap(), Some(1)); + // one move different: not a duplicate + assert_eq!(db.find_duplicate("1. e4 e5 2. Nf3 Nf6 *".into()).unwrap(), None); + // a game edited in place keeps its signature current + db.update_game(1, "1. d4 d5 2. c4 *".into()).unwrap(); + assert_eq!(db.find_duplicate("1. d4 d5 2. c4 *".into()).unwrap(), Some(1)); + assert_eq!(db.find_duplicate("1. e4 e5 2. Nf3 Nc6 *".into()).unwrap(), None); + assert!(!db.needs_rebuild()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn a_cache_from_the_old_schema_asks_to_be_rebuilt() { + let path = std::env::temp_dir().join(format!( + "dcstudio-test-{}-{}.db", std::process::id(), unique())); + let _ = std::fs::remove_file(&path); + { + // hand-made old cache: no signature column, no user_version, one row + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE games (id INTEGER PRIMARY KEY, white TEXT NOT NULL DEFAULT '', + black TEXT NOT NULL DEFAULT '', white_elo INTEGER, black_elo INTEGER, + result TEXT NOT NULL DEFAULT '*', event TEXT NOT NULL DEFAULT '', + site TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '', + round TEXT NOT NULL DEFAULT '', eco TEXT NOT NULL DEFAULT '', + ply_count INTEGER NOT NULL DEFAULT 0, pgn TEXT NOT NULL); + INSERT INTO games (pgn) VALUES ('1. e4 *');").unwrap(); + } + let db = Database::open(path.to_string_lossy().into()).unwrap(); + assert!(db.needs_rebuild(), "old rows have no signature"); + // after a rebuild the same file is current + db.clear_all().unwrap(); + import_str(&db, TWO_GAMES); + let again = Database::open(path.to_string_lossy().into()).unwrap(); + assert!(!again.needs_rebuild()); + assert_eq!(again.find_duplicate("1. e4 e5 2. Nf3 Nc6 *".into()).unwrap(), Some(1)); + let _ = std::fs::remove_file(path); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a74c22..20a2240 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,7 @@ more naturally expressed in Swift, so `UCIKit` owns it. | `notation.rs` | `notation_tokens()` — flattens the tree into the token stream the notation panel renders, including a `Diagram` token for NAG `$220`. The contract is in [NOTATION-VIEW.md](NOTATION-VIEW.md). | | `db.rs` | The `Database` object: streaming PGN import, paged and sorted game lists, one `GameFilter` (text, result, date range, Elo range, position) behind `query_games`/`count_games`, opening-tree aggregation, write-back to `.pgn`. | -`cargo test` covers all of it (35 tests), including PGN round-trip fidelity — +`cargo test` covers all of it (37 tests), including PGN round-trip fidelity — the property everything else leans on. ## The FFI contract @@ -104,11 +104,21 @@ but line breaks and spacing become ours. ```sql games(id, white, black, white_elo, black_elo, result, event, site, - date, round, eco, ply_count, pgn) + date, round, eco, ply_count, pgn, signature) -- INDEX(signature) positions(zobrist, game_id, move, result) -- INDEX(zobrist) ``` +- `signature` is a hash of the start position and the main line's moves + (check/mate suffixes stripped) — what "the same game" means when copying + between files. Two files of one tournament disagree about the event's + name, how a player is spelled, sometimes who had White; the moves are the + one thing both copies got from the same scoresheet. +- `PRAGMA user_version` carries the schema version. A cache from an older + layout is upgraded in place so it opens, and reports `needs_rebuild`; the + app clears and re-imports it — two seconds for 100k games, against the + alternative of query paths that tolerate every past layout. + - `games.pgn` holds the **re-serialized** game, not the original text. Storing one canonical form means there is exactly one parse path on the way back out. - `positions` exists solely for the opening tree and the reference view; it is @@ -173,6 +183,13 @@ rendered by `BoardImage`, tagged with the move's node id like any other run. - `EngineSession` suspends when its window resigns key and resumes when it becomes key again, so several tabs with the panel open are one running search, not several. +- Each store watches its file with a `DispatchSource` on the path. Its own + writes are told apart by the modification date recorded after each write; + an editor's save-by-rename swaps the inode under the descriptor, so a + rename/delete event re-arms the watch on the path. A reload detaches the + sessions holding this file's games first: after a re-import the ids are + file order again, and a session keeping an old id could save into a + different game. - `app/Package.swift` is a SwiftPM harness that builds and runs the whole app with only the Command Line Tools. `app/project.yml` (XcodeGen) is optional diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a7f3b8c..8bed950 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,6 +17,17 @@ a `.pgn.bak` kept from before the session's first write. New files, manual game entry with a ChessBase-style save mask, delete, and per-game PGN export all round-trip through the same path. +**Games move between files.** Right-click ▸ Copy to ▸ *another open tab* +appends the selected games to that file and writes it back once. A game +already there — same start position, same main line, whatever the headers +say — is skipped and counted, so copying twice lands once. + +**The file is watched.** If another program changes a `.pgn` while it is +open here, a banner offers **Reload** (take the file as it is now; unsaved +edits to its games are lost) or **Keep Mine** (carry on; the next save +replaces the file, with a `.pgn.bak` kept). The app's own saves do not +trigger it. + **One window per file, as tabs.** Every `.pgn` you open gets its own window, and windows tab together the way Safari's do (drag them apart if you prefer windows). A file is opened once: opening it again brings its tab forward. Each @@ -96,13 +107,12 @@ game, which is what makes studies and tactics puzzles usable. - **Not notarized.** The released build is ad-hoc signed, so macOS quarantines it on first launch. See the install notes in the README. - **Apple Silicon only.** No Intel build. -- **A source file edited outside the app discards in-app edits.** Cache - freshness is a modification-time comparison made when the file is opened; - if the `.pgn` is newer, the cache is rebuilt from it. Save before editing - the file elsewhere — and with several tabs open for a long session, this - matters more than it used to. A file watcher is the next step. -- **Nothing moves between tabs yet.** Copying games from one file to another - is the second half of the multi-file work. +- **A source file edited outside the app while it is closed here is taken as + the truth.** Cache freshness is a modification-time comparison made when + the file is opened; if the `.pgn` is newer, the cache is rebuilt from it + and in-app edits that were never saved are gone. (While the file is open, + the banner above catches this.) +- **No drag and drop between tabs**; copying is a menu command. - **No cross-file search**, no player or tournament index, no position or material search. A search is about one file — the tab it runs in. - **Analysis uses a fixed depth**; there is no time budget, no second