Files
2026-08-07 09:48:59 +02:00

614 lines
15 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
appStyle = lipgloss.NewStyle().
Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62"))
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1).
MarginBottom(1)
selectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("205"))
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
cursorStyle = focusedStyle.Copy()
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("205")).
Padding(1, 2).Align(lipgloss.Center)
)
type AppState int
const (
StateStartup AppState = iota
StateMainMenu
StateWriteMessage
StateDecryptMessage
StateShowDecryptedMessage
StateManageCiphers
StateCreateCipherModal
StateImportCipherModal
StateExportCipherModal
StateConfirmDeleteCipher
)
type model struct {
state AppState
ciphers []string
activeCipher string
activeConfig *CipherConfig
cursor int
textInput textinput.Model // Reusable path/name textinput
textarea textarea.Model // Plaintext message content
filenameInput textinput.Model // Filename input for write screen
viewport viewport.Model // Scrolling viewport for decrypted messages
focusIndex int // For dual fields (e.g. Write Screen)
statusMsg string
lang string
width int
height int
ready bool
}
func NewModel() model {
ti := textinput.New()
ti.Cursor.Style = cursorStyle
ta := textarea.New()
ta.SetWidth(60)
ta.SetHeight(10)
ta.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color("62"))
fi := textinput.New()
fi.Cursor.Style = cursorStyle
m := model{
state: StateStartup,
textInput: ti,
textarea: ta,
filenameInput: fi,
lang: "cs",
focusIndex: 0,
}
m.loadCiphersAndActivateDefault()
return m
}
func (m *model) loadCiphersAndActivateDefault() {
cList, err := ListCiphers()
if err != nil {
m.statusMsg = fmt.Sprintf("Error: %v", err)
return
}
if len(cList) == 0 {
err = GenerateNewCipher("default")
if err != nil {
m.statusMsg = fmt.Sprintf("Error generating default: %v", err)
return
}
cList, _ = ListCiphers()
}
m.ciphers = cList
// Find if activeCipher is still in the list, otherwise select first
found := false
for _, c := range cList {
if c == m.activeCipher {
found = true
break
}
}
if !found && len(cList) > 0 {
m.activeCipher = cList[0]
}
if m.activeCipher != "" {
cfg, err := LoadCipher(m.activeCipher)
if err == nil {
m.activeConfig = cfg
} else {
m.statusMsg = fmt.Sprintf("Error: %v", err)
}
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink, tea.EnterAltScreen)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
tr := Translations[m.lang]
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textarea.SetWidth(msg.Width - 10)
m.textarea.SetHeight(msg.Height - 12)
vpHeight := msg.Height - 8
if vpHeight < 1 {
vpHeight = 1
}
if !m.ready {
m.viewport = viewport.New(msg.Width-6, vpHeight)
m.ready = true
} else {
m.viewport.Width = msg.Width - 6
m.viewport.Height = vpHeight
}
case tea.KeyMsg:
// Global keys
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch m.state {
case StateStartup:
if msg.String() == "enter" {
m.state = StateMainMenu
m.statusMsg = ""
return m, nil
}
if msg.String() == "q" {
return m, tea.Quit
}
case StateMainMenu:
switch msg.String() {
case "q", "esc":
return m, tea.Quit
case "l":
if m.lang == "cs" {
m.lang = "en"
} else {
m.lang = "cs"
}
m.statusMsg = ""
return m, nil
case "1":
m.state = StateWriteMessage
m.textarea.SetValue("")
m.textarea.Focus()
m.filenameInput.SetValue(fmt.Sprintf("zprava_%s.txt", time.Now().Format("20060102_150405")))
m.focusIndex = 0
m.statusMsg = ""
return m, nil
case "2":
m.state = StateDecryptMessage
m.textInput.Focus()
m.textInput.SetValue("")
m.statusMsg = ""
return m, nil
case "3":
m.state = StateManageCiphers
m.loadCiphersAndActivateDefault()
// Find cursor index for active cipher
m.cursor = 0
for idx, val := range m.ciphers {
if val == m.activeCipher {
m.cursor = idx
break
}
}
m.statusMsg = ""
return m, nil
}
case StateWriteMessage:
switch msg.String() {
case "esc":
m.state = StateMainMenu
m.statusMsg = ""
return m, nil
case "tab", "shift+tab":
if m.focusIndex == 0 {
m.focusIndex = 1
m.textarea.Blur()
m.filenameInput.Focus()
} else {
m.focusIndex = 0
m.filenameInput.Blur()
m.textarea.Focus()
}
return m, nil
case "ctrl+s":
// Save & encrypt
content := m.textarea.Value()
filename := strings.TrimSpace(m.filenameInput.Value())
if filename == "" {
filename = "zprava.txt"
}
if m.activeConfig == nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, "není vybraná žádná šifra")
return m, nil
}
encrypted := EncryptMessage(content, m.activeConfig)
exportDir, err := getExportDir()
if err != nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error())
return m, nil
}
fullPath := filepath.Join(exportDir, filename)
err = os.WriteFile(fullPath, []byte(encrypted), 0644)
if err != nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error())
} else {
m.statusMsg = fmt.Sprintf(tr.StatusSaved, fullPath)
m.state = StateMainMenu
}
return m, nil
}
// Forward keys to the focused input
if m.focusIndex == 0 {
m.textarea, cmd = m.textarea.Update(msg)
} else {
m.filenameInput, cmd = m.filenameInput.Update(msg)
}
return m, cmd
case StateDecryptMessage:
switch msg.String() {
case "esc":
m.state = StateMainMenu
m.statusMsg = ""
return m, nil
case "enter":
path := strings.TrimSpace(m.textInput.Value())
if path == "" {
return m, nil
}
var fullPath string
if filepath.IsAbs(path) {
fullPath = path
} else {
importDir, err := getImportDir()
if err != nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error())
return m, nil
}
fullPath = filepath.Join(importDir, path)
}
contentBytes, err := os.ReadFile(fullPath)
if err != nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error())
return m, nil
}
if m.activeConfig == nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, "není vybraná žádná šifra")
return m, nil
}
decrypted := DecryptMessage(string(contentBytes), m.activeConfig)
m.viewport.SetContent(decrypted)
m.viewport.YOffset = 0
m.state = StateShowDecryptedMessage
m.statusMsg = tr.StatusDecrypted
return m, nil
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
case StateShowDecryptedMessage:
switch msg.String() {
case "esc", "q":
m.state = StateMainMenu
m.statusMsg = ""
return m, nil
}
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
case StateManageCiphers:
switch msg.String() {
case "esc", "q":
m.state = StateMainMenu
m.statusMsg = ""
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.ciphers)-1 {
m.cursor++
}
case "enter":
if len(m.ciphers) > 0 {
m.activeCipher = m.ciphers[m.cursor]
m.loadCiphersAndActivateDefault()
m.statusMsg = fmt.Sprintf(tr.StatusActive, m.activeCipher)
m.state = StateMainMenu
}
case "c":
m.state = StateCreateCipherModal
m.textInput.SetValue("")
m.textInput.Focus()
m.statusMsg = ""
return m, nil
case "i":
m.state = StateImportCipherModal
m.textInput.SetValue("")
m.textInput.Focus()
m.statusMsg = ""
return m, nil
case "e":
if m.activeCipher != "" {
m.state = StateExportCipherModal
m.textInput.SetValue(fmt.Sprintf("%s_export.json", m.activeCipher))
m.textInput.Focus()
m.statusMsg = ""
return m, nil
}
case "d":
if len(m.ciphers) > 0 {
m.state = StateConfirmDeleteCipher
m.statusMsg = ""
return m, nil
}
}
case StateCreateCipherModal:
switch msg.String() {
case "esc":
m.state = StateManageCiphers
m.statusMsg = ""
return m, nil
case "enter":
name := strings.TrimSpace(m.textInput.Value())
if name == "" {
return m, nil
}
err := GenerateNewCipher(name)
if err != nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error())
} else {
m.statusMsg = fmt.Sprintf(tr.StatusCreated, name)
m.activeCipher = name
m.loadCiphersAndActivateDefault()
}
m.state = StateManageCiphers
return m, nil
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
case StateImportCipherModal:
switch msg.String() {
case "esc":
m.state = StateManageCiphers
m.statusMsg = ""
return m, nil
case "enter":
path := strings.TrimSpace(m.textInput.Value())
if path == "" {
return m, nil
}
name, err := ImportCipher(path)
if err != nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error())
} else {
m.statusMsg = fmt.Sprintf(tr.StatusImported, name)
m.activeCipher = name
m.loadCiphersAndActivateDefault()
}
m.state = StateManageCiphers
return m, nil
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
case StateExportCipherModal:
switch msg.String() {
case "esc":
m.state = StateManageCiphers
m.statusMsg = ""
return m, nil
case "enter":
path := strings.TrimSpace(m.textInput.Value())
if path == "" {
return m, nil
}
err := ExportCipher(m.activeCipher, path)
if err != nil {
m.statusMsg = fmt.Sprintf(tr.StatusError, err.Error())
} else {
m.statusMsg = fmt.Sprintf(tr.StatusExported, path)
}
m.state = StateManageCiphers
return m, nil
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
case StateConfirmDeleteCipher:
switch msg.String() {
case "y", "Y":
if len(m.ciphers) > 0 {
selected := m.ciphers[m.cursor]
dir, err := getCipherDir()
if err == nil {
path := filepath.Join(dir, selected+".json")
_ = os.Remove(path)
}
m.loadCiphersAndActivateDefault()
m.cursor = 0
}
m.state = StateManageCiphers
return m, nil
case "n", "N", "esc":
m.state = StateManageCiphers
return m, nil
}
}
}
return m, nil
}
func (m model) View() string {
tr := Translations[m.lang]
var b strings.Builder
// Top Title
b.WriteString(titleStyle.Render(tr.Title))
b.WriteString("\n")
// Status Line
activeName := m.activeCipher
if activeName == "" {
activeName = "none"
}
b.WriteString(fmt.Sprintf(tr.ActiveCipher, activeName) + "\n")
if m.statusMsg != "" {
b.WriteString(statusStyle.Render(m.statusMsg) + "\n")
}
b.WriteString("\n")
switch m.state {
case StateStartup:
eagle := `
_
( ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("`") + `
\ ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("\\") + `
/ / _
__ / / ( \
( "---" / / /
\ / / /
\ /-----' /
\ / /
\ / /
'-------'
`
b.WriteString(lipgloss.Place(m.width-12, 14, lipgloss.Center, lipgloss.Center,
eagle+"\n"+
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4")).Render("DOXA-SECRET 🦅")+"\n\n"+
tr.StartupSubtitle+"\n\n"+
tr.StartupHelp+"\n\n"+
"License: MIT | © 2026 Kankys"))
case StateMainMenu:
b.WriteString(fmt.Sprintf(" %s\n %s\n %s\n\n", tr.MenuWrite, tr.MenuDecrypt, tr.MenuManage))
b.WriteString(fmt.Sprintf(" %s | %s\n", tr.MenuLang, tr.MenuQuit))
case StateWriteMessage:
b.WriteString(tr.WriteTitle + "\n\n")
// Render content textarea
taView := m.textarea.View()
if m.focusIndex == 0 {
taView = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("205")).Render(taView)
} else {
taView = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).Render(taView)
}
b.WriteString(tr.WritePromptContent + "\n" + taView + "\n\n")
// Render filename input
b.WriteString(tr.WritePromptFilename)
if m.focusIndex == 1 {
b.WriteString(focusedStyle.Render(m.filenameInput.View()) + "\n\n")
} else {
b.WriteString(m.filenameInput.View() + "\n\n")
}
b.WriteString(tr.WriteHelp)
case StateDecryptMessage:
b.WriteString(tr.DecryptTitle + "\n\n")
b.WriteString(tr.DecryptPromptPath + m.textInput.View() + "\n\n")
b.WriteString(tr.DecryptHelp)
case StateShowDecryptedMessage:
b.WriteString(tr.DecryptResult + "\n")
b.WriteString(confirmBoxStyle.Render(m.viewport.View()) + "\n\n")
b.WriteString("q/esc: " + tr.MenuQuit)
case StateManageCiphers:
b.WriteString(fmt.Sprintf("--- %s ---\n\n", tr.ManageTitle))
if len(m.ciphers) == 0 {
b.WriteString(" " + tr.NoCiphers + "\n\n")
} else {
for i, c := range m.ciphers {
cursor := " "
if m.cursor == i {
cursor = "> "
}
nameStr := c
if c == m.activeCipher {
nameStr += " (aktivní)"
}
if m.cursor == i {
b.WriteString(selectedStyle.Render(fmt.Sprintf("%s%s", cursor, nameStr)) + "\n")
} else {
b.WriteString(fmt.Sprintf("%s%s\n", cursor, nameStr) + "\n")
}
}
}
b.WriteString("\n" + fmt.Sprintf("%s | %s | %s | %s | %s\n",
tr.ManageCreateKey, tr.ManageImportKey, tr.ManageExportKey, tr.ManageDeleteKey, tr.ManageBackKey))
b.WriteString(tr.ManageHelp)
case StateCreateCipherModal:
b.WriteString(tr.CreateTitle + "\n\n")
b.WriteString(tr.CreatePrompt + m.textInput.View() + "\n\n")
b.WriteString(tr.CreateHelp)
case StateImportCipherModal:
b.WriteString(tr.ImportTitle + "\n\n")
b.WriteString(tr.ImportPrompt + m.textInput.View() + "\n\n")
b.WriteString(tr.ImportHelp)
case StateExportCipherModal:
b.WriteString(tr.ExportTitle + "\n\n")
b.WriteString(tr.ExportPrompt + m.textInput.View() + "\n\n")
b.WriteString(tr.ExportHelp)
case StateConfirmDeleteCipher:
selected := ""
if len(m.ciphers) > m.cursor {
selected = m.ciphers[m.cursor]
}
confirmText := fmt.Sprintf(tr.ConfirmDelete+"\n\n%s | %s", selected, tr.ConfirmYes, tr.ConfirmNo)
b.WriteString(confirmBoxStyle.Render(confirmText))
}
return appStyle.Render(b.String())
}