From: PhiTux Date: Sat, 11 Oct 2025 20:36:58 +0000 (+0200) Subject: cleanup of go code X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=8784ce8e47484916c7e6d3cdec81bdd5fa616533;p=DailyTxT.git cleanup of go code --- diff --git a/README.md b/README.md index f345567..d73e585 100644 --- a/README.md +++ b/README.md @@ -134,17 +134,17 @@ When all "old" users have logged in once (and it is ensured, that all data has b ## About encryption and data storage -For encryption I use ChaCha20-Poly1305. +For encryption ChaCha20-Poly1305 is used. -When a user logs in, a key is derived from his password with Argon2id, it is called the *derived key*. This key is used to decrypt the user's *encryption key* (which is randomly generated when the user is created). The *derived key* is stored in a http-only cookie and send on every API-call. This *encryption key* is used to encrypt/decrypt all data of this user (entries and uploaded files) and never leaves the server. +When a user logs in, a key is derived from his password with Argon2id, it is called the *derived key*. The *derived key* is stored in a http-only cookie and send on every API-call. This key is used to decrypt the user's *encryption key* (which is randomly generated when the user is created). The *encryption key* is used to encrypt/decrypt all data of this user (entries and uploaded files) and never leaves the server. When a user changes his password, the *encryption key* is decrypted with the old *derived key* and re-encrypted with a new *derived key* (derived from the new password). There is no E2E-encryption used on client-side, because the search-functionality would not work then. All data would have to be sent to client-side for searching. -There are also backup-keys available which can be used as a password-replacement. When they are created, they store the *derived key* encrypted with a random *backup key*. This *backup key* is shown to the user only once and has to be stored safely by him. When a user loses his password, he can use this *backup key* to decrypt the *derived key* and from that the *encryption key*. +There are also backup-keys available which can be used as a password-replacement. When they are created, they store the *derived key* encrypted with a random *backup key*. These *backup keys* are shown to the user only once and are to be stored safely by him. When a user loses his password, he can use this *backup key* to decrypt the *derived key* and from that the *encryption key*. -All data is stored in json-files. I do not use a database, because I want to guarantee highest portability and longterm availability of the data. +All data is stored in json-files. No database is used, because the main goal is to guarantee highest portability and longterm availability of the data. ## Changelog @@ -163,7 +163,7 @@ The old version 1 is moved to the [v1 branch](https://github.com/mk/git/DailyTxT ## Start developing -You need [Go](https://golang.org/) (at least version 1.20) and [Node.js](https://nodejs.org/) (at least version 24) installed. +You need [Go](https://golang.org/) (at least version 1.24) and [Node.js](https://nodejs.org/) (at least version 24) installed. ### Backend - `cd backend` diff --git a/backend/handlers/export.go b/backend/handlers/export.go new file mode 100644 index 0000000..42c0c2d --- /dev/null +++ b/backend/handlers/export.go @@ -0,0 +1,1117 @@ +package handlers + +import ( + "archive/zip" + "encoding/json" + "fmt" + htmlpkg "html" + "io" + "net/http" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/ast" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "github.com/phitux/dailytxt/backend/utils" +) + +// LogEntry represents a single diary entry for export +type LogEntry struct { + Year int + Month int + Day int + Text string + DateWritten string + Files []string + Tags []int +} + +type TranslationData struct { + Weekdays []string `json:"weekdays"` + DateFormat string `json:"dateFormat"` + DateFormatOrder string `json:"dateFormatOrder"` + UiElements struct { + ExportTitle string `json:"exportTitle"` + User string `json:"user"` + ExportedOn string `json:"exportedOn"` + ExportedOnFormat string `json:"exportedOnFormat"` + EntriesCount string `json:"entriesCount"` + Images string `json:"images"` + Files string `json:"files"` + Tags string `json:"tags"` + } `json:"uiElements"` +} + +// ExportData handles exporting user data +func ExportData(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters from URL + period := r.URL.Query().Get("period") + if period == "" { + http.Error(w, "Missing period parameter", http.StatusBadRequest) + return + } else if period != "periodAll" && period != "periodVariable" { + http.Error(w, "Invalid period parameter", http.StatusBadRequest) + return + } + + var startYear, startMonth, startDay, endYear, endMonth, endDay int + var err error + + if period == "periodVariable" { + startDate := r.URL.Query().Get("startDate") + if startDate != "" { + startParts := strings.Split(startDate, "-") + if len(startParts) != 3 { + http.Error(w, "Invalid startDate format", http.StatusBadRequest) + return + } + startYear, _ = strconv.Atoi(startParts[0]) + startMonth, _ = strconv.Atoi(startParts[1]) + startDay, _ = strconv.Atoi(startParts[2]) + } else { + http.Error(w, "Missing startDate parameter", http.StatusBadRequest) + return + } + + endDate := r.URL.Query().Get("endDate") + if endDate != "" { + endParts := strings.Split(endDate, "-") + if len(endParts) != 3 { + http.Error(w, "Invalid endDate format", http.StatusBadRequest) + return + } + endYear, _ = strconv.Atoi(endParts[0]) + endMonth, _ = strconv.Atoi(endParts[1]) + endDay, _ = strconv.Atoi(endParts[2]) + } else { + http.Error(w, "Missing endDate parameter", http.StatusBadRequest) + return + } + } + + imagesInHTML := r.URL.Query().Get("imagesInHTML") == "true" + + split := r.URL.Query().Get("split") + if split == "" { + http.Error(w, "Missing split parameter", http.StatusBadRequest) + return + } else if split != "month" && split != "year" && split != "aio" { + http.Error(w, "Invalid split parameter", http.StatusBadRequest) + return + } + + tagsInHTML := r.URL.Query().Get("tagsInHTML") == "true" + + translationsStr := r.URL.Query().Get("translations") + if translationsStr == "" { + http.Error(w, "Missing translations parameter", http.StatusBadRequest) + return + } + + var translations TranslationData + + if err := json.Unmarshal([]byte(translationsStr), &translations); err != nil { + http.Error(w, fmt.Sprintf("Error parsing translations: %v", err), http.StatusBadRequest) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Set response headers for ZIP download + var filename string + if period == "periodAll" { + filename = fmt.Sprintf("DailyTxT_export_%s_all_%s.zip", utils.GetUsernameByID(userID), time.Now().Format("2006-01-02")) + } else { + filename = fmt.Sprintf("DailyTxT_export_%s_%d-%02d-%02d_to_%d-%02d-%02d.zip", + utils.GetUsernameByID(userID), startYear, startMonth, startDay, + endYear, endMonth, endDay) + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + // Create ZIP writer + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + + // Collect all log entries for HTML generation + var allEntries []LogEntry + var yearlyEntries map[int][]LogEntry = make(map[int][]LogEntry) + var monthlyEntries map[string][]LogEntry = make(map[string][]LogEntry) + + // Track used filenames per directory to avoid conflicts + var usedFilenamesPerDay map[string]map[string]bool = make(map[string]map[string]bool) + + // Helper function to check if a date is within range + isDateInRange := func(year, month, day int) bool { + if period == "periodAll" { + return true + } + + // Create time objects for comparison + targetDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) + startDateObj := time.Date(startYear, time.Month(startMonth), startDay, 0, 0, 0, 0, time.UTC) + endDateObj := time.Date(endYear, time.Month(endMonth), endDay, 0, 0, 0, 0, time.UTC) + + return !targetDate.Before(startDateObj) && !targetDate.After(endDateObj) + } + + // Determine year range to scan + var minYear, maxYear int + if period == "periodAll" { + // For "periodAll", scan a reasonable range + minYear = 2010 + maxYear = time.Now().Year() + 1 + } else { + minYear = startYear + maxYear = endYear + } + + // Process each year in the range + for year := minYear; year <= maxYear; year++ { + startMonthLoop := 1 + endMonthLoop := 12 + + // Optimize month range for specific date ranges + if period == "periodVariable" { + if year == startYear { + startMonthLoop = startMonth + } + if year == endYear { + endMonthLoop = endMonth + } + } + + for month := startMonthLoop; month <= endMonthLoop; month++ { + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + continue // Skip months that don't exist + } + + days, ok := content["days"].([]any) + if !ok { + continue + } + + // Process each day in the month + for _, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok { + continue + } + + dayInt := int(dayNum) + + // Check if this specific day is within the date range + if !isDateInRange(year, month, dayInt) { + continue + } + + entry := LogEntry{ + Year: year, + Month: month, + Day: dayInt, + } + + // Decrypt text and date_written + if text, ok := day["text"].(string); ok && text != "" { + decryptedText, err := utils.DecryptText(text, encKey) + if err != nil { + utils.Logger.Printf("Error decrypting text for %d-%d-%d: %v", year, month, dayInt, err) + continue + } + entry.Text = decryptedText + + if dateWritten, ok := day["date_written"].(string); ok && dateWritten != "" { + decryptedDate, err := utils.DecryptText(dateWritten, encKey) + if err == nil { + entry.DateWritten = decryptedDate + } + } + } + + // Process files + if filesList, ok := day["files"].([]any); ok && len(filesList) > 0 { + for _, fileInterface := range filesList { + file, ok := fileInterface.(map[string]any) + if !ok { + continue + } + + fileID, ok := file["uuid_filename"].(string) + if !ok { + continue + } + + encFilename, ok := file["enc_filename"].(string) + if !ok { + continue + } + + // Decrypt filename + decryptedFilename, err := utils.DecryptText(encFilename, encKey) + if err != nil { + utils.Logger.Printf("Error decrypting filename %s: %v", fileID, err) + continue + } + + // Read and decrypt file content + fileContent, err := utils.ReadFile(userID, fileID) + if err != nil { + utils.Logger.Printf("Error reading file %s: %v", fileID, err) + continue + } + + decryptedContent, err := utils.DecryptFile(fileContent, encKey) + if err != nil { + utils.Logger.Printf("Error decrypting file %s: %v", fileID, err) + continue + } + + // Create unique filename to avoid conflicts in ZIP + dayKey := fmt.Sprintf("%d-%02d-%02d", year, month, dayInt) + if usedFilenamesPerDay[dayKey] == nil { + usedFilenamesPerDay[dayKey] = make(map[string]bool) + } + uniqueFilename := generateUniqueFilename(usedFilenamesPerDay[dayKey], decryptedFilename) + + // Add file to ZIP with unique filename + filePath := fmt.Sprintf("files/%d-%02d-%02d/%s", year, month, dayInt, uniqueFilename) + fileWriter, err := zipWriter.Create(filePath) + if err != nil { + utils.Logger.Printf("Error creating file in ZIP %s: %v", filePath, err) + continue + } + + _, err = fileWriter.Write(decryptedContent) + if err != nil { + utils.Logger.Printf("Error writing file to ZIP %s: %v", filePath, err) + continue + } + + entry.Files = append(entry.Files, uniqueFilename) + } + } + + // Add tags + if tags, ok := day["tags"].([]any); ok && len(tags) > 0 { + for _, tag := range tags { + if tagID, ok := tag.(float64); ok { + entry.Tags = append(entry.Tags, int(tagID)) + } + } + } + + // Add entry if it has content + if entry.Text != "" || len(entry.Files) > 0 || len(entry.Tags) > 0 { + allEntries = append(allEntries, entry) + + // Add to yearly collections + yearlyEntries[year] = append(yearlyEntries[year], entry) + + // Add to monthly collections + monthKey := fmt.Sprintf("%d-%02d", year, month) + monthlyEntries[monthKey] = append(monthlyEntries[monthKey], entry) + } + } + } + } + + // Create HTML files based on split preference + switch split { + case "month": + // Create one HTML per month + for monthKey, entries := range monthlyEntries { + if len(entries) > 0 { + htmlBytes, err := generateHTML(entries, userID, derivedKey, tagsInHTML, imagesInHTML, translations) + if err != nil { + utils.Logger.Printf("Error generating HTML for month %s: %v", monthKey, err) + } else { + fileName := fmt.Sprintf("DailyTxT_%s.html", monthKey) + htmlWriter, err := zipWriter.Create(fileName) + if err != nil { + utils.Logger.Printf("Error creating month HTML in ZIP %s: %v", fileName, err) + } else { + _, err = htmlWriter.Write(htmlBytes) + if err != nil { + utils.Logger.Printf("Error writing month HTML to ZIP %s: %v", fileName, err) + } + } + } + } + } + + case "year": + // Create one HTML per year + for year, entries := range yearlyEntries { + if len(entries) > 0 { + htmlBytes, err := generateHTML(entries, userID, derivedKey, tagsInHTML, imagesInHTML, translations) + if err != nil { + utils.Logger.Printf("Error generating HTML for year %d: %v", year, err) + } else { + fileName := fmt.Sprintf("DailyTxT_%d.html", year) + htmlWriter, err := zipWriter.Create(fileName) + if err != nil { + utils.Logger.Printf("Error creating year HTML in ZIP %s: %v", fileName, err) + } else { + _, err = htmlWriter.Write(htmlBytes) + if err != nil { + utils.Logger.Printf("Error writing year HTML to ZIP %s: %v", fileName, err) + } + } + } + } + } + + case "aio": + // Create one single HTML with all entries + if len(allEntries) > 0 { + htmlBytes, err := generateHTML(allEntries, userID, derivedKey, tagsInHTML, imagesInHTML, translations) + if err != nil { + utils.Logger.Printf("Error generating HTML: %v", err) + } else { + // Add HTML to ZIP + htmlWriter, err := zipWriter.Create("DailyTxT_export.html") + if err != nil { + utils.Logger.Printf("Error creating HTML in ZIP: %v", err) + } else { + _, err = htmlWriter.Write(htmlBytes) + if err != nil { + utils.Logger.Printf("Error writing HTML to ZIP: %v", err) + } + } + } + } + } +} + +// generateHTML creates an HTML document with all diary entries +func generateHTML(entries []LogEntry, userID int, derivedKey string, includeTags bool, includeImages bool, translations TranslationData) ([]byte, error) { + // Load and decrypt tags if needed + var tagMap map[int]Tag + if includeTags { + var err error + tagMap, err = loadAndDecryptTags(userID, derivedKey) + if err != nil { + utils.Logger.Printf("Warning: Could not load tags for HTML export: %v", err) + tagMap = make(map[int]Tag) // Use empty map if loading fails + } + } + + // Sort entries by date (year, month, day) + sort.Slice(entries, func(i, j int) bool { + if entries[i].Year != entries[j].Year { + return entries[i].Year < entries[j].Year + } + if entries[i].Month != entries[j].Month { + return entries[i].Month < entries[j].Month + } + return entries[i].Day < entries[j].Day + }) + + var html strings.Builder + + // HTML header with embedded CSS + html.WriteString(` + + + + + DailyTxT Export + + + +`) + + // Header + html.WriteString(fmt.Sprintf(`
+

%s

`, translations.UiElements.ExportTitle)) + html.WriteString(fmt.Sprintf(`

%s: %s

`, translations.UiElements.User, utils.GetUsernameByID(userID))) + html.WriteString(fmt.Sprintf(`

%s: %s

`, translations.UiElements.ExportedOn, time.Now().Format(translations.UiElements.ExportedOnFormat))) + html.WriteString(fmt.Sprintf(`

%s: %d

`, translations.UiElements.EntriesCount, len(entries))) + html.WriteString(`
+`) + + // Process entries + for _, entry := range entries { + html.WriteString(`
+`) + + // Date header with weekday + date := time.Date(entry.Year, time.Month(entry.Month), entry.Day, 0, 0, 0, 0, time.UTC) + weekday := translations.Weekdays[date.Weekday()] + dateStr := translations.DateFormat + dateStr = strings.ReplaceAll(dateStr, "%W", weekday) // Wochentag + dateStr = strings.ReplaceAll(dateStr, "%D", fmt.Sprintf("%02d", entry.Day)) // Tag mit führender Null + dateStr = strings.ReplaceAll(dateStr, "%M", fmt.Sprintf("%02d", entry.Month)) // Monat mit führender Null + dateStr = strings.ReplaceAll(dateStr, "%Y", fmt.Sprintf("%d", entry.Year)) // Jahr + html.WriteString(fmt.Sprintf(` +`, htmlpkg.EscapeString(dateStr))) + + html.WriteString(`
+`) + + // Entry text + if entry.Text != "" { + // Decode HTML entities and render markdown + text := htmlpkg.UnescapeString(entry.Text) + text = renderMarkdownToHTML(text) + html.WriteString(fmt.Sprintf(`
%s
+`, text)) + } + + // Images (if enabled and images exist) + if includeImages && len(entry.Files) > 0 { + imageFiles := make([]string, 0) + for _, file := range entry.Files { + // Check if file is an image (simple check by extension) + ext := strings.ToLower(filepath.Ext(file)) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" { + imageFiles = append(imageFiles, file) + } + } + + if len(imageFiles) > 0 { + html.WriteString(fmt.Sprintf(`
+

%s

+ +
+`) + } + } + + // Tags + if includeTags && len(entry.Tags) > 0 { + html.WriteString(fmt.Sprintf(` +`) + } + + // Files + if len(entry.Files) > 0 { + html.WriteString(fmt.Sprintf(`
+

%s

+
    +`, translations.UiElements.Files)) + for _, file := range entry.Files { + filePath := fmt.Sprintf("files/%d-%02d-%02d/%s", entry.Year, entry.Month, entry.Day, file) + html.WriteString(fmt.Sprintf(`
  • %s
  • +`, htmlpkg.EscapeString(filePath), htmlpkg.EscapeString(file))) + } + html.WriteString(`
+
+`) + } + + html.WriteString(`
+
+`) + } + + html.WriteString(` + + + + + + + +`) + + return []byte(html.String()), nil +} + +// Tag represents a decrypted tag +type Tag struct { + ID int `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Color string `json:"color"` +} + +// loadAndDecryptTags loads and decrypts user tags +func loadAndDecryptTags(userID int, derivedKey string) (map[int]Tag, error) { + content, err := utils.GetTags(userID) + if err != nil { + return nil, err + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + return nil, err + } + + tagMap := make(map[int]Tag) + + if tags, ok := content["tags"].([]any); ok { + for _, tagInterface := range tags { + if tagData, ok := tagInterface.(map[string]any); ok { + if tagID, ok := tagData["id"].(float64); ok { + tag := Tag{ID: int(tagID)} + + // Decrypt name + if encName, ok := tagData["name"].(string); ok { + if decryptedName, err := utils.DecryptText(encName, encKey); err == nil { + tag.Name = decryptedName + } + } + + // Decrypt icon + if encIcon, ok := tagData["icon"].(string); ok { + if decryptedIcon, err := utils.DecryptText(encIcon, encKey); err == nil { + tag.Icon = decryptedIcon + } + } + + // Decrypt color + if encColor, ok := tagData["color"].(string); ok { + if decryptedColor, err := utils.DecryptText(encColor, encKey); err == nil { + tag.Color = decryptedColor + } + } + + tagMap[int(tagID)] = tag + } + } + } + } + + return tagMap, nil +} + +// generateUniqueFilename creates a unique filename by appending (2), (3), etc. if conflicts exist +func generateUniqueFilename(usedFilenames map[string]bool, originalFilename string) string { + if !usedFilenames[originalFilename] { + usedFilenames[originalFilename] = true + return originalFilename + } + + // Extract file extension + ext := filepath.Ext(originalFilename) + nameWithoutExt := strings.TrimSuffix(originalFilename, ext) + + // Try appending (2), (3), etc. + counter := 2 + for { + newFilename := fmt.Sprintf("%s (%d)%s", nameWithoutExt, counter, ext) + if !usedFilenames[newFilename] { + usedFilenames[newFilename] = true + return newFilename + } + counter++ + } +} + +// renderMarkdownToHTML converts markdown to HTML using gomarkdown library +func renderMarkdownToHTML(text string) string { + // Create parser with extensions including hard line breaks + extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.HardLineBreak + p := parser.NewWithExtensions(extensions) + + // Create HTML renderer with options for Prism.js compatibility + htmlFlags := html.CommonFlags | html.HrefTargetBlank + opts := html.RendererOptions{ + Flags: htmlFlags, + RenderNodeHook: func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { + if codeBlock, ok := node.(*ast.CodeBlock); ok && entering { + lang := string(codeBlock.Info) + if lang == "" { + lang = "none" + } + + // Write opening tag with Prism.js class + fmt.Fprintf(w, `
`, lang)
+
+				// Write escaped content
+				content := string(codeBlock.Literal)
+				html.EscapeHTML(w, []byte(content))
+
+				// Write closing tag
+				w.Write([]byte("
")) + return ast.GoToNext, true + } + return ast.GoToNext, false + }, + } + renderer := html.NewRenderer(opts) + + // Parse and render + md := []byte(text) + htmlBytes := markdown.ToHTML(md, p, renderer) + + return string(htmlBytes) +} diff --git a/backend/handlers/search.go b/backend/handlers/search.go index b1a550b..b2d64cf 100644 --- a/backend/handlers/search.go +++ b/backend/handlers/search.go @@ -1,11 +1,7 @@ package handlers import ( - "archive/zip" - "encoding/json" "fmt" - htmlpkg "html" - "io" "net/http" "os" "path/filepath" @@ -13,12 +9,7 @@ import ( "sort" "strconv" "strings" - "time" - "github.com/gomarkdown/markdown" - "github.com/gomarkdown/markdown/ast" - "github.com/gomarkdown/markdown/html" - "github.com/gomarkdown/markdown/parser" "github.com/phitux/dailytxt/backend/utils" ) @@ -140,30 +131,6 @@ func SearchTag(w http.ResponseWriter, r *http.Request) { } } - // Sort results by date - /* - sort.Slice(results, func(i, j int) bool { - ri := results[i].(map[string]any) - rj := results[j].(map[string]any) - - yearI := ri["year"].(int) - yearJ := rj["year"].(int) - if yearI != yearJ { - return yearI < yearJ - } - - monthI := ri["month"].(int) - monthJ := rj["month"].(int) - if monthI != monthJ { - return monthI < monthJ - } - - dayI := ri["day"].(int) - dayJ := rj["day"].(int) - return dayI < dayJ - }) - */ - // Return results utils.JSONResponse(w, http.StatusOK, results) } @@ -339,8 +306,8 @@ func Search(w http.ResponseWriter, r *http.Request) { } } else if strings.Contains(searchString, "|") { // OR search - words := strings.Split(searchString, "|") - for _, word := range words { + words := strings.SplitSeq(searchString, "|") + for word := range words { wordTrimmed := strings.TrimSpace(word) if strings.Contains(strings.ToLower(decryptedText), strings.ToLower(wordTrimmed)) { context := getContext(decryptedText, wordTrimmed, false) @@ -443,1256 +410,3 @@ func Search(w http.ResponseWriter, r *http.Request) { // Return results utils.JSONResponse(w, http.StatusOK, results) } - -// LogEntry represents a single diary entry for export -type LogEntry struct { - Year int - Month int - Day int - Text string - DateWritten string - Files []string - Tags []int -} - -type TranslationData struct { - Weekdays []string `json:"weekdays"` - DateFormat string `json:"dateFormat"` - DateFormatOrder string `json:"dateFormatOrder"` - UiElements struct { - ExportTitle string `json:"exportTitle"` - User string `json:"user"` - ExportedOn string `json:"exportedOn"` - ExportedOnFormat string `json:"exportedOnFormat"` - EntriesCount string `json:"entriesCount"` - Images string `json:"images"` - Files string `json:"files"` - Tags string `json:"tags"` - } `json:"uiElements"` -} - -// ExportData handles exporting user data -func ExportData(w http.ResponseWriter, r *http.Request) { - // Get user ID and derived key from context - userID, ok := r.Context().Value(utils.UserIDKey).(int) - if !ok { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) - if !ok { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Get parameters from URL - period := r.URL.Query().Get("period") - if period == "" { - http.Error(w, "Missing period parameter", http.StatusBadRequest) - return - } else if period != "periodAll" && period != "periodVariable" { - http.Error(w, "Invalid period parameter", http.StatusBadRequest) - return - } - - var startYear, startMonth, startDay, endYear, endMonth, endDay int - var err error - - if period == "periodVariable" { - startDate := r.URL.Query().Get("startDate") - if startDate != "" { - startParts := strings.Split(startDate, "-") - if len(startParts) != 3 { - http.Error(w, "Invalid startDate format", http.StatusBadRequest) - return - } - startYear, _ = strconv.Atoi(startParts[0]) - startMonth, _ = strconv.Atoi(startParts[1]) - startDay, _ = strconv.Atoi(startParts[2]) - } else { - http.Error(w, "Missing startDate parameter", http.StatusBadRequest) - return - } - - endDate := r.URL.Query().Get("endDate") - if endDate != "" { - endParts := strings.Split(endDate, "-") - if len(endParts) != 3 { - http.Error(w, "Invalid endDate format", http.StatusBadRequest) - return - } - endYear, _ = strconv.Atoi(endParts[0]) - endMonth, _ = strconv.Atoi(endParts[1]) - endDay, _ = strconv.Atoi(endParts[2]) - } else { - http.Error(w, "Missing endDate parameter", http.StatusBadRequest) - return - } - } - - imagesInHTML := r.URL.Query().Get("imagesInHTML") == "true" - - split := r.URL.Query().Get("split") - if split == "" { - http.Error(w, "Missing split parameter", http.StatusBadRequest) - return - } else if split != "month" && split != "year" && split != "aio" { - http.Error(w, "Invalid split parameter", http.StatusBadRequest) - return - } - - tagsInHTML := r.URL.Query().Get("tagsInHTML") == "true" - - translationsStr := r.URL.Query().Get("translations") - if translationsStr == "" { - http.Error(w, "Missing translations parameter", http.StatusBadRequest) - return - } - - var translations TranslationData - - if err := json.Unmarshal([]byte(translationsStr), &translations); err != nil { - http.Error(w, fmt.Sprintf("Error parsing translations: %v", err), http.StatusBadRequest) - return - } - - // Get encryption key - encKey, err := utils.GetEncryptionKey(userID, derivedKey) - if err != nil { - http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) - return - } - - // Set response headers for ZIP download - var filename string - if period == "periodAll" { - filename = fmt.Sprintf("DailyTxT_export_%s_all_%s.zip", utils.GetUsernameByID(userID), time.Now().Format("2006-01-02")) - } else { - filename = fmt.Sprintf("DailyTxT_export_%s_%d-%02d-%02d_to_%d-%02d-%02d.zip", - utils.GetUsernameByID(userID), startYear, startMonth, startDay, - endYear, endMonth, endDay) - } - - w.Header().Set("Content-Type", "application/zip") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - - // Create ZIP writer - zipWriter := zip.NewWriter(w) - defer zipWriter.Close() - - // Collect all log entries for HTML generation - var allEntries []LogEntry - var yearlyEntries map[int][]LogEntry = make(map[int][]LogEntry) - var monthlyEntries map[string][]LogEntry = make(map[string][]LogEntry) - - // Track used filenames per directory to avoid conflicts - var usedFilenamesPerDay map[string]map[string]bool = make(map[string]map[string]bool) - - // Helper function to check if a date is within range - isDateInRange := func(year, month, day int) bool { - if period == "periodAll" { - return true - } - - // Create time objects for comparison - targetDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) - startDateObj := time.Date(startYear, time.Month(startMonth), startDay, 0, 0, 0, 0, time.UTC) - endDateObj := time.Date(endYear, time.Month(endMonth), endDay, 0, 0, 0, 0, time.UTC) - - return !targetDate.Before(startDateObj) && !targetDate.After(endDateObj) - } - - // Determine year range to scan - var minYear, maxYear int - if period == "periodAll" { - // For "periodAll", scan a reasonable range - minYear = 2010 - maxYear = time.Now().Year() + 1 - } else { - minYear = startYear - maxYear = endYear - } - - // Process each year in the range - for year := minYear; year <= maxYear; year++ { - startMonthLoop := 1 - endMonthLoop := 12 - - // Optimize month range for specific date ranges - if period == "periodVariable" { - if year == startYear { - startMonthLoop = startMonth - } - if year == endYear { - endMonthLoop = endMonth - } - } - - for month := startMonthLoop; month <= endMonthLoop; month++ { - // Get month data - content, err := utils.GetMonth(userID, year, month) - if err != nil { - continue // Skip months that don't exist - } - - days, ok := content["days"].([]any) - if !ok { - continue - } - - // Process each day in the month - for _, dayInterface := range days { - day, ok := dayInterface.(map[string]any) - if !ok { - continue - } - - dayNum, ok := day["day"].(float64) - if !ok { - continue - } - - dayInt := int(dayNum) - - // Check if this specific day is within the date range - if !isDateInRange(year, month, dayInt) { - continue - } - - entry := LogEntry{ - Year: year, - Month: month, - Day: dayInt, - } - - // Decrypt text and date_written - if text, ok := day["text"].(string); ok && text != "" { - decryptedText, err := utils.DecryptText(text, encKey) - if err != nil { - utils.Logger.Printf("Error decrypting text for %d-%d-%d: %v", year, month, dayInt, err) - continue - } - entry.Text = decryptedText - - if dateWritten, ok := day["date_written"].(string); ok && dateWritten != "" { - decryptedDate, err := utils.DecryptText(dateWritten, encKey) - if err == nil { - entry.DateWritten = decryptedDate - } - } - } - - // Process files - if filesList, ok := day["files"].([]any); ok && len(filesList) > 0 { - for _, fileInterface := range filesList { - file, ok := fileInterface.(map[string]any) - if !ok { - continue - } - - fileID, ok := file["uuid_filename"].(string) - if !ok { - continue - } - - encFilename, ok := file["enc_filename"].(string) - if !ok { - continue - } - - // Decrypt filename - decryptedFilename, err := utils.DecryptText(encFilename, encKey) - if err != nil { - utils.Logger.Printf("Error decrypting filename %s: %v", fileID, err) - continue - } - - // Read and decrypt file content - fileContent, err := utils.ReadFile(userID, fileID) - if err != nil { - utils.Logger.Printf("Error reading file %s: %v", fileID, err) - continue - } - - decryptedContent, err := utils.DecryptFile(fileContent, encKey) - if err != nil { - utils.Logger.Printf("Error decrypting file %s: %v", fileID, err) - continue - } - - // Create unique filename to avoid conflicts in ZIP - dayKey := fmt.Sprintf("%d-%02d-%02d", year, month, dayInt) - if usedFilenamesPerDay[dayKey] == nil { - usedFilenamesPerDay[dayKey] = make(map[string]bool) - } - uniqueFilename := generateUniqueFilename(usedFilenamesPerDay[dayKey], decryptedFilename) - - // Add file to ZIP with unique filename - filePath := fmt.Sprintf("files/%d-%02d-%02d/%s", year, month, dayInt, uniqueFilename) - fileWriter, err := zipWriter.Create(filePath) - if err != nil { - utils.Logger.Printf("Error creating file in ZIP %s: %v", filePath, err) - continue - } - - _, err = fileWriter.Write(decryptedContent) - if err != nil { - utils.Logger.Printf("Error writing file to ZIP %s: %v", filePath, err) - continue - } - - entry.Files = append(entry.Files, uniqueFilename) - } - } - - // Add tags - if tags, ok := day["tags"].([]any); ok && len(tags) > 0 { - for _, tag := range tags { - if tagID, ok := tag.(float64); ok { - entry.Tags = append(entry.Tags, int(tagID)) - } - } - } - - // Add entry if it has content - if entry.Text != "" || len(entry.Files) > 0 || len(entry.Tags) > 0 { - allEntries = append(allEntries, entry) - - // Add to yearly collections - yearlyEntries[year] = append(yearlyEntries[year], entry) - - // Add to monthly collections - monthKey := fmt.Sprintf("%d-%02d", year, month) - monthlyEntries[monthKey] = append(monthlyEntries[monthKey], entry) - } - } - } - } - - // Create HTML files based on split preference - switch split { - case "month": - // Create one HTML per month - for monthKey, entries := range monthlyEntries { - if len(entries) > 0 { - htmlBytes, err := generateHTML(entries, userID, derivedKey, tagsInHTML, imagesInHTML, translations) - if err != nil { - utils.Logger.Printf("Error generating HTML for month %s: %v", monthKey, err) - } else { - fileName := fmt.Sprintf("DailyTxT_%s.html", monthKey) - htmlWriter, err := zipWriter.Create(fileName) - if err != nil { - utils.Logger.Printf("Error creating month HTML in ZIP %s: %v", fileName, err) - } else { - _, err = htmlWriter.Write(htmlBytes) - if err != nil { - utils.Logger.Printf("Error writing month HTML to ZIP %s: %v", fileName, err) - } - } - } - } - } - - case "year": - // Create one HTML per year - for year, entries := range yearlyEntries { - if len(entries) > 0 { - htmlBytes, err := generateHTML(entries, userID, derivedKey, tagsInHTML, imagesInHTML, translations) - if err != nil { - utils.Logger.Printf("Error generating HTML for year %d: %v", year, err) - } else { - fileName := fmt.Sprintf("DailyTxT_%d.html", year) - htmlWriter, err := zipWriter.Create(fileName) - if err != nil { - utils.Logger.Printf("Error creating year HTML in ZIP %s: %v", fileName, err) - } else { - _, err = htmlWriter.Write(htmlBytes) - if err != nil { - utils.Logger.Printf("Error writing year HTML to ZIP %s: %v", fileName, err) - } - } - } - } - } - - case "aio": - // Create one single HTML with all entries - if len(allEntries) > 0 { - htmlBytes, err := generateHTML(allEntries, userID, derivedKey, tagsInHTML, imagesInHTML, translations) - if err != nil { - utils.Logger.Printf("Error generating HTML: %v", err) - } else { - // Add HTML to ZIP - htmlWriter, err := zipWriter.Create("DailyTxT_export.html") - if err != nil { - utils.Logger.Printf("Error creating HTML in ZIP: %v", err) - } else { - _, err = htmlWriter.Write(htmlBytes) - if err != nil { - utils.Logger.Printf("Error writing HTML to ZIP: %v", err) - } - } - } - } - } -} - -// generateHTML creates an HTML document with all diary entries -func generateHTML(entries []LogEntry, userID int, derivedKey string, includeTags bool, includeImages bool, translations TranslationData) ([]byte, error) { - // Load and decrypt tags if needed - var tagMap map[int]Tag - if includeTags { - var err error - tagMap, err = loadAndDecryptTags(userID, derivedKey) - if err != nil { - utils.Logger.Printf("Warning: Could not load tags for HTML export: %v", err) - tagMap = make(map[int]Tag) // Use empty map if loading fails - } - } - - // Sort entries by date (year, month, day) - sort.Slice(entries, func(i, j int) bool { - if entries[i].Year != entries[j].Year { - return entries[i].Year < entries[j].Year - } - if entries[i].Month != entries[j].Month { - return entries[i].Month < entries[j].Month - } - return entries[i].Day < entries[j].Day - }) - - var html strings.Builder - - // HTML header with embedded CSS - html.WriteString(` - - - - - DailyTxT Export - - - -`) - - // Header - html.WriteString(fmt.Sprintf(`
-

