v3.9.5: Добавлена возможность копирования желаний, исправлена замена изображений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user