v3.9.5: Добавлена возможность копирования желаний, исправлена замена изображений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s

This commit is contained in:
poignatov
2026-01-12 17:42:51 +03:00
parent 3cf3cd4edb
commit 705eb2400e
9 changed files with 509 additions and 162 deletions

View File

@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"html"
"io"
"log"
"math"
@@ -317,9 +318,10 @@ type UnlockConditionRequest struct {
}
type WishlistResponse struct {
Unlocked []WishlistItem `json:"unlocked"`
Locked []WishlistItem `json:"locked"`
Completed []WishlistItem `json:"completed,omitempty"`
Unlocked []WishlistItem `json:"unlocked"`
Locked []WishlistItem `json:"locked"`
Completed []WishlistItem `json:"completed,omitempty"`
CompletedCount int `json:"completed_count"` // Количество завершённых желаний
}
// ============================================
@@ -8480,6 +8482,7 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
// ============================================
// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента
// Считает напрямую из таблицы nodes, фильтруя по дате entries
func (a *App) calculateProjectPointsFromDate(
projectID int,
startDate sql.NullTime,
@@ -8488,39 +8491,32 @@ func (a *App) calculateProjectPointsFromDate(
var totalScore float64
var err error
// Обновляем materialized view перед запросом
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
}
if !startDate.Valid {
// За всё время
// За всё время - считаем все nodes этого пользователя для указанного проекта
err = a.DB.QueryRow(`
SELECT COALESCE(SUM(wr.total_score), 0)
FROM weekly_report_mv wr
JOIN projects p ON wr.project_id = p.id
WHERE wr.project_id = $1 AND p.user_id = $2
SELECT COALESCE(SUM(n.score), 0)
FROM nodes n
JOIN projects p ON n.project_id = p.id
WHERE n.project_id = $1 AND n.user_id = $2 AND p.user_id = $2
`, projectID, userID).Scan(&totalScore)
} else {
// С указанной даты до текущего момента
// Нужно найти все недели, которые попадают в диапазон от startDate до CURRENT_DATE
// Используем сравнение (year, week) >= (startDate_year, startDate_week)
// Считаем все nodes этого пользователя, где дата entry >= startDate
// Используем DATE() для сравнения только по дате (без времени)
err = a.DB.QueryRow(`
SELECT COALESCE(SUM(wr.total_score), 0)
FROM weekly_report_mv wr
JOIN projects p ON wr.project_id = p.id
WHERE wr.project_id = $1
SELECT COALESCE(SUM(n.score), 0)
FROM nodes n
JOIN entries e ON n.entry_id = e.id
JOIN projects p ON n.project_id = p.id
WHERE n.project_id = $1
AND n.user_id = $2
AND p.user_id = $2
AND (
wr.report_year > EXTRACT(ISOYEAR FROM $3)::INTEGER
OR (wr.report_year = EXTRACT(ISOYEAR FROM $3)::INTEGER
AND wr.report_week >= EXTRACT(WEEK FROM $3)::INTEGER)
)
AND DATE(e.created_date) >= DATE($3)
`, projectID, userID, startDate.Time).Scan(&totalScore)
}
if err != nil {
log.Printf("Error calculating project points from date: %v", err)
return 0, err
}
@@ -8805,9 +8801,14 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
if err == nil {
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
conditionMet = err == nil && totalScore >= requiredPoints
if err == nil {
if err != nil {
// Если ошибка при расчете, устанавливаем 0
zeroScore := 0.0
condition.CurrentPoints = &zeroScore
conditionMet = false
} else {
condition.CurrentPoints = &totalScore
conditionMet = totalScore >= requiredPoints
}
}
}
@@ -8857,7 +8858,11 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
if err == nil {
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
if err == nil {
if err != nil {
// Если ошибка при расчете, устанавливаем 0
zeroScore := 0.0
condition.CurrentPoints = &zeroScore
} else {
condition.CurrentPoints = &totalScore
}
}
@@ -9025,10 +9030,14 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
unlocked := make([]WishlistItem, 0)
locked := make([]WishlistItem, 0)
completed := make([]WishlistItem, 0)
completedCount := 0
for _, item := range items {
if item.Completed {
completed = append(completed, item)
completedCount++
if includeCompleted {
completed = append(completed, item)
}
} else if item.Unlocked {
unlocked = append(unlocked, item)
} else {
@@ -9074,9 +9083,10 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
})
response := WishlistResponse{
Unlocked: unlocked,
Locked: locked,
Completed: completed,
Unlocked: unlocked,
Locked: locked,
Completed: completed,
CompletedCount: completedCount,
}
w.Header().Set("Content-Type", "application/json")
@@ -9457,8 +9467,10 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request)
return
}
// Сохраняем как JPEG
filename := fmt.Sprintf("%d.jpg", wishlistID)
// Генерируем уникальное имя файла
randomBytes := make([]byte, 8)
rand.Read(randomBytes)
filename := fmt.Sprintf("%d_%x.jpg", wishlistID, randomBytes)
filepath := filepath.Join(uploadDir, filename)
dst, err := os.Create(filepath)
@@ -9477,7 +9489,7 @@ func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request)
return
}
// Обновляем путь в БД
// Обновляем путь в БД (уникальное имя файла уже обеспечивает сброс кэша)
imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename)
_, err = a.DB.Exec(`
UPDATE wishlist_items
@@ -9690,13 +9702,14 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
body := string(bodyBytes)
metadata := &LinkMetadataResponse{}
// Извлекаем Open Graph теги
ogTitleRe := regexp.MustCompile(`<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`)
ogTitleRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']`)
ogImageRe := regexp.MustCompile(`<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']`)
ogImageRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']`)
ogDescRe := regexp.MustCompile(`<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']`)
ogDescRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']`)
// Извлекаем Open Graph теги (более гибкие регулярные выражения с case-insensitive)
// Поддерживаем различные варианты: property/content, content/property, одинарные/двойные кавычки, пробелы
ogTitleRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogTitleRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`)
ogImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`)
ogDescRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`)
ogDescRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["']`)
// og:title
if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
@@ -9721,19 +9734,66 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
// Если нет og:title, пытаемся взять <title>
if metadata.Title == "" {
titleRe := regexp.MustCompile(`<title[^>]*>([^<]+)</title>`)
titleRe := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
}
}
// Если нет og:image, пытаемся найти другие meta теги для изображения
if metadata.Image == "" {
// Twitter Card image
twitterImageRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["']twitter:image["'][^>]*content\s*=\s*["']([^"']+)["']`)
if matches := twitterImageRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
} else {
twitterImageRe2 := regexp.MustCompile(`(?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']twitter:image["']`)
if matches := twitterImageRe2.FindStringSubmatch(body); len(matches) > 1 {
metadata.Image = strings.TrimSpace(matches[1])
}
}
}
// Пытаемся найти цену (Schema.org JSON-LD или типовые паттерны)
// Schema.org Product price
priceRe := regexp.MustCompile(`"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
priceStr := strings.ReplaceAll(matches[1], ",", ".")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
// Сначала ищем в JSON-LD (Schema.org)
jsonLdRe := regexp.MustCompile(`(?i)<script[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)</script>`)
jsonLdMatches := jsonLdRe.FindAllStringSubmatch(body, -1)
for _, match := range jsonLdMatches {
if len(match) > 1 {
jsonStr := match[1]
// Ищем цену в JSON-LD
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
if priceMatches := priceRe.FindStringSubmatch(jsonStr); len(priceMatches) > 1 {
priceStr := strings.ReplaceAll(priceMatches[1], ",", ".")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
break
}
}
}
}
// Если не нашли в JSON-LD, ищем в обычном HTML
if metadata.Price == nil {
priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
priceStr := strings.ReplaceAll(matches[1], ",", ".")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
}
}
}
// Также ищем цену в meta тегах (некоторые сайты используют это)
if metadata.Price == nil {
metaPriceRe := regexp.MustCompile(`(?i)<meta[^>]*(?:property|name)\s*=\s*["'](?:price|product:price)["'][^>]*content\s*=\s*["']([^"']+)["']`)
if matches := metaPriceRe.FindStringSubmatch(body); len(matches) > 1 {
priceStr := strings.ReplaceAll(strings.TrimSpace(matches[1]), ",", ".")
// Убираем валюту и лишние символы
priceStr = regexp.MustCompile(`[^\d.]`).ReplaceAllString(priceStr, "")
if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
metadata.Price = &price
}
}
}
@@ -9749,9 +9809,9 @@ func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request)
}
}
// Декодируем HTML entities
metadata.Title = decodeHTMLEntities(metadata.Title)
metadata.Description = decodeHTMLEntities(metadata.Description)
// Декодируем HTML entities (используем стандартную библиотеку)
metadata.Title = html.UnescapeString(metadata.Title)
metadata.Description = html.UnescapeString(metadata.Description)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metadata)