%s

`, translations.UiElements.ExportTitle)) - html.WriteString(fmt.Sprintf(`

%s: %s

`, translations.UiElements.User, utils.GetUsernameByID(userID))) - html.WriteString(fmt.Sprintf(`

%s: %s

`, translations.UiElements.ExportedOn, time.Now().Format(translations.UiElements.ExportedOnFormat))) - html.WriteString(fmt.Sprintf(`

%s: %d

`, translations.UiElements.EntriesCount, len(entries))) - html.WriteString(`
-`) - - // Process entries - for _, entry := range entries { - html.WriteString(`
-`) - - // Date header with weekday - date := time.Date(entry.Year, time.Month(entry.Month), entry.Day, 0, 0, 0, 0, time.UTC) - weekday := translations.Weekdays[date.Weekday()] - dateStr := translations.DateFormat - dateStr = strings.ReplaceAll(dateStr, "%W", weekday) // Wochentag - dateStr = strings.ReplaceAll(dateStr, "%D", fmt.Sprintf("%02d", entry.Day)) // Tag mit führender Null - dateStr = strings.ReplaceAll(dateStr, "%M", fmt.Sprintf("%02d", entry.Month)) // Monat mit führender Null - dateStr = strings.ReplaceAll(dateStr, "%Y", fmt.Sprintf("%d", entry.Year)) // Jahr - html.WriteString(fmt.Sprintf(` -`, htmlpkg.EscapeString(dateStr))) - - html.WriteString(`
-`) - - // Entry text - if entry.Text != "" { - // Decode HTML entities and render markdown - text := htmlpkg.UnescapeString(entry.Text) - text = renderMarkdownToHTML(text) - html.WriteString(fmt.Sprintf(`
%s
-`, text)) - } - - // Images (if enabled and images exist) - if includeImages && len(entry.Files) > 0 { - imageFiles := make([]string, 0) - for _, file := range entry.Files { - // Check if file is an image (simple check by extension) - ext := strings.ToLower(filepath.Ext(file)) - if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" { - imageFiles = append(imageFiles, file) - } - } - - if len(imageFiles) > 0 { - html.WriteString(fmt.Sprintf(`
-

