mirror of
https://codeberg.org/Kankys/doxa.git
synced 2026-11-11 14:44:36 +01:00
1130 lines
28 KiB
Go
1130 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"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)
|
|
|
|
priorityStyleLow = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#2ECC71")) // Zelená
|
|
|
|
priorityStyleMedium = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#F39C12")) // Oranžová
|
|
|
|
priorityStyleHigh = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#E74C3C")) // Červená
|
|
|
|
categoryPersonal = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#3498DB")) // Modrá
|
|
|
|
categoryWork = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#9B59B6")) // Fialová
|
|
|
|
completedStyle = lipgloss.NewStyle().
|
|
Strikethrough(true).
|
|
Foreground(lipgloss.Color("#95A5A6")) // Šedá pro splněné úkoly
|
|
)
|
|
|
|
// Priority levels
|
|
const (
|
|
PriorityLow = iota
|
|
PriorityMedium
|
|
PriorityHigh
|
|
)
|
|
|
|
// Category types
|
|
const (
|
|
CategoryPersonal = iota
|
|
CategoryWork
|
|
)
|
|
|
|
// Priority to string mapping
|
|
var priorityLabels = map[int]string{
|
|
PriorityLow: "Nízká",
|
|
PriorityMedium: "Střední",
|
|
PriorityHigh: "Vysoká",
|
|
}
|
|
|
|
var priorityLabelsEn = map[int]string{
|
|
PriorityLow: "Low",
|
|
PriorityMedium: "Medium",
|
|
PriorityHigh: "High",
|
|
}
|
|
|
|
// Category to string mapping
|
|
var categoryLabels = map[int]string{
|
|
CategoryPersonal: "Osobní",
|
|
CategoryWork: "Pracovní",
|
|
}
|
|
|
|
var categoryLabelsEn = map[int]string{
|
|
CategoryPersonal: "Personal",
|
|
CategoryWork: "Work",
|
|
}
|
|
|
|
type AppState int
|
|
|
|
const (
|
|
StateStartup AppState = iota
|
|
StateViewTodos
|
|
StateViewTodoModal
|
|
StateAddTodo
|
|
StateEditTodo
|
|
StateSearch
|
|
StateBrowseTags
|
|
StateConfirmDelete
|
|
StateFilterCategory
|
|
)
|
|
|
|
type Todo struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Priority int `json:"priority"`
|
|
Category int `json:"category"`
|
|
Tags []string `json:"tags"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ModifiedAt time.Time `json:"modified_at"`
|
|
Completed bool `json:"completed"`
|
|
}
|
|
|
|
type model struct {
|
|
todos []Todo
|
|
filteredTodos []Todo
|
|
cursor int
|
|
state AppState
|
|
browseTags []string
|
|
filterText string
|
|
textInputs []textinput.Model
|
|
viewport viewport.Model
|
|
ready bool
|
|
focusIndex int
|
|
selectedTodoID int64
|
|
statusMsg string
|
|
lang string
|
|
width int
|
|
height int
|
|
currentCategoryFilter int // -1 = no filter, 0 = personal, 1 = work
|
|
newTodoPriority int // For tracking priority when adding new todo
|
|
newTodoCategory int // For tracking category when adding new todo
|
|
}
|
|
|
|
func NewModel() model {
|
|
inputs := make([]textinput.Model, 3) // title, description, tags
|
|
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
|
|
|
|
m := model{
|
|
todos: loadTodos(),
|
|
cursor: 0,
|
|
state: StateStartup,
|
|
textInputs: inputs,
|
|
focusIndex: 0,
|
|
lang: "cs",
|
|
currentCategoryFilter: -1,
|
|
newTodoPriority: PriorityMedium,
|
|
newTodoCategory: CategoryPersonal,
|
|
}
|
|
m.sortTodos()
|
|
m.applyFilter()
|
|
m.updatePlaceholders()
|
|
return m
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return tea.Batch(textinput.Blink, tea.EnterAltScreen)
|
|
}
|
|
|
|
func (m *model) sortTodos() {
|
|
// Sort todos: completed ones always last, then by ID (newest first)
|
|
for i := 0; i < len(m.todos)-1; i++ {
|
|
for j := i + 1; j < len(m.todos); j++ {
|
|
// If i is completed and j is not, swap
|
|
if m.todos[i].Completed && !m.todos[j].Completed {
|
|
m.todos[i], m.todos[j] = m.todos[j], m.todos[i]
|
|
} else if !m.todos[i].Completed && !m.todos[j].Completed {
|
|
// Both not completed, sort by ID (newest first)
|
|
if m.todos[i].ID < m.todos[j].ID {
|
|
m.todos[i], m.todos[j] = m.todos[j], m.todos[i]
|
|
}
|
|
} else if m.todos[i].Completed && m.todos[j].Completed {
|
|
// Both completed, sort by ID (newest first)
|
|
if m.todos[i].ID < m.todos[j].ID {
|
|
m.todos[i], m.todos[j] = m.todos[j], m.todos[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *model) applyFilter() {
|
|
if m.filterText == "" && m.currentCategoryFilter == -1 {
|
|
m.filteredTodos = m.todos
|
|
} else {
|
|
m.filteredTodos = []Todo{}
|
|
query := strings.ToLower(m.filterText)
|
|
for _, t := range m.todos {
|
|
// Apply text filter
|
|
textMatch := true
|
|
if m.filterText != "" {
|
|
textMatch = strings.Contains(strings.ToLower(t.Title), query) ||
|
|
strings.Contains(strings.ToLower(t.Description), query) ||
|
|
strings.Contains(strings.ToLower(strings.Join(t.Tags, " ")), query)
|
|
}
|
|
|
|
// Apply category filter
|
|
categoryMatch := true
|
|
if m.currentCategoryFilter != -1 {
|
|
categoryMatch = t.Category == m.currentCategoryFilter
|
|
}
|
|
|
|
if textMatch && categoryMatch {
|
|
m.filteredTodos = append(m.filteredTodos, t)
|
|
}
|
|
}
|
|
}
|
|
// Sort filtered todos: completed ones always last
|
|
for i := 0; i < len(m.filteredTodos)-1; i++ {
|
|
for j := i + 1; j < len(m.filteredTodos); j++ {
|
|
// If i is completed and j is not, swap
|
|
if m.filteredTodos[i].Completed && !m.filteredTodos[j].Completed {
|
|
m.filteredTodos[i], m.filteredTodos[j] = m.filteredTodos[j], m.filteredTodos[i]
|
|
} else if !m.filteredTodos[i].Completed && !m.filteredTodos[j].Completed {
|
|
// Both not completed, sort by ID (newest first)
|
|
if m.filteredTodos[i].ID < m.filteredTodos[j].ID {
|
|
m.filteredTodos[i], m.filteredTodos[j] = m.filteredTodos[j], m.filteredTodos[i]
|
|
}
|
|
} else if m.filteredTodos[i].Completed && m.filteredTodos[j].Completed {
|
|
// Both completed, sort by ID (newest first)
|
|
if m.filteredTodos[i].ID < m.filteredTodos[j].ID {
|
|
m.filteredTodos[i], m.filteredTodos[j] = m.filteredTodos[j], m.filteredTodos[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if m.cursor >= len(m.filteredTodos) {
|
|
m.cursor = 0
|
|
if len(m.filteredTodos) > 0 {
|
|
m.cursor = len(m.filteredTodos) - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *model) getBrowseTags() {
|
|
set := make(map[string]bool)
|
|
for _, t := range m.todos {
|
|
for _, tag := range t.Tags {
|
|
if tag != "" {
|
|
set[tag] = 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
|
|
|
|
headerHeight := 8
|
|
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 = StateViewTodos
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
}
|
|
if m.state == StateViewTodos {
|
|
if m.filterText != "" {
|
|
m.filterText = ""
|
|
m.applyFilter()
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
if m.state == StateFilterCategory {
|
|
m.state = StateViewTodos
|
|
m.currentCategoryFilter = -1
|
|
m.applyFilter()
|
|
return m, nil
|
|
}
|
|
m.resetToView()
|
|
return m, nil
|
|
case "q":
|
|
if m.state == StateStartup {
|
|
return m, tea.Quit
|
|
}
|
|
if m.state == StateConfirmDelete {
|
|
m.state = StateViewTodos
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
}
|
|
if m.state == StateViewTodoModal {
|
|
m.state = StateViewTodos
|
|
headerHeight := 8
|
|
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 == StateViewTodos {
|
|
if m.filterText != "" {
|
|
m.filterText = ""
|
|
m.applyFilter()
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
m.resetToView()
|
|
return m, nil
|
|
|
|
case "l":
|
|
if m.state == StateViewTodos {
|
|
if m.lang == "cs" {
|
|
m.lang = "en"
|
|
} else {
|
|
m.lang = "cs"
|
|
}
|
|
m.updatePlaceholders()
|
|
return m, nil
|
|
}
|
|
|
|
case "/":
|
|
if m.state == StateViewTodos {
|
|
m.state = StateSearch
|
|
m.textInputs[2].Focus()
|
|
m.textInputs[2].SetValue("")
|
|
return m, nil
|
|
}
|
|
|
|
case "tab", "shift+tab":
|
|
if m.state == StateAddTodo || m.state == StateEditTodo {
|
|
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 == StateViewTodos && m.cursor > 0 {
|
|
m.cursor--
|
|
m.viewport.LineUp(1)
|
|
}
|
|
if m.state == StateViewTodoModal {
|
|
m.viewport.LineUp(1)
|
|
}
|
|
if m.state == StateBrowseTags && m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
case "down", "j":
|
|
if m.state == StateViewTodos {
|
|
max := len(m.filteredTodos)
|
|
if m.cursor < max-1 {
|
|
m.cursor++
|
|
m.viewport.LineDown(1)
|
|
}
|
|
}
|
|
if m.state == StateViewTodoModal {
|
|
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 = StateViewTodos
|
|
case StateViewTodos:
|
|
if len(m.filteredTodos) > 0 {
|
|
m.state = StateViewTodoModal
|
|
m.selectedTodoID = m.filteredTodos[m.cursor].ID
|
|
|
|
var t Todo
|
|
for _, todo := range m.todos {
|
|
if todo.ID == m.selectedTodoID {
|
|
t = todo
|
|
break
|
|
}
|
|
}
|
|
|
|
categoryLabel := categoryLabels[t.Category]
|
|
if m.lang == "en" {
|
|
categoryLabel = categoryLabelsEn[t.Category]
|
|
}
|
|
priorityLabel := priorityLabels[t.Priority]
|
|
if m.lang == "en" {
|
|
priorityLabel = priorityLabelsEn[t.Priority]
|
|
}
|
|
|
|
modalContent := fmt.Sprintf("%s\n---\n[%d] %s\n\n%s\n\nKategorie: %s\nPriorita: %s\n\nTagy: %s\n\nVytvořeno: %s",
|
|
tr.HelpModal,
|
|
t.ID, t.Title, t.Description, categoryLabel, priorityLabel, strings.Join(t.Tags, ", "), t.CreatedAt.Format("2006-01-02 15:04"))
|
|
|
|
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.textInputs[2].Value()
|
|
m.applyFilter()
|
|
m.state = StateViewTodos
|
|
case StateBrowseTags:
|
|
if len(m.browseTags) > 0 {
|
|
m.filterText = m.browseTags[m.cursor]
|
|
m.applyFilter()
|
|
}
|
|
m.state = StateViewTodos
|
|
case StateAddTodo:
|
|
m.handleAddTodo()
|
|
m.state = StateViewTodos
|
|
case StateEditTodo:
|
|
m.handleEditTodo()
|
|
m.state = StateViewTodos
|
|
case StateConfirmDelete:
|
|
if m.focusIndex == 0 {
|
|
m.handleDeleteTodo()
|
|
m.state = StateViewTodos
|
|
m.statusMsg = tr.StatusDeleted
|
|
return m, nil
|
|
} else {
|
|
m.state = StateViewTodos
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
case "c":
|
|
if m.state == StateViewTodos {
|
|
m.state = StateFilterCategory
|
|
return m, nil
|
|
}
|
|
|
|
case "b":
|
|
if m.state == StateViewTodos {
|
|
m.state = StateBrowseTags
|
|
m.getBrowseTags()
|
|
return m, nil
|
|
}
|
|
case "1":
|
|
if m.state == StateViewTodos {
|
|
m.state = StateAddTodo
|
|
m.resetInputs()
|
|
m.textInputs[0].SetValue("")
|
|
m.updatePlaceholders()
|
|
// Reset priority and category for new todo
|
|
m.newTodoPriority = PriorityMedium
|
|
m.newTodoCategory = CategoryPersonal
|
|
return m, nil
|
|
}
|
|
case "2":
|
|
if m.state == StateViewTodos && len(m.filteredTodos) > 0 {
|
|
m.state = StateEditTodo
|
|
m.selectedTodoID = m.filteredTodos[m.cursor].ID
|
|
m.populateInputs(m.filteredTodos[m.cursor])
|
|
return m, nil
|
|
}
|
|
if m.state == StateViewTodoModal {
|
|
for _, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
m.state = StateEditTodo
|
|
m.populateInputs(t)
|
|
break
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
case "3":
|
|
if m.state == StateViewTodos && len(m.filteredTodos) > 0 {
|
|
exportTodo(m.todos, m.filteredTodos[m.cursor].ID)
|
|
m.statusMsg = tr.StatusExported
|
|
}
|
|
case "4":
|
|
if m.state == StateViewTodos && len(m.filteredTodos) > 0 {
|
|
m.state = StateConfirmDelete
|
|
m.selectedTodoID = m.filteredTodos[m.cursor].ID
|
|
m.focusIndex = 0
|
|
}
|
|
case "t":
|
|
if m.state == StateViewTodos && len(m.filteredTodos) > 0 {
|
|
m.toggleTodoCompleted()
|
|
}
|
|
case "+":
|
|
if m.state == StateAddTodo || m.state == StateEditTodo {
|
|
// Cycle priority up
|
|
currentPriority := m.newTodoPriority
|
|
if m.state == StateEditTodo {
|
|
for _, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
currentPriority = t.Priority
|
|
break
|
|
}
|
|
}
|
|
}
|
|
newPriority := (currentPriority + 1) % 3
|
|
if m.state == StateAddTodo {
|
|
// For new todos, update the tracking variable
|
|
m.newTodoPriority = newPriority
|
|
priorityLabel := priorityLabels[newPriority]
|
|
if m.lang == "en" {
|
|
priorityLabel = priorityLabelsEn[newPriority]
|
|
}
|
|
m.statusMsg = fmt.Sprintf("%s: %s", tr.LabelPriority, priorityLabel)
|
|
} else {
|
|
// For existing todos, update directly
|
|
for i, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
m.todos[i].Priority = newPriority
|
|
m.todos[i].ModifiedAt = time.Now()
|
|
break
|
|
}
|
|
}
|
|
saveTodos(m.todos)
|
|
priorityLabel := priorityLabels[newPriority]
|
|
if m.lang == "en" {
|
|
priorityLabel = priorityLabelsEn[newPriority]
|
|
}
|
|
m.statusMsg = fmt.Sprintf("%s: %s", tr.LabelPriority, priorityLabel)
|
|
}
|
|
return m, nil
|
|
}
|
|
case "-":
|
|
if m.state == StateAddTodo || m.state == StateEditTodo {
|
|
// Cycle category
|
|
currentCategory := m.newTodoCategory
|
|
if m.state == StateEditTodo {
|
|
for _, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
currentCategory = t.Category
|
|
break
|
|
}
|
|
}
|
|
}
|
|
newCategory := (currentCategory + 1) % 2
|
|
if m.state == StateAddTodo {
|
|
// For new todos, update the tracking variable
|
|
m.newTodoCategory = newCategory
|
|
categoryLabel := categoryLabels[newCategory]
|
|
if m.lang == "en" {
|
|
categoryLabel = categoryLabelsEn[newCategory]
|
|
}
|
|
m.statusMsg = fmt.Sprintf("%s: %s", tr.LabelCategory, categoryLabel)
|
|
} else {
|
|
// For existing todos, update directly
|
|
for i, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
m.todos[i].Category = newCategory
|
|
m.todos[i].ModifiedAt = time.Now()
|
|
break
|
|
}
|
|
}
|
|
saveTodos(m.todos)
|
|
categoryLabel := categoryLabels[newCategory]
|
|
if m.lang == "en" {
|
|
categoryLabel = categoryLabelsEn[newCategory]
|
|
}
|
|
m.statusMsg = fmt.Sprintf("%s: %s", tr.LabelCategory, categoryLabel)
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if m.state == StateSearch {
|
|
m.textInputs[2], cmd = m.textInputs[2].Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
if m.state == StateAddTodo || m.state == StateEditTodo {
|
|
idx := m.focusIndex
|
|
m.textInputs[idx], cmd = m.textInputs[idx].Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
if m.state == StateConfirmDelete {
|
|
switch msg.String() {
|
|
case "y":
|
|
m.handleDeleteTodo()
|
|
m.state = StateViewTodos
|
|
m.statusMsg = tr.StatusDeleted
|
|
return m, nil
|
|
case "n", "esc":
|
|
m.state = StateViewTodos
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if m.state == StateFilterCategory {
|
|
switch msg.String() {
|
|
case "1":
|
|
m.currentCategoryFilter = CategoryPersonal
|
|
m.applyFilter()
|
|
m.state = StateViewTodos
|
|
m.statusMsg = fmt.Sprintf("Filtr: %s", categoryLabels[CategoryPersonal])
|
|
case "2":
|
|
m.currentCategoryFilter = CategoryWork
|
|
m.applyFilter()
|
|
m.state = StateViewTodos
|
|
m.statusMsg = fmt.Sprintf("Filtr: %s", categoryLabels[CategoryWork])
|
|
case "0", "esc":
|
|
m.currentCategoryFilter = -1
|
|
m.applyFilter()
|
|
m.state = StateViewTodos
|
|
m.statusMsg = "Filtr zrušen"
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
switch m.focusIndex {
|
|
case 0:
|
|
cmds = append(cmds, m.textInputs[0].Focus())
|
|
m.textInputs[0].PromptStyle = focusedStyle
|
|
case 1:
|
|
cmds = append(cmds, m.textInputs[1].Focus())
|
|
m.textInputs[1].PromptStyle = focusedStyle
|
|
case 2:
|
|
cmds = append(cmds, m.textInputs[2].Focus())
|
|
m.textInputs[2].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-TODO 🦅") + "\n\n" +
|
|
"Local-First Todo Manager\n\n" +
|
|
"Enter: Start | q: Quit\n\n" +
|
|
"License: MIT | © 2026 Kankys"))
|
|
|
|
case StateSearch:
|
|
b.WriteString(tr.SearchTitle + "\n" + m.textInputs[2].View() + "\n\n" + tr.HelpForm)
|
|
|
|
case StateBrowseTags:
|
|
b.WriteString(fmt.Sprintf("--- %s ---\n", tr.BrowseTags))
|
|
b.WriteString("Enter: Filter | q: Back\n\n")
|
|
for i, item := range m.browseTags {
|
|
cursor := " "
|
|
if m.cursor == i {
|
|
cursor = "> "
|
|
}
|
|
b.WriteString(fmt.Sprintf("%s%s\n", cursor, item))
|
|
}
|
|
|
|
case StateViewTodos:
|
|
filterInfo := ""
|
|
if m.filterText != "" {
|
|
filterInfo = fmt.Sprintf(" [Text: %s]", m.filterText)
|
|
}
|
|
categoryInfo := ""
|
|
if m.currentCategoryFilter != -1 {
|
|
if m.lang == "cs" {
|
|
categoryInfo = fmt.Sprintf(" [Kategorie: %s]", categoryLabels[m.currentCategoryFilter])
|
|
} else {
|
|
categoryInfo = fmt.Sprintf(" [Category: %s]", categoryLabelsEn[m.currentCategoryFilter])
|
|
}
|
|
}
|
|
|
|
var listBuilder strings.Builder
|
|
if len(m.filteredTodos) == 0 {
|
|
listBuilder.WriteString(tr.NoTodos + "\n")
|
|
} else {
|
|
for i, t := range m.filteredTodos {
|
|
priorityIcon := ""
|
|
switch t.Priority {
|
|
case PriorityLow:
|
|
priorityIcon = priorityStyleLow.Render("●")
|
|
case PriorityMedium:
|
|
priorityIcon = priorityStyleMedium.Render("●")
|
|
case PriorityHigh:
|
|
priorityIcon = priorityStyleHigh.Render("●")
|
|
}
|
|
|
|
categoryIcon := ""
|
|
switch t.Category {
|
|
case CategoryPersonal:
|
|
categoryIcon = categoryPersonal.Render("🏠")
|
|
case CategoryWork:
|
|
categoryIcon = categoryWork.Render("💼")
|
|
}
|
|
|
|
tagStr := ""
|
|
if len(t.Tags) > 0 {
|
|
tagStr = " [" + strings.Join(t.Tags, ", ") + "]"
|
|
}
|
|
|
|
cursor := " "
|
|
if m.cursor == i {
|
|
cursor = selectedStyle.Render("> ")
|
|
}
|
|
|
|
title := t.Title
|
|
if t.Completed {
|
|
title = completedStyle.Render(t.Title)
|
|
}
|
|
listBuilder.WriteString(fmt.Sprintf("%s%s %s %s%s\n", cursor, priorityIcon, categoryIcon, title, tagStr))
|
|
}
|
|
}
|
|
|
|
if m.ready {
|
|
m.viewport.SetContent(listBuilder.String())
|
|
}
|
|
|
|
b.WriteString(fmt.Sprintf("%s | %s | %s | %s | %s | %s | %s | %s | %s\n\n",
|
|
tr.MenuAdd, tr.MenuEdit, tr.MenuExport, tr.MenuDelete, tr.MenuSearch, tr.MenuBrowse, tr.MenuFilter, tr.MenuToggleComplete, tr.MenuLang))
|
|
b.WriteString(tr.ListTitle + filterInfo + categoryInfo + "\n")
|
|
|
|
if m.ready {
|
|
b.WriteString(m.viewport.View() + "\n")
|
|
} else {
|
|
b.WriteString(listBuilder.String())
|
|
}
|
|
b.WriteString("\n" + tr.HelpList)
|
|
|
|
case StateAddTodo, StateEditTodo:
|
|
title := tr.AddTitle
|
|
if m.state == StateEditTodo {
|
|
title = fmt.Sprintf("%s (%d)", tr.EditTitle, m.selectedTodoID)
|
|
}
|
|
b.WriteString(title + "\n\n")
|
|
|
|
b.WriteString(fmt.Sprintf("%-8s %s\n", tr.LabelTitle, m.textInputs[0].View()))
|
|
b.WriteString(fmt.Sprintf("%-8s %s\n", tr.LabelDescription, m.textInputs[1].View()))
|
|
b.WriteString(fmt.Sprintf("%-8s %s\n", tr.LabelTags, m.textInputs[2].View()))
|
|
|
|
// Show priority selection
|
|
currentPriority := m.newTodoPriority
|
|
if m.state == StateEditTodo {
|
|
for _, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
currentPriority = t.Priority
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
priorityLabel := priorityLabels[currentPriority]
|
|
if m.lang == "en" {
|
|
priorityLabel = priorityLabelsEn[currentPriority]
|
|
}
|
|
b.WriteString(fmt.Sprintf("\n%s: %s (+: Zvýšit prioritu)\n", tr.LabelPriority, priorityStyle(currentPriority).Render(priorityLabel)))
|
|
|
|
// Show category selection
|
|
currentCategory := m.newTodoCategory
|
|
if m.state == StateEditTodo {
|
|
for _, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
currentCategory = t.Category
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
categoryLabel := categoryLabels[currentCategory]
|
|
if m.lang == "en" {
|
|
categoryLabel = categoryLabelsEn[currentCategory]
|
|
}
|
|
b.WriteString(fmt.Sprintf("%s: %s (-: Změnit kategorii)\n", tr.LabelCategory, categoryStyle(currentCategory).Render(categoryLabel)))
|
|
|
|
b.WriteString("\n" + tr.HelpForm)
|
|
|
|
case StateViewTodoModal:
|
|
if m.ready {
|
|
b.WriteString(confirmBoxStyle.Render(m.viewport.View()))
|
|
} else {
|
|
b.WriteString(confirmBoxStyle.Render("Načítání..."))
|
|
}
|
|
|
|
case StateConfirmDelete:
|
|
var todoToDelete Todo
|
|
found := false
|
|
for _, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
todoToDelete = t
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
m.state = StateViewTodos
|
|
m.statusMsg = "Chyba: Úkol k smazání nebyl nalezen!"
|
|
return ""
|
|
}
|
|
|
|
confirmText := fmt.Sprintf("%s\n\n[%d] %s\nPopis: %s\n%s | %s",
|
|
tr.ConfirmDelete,
|
|
todoToDelete.ID,
|
|
todoToDelete.Title,
|
|
truncate(todoToDelete.Description, 40),
|
|
tr.ConfirmYes, tr.ConfirmNo,
|
|
)
|
|
b.WriteString(confirmBoxStyle.Render(confirmText))
|
|
|
|
case StateFilterCategory:
|
|
b.WriteString("--- " + tr.FilterCategoryTitle + " ---\n\n")
|
|
b.WriteString("1: " + categoryLabels[CategoryPersonal] + "\n")
|
|
b.WriteString("2: " + categoryLabels[CategoryWork] + "\n")
|
|
b.WriteString("0: " + tr.FilterCategoryNone + "\n\n")
|
|
b.WriteString(tr.FilterCategoryHelp)
|
|
}
|
|
|
|
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.PlaceholderTitle
|
|
m.textInputs[1].Placeholder = tr.PlaceholderDescription
|
|
m.textInputs[2].Placeholder = tr.PlaceholderTags
|
|
}
|
|
|
|
func (m *model) resetToView() {
|
|
m.state = StateViewTodos
|
|
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.focusIndex = 0
|
|
m.updateFocus()
|
|
}
|
|
|
|
func (m *model) populateInputs(t Todo) {
|
|
m.textInputs[0].SetValue(t.Title)
|
|
m.textInputs[1].SetValue(t.Description)
|
|
m.textInputs[2].SetValue(strings.Join(t.Tags, ", "))
|
|
m.focusIndex = 0
|
|
m.updateFocus()
|
|
}
|
|
|
|
func (m *model) handleAddTodo() {
|
|
tr := Translations[m.lang]
|
|
title := m.textInputs[0].Value()
|
|
description := m.textInputs[1].Value()
|
|
if title == "" {
|
|
m.statusMsg = tr.ErrRequired
|
|
return
|
|
}
|
|
tags := []string{}
|
|
if m.textInputs[2].Value() != "" {
|
|
tags = strings.Split(m.textInputs[2].Value(), ",")
|
|
for i := range tags {
|
|
tags[i] = strings.TrimSpace(tags[i])
|
|
}
|
|
}
|
|
|
|
// Use the tracked priority and category
|
|
priority := m.newTodoPriority
|
|
category := m.newTodoCategory
|
|
|
|
now := time.Now()
|
|
m.todos = append(m.todos, Todo{
|
|
ID: now.UnixMilli(),
|
|
Title: title,
|
|
Description: description,
|
|
Priority: priority,
|
|
Category: category,
|
|
Tags: tags,
|
|
CreatedAt: now,
|
|
ModifiedAt: now,
|
|
Completed: false,
|
|
})
|
|
saveTodos(m.todos)
|
|
m.sortTodos()
|
|
m.applyFilter()
|
|
m.statusMsg = tr.StatusSaved
|
|
m.state = StateViewTodos
|
|
// Reset priority and category for next todo
|
|
m.newTodoPriority = PriorityMedium
|
|
m.newTodoCategory = CategoryPersonal
|
|
}
|
|
|
|
func (m *model) handleEditTodo() {
|
|
tr := Translations[m.lang]
|
|
for i, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
m.todos[i].Title = m.textInputs[0].Value()
|
|
m.todos[i].Description = m.textInputs[1].Value()
|
|
tags := []string{}
|
|
if m.textInputs[2].Value() != "" {
|
|
tags = strings.Split(m.textInputs[2].Value(), ",")
|
|
for j := range tags {
|
|
tags[j] = strings.TrimSpace(tags[j])
|
|
}
|
|
}
|
|
m.todos[i].Tags = tags
|
|
m.todos[i].ModifiedAt = time.Now()
|
|
break
|
|
}
|
|
}
|
|
saveTodos(m.todos)
|
|
m.sortTodos()
|
|
m.applyFilter()
|
|
m.statusMsg = tr.StatusEdited
|
|
m.state = StateViewTodos
|
|
}
|
|
|
|
func (m *model) handleDeleteTodo() {
|
|
todoIndex := -1
|
|
for i, t := range m.todos {
|
|
if t.ID == m.selectedTodoID {
|
|
todoIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if todoIndex != -1 {
|
|
m.todos = append(m.todos[:todoIndex], m.todos[todoIndex+1:]...)
|
|
saveTodos(m.todos)
|
|
}
|
|
m.sortTodos()
|
|
m.applyFilter()
|
|
}
|
|
|
|
func (m *model) toggleTodoCompleted() {
|
|
tr := Translations[m.lang]
|
|
selectedID := m.filteredTodos[m.cursor].ID
|
|
|
|
for i, t := range m.todos {
|
|
if t.ID == selectedID {
|
|
m.todos[i].Completed = !m.todos[i].Completed
|
|
m.todos[i].ModifiedAt = time.Now()
|
|
break
|
|
}
|
|
}
|
|
saveTodos(m.todos)
|
|
m.sortTodos()
|
|
m.applyFilter()
|
|
|
|
// Find the toggled todo in filtered list to update cursor
|
|
for i, t := range m.filteredTodos {
|
|
if t.ID == selectedID {
|
|
m.cursor = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// Find the todo to check its completed status for status message
|
|
for _, t := range m.todos {
|
|
if t.ID == selectedID {
|
|
if t.Completed {
|
|
m.statusMsg = tr.StatusCompleted
|
|
} else {
|
|
m.statusMsg = tr.StatusUncompleted
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func exportTodo(todos []Todo, id int64) {
|
|
for _, todo := range todos {
|
|
if todo.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-todo-export", homeDir)
|
|
if err := os.MkdirAll(exportDir, 0755); err != nil {
|
|
fmt.Printf("Chyba při vytváření adresáře pro export: %v\n", err)
|
|
return
|
|
}
|
|
|
|
categoryLabel := "Osobní"
|
|
if todo.Category == CategoryWork {
|
|
categoryLabel = "Pracovní"
|
|
}
|
|
priorityLabel := priorityLabels[todo.Priority]
|
|
|
|
fileName := fmt.Sprintf("%s_%d.txt", strings.ReplaceAll(todo.Title, " ", "_"), todo.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\nKategorie: %s\nPriorita: %s\nTagy: %s\nVytvořeno: %s\n\n%s",
|
|
todo.Title, categoryLabel, priorityLabel, strings.Join(todo.Tags, ", "), todo.CreatedAt.Format("2006-01-02 15:04"), todo.Description)
|
|
|
|
_, _ = file.WriteString(content)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func truncate(s string, max int) string {
|
|
if len(s) > max {
|
|
return strings.TrimSpace(s[:max]) + "..."
|
|
}
|
|
return s
|
|
}
|
|
|
|
func priorityStyle(priority int) lipgloss.Style {
|
|
switch priority {
|
|
case PriorityLow:
|
|
return priorityStyleLow
|
|
case PriorityMedium:
|
|
return priorityStyleMedium
|
|
case PriorityHigh:
|
|
return priorityStyleHigh
|
|
}
|
|
return lipgloss.NewStyle()
|
|
}
|
|
|
|
func categoryStyle(category int) lipgloss.Style {
|
|
switch category {
|
|
case CategoryPersonal:
|
|
return categoryPersonal
|
|
case CategoryWork:
|
|
return categoryWork
|
|
}
|
|
return lipgloss.NewStyle()
|
|
}
|
|
|
|
type errMsg error
|