package main import ( "fmt" "os" "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 StateViewNotes StateViewNoteModal StateAddNote StateEditNote StateSearch StateBrowseTags StateConfirmDelete ) type model struct { notes []Note filteredNotes []Note cursor int state AppState browseTags []string filterText string textInputs []textinput.Model contentInput textarea.Model searchInput textinput.Model viewport viewport.Model ready bool focusIndex int selectedNoteID int64 statusMsg string lang string width int height int } func NewModel() model { inputs := make([]textinput.Model, 2) for i := range inputs { t := textinput.New() t.Cursor.Style = cursorStyle inputs[i] = t } inputs[0].Focus() inputs[0].PromptStyle = focusedStyle inputs[0].TextStyle = focusedStyle ta := textarea.New() ta.SetWidth(60) ta.SetHeight(8) ta.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color("62")) si := textinput.New() si.Prompt = "🔍 " si.Placeholder = "Hledat..." m := model{ notes: loadNotes(), cursor: 0, state: StateStartup, textInputs: inputs, contentInput: ta, searchInput: si, focusIndex: 0, lang: "cs", } m.applyFilter() m.updatePlaceholders() return m } func (m model) Init() tea.Cmd { return tea.Batch(textinput.Blink, textarea.Blink, tea.EnterAltScreen) } func (m *model) applyFilter() { if m.filterText == "" { m.filteredNotes = m.notes } else { m.filteredNotes = []Note{} query := strings.ToLower(m.filterText) for _, n := range m.notes { if strings.Contains(strings.ToLower(n.Name), query) || strings.Contains(strings.ToLower(n.Content), query) || strings.Contains(strings.ToLower(strings.Join(n.Tags, " ")), query) { m.filteredNotes = append(m.filteredNotes, n) } } } if m.cursor >= len(m.filteredNotes) { m.cursor = 0 if len(m.filteredNotes) > 0 { m.cursor = len(m.filteredNotes) - 1 } } } func (m *model) getBrowseTags() { set := make(map[string]bool) for _, n := range m.notes { for _, t := range n.Tags { if t != "" { set[t] = true } } } m.browseTags = []string{} for k := range set { m.browseTags = append(m.browseTags, k) } // Manual sort for i := 0; i < len(m.browseTags); i++ { for j := i + 1; j < len(m.browseTags); j++ { if m.browseTags[i] > m.browseTags[j] { m.browseTags[i], m.browseTags[j] = m.browseTags[j], m.browseTags[i] } } } m.cursor = 0 } 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.contentInput.SetWidth(msg.Width - 25) m.contentInput.SetHeight(m.height/2 - 5) headerHeight := 6 footerHeight := 3 vpHeight := msg.Height - headerHeight - footerHeight 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: switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": if m.state == StateConfirmDelete { m.state = StateViewNotes m.statusMsg = "" return m, nil } if m.state == StateViewNotes { if m.filterText != "" { m.filterText = "" m.applyFilter() return m, nil } return m, tea.Quit } m.resetToView() return m, nil case "q": if m.state == StateStartup { return m, tea.Quit } if m.state == StateConfirmDelete { m.state = StateViewNotes m.statusMsg = "" return m, nil } if m.state == StateViewNoteModal { m.state = StateViewNotes // Reset viewport to full list size headerHeight := 6 footerHeight := 3 vpHeight := m.height - headerHeight - footerHeight if vpHeight < 1 { vpHeight = 1 } m.viewport.Width = m.width - 6 m.viewport.Height = vpHeight return m, nil } if m.state == StateViewNotes { if m.filterText != "" { m.filterText = "" m.applyFilter() return m, nil } return m, tea.Quit } m.resetToView() return m, nil case "l": if m.state == StateViewNotes { if m.lang == "cs" { m.lang = "en" } else { m.lang = "cs" } m.updatePlaceholders() return m, nil } case "/": if m.state == StateViewNotes { m.state = StateSearch m.searchInput.Focus() m.searchInput.SetValue("") return m, nil } case "tab", "shift+tab": if m.state == StateAddNote || m.state == StateEditNote { if msg.String() == "shift+tab" { m.focusIndex-- } else { m.focusIndex++ } if m.focusIndex > 2 { m.focusIndex = 0 } if m.focusIndex < 0 { m.focusIndex = 2 } return m.updateFocus() } case "up", "k": if m.state == StateViewNotes && m.cursor > 0 { m.cursor-- m.viewport.LineUp(1) } if m.state == StateViewNoteModal { m.viewport.LineUp(1) } if m.state == StateBrowseTags && m.cursor > 0 { m.cursor-- } case "down", "j": if m.state == StateViewNotes { max := len(m.filteredNotes) if m.cursor < max-1 { m.cursor++ m.viewport.LineDown(1) } } if m.state == StateViewNoteModal { m.viewport.LineDown(1) } if m.state == StateBrowseTags { max := len(m.browseTags) if m.cursor < max-1 { m.cursor++ } } case "enter": switch m.state { case StateStartup: m.state = StateViewNotes case StateViewNotes: if len(m.filteredNotes) > 0 { m.state = StateViewNoteModal m.selectedNoteID = m.filteredNotes[m.cursor].ID var n Note for _, note := range m.notes { if note.ID == m.selectedNoteID { n = note break } } modalContent := fmt.Sprintf("%s\n---\n[%d] %s\n\n%s\n\nTagy: %s", tr.HelpModal, n.ID, n.Name, n.Content, strings.Join(n.Tags, ", "), ) // Set viewport size for modal (smaller than full screen) modalWidth := m.width - 20 if modalWidth < 40 { modalWidth = 40 } modalHeight := m.height - 10 if modalHeight < 10 { modalHeight = 10 } m.viewport.Width = modalWidth m.viewport.Height = modalHeight m.viewport.SetContent(modalContent) m.viewport.YOffset = 0 } case StateSearch: m.filterText = m.searchInput.Value() m.applyFilter() m.state = StateViewNotes case StateBrowseTags: if len(m.browseTags) > 0 { m.filterText = m.browseTags[m.cursor] m.applyFilter() } m.state = StateViewNotes case StateAddNote: if m.focusIndex != 1 { m.handleAddNote() } case StateEditNote: if m.focusIndex != 1 { m.handleEditNote() } case StateConfirmDelete: if m.focusIndex == 0 { m.handleDeleteNote() m.state = StateViewNotes m.statusMsg = tr.StatusDeleted return m, nil } else { m.state = StateViewNotes m.statusMsg = "" return m, nil } } case "b": if m.state == StateViewNotes { m.state = StateBrowseTags m.getBrowseTags() return m, nil } case "1": if m.state == StateViewNotes { m.state = StateAddNote m.resetInputs() m.textInputs[0].SetValue("") m.updatePlaceholders() return m, nil } case "2": if m.state == StateViewNotes && len(m.filteredNotes) > 0 { m.state = StateEditNote m.selectedNoteID = m.filteredNotes[m.cursor].ID m.populateInputs(m.filteredNotes[m.cursor]) return m, nil } if m.state == StateViewNoteModal { for _, n := range m.notes { if n.ID == m.selectedNoteID { m.state = StateEditNote m.populateInputs(n) break } } return m, nil } case "3": if m.state == StateViewNotes && len(m.filteredNotes) > 0 { exportNote(m.notes, m.filteredNotes[m.cursor].ID) m.statusMsg = tr.StatusExported } case "4": if m.state == StateViewNotes && len(m.filteredNotes) > 0 { m.state = StateConfirmDelete m.selectedNoteID = m.filteredNotes[m.cursor].ID m.focusIndex = 0 } } } if m.state == StateSearch { m.searchInput, cmd = m.searchInput.Update(msg) return m, cmd } if m.state == StateAddNote || m.state == StateEditNote { if m.focusIndex == 1 { m.contentInput, cmd = m.contentInput.Update(msg) } else { idx := m.focusIndex if idx > 1 { idx-- } m.textInputs[idx], cmd = m.textInputs[idx].Update(msg) } return m, cmd } if m.state == StateConfirmDelete { if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "y": m.handleDeleteNote() m.state = StateViewNotes m.statusMsg = tr.StatusDeleted return m, nil case "n", "esc": m.state = StateViewNotes m.statusMsg = "" return m, nil } } } return m, nil } func (m *model) updateFocus() (tea.Model, tea.Cmd) { var cmds []tea.Cmd for i := range m.textInputs { m.textInputs[i].Blur() m.textInputs[i].PromptStyle = lipgloss.NewStyle() } m.contentInput.Blur() m.searchInput.Blur() switch m.focusIndex { case 0: cmds = append(cmds, m.textInputs[0].Focus()) m.textInputs[0].PromptStyle = focusedStyle case 1: cmds = append(cmds, m.contentInput.Focus()) case 2: cmds = append(cmds, m.textInputs[1].Focus()) m.textInputs[1].PromptStyle = focusedStyle } return m, tea.Batch(cmds...) } func (m model) View() string { tr := Translations[m.lang] var b strings.Builder b.WriteString(titleStyle.Render(tr.Title)) b.WriteString("\n") if m.statusMsg != "" { b.WriteString(statusStyle.Render(m.statusMsg) + "\n\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-CLI 🦅") + "\n\n" + "Local-First Notes & Sovereignty\n\n" + "Enter: Start | q: Quit\n\n" + "License: MIT | © 2026 Kankys")) case StateSearch: b.WriteString(tr.SearchTitle + "\n" + m.searchInput.View() + "\n\n" + tr.HelpForm) case StateBrowseTags: b.WriteString(fmt.Sprintf("--- %s ---\n", tr.BrowseTags)) b.WriteString("Enter: Filtrovat | q: Zpět\n\n") for i, item := range m.browseTags { cursor := " " if m.cursor == i { cursor = "> " } b.WriteString(fmt.Sprintf("%s%s\n", cursor, item)) } case StateViewNotes: filterInfo := "" if m.filterText != "" { filterInfo = fmt.Sprintf(" [Filtr: %s]", m.filterText) } var listBuilder strings.Builder if len(m.filteredNotes) == 0 { listBuilder.WriteString(tr.NoNotes + "\n") } else { for i, n := range m.filteredNotes { if m.cursor == i { listBuilder.WriteString(selectedStyle.Render(fmt.Sprintf("> [%d] %s", n.ID, n.Name)) + "\n") } else { listBuilder.WriteString(fmt.Sprintf(" [%d] %s\n", n.ID, n.Name)) } } } if m.ready { m.viewport.SetContent(listBuilder.String()) } b.WriteString(fmt.Sprintf("%s | %s | %s | %s | %s | %s | %s\n\n", tr.MenuAdd, tr.MenuEdit, tr.MenuExport, tr.MenuDelete, tr.MenuSearch, tr.MenuBrowse, tr.MenuLang)) b.WriteString(tr.ListTitle + filterInfo + "\n") if m.ready { b.WriteString(m.viewport.View() + "\n") } else { b.WriteString(listBuilder.String()) } b.WriteString("\n" + tr.HelpList) case StateAddNote, StateEditNote: title := tr.AddTitle if m.state == StateEditNote { title = fmt.Sprintf("%s (%d)", tr.EditTitle, m.selectedNoteID) } b.WriteString(title + "\n\n") b.WriteString(fmt.Sprintf("%-8s %s\n", tr.LabelName, m.textInputs[0].View())) b.WriteString(fmt.Sprintf("%-8s\n%s\n\n", tr.LabelContent, m.contentInput.View())) b.WriteString(fmt.Sprintf("%-8s %s\n", tr.LabelTags, m.textInputs[1].View())) b.WriteString("\n" + tr.HelpForm) case StateViewNoteModal: if m.ready { b.WriteString(confirmBoxStyle.Render(m.viewport.View())) } else { b.WriteString(confirmBoxStyle.Render("Načítání...")) } case StateConfirmDelete: var noteToDelete Note found := false for _, n := range m.notes { if n.ID == m.selectedNoteID { noteToDelete = n found = true break } } if !found { m.state = StateViewNotes m.statusMsg = "Chyba: Poznámka k smazání nebyla nalezena!" return "" } confirmText := fmt.Sprintf("%s\n\n[%d] %s\nObsah: %s\n\n%s | %s", tr.ConfirmDelete, noteToDelete.ID, noteToDelete.Name, truncate(noteToDelete.Content, 40), tr.ConfirmYes, tr.ConfirmNo, ) b.WriteString(confirmBoxStyle.Render(confirmText)) } content := b.String() if m.width > 0 && m.height > 0 { return appStyle.Width(m.width - 6).Height(m.height - 4).Render(content) } return appStyle.Render(content) } func (m *model) updatePlaceholders() { tr := Translations[m.lang] m.textInputs[0].Placeholder = tr.PlaceholderName m.contentInput.Placeholder = tr.PlaceholderContent m.textInputs[1].Placeholder = tr.PlaceholderTags } func (m *model) resetToView() { m.state = StateViewNotes m.statusMsg = "" m.filterText = "" m.applyFilter() m.resetInputs() } func (m *model) resetInputs() { for i := range m.textInputs { m.textInputs[i].SetValue("") m.textInputs[i].Blur() } m.contentInput.SetValue("") m.searchInput.SetValue("") m.focusIndex = 0 m.updateFocus() } func (m *model) populateInputs(n Note) { m.textInputs[0].SetValue(n.Name) m.contentInput.SetValue(n.Content) m.textInputs[1].SetValue(strings.Join(n.Tags, ", ")) m.focusIndex = 0 m.updateFocus() } func (m *model) handleAddNote() { tr := Translations[m.lang] name := m.textInputs[0].Value() content := m.contentInput.Value() if name == "" || content == "" { m.statusMsg = tr.ErrRequired return } tags := []string{} if m.textInputs[1].Value() != "" { tags = strings.Split(m.textInputs[1].Value(), ",") for i := range tags { tags[i] = strings.TrimSpace(tags[i]) } } now := time.Now() m.notes = append(m.notes, Note{ ID: now.UnixMilli(), Name: name, Content: content, Folder: "", Tags: tags, CreatedAt: now, ModifiedAt: now, }) saveNotes(m.notes) m.applyFilter() m.statusMsg = tr.StatusSaved m.state = StateViewNotes } func (m *model) handleEditNote() { tr := Translations[m.lang] for i, n := range m.notes { if n.ID == m.selectedNoteID { m.notes[i].Name = m.textInputs[0].Value() m.notes[i].Content = m.contentInput.Value() m.notes[i].Folder = "" tags := []string{} if m.textInputs[1].Value() != "" { tags = strings.Split(m.textInputs[1].Value(), ",") for j := range tags { tags[j] = strings.TrimSpace(tags[j]) } } m.notes[i].Tags = tags m.notes[i].ModifiedAt = time.Now() break } } saveNotes(m.notes) m.applyFilter() m.statusMsg = tr.StatusEdited m.state = StateViewNotes } func (m *model) handleDeleteNote() { noteIndex := -1 for i, n := range m.notes { if n.ID == m.selectedNoteID { noteIndex = i break } } if noteIndex != -1 { m.notes = append(m.notes[:noteIndex], m.notes[noteIndex+1:]...) saveNotes(m.notes) } m.applyFilter() } func exportNote(notes []Note, id int64) { for _, note := range notes { if note.ID == id { homeDir, err := os.UserHomeDir() if err != nil { fmt.Printf("Chyba při zjišťování domovského adresáře: %v\n", err) return } exportDir := fmt.Sprintf("%s/doxa-cli-txt", homeDir) if err := os.MkdirAll(exportDir, 0755); err != nil { fmt.Printf("Chyba při vytváření adresáře pro export: %v\n", err) return } fileName := fmt.Sprintf("%s_%d.txt", strings.ReplaceAll(note.Name, " ", "_"), note.ID) fullPath := fmt.Sprintf("%s/%s", exportDir, fileName) file, err := os.Create(fullPath) if err != nil { fmt.Printf("Chyba při vytváření souboru pro export: %v\n", err) return } defer file.Close() content := fmt.Sprintf("Název: %s\nTagy: %s\nVytvořeno: %s\n\n%s", note.Name, strings.Join(note.Tags, ", "), note.CreatedAt.Format("2006-01-02 15:04"), note.Content) _, _ = file.WriteString(content) return } } } func truncate(s string, max int) string { if len(s) > max { return strings.TrimSpace(s[:max]) + "..." } return s } type errMsg error