%s

- -
-`) - } - } - - // Tags - if includeTags && len(entry.Tags) > 0 { - html.WriteString(fmt.Sprintf(` -`) - } - - // Files - if len(entry.Files) > 0 { - html.WriteString(fmt.Sprintf(`
-

%s

-
    -`, translations.UiElements.Files)) - for _, file := range entry.Files { - filePath := fmt.Sprintf("files/%d-%02d-%02d/%s", entry.Year, entry.Month, entry.Day, file) - html.WriteString(fmt.Sprintf(`
  • %s
  • -`, htmlpkg.EscapeString(filePath), htmlpkg.EscapeString(file))) - } - html.WriteString(`
-
-`) - } - - html.WriteString(`
-
-`) - } - - html.WriteString(` - - - - - - - -`) - - return []byte(html.String()), nil -} - -// Tag represents a decrypted tag -type Tag struct { - ID int `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Color string `json:"color"` -} - -// loadAndDecryptTags loads and decrypts user tags -func loadAndDecryptTags(userID int, derivedKey string) (map[int]Tag, error) { - content, err := utils.GetTags(userID) - if err != nil { - return nil, err - } - - // Get encryption key - encKey, err := utils.GetEncryptionKey(userID, derivedKey) - if err != nil { - return nil, err - } - - tagMap := make(map[int]Tag) - - if tags, ok := content["tags"].([]any); ok { - for _, tagInterface := range tags { - if tagData, ok := tagInterface.(map[string]any); ok { - if tagID, ok := tagData["id"].(float64); ok { - tag := Tag{ID: int(tagID)} - - // Decrypt name - if encName, ok := tagData["name"].(string); ok { - if decryptedName, err := utils.DecryptText(encName, encKey); err == nil { - tag.Name = decryptedName - } - } - - // Decrypt icon - if encIcon, ok := tagData["icon"].(string); ok { - if decryptedIcon, err := utils.DecryptText(encIcon, encKey); err == nil { - tag.Icon = decryptedIcon - } - } - - // Decrypt color - if encColor, ok := tagData["color"].(string); ok { - if decryptedColor, err := utils.DecryptText(encColor, encKey); err == nil { - tag.Color = decryptedColor - } - } - - tagMap[int(tagID)] = tag - } - } - } - } - - return tagMap, nil -} - -// generateUniqueFilename creates a unique filename by appending (2), (3), etc. if conflicts exist -func generateUniqueFilename(usedFilenames map[string]bool, originalFilename string) string { - if !usedFilenames[originalFilename] { - usedFilenames[originalFilename] = true - return originalFilename - } - - // Extract file extension - ext := filepath.Ext(originalFilename) - nameWithoutExt := strings.TrimSuffix(originalFilename, ext) - - // Try appending (2), (3), etc. - counter := 2 - for { - newFilename := fmt.Sprintf("%s (%d)%s", nameWithoutExt, counter, ext) - if !usedFilenames[newFilename] { - usedFilenames[newFilename] = true - return newFilename - } - counter++ - } -} - -// renderMarkdownToHTML converts markdown to HTML using gomarkdown library -func renderMarkdownToHTML(text string) string { - // Create parser with extensions including hard line breaks - extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.HardLineBreak - p := parser.NewWithExtensions(extensions) - - // Create HTML renderer with options for Prism.js compatibility - htmlFlags := html.CommonFlags | html.HrefTargetBlank - opts := html.RendererOptions{ - Flags: htmlFlags, - RenderNodeHook: func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { - if codeBlock, ok := node.(*ast.CodeBlock); ok && entering { - lang := string(codeBlock.Info) - if lang == "" { - lang = "none" - } - - // Write opening tag with Prism.js class - fmt.Fprintf(w, `
`, lang)
-
-				// Write escaped content
-				content := string(codeBlock.Literal)
-				html.EscapeHTML(w, []byte(content))
-
-				// Write closing tag
-				w.Write([]byte("
")) - return ast.GoToNext, true - } - return ast.GoToNext, false - }, - } - renderer := html.NewRenderer(opts) - - // Parse and render - md := []byte(text) - htmlBytes := markdown.ToHTML(md, p, renderer) - - return string(htmlBytes) -} - -// Load user statistics: -// - each logged day with amount of words for each day -// - amount of files for each day -// - tags for each day -func GetStatistics(w http.ResponseWriter, r *http.Request) { - // Get user ID and derived key from context - userID, ok := r.Context().Value(utils.UserIDKey).(int) - if !ok { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) - if !ok { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Prepare encryption key for decrypting texts and filenames - encKey, err := utils.GetEncryptionKey(userID, derivedKey) - if err != nil { - http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) - return - } - - // Define response structure (per day only) - type DayStat struct { - Year int `json:"year"` - Month int `json:"month"` - Day int `json:"day"` - WordCount int `json:"wordCount"` - FileCount int `json:"fileCount"` - FileSizeBytes int64 `json:"fileSizeBytes"` - Tags []int `json:"tags"` - IsBookmarked bool `json:"isBookmarked"` - } - - dayStats := []DayStat{} - - // Get all years - years, err := utils.GetYears(userID) - if err != nil { - http.Error(w, fmt.Sprintf("Error retrieving years: %v", err), http.StatusInternalServerError) - return - } - - // Iterate years and months - for _, yearStr := range years { - yearInt, _ := strconv.Atoi(yearStr) - months, err := utils.GetMonths(userID, yearStr) - if err != nil { - continue // skip problematic year - } - for _, monthStr := range months { - monthInt, _ := strconv.Atoi(monthStr) - content, err := utils.GetMonth(userID, yearInt, monthInt) - if err != nil { - continue - } - daysArr, ok := content["days"].([]any) - if !ok { - continue - } - for _, dayInterface := range daysArr { - dayMap, ok := dayInterface.(map[string]any) - if !ok { - continue - } - dayNumFloat, ok := dayMap["day"].(float64) - if !ok { - continue - } - dayNum := int(dayNumFloat) - - // Word count (decrypt text if present) - wordCount := 0 - if encText, ok := dayMap["text"].(string); ok && encText != "" { - if decrypted, err := utils.DecryptText(encText, encKey); err == nil { - // Count words using Fields (splits on any whitespace) - words := strings.Fields(decrypted) - wordCount = len(words) - } - } - - // File count and total size - fileCount := 0 - var totalFileSize int64 = 0 - if filesAny, ok := dayMap["files"].([]any); ok { - fileCount = len(filesAny) - // Calculate total file size for this day - for _, fileInterface := range filesAny { - if fileMap, ok := fileInterface.(map[string]any); ok { - if sizeAny, ok := fileMap["size"]; ok { - // Handle both int64 and float64 types - switch size := sizeAny.(type) { - case int64: - totalFileSize += size - case float64: - totalFileSize += int64(size) - case int: - totalFileSize += int64(size) - } - } - } - } - } - - // Tags (IDs are numeric) - var tagIDs []int - if tagsAny, ok := dayMap["tags"].([]any); ok { - for _, t := range tagsAny { - if tf, ok := t.(float64); ok { - tagIDs = append(tagIDs, int(tf)) - } - } - } - - // Bookmark flag - isBookmarked := false - if bmRaw, ok := dayMap["isBookmarked"]; ok { - if b, ok2 := bmRaw.(bool); ok2 { - isBookmarked = b - } else if f, ok2 := bmRaw.(float64); ok2 { // if stored as number - isBookmarked = f != 0 - } - } - - dayStats = append(dayStats, DayStat{ - Year: yearInt, - Month: monthInt, - Day: dayNum, - WordCount: wordCount, - FileCount: fileCount, - FileSizeBytes: totalFileSize, - Tags: tagIDs, - IsBookmarked: isBookmarked, - }) - } - } - } - - // Sort days by date descending (latest first) if desired; currently ascending by traversal. Keep ascending. - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(dayStats) -} - -// GetVersionInfo returns the current application version (public endpoint, no auth required) -func GetVersionInfo(w http.ResponseWriter, r *http.Request) { - latest_stable, latest_overall := utils.GetLatestVersion() - - utils.JSONResponse(w, http.StatusOK, map[string]string{ - "current_version": utils.GetVersion(), - "latest_stable_version": latest_stable, - "latest_overall_version": latest_overall, - }) -} diff --git a/backend/handlers/statistics.go b/backend/handlers/statistics.go new file mode 100644 index 0000000..b856538 --- /dev/null +++ b/backend/handlers/statistics.go @@ -0,0 +1,157 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/phitux/dailytxt/backend/utils" +) + +// Load user statistics: +// - each logged day with amount of words for each day +// - amount of files for each day +// - tags for each day +func GetStatistics(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Prepare encryption key for decrypting texts and filenames + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Define response structure (per day only) + type DayStat struct { + Year int `json:"year"` + Month int `json:"month"` + Day int `json:"day"` + WordCount int `json:"wordCount"` + FileCount int `json:"fileCount"` + FileSizeBytes int64 `json:"fileSizeBytes"` + Tags []int `json:"tags"` + IsBookmarked bool `json:"isBookmarked"` + } + + dayStats := []DayStat{} + + // Get all years + years, err := utils.GetYears(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving years: %v", err), http.StatusInternalServerError) + return + } + + // Iterate years and months + for _, yearStr := range years { + yearInt, _ := strconv.Atoi(yearStr) + months, err := utils.GetMonths(userID, yearStr) + if err != nil { + continue // skip problematic year + } + for _, monthStr := range months { + monthInt, _ := strconv.Atoi(monthStr) + content, err := utils.GetMonth(userID, yearInt, monthInt) + if err != nil { + continue + } + daysArr, ok := content["days"].([]any) + if !ok { + continue + } + for _, dayInterface := range daysArr { + dayMap, ok := dayInterface.(map[string]any) + if !ok { + continue + } + dayNumFloat, ok := dayMap["day"].(float64) + if !ok { + continue + } + dayNum := int(dayNumFloat) + + // Word count (decrypt text if present) + wordCount := 0 + if encText, ok := dayMap["text"].(string); ok && encText != "" { + if decrypted, err := utils.DecryptText(encText, encKey); err == nil { + // Count words using Fields (splits on any whitespace) + words := strings.Fields(decrypted) + wordCount = len(words) + } + } + + // File count and total size + fileCount := 0 + var totalFileSize int64 = 0 + if filesAny, ok := dayMap["files"].([]any); ok { + fileCount = len(filesAny) + // Calculate total file size for this day + for _, fileInterface := range filesAny { + if fileMap, ok := fileInterface.(map[string]any); ok { + if sizeAny, ok := fileMap["size"]; ok { + // Handle both int64 and float64 types + switch size := sizeAny.(type) { + case int64: + totalFileSize += size + case float64: + totalFileSize += int64(size) + case int: + totalFileSize += int64(size) + } + } + } + } + } + + // Tags (IDs are numeric) + var tagIDs []int + if tagsAny, ok := dayMap["tags"].([]any); ok { + for _, t := range tagsAny { + if tf, ok := t.(float64); ok { + tagIDs = append(tagIDs, int(tf)) + } + } + } + + // Bookmark flag + isBookmarked := false + if bmRaw, ok := dayMap["isBookmarked"]; ok { + if b, ok2 := bmRaw.(bool); ok2 { + isBookmarked = b + } else if f, ok2 := bmRaw.(float64); ok2 { // if stored as number + isBookmarked = f != 0 + } + } + + dayStats = append(dayStats, DayStat{ + Year: yearInt, + Month: monthInt, + Day: dayNum, + WordCount: wordCount, + FileCount: fileCount, + FileSizeBytes: totalFileSize, + Tags: tagIDs, + IsBookmarked: isBookmarked, + }) + } + } + } + + // Sort days by date descending (latest first) if desired; currently ascending by traversal. Keep ascending. + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dayStats) +} diff --git a/backend/main.go b/backend/main.go index 30ae34d..4415764 100644 --- a/backend/main.go +++ b/backend/main.go @@ -73,7 +73,7 @@ func main() { api := http.NewServeMux() // Public routes (no authentication required) - api.HandleFunc("GET /version", handlers.GetVersionInfo) + api.HandleFunc("GET /version", utils.GetVersionInfo) // Users api.HandleFunc("POST /users/login", handlers.Login) diff --git a/backend/utils/file_handling.go b/backend/utils/file_handling.go index 2f26504..54fda8a 100644 --- a/backend/utils/file_handling.go +++ b/backend/utils/file_handling.go @@ -11,10 +11,10 @@ import ( "sync" ) -// Mutexes für Dateizugriffe +// Mutexes for file operations var ( - UsersFileMutex sync.RWMutex // Für users.json - userSettingsMutex sync.RWMutex // Für Benutzereinstellungen + UsersFileMutex sync.RWMutex // For users.json + userSettingsMutex sync.RWMutex // FFor user settings ) // GetUsers retrieves the users from the users.json file diff --git a/backend/utils/helpers.go b/backend/utils/helpers.go index 5f9f031..0120daa 100644 --- a/backend/utils/helpers.go +++ b/backend/utils/helpers.go @@ -178,12 +178,12 @@ func InitSettings() error { } func GetAppSettings() AppSettings { - // dont't show secret - remove it! var tempSettings AppSettings data, _ := json.Marshal(Settings) json.Unmarshal(data, &tempSettings) + // dont't show secret - remove it! tempSettings.SecretToken = "" return tempSettings } @@ -469,7 +469,6 @@ type DockerHubTag struct { } type DockerHubTagsResponse struct { - //Count int `json:"count"` Results []DockerHubTag `json:"results"` } @@ -643,3 +642,14 @@ func GetLatestVersion() (string, string) { return latestStable, latestOverall } + +// GetVersionInfo returns the current application version (public endpoint, no auth required) +func GetVersionInfo(w http.ResponseWriter, r *http.Request) { + latest_stable, latest_overall := GetLatestVersion() + + JSONResponse(w, http.StatusOK, map[string]string{ + "current_version": GetVersion(), + "latest_stable_version": latest_stable, + "latest_overall_version": latest_overall, + }) +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index f53569d..61d6958 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -12,8 +12,6 @@ echo "Started backend (PID $BACKEND_PID)" # Edit some files to make dailytxt work on a subpath provided by BASE_PATH if [ -n "${BASE_PATH:-}" ]; then - echo "Configuring frontend for BASE_PATH: $BASE_PATH" - # remove leading and trailing slash if exists BASE_PATH="${BASE_PATH#/}" BASE_PATH="${BASE_PATH%/}"