refactor
This commit is contained in:
829
tg_bot/screens/ui/date_time_picker.go
Normal file
829
tg_bot/screens/ui/date_time_picker.go
Normal file
@@ -0,0 +1,829 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||||
)
|
||||
|
||||
const (
|
||||
pickerViewCalendar = "calendar"
|
||||
pickerViewMonths = "months"
|
||||
pickerViewTimeCombined = "time_combined"
|
||||
pickerViewConfirm = "confirm"
|
||||
dtpCallbackPrefix = "dtp:"
|
||||
)
|
||||
|
||||
var MskLocation = time.FixedZone("MSK", 3*60*60)
|
||||
|
||||
type DateTimePickerConfig struct {
|
||||
Title string
|
||||
Key string
|
||||
IncludeTime bool
|
||||
Selected *time.Time
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
type DateTimeSelectionTarget interface {
|
||||
SetDateTimeSelection(key string, value time.Time)
|
||||
}
|
||||
|
||||
type DateTimePicker struct {
|
||||
Title string
|
||||
Key string
|
||||
IncludeTime bool
|
||||
BackState bot.State
|
||||
|
||||
view string
|
||||
year int
|
||||
month time.Month
|
||||
selectedDate *time.Time
|
||||
selectedHour *int
|
||||
selectedMinute *int
|
||||
}
|
||||
|
||||
func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
|
||||
now := time.Now().In(MskLocation)
|
||||
year := now.Year()
|
||||
month := now.Month()
|
||||
var selected *time.Time
|
||||
|
||||
if cfg.Selected != nil {
|
||||
value := cfg.Selected.In(MskLocation)
|
||||
selected = &value
|
||||
year = value.Year()
|
||||
month = value.Month()
|
||||
}
|
||||
|
||||
return &DateTimePicker{
|
||||
Title: cfg.Title,
|
||||
Key: cfg.Key,
|
||||
IncludeTime: cfg.IncludeTime,
|
||||
BackState: cfg.BackState,
|
||||
view: pickerViewCalendar,
|
||||
year: year,
|
||||
month: month,
|
||||
selectedDate: selected,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
text := fmt.Sprintf("<b>%s</b>\n\n", s.Title)
|
||||
text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime())
|
||||
switch s.view {
|
||||
case pickerViewCalendar:
|
||||
text += s.renderCalendarTitle()
|
||||
b.Render(text, s.calendarKeyboard(), mode)
|
||||
case pickerViewMonths:
|
||||
text += "Выберите месяц\n\n"
|
||||
b.Render(text, s.monthsKeyboard(), mode)
|
||||
case pickerViewTimeCombined:
|
||||
b.Render(text, s.timeCombinedKeyboard(), mode)
|
||||
case pickerViewConfirm:
|
||||
text += s.renderSelectedDateTime()
|
||||
b.Render(text, s.confirmKeyboard(), mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
data := u.CallbackQuery.Data
|
||||
if data == "back" {
|
||||
s.handleBack(b)
|
||||
return
|
||||
}
|
||||
if data == "cancel" {
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(data, dtpCallbackPrefix) {
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
|
||||
payload := strings.TrimPrefix(data, dtpCallbackPrefix)
|
||||
switch {
|
||||
case payload == "month_prev":
|
||||
s.prevMonth()
|
||||
case payload == "month_next":
|
||||
s.nextMonth()
|
||||
case payload == "open_months":
|
||||
s.view = pickerViewMonths
|
||||
case payload == "year_prev":
|
||||
s.year--
|
||||
case payload == "year_next":
|
||||
s.year++
|
||||
case strings.HasPrefix(payload, "month:"):
|
||||
month := parseInt(strings.TrimPrefix(payload, "month:"))
|
||||
if month >= 1 && month <= 12 {
|
||||
s.month = time.Month(month)
|
||||
s.view = pickerViewCalendar
|
||||
}
|
||||
case strings.HasPrefix(payload, "day:"):
|
||||
s.handleDaySelect(payload)
|
||||
case strings.HasPrefix(payload, "hour:"):
|
||||
s.handleHourSelect(payload)
|
||||
case strings.HasPrefix(payload, "min:"):
|
||||
s.handleMinuteSelect(payload)
|
||||
case payload == "confirm":
|
||||
s.confirmSelection(b)
|
||||
return
|
||||
}
|
||||
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) handleBack(b *bot.Bot) {
|
||||
switch s.view {
|
||||
case pickerViewMonths:
|
||||
s.view = pickerViewCalendar
|
||||
case pickerViewTimeCombined:
|
||||
s.view = pickerViewCalendar
|
||||
case pickerViewConfirm:
|
||||
if s.IncludeTime {
|
||||
s.view = pickerViewTimeCombined
|
||||
} else {
|
||||
s.view = pickerViewCalendar
|
||||
}
|
||||
default:
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Enter(b, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) renderCalendarTitle() string {
|
||||
if s.year == time.Now().In(MskLocation).Year() {
|
||||
return "\n"
|
||||
}
|
||||
return fmt.Sprintf("%d\n\n", s.year)
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
s.monthPrevButton(),
|
||||
{Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"},
|
||||
{Text: "→", CallbackData: dtpCallbackPrefix + "month_next"},
|
||||
})
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "Пн", CallbackData: "empty"},
|
||||
{Text: "Вт", CallbackData: "empty"},
|
||||
{Text: "Ср", CallbackData: "empty"},
|
||||
{Text: "Чт", CallbackData: "empty"},
|
||||
{Text: "Пт", CallbackData: "empty"},
|
||||
{Text: "Сб", CallbackData: "empty"},
|
||||
{Text: "Вс", CallbackData: "empty"},
|
||||
})
|
||||
|
||||
firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation)
|
||||
weekday := int(firstOfMonth.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
daysInMonth := daysInMonth(s.year, s.month)
|
||||
today := time.Now().In(MskLocation)
|
||||
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
|
||||
|
||||
var row []echotron.InlineKeyboardButton
|
||||
for i := 1; i < weekday; i++ {
|
||||
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
|
||||
}
|
||||
|
||||
for day := 1; day <= daysInMonth; day++ {
|
||||
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation)
|
||||
if date.Before(todayDate) {
|
||||
row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"})
|
||||
} else {
|
||||
label := fmt.Sprintf("%d", day)
|
||||
row = append(row, echotron.InlineKeyboardButton{
|
||||
Text: label,
|
||||
CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day),
|
||||
})
|
||||
}
|
||||
if len(row) == 7 {
|
||||
rows = append(rows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(row) > 0 {
|
||||
for len(row) < 7 {
|
||||
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
{Text: "Отмена", CallbackData: "cancel"},
|
||||
})
|
||||
|
||||
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
|
||||
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
|
||||
for i := 0; i < 12; i += 3 {
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)},
|
||||
{Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)},
|
||||
{Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)},
|
||||
})
|
||||
}
|
||||
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
})
|
||||
|
||||
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}})
|
||||
for i := 0; i < 24; i += 6 {
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
s.hourButton(i),
|
||||
s.hourButton(i + 1),
|
||||
s.hourButton(i + 2),
|
||||
s.hourButton(i + 3),
|
||||
s.hourButton(i + 4),
|
||||
s.hourButton(i + 5),
|
||||
})
|
||||
}
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}})
|
||||
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
|
||||
for i := 0; i < len(minutes); i += 6 {
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
s.minuteButton(minutes[i]),
|
||||
s.minuteButton(minutes[i+1]),
|
||||
s.minuteButton(minutes[i+2]),
|
||||
s.minuteButton(minutes[i+3]),
|
||||
s.minuteButton(minutes[i+4]),
|
||||
s.minuteButton(minutes[i+5]),
|
||||
})
|
||||
}
|
||||
rows = append(rows, []echotron.InlineKeyboardButton{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
|
||||
})
|
||||
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
|
||||
return echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]echotron.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "← Назад", CallbackData: "back"},
|
||||
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) renderSelectedDateTime() string {
|
||||
if s.selectedDate == nil {
|
||||
return "—"
|
||||
}
|
||||
hour := 0
|
||||
minute := 0
|
||||
if s.selectedHour != nil {
|
||||
hour = *s.selectedHour
|
||||
}
|
||||
if s.selectedMinute != nil {
|
||||
minute = *s.selectedMinute
|
||||
}
|
||||
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
|
||||
return fmt.Sprintf("<b>%s</b>", formatCompactDateTime(value))
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
|
||||
hour := "__"
|
||||
minute := "__"
|
||||
if s.selectedHour != nil {
|
||||
hour = fmt.Sprintf("%02d", *s.selectedHour)
|
||||
} else if !showPlaceholders {
|
||||
hour = "00"
|
||||
}
|
||||
if s.selectedMinute != nil {
|
||||
minute = fmt.Sprintf("%02d", *s.selectedMinute)
|
||||
} else if !showPlaceholders {
|
||||
minute = "00"
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", hour, minute)
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) formatSelectedDateTime() string {
|
||||
displayDate, ok := s.displayDate()
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
timeLabel := "--:--"
|
||||
if s.selectedHour != nil && s.selectedMinute != nil {
|
||||
timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute)
|
||||
}
|
||||
weekday := WeekdayName(displayDate.Weekday())
|
||||
month := MonthShort(displayDate.Month())
|
||||
return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year())
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) displayDate() (time.Time, bool) {
|
||||
if s.selectedDate == nil {
|
||||
if s.view == pickerViewCalendar || s.view == pickerViewMonths {
|
||||
return time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
if (s.view == pickerViewCalendar || s.view == pickerViewMonths) &&
|
||||
(s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) {
|
||||
day := s.selectedDate.Day()
|
||||
maxDay := daysInMonth(s.year, s.month)
|
||||
if day > maxDay {
|
||||
day = maxDay
|
||||
}
|
||||
return time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation), true
|
||||
}
|
||||
|
||||
return s.selectedDate.In(MskLocation), true
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) handleDaySelect(payload string) {
|
||||
datePart := strings.TrimPrefix(payload, "day:")
|
||||
parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.selectedDate = &parsed
|
||||
if s.IncludeTime {
|
||||
s.view = pickerViewTimeCombined
|
||||
s.selectedHour = nil
|
||||
s.selectedMinute = nil
|
||||
} else {
|
||||
s.view = pickerViewConfirm
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) handleHourSelect(payload string) {
|
||||
hour := parseInt(strings.TrimPrefix(payload, "hour:"))
|
||||
if hour < 0 || hour > 23 {
|
||||
return
|
||||
}
|
||||
if s.isHourDisabled(hour) {
|
||||
return
|
||||
}
|
||||
s.selectedHour = &hour
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) handleMinuteSelect(payload string) {
|
||||
minute := parseInt(strings.TrimPrefix(payload, "min:"))
|
||||
if minute < 0 || minute > 59 {
|
||||
return
|
||||
}
|
||||
if s.isMinuteDisabled(minute) {
|
||||
return
|
||||
}
|
||||
s.selectedMinute = &minute
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
|
||||
if s.selectedDate == nil {
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
|
||||
s.view = pickerViewTimeCombined
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
}
|
||||
hour := 0
|
||||
minute := 0
|
||||
if s.selectedHour != nil {
|
||||
hour = *s.selectedHour
|
||||
}
|
||||
if s.selectedMinute != nil {
|
||||
minute = *s.selectedMinute
|
||||
}
|
||||
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
|
||||
if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
|
||||
target.SetDateTimeSelection(s.Key, value)
|
||||
}
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
|
||||
label := fmt.Sprintf("%02d", hour)
|
||||
if s.isHourDisabled(hour) {
|
||||
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
|
||||
}
|
||||
if s.selectedHour != nil && *s.selectedHour == hour {
|
||||
label = "● " + label
|
||||
}
|
||||
return echotron.InlineKeyboardButton{
|
||||
Text: label,
|
||||
CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
|
||||
label := fmt.Sprintf("%02d", minute)
|
||||
if s.isMinuteDisabled(minute) {
|
||||
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
|
||||
}
|
||||
if s.selectedMinute != nil && *s.selectedMinute == minute {
|
||||
label = "● " + label
|
||||
}
|
||||
return echotron.InlineKeyboardButton{
|
||||
Text: label,
|
||||
CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) isHourDisabled(hour int) bool {
|
||||
if s.selectedDate == nil {
|
||||
return false
|
||||
}
|
||||
today := time.Now().In(MskLocation)
|
||||
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
|
||||
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
|
||||
if date.After(todayDate) {
|
||||
return false
|
||||
}
|
||||
return hour < today.Hour()
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
|
||||
if s.selectedDate == nil || s.selectedHour == nil {
|
||||
return false
|
||||
}
|
||||
today := time.Now().In(MskLocation)
|
||||
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
|
||||
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
|
||||
if date.After(todayDate) {
|
||||
return false
|
||||
}
|
||||
if *s.selectedHour > today.Hour() {
|
||||
return false
|
||||
}
|
||||
return minute < today.Minute()
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) prevMonth() {
|
||||
now := time.Now().In(MskLocation)
|
||||
if s.year == now.Year() && s.month == now.Month() {
|
||||
return
|
||||
}
|
||||
if s.month == time.January {
|
||||
s.month = time.December
|
||||
s.year--
|
||||
} else {
|
||||
s.month--
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) nextMonth() {
|
||||
if s.month == time.December {
|
||||
s.month = time.January
|
||||
s.year++
|
||||
} else {
|
||||
s.month++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
|
||||
now := time.Now().In(MskLocation)
|
||||
if s.year == now.Year() && s.month == now.Month() {
|
||||
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
|
||||
}
|
||||
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
|
||||
}
|
||||
|
||||
func daysInMonth(year int, month time.Month) int {
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day()
|
||||
}
|
||||
|
||||
func monthName(month time.Month) string {
|
||||
names := []string{
|
||||
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
|
||||
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
|
||||
}
|
||||
if int(month) < 1 || int(month) > len(names) {
|
||||
return ""
|
||||
}
|
||||
return names[int(month)-1]
|
||||
}
|
||||
|
||||
func parseInt(value string) int {
|
||||
result := 0
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
result = result*10 + int(r-'0')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(u.Message.Text)
|
||||
switch s.view {
|
||||
case pickerViewCalendar:
|
||||
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
|
||||
if !hasDate {
|
||||
s.Enter(b, bot.NewMessage)
|
||||
return
|
||||
}
|
||||
s.selectedDate = &value
|
||||
if hasTime {
|
||||
hour := value.Hour()
|
||||
minute := value.Minute()
|
||||
s.selectedHour = &hour
|
||||
s.selectedMinute = &minute
|
||||
}
|
||||
if s.IncludeTime {
|
||||
s.view = pickerViewTimeCombined
|
||||
} else {
|
||||
s.view = pickerViewConfirm
|
||||
}
|
||||
s.Enter(b, bot.NewMessage)
|
||||
case pickerViewTimeCombined:
|
||||
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
|
||||
if !hasTime {
|
||||
s.Enter(b, bot.NewMessage)
|
||||
return
|
||||
}
|
||||
if hasDate {
|
||||
s.selectedDate = &value
|
||||
}
|
||||
hour := value.Hour()
|
||||
minute := value.Minute()
|
||||
s.selectedHour = &hour
|
||||
s.selectedMinute = &minute
|
||||
s.Enter(b, bot.NewMessage)
|
||||
default:
|
||||
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
|
||||
if !hasDate && !hasTime {
|
||||
s.Enter(b, bot.NewMessage)
|
||||
return
|
||||
}
|
||||
s.selectedDate = &value
|
||||
hour := value.Hour()
|
||||
minute := value.Minute()
|
||||
s.selectedHour = &hour
|
||||
s.selectedMinute = &minute
|
||||
s.view = pickerViewConfirm
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *DateTimePicker) Exit() {}
|
||||
|
||||
func parseDateTimeInput(
|
||||
input string,
|
||||
currentDate *time.Time,
|
||||
currentHour *int,
|
||||
currentMinute *int,
|
||||
) (time.Time, bool, bool) {
|
||||
text := strings.ToLower(strings.TrimSpace(input))
|
||||
text = strings.ReplaceAll(text, ",", " ")
|
||||
text = strings.ReplaceAll(text, " ", " ")
|
||||
|
||||
now := time.Now().In(MskLocation)
|
||||
baseDate := now
|
||||
if currentDate != nil {
|
||||
baseDate = currentDate.In(MskLocation)
|
||||
}
|
||||
|
||||
date, dateOk := parseDate(text, baseDate)
|
||||
hour, minute, timeOk := parseTime(text, currentHour, currentMinute)
|
||||
|
||||
if !dateOk && !timeOk {
|
||||
return time.Time{}, false, false
|
||||
}
|
||||
if !dateOk {
|
||||
date = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), 0, 0, 0, 0, MskLocation)
|
||||
}
|
||||
if !timeOk {
|
||||
hour = 0
|
||||
minute = 0
|
||||
}
|
||||
|
||||
value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, MskLocation)
|
||||
if value.Before(now) {
|
||||
return time.Time{}, false, false
|
||||
}
|
||||
return value, dateOk, timeOk
|
||||
}
|
||||
|
||||
func parseDate(text string, baseDate time.Time) (time.Time, bool) {
|
||||
if strings.Contains(text, "сегодня") {
|
||||
return baseDate, true
|
||||
}
|
||||
if strings.Contains(text, "завтра") {
|
||||
return baseDate.AddDate(0, 0, 1), true
|
||||
}
|
||||
if strings.Contains(text, "послезавтра") {
|
||||
return baseDate.AddDate(0, 0, 2), true
|
||||
}
|
||||
|
||||
if date := parseNumericDate(text); !date.IsZero() {
|
||||
return date, true
|
||||
}
|
||||
if date := parseWordDate(text); !date.IsZero() {
|
||||
return date, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseNumericDate(text string) time.Time {
|
||||
reYMD := regexp.MustCompile(`\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b`)
|
||||
if match := reYMD.FindStringSubmatch(text); len(match) == 4 {
|
||||
year := parseInt(match[1])
|
||||
month := parseInt(match[2])
|
||||
day := parseInt(match[3])
|
||||
return safeDate(year, month, day)
|
||||
}
|
||||
|
||||
reDMY := regexp.MustCompile(`\b(\d{1,2})[./-](\d{1,2})(?:[./-](\d{2,4}))?\b`)
|
||||
if match := reDMY.FindStringSubmatch(text); len(match) >= 3 {
|
||||
day := parseInt(match[1])
|
||||
month := parseInt(match[2])
|
||||
year := time.Now().In(MskLocation).Year()
|
||||
if len(match) == 4 && match[3] != "" {
|
||||
year = parseInt(match[3])
|
||||
if year < 100 {
|
||||
year += 2000
|
||||
}
|
||||
}
|
||||
return safeDate(year, month, day)
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseWordDate(text string) time.Time {
|
||||
monthMap := map[string]time.Month{
|
||||
"янв": 1, "январ": 1, "января": 1,
|
||||
"фев": 2, "феврал": 2, "февраля": 2,
|
||||
"мар": 3, "марта": 3,
|
||||
"апр": 4, "апрел": 4, "апреля": 4,
|
||||
"май": 5, "мая": 5,
|
||||
"июн": 6, "июня": 6,
|
||||
"июл": 7, "июля": 7,
|
||||
"авг": 8, "август": 8, "августа": 8,
|
||||
"сен": 9, "сент": 9, "сентябр": 9, "сентября": 9,
|
||||
"окт": 10, "октябр": 10, "октября": 10,
|
||||
"ноя": 11, "ноябр": 11, "ноября": 11,
|
||||
"дек": 12, "декабр": 12, "декабря": 12,
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`\b(\d{1,2})\s+([a-zа-яё]+)(?:\s+(\d{2,4}))?\b`)
|
||||
match := re.FindStringSubmatch(text)
|
||||
if len(match) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
day := parseInt(match[1])
|
||||
monthToken := match[2]
|
||||
var month time.Month
|
||||
for key, value := range monthMap {
|
||||
if strings.HasPrefix(monthToken, key) {
|
||||
month = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if month == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
year := time.Now().In(MskLocation).Year()
|
||||
if len(match) == 4 && match[3] != "" {
|
||||
year = parseInt(match[3])
|
||||
if year < 100 {
|
||||
year += 2000
|
||||
}
|
||||
}
|
||||
|
||||
return safeDate(year, int(month), day)
|
||||
}
|
||||
|
||||
func parseTime(text string, currentHour *int, currentMinute *int) (int, int, bool) {
|
||||
re := regexp.MustCompile(`\b(\d{1,2})[:.](\d{2})\b`)
|
||||
if match := re.FindStringSubmatch(text); len(match) == 3 {
|
||||
hour := parseInt(match[1])
|
||||
minute := parseInt(match[2])
|
||||
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
reCompact := regexp.MustCompile(`\b(\d{1,2})\s+(\d{2})\b`)
|
||||
if match := reCompact.FindStringSubmatch(text); len(match) == 3 {
|
||||
hour := parseInt(match[1])
|
||||
minute := parseInt(match[2])
|
||||
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
reHour := regexp.MustCompile(`\b(\d{1,2})\s*(?:ч|час|часов)\b`)
|
||||
if match := reHour.FindStringSubmatch(text); len(match) == 2 {
|
||||
hour := parseInt(match[1])
|
||||
if hour >= 0 && hour <= 23 {
|
||||
minute := 0
|
||||
if currentMinute != nil {
|
||||
minute = *currentMinute
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
reJustHour := regexp.MustCompile(`\b(\d{1,2})\b`)
|
||||
if match := reJustHour.FindStringSubmatch(text); len(match) == 2 && strings.Contains(text, "в") {
|
||||
hour := parseInt(match[1])
|
||||
if hour >= 0 && hour <= 23 {
|
||||
minute := 0
|
||||
if currentMinute != nil {
|
||||
minute = *currentMinute
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
}
|
||||
|
||||
if currentHour != nil && currentMinute != nil {
|
||||
return *currentHour, *currentMinute, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func safeDate(year, month, day int) time.Time {
|
||||
if month < 1 || month > 12 {
|
||||
return time.Time{}
|
||||
}
|
||||
if day < 1 || day > 31 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, MskLocation)
|
||||
}
|
||||
|
||||
func WeekdayName(day time.Weekday) string {
|
||||
switch day {
|
||||
case time.Monday:
|
||||
return "Пн"
|
||||
case time.Tuesday:
|
||||
return "Вт"
|
||||
case time.Wednesday:
|
||||
return "Ср"
|
||||
case time.Thursday:
|
||||
return "Чт"
|
||||
case time.Friday:
|
||||
return "Пт"
|
||||
case time.Saturday:
|
||||
return "Сб"
|
||||
case time.Sunday:
|
||||
return "Вс"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatCompactDateTime(value time.Time) string {
|
||||
weekday := WeekdayName(value.Weekday())
|
||||
month := MonthShort(value.Month())
|
||||
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
|
||||
}
|
||||
|
||||
func MonthShort(month time.Month) string {
|
||||
months := []string{
|
||||
"янв", "фев", "мар", "апр", "май", "июн",
|
||||
"июл", "авг", "сен", "окт", "ноя", "дек",
|
||||
}
|
||||
if int(month) < 1 || int(month) > len(months) {
|
||||
return ""
|
||||
}
|
||||
return months[int(month)-1]
|
||||
}
|
||||
304
tg_bot/screens/ui/message_format.go
Normal file
304
tg_bot/screens/ui/message_format.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
type htmlTag struct {
|
||||
open string
|
||||
close string
|
||||
}
|
||||
|
||||
var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{
|
||||
echotron.BoldEntity: {open: "<b>", close: "</b>"},
|
||||
echotron.ItalicEntity: {open: "<i>", close: "</i>"},
|
||||
echotron.UnderlineEntity: {open: "<u>", close: "</u>"},
|
||||
echotron.StrikethroughEntity: {open: "<s>", close: "</s>"},
|
||||
echotron.CodeEntity: {open: "<code>", close: "</code>"},
|
||||
echotron.PreEntity: {open: "<pre>", close: "</pre>"},
|
||||
}
|
||||
|
||||
// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
|
||||
// Invite links are normalized into <a class="tg-link"> placeholders for previews.
|
||||
func FormatMessageHTML(message *echotron.Message) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Telegram sends formatting as entities, so we rebuild the HTML from offsets.
|
||||
text := message.Text
|
||||
entities := message.Entities
|
||||
if text == "" {
|
||||
text = message.Caption
|
||||
entities = message.CaptionEntities
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(entities) == 0 {
|
||||
return EscapeHTML(text)
|
||||
}
|
||||
|
||||
type tagSpan struct {
|
||||
open string
|
||||
close string
|
||||
start int
|
||||
end int
|
||||
len int
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
positions := make([]int, len(runes)+1)
|
||||
utf16Count := 0
|
||||
for i, r := range runes {
|
||||
positions[i] = utf16Count
|
||||
if r > 0xFFFF {
|
||||
utf16Count += 2
|
||||
} else {
|
||||
utf16Count++
|
||||
}
|
||||
}
|
||||
positions[len(runes)] = utf16Count
|
||||
|
||||
utf16ToRuneIndex := func(utf16Index int) (int, bool) {
|
||||
i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index })
|
||||
if i < len(positions) && positions[i] == utf16Index {
|
||||
return i, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
toRuneRange := func(offset, length int) (int, int, bool) {
|
||||
start, ok := utf16ToRuneIndex(offset)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
end, ok := utf16ToRuneIndex(offset + length)
|
||||
if !ok || end > len(runes) || end < start {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// URL ranges are used to prevent nested tags inside links.
|
||||
urlRanges := make([][2]int, 0, len(entities))
|
||||
|
||||
for _, entity := range entities {
|
||||
if entity == nil {
|
||||
continue
|
||||
}
|
||||
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity {
|
||||
continue
|
||||
}
|
||||
start, end, ok := toRuneRange(entity.Offset, entity.Length)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
urlRanges = append(urlRanges, [2]int{start, end})
|
||||
if entity.Type == echotron.UrlEntity {
|
||||
_ = string(runes[start:end])
|
||||
}
|
||||
}
|
||||
|
||||
isInsideURL := func(offset, length int) bool {
|
||||
for _, urlRange := range urlRanges {
|
||||
if offset > urlRange[0] && offset+length <= urlRange[1] {
|
||||
return true
|
||||
}
|
||||
if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var spans []tagSpan
|
||||
for _, entity := range entities {
|
||||
if entity == nil {
|
||||
continue
|
||||
}
|
||||
start, end, ok := toRuneRange(entity.Offset, entity.Length)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) {
|
||||
continue
|
||||
}
|
||||
|
||||
var openTag, closeTag string
|
||||
if tag, ok := simpleHTMLTags[entity.Type]; ok {
|
||||
openTag, closeTag = tag.open, tag.close
|
||||
} else if entity.Type == echotron.UrlEntity {
|
||||
entityText := string(runes[start:end])
|
||||
if isInviteLink(entityText) {
|
||||
openTag = `<a class="tg-link">`
|
||||
closeTag = "</a>"
|
||||
} else {
|
||||
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
|
||||
closeTag = "</a>"
|
||||
}
|
||||
} else if entity.Type == echotron.TextLinkEntity {
|
||||
if entity.URL != "" {
|
||||
if isInviteLink(entity.URL) {
|
||||
openTag = `<a class="tg-link">`
|
||||
closeTag = "</a>"
|
||||
} else {
|
||||
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
|
||||
closeTag = "</a>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if openTag != "" && closeTag != "" {
|
||||
spans = append(spans, tagSpan{
|
||||
open: openTag,
|
||||
close: closeTag,
|
||||
start: start,
|
||||
end: end,
|
||||
len: end - start,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
opens := make(map[int][]tagSpan)
|
||||
closes := make(map[int][]tagSpan)
|
||||
for _, span := range spans {
|
||||
opens[span.start] = append(opens[span.start], span)
|
||||
closes[span.end] = append(closes[span.end], span)
|
||||
}
|
||||
|
||||
// Build the final text with tags inserted and HTML escaped.
|
||||
var result strings.Builder
|
||||
for i := 0; i <= len(runes); i++ {
|
||||
if closing, ok := closes[i]; ok {
|
||||
sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len })
|
||||
for _, span := range closing {
|
||||
result.WriteString(span.close)
|
||||
}
|
||||
}
|
||||
if opening, ok := opens[i]; ok {
|
||||
sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len })
|
||||
for _, span := range opening {
|
||||
result.WriteString(span.open)
|
||||
}
|
||||
}
|
||||
if i < len(runes) {
|
||||
switch runes[i] {
|
||||
case '<':
|
||||
result.WriteString("<")
|
||||
case '>':
|
||||
result.WriteString(">")
|
||||
case '&':
|
||||
result.WriteString("&")
|
||||
default:
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeHTMLTags(result.String())
|
||||
}
|
||||
|
||||
// isInviteLink detects Telegram invite links used in creatives.
|
||||
func isInviteLink(url string) bool {
|
||||
return strings.Contains(url, "t.me/+") || strings.Contains(url, "t.me/joinchat/")
|
||||
}
|
||||
|
||||
func normalizeHTMLTags(input string) string {
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
|
||||
allowed := map[string]bool{
|
||||
"a": true,
|
||||
"b": true,
|
||||
"i": true,
|
||||
"u": true,
|
||||
"s": true,
|
||||
"code": true,
|
||||
"pre": true,
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.Grow(len(input))
|
||||
stack := make([]string, 0, 8)
|
||||
|
||||
for i := 0; i < len(input); {
|
||||
if input[i] != '<' {
|
||||
out.WriteByte(input[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
end := strings.IndexByte(input[i:], '>')
|
||||
if end == -1 {
|
||||
out.WriteByte(input[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
end += i
|
||||
tag := input[i+1 : end]
|
||||
if tag == "" {
|
||||
out.WriteString(input[i : end+1])
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
|
||||
isClosing := tag[0] == '/'
|
||||
tagName := tag
|
||||
if isClosing {
|
||||
tagName = tag[1:]
|
||||
}
|
||||
if space := strings.IndexByte(tagName, ' '); space != -1 {
|
||||
tagName = tagName[:space]
|
||||
}
|
||||
tagName = strings.TrimSpace(tagName)
|
||||
|
||||
if !allowed[tagName] {
|
||||
out.WriteString(input[i : end+1])
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if isClosing {
|
||||
if len(stack) > 0 && stack[len(stack)-1] == tagName {
|
||||
stack = stack[:len(stack)-1]
|
||||
out.WriteString(input[i : end+1])
|
||||
}
|
||||
} else {
|
||||
stack = append(stack, tagName)
|
||||
out.WriteString(input[i : end+1])
|
||||
}
|
||||
|
||||
i = end + 1
|
||||
}
|
||||
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
out.WriteString("</")
|
||||
out.WriteString(stack[i])
|
||||
out.WriteString(">")
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// EscapeHTML escapes HTML special characters for safe output.
|
||||
func EscapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
|
||||
func SanitizeHTML(input string) string {
|
||||
return normalizeHTMLTags(input)
|
||||
}
|
||||
175
tg_bot/screens/ui/pagination.go
Normal file
175
tg_bot/screens/ui/pagination.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// PaginationConfig конфигурация для построения навигационного ряда пагинации
|
||||
type PaginationConfig struct {
|
||||
CurrentPage int
|
||||
TotalPages int // Для API пагинации (из page.Pages)
|
||||
TotalItems int // Для локальной пагинации (len(array))
|
||||
ItemsPerPage int // Для расчета TotalPages из TotalItems
|
||||
PrevCallback string // По умолчанию "prev"
|
||||
NextCallback string // По умолчанию "next"
|
||||
MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, "+ Добавить")
|
||||
}
|
||||
|
||||
// BuildNavigationRow создает навигационный ряд с динамической шириной.
|
||||
// Показывает ряд только если страниц > 1.
|
||||
// Возвращает nil если пагинация не нужна.
|
||||
func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton {
|
||||
// Вычисляем TotalPages из TotalItems если не задан
|
||||
totalPages := config.TotalPages
|
||||
if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 {
|
||||
totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage)
|
||||
}
|
||||
|
||||
// Устанавливаем значения по умолчанию для callbacks
|
||||
prevCallback := config.PrevCallback
|
||||
if prevCallback == "" {
|
||||
prevCallback = "prev"
|
||||
}
|
||||
nextCallback := config.NextCallback
|
||||
if nextCallback == "" {
|
||||
nextCallback = "next"
|
||||
}
|
||||
|
||||
var row []echotron.InlineKeyboardButton
|
||||
|
||||
// Левая стрелка (только если есть предыдущая страница)
|
||||
if totalPages > 1 && config.CurrentPage > 0 {
|
||||
row = append(row, echotron.InlineKeyboardButton{
|
||||
Text: "←",
|
||||
CallbackData: prevCallback,
|
||||
})
|
||||
}
|
||||
|
||||
// Средние кнопки (если есть)
|
||||
if len(config.MiddleButtons) > 0 {
|
||||
row = append(row, config.MiddleButtons...)
|
||||
}
|
||||
|
||||
// Правая стрелка (только если есть следующая страница)
|
||||
if totalPages > 1 && config.CurrentPage+1 < totalPages {
|
||||
row = append(row, echotron.InlineKeyboardButton{
|
||||
Text: "→",
|
||||
CallbackData: nextCallback,
|
||||
})
|
||||
}
|
||||
|
||||
if len(row) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// CalculatePages вычисляет количество страниц из общего количества элементов
|
||||
func CalculatePages(totalItems, itemsPerPage int) int {
|
||||
if itemsPerPage <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (totalItems + itemsPerPage - 1) / itemsPerPage
|
||||
}
|
||||
|
||||
// GetPageBounds возвращает start/end индексы для текущей страницы.
|
||||
// Автоматически сбрасывает на первую страницу если currentPage выходит за границы.
|
||||
func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) {
|
||||
start = currentPage * itemsPerPage
|
||||
if start >= totalItems {
|
||||
start = 0
|
||||
}
|
||||
end = start + itemsPerPage
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// FormatPageInfo форматирует текст индикатора страницы для заголовка
|
||||
func FormatPageInfo(currentPage, totalPages int) string {
|
||||
if totalPages <= 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" <i>(стр. %d/%d)</i>", currentPage+1, totalPages)
|
||||
}
|
||||
|
||||
// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией
|
||||
type ElementLayoutConfig struct {
|
||||
CurrentPage int // Текущая страница (0-indexed)
|
||||
ItemsPerPage int // Элементов на странице
|
||||
ItemsPerRow int // Элементов в одном ряду
|
||||
EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ")
|
||||
}
|
||||
|
||||
// BuildElementRows автоматически раскладывает элементы с пагинацией.
|
||||
// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой.
|
||||
// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
|
||||
// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру.
|
||||
//
|
||||
// Пример использования:
|
||||
//
|
||||
// config := ui.ElementLayoutConfig{
|
||||
// CurrentPage: 0,
|
||||
// ItemsPerPage: 6,
|
||||
// ItemsPerRow: 2,
|
||||
// }
|
||||
// rows := ui.BuildElementRows(config, allButtons)
|
||||
// keyboard = append(keyboard, rows...)
|
||||
func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
|
||||
if len(allItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Параметры по умолчанию
|
||||
if config.ItemsPerPage <= 0 {
|
||||
config.ItemsPerPage = 6
|
||||
}
|
||||
if config.ItemsPerRow <= 0 {
|
||||
config.ItemsPerRow = 2
|
||||
}
|
||||
|
||||
// Кнопка-заполнитель по умолчанию
|
||||
emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
|
||||
if config.EmptyButton != nil {
|
||||
emptyButton = *config.EmptyButton
|
||||
}
|
||||
|
||||
// Вычисляем границы страницы
|
||||
totalPages := CalculatePages(len(allItems), config.ItemsPerPage)
|
||||
start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems))
|
||||
|
||||
// Получаем элементы текущей страницы
|
||||
pageItems := allItems[start:end]
|
||||
|
||||
// Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
|
||||
// Это обеспечивает одинаковую высоту клавиатуры на всех страницах
|
||||
if totalPages >= 2 {
|
||||
for len(pageItems) < config.ItemsPerPage {
|
||||
pageItems = append(pageItems, emptyButton)
|
||||
}
|
||||
}
|
||||
|
||||
// Раскладываем элементы по рядам
|
||||
var rows [][]echotron.InlineKeyboardButton
|
||||
for i := 0; i < len(pageItems); i += config.ItemsPerRow {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
|
||||
// Добавляем элементы в ряд
|
||||
for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ {
|
||||
row = append(row, pageItems[i+j])
|
||||
}
|
||||
|
||||
// Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми
|
||||
for len(row) < config.ItemsPerRow {
|
||||
row = append(row, emptyButton)
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
Reference in New Issue
Block a user