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