mirror of
https://codeberg.org/Kankys/doxa.git
synced 2026-11-11 06:44:36 +01:00
614 lines
15 KiB
Go
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())
|
|
}
|