added feature to export data
authorPhiTux <redacted>
Sat, 16 Aug 2025 15:58:55 +0000 (17:58 +0200)
committerPhiTux <redacted>
Sat, 16 Aug 2025 15:58:55 +0000 (17:58 +0200)
backend/go.mod
backend/go.sum
backend/handlers/additional.go
backend/main.go
frontend/src/routes/(authed)/+layout.svelte

index 972486498541cd1a3cd04ddfc6600c291b89129c..36168cc6738d165476093208bd00a0ea7e832833 100644 (file)
@@ -4,11 +4,11 @@ go 1.24
 
 require (
        github.com/golang-jwt/jwt/v5 v5.2.0
+       github.com/google/uuid v1.6.0
        golang.org/x/crypto v0.19.0
 )
 
 require (
-       github.com/google/uuid v1.6.0 // indirect
-       github.com/gorilla/websocket v1.5.3 // indirect
+       github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a // indirect
        golang.org/x/sys v0.17.0 // indirect
 )
index 450ba8054b589ddfaaf163fffc1627f47f54647c..4c947f809cbd67867bc56bed07c791445e21ac8f 100644 (file)
@@ -1,9 +1,9 @@
 github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
 github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
+github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
index 31dfcc6b8f10b670f340fd447b1aaf4e334d4493..9be842fc444016e4a5f7106545ff3dad7e885cbe 100644 (file)
@@ -1,8 +1,10 @@
 package handlers
 
 import (
+       "archive/zip"
        "encoding/json"
        "fmt"
+       htmlpkg "html"
        "io"
        "net/http"
        "os"
@@ -11,7 +13,12 @@ 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"
 )
 
@@ -1504,3 +1511,1191 @@ 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
+}
+
+// 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"
+
+       // 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("diary_export_all_%s.zip", time.Now().Format("2006-01-02"))
+       } else {
+               filename = fmt.Sprintf("diary_export_%d-%02d-%02d_to_%d-%02d-%02d.zip",
+                       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)
+
+       // 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
+                                               }
+
+                                               // Add file to ZIP
+                                               filePath := fmt.Sprintf("files/%d-%02d-%02d/%s", year, month, dayInt, decryptedFilename)
+                                               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, decryptedFilename)
+                                       }
+                               }
+
+                               // 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)
+                               if err != nil {
+                                       utils.Logger.Printf("Error generating HTML for month %s: %v", monthKey, err)
+                               } else {
+                                       fileName := fmt.Sprintf("diary_%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)
+                               if err != nil {
+                                       utils.Logger.Printf("Error generating HTML for year %d: %v", year, err)
+                               } else {
+                                       fileName := fmt.Sprintf("diary_%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)
+                       if err != nil {
+                               utils.Logger.Printf("Error generating HTML: %v", err)
+                       } else {
+                               // Add HTML to ZIP
+                               htmlWriter, err := zipWriter.Create("diary_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) ([]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(`<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>DailyTxT Export</title>
+    <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet">
+    <style>
+        body {
+            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+            line-height: 1.4;
+            max-width: 800px;
+            margin: 0 auto;
+            padding: 20px;
+            background-color: #f9f9f9;
+            color: #333;
+        }
+        .header {
+            text-align: center;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 30px;
+            border-radius: 10px;
+            margin-bottom: 30px;
+            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+        }
+        .header h1 {
+            margin: 0 0 10px 0;
+            font-size: 2.5em;
+            font-weight: 300;
+        }
+        .header p {
+            margin: 5px 0;
+            opacity: 0.9;
+        }
+        .entry {
+            background: white;
+            margin-bottom: 25px;
+            border-radius: 8px;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+            overflow: hidden;
+        }
+        .entry-date {
+            background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+            color: white;
+            padding: 15px 20px;
+            font-size: 1.2em;
+            font-weight: 600;
+        }
+        .entry-content {
+            padding: 20px;
+        }
+        .entry-text {
+            margin-bottom: 15px;
+            font-size: 1.1em;
+            line-height: 1.5;
+        }
+        .entry-files, .entry-tags, .entry-images {
+            margin-top: 15px;
+            padding-top: 15px;
+            border-top: 1px solid #eee;
+        }
+        .entry-files h4, .entry-tags h4, .entry-images h4 {
+            margin: 0 0 10px 0;
+            color: #666;
+            font-size: 0.9em;
+            text-transform: uppercase;
+            letter-spacing: 1px;
+        }
+        .file-list {
+            list-style: none;
+            padding: 0;
+        }
+        .file-list li {
+            background: #f8f9fa;
+            padding: 8px 12px;
+            margin: 5px 0;
+            border-radius: 4px;
+            border-left: 3px solid #007bff;
+        }
+        .tags {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 8px;
+        }
+        .tag {
+            background: #e9ecef;
+            color: #495057;
+            padding: 4px 8px;
+            border-radius: 12px;
+            font-size: 0.85em;
+            font-weight: 500;
+        }
+        .image-gallery {
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+            gap: 10px;
+            margin-top: 10px;
+        }
+        .image-item {
+            text-align: center;
+        }
+        .image-item img {
+            max-width: 100%;
+            height: auto;
+            border-radius: 4px;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+        }
+        .image-filename {
+            font-size: 0.8em;
+            color: #666;
+            margin-top: 5px;
+        }
+        /* Markdown formatting */
+        .entry-text h1, .entry-text h2, .entry-text h3, .entry-text h4, .entry-text h5, .entry-text h6 {
+            color: #2c3e50;
+            margin-top: 20px;
+            margin-bottom: 10px;
+        }
+        .entry-text h1 { font-size: 2em; }
+        .entry-text h2 { font-size: 1.5em; }
+        .entry-text h3 { font-size: 1.3em; }
+        .entry-text strong { font-weight: 600; color: #2c3e50; }
+        .entry-text em { font-style: italic; color: #555; }
+        .entry-text code {
+            background: #f8f9fa;
+            border: 1px solid #e9ecef;
+            border-radius: 3px;
+            padding: 2px 4px;
+            font-family: 'Courier New', monospace;
+            font-size: 0.9em;
+            color: #e83e8c;
+        }
+        .entry-text pre {
+            background: #f8f9fa;
+            border: 1px solid #e9ecef;
+            border-radius: 6px;
+            padding: 15px;
+            overflow-x: auto;
+            margin: 15px 0;
+            font-family: 'Fira Code', 'Courier New', monospace;
+            font-size: 0.9em;
+            line-height: 1.4;
+        }
+        .entry-text pre code {
+            background: none;
+            border: none;
+            padding: 0;
+            color: inherit;
+            font-size: inherit;
+        }
+        .entry-text p {
+            margin: 8px 0;
+            line-height: 1.5;
+        }
+        .entry-text br {
+            line-height: 0.8;
+        }
+        .entry-text ul, .entry-text ol {
+            margin: 10px 0;
+            padding-left: 20px;
+        }
+        .entry-text li {
+            margin: 4px 0;
+            line-height: 1.4;
+        }
+        .entry-text blockquote {
+            border-left: 4px solid #007bff;
+            padding-left: 15px;
+            margin: 15px 0;
+            color: #666;
+            font-style: italic;
+        }
+        .entry-text a {
+            color: #007bff;
+            text-decoration: none;
+        }
+        .entry-text a:hover {
+            text-decoration: underline;
+        }
+        /* Custom Prism.js overrides for better code highlighting */
+        .token.comment,
+        .token.prolog,
+        .token.doctype,
+        .token.cdata {
+            color: #6a737d;
+        }
+        .token.punctuation {
+            color: #586e75;
+        }
+        .token.property,
+        .token.tag,
+        .token.constant,
+        .token.symbol,
+        .token.deleted {
+            color: #d73a49;
+        }
+        .token.boolean,
+        .token.number {
+            color: #005cc5;
+        }
+        .token.selector,
+        .token.attr-name,
+        .token.string,
+        .token.char,
+        .token.builtin,
+        .token.inserted {
+            color: #032f62;
+        }
+        .token.operator,
+        .token.entity,
+        .token.url,
+        .language-css .token.string,
+        .style .token.string,
+        .token.variable {
+            color: #e36209;
+        }
+        .token.atrule,
+        .token.attr-value,
+        .token.function,
+        .token.class-name {
+            color: #6f42c1;
+        }
+        .token.keyword {
+            color: #d73a49;
+        }
+            border-radius: 5px;
+            padding: 15px;
+            overflow-x: auto;
+            margin: 15px 0;
+        }
+        .entry-text pre code {
+            background: transparent;
+            border: none;
+            padding: 0;
+            font-size: 0.9em;
+            color: #333;
+            white-space: pre;
+        }
+        .entry-text a {
+            color: #007bff;
+            text-decoration: none;
+        }
+        .entry-text a:hover {
+            color: #0056b3;
+            text-decoration: underline;
+        }
+        .entry-text ul, .entry-text ol {
+            margin: 10px 0;
+            padding-left: 20px;
+        }
+        .entry-text li {
+            margin: 5px 0;
+        }
+        .entry-text blockquote {
+            border-left: 4px solid #007bff;
+            margin: 15px 0;
+            padding: 10px 15px;
+            background: #f8f9fa;
+            font-style: italic;
+        }
+        .entry-text p {
+            margin: 10px 0;
+        }
+        
+        /* Lightbox styles */
+        .lightbox {
+            display: none;
+            position: fixed;
+            z-index: 1000;
+            left: 0;
+            top: 0;
+            width: 100%;
+            height: 100%;
+            background-color: rgba(0, 0, 0, 0.9);
+            cursor: pointer;
+        }
+        .lightbox.active {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+        .lightbox-content {
+            position: relative;
+            max-width: 95vw;
+            max-height: 95vh;
+            cursor: default;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+        .lightbox-image {
+            max-width: 95vw;
+            max-height: 95vh;
+            width: auto;
+            height: auto;
+            object-fit: contain;
+        }
+        .lightbox-nav {
+            position: absolute;
+            top: 50%;
+            transform: translateY(-50%);
+            background: rgba(255, 255, 255, 0.8);
+            border: none;
+            font-size: 24px;
+            padding: 15px 20px;
+            cursor: pointer;
+            border-radius: 5px;
+            color: #333;
+            font-weight: bold;
+        }
+        .lightbox-nav:hover {
+            background: rgba(255, 255, 255, 1);
+        }
+        .lightbox-prev {
+            left: 20px;
+        }
+        .lightbox-next {
+            right: 20px;
+        }
+        .lightbox-close {
+            position: absolute;
+            top: 20px;
+            right: 20px;
+            background: rgba(255, 255, 255, 0.8);
+            border: none;
+            font-size: 24px;
+            padding: 10px 15px;
+            cursor: pointer;
+            border-radius: 5px;
+            color: #333;
+            font-weight: bold;
+        }
+        .lightbox-close:hover {
+            background: rgba(255, 255, 255, 1);
+        }
+        .image-item img {
+            cursor: pointer;
+        }
+        
+        @media print {
+            body {
+                background: white;
+                font-size: 12pt;
+            }
+            .entry {
+                box-shadow: none;
+                border: 1px solid #ddd;
+                break-inside: avoid;
+            }
+        }
+    </style>
+</head>
+<body>`)
+
+       // Header
+       html.WriteString(`    <div class="header">
+        <h1>DailyTxT Export</h1>`)
+       html.WriteString(fmt.Sprintf(`        <p>Benutzer ID: %d</p>`, userID))
+       html.WriteString(fmt.Sprintf(`        <p>Exportiert am: %s</p>`, time.Now().Format("02.01.2006 15:04:05")))
+       html.WriteString(fmt.Sprintf(`        <p>Anzahl Einträge: %d</p>`, len(entries)))
+       html.WriteString(`    </div>
+`)
+
+       // Process entries
+       for _, entry := range entries {
+               html.WriteString(`    <div class="entry">
+`)
+
+               // Date header with weekday
+               date := time.Date(entry.Year, time.Month(entry.Month), entry.Day, 0, 0, 0, 0, time.UTC)
+               weekdays := []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
+               weekday := weekdays[date.Weekday()]
+               dateStr := fmt.Sprintf("%s, %02d.%02d.%d", weekday, entry.Day, entry.Month, entry.Year)
+               html.WriteString(fmt.Sprintf(`        <div class="entry-date">%s</div>
+`, htmlpkg.EscapeString(dateStr)))
+
+               html.WriteString(`        <div class="entry-content">
+`)
+
+               // Entry text
+               if entry.Text != "" {
+                       // Decode HTML entities and render markdown
+                       text := htmlpkg.UnescapeString(entry.Text)
+                       text = renderMarkdownToHTML(text)
+                       html.WriteString(fmt.Sprintf(`            <div class="entry-text">%s</div>
+`, 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(`            <div class="entry-images">
+                <h4>Bilder</h4>
+                <div class="image-gallery">
+`)
+                               for _, imageFile := range imageFiles {
+                                       imagePath := fmt.Sprintf("files/%d-%02d-%02d/%s", entry.Year, entry.Month, entry.Day, imageFile)
+                                       html.WriteString(fmt.Sprintf(`                    <div class="image-item">
+                        <img src="%s" data-path="%s" alt="%s" loading="lazy" onclick="openLightbox('%s')">
+                        <div class="image-filename">%s</div>
+                    </div>
+`, htmlpkg.EscapeString(imagePath), htmlpkg.EscapeString(imagePath), htmlpkg.EscapeString(imageFile), htmlpkg.EscapeString(imagePath), htmlpkg.EscapeString(imageFile)))
+                               }
+                               html.WriteString(`                </div>
+            </div>
+`)
+                       }
+               }
+
+               // Tags
+               if includeTags && len(entry.Tags) > 0 {
+                       html.WriteString(`            <div class="entry-tags">
+                <h4>Tags</h4>
+                <div class="tags">
+`)
+                       for _, tagID := range entry.Tags {
+                               if tag, exists := tagMap[tagID]; exists {
+                                       // Use decrypted tag information
+                                       style := ""
+                                       if tag.Color != "" {
+                                               style = fmt.Sprintf(` style="background-color: %s; color: white;"`, htmlpkg.EscapeString(tag.Color))
+                                       }
+                                       html.WriteString(fmt.Sprintf(`                    <span class="tag"%s>%s #%s</span>
+`, style, htmlpkg.EscapeString(tag.Icon), htmlpkg.EscapeString(tag.Name)))
+                               } else {
+                                       // Fallback to ID if tag not found
+                                       html.WriteString(fmt.Sprintf(`                    <span class="tag">#%d</span>
+`, tagID))
+                               }
+                       }
+                       html.WriteString(`                </div>
+            </div>
+`)
+               }
+
+               // Files
+               if len(entry.Files) > 0 {
+                       html.WriteString(`            <div class="entry-files">
+                <h4>Dateien</h4>
+                <ul class="file-list">
+`)
+                       for _, file := range entry.Files {
+                               filePath := fmt.Sprintf("files/%d-%02d-%02d/%s", entry.Year, entry.Month, entry.Day, file)
+                               html.WriteString(fmt.Sprintf(`                    <li><a href="%s" target="_blank">%s</a></li>
+`, htmlpkg.EscapeString(filePath), htmlpkg.EscapeString(file)))
+                       }
+                       html.WriteString(`                </ul>
+            </div>
+`)
+               }
+
+               html.WriteString(`        </div>
+    </div>
+`)
+       }
+
+       html.WriteString(`
+    <!-- Lightbox for images -->
+    <div id="lightbox" class="lightbox" onclick="closeLightbox()">
+        <div class="lightbox-content" onclick="event.stopPropagation()">
+            <button class="lightbox-close" onclick="closeLightbox()">&times;</button>
+            <button class="lightbox-nav lightbox-prev" onclick="changeLightboxImage(-1)">&lt;</button>
+            <img id="lightbox-image" class="lightbox-image" src="" alt="">
+            <button class="lightbox-nav lightbox-next" onclick="changeLightboxImage(1)">&gt;</button>
+        </div>
+    </div>
+
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
+    <script>
+        // Enable automatic syntax highlighting
+        Prism.highlightAll();
+
+        // Lightbox functionality
+        let currentImageIndex = 0;
+        let allImages = [];
+
+        // Collect all images on page load
+        document.addEventListener('DOMContentLoaded', function() {
+            const images = document.querySelectorAll('.image-item img');
+            allImages = Array.from(images).map(img => img.dataset.path);
+        });
+
+        function openLightbox(imageSrc) {
+            currentImageIndex = allImages.indexOf(imageSrc);
+            if (currentImageIndex === -1) {
+                currentImageIndex = 0;
+                allImages = [imageSrc];
+            }
+            
+            document.getElementById('lightbox-image').src = imageSrc;
+            document.getElementById('lightbox').classList.add('active');
+            
+            // Hide navigation buttons if only one image
+            const prevBtn = document.querySelector('.lightbox-prev');
+            const nextBtn = document.querySelector('.lightbox-next');
+            if (allImages.length <= 1) {
+                prevBtn.style.display = 'none';
+                nextBtn.style.display = 'none';
+            } else {
+                prevBtn.style.display = 'block';
+                nextBtn.style.display = 'block';
+            }
+        }
+
+        function closeLightbox() {
+            document.getElementById('lightbox').classList.remove('active');
+        }
+
+        function changeLightboxImage(direction) {
+            if (allImages.length <= 1) return;
+            
+            currentImageIndex += direction;
+            if (currentImageIndex >= allImages.length) {
+                currentImageIndex = 0;
+            } else if (currentImageIndex < 0) {
+                currentImageIndex = allImages.length - 1;
+            }
+            
+            document.getElementById('lightbox-image').src = allImages[currentImageIndex];
+        }
+
+        // Keyboard navigation
+        document.addEventListener('keydown', function(e) {
+            if (document.getElementById('lightbox').classList.contains('active')) {
+                if (e.key === 'Escape') {
+                    closeLightbox();
+                } else if (e.key === 'ArrowLeft') {
+                    changeLightboxImage(-1);
+                } else if (e.key === 'ArrowRight') {
+                    changeLightboxImage(1);
+                }
+            }
+        });
+    </script>
+</body>
+</html>`)
+
+       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
+}
+
+// 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, `<pre><code class="language-%s">`, lang)
+
+                               // Write escaped content
+                               content := string(codeBlock.Literal)
+                               html.EscapeHTML(w, []byte(content))
+
+                               // Write closing tag
+                               w.Write([]byte("</code></pre>"))
+                               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)
+}
+
+/*
+// BACKUP: Old custom markdown renderer - kept for reference
+// renderMarkdownToHTML converts simple markdown to HTML
+func renderMarkdownToHTML_OLD(text string) string {
+       lines := strings.Split(text, "\n")
+       var result strings.Builder
+       var inCodeBlock bool
+       var codeBlockContent strings.Builder
+       var codeLanguage string
+
+       for _, line := range lines {
+               // Handle code blocks
+               if strings.HasPrefix(line, "```") {
+                       if !inCodeBlock {
+                               // Starting a code block
+                               inCodeBlock = true
+                               codeLanguage = strings.TrimSpace(line[3:])
+                               codeBlockContent.Reset()
+                               continue
+                       } else {
+                               // Ending a code block
+                               inCodeBlock = false
+                               codeContent := codeBlockContent.String()
+                               if codeLanguage != "" {
+                                       result.WriteString(fmt.Sprintf(`<pre><code class="language-%s">%s</code></pre>`,
+                                               htmlpkg.EscapeString(codeLanguage), htmlpkg.EscapeString(codeContent)))
+                               } else {
+                                       result.WriteString(fmt.Sprintf(`<pre><code>%s</code></pre>`,
+                                               htmlpkg.EscapeString(codeContent)))
+                               }
+                               codeLanguage = ""
+                               continue
+                       }
+               }
+
+               // If we're in a code block, accumulate the content
+               if inCodeBlock {
+                       if codeBlockContent.Len() > 0 {
+                               codeBlockContent.WriteString("\n")
+                       }
+                       codeBlockContent.WriteString(line)
+                       continue
+               }
+
+               // Normal line processing (when not in code block)
+               line = strings.TrimSpace(line)
+
+               if line == "" {
+                       result.WriteString("<br>")
+                       continue
+               }
+
+               // Handle headings
+               if strings.HasPrefix(line, "###") {
+                       content := strings.TrimSpace(line[3:])
+                       result.WriteString(fmt.Sprintf("<h3>%s</h3>", htmlpkg.EscapeString(content)))
+                       continue
+               }
+               if strings.HasPrefix(line, "##") {
+                       content := strings.TrimSpace(line[2:])
+                       result.WriteString(fmt.Sprintf("<h2>%s</h2>", htmlpkg.EscapeString(content)))
+                       continue
+               }
+               if strings.HasPrefix(line, "#") {
+                       content := strings.TrimSpace(line[1:])
+                       result.WriteString(fmt.Sprintf("<h1>%s</h1>", htmlpkg.EscapeString(content)))
+                       continue
+               }
+
+               // Handle list items
+               if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
+                       content := strings.TrimSpace(line[2:])
+                       content = renderInlineMarkdownToHTML(content)
+                       result.WriteString(fmt.Sprintf("<ul><li>%s</li></ul>", content))
+                       continue
+               }
+
+               // Handle blockquotes
+               if strings.HasPrefix(line, "> ") {
+                       content := strings.TrimSpace(line[2:])
+                       content = renderInlineMarkdownToHTML(content)
+                       result.WriteString(fmt.Sprintf("<blockquote>%s</blockquote>", content))
+                       continue
+               }
+
+               // Regular paragraph with inline formatting
+               line = renderInlineMarkdownToHTML(line)
+               result.WriteString(fmt.Sprintf("<p>%s</p>", line))
+       }
+
+       // Handle unclosed code block
+       if inCodeBlock {
+               codeContent := codeBlockContent.String()
+               if codeLanguage != "" {
+                       result.WriteString(fmt.Sprintf(`<pre><code class="language-%s">%s</code></pre>`,
+                               htmlpkg.EscapeString(codeLanguage), htmlpkg.EscapeString(codeContent)))
+               } else {
+                       result.WriteString(fmt.Sprintf(`<pre><code>%s</code></pre>`,
+                               htmlpkg.EscapeString(codeContent)))
+               }
+       }
+
+       return result.String()
+}
+
+// renderInlineMarkdownToHTML processes inline markdown formatting
+func renderInlineMarkdownToHTML_OLD(text string) string {
+       // Escape HTML first
+       text = htmlpkg.EscapeString(text)
+
+       // Links with title: [text](url "title")
+       linkWithTitleRegex := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\s+"([^"]+)"\)`)
+       text = linkWithTitleRegex.ReplaceAllStringFunc(text, func(match string) string {
+               matches := linkWithTitleRegex.FindStringSubmatch(match)
+               if len(matches) == 4 {
+                       linkText := matches[1]
+                       url := matches[2]
+                       title := matches[3]
+                       return fmt.Sprintf(`<a href="%s" title="%s" target="_blank" rel="noopener noreferrer">%s</a>`, url, title, linkText)
+               }
+               return match
+       })
+
+       // Links without title: [text](url)
+       linkRegex := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+       text = linkRegex.ReplaceAllStringFunc(text, func(match string) string {
+               matches := linkRegex.FindStringSubmatch(match)
+               if len(matches) == 3 {
+                       linkText := matches[1]
+                       url := matches[2]
+                       return fmt.Sprintf(`<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>`, url, linkText)
+               }
+               return match
+       })
+
+       // Bold (**text** or __text__)
+       boldRegex := regexp.MustCompile(`\*\*(.*?)\*\*`)
+       text = boldRegex.ReplaceAllString(text, "<strong>$1</strong>")
+
+       boldRegex2 := regexp.MustCompile(`__(.*?)__`)
+       text = boldRegex2.ReplaceAllString(text, "<strong>$1</strong>")
+
+       // Italic (*text* or _text_)
+       italicRegex := regexp.MustCompile(`\*(.*?)\*`)
+       text = italicRegex.ReplaceAllString(text, "<em>$1</em>")
+
+       italicRegex2 := regexp.MustCompile(`_(.*?)_`)
+       text = italicRegex2.ReplaceAllString(text, "<em>$1</em>")
+
+       // Code (`text`)
+       codeRegex := regexp.MustCompile("`(.*?)`")
+       text = codeRegex.ReplaceAllString(text, "<code>$1</code>")
+
+       return text
+}
+*/
index 316a9371327dc53eeff7bd6a36aec9667bdb670c..338dd44b3d4306544853e7a54fbaad26e41b221a 100644 (file)
@@ -64,6 +64,7 @@ func main() {
        mux.HandleFunc("GET /logs/getHistory", middleware.RequireAuth(handlers.GetHistory))
        mux.HandleFunc("GET /logs/bookmarkDay", middleware.RequireAuth(handlers.BookmarkDay))
        mux.HandleFunc("GET /logs/deleteDay", middleware.RequireAuth(handlers.DeleteDay))
+       mux.HandleFunc("GET /logs/exportData", middleware.RequireAuth(handlers.ExportData))
 
        // Create a handler chain with Logger and CORS middleware
        // Logger middleware will be executed first, then CORS
index c195c0b4f965d0b1f9f44d072cfd77558d8c24c3..1afcc5c04ba5a40db8cfb17ccf0a6022a70b620c 100644 (file)
                        }
                );
        }
+
+       let exportPeriod = $state('periodAll');
+       let exportStartDate = $state('');
+       let exportEndDate = $state('');
+       let exportImagesInHTML = $state(true);
+       let exportSplit = $state('aio');
+       let exportTagsInHTML = $state(true);
+       let isExporting = $state(false);
+
+       function exportData() {
+               if (isExporting) return;
+               isExporting = true;
+
+               axios
+                       .get(API_URL + '/logs/exportData', {
+                               params: {
+                                       period: exportPeriod,
+                                       startDate: exportStartDate,
+                                       endDate: exportEndDate,
+                                       imagesInHTML: exportImagesInHTML,
+                                       split: exportSplit,
+                                       tagsInHTML: exportTagsInHTML
+                               },
+                               responseType: 'blob' // Expect a binary response
+                       })
+                       .then((response) => {
+                               const blob = new Blob([response.data], { type: 'application/zip' });
+                               const url = window.URL.createObjectURL(blob);
+                               const a = document.createElement('a');
+                               a.href = url;
+                               a.download = `DailyTxT_Export_${localStorage.getItem('users')}.zip`;
+                               document.body.appendChild(a);
+                               a.click();
+                               a.remove();
+                               window.URL.revokeObjectURL(url);
+                       })
+                       .catch((error) => {
+                               console.error(error);
+
+                               // show toast
+                               const toast = new bootstrap.Toast(document.getElementById('toastErrorExportData'));
+                               toast.show();
+                       })
+                       .finally(() => {
+                               isExporting = false;
+                       });
+       }
 </script>
 
 <div class="d-flex flex-column h-100">
 
                                                        <div id="data">
                                                                <h3 class="text-primary">📁 Daten</h3>
-                                                               <div><h5>Export</h5></div>
+                                                               <div>
+                                                                       <h5>Export</h5>
+                                                                       Exportiere deine Einträge in einer formatierten HTML-Datei. Bilder werden wahlweise
+                                                                       in der HTML eingebunden. Alle Dateien werden außerdem in einer Zip-Datei bereitgestellt.
+
+                                                                       <h6>Zeitraum</h6>
+                                                                       <div class="form-check">
+                                                                               <input
+                                                                                       class="form-check-input"
+                                                                                       type="radio"
+                                                                                       name="period"
+                                                                                       value="periodAll"
+                                                                                       id="periodAll"
+                                                                                       bind:group={exportPeriod}
+                                                                               />
+                                                                               <label class="form-check-label" for="periodAll">Gesamter Zeitraum</label>
+                                                                       </div>
+                                                                       <div class="form-check">
+                                                                               <input
+                                                                                       class="form-check-input"
+                                                                                       type="radio"
+                                                                                       name="period"
+                                                                                       value="periodVariable"
+                                                                                       id="periodVariable"
+                                                                                       bind:group={exportPeriod}
+                                                                               />
+                                                                               <label class="form-check-label" for="periodVariable">Variabler Zeitraum</label>
+                                                                               {#if exportPeriod === 'periodVariable'}
+                                                                                       <div class="d-flex flex-row" transition:slide>
+                                                                                               <div class="me-2">
+                                                                                                       <label for="exportStartDate">Von:</label>
+                                                                                                       <input
+                                                                                                               type="date"
+                                                                                                               class="form-control me-2"
+                                                                                                               id="exportStartDate"
+                                                                                                               bind:value={exportStartDate}
+                                                                                                       />
+                                                                                               </div>
+                                                                                               <div>
+                                                                                                       <label for="exportEndDate">Bis:</label>
+                                                                                                       <input
+                                                                                                               type="date"
+                                                                                                               class="form-control"
+                                                                                                               id="exportEndDate"
+                                                                                                               bind:value={exportEndDate}
+                                                                                                       />
+                                                                                               </div>
+                                                                                       </div>
+                                                                                       {#if exportStartDate !== '' && exportEndDate !== '' && exportStartDate > exportEndDate}
+                                                                                               <div class="alert alert-danger mt-2" role="alert" transition:slide>
+                                                                                                       Das Startdatum muss vor dem Enddatum liegen!
+                                                                                               </div>
+                                                                                       {/if}
+                                                                               {/if}
+                                                                       </div>
+
+                                                                       <h6>Anzahl der HTML-Dokumente</h6>
+                                                                       <div class="form-check">
+                                                                               <input
+                                                                                       class="form-check-input"
+                                                                                       type="radio"
+                                                                                       name="split"
+                                                                                       value="aio"
+                                                                                       id="splitAIO"
+                                                                                       bind:group={exportSplit}
+                                                                               />
+                                                                               <label class="form-check-label" for="splitAIO">Eine einzige HTML</label>
+                                                                       </div>
+                                                                       <div class="form-check">
+                                                                               <input
+                                                                                       class="form-check-input"
+                                                                                       type="radio"
+                                                                                       name="split"
+                                                                                       value="year"
+                                                                                       id="splitYear"
+                                                                                       bind:group={exportSplit}
+                                                                               />
+                                                                               <label class="form-check-label" for="splitYear">Eine HTML pro Jahr</label>
+                                                                       </div>
+                                                                       <div class="form-check">
+                                                                               <input
+                                                                                       class="form-check-input"
+                                                                                       type="radio"
+                                                                                       name="split"
+                                                                                       value="month"
+                                                                                       id="splitMonth"
+                                                                                       bind:group={exportSplit}
+                                                                               />
+                                                                               <label class="form-check-label" for="splitMonth">Eine HTML pro Monat</label>
+                                                                       </div>
+
+                                                                       <h6>Bilder in HTML anzeigen</h6>
+                                                                       <div class="form-check">
+                                                                               <input
+                                                                                       class="form-check-input"
+                                                                                       type="checkbox"
+                                                                                       name="images"
+                                                                                       id="exportImagesInHTML"
+                                                                                       bind:checked={exportImagesInHTML}
+                                                                               />
+                                                                               <label class="form-check-label" for="exportImagesInHTML">
+                                                                                       Bilder direkt unter dem Text anzeigen <em
+                                                                                               >(werden zudem immer als Link bereitgestellt)</em
+                                                                                       >
+                                                                               </label>
+                                                                       </div>
+
+                                                                       <h6>Tags drucken</h6>
+                                                                       <div class="form-check">
+                                                                               <input
+                                                                                       class="form-check-input"
+                                                                                       type="checkbox"
+                                                                                       id="exportTagsInHTML"
+                                                                                       bind:checked={exportTagsInHTML}
+                                                                               />
+                                                                               <label class="form-check-label" for="exportTagsInHTML"
+                                                                                       >Tags in der HTML anzeigen</label
+                                                                               >
+                                                                       </div>
+
+                                                                       <div class="form-text">
+                                                                               <u>Hinweise:</u>
+                                                                               <ul>
+                                                                                       <li>Die HTML wird keinen Verlauf der einzelnen Tage enthalten.</li>
+                                                                                       <li>
+                                                                                               Ein Re-Import ist nicht möglich. Diese Funktion dient nicht dem Backup,
+                                                                                               sondern rein dem Export, um eine einfach lesbare HTML-Datei zu erhalten.
+                                                                                       </li>
+                                                                               </ul>
+                                                                       </div>
+                                                                       <button
+                                                                               class="btn btn-primary mt-3"
+                                                                               onclick={exportData}
+                                                                               data-sveltekit-noscroll
+                                                                               disabled={isExporting ||
+                                                                                       (exportPeriod === 'periodVariable' &&
+                                                                                               (exportStartDate === '' || exportEndDate === ''))}
+                                                                       >
+                                                                               Exportieren
+                                                                               {#if isExporting}
+                                                                                       <div class="spinner-border spinner-border-sm ms-2" role="status">
+                                                                                               <span class="visually-hidden">Loading...</span>
+                                                                                       </div>
+                                                                               {/if}
+                                                                       </button>
+                                                               </div>
                                                                <div><h5>Import</h5></div>
                                                        </div>
 
                        <div class="toast-body">Fehler beim Logout</div>
                </div>
        </div>
+
+       <div
+               id="toastErrorExportData"
+               class="toast align-items-center text-bg-danger"
+               role="alert"
+               aria-live="assertive"
+               aria-atomic="true"
+       >
+               <div class="d-flex">
+                       <div class="toast-body">Fehler beim Exportieren!</div>
+               </div>
+       </div>
 </div>
 
 <style>
+       h5,
+       h6 {
+               font-weight: 600;
+               text-decoration: underline;
+               text-decoration-color: #0d6efd;
+       }
+
+       h6 {
+               margin-top: 0.7rem;
+       }
+
        .backupCode {
                font-size: 15pt;
        }
git clone https://git.99rst.org/PROJECT