diff --git a/internal/detector/application.go b/internal/detector/application.go index 98b7440..da2ef62 100644 --- a/internal/detector/application.go +++ b/internal/detector/application.go @@ -577,7 +577,7 @@ func (bd *BD) gameStateTracker(ctx context.Context) { var sourcePlayer *model.Player if update.source.Valid() { sourcePlayer = bd.GetPlayer(update.source) - if sourcePlayer == nil && update.kind != updateStatus { + if sourcePlayer == nil && update.kind != updateStatus && update.kind != updateMark { // Only register a new user to track once we received a status line continue } @@ -600,7 +600,8 @@ func (bd *BD) gameStateTracker(ctx context.Context) { log.Printf("updateStatus error: %v\n", errUpdate) } case updateMark: - if errUpdate := bd.onUpdateMark(update.data.(updateMarkEvent)); errUpdate != nil { + d := update.data.(updateMarkEvent) + if errUpdate := bd.onUpdateMark(d); errUpdate != nil { log.Printf("updateMark error: %v\n", errUpdate) } case updateWhitelist: @@ -783,10 +784,20 @@ func (bd *BD) onUpdateWhitelist(event updateWhitelistEvent) error { func (bd *BD) onUpdateMark(status updateMarkEvent) error { player := bd.GetPlayer(status.target) + if player == nil { + player = model.NewPlayer(status.target, "") + if err := bd.store.GetPlayer(context.Background(), status.target, player); err != nil { + return err + } + } + name := player.Name + if name == "" { + name = player.NamePrevious + } if errMark := bd.rules.Mark(rules.MarkOpts{ SteamID: status.target, Attributes: status.attrs, - Name: player.Name, + Name: name, }); errMark != nil { return errors.Wrap(errMark, "Failed to add mark") } diff --git a/internal/model/consts.go b/internal/model/consts.go index e53054c..05c76ae 100644 --- a/internal/model/consts.go +++ b/internal/model/consts.go @@ -47,6 +47,16 @@ type SteamIDErrFunc func(sid64 steamid.SID64) error type GetPlayer func(sid64 steamid.SID64) *Player +type GetPlayerOffline func(ctx context.Context, sid64 steamid.SID64, player *Player) error + +type SearchOpts struct { + Query string +} + +type SavePlayer func(ctx context.Context, state *Player) error + +type SearchPlayers func(ctx context.Context, opts SearchOpts) (PlayerCollection, error) + type MarkFunc func(sid64 steamid.SID64, attrs []string) error type NoteFunc func(sid64 steamid.SID64, note string) error diff --git a/internal/store/store.go b/internal/store/store.go index b79e423..8cc265a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "embed" + "fmt" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite" "github.com/golang-migrate/migrate/v4/source/iofs" @@ -24,9 +25,11 @@ type DataStore interface { SaveName(ctx context.Context, steamID steamid.SID64, name string) error SaveMessage(ctx context.Context, message *model.UserMessage) error SavePlayer(ctx context.Context, state *model.Player) error + SearchPlayers(ctx context.Context, opts model.SearchOpts) (model.PlayerCollection, error) FetchNames(ctx context.Context, sid64 steamid.SID64) (model.UserNameHistoryCollection, error) FetchMessages(ctx context.Context, sid steamid.SID64) (model.UserMessageCollection, error) LoadOrCreatePlayer(ctx context.Context, steamID steamid.SID64, player *model.Player) error + GetPlayer(ctx context.Context, steamID steamid.SID64, player *model.Player) error } type SqliteStore struct { @@ -200,6 +203,116 @@ func (store *SqliteStore) SavePlayer(ctx context.Context, state *model.Player) e return store.updatePlayer(ctx, state) } +func (store *SqliteStore) SearchPlayers(ctx context.Context, opts model.SearchOpts) (model.PlayerCollection, error) { + sid64, errSid := steamid.StringToSID64(opts.Query) + if errSid == nil && sid64.Valid() { + var player model.Player + if errPlayer := store.LoadOrCreatePlayer(ctx, sid64, &player); errPlayer != nil { + return nil, errPlayer + } + player.SteamId = sid64 + return model.PlayerCollection{&player}, nil + } + const query = ` + SELECT + p.steam_id, + p.visibility, + p.real_name, + p.account_created_on, + p.avatar_hash, + p.community_banned, + p.game_bans, + p.vac_bans, + p.last_vac_ban_on, + p.kills_on, + p.deaths_by, + p.rage_quits, + p.notes, + p.whitelist, + p.created_on, + p.updated_on, + p.profile_updated_on, + pn.name + FROM player p + LEFT JOIN player_names pn ON p.steam_id = pn.steam_id + WHERE pn.name LIKE '%%%s%%' + ORDER BY p.updated_on DESC + LIMIT 1000` + + rows, rowErr := store.db.Query(fmt.Sprintf(query, opts.Query)) + if rowErr != nil { + return nil, rowErr + } + defer util.LogClose(rows) + var col model.PlayerCollection + for rows.Next() { + var prevName *string + var player model.Player + if errScan := rows.Scan(&player.SteamId, &player.Visibility, &player.RealName, &player.AccountCreatedOn, &player.AvatarHash, + &player.CommunityBanned, &player.NumberOfGameBans, &player.NumberOfVACBans, + &player.LastVACBanOn, &player.KillsOn, &player.DeathsBy, &player.RageQuits, &player.Notes, + &player.Whitelisted, &player.CreatedOn, &player.UpdatedOn, &player.ProfileUpdatedOn, &prevName, + ); errScan != nil { + return nil, errScan + } + if prevName != nil { + player.Name = *prevName + player.NamePrevious = *prevName + } + col = append(col, &player) + + } + return col, nil +} + +func (store *SqliteStore) GetPlayer(ctx context.Context, steamID steamid.SID64, player *model.Player) error { + const query = ` + SELECT + p.visibility, + p.real_name, + p.account_created_on, + p.avatar_hash, + p.community_banned, + p.game_bans, + p.vac_bans, + p.last_vac_ban_on, + p.kills_on, + p.deaths_by, + p.rage_quits, + p.notes, + p.whitelist, + p.created_on, + p.updated_on, + p.profile_updated_on, + pn.name + FROM player p + LEFT JOIN player_names pn ON p.steam_id = pn.steam_id + WHERE p.steam_id = ? + ORDER BY pn.created_on DESC + LIMIT 1` + + var prevName *string + rowErr := store.db. + QueryRowContext(ctx, query, steamID). + Scan(&player.Visibility, &player.RealName, &player.AccountCreatedOn, &player.AvatarHash, + &player.CommunityBanned, &player.NumberOfGameBans, &player.NumberOfVACBans, + &player.LastVACBanOn, &player.KillsOn, &player.DeathsBy, &player.RageQuits, &player.Notes, + &player.Whitelisted, &player.CreatedOn, &player.UpdatedOn, &player.ProfileUpdatedOn, &prevName, + ) + if rowErr != nil { + if rowErr != sql.ErrNoRows { + return rowErr + } + player.Dangling = true + } + player.SteamId = steamID + player.Dangling = false + if prevName != nil { + player.NamePrevious = *prevName + } + return nil +} + func (store *SqliteStore) LoadOrCreatePlayer(ctx context.Context, steamID steamid.SID64, player *model.Player) error { const query = ` SELECT @@ -228,18 +341,18 @@ func (store *SqliteStore) LoadOrCreatePlayer(ctx context.Context, steamID steami var prevName *string rowErr := store.db. - QueryRow(query, steamID). + QueryRowContext(ctx, query, steamID). Scan(&player.Visibility, &player.RealName, &player.AccountCreatedOn, &player.AvatarHash, &player.CommunityBanned, &player.NumberOfGameBans, &player.NumberOfVACBans, &player.LastVACBanOn, &player.KillsOn, &player.DeathsBy, &player.RageQuits, &player.Notes, &player.Whitelisted, &player.CreatedOn, &player.UpdatedOn, &player.ProfileUpdatedOn, &prevName, ) + player.SteamId = steamID if rowErr != nil { if rowErr != sql.ErrNoRows { return rowErr } player.Dangling = true - player.SteamId = steamID return store.SavePlayer(ctx, player) } player.Dangling = false diff --git a/internal/translations/active.en.yaml b/internal/translations/active.en.yaml index 598f2a8..fcef231 100644 --- a/internal/translations/active.en.yaml +++ b/internal/translations/active.en.yaml @@ -212,6 +212,9 @@ menu_name_history: label_message_count: one: "Message Count: " +label_result_count: + one: "Results: " + window_name_history: one: "Name History: {{ .SteamId }}" @@ -221,6 +224,9 @@ window_chat_history_user: window_chat_history_game: one: "Chat History" +window_player_search: + one: "Player Search" + window_mark_custom: one: "Mark with custom attribute" diff --git a/internal/translations/translations.go b/internal/translations/translations.go index f76a051..e948348 100644 --- a/internal/translations/translations.go +++ b/internal/translations/translations.go @@ -43,6 +43,7 @@ const ( LabelEnabled Key = "label_enabled" LabelAttributeName Key = "label_attribute_name" LabelMessageCount Key = "label_message_count" + LabelResultCount Key = "label_result_count" LabelAboutBuiltBy Key = "label_about_built_by" LabelAboutBuildDate Key = "label_about_build_date" LabelAboutVersion Key = "label_about_version" @@ -93,6 +94,7 @@ const ( WindowNameHistory Key = "window_name_history" WindowChatHistoryUser Key = "window_chat_history_user" WindowChatHistoryGame Key = "window_chat_history_game" + WindowPlayerSearch Key = "window_player_search" TitleSettings Key = "title_settings" WindowMarkCustom Key = "window_mark_custom" ErrorNameEmpty Key = "error_name_empty" diff --git a/internal/ui/menu.go b/internal/ui/menu.go index a7585a0..e1103d9 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -178,36 +178,40 @@ func generateKickMenu(ctx context.Context, userId int64, kickFunc model.KickFunc func generateUserMenu(ctx context.Context, app fyne.App, window fyne.Window, steamId steamid.SID64, userId int64, cb callBacks, knownAttributes binding.StringList, links []model.LinkConfig) *fyne.Menu { - menu := fyne.NewMenu("User Actions", - &fyne.MenuItem{ + + var items []*fyne.MenuItem + if userId > 0 { + items = append(items, &fyne.MenuItem{ Icon: theme.CheckButtonCheckedIcon(), ChildMenu: generateKickMenu(ctx, userId, cb.kickFunc), - Label: translations.One(translations.MenuCallVote)}, - &fyne.MenuItem{ + Label: translations.One(translations.MenuCallVote)}) + } + items = append(items, []*fyne.MenuItem{ + { Icon: theme.ZoomFitIcon(), ChildMenu: generateAttributeMenu(window, steamId, knownAttributes, cb.markFn), Label: translations.One(translations.MenuMarkAs)}, - &fyne.MenuItem{ + { Icon: theme.SearchIcon(), ChildMenu: generateExternalLinksMenu(steamId, links, app.OpenURL), Label: translations.One(translations.MenuOpenExternal)}, - &fyne.MenuItem{ + { Icon: theme.ContentCopyIcon(), ChildMenu: generateSteamIdMenu(window, steamId), Label: translations.One(translations.MenuCopySteamId)}, - &fyne.MenuItem{ + { Icon: theme.ListIcon(), Action: func() { cb.createUserChat(steamId) }, Label: translations.One(translations.MenuChatHistory)}, - &fyne.MenuItem{ + { Icon: theme.VisibilityIcon(), Action: func() { cb.createNameHistory(steamId) }, Label: translations.One(translations.MenuNameHistory)}, - &fyne.MenuItem{ + { Icon: theme.VisibilityOffIcon(), Action: func() { if err := cb.whitelistFn(steamId); err != nil { @@ -215,12 +219,18 @@ func generateUserMenu(ctx context.Context, app fyne.App, window fyne.Window, ste } }, Label: translations.One(translations.MenuWhitelist)}, - &fyne.MenuItem{ + { Icon: theme.DocumentCreateIcon(), Action: func() { + offline := false player := cb.getPlayer(steamId) if player == nil { - return + player = model.NewPlayer(steamId, "") + if errOffline := cb.getPlayerOffline(ctx, steamId, player); errOffline != nil { + showUserError(errors.Errorf("Unknown player: %v", errOffline), window) + return + } + offline = true } entry := widget.NewMultiLineEntry() entry.SetMinRowsVisible(30) @@ -239,11 +249,18 @@ func generateUserMenu(ctx context.Context, app fyne.App, window fyne.Window, ste player.Notes = entry.Text player.Touch() player.Unlock() + if offline { + if errSave := cb.savePlayer(ctx, player); errSave != nil { + log.Printf("Failed to save: %v\n", errSave) + } + } + }, window) d.Resize(fyne.NewSize(700, 600)) d.Show() }, Label: "Edit Notes"}, - ) + }...) + menu := fyne.NewMenu("User Actions", items...) return menu } diff --git a/internal/ui/players.go b/internal/ui/players.go index 08dc0ed..87303a9 100644 --- a/internal/ui/players.go +++ b/internal/ui/players.go @@ -47,7 +47,8 @@ type playerWindow struct { containerHeading *fyne.Container containerStatPanel *fyne.Container - onShowChat func() + onShowChat func() + onShowSearch func() menuCreator MenuCreator onReload func(count int) @@ -262,7 +263,7 @@ const symbolBad = "✗" // ┌─────┬───────────────────────────────────────────────────┐ // │ P │ profile name │ Vac.. │ // │─────────────────────────────────────────────────────────┤ -func newPlayerWindow(app fyne.App, settings *model.Settings, boundSettings boundSettings, showChatWindowFunc func(), +func newPlayerWindow(app fyne.App, settings *model.Settings, boundSettings boundSettings, showChatWindowFunc func(), showSearchWindowFunc func(), callbacks callBacks, menuCreator MenuCreator, cache *avatarCache, version model.Version) *playerWindow { screen := &playerWindow{ app: app, @@ -270,6 +271,7 @@ func newPlayerWindow(app fyne.App, settings *model.Settings, boundSettings bound boundList: binding.BindUntypedList(&[]interface{}{}), bindingPlayerCount: binding.NewInt(), onShowChat: showChatWindowFunc, + onShowSearch: showSearchWindowFunc, callBacks: callbacks, menuCreator: menuCreator, avatarCache: cache, @@ -313,6 +315,9 @@ func newPlayerWindow(app fyne.App, settings *model.Settings, boundSettings bound }, func() { screen.listsDialog.Show() + }, + func() { + screen.onShowSearch() }) var dirNames []string @@ -472,7 +477,7 @@ func newPlayerWindow(app fyne.App, settings *model.Settings, boundSettings bound return screen } -func newToolbar(app fyne.App, parent fyne.Window, settings *model.Settings, chatFunc func(), settingsFunc func(), aboutFunc func(), launchFunc func(), showListsFunc func()) *widget.Toolbar { +func newToolbar(app fyne.App, parent fyne.Window, settings *model.Settings, chatFunc func(), settingsFunc func(), aboutFunc func(), launchFunc func(), showListsFunc func(), showSearchFunc func()) *widget.Toolbar { wikiUrl, _ := url.Parse(urlHelp) toolBar := widget.NewToolbar( widget.NewToolbarAction(resourceTf2Png, func() { @@ -484,6 +489,7 @@ func newToolbar(app fyne.App, parent fyne.Window, settings *model.Settings, chat } }), widget.NewToolbarAction(theme.MailComposeIcon(), chatFunc), + widget.NewToolbarAction(theme.SearchIcon(), showSearchFunc), widget.NewToolbarSeparator(), widget.NewToolbarAction(theme.SettingsIcon(), settingsFunc), widget.NewToolbarAction(theme.StorageIcon(), func() { diff --git a/internal/ui/search.go b/internal/ui/search.go new file mode 100644 index 0000000..1b6ca58 --- /dev/null +++ b/internal/ui/search.go @@ -0,0 +1,125 @@ +package ui + +import ( + "context" + "fmt" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" + "github.com/leighmacdonald/bd/internal/model" + "github.com/leighmacdonald/bd/internal/translations" + "sync" + "time" +) + +type searchWindow struct { + fyne.Window + ctx context.Context + app fyne.App + list *widget.List + boundList binding.UntypedList + queryString binding.String + objectMu *sync.RWMutex + boundListMu *sync.RWMutex + resultCount binding.Int + avatarCache *avatarCache + queryEntry *widget.Entry + cb callBacks +} + +func newSearchWindow(ctx context.Context, app fyne.App, cb callBacks, attrs binding.StringList, settings *model.Settings, cache *avatarCache) *searchWindow { + window := app.NewWindow(translations.One(translations.WindowPlayerSearch)) + window.Canvas().AddShortcut( + &desktop.CustomShortcut{KeyName: fyne.KeyW, Modifier: fyne.KeyModifierControl}, + func(shortcut fyne.Shortcut) { + window.Hide() + }) + window.SetCloseIntercept(func() { + window.Hide() + }) + + sw := searchWindow{ + Window: window, + ctx: ctx, + app: app, + list: nil, + boundList: binding.BindUntypedList(&[]interface{}{}), + objectMu: &sync.RWMutex{}, + boundListMu: &sync.RWMutex{}, + avatarCache: cache, + queryString: binding.NewString(), + cb: cb, + resultCount: binding.NewInt(), + } + + sw.list = widget.NewListWithData(sw.boundList, func() fyne.CanvasObject { + return container.NewBorder( + nil, + nil, + widget.NewLabel("Timestamp"), + nil, + newContextMenuRichText(nil)) + }, func(i binding.DataItem, o fyne.CanvasObject) { + value := i.(binding.Untyped) + obj, _ := value.Get() + pl := obj.(*model.Player) + sw.objectMu.Lock() + + rootContainer := o.(*fyne.Container) + timeStamp := rootContainer.Objects[1].(*widget.Label) + timeStamp.SetText(pl.UpdatedOn.Format(time.RFC822)) + + profileButton := rootContainer.Objects[0].(*contextMenuRichText) + profileButton.Alignment = widget.ButtonAlignLeading + if pl.Name != "" { + profileButton.SetText(pl.Name) + } else { + profileButton.SetText(pl.NamePrevious) + } + profileButton.SetIcon(sw.avatarCache.GetAvatar(pl.SteamId)) + profileButton.menu = generateUserMenu(sw.ctx, app, window, pl.SteamId, pl.UserId, cb, attrs, settings.Links) + //profileButton.menu.Refresh() + profileButton.Refresh() + + sw.objectMu.Unlock() + }) + + sw.queryEntry = widget.NewEntryWithData(sw.queryString) + sw.queryEntry.PlaceHolder = "SteamID or Name" + sw.queryEntry.OnSubmitted = func(s string) { + results, errSearch := cb.searchPlayer(sw.ctx, model.SearchOpts{Query: s}) + if errSearch != nil { + showUserError(errSearch, window) + return + } + sw.boundListMu.Lock() + if errSet := sw.boundList.Set(results.AsAny()); errSet != nil { + showUserError(errSet, window) + return + } + if errSet := sw.resultCount.Set(len(results)); errSet != nil { + showUserError(errSet, window) + return + } + sw.boundListMu.Unlock() + sw.list.Refresh() + window.Content().Refresh() + } + sw.SetContent(container.NewBorder( + container.NewBorder( + nil, + nil, + nil, + widget.NewLabelWithData(binding.IntToStringWithFormat( + sw.resultCount, + fmt.Sprintf("%s%%d", translations.One(translations.LabelResultCount)))), + container.NewMax(sw.queryEntry), + ), + nil, nil, nil, + container.NewMax(sw.list))) + sw.Window.Resize(fyne.NewSize(500, 700)) + + return &sw +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index d8f4c76..0095665 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -43,6 +43,7 @@ func defaultApp() fyne.App { type windows struct { player *playerWindow chat *gameChatWindow + search *searchWindow chatHistory map[steamid.SID64]*userChatWindow nameHistory map[steamid.SID64]*userNameWindow } @@ -57,6 +58,9 @@ type callBacks struct { createUserChat model.SteamIDFunc createNameHistory model.SteamIDFunc getPlayer model.GetPlayer + getPlayerOffline model.GetPlayerOffline + searchPlayer model.SearchPlayers + savePlayer model.SavePlayer } type MenuCreator func(window fyne.Window, steamId steamid.SID64, userId int64) *fyne.Menu @@ -100,6 +104,9 @@ func New(ctx context.Context, bd *detector.BD, settings *model.Settings, store s userAvatar: make(map[steamid.SID64]fyne.Resource), }, callBacks: callBacks{ + savePlayer: store.SavePlayer, + getPlayerOffline: store.GetPlayer, + searchPlayer: store.SearchPlayers, queryNamesFunc: store.FetchNames, queryUserMessagesFunc: store.FetchMessages, kickFunc: bd.CallVote, @@ -118,6 +125,8 @@ func New(ctx context.Context, bd *detector.BD, settings *model.Settings, store s ui.windows.chat = newGameChatWindow(ui.ctx, ui.application, ui.callBacks, ui.knownAttributes, settings, ui.avatarCache) + ui.windows.search = newSearchWindow(ui.ctx, ui.application, ui.callBacks, ui.knownAttributes, settings, ui.avatarCache) + ui.windows.player = newPlayerWindow( ui.application, settings, @@ -125,6 +134,9 @@ func New(ctx context.Context, bd *detector.BD, settings *model.Settings, store s func() { ui.windows.chat.window.Show() }, + func() { + ui.windows.search.Show() + }, ui.callBacks, func(window fyne.Window, steamId steamid.SID64, userId int64) *fyne.Menu { return generateUserMenu(ui.ctx, ui.application, window, steamId, userId, ui.callBacks, ui.knownAttributes, ui.settings.Links)