tagliatelle

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

commit 8a12033f6721aa80559f6555f9a60b120a4c9d54
parent 7030fbe85d35058d597f7165bfe34250b240b96c
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Sun, 26 Jul 2026 10:16:47 +0100

Finally move everything into it's own project

Also run gofmt over the project

Diffstat:
Dinclude-admin-orphans.go | 74--------------------------------------------------------------------------
Dinclude-admin-thumbnails.go | 220-------------------------------------------------------------------------------
Dinclude-admin.go | 302------------------------------------------------------------------------------
Dinclude-bulk.go | 549-------------------------------------------------------------------------------
Dinclude-cbz.go | 356-------------------------------------------------------------------------------
Dinclude-db.go | 214-------------------------------------------------------------------------------
Dinclude-files.go | 206-------------------------------------------------------------------------------
Dinclude-filters.go | 308-------------------------------------------------------------------------------
Dinclude-general.go | 159-------------------------------------------------------------------------------
Dinclude-notes.go | 444-------------------------------------------------------------------------------
Dinclude-pagination.go | 208-------------------------------------------------------------------------------
Dinclude-previews.go | 129-------------------------------------------------------------------------------
Dinclude-properties.go | 305------------------------------------------------------------------------------
Dinclude-routes.go | 37-------------------------------------
Dinclude-search.go | 35-----------------------------------
Dinclude-templates.go | 42------------------------------------------
Dinclude-upload-local.go | 68--------------------------------------------------------------------
Dinclude-uploads.go | 333-------------------------------------------------------------------------------
Dinclude-viewer.go | 394-------------------------------------------------------------------------------
Ainternal/app/admin.go | 302++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/admin_orphans.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/admin_thumbnails.go | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/bulk.go | 546+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/cbz.go | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/db.go | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/files.go | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/filters.go | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/general.go | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/notes.go | 444+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/pagination.go | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/previews.go | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/properties.go | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/routes.go | 37+++++++++++++++++++++++++++++++++++++
Ainternal/app/search.go | 34++++++++++++++++++++++++++++++++++
Ainternal/app/templates.go | 42++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/upload_local.go | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/uploads.go | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/vars.go | 13+++++++++++++
Ainternal/app/viewer.go | 393+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmain.go | 34++++++++++++++--------------------
40 files changed, 4395 insertions(+), 4403 deletions(-)

diff --git a/include-admin-orphans.go b/include-admin-orphans.go @@ -1,73 +0,0 @@ -package main - -import ( - "log" - "net/http" - "os" -) - -type OrphanData struct { - Orphans []string // on disk, not in DB - ReverseOrphans []string // in DB, not on disk -} - -func getOrphanedFiles(uploadDir string) (OrphanData, error) { - diskFiles, err := getFilesOnDisk(uploadDir) - if err != nil { - return OrphanData{}, err - } - dbFiles, err := getFilesInDB() - if err != nil { - return OrphanData{}, err - } - - // Build a set of disk files for reverse lookup - diskFileSet := make(map[string]bool, len(diskFiles)) - for _, f := range diskFiles { - diskFileSet[f] = true - } - - var orphans []string - for _, f := range diskFiles { - if !dbFiles[f] { - orphans = append(orphans, f) - } - } - - var reverseOrphans []string - for f := range dbFiles { - if !diskFileSet[f] { - reverseOrphans = append(reverseOrphans, f) - } - } - - return OrphanData{ - Orphans: orphans, - ReverseOrphans: reverseOrphans, - }, nil -} - -func orphansHandler(w http.ResponseWriter, r *http.Request) { - orphanData, err := getOrphanedFiles(config.UploadDir) - if err != nil { - log.Printf("Error: orphansHandler: failed to read orphaned files: %v", err) - renderError(w, "Error reading orphaned files", http.StatusInternalServerError) - return - } - pageData := buildPageData("Orphaned Files", orphanData) - renderTemplate(w, "orphans.html", pageData) -} - -func getFilesOnDisk(uploadDir string) ([]string, error) { - entries, err := os.ReadDir(uploadDir) - if err != nil { - return nil, err - } - var files []string - for _, e := range entries { - if !e.IsDir() { - files = append(files, e.Name()) - } - } - return files, nil -} -\ No newline at end of file diff --git a/include-admin-thumbnails.go b/include-admin-thumbnails.go @@ -1,219 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "log" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" -) - -type VideoFile struct { - ID int - Filename string - Path string - HasThumbnail bool - ThumbnailPath string - EscapedFilename string -} - -func generateThumbnailAtTime(videoPath, uploadDir, filename, timestamp string) error { - thumbDir := filepath.Join(uploadDir, "thumbnails") - if err := os.MkdirAll(thumbDir, 0755); err != nil { - return fmt.Errorf("failed to create thumbnails directory: %v", err) - } - - thumbPath := filepath.Join(thumbDir, filename+".jpg") - - var stderr bytes.Buffer - cmd := exec.Command("ffmpeg", "-y", "-ss", timestamp, "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to generate thumbnail at %s: %v\nffmpeg output: %s", timestamp, err, stderr.String()) - } - - return nil -} - -func getVideoFiles() ([]VideoFile, error) { - videoExts := []string{".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"} - - rows, err := db.Query(`SELECT id, filename, path FROM files ORDER BY id DESC`) - if err != nil { - return nil, err - } - defer rows.Close() - - var videos []VideoFile - for rows.Next() { - var v VideoFile - if err := rows.Scan(&v.ID, &v.Filename, &v.Path); err != nil { - log.Printf("Warning: getVideoFiles: failed to scan row: %v", err) - continue - } - - // Check if it's a video file - isVideo := false - ext := strings.ToLower(filepath.Ext(v.Filename)) - for _, vidExt := range videoExts { - if ext == vidExt { - isVideo = true - break - } - } - - if !isVideo { - continue - } - - v.EscapedFilename = url.PathEscape(v.Filename) - thumbPath := filepath.Join(config.UploadDir, "thumbnails", v.Filename+".jpg") - v.ThumbnailPath = "/uploads/thumbnails/" + v.EscapedFilename + ".jpg" - - if _, err := os.Stat(thumbPath); err == nil { - v.HasThumbnail = true - } - - videos = append(videos, v) - } - - return videos, nil -} - -func thumbnailsHandler(w http.ResponseWriter, r *http.Request) { - allVideos, err := getVideoFiles() - if err != nil { - log.Printf("Error: thumbnailsHandler: failed to get video files: %v", err) - renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) - return - } - - missing, err := getMissingThumbnailVideos() - if err != nil { - log.Printf("Error: thumbnailsHandler: failed to get missing thumbnail videos: %v", err) - renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) - return - } - - pageData := buildPageData("Thumbnail Management", struct { - AllVideos []VideoFile - MissingThumbnails []VideoFile - Error string - Success string - }{ - AllVideos: allVideos, - MissingThumbnails: missing, - Error: r.URL.Query().Get("error"), - Success: r.URL.Query().Get("success"), - }) - - renderTemplate(w, "thumbnails.html", pageData) -} - -func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Redirect(w, r, "/admin", http.StatusSeeOther) - return - } - - action := r.FormValue("action") - redirectTo := r.FormValue("redirect") - if redirectTo == "" { - redirectTo = "thumbnails" - } - - redirectBase := "/" + redirectTo - - switch action { - case "generate_all": - missing, err := getMissingThumbnailVideos() - if err != nil { - log.Printf("Error: generateThumbnailHandler: failed to get missing thumbnails: %v", err) - http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther) - return - } - - successCount := 0 - var errors []string - - for _, v := range missing { - if err := generateThumbnail(v.Path, config.UploadDir, v.Filename); err != nil { - log.Printf("Error: generateThumbnailHandler: failed to generate thumbnail for %s: %v", v.Filename, err) - errors = append(errors, fmt.Sprintf("%s: %v", v.Filename, err)) - } else { - successCount++ - } - } - - if len(errors) > 0 { - http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Generated %d thumbnails", successCount))+"&error="+url.QueryEscape(fmt.Sprintf("Failed: %s", strings.Join(errors, "; "))), http.StatusSeeOther) - } else { - http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther) - } - - case "generate_single": - fileID := r.FormValue("file_id") - timestamp := strings.TrimSpace(r.FormValue("timestamp")) - - if timestamp == "" { - timestamp = "00:00:05" - } - - var filename, path string - err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&filename, &path) - if err != nil { - log.Printf("Error: generateThumbnailHandler: file not found for id=%s: %v", fileID, err) - http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("File not found"), http.StatusSeeOther) - return - } - - if !filepath.IsAbs(path) { - path = filepath.Join(config.UploadDir, path) - } - - if err = generateThumbnailAtTime(path, config.UploadDir, filename, timestamp); err != nil { - log.Printf("Error: generateThumbnailHandler: failed to generate thumbnail for file id=%s at %s: %v", fileID, timestamp, err) - http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther) - return - } - - if redirectTo == "admin" { - http.Redirect(w, r, "/admin?success="+url.QueryEscape(fmt.Sprintf("Thumbnail generated for file %s at %s", fileID, timestamp)), http.StatusSeeOther) - } else { - http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther) - } - - default: - http.Redirect(w, r, redirectBase, http.StatusSeeOther) - } -} - -func generateThumbnail(videoPath, uploadDir, filename string) error { - if err := generateThumbnailAtTime(videoPath, uploadDir, filename, "00:00:05"); err != nil { - log.Printf("Warning: generateThumbnail: seek to 5s failed for %s, retrying from start: %v", filename, err) - return generateThumbnailAtTime(videoPath, uploadDir, filename, "00:00:00") - } - - return nil -} - -func getMissingThumbnailVideos() ([]VideoFile, error) { - allVideos, err := getVideoFiles() - if err != nil { - return nil, err - } - - var missing []VideoFile - for _, v := range allVideos { - if !v.HasThumbnail { - missing = append(missing, v) - } - } - - return missing, nil -} -\ No newline at end of file diff --git a/include-admin.go b/include-admin.go @@ -1,302 +0,0 @@ -package main - -import ( - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" -) - -type AdminPageData struct { - Config Config - Error string - Success string - OrphanData OrphanData - ActiveTab string - MissingThumbnails []VideoFile - RecentFiles []File -} - -type SedRule struct { - Name string `json:"name"` - Description string `json:"description"` - Command string `json:"command"` -} - -func renderAdminPage(w http.ResponseWriter, r *http.Request, data AdminPageData) { - if data.ActiveTab == "" { - data.ActiveTab = r.FormValue("active_tab") - } - pageData := buildPageData("Admin", data) - renderTemplate(w, "admin.html", pageData) -} - -func getRecentFiles() []File { - rows, err := db.Query("SELECT id, filename FROM files ORDER BY id DESC LIMIT 20") - if err != nil { - log.Printf("Warning: getRecentFiles: failed to query recent files: %v", err) - return nil - } - defer rows.Close() - var files []File - for rows.Next() { - var f File - rows.Scan(&f.ID, &f.Filename) - files = append(files, f) - } - return files -} - -func currentAdminState(r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) AdminPageData { - return AdminPageData{ - Config: config, - OrphanData: orphanData, - ActiveTab: r.FormValue("active_tab"), - MissingThumbnails: missingThumbnails, - RecentFiles: getRecentFiles(), - } -} - -func validateConfig(newConfig Config) error { - if newConfig.GallerySize == "" { - return fmt.Errorf("gallery size cannot be empty") - } - if newConfig.ItemsPerPage == "" { - return fmt.Errorf("items per page cannot be empty") - } - return nil -} - -func adminHandler(w http.ResponseWriter, r *http.Request) { - // Get orphaned files - orphanData, err := getOrphanedFiles(config.UploadDir) - if err != nil { - log.Printf("Warning: adminHandler: failed to get orphaned files: %v", err) - } - - // Get video files for thumbnails - missingThumbnails, err := getMissingThumbnailVideos() - if err != nil { - log.Printf("Warning: adminHandler: failed to get missing thumbnails: %v", err) - } - - switch r.Method { - case http.MethodPost: - switch r.FormValue("action") { - case "save", "": - handleSaveSettings(w, r, orphanData, missingThumbnails) - - case "backup": - err := backupDatabase(config.DatabasePath) - if err != nil { - log.Printf("Error: adminHandler: database backup failed: %v", err) - } - data := currentAdminState(r, orphanData, missingThumbnails) - data.Error = errorString(err) - data.Success = successString(err, "Database backup created successfully!") - renderAdminPage(w, r, data) - - case "vacuum": - err := vacuumDatabase() - if err != nil { - log.Printf("Error: adminHandler: database vacuum failed: %v", err) - } - data := currentAdminState(r, orphanData, missingThumbnails) - data.Error = errorString(err) - data.Success = successString(err, "Database vacuum completed successfully!") - renderAdminPage(w, r, data) - - case "save_aliases": - handleSaveAliases(w, r, orphanData, missingThumbnails) - - case "save_sed_rules": - handleSaveSedRules(w, r, orphanData, missingThumbnails) - - case "compute_properties": - handleComputeProperties(w, r, orphanData, missingThumbnails) - } - - default: - renderAdminPage(w, r, currentAdminState(r, orphanData, missingThumbnails)) - } -} - -func parseAliasesFromForm(r *http.Request) []TagAliasGroup { - var groups []TagAliasGroup - for i := 0; ; i++ { - category := strings.TrimSpace(r.FormValue(fmt.Sprintf("aliases[%d][category]", i))) - if category == "" { - break - } - var aliases []string - for j := 0; ; j++ { - v := strings.TrimSpace(r.FormValue(fmt.Sprintf("aliases[%d][aliases][%d]", i, j))) - if v == "" { - break - } - aliases = append(aliases, v) - } - if len(aliases) >= 2 { - groups = append(groups, TagAliasGroup{Category: category, Aliases: aliases}) - } else { - log.Printf("Warning: parseAliasesFromForm: alias group for category %q has fewer than 2 aliases (%d) and was discarded", category, len(aliases)) - } - } - return groups -} - -func parseSedRulesFromForm(r *http.Request) ([]SedRule, error) { - var rules []SedRule - for i := 0; ; i++ { - name := strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][name]", i))) - if name == "" { - break - } - command := strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][command]", i))) - if command == "" { - return nil, fmt.Errorf("rule %s is missing a command", strconv.Itoa(i+1)) - } - rules = append(rules, SedRule{ - Name: name, - Description: strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][description]", i))), - Command: command, - }) - } - return rules, nil -} - -func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { - config.TagAliases = parseAliasesFromForm(r) - - if err := SaveConfig(db, config); err != nil { - log.Printf("Error: handleSaveAliases: failed to save configuration: %v", err) - data := currentAdminState(r, orphanData, missingThumbnails) - data.Error = "Failed to save configuration: " + err.Error() - renderAdminPage(w, r, data) - return - } - - data := currentAdminState(r, orphanData, missingThumbnails) - data.Success = "Tag aliases saved successfully!" - renderAdminPage(w, r, data) -} - -func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { - newConfig := config // preserve runtime fields - newConfig.GallerySize = strings.TrimSpace(r.FormValue("gallery_size")) - newConfig.ItemsPerPage = strings.TrimSpace(r.FormValue("items_per_page")) - - if err := validateConfig(newConfig); err != nil { - data := currentAdminState(r, orphanData, missingThumbnails) - data.Error = err.Error() - renderAdminPage(w, r, data) - return - } - - config = newConfig - - if err := SaveConfig(db, config); err != nil { - log.Printf("Error: handleSaveSettings: failed to save configuration: %v", err) - data := currentAdminState(r, orphanData, missingThumbnails) - data.Error = "Failed to save configuration: " + err.Error() - renderAdminPage(w, r, data) - return - } - - data := currentAdminState(r, orphanData, missingThumbnails) - data.Success = "Settings saved successfully!" - renderAdminPage(w, r, data) -} - -func handleSaveSedRules(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { - rules, err := parseSedRulesFromForm(r) - if err != nil { - data := currentAdminState(r, orphanData, missingThumbnails) - data.Error = err.Error() - renderAdminPage(w, r, data) - return - } - - config.SedRules = rules - - if err := SaveConfig(db, config); err != nil { - log.Printf("Error: handleSaveSedRules: failed to save configuration: %v", err) - data := currentAdminState(r, orphanData, missingThumbnails) - data.Error = "Failed to save configuration: " + err.Error() - renderAdminPage(w, r, data) - return - } - - data := currentAdminState(r, orphanData, missingThumbnails) - data.Success = "Sed rules saved successfully!" - renderAdminPage(w, r, data) -} - -func backupDatabase(dbPath string) error { - if dbPath == "" { - return fmt.Errorf("database path not configured") - } - - backupDir := filepath.Join(filepath.Dir(dbPath), "backups") - if err := os.MkdirAll(backupDir, 0755); err != nil { - return fmt.Errorf("failed to create backups directory: %w", err) - } - - dbName := strings.TrimSuffix(filepath.Base(dbPath), filepath.Ext(dbPath)) - timestamp := time.Now().Format("20060102_150405") - backupPath := filepath.Join(backupDir, fmt.Sprintf("%s_backup_%s.db", dbName, timestamp)) - - input, err := os.Open(dbPath) - if err != nil { - return fmt.Errorf("failed to open database: %w", err) - } - defer input.Close() - - output, err := os.Create(backupPath) - if err != nil { - return fmt.Errorf("failed to create backup file: %w", err) - } - defer output.Close() - - if _, err := io.Copy(output, input); err != nil { - return fmt.Errorf("failed to copy database: %w", err) - } - - log.Printf("Info: backupDatabase: backup written to %s", backupPath) - return nil -} - -func vacuumDatabase() error { - if _, err := db.Exec("VACUUM;"); err != nil { - return fmt.Errorf("VACUUM failed: %w", err) - } - - log.Printf("Info: vacuumDatabase: VACUUM completed successfully") - return nil -} - -func getFilesInDB() (map[string]bool, error) { - rows, err := db.Query("SELECT filename FROM files") - if err != nil { - return nil, err - } - defer rows.Close() - - fileMap := make(map[string]bool) - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, err - } - fileMap[name] = true - } - if err := rows.Err(); err != nil { - return nil, err - } - return fileMap, nil -} diff --git a/include-bulk.go b/include-bulk.go @@ -1,548 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "log" - "net/http" - "strconv" - "strings" -) - -type TagPair struct { - Category string - Value string -} - -type BulkTagFormData struct { - Categories []string - RecentFiles []File - Error string - Success string - FormData struct { - FileRange string - Category string - Value string - Operation string - TagQuery string - SelectionMode string - } -} - -func applyBulkTagOperations(fileIDs []int, category, value, operation string) error { - category = strings.TrimSpace(category) - value = strings.TrimSpace(value) - if category == "" { - return fmt.Errorf("category cannot be empty") - } - - if operation == "add" && value == "" { - return fmt.Errorf("value cannot be empty when adding tags") - } - - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to start transaction: %v", err) - } - defer tx.Rollback() - - var catID int - err = tx.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) - if err != nil && err != sql.ErrNoRows { - return fmt.Errorf("failed to query category: %v", err) - } - - if catID == 0 { - if operation == "remove" { - return fmt.Errorf("cannot remove non-existent category: %s", category) - } - res, err := tx.Exec("INSERT INTO categories(name) VALUES(?)", category) - if err != nil { - return fmt.Errorf("failed to create category: %v", err) - } - cid, _ := res.LastInsertId() - catID = int(cid) - } - - var tagID int - if value != "" { - err = tx.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) - if err != nil && err != sql.ErrNoRows { - return fmt.Errorf("failed to query tag: %v", err) - } - - if tagID == 0 { - if operation == "remove" { - return fmt.Errorf("cannot remove non-existent tag: %s=%s", category, value) - } - res, err := tx.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) - if err != nil { - return fmt.Errorf("failed to create tag: %v", err) - } - tid, _ := res.LastInsertId() - tagID = int(tid) - } - } - - for _, fileID := range fileIDs { - if operation == "add" { - _, err = tx.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", fileID, tagID) - } else if operation == "remove" { - if value != "" { - _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID) - } else { - _, err = tx.Exec(`DELETE FROM file_tags WHERE file_id=? AND tag_id IN (SELECT t.id FROM tags t WHERE t.category_id=?)`, fileID, catID) - } - } else { - return fmt.Errorf("invalid operation: %s (must be 'add' or 'remove')", operation) - } - if err != nil { - return fmt.Errorf("failed to %s tag for file %d: %v", operation, fileID, err) - } - } - - return tx.Commit() -} - -func getBulkTagFormData() BulkTagFormData { - catRows, err := db.Query("SELECT name FROM categories ORDER BY name") - if err != nil { - log.Printf("Error: getBulkTagFormData: failed to query categories: %v", err) - } - var cats []string - if catRows != nil { - for catRows.Next() { - var c string - catRows.Scan(&c) - cats = append(cats, c) - } - catRows.Close() - } - - return BulkTagFormData{ - Categories: cats, - RecentFiles: getRecentFiles(), - FormData: struct { - FileRange string - Category string - Value string - Operation string - TagQuery string - SelectionMode string - }{Operation: "add"}, - } -} - -func bulkTagHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - formData := getBulkTagFormData() - pageData := buildPageData("Bulk Tag Editor", formData) - renderTemplate(w, "bulk-tag.html", pageData) - return - } - if r.Method == http.MethodPost { - rangeStr := strings.TrimSpace(r.FormValue("file_range")) - tagQuery := strings.TrimSpace(r.FormValue("tag_query")) - selectionMode := r.FormValue("selection_mode") - category := strings.TrimSpace(r.FormValue("category")) - value := strings.TrimSpace(r.FormValue("value")) - operation := r.FormValue("operation") - - formData := getBulkTagFormData() - formData.FormData.FileRange = rangeStr - formData.FormData.TagQuery = tagQuery - formData.FormData.SelectionMode = selectionMode - formData.FormData.Category = category - formData.FormData.Value = value - formData.FormData.Operation = operation - - createErrorResponse := func(errorMsg string) { - formData.Error = errorMsg - pageData := buildPageData("Bulk Tag Editor", formData) - renderTemplate(w, "bulk-tag.html", pageData) - } - - // Validate selection mode - if selectionMode == "" { - selectionMode = "range" // default - } - - // Validate inputs based on selection mode - if selectionMode == "range" && rangeStr == "" { - createErrorResponse("File range cannot be empty") - return - } - if selectionMode == "tags" && tagQuery == "" { - createErrorResponse("Tag query cannot be empty") - return - } - if category == "" { - createErrorResponse("Category cannot be empty") - return - } - if operation == "add" && value == "" { - createErrorResponse("Value cannot be empty when adding tags") - return - } - - // Get file IDs based on selection mode - var fileIDs []int - var err error - - if selectionMode == "range" { - fileIDs, err = parseFileIDRange(rangeStr) - if err != nil { - createErrorResponse(fmt.Sprintf("Invalid file range: %v", err)) - return - } - } else if selectionMode == "tags" { - fileIDs, err = getFileIDsFromTagQuery(tagQuery) - if err != nil { - createErrorResponse(fmt.Sprintf("Tag query error: %v", err)) - return - } - if len(fileIDs) == 0 { - createErrorResponse("No files match the tag query") - return - } - } else { - createErrorResponse("Invalid selection mode") - return - } - - validFiles, err := validateFileIDs(fileIDs) - if err != nil { - createErrorResponse(fmt.Sprintf("File validation error: %v", err)) - return - } - - err = applyBulkTagOperations(fileIDs, category, value, operation) - if err != nil { - createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err)) - return - } - - // Build success message - var successMsg string - var selectionDesc string - if selectionMode == "range" { - selectionDesc = fmt.Sprintf("file range '%s'", rangeStr) - } else { - selectionDesc = fmt.Sprintf("tag query '%s'", tagQuery) - } - - if operation == "add" { - successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files matching %s", - category, value, len(validFiles), selectionDesc) - } else { - if value != "" { - successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files matching %s", - category, value, len(validFiles), selectionDesc) - } else { - successMsg = fmt.Sprintf("All '%s' category tags removed from %d files matching %s", - category, len(validFiles), selectionDesc) - } - } - - // Add file list - var filenames []string - for _, f := range validFiles { - filenames = append(filenames, f.Filename) - } - if len(filenames) <= 5 { - successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", ")) - } else { - successMsg += fmt.Sprintf(": %s and %d more", strings.Join(filenames[:5], ", "), len(filenames)-5) - } - - formData.Success = successMsg - pageData := buildPageData("Bulk Tag Editor", formData) - renderTemplate(w, "bulk-tag.html", pageData) - return - } - renderError(w, "Method not allowed", http.StatusMethodNotAllowed) -} - - -func parseFileIDRange(rangeStr string) ([]int, error) { - var fileIDs []int - parts := strings.Split(rangeStr, ",") - - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - - if strings.Contains(part, "-") { - rangeParts := strings.Split(part, "-") - if len(rangeParts) != 2 { - return nil, fmt.Errorf("invalid range format: %s", part) - } - - start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0])) - if err != nil { - return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err) - } - - end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1])) - if err != nil { - return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err) - } - - if start > end { - return nil, fmt.Errorf("invalid range %s: start must be <= end", part) - } - - for i := start; i <= end; i++ { - fileIDs = append(fileIDs, i) - } - } else { - id, err := strconv.Atoi(part) - if err != nil { - return nil, fmt.Errorf("invalid file ID: %s", part) - } - fileIDs = append(fileIDs, id) - } - } - - uniqueIDs := make(map[int]bool) - var result []int - for _, id := range fileIDs { - if !uniqueIDs[id] { - uniqueIDs[id] = true - result = append(result, id) - } - } - - return result, nil -} - -func getFileIDsFromTagQuery(query string) ([]int, error) { - query = strings.TrimSpace(query) - if query == "" { - return nil, fmt.Errorf("empty query") - } - - // Check if query contains OR operator - if strings.Contains(strings.ToUpper(query), " OR ") { - return getFileIDsFromORQuery(query) - } - - // Otherwise treat as AND query (comma-separated or single tag) - return getFileIDsFromANDQuery(query) -} - -// getFileIDsFromANDQuery handles comma-separated tags (AND logic) -func getFileIDsFromANDQuery(query string) ([]int, error) { - tagPairs := strings.Split(query, ",") - var tags []TagPair - - for _, pair := range tagPairs { - pair = strings.TrimSpace(pair) - if pair == "" { - continue - } - - parts := strings.SplitN(pair, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair) - } - - tags = append(tags, TagPair{ - Category: strings.TrimSpace(parts[0]), - Value: strings.TrimSpace(parts[1]), - }) - } - - if len(tags) == 0 { - return nil, fmt.Errorf("no valid tags found in query") - } - - // Query database for files matching ALL tags - return findFilesWithAllTags(tags) -} - -// getFileIDsFromORQuery handles OR-separated tags -func getFileIDsFromORQuery(query string) ([]int, error) { - // Split on " OR " case-insensitively by working on the uppercased copy for - // index finding, but extracting substrings from the original query. - upperQuery := strings.ToUpper(query) - const sep = " OR " - var rawPairs []string - start := 0 - for { - idx := strings.Index(upperQuery[start:], sep) - if idx < 0 { - rawPairs = append(rawPairs, query[start:]) - break - } - rawPairs = append(rawPairs, query[start:start+idx]) - start += idx + len(sep) - } - - var tags []TagPair - for _, pair := range rawPairs { - pair = strings.TrimSpace(pair) - if pair == "" { - continue - } - - parts := strings.SplitN(pair, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair) - } - - tags = append(tags, TagPair{ - Category: strings.TrimSpace(parts[0]), - Value: strings.TrimSpace(parts[1]), - }) - } - - if len(tags) == 0 { - return nil, fmt.Errorf("no valid tags found in query") - } - - // Query database for files matching ANY tag - return findFilesWithAnyTag(tags) -} - -func validateFileIDs(fileIDs []int) ([]File, error) { - if len(fileIDs) == 0 { - return nil, fmt.Errorf("no file IDs provided") - } - - placeholders := make([]string, len(fileIDs)) - args := make([]interface{}, len(fileIDs)) - for i, id := range fileIDs { - placeholders[i] = "?" - args[i] = id - } - - query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id", - strings.Join(placeholders, ",")) - - rows, err := db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("database error: %v", err) - } - defer rows.Close() - - var files []File - foundIDs := make(map[int]bool) - - for rows.Next() { - var f File - err := rows.Scan(&f.ID, &f.Filename, &f.Path) - if err != nil { - return nil, fmt.Errorf("error scanning file: %v", err) - } - files = append(files, f) - foundIDs[f.ID] = true - } - - var missingIDs []int - for _, id := range fileIDs { - if !foundIDs[id] { - missingIDs = append(missingIDs, id) - } - } - - if len(missingIDs) > 0 { - return files, fmt.Errorf("file IDs not found: %v", missingIDs) - } - - return files, nil -} - -func findFilesWithAnyTag(tags []TagPair) ([]int, error) { - if len(tags) == 0 { - return nil, fmt.Errorf("no tags specified") - } - - // Build query with OR conditions - query := ` - SELECT DISTINCT f.id - FROM files f - INNER JOIN file_tags ft ON f.id = ft.file_id - INNER JOIN tags t ON ft.tag_id = t.id - INNER JOIN categories c ON t.category_id = c.id - WHERE ` - - var conditions []string - var args []interface{} - - for _, tag := range tags { - conditions = append(conditions, "(c.name = ? AND t.value = ?)") - args = append(args, tag.Category, tag.Value) - } - - query += strings.Join(conditions, " OR ") - query += " ORDER BY f.id" - - rows, err := db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("database query failed: %w", err) - } - defer rows.Close() - - var fileIDs []int - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("scan error: %w", err) - } - fileIDs = append(fileIDs, id) - } - - return fileIDs, rows.Err() -} - - -func findFilesWithAllTags(tags []TagPair) ([]int, error) { - if len(tags) == 0 { - return nil, fmt.Errorf("no tags specified") - } - - // Build query with subqueries for each tag - query := ` - SELECT f.id - FROM files f - WHERE ` - - var conditions []string - var args []interface{} - - for _, tag := range tags { - conditions = append(conditions, ` - EXISTS ( - SELECT 1 FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - JOIN categories c ON t.category_id = c.id - WHERE ft.file_id = f.id - AND c.name = ? - AND t.value = ? - )`) - args = append(args, tag.Category, tag.Value) - } - - query += strings.Join(conditions, " AND ") - query += " ORDER BY f.id" - - rows, err := db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("database query failed: %w", err) - } - defer rows.Close() - - var fileIDs []int - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("scan error: %w", err) - } - fileIDs = append(fileIDs, id) - } - - return fileIDs, rows.Err() -} -\ No newline at end of file diff --git a/include-cbz.go b/include-cbz.go @@ -1,355 +0,0 @@ -package main - -import ( - "archive/zip" - "fmt" - "image" - "image/jpeg" - "io" - "log" - "net/http" - "os" - "path/filepath" - "sort" - "strings" -) - -type CBZImage struct { - Filename string - Index int -} - -// generateCBZThumbnail creates a 2x2 collage thumbnail from a CBZ file -func generateCBZThumbnail(cbzPath, uploadDir, filename string) error { - - thumbDir := filepath.Join(uploadDir, "thumbnails") - - if err := os.MkdirAll(thumbDir, 0755); err != nil { - return fmt.Errorf("failed to create thumbnails directory: %v", err) - } - - thumbPath := filepath.Join(thumbDir, filename+".jpg") - - // Open the CBZ (ZIP) file - r, err := zip.OpenReader(cbzPath) - if err != nil { - return fmt.Errorf("failed to open CBZ: %v", err) - } - defer r.Close() - - // Get list of image files from the archive - var imageFiles []*zip.File - for _, f := range r.File { - if !f.FileInfo().IsDir() { - ext := strings.ToLower(filepath.Ext(f.Name)) - if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { - imageFiles = append(imageFiles, f) - } - } - } - - if len(imageFiles) == 0 { - return fmt.Errorf("no images found in CBZ") - } - - // Sort files by name to get consistent ordering - sort.Slice(imageFiles, func(i, j int) bool { - return imageFiles[i].Name < imageFiles[j].Name - }) - - // Select up to 4 images evenly distributed - var selectedFiles []*zip.File - if len(imageFiles) <= 4 { - selectedFiles = imageFiles - } else { - // Pick 4 images evenly distributed through the comic - step := len(imageFiles) / 4 - for i := 0; i < 4; i++ { - selectedFiles = append(selectedFiles, imageFiles[i*step]) - } - } - - // Load the selected images - var images []image.Image - for _, f := range selectedFiles { - rc, err := f.Open() - if err != nil { - log.Printf("Warning: generateCBZThumbnail: failed to open %s: %v", f.Name, err) - continue - } - - img, _, err := image.Decode(rc) - rc.Close() - if err != nil { - log.Printf("Warning: generateCBZThumbnail: failed to decode %s: %v", f.Name, err) - continue - } - images = append(images, img) - } - - if len(images) == 0 { - return fmt.Errorf("failed to decode any images from CBZ") - } - - // Create collage - collage := createCollage(images, 400) // 400px target width - - // Save as JPEG - outFile, err := os.Create(thumbPath) - if err != nil { - return fmt.Errorf("failed to create thumbnail file: %v", err) - } - defer outFile.Close() - - if err := jpeg.Encode(outFile, collage, &jpeg.Options{Quality: 85}); err != nil { - return fmt.Errorf("failed to encode JPEG: %v", err) - } - - return nil -} - -// createCollage creates a 2x2 grid from up to 4 images -func createCollage(images []image.Image, targetWidth int) image.Image { - // Calculate cell size (half of target width) - cellSize := targetWidth / 2 - - // Create output image - collageImg := image.NewRGBA(image.Rect(0, 0, targetWidth, targetWidth)) - - // Fill with white background - for y := 0; y < targetWidth; y++ { - for x := 0; x < targetWidth; x++ { - collageImg.Set(x, y, image.White) - } - } - - // Positions for the 2x2 grid - positions := []image.Point{ - {0, 0}, // Top-left - {cellSize, 0}, // Top-right - {0, cellSize}, // Bottom-left - {cellSize, cellSize}, // Bottom-right - } - - // Draw each image - for i, img := range images { - if i >= 4 { - break - } - - // Resize image to fit cell - resized := resizeImage(img, cellSize, cellSize) - - // Draw at position - pos := positions[i] - drawImage(collageImg, resized, pos.X, pos.Y) - } - - return collageImg -} - -// resizeImage resizes an image to fit within maxWidth x maxHeight while maintaining aspect ratio -func resizeImage(img image.Image, maxWidth, maxHeight int) image.Image { - bounds := img.Bounds() - srcWidth := bounds.Dx() - srcHeight := bounds.Dy() - - // Calculate scale to fit - scaleX := float64(maxWidth) / float64(srcWidth) - scaleY := float64(maxHeight) / float64(srcHeight) - scale := scaleX - if scaleY < scale { - scale = scaleY - } - - newWidth := int(float64(srcWidth) * scale) - newHeight := int(float64(srcHeight) * scale) - - // Create new image - dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) - - // Simple nearest-neighbor scaling - for y := 0; y < newHeight; y++ { - for x := 0; x < newWidth; x++ { - srcX := int(float64(x) / scale) - srcY := int(float64(y) / scale) - dst.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) - } - } - - return dst -} - -// drawImage draws src onto dst at the given position -func drawImage(dst *image.RGBA, src image.Image, x, y int) { - bounds := src.Bounds() - for dy := 0; dy < bounds.Dy(); dy++ { - for dx := 0; dx < bounds.Dx(); dx++ { - dst.Set(x+dx, y+dy, src.At(bounds.Min.X+dx, bounds.Min.Y+dy)) - } - } -} - -// getCBZImages returns a list of images in a CBZ file -func getCBZImages(cbzPath string) ([]CBZImage, error) { - r, err := zip.OpenReader(cbzPath) - if err != nil { - return nil, err - } - defer r.Close() - - var images []CBZImage - for i, f := range r.File { - if !f.FileInfo().IsDir() { - ext := strings.ToLower(filepath.Ext(f.Name)) - if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { - images = append(images, CBZImage{ - Filename: f.Name, - Index: i, - }) - } - } - } - - // Sort by filename for consistent ordering - sort.Slice(images, func(i, j int) bool { - return images[i].Filename < images[j].Filename - }) - - // Re-index after sorting - for i := range images { - images[i].Index = i - } - - return images, nil -} - -// serveCBZImage extracts and serves a specific image from a CBZ file -func serveCBZImage(w http.ResponseWriter, cbzPath string, imageIndex int) error { - r, err := zip.OpenReader(cbzPath) - if err != nil { - return err - } - defer r.Close() - - // Get sorted list of images - var imageFiles []*zip.File - for _, f := range r.File { - if !f.FileInfo().IsDir() { - ext := strings.ToLower(filepath.Ext(f.Name)) - if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { - imageFiles = append(imageFiles, f) - } - } - } - - sort.Slice(imageFiles, func(i, j int) bool { - return imageFiles[i].Name < imageFiles[j].Name - }) - - if imageIndex < 0 || imageIndex >= len(imageFiles) { - return fmt.Errorf("image index out of range") - } - - targetFile := imageFiles[imageIndex] - - // Set content type based on extension - ext := strings.ToLower(filepath.Ext(targetFile.Name)) - switch ext { - case ".jpg", ".jpeg": - w.Header().Set("Content-Type", "image/jpeg") - case ".png": - w.Header().Set("Content-Type", "image/png") - case ".webp": - w.Header().Set("Content-Type", "image/webp") - } - - rc, err := targetFile.Open() - if err != nil { - return err - } - defer rc.Close() - - _, err = io.Copy(w, rc) - return err -} - -// cbzViewerHandler handles the CBZ gallery viewer -func cbzViewerHandler(w http.ResponseWriter, r *http.Request) { - // Extract file ID from URL - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/cbz/"), "/") - if len(parts) < 1 { - renderError(w, "Invalid CBZ viewer path", http.StatusBadRequest) - return - } - - fileID := parts[0] - - // Get the file from database - var f File - err := db.QueryRow("SELECT id, filename, path, COALESCE(description, '') FROM files WHERE id = ?", fileID). - Scan(&f.ID, &f.Filename, &f.Path, &f.Description) - if err != nil { - log.Printf("Error: cbzViewerHandler: file not found for id=%s: %v", fileID, err) - renderError(w, "File not found", http.StatusNotFound) - return - } - - cbzPath := filepath.Join(config.UploadDir, f.Path) - - // Check if requesting a specific image - if len(parts) >= 3 && parts[1] == "image" { - imageIndex := 0 - fmt.Sscanf(parts[2], "%d", &imageIndex) - - if err := serveCBZImage(w, cbzPath, imageIndex); err != nil { - log.Printf("Error: cbzViewerHandler: failed to serve image index %d from %s: %v", imageIndex, cbzPath, err) - renderError(w, "Failed to serve image", http.StatusInternalServerError) - } - return - } - - // Get list of images - images, err := getCBZImages(cbzPath) - if err != nil { - log.Printf("Error: cbzViewerHandler: failed to read CBZ contents at %s: %v", cbzPath, err) - renderError(w, "Failed to read CBZ contents", http.StatusInternalServerError) - return - } - - // Determine which image to display - currentIndex := 0 - if len(parts) >= 2 { - fmt.Sscanf(parts[1], "%d", &currentIndex) - } - - if currentIndex < 0 { - currentIndex = 0 - } - if currentIndex >= len(images) { - currentIndex = len(images) - 1 - } - - // Prepare data for template - type CBZViewData struct { - File File - Images []CBZImage - CurrentIndex int - TotalImages int - HasPrev bool - HasNext bool - } - - viewData := CBZViewData{ - File: f, - Images: images, - CurrentIndex: currentIndex, - TotalImages: len(images), - HasPrev: currentIndex > 0, - HasNext: currentIndex < len(images)-1, - } - - pageData := buildPageData(f.Filename, viewData) - - renderTemplate(w, "cbz_viewer.html", pageData) -} -\ No newline at end of file diff --git a/include-db.go b/include-db.go @@ -1,214 +0,0 @@ -package main - -import ( - "database/sql" - "strings" - - _ "github.com/mattn/go-sqlite3" -) - -type Config struct { - // Values from CLI arguments - DatabasePath string - UploadDir string - ServerPort string - // Values from database - GallerySize string - ItemsPerPage string - TagAliases []TagAliasGroup - SedRules []SedRule -} - -type TagAliasGroup struct { - Category string `json:"category"` - Aliases []string `json:"aliases"` -} - -// InitDatabase opens the database connection and creates tables if needed -func InitDatabase(dbPath string) (*sql.DB, error) { - db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000") - if err != nil { - return nil, err - } - - if err := createTables(db); err != nil { - db.Close() - return nil, err - } - - return db, nil -} - -// createTables creates all necessary database tables -func createTables(db *sql.DB) error { - schema := ` - CREATE TABLE IF NOT EXISTS files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filename TEXT, - path TEXT, - description TEXT DEFAULT '' - ); - CREATE TABLE IF NOT EXISTS categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE - ); - CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category_id INTEGER, - value TEXT, - UNIQUE(category_id, value) - ); - CREATE TABLE IF NOT EXISTS file_tags ( - file_id INTEGER, - tag_id INTEGER, - UNIQUE(file_id, tag_id) - ); - CREATE TABLE IF NOT EXISTS file_properties ( - file_id INTEGER, - key TEXT, - value TEXT, - UNIQUE(file_id, key) - ); - CREATE TABLE IF NOT EXISTS notes ( - id INTEGER PRIMARY KEY CHECK (id = 1), - content TEXT DEFAULT '', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL DEFAULT '' - ); - CREATE TABLE IF NOT EXISTS tag_aliases ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category TEXT NOT NULL, - aliases TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS sed_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - command TEXT NOT NULL - ); - ` - - _, err := db.Exec(schema) - return err -} - -func LoadConfig(db *sql.DB) (Config, error) { - cfg := Config{ - GallerySize: "400px", - ItemsPerPage: "100", - TagAliases: []TagAliasGroup{}, - SedRules: []SedRule{}, - } - - rows, err := db.Query(`SELECT key, value FROM settings`) - if err != nil { - return cfg, err - } - defer rows.Close() - for rows.Next() { - var key, value string - if err := rows.Scan(&key, &value); err != nil { - return cfg, err - } - switch key { - case "gallery_size": - if value != "" { - cfg.GallerySize = value - } - case "items_per_page": - if value != "" { - cfg.ItemsPerPage = value - } - } - } - if err := rows.Err(); err != nil { - return cfg, err - } - - aliasRows, err := db.Query(`SELECT category, aliases FROM tag_aliases ORDER BY id`) - if err != nil { - return cfg, err - } - defer aliasRows.Close() - for aliasRows.Next() { - var category, aliasesStr string - if err := aliasRows.Scan(&category, &aliasesStr); err != nil { - return cfg, err - } - cfg.TagAliases = append(cfg.TagAliases, TagAliasGroup{ - Category: category, - Aliases: strings.Split(aliasesStr, "|"), - }) - } - if err := aliasRows.Err(); err != nil { - return cfg, err - } - - sedRows, err := db.Query(`SELECT name, description, command FROM sed_rules ORDER BY id`) - if err != nil { - return cfg, err - } - defer sedRows.Close() - for sedRows.Next() { - var rule SedRule - if err := sedRows.Scan(&rule.Name, &rule.Description, &rule.Command); err != nil { - return cfg, err - } - cfg.SedRules = append(cfg.SedRules, rule) - } - if err := sedRows.Err(); err != nil { - return cfg, err - } - - return cfg, nil -} - -func SaveConfig(db *sql.DB, cfg Config) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - for _, kv := range [][2]string{ - {"gallery_size", cfg.GallerySize}, - {"items_per_page", cfg.ItemsPerPage}, - } { - if _, err := tx.Exec(` - INSERT INTO settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - `, kv[0], kv[1]); err != nil { - return err - } - } - - if _, err := tx.Exec(`DELETE FROM tag_aliases`); err != nil { - return err - } - for _, group := range cfg.TagAliases { - aliasesStr := strings.Join(group.Aliases, "|") - if _, err := tx.Exec( - `INSERT INTO tag_aliases (category, aliases) VALUES (?, ?)`, - group.Category, aliasesStr, - ); err != nil { - return err - } - } - - if _, err := tx.Exec(`DELETE FROM sed_rules`); err != nil { - return err - } - for _, rule := range cfg.SedRules { - if _, err := tx.Exec( - `INSERT INTO sed_rules (name, description, command) VALUES (?, ?, ?)`, - rule.Name, rule.Description, rule.Command, - ); err != nil { - return err - } - } - - return tx.Commit() -} diff --git a/include-files.go b/include-files.go @@ -1,205 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" -) - -func fileRouter(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(r.URL.Path, "/") - - if len(parts) >= 4 && parts[3] == "delete" { - fileDeleteHandler(w, r, parts) - return - } - - if len(parts) >= 4 && parts[3] == "rename" { - fileRenameHandler(w, r, parts) - return - } - - if len(parts) >= 5 && parts[3] == "tag" && parts[4] == "delete" { - tagActionHandler(w, r, parts) - return - } - - fileHandler(w, r) -} - -func fileDeleteHandler(w http.ResponseWriter, r *http.Request, parts []string) { - if r.Method != http.MethodPost { - http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) - return - } - - fileID := parts[2] - - var currentFile File - err := db.QueryRow("SELECT id, filename, path FROM files WHERE id=?", fileID).Scan(&currentFile.ID, &currentFile.Filename, &currentFile.Path) - if err != nil { - log.Printf("Error: fileDeleteHandler: file not found for id=%s: %v", fileID, err) - renderError(w, "File not found", http.StatusNotFound) - return - } - - tx, err := db.Begin() - if err != nil { - log.Printf("Error: fileDeleteHandler: failed to start transaction for file id=%s: %v", fileID, err) - renderError(w, "Failed to start transaction", http.StatusInternalServerError) - return - } - defer tx.Rollback() - - if _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=?", fileID); err != nil { - log.Printf("Error: fileDeleteHandler: failed to delete file_tags for file id=%s: %v", fileID, err) - renderError(w, "Failed to delete file tags", http.StatusInternalServerError) - return - } - - if _, err = tx.Exec("DELETE FROM file_properties WHERE file_id=?", fileID); err != nil { - log.Printf("Error: fileDeleteHandler: failed to delete file_properties for file id=%s: %v", fileID, err) - renderError(w, "Failed to delete file properties", http.StatusInternalServerError) - return - } - - if _, err = tx.Exec("DELETE FROM files WHERE id=?", fileID); err != nil { - log.Printf("Error: fileDeleteHandler: failed to delete files record for file id=%s: %v", fileID, err) - renderError(w, "Failed to delete file record", http.StatusInternalServerError) - return - } - - if err = tx.Commit(); err != nil { - log.Printf("Error: fileDeleteHandler: failed to commit transaction for file id=%s: %v", fileID, err) - renderError(w, "Failed to commit transaction", http.StatusInternalServerError) - return - } - - absPath := filepath.Join(config.UploadDir, currentFile.Path) - if err = os.Remove(absPath); err != nil { - log.Printf("Warning: fileDeleteHandler: failed to delete physical file %s: %v", absPath, err) - } - - // Delete thumbnail if it exists - thumbPath := filepath.Join(config.UploadDir, "thumbnails", currentFile.Filename+".jpg") - if _, err := os.Stat(thumbPath); err == nil { - if err := os.Remove(thumbPath); err != nil { - log.Printf("Warning: fileDeleteHandler: failed to delete thumbnail %s: %v", thumbPath, err) - } - } - - http.Redirect(w, r, "/?deleted="+currentFile.Filename, http.StatusSeeOther) -} - -func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) { - if r.Method != http.MethodPost { - http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) - return - } - - fileID := parts[2] - newFilename := sanitizeFilename(strings.TrimSpace(r.FormValue("newfilename"))) - - if newFilename == "" { - renderError(w, "New filename cannot be empty", http.StatusBadRequest) - return - } - - var currentFilename, currentRelPath string - err := db.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&currentFilename, &currentRelPath) - if err != nil { - log.Printf("Error: fileRenameHandler: file not found for id=%s: %v", fileID, err) - renderError(w, "File not found", http.StatusNotFound) - return - } - - if currentFilename == newFilename { - http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) - return - } - - currentAbsPath := filepath.Join(config.UploadDir, currentRelPath) - newPath := filepath.Join(config.UploadDir, newFilename) - if _, err := os.Stat(newPath); !os.IsNotExist(err) { - renderError(w, "A file with that name already exists", http.StatusConflict) - return - } - - if err := os.Rename(currentAbsPath, newPath); err != nil { - log.Printf("Error: fileRenameHandler: failed to rename %s -> %s: %v", currentAbsPath, newPath, err) - renderError(w, "Failed to rename physical file: "+err.Error(), http.StatusInternalServerError) - return - } - - thumbOld := filepath.Join(config.UploadDir, "thumbnails", currentFilename+".jpg") - thumbNew := filepath.Join(config.UploadDir, "thumbnails", newFilename+".jpg") - - if _, err := os.Stat(thumbOld); err == nil { - if err := os.Rename(thumbOld, thumbNew); err != nil { - log.Printf("Error: fileRenameHandler: failed to rename thumbnail %s -> %s: %v", thumbOld, thumbNew, err) - if renameErr := os.Rename(newPath, currentAbsPath); renameErr != nil { - log.Printf("Error: fileRenameHandler: failed to roll back file rename %s -> %s: %v", newPath, currentAbsPath, renameErr) - } - renderError(w, "Failed to rename thumbnail: "+err.Error(), http.StatusInternalServerError) - return - } - } - - newRelPath, err := filepath.Rel(config.UploadDir, newPath) - if err != nil { - log.Printf("Error: fileRenameHandler: failed to compute relative path for %s: %v", newPath, err) - newRelPath = newFilename - } - - _, err = db.Exec("UPDATE files SET filename=?, path=? WHERE id=?", newFilename, newRelPath, fileID) - if err != nil { - log.Printf("Error: fileRenameHandler: failed to update database for file id=%s: %v", fileID, err) - if renameErr := os.Rename(newPath, currentAbsPath); renameErr != nil { - log.Printf("Error: fileRenameHandler: failed to roll back file rename %s -> %s: %v", newPath, currentAbsPath, renameErr) - } - if _, statErr := os.Stat(thumbNew); statErr == nil { - if renameErr := os.Rename(thumbNew, thumbOld); renameErr != nil { - log.Printf("Error: fileRenameHandler: failed to roll back thumbnail rename %s -> %s: %v", thumbNew, thumbOld, renameErr) - } - } - renderError(w, "Failed to update database", http.StatusInternalServerError) - return - } - - // Recompute properties in case the extension changed - if _, err := db.Exec("DELETE FROM file_properties WHERE file_id = ?", fileID); err != nil { - log.Printf("Warning: fileRenameHandler: failed to delete old properties for file id=%s: %v", fileID, err) - } - if id, err := strconv.ParseInt(fileID, 10, 64); err == nil { - computeProperties(id, newPath) - } else { - log.Printf("Warning: fileRenameHandler: failed to parse file id %s for property recompute: %v", fileID, err) - } - - http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) -} - -func checkFileConflictStrict(filename string) (string, string, int64, error) { - finalPath := filepath.Join(config.UploadDir, filename) - if _, err := os.Stat(finalPath); err == nil { - existingID, dbErr := getFileIDByName(filename) - if dbErr != nil { - log.Printf("Warning: checkFileConflictStrict: file %s exists on disk but not in DB: %v", filename, dbErr) - return "", "", 0, fmt.Errorf("a file with that name already exists") - } - return "", "", existingID, nil - } else if !os.IsNotExist(err) { - return "", "", 0, fmt.Errorf("failed to check for existing file: %v", err) - } - return filename, finalPath, 0, nil -} - -func getFileIDByName(filename string) (int64, error) { - var id int64 - err := db.QueryRow("SELECT id FROM files WHERE filename = ?", filename).Scan(&id) - return id, err -} -\ No newline at end of file diff --git a/include-filters.go b/include-filters.go @@ -1,307 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net/http" - "net/url" - "strings" -) - -type Breadcrumb struct { - Name string - URL string - Count int // 0 = not shown -} - -type filter struct { - Category string - Value string - Values []string // Expanded values including aliases - IsPreviews bool // New field to indicate preview mode - IsProperty bool -} - -func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) { - page := pageFromRequest(r) - perPage := perPageFromConfig(50) - - files, total, err := getUntaggedFilesPaginated(page, perPage) - if err != nil { - log.Printf("Error: untaggedFilesHandler: failed to get untagged files: %v", err) - } - pageData := buildPageDataWithPagination("Untagged Files", files, page, total, perPage, r) - renderTemplate(w, "untagged.html", pageData) -} - -func buildTagFilterWhere(filters []filter) (string, []interface{}) { - where := " WHERE 1=1" - var args []interface{} - - for _, f := range filters { - if f.IsProperty { - where += ` - AND EXISTS ( - SELECT 1 - FROM file_properties fp - WHERE fp.file_id = f.id AND fp.key = ? AND fp.value = ? - )` - args = append(args, f.Category, f.Value) - } else if f.Value == "unassigned" { - where += ` - AND NOT EXISTS ( - SELECT 1 - FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - JOIN categories c ON c.id = t.category_id - WHERE ft.file_id = f.id AND c.name = ? - )` - args = append(args, f.Category) - } else { - // Build OR clause for aliases - placeholders := make([]string, len(f.Values)) - for i := range f.Values { - placeholders[i] = "?" - } - - where += fmt.Sprintf(` - AND EXISTS ( - SELECT 1 - FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - JOIN categories c ON c.id = t.category_id - WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) - )`, strings.Join(placeholders, ",")) - - args = append(args, f.Category) - for _, v := range f.Values { - args = append(args, v) - } - } - } - - return where, args -} - -// parseFilterSegments splits filter path into individual filter structs -func parseFilterSegments(fullPath, firstKind string) ([]filter, []Breadcrumb, error) { - rawSegments := strings.Split(fullPath, "/and/") - - breadcrumbs := []Breadcrumb{ - {Name: "home", URL: "/"}, - {Name: "tags", URL: "/tags"}, - } - - var filters []filter - currentPath := "/" + firstKind - - for i, seg := range rawSegments { - var kind, category, value string - - if i == 0 { - // First segment has no explicit kind prefix — the caller supplies it. - parts := strings.SplitN(seg, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return nil, nil, fmt.Errorf("invalid %s segment: %q", firstKind, seg) - } - kind, category, value = firstKind, parts[0], parts[1] - } else { - // Subsequent segments are "kind/category/value". - parts := strings.SplitN(seg, "/", 3) - if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - return nil, nil, fmt.Errorf("invalid filter segment: %q", seg) - } - kind, category, value = parts[0], parts[1], parts[2] - if kind != "tag" && kind != "property" { - return nil, nil, fmt.Errorf("unknown filter kind %q in segment: %q", kind, seg) - } - } - - f := filter{ - Category: category, - Value: value, - IsProperty: kind == "property", - IsPreviews: kind == "tag" && value == "previews", - } - - if kind == "tag" && value != "unassigned" && value != "previews" { - f.Values = expandTagWithAliases(category, value) - } - - filters = append(filters, f) - - // Build the cumulative breadcrumb URL for this filter step. - if i == 0 { - currentPath += "/" + category + "/" + value - } else { - currentPath += "/and/" + kind + "/" + category + "/" + value - } - - // Add a category/key breadcrumb (deduplicated). - categoryExists := false - for _, bc := range breadcrumbs { - if bc.Name == category { - categoryExists = true - break - } - } - if !categoryExists { - anchorURL := "/tags#tag-" + category - if kind == "property" { - anchorURL = "/properties#prop-" + category - } - breadcrumbs = append(breadcrumbs, Breadcrumb{ - Name: category, - URL: anchorURL, - }) - } - - breadcrumbs = append(breadcrumbs, Breadcrumb{ - Name: value, - URL: currentPath, - }) - } - - return filters, breadcrumbs, nil -} - -func tagFilterHandler(w http.ResponseWriter, r *http.Request) { - page := pageFromRequest(r) - perPage := perPageFromConfig(50) - - fullPath := strings.TrimPrefix(r.URL.Path, "/tag/") - - filters, breadcrumbs, err := parseFilterSegments(fullPath, "tag") - if err != nil { - renderError(w, "Invalid filter path", http.StatusBadRequest) - return - } - - // Check if we're in preview mode for any filter. - hasPreviewFilter := false - for _, f := range filters { - if f.IsPreviews { - hasPreviewFilter = true - break - } - } - - if hasPreviewFilter { - files, err := getPreviewFiles(filters) - if err != nil { - log.Printf("Error: tagFilterHandler: failed to fetch preview files: %v", err) - renderError(w, "Failed to fetch preview files", http.StatusInternalServerError) - return - } - - title := "Tagged: " + buildFilterTitle(filters, " + ") - pageData := buildPageDataWithPagination(title, ListData{ - Tagged: files, - Untagged: nil, - Breadcrumbs: []Breadcrumb{}, - }, 1, len(files), len(files), r) - if len(breadcrumbs) > 0 { - breadcrumbs[len(breadcrumbs)-1].Count = len(files) - } - pageData.Breadcrumbs = breadcrumbs - - renderTemplate(w, "list.html", pageData) - return - } - - // Build the shared WHERE clause once and reuse it for both queries. - where, whereArgs := buildTagFilterWhere(filters) - - var total int - countArgs := append([]interface{}(nil), whereArgs...) // copy; count query does not need pagination args - err = db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total) - if err != nil { - log.Printf("Error: tagFilterHandler: failed to count files: %v", err) - renderError(w, "Failed to count files", http.StatusInternalServerError) - return - } - - offset := (page - 1) * perPage - dataArgs := append(append([]interface{}(nil), whereArgs...), perPage, offset) - files, err := queryFilesWithTags( - `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f`+ - where+` ORDER BY f.id DESC LIMIT ? OFFSET ?`, - dataArgs..., - ) - if err != nil { - log.Printf("Error: tagFilterHandler: failed to fetch files: %v", err) - renderError(w, "Failed to fetch files", http.StatusInternalServerError) - return - } - - title := "Tagged: " + buildFilterTitle(filters, ", ") - pageData := buildPageDataWithPagination(title, ListData{ - Tagged: files, - Untagged: nil, - Breadcrumbs: []Breadcrumb{}, - }, page, total, perPage, r) - if len(breadcrumbs) > 0 { - breadcrumbs[len(breadcrumbs)-1].Count = total - } - pageData.Breadcrumbs = breadcrumbs - - renderTemplate(w, "list.html", pageData) -} - -func buildFilterTitle(filters []filter, sep string) string { - var parts []string - for _, f := range filters { - parts = append(parts, fmt.Sprintf("%s: %s", f.Category, f.Value)) - } - return strings.Join(parts, sep) -} - -func expandTagWithAliases(category, value string) []string { - values := []string{value} - - for _, group := range config.TagAliases { - if group.Category != category { - continue - } - - // Check if the value is in this alias group - found := false - for _, alias := range group.Aliases { - if strings.EqualFold(alias, value) { - found = true - break - } - } - - if found { - // Add all aliases from this group - for _, alias := range group.Aliases { - if !strings.EqualFold(alias, value) { - values = append(values, alias) - } - } - break - } - } - - return values -} - -func queryFilesWithTags(query string, args ...interface{}) ([]File, error) { - rows, err := db.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var files []File - for rows.Next() { - var f File - if err := rows.Scan(&f.ID, &f.Filename, &f.Path, &f.Description); err != nil { - return nil, err - } - f.EscapedFilename = url.PathEscape(f.Filename) - files = append(files, f) - } - return files, nil -} -\ No newline at end of file diff --git a/include-general.go b/include-general.go @@ -1,159 +0,0 @@ -package main - -import ( - "log" - "net/http" - "net/url" - "strings" -) - -type TagDisplay struct { - Value string - Count int -} - -type ListData struct { - Tagged []File - Untagged []File - Breadcrumbs []Breadcrumb -} - -type PageData struct { - Title string - Data interface{} - Query string - IP string - Port string - Files []File - Tags map[string][]TagDisplay - Properties map[string][]PropertyDisplay - Breadcrumbs []Breadcrumb - Pagination *Pagination - GallerySize string - UntaggedCount int -} - -type File struct { - ID int - Filename string - EscapedFilename string - Path string - Description string - Tags map[string][]string -} - -func sanitizeFilename(filename string) string { - if filename == "" { - return "file" - } - filename = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(filename, "/", "_"), "\\", "_"), "..", "_") - if filename == "" { - return "file" - } - return filename -} - -func renderError(w http.ResponseWriter, message string, statusCode int) { - http.Error(w, message, statusCode) -} - -func renderTemplate(w http.ResponseWriter, tmplName string, data PageData) { - if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil { - log.Printf("Error: renderTemplate: failed to execute template %s: %v", tmplName, err) - renderError(w, "Template rendering failed", http.StatusInternalServerError) - } -} - -func redirectWithWarning(w http.ResponseWriter, r *http.Request, baseURL, warningMsg string) { - redirectURL := baseURL - if warningMsg != "" { - redirectURL += "?warning=" + url.QueryEscape(warningMsg) - } - http.Redirect(w, r, redirectURL, http.StatusSeeOther) -} - -func errorString(err error) string { - if err != nil { - return err.Error() - } - return "" -} - -func successString(err error, msg string) string { - if err == nil { - return msg - } - return "" -} - -func buildPageData(title string, data interface{}) PageData { - tagMap, err := getTagData() - if err != nil { - log.Printf("Warning: buildPageData: failed to load tag data for page %q: %v", title, err) - } - propMap, err := getPropertyNav() - if err != nil { - log.Printf("Warning: buildPageData: failed to load property nav for page %q: %v", title, err) - } - return PageData{ - Title: title, - Data: data, - Tags: tagMap, - Properties: propMap, - GallerySize: config.GallerySize, - UntaggedCount: getUntaggedCount(), - } -} - -func getTagData() (map[string][]TagDisplay, error) { - rows, err := db.Query(` - SELECT c.name, t.value, COUNT(ft.file_id) - FROM tags t - JOIN categories c ON c.id = t.category_id - LEFT JOIN file_tags ft ON ft.tag_id = t.id - GROUP BY t.id - HAVING COUNT(ft.file_id) > 0 - ORDER BY c.name, t.value`) - if err != nil { - return nil, err - } - defer rows.Close() - - tagMap := make(map[string][]TagDisplay) - for rows.Next() { - var cat, val string - var count int - if err := rows.Scan(&cat, &val, &count); err != nil { - log.Printf("Warning: getTagData: failed to scan row: %v", err) - continue - } - tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count}) - } - if err := rows.Err(); err != nil { - return tagMap, err - } - return tagMap, nil -} - -func tagsHandler(w http.ResponseWriter, r *http.Request) { - pageData := buildPageData("All Tags", nil) - pageData.Data = pageData.Tags - renderTemplate(w, "tags.html", pageData) -} - -func getUntaggedCount() int { - var count int - - err := db.QueryRow(` - SELECT COUNT(*) - FROM files f - LEFT JOIN file_tags ft ON ft.file_id = f.id - WHERE ft.file_id IS NULL - `).Scan(&count) - - if err != nil { - log.Printf("Warning: getUntaggedCount: %v", err) - return 0 - } - return count -} diff --git a/include-notes.go b/include-notes.go @@ -1,444 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "os/exec" - "sort" - "strconv" - "strings" -) - -type notesAnalysis struct { - Stats map[string]int - Categories []string - LineCount int -} - -type Note struct { - Category string - Value string - Original string // The full line as stored -} - -// GetNotes retrieves the notes content from database -func GetNotes(db *sql.DB) (string, error) { - var content string - err := db.QueryRow("SELECT content FROM notes WHERE id = 1").Scan(&content) - if err == sql.ErrNoRows { - return "", nil - } - return content, err -} - -// SaveNotes saves the notes content to database with sorting and deduplication -func SaveNotes(db *sql.DB, content string) error { - // Process: deduplicate and sort - processed := ProcessNotes(content) - - _, err := db.Exec(` - INSERT INTO notes (id, content, updated_at) - VALUES (1, ?, datetime('now')) - ON CONFLICT(id) DO UPDATE SET - content = excluded.content, - updated_at = excluded.updated_at - `, processed) - - return err -} - -// ProcessNotes deduplicates and sorts lines alphabetically -func ProcessNotes(content string) string { - lines := strings.Split(content, "\n") - - // Deduplicate using map - seen := make(map[string]bool) - var unique []string - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue // Skip empty lines - } - if !seen[trimmed] { - seen[trimmed] = true - unique = append(unique, trimmed) - } - } - - // Sort alphabetically (case-insensitive) - sort.Slice(unique, func(i, j int) bool { - return strings.ToLower(unique[i]) < strings.ToLower(unique[j]) - }) - - return strings.Join(unique, "\n") -} - -// ApplySedRule applies a sed command to content -func ApplySedRule(content, sedCmd string) (string, error) { - // Create temp file for input - tmpIn, err := os.CreateTemp("", "notes-in-*.txt") - if err != nil { - return "", fmt.Errorf("failed to create temp file: %v", err) - } - defer os.Remove(tmpIn.Name()) - defer tmpIn.Close() - - if _, err := tmpIn.WriteString(content); err != nil { - return "", fmt.Errorf("failed to write temp file: %v", err) - } - tmpIn.Close() - - // Run sed command - cmd := exec.Command("sed", sedCmd, tmpIn.Name()) - - // Capture both stdout and stderr - var stdout, stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err = cmd.Run() - if err != nil { - // Include stderr in error message for debugging - errMsg := stderr.String() - if errMsg != "" { - return "", fmt.Errorf("sed failed: %s (command: sed %s)", errMsg, sedCmd) - } - return "", fmt.Errorf("sed failed: %v (command: sed %s)", err, sedCmd) - } - - return stdout.String(), nil -} - -// ParseNote parses a line into category and value -func ParseNote(line string) Note { - parts := strings.SplitN(line, ">", 2) - - note := Note{Original: line} - - if len(parts) == 2 { - note.Category = strings.TrimSpace(parts[0]) - note.Value = strings.TrimSpace(parts[1]) - } else { - note.Value = strings.TrimSpace(line) - } - - return note -} - -// FilterNotes filters notes by search term -func FilterNotes(content, searchTerm string) string { - if searchTerm == "" { - return content - } - - lines := strings.Split(content, "\n") - var filtered []string - - searchLower := strings.ToLower(searchTerm) - - for _, line := range lines { - if strings.Contains(strings.ToLower(line), searchLower) { - filtered = append(filtered, line) - } - } - - return strings.Join(filtered, "\n") -} - -// FilterByCategory filters notes by category -func FilterByCategory(content, category string) string { - if category == "" { - return content - } - - lines := strings.Split(content, "\n") - var filtered []string - - for _, line := range lines { - note := ParseNote(line) - if note.Category == category { - filtered = append(filtered, line) - } - } - - return strings.Join(filtered, "\n") -} - -func analyzeNotes(content string) notesAnalysis { - lines := strings.Split(content, "\n") - - totalLines := 0 - categorizedLines := 0 - categoryMap := make(map[string]bool) - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - totalLines++ - note := ParseNote(trimmed) - if note.Category != "" { - categorizedLines++ - categoryMap[note.Category] = true - } - } - - categories := make([]string, 0, len(categoryMap)) - for cat := range categoryMap { - categories = append(categories, cat) - } - sort.Strings(categories) - - return notesAnalysis{ - Stats: map[string]int{ - "total_lines": totalLines, - "categorized_lines": categorizedLines, - "uncategorized": totalLines - categorizedLines, - "unique_categories": len(categories), - }, - Categories: categories, - LineCount: totalLines, - } -} - -// notesViewHandler displays the notes editor page -func notesViewHandler(w http.ResponseWriter, r *http.Request) { - content, err := GetNotes(db) - if err != nil { - log.Printf("Error: notesViewHandler: failed to load notes: %v", err) - http.Error(w, "Failed to load notes", http.StatusInternalServerError) - return - } - - analysis := analyzeNotes(content) - - notesData := struct { - Content string - Stats map[string]int - Categories []string - SedRules []SedRule - }{ - Content: content, - Stats: analysis.Stats, - Categories: analysis.Categories, - SedRules: config.SedRules, - } - - pageData := buildPageData("Notes", notesData) - - if err := tmpl.ExecuteTemplate(w, "notes.html", pageData); err != nil { - log.Printf("Error: notesViewHandler: template error: %v", err) - http.Error(w, "Template error", http.StatusInternalServerError) - } -} - -// notesSaveHandler saves the notes content -func notesSaveHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - content := r.FormValue("content") - - // Process (deduplicate and sort) before saving - if err := SaveNotes(db, content); err != nil { - log.Printf("Error: notesSaveHandler: failed to save notes: %v", err) - http.Error(w, "Failed to save notes", http.StatusInternalServerError) - return - } - - // Return success response - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "message": "Notes saved successfully", - }) -} - -// notesFilterHandler filters notes by search term or category -func notesFilterHandler(w http.ResponseWriter, r *http.Request) { - content, err := GetNotes(db) - if err != nil { - log.Printf("Error: notesFilterHandler: failed to load notes: %v", err) - http.Error(w, "Failed to load notes", http.StatusInternalServerError) - return - } - - searchTerm := r.URL.Query().Get("search") - category := r.URL.Query().Get("category") - - // Filter by search term - if searchTerm != "" { - content = FilterNotes(content, searchTerm) - } - - // Filter by category - if category != "" { - content = FilterByCategory(content, category) - } - - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte(content)) -} - -// notesStatsHandler returns statistics about the notes -func notesStatsHandler(w http.ResponseWriter, r *http.Request) { - content, err := GetNotes(db) - if err != nil { - log.Printf("Error: notesStatsHandler: failed to load notes: %v", err) - http.Error(w, "Failed to load notes", http.StatusInternalServerError) - return - } - - stats := analyzeNotes(content).Stats - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stats) -} - -// notesApplySedHandler applies a sed rule to the notes -func notesApplySedHandler(w http.ResponseWriter, r *http.Request) { - log.Printf("Info: notesApplySedHandler called - Method: %s, Path: %s", r.Method, r.URL.Path) - - if r.Method != http.MethodPost { - log.Printf("Warning: notesApplySedHandler: wrong method: %s", r.Method) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusMethodNotAllowed) - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "error": "Method not allowed", - }) - return - } - - content := r.FormValue("content") - ruleIndexStr := r.FormValue("rule_index") - log.Printf("Info: notesApplySedHandler: content length=%d, rule_index=%s", len(content), ruleIndexStr) - - ruleIndex, err := strconv.Atoi(ruleIndexStr) - if err != nil || ruleIndex < 0 || ruleIndex >= len(config.SedRules) { - log.Printf("Warning: notesApplySedHandler: invalid rule index: %s (error: %v, len(rules)=%d)", ruleIndexStr, err, len(config.SedRules)) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "error": "Invalid rule index", - }) - return - } - - rule := config.SedRules[ruleIndex] - log.Printf("Info: notesApplySedHandler: applying rule: %s (command: %s)", rule.Name, rule.Command) - result, err := ApplySedRule(content, rule.Command) - if err != nil { - log.Printf("Error: notesApplySedHandler: sed rule error: %v", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - return - } - - // Return the processed content - w.Header().Set("Content-Type", "application/json") - response := map[string]interface{}{ - "success": true, - "content": result, - "stats": analyzeNotes(result).Stats, - } - log.Printf("Info: notesApplySedHandler: sed rule success, returning %d bytes", len(result)) - json.NewEncoder(w).Encode(response) -} - -// notesPreviewHandler previews an operation without saving -func notesPreviewHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - content := r.FormValue("content") - - // Process (deduplicate and sort) - processed := ProcessNotes(content) - analysis := analyzeNotes(processed) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "content": processed, - "stats": analysis.Stats, - "lineCount": analysis.LineCount, - }) -} - -// notesExportHandler exports notes as plain text file -func notesExportHandler(w http.ResponseWriter, r *http.Request) { - content, err := GetNotes(db) - if err != nil { - log.Printf("Error: notesExportHandler: failed to load notes: %v", err) - http.Error(w, "Failed to load notes", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/plain") - w.Header().Set("Content-Disposition", "attachment; filename=notes.txt") - w.Write([]byte(content)) -} - -// notesImportHandler imports notes from uploaded file -func notesImportHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - file, _, err := r.FormFile("file") - if err != nil { - log.Printf("Warning: notesImportHandler: failed to read uploaded file: %v", err) - http.Error(w, "Failed to read file", http.StatusBadRequest) - return - } - defer file.Close() - - // Read file content - buf := new(strings.Builder) - if _, err := io.Copy(buf, file); err != nil { - log.Printf("Error: notesImportHandler: failed to read file content: %v", err) - http.Error(w, "Failed to read file", http.StatusInternalServerError) - return - } - - content := buf.String() - - // Option to merge or replace - mergeMode := r.FormValue("merge") == "true" - - if mergeMode { - // Merge with existing content - existingContent, err := GetNotes(db) - if err != nil { - log.Printf("Warning: notesImportHandler: failed to load existing notes for merge: %v", err) - } - content = existingContent + "\n" + content - } - - // Save (will auto-process) - if err := SaveNotes(db, content); err != nil { - log.Printf("Error: notesImportHandler: failed to save imported notes: %v", err) - http.Error(w, "Failed to save notes", http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/notes", http.StatusSeeOther) -} diff --git a/include-pagination.go b/include-pagination.go @@ -1,207 +0,0 @@ -package main - -import ( - "database/sql" - "net/http" - "net/url" - "strconv" - "strings" -) - -type Pagination struct { - CurrentPage int - TotalPages int - HasPrev bool - HasNext bool - PrevPage int - NextPage int - PerPage int - PageBaseURL string -} - -func pageFromRequest(r *http.Request) int { - if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { - return p - } - return 1 -} - -func perPageFromConfig(fallback int) int { - if n, err := strconv.Atoi(config.ItemsPerPage); err == nil && n > 0 { - return n - } - return fallback -} - -func getUntaggedFilesPaginated(page, perPage int) ([]File, int, error) { - // Get total count - var total int - err := db.QueryRow(`SELECT COUNT(*) FROM files f LEFT JOIN file_tags ft ON ft.file_id = f.id WHERE ft.file_id IS NULL`).Scan(&total) - if err != nil { - return nil, 0, err - } - - offset := (page - 1) * perPage - files, err := queryFilesWithTags(` - SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description - FROM files f - LEFT JOIN file_tags ft ON ft.file_id = f.id - WHERE ft.file_id IS NULL - ORDER BY f.id DESC - LIMIT ? OFFSET ? - `, perPage, offset) - - return files, total, err -} - -func buildPageDataWithPagination(title string, data interface{}, page, total, perPage int, r *http.Request) PageData { - pd := buildPageData(title, data) - pd.Pagination = calculatePagination(page, total, perPage) - pd.Pagination.PageBaseURL = pageBaseURL(r) - return pd -} - -// pageBaseURL returns a URL base suitable for appending page=N. -// It preserves all existing query parameters except 'page'. -// e.g. /search?q=cats → "?q=cats&" -// /browse → "?" -func pageBaseURL(r *http.Request) string { - params := r.URL.Query() - params.Del("page") - if encoded := params.Encode(); encoded != "" { - return "?" + encoded + "&" - } - return "?" -} - -func calculatePagination(page, total, perPage int) *Pagination { - totalPages := (total + perPage - 1) / perPage - if totalPages < 1 { - totalPages = 1 - } - - return &Pagination{ - CurrentPage: page, - TotalPages: totalPages, - HasPrev: page > 1, - HasNext: page < totalPages, - PrevPage: page - 1, - NextPage: page + 1, - PerPage: perPage, - } -} - -func getSearchResultsPaginated(query string, page, perPage int) ([]File, int, error) { - sqlPattern := "%" + strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(query), "*", "%"), "?", "_") + "%" - - var total int - err := db.QueryRow(` - SELECT COUNT(*) - FROM ( - SELECT f.id - FROM files f - LEFT JOIN file_tags ft ON ft.file_id = f.id - LEFT JOIN tags t ON t.id = ft.tag_id - WHERE LOWER(f.filename) LIKE ? - OR LOWER(f.description) LIKE ? - OR LOWER(t.value) LIKE ? - GROUP BY f.id - ) matched - `, sqlPattern, sqlPattern, sqlPattern).Scan(&total) - if err != nil { - return nil, 0, err - } - - offset := (page - 1) * perPage - rows, err := db.Query(` - SELECT - f.id, - f.filename, - f.path, - COALESCE(f.description, '') AS description, - c.name AS category, - t.value AS tag - FROM ( - SELECT f2.id - FROM files f2 - LEFT JOIN file_tags ft2 ON ft2.file_id = f2.id - LEFT JOIN tags t2 ON t2.id = ft2.tag_id - WHERE LOWER(f2.filename) LIKE ? - OR LOWER(f2.description) LIKE ? - OR LOWER(t2.value) LIKE ? - GROUP BY f2.id - ORDER BY f2.id DESC - LIMIT ? OFFSET ? - ) matched - JOIN files f - ON f.id = matched.id - LEFT JOIN file_tags ft - ON ft.file_id = f.id - LEFT JOIN tags t - ON t.id = ft.tag_id - LEFT JOIN categories c - ON c.id = t.category_id - ORDER BY f.id DESC, c.name, t.value - `, sqlPattern, sqlPattern, sqlPattern, perPage, offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - fileMap := make(map[int]*File) - files := make([]File, 0, perPage) - - for rows.Next() { - var ( - id int - filename, path, description sql.NullString - category, tag sql.NullString - ) - - if err := rows.Scan(&id, &filename, &path, &description, &category, &tag); err != nil { - return nil, 0, err - } - f, exists := fileMap[id] - if !exists { - file := File{ - ID: id, - Filename: filename.String, - Path: path.String, - EscapedFilename: url.PathEscape(filename.String), - Description: description.String, - Tags: make(map[string][]string), - } - files = append(files, file) - f = &files[len(files)-1] - fileMap[id] = f - } - if category.Valid && tag.Valid && tag.String != "" { - f.Tags[category.String] = append(f.Tags[category.String], tag.String) - } - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - - return files, total, nil -} - -func getTaggedFilesPaginated(page, perPage int) ([]File, int, error) { - // Get total count - var total int - err := db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f JOIN file_tags ft ON ft.file_id = f.id`).Scan(&total) - if err != nil { - return nil, 0, err - } - - offset := (page - 1) * perPage - files, err := queryFilesWithTags(` - SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description - FROM files f - JOIN file_tags ft ON ft.file_id = f.id - ORDER BY f.id DESC - LIMIT ? OFFSET ? - `, perPage, offset) - - return files, total, err -} -\ No newline at end of file diff --git a/include-previews.go b/include-previews.go @@ -1,128 +0,0 @@ -package main - -import ( - "fmt" - "strings" -) - -// getPreviewFiles returns one representative file for each tag value in the specified category -func getPreviewFiles(filters []filter) ([]File, error) { - // Find the preview filter category - var previewCategory string - for _, f := range filters { - if f.IsPreviews { - previewCategory = f.Category - break - } - } - - if previewCategory == "" { - return []File{}, nil - } - - // First, get all tag values for the preview category that have files - tagQuery := ` - SELECT DISTINCT t.value - FROM tags t - JOIN categories c ON t.category_id = c.id - JOIN file_tags ft ON ft.tag_id = t.id - WHERE c.name = ? - ORDER BY t.value` - - tagRows, err := db.Query(tagQuery, previewCategory) - if err != nil { - return nil, fmt.Errorf("failed to query tag values: %w", err) - } - defer tagRows.Close() - - var tagValues []string - for tagRows.Next() { - var tagValue string - if err := tagRows.Scan(&tagValue); err != nil { - return nil, fmt.Errorf("failed to scan tag value: %w", err) - } - tagValues = append(tagValues, tagValue) - } - - - if len(tagValues) == 0 { - return []File{}, nil - } - - // For each tag value, find one representative file - var allFiles []File - for _, tagValue := range tagValues { - // Build query for this specific tag value with all filters applied - query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description - FROM files f - WHERE 1=1` - args := []interface{}{} - - // Apply all filters (including the preview category with this specific value) - for _, filter := range filters { - if filter.IsPreviews { - // For the preview filter, use the current tag value we're iterating over - query += ` - AND EXISTS ( - SELECT 1 - FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - JOIN categories c ON c.id = t.category_id - WHERE ft.file_id = f.id AND c.name = ? AND t.value = ? - )` - args = append(args, filter.Category, tagValue) - } else if filter.IsProperty { - query += ` - AND EXISTS ( - SELECT 1 - FROM file_properties fp - WHERE fp.file_id = f.id AND fp.key = ? AND fp.value = ? - )` - args = append(args, filter.Category, filter.Value) - } else if filter.Value == "unassigned" { - query += ` - AND NOT EXISTS ( - SELECT 1 - FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - JOIN categories c ON c.id = t.category_id - WHERE ft.file_id = f.id AND c.name = ? - )` - args = append(args, filter.Category) - } else { - // Normal filter with aliases - placeholders := make([]string, len(filter.Values)) - for i := range filter.Values { - placeholders[i] = "?" - } - - query += fmt.Sprintf(` - AND EXISTS ( - SELECT 1 - FROM file_tags ft - JOIN tags t ON ft.tag_id = t.id - JOIN categories c ON c.id = t.category_id - WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) - )`, strings.Join(placeholders, ",")) - - args = append(args, filter.Category) - for _, v := range filter.Values { - args = append(args, v) - } - } - } - - query += ` ORDER BY f.id DESC LIMIT 1` - - files, err := queryFilesWithTags(query, args...) - if err != nil { - return nil, fmt.Errorf("failed to query files for tag %s: %w", tagValue, err) - } - - if len(files) > 0 { - allFiles = append(allFiles, files[0]) - } - } - - return allFiles, nil -} -\ No newline at end of file diff --git a/include-properties.go b/include-properties.go @@ -1,305 +0,0 @@ -package main - -import ( - "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "log" - "net/http" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" -) - -type PropertyDisplay struct { - Value string - Count int -} - -func computeProperties(fileID int64, filePath string) { - ext := strings.ToLower(filepath.Ext(filePath)) - - setProperty(fileID, "filetype", strings.TrimPrefix(ext, ".")) - - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".webp": - computeImageProperties(fileID, filePath) - case ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v": - computeVideoProperties(fileID, filePath) - } -} - -func setProperty(fileID int64, key, value string) { - if value == "" { - return - } - _, err := db.Exec( - `INSERT OR IGNORE INTO file_properties (file_id, key, value) VALUES (?, ?, ?)`, - fileID, key, value, - ) - if err != nil { - log.Printf("Warning: failed to set property %s for file %d: %v", key, fileID, err) - } -} - -func computeImageProperties(fileID int64, filePath string) { - f, err := os.Open(filePath) - if err != nil { - log.Printf("Warning: could not open image for properties %s: %v", filePath, err) - return - } - defer f.Close() - - cfg, _, err := image.DecodeConfig(f) - if err != nil { - log.Printf("Warning: could not decode image config %s: %v", filePath, err) - return - } - - w, h := cfg.Width, cfg.Height - - var orientation string - switch { - case w > h: - orientation = "landscape" - case h > w: - orientation = "portrait" - default: - orientation = "square" - } - setProperty(fileID, "orientation", orientation) - - mp := w * h - var tier string - switch { - case mp < 1_000_000: - tier = "small" - case mp < 4_000_000: - tier = "medium" - case mp < 12_000_000: - tier = "large" - default: - tier = "huge" - } - setProperty(fileID, "resolution", tier) -} - -func computeVideoProperties(fileID int64, filePath string) { - cmd := exec.Command("ffprobe", - "-v", "error", - "-show_entries", "format=duration", - "-of", "default=nokey=1:noprint_wrappers=1", - filePath, - ) - out, err := cmd.Output() - if err != nil { - log.Printf("Warning: ffprobe failed for %s: %v", filePath, err) - return - } - - seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) - if err != nil { - log.Printf("Warning: could not parse duration for %s: %v", filePath, err) - return - } - - var bucket string - switch { - case seconds < 60: - bucket = "tiny" - case seconds < 300: - bucket = "short" - case seconds < 2700: - bucket = "moderate" - default: - bucket = "long" - } - setProperty(fileID, "duration", bucket) -} - -func computeMissingProperties() (int, error) { - rows, err := db.Query(` - SELECT f.id, f.path - FROM files f - WHERE NOT EXISTS ( - SELECT 1 FROM file_properties fp WHERE fp.file_id = f.id - ) - `) - if err != nil { - return 0, fmt.Errorf("failed to query files: %w", err) - } - - type fileRow struct { - id int64 - path string - } - var files []fileRow - for rows.Next() { - var r fileRow - if err := rows.Scan(&r.id, &r.path); err != nil { - continue - } - files = append(files, r) - } - rows.Close() - - if err := rows.Err(); err != nil { - return 0, err - } - - for _, f := range files { - computeProperties(f.id, f.path) - } - return len(files), nil -} - -func getPropertyNav() (map[string][]PropertyDisplay, error) { - rows, err := db.Query(` - SELECT fp.key, fp.value, COUNT(*) as cnt - FROM file_properties fp - JOIN files f ON f.id = fp.file_id - GROUP BY fp.key, fp.value - ORDER BY fp.key, fp.value - `) - if err != nil { - return nil, err - } - defer rows.Close() - - propMap := make(map[string][]PropertyDisplay) - for rows.Next() { - var key, val string - var count int - if err := rows.Scan(&key, &val, &count); err != nil { - continue - } - propMap[key] = append(propMap[key], PropertyDisplay{Value: val, Count: count}) - } - return propMap, nil -} - -func propertyFilterHandler(w http.ResponseWriter, r *http.Request) { - trimmed := strings.TrimPrefix(r.URL.Path, "/property/") - - // If path contains /and/ delegate to the shared multi-filter - if strings.Contains(trimmed, "/and/") { - page := pageFromRequest(r) - perPage := perPageFromConfig(50) - - filters, breadcrumbs, err := parseFilterSegments(trimmed, "property") - if err != nil { - renderError(w, "Invalid filter path", http.StatusBadRequest) - return - } - - where, whereArgs := buildTagFilterWhere(filters) - - var total int - countArgs := append([]interface{}(nil), whereArgs...) - err = db.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total) - if err != nil { - log.Printf("Error: propertyFilterHandler: failed to count files: %v", err) - renderError(w, "Failed to count files", http.StatusInternalServerError) - return - } - - offset := (page - 1) * perPage - dataArgs := append(append([]interface{}(nil), whereArgs...), perPage, offset) - files, err := queryFilesWithTags( - `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f`+ - where+` ORDER BY f.id DESC LIMIT ? OFFSET ?`, - dataArgs..., - ) - if err != nil { - log.Printf("Error: propertyFilterHandler: failed to fetch files: %v", err) - renderError(w, "Failed to fetch files", http.StatusInternalServerError) - return - } - - title := buildFilterTitle(filters, ", ") - pageData := buildPageDataWithPagination(title, ListData{ - Tagged: files, - Untagged: nil, - Breadcrumbs: []Breadcrumb{}, - }, page, total, perPage, r) - pageData.Breadcrumbs = breadcrumbs - - renderTemplate(w, "list.html", pageData) - return - } - - parts := strings.SplitN(trimmed, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - renderError(w, "Invalid property filter path", http.StatusBadRequest) - return - } - - key := parts[0] - value := parts[1] - - page := pageFromRequest(r) - perPage := perPageFromConfig(50) - - var total int - err := db.QueryRow(` - SELECT COUNT(DISTINCT f.id) - FROM files f - JOIN file_properties fp ON fp.file_id = f.id - WHERE fp.key = ? AND fp.value = ? - `, key, value).Scan(&total) - if err != nil { - renderError(w, "Failed to count files", http.StatusInternalServerError) - return - } - - offset := (page - 1) * perPage - files, err := queryFilesWithTags(` - SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description - FROM files f - JOIN file_properties fp ON fp.file_id = f.id - WHERE fp.key = ? AND fp.value = ? - ORDER BY f.id DESC - LIMIT ? OFFSET ? - `, key, value, perPage, offset) - if err != nil { - renderError(w, "Failed to fetch files", http.StatusInternalServerError) - return - } - - breadcrumbs := []Breadcrumb{ - {Name: "home", URL: "/"}, - {Name: "properties", URL: "/properties"}, - {Name: key, URL: "/properties#prop-" + key}, - {Name: value, URL: r.URL.Path}, - } - - title := fmt.Sprintf("%s: %s", key, value) - pageData := buildPageDataWithPagination(title, ListData{ - Tagged: files, - Breadcrumbs: breadcrumbs, - }, page, total, perPage, r) - pageData.Breadcrumbs = breadcrumbs - - renderTemplate(w, "list.html", pageData) -} - -func propertiesIndexHandler(w http.ResponseWriter, r *http.Request) { - pageData := buildPageData("Properties", nil) - pageData.Data = pageData.Properties - renderTemplate(w, "properties.html", pageData) -} - -func handleComputeProperties(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { - count, err := computeMissingProperties() - data := currentAdminState(r, orphanData, missingThumbnails) - if err != nil { - data.Error = "Property computation failed: " + err.Error() - } else { - data.Success = fmt.Sprintf("Computed properties for %d files.", count) - } - renderAdminPage(w, r, data) -} diff --git a/include-routes.go b/include-routes.go @@ -1,37 +0,0 @@ -package main - -import ( - "net/http" -) - -// RegisterRoutes sets up all HTTP routes for the application -func RegisterRoutes() { - // Page handlers - http.HandleFunc("/", listFilesHandler) - http.HandleFunc("/add", uploadHandler) - http.HandleFunc("/add-yt", ytdlpHandler) - http.HandleFunc("/add-local", localFileHandler) - http.HandleFunc("/admin", adminHandler) - http.HandleFunc("/bulk-tag", bulkTagHandler) - http.HandleFunc("/cbz/", cbzViewerHandler) - http.HandleFunc("/file/", fileRouter) - http.HandleFunc("/notes", notesViewHandler) - http.HandleFunc("/notes/apply-sed", notesApplySedHandler) - http.HandleFunc("/notes/export", notesExportHandler) - http.HandleFunc("/notes/filter", notesFilterHandler) - http.HandleFunc("/notes/import", notesImportHandler) - http.HandleFunc("/notes/preview", notesPreviewHandler) - http.HandleFunc("/notes/save", notesSaveHandler) - http.HandleFunc("/notes/stats", notesStatsHandler) - http.HandleFunc("/properties", propertiesIndexHandler) - http.HandleFunc("/property/", propertyFilterHandler) - http.HandleFunc("/search/", searchHandler) - http.HandleFunc("/tag/", tagFilterHandler) - http.HandleFunc("/tags", tagsHandler) - http.HandleFunc("/thumbnails/generate", generateThumbnailHandler) - http.HandleFunc("/untagged", untaggedFilesHandler) - http.HandleFunc("/upload-url", uploadFromURLHandler) - // Static file serving - http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(config.UploadDir)))) - http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) -} diff --git a/include-search.go b/include-search.go @@ -1,34 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "strings" -) - -func searchHandler(w http.ResponseWriter, r *http.Request) { - query := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/search/")) - page := pageFromRequest(r) - perPage := perPageFromConfig(50) - - var files []File - var total int - var searchTitle string - - if query != "" { - var err error - files, total, err = getSearchResultsPaginated(query, page, perPage) - if err != nil { - renderError(w, "Search failed: "+err.Error(), http.StatusInternalServerError) - return - } - searchTitle = fmt.Sprintf("Search Results for: %s", query) - } else { - searchTitle = "Search Files" - } - - pageData := buildPageDataWithPagination(searchTitle, files, page, total, perPage, r) - pageData.Query = query - pageData.Files = files - renderTemplate(w, "search.html", pageData) -} -\ No newline at end of file diff --git a/include-templates.go b/include-templates.go @@ -1,42 +0,0 @@ -package main - -import ( - "fmt" - "html/template" - "net/url" - "strings" -) - -// InitTemplates loads and parses all HTML templates with helper functions -func InitTemplates() (*template.Template, error) { - return template.New("").Funcs(template.FuncMap{ - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, - "pathEscape": url.PathEscape, - "hasPrefix": func(s, prefix string) bool { - return strings.HasPrefix(s, prefix) - }, - "hasAnySuffix": func(s string, suffixes ...string) bool { - for _, suf := range suffixes { - if strings.HasSuffix(strings.ToLower(s), suf) { - return true - } - } - return false - }, - "dict": func(values ...interface{}) (map[string]interface{}, error) { - if len(values)%2 != 0 { - return nil, fmt.Errorf("dict requires an even number of args") - } - dict := make(map[string]interface{}, len(values)/2) - for i := 0; i < len(values); i += 2 { - key, ok := values[i].(string) - if !ok { - return nil, fmt.Errorf("dict keys must be strings") - } - dict[key] = values[i+1] - } - return dict, nil - }, - }).ParseGlob("templates/*.html") -} diff --git a/include-upload-local.go b/include-upload-local.go @@ -1,68 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" -) - -func localFileHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Redirect(w, r, "/upload", http.StatusSeeOther) - return - } - - rawPath := strings.TrimSpace(r.FormValue("filepath")) - if rawPath == "" { - renderError(w, "No file path provided", http.StatusBadRequest) - return - } - - // Resolve to an absolute, cleaned path to prevent traversal tricks - absPath, err := filepath.Abs(rawPath) - if err != nil { - renderError(w, "Invalid file path", http.StatusBadRequest) - return - } - - // Confirm the file actually exists and is a regular file (not a dir/symlink) - info, err := os.Stat(absPath) - if err != nil { - if os.IsNotExist(err) { - renderError(w, fmt.Sprintf("File not found: %s", absPath), http.StatusBadRequest) - } else { - renderError(w, fmt.Sprintf("Cannot access file: %v", err), http.StatusInternalServerError) - } - return - } - if !info.Mode().IsRegular() { - renderError(w, "Path must point to a regular file", http.StatusBadRequest) - return - } - - f, err := os.Open(absPath) - if err != nil { - renderError(w, fmt.Sprintf("Failed to open file: %v", err), http.StatusInternalServerError) - return - } - defer f.Close() - - deleteSource := r.FormValue("delete_source") == "on" - - id, warningMsg, err := processUpload(f, filepath.Base(absPath)) - if err != nil { - renderError(w, err.Error(), http.StatusInternalServerError) - return - } - - if deleteSource { - f.Close() - if removeErr := os.Remove(absPath); removeErr != nil { - warningMsg = strings.TrimPrefix(fmt.Sprintf("%s; could not delete source file: %v", warningMsg, removeErr), "; ") - } - } - - redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) -} diff --git a/include-uploads.go b/include-uploads.go @@ -1,332 +0,0 @@ -package main - -import ( - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" -) - -func uploadHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - pageData := buildPageData("Add File", nil) - renderTemplate(w, "add.html", pageData) - return - } - - // Parse the multipart form (with max memory limit, e.g., 32MB) - err := r.ParseMultipartForm(32 << 20) - if err != nil { - renderError(w, "Failed to parse form", http.StatusBadRequest) - return - } - - files := r.MultipartForm.File["file"] - if len(files) == 0 { - renderError(w, "No files uploaded", http.StatusBadRequest) - return - } - - var warnings []string - var lastID int64 - - // Process each file - for _, fileHeader := range files { - file, err := fileHeader.Open() - if err != nil { - renderError(w, "Failed to open uploaded file", http.StatusInternalServerError) - return - } - defer file.Close() - - id, warningMsg, err := processUpload(file, fileHeader.Filename) - if err != nil { - renderError(w, err.Error(), http.StatusInternalServerError) - return - } - - lastID = id - - if warningMsg != "" { - warnings = append(warnings, warningMsg) - } - } - - var warningMsg string - if len(warnings) > 0 { - warningMsg = strings.Join(warnings, "; ") - } - - redirectTarget := "/untagged" - if len(files) == 1 && lastID != 0 { - redirectTarget = "/file/" + strconv.FormatInt(lastID, 10) - } - - redirectWithWarning(w, r, redirectTarget, warningMsg) -} - -func processUpload(src io.Reader, filename string) (int64, string, error) { - finalFilename, finalPath, conflictID, err := checkFileConflictStrict(filename) - if err != nil { - return 0, "", err - } - if conflictID != 0 { - return conflictID, "", nil - } - - tempPath := finalPath + ".tmp" - tempFile, err := os.Create(tempPath) - if err != nil { - return 0, "", fmt.Errorf("failed to create temp file: %v", err) - } - - _, err = io.Copy(tempFile, src) - tempFile.Close() - if err != nil { - os.Remove(tempPath) - return 0, "", fmt.Errorf("failed to copy file data: %v", err) - } - - ext := strings.ToLower(filepath.Ext(filename)) - videoExts := map[string]bool{ - ".mp4": true, ".mov": true, ".avi": true, - ".mkv": true, ".webm": true, ".m4v": true, - } - - var processedPath string - var warningMsg string - - if videoExts[ext] || ext == ".cbz" { - // Process videos and CBZ files - processedPath, warningMsg, err = processVideoFile(tempPath, finalPath) - if err != nil { - os.Remove(tempPath) - return 0, "", err - } - } else { - // Non-video, non-CBZ → just rename temp file to final - if err := os.Rename(tempPath, finalPath); err != nil { - return 0, "", fmt.Errorf("failed to move file: %v", err) - } - processedPath = finalPath - } - - id, err := saveFileToDatabase(finalFilename, processedPath) - if err != nil { - os.Remove(processedPath) - return 0, "", err - } - - return id, warningMsg, nil -} - -func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Redirect(w, r, "/upload", http.StatusSeeOther) - return - } - - fileURL := r.FormValue("fileurl") - if fileURL == "" { - renderError(w, "No URL provided", http.StatusBadRequest) - return - } - - customFilename := strings.TrimSpace(r.FormValue("filename")) - - parsedURL, err := url.ParseRequestURI(fileURL) - if err != nil || !(parsedURL.Scheme == "http" || parsedURL.Scheme == "https") { - renderError(w, "Invalid URL", http.StatusBadRequest) - return - } - - resp, err := http.Get(fileURL) - if err != nil || resp.StatusCode != http.StatusOK { - renderError(w, "Failed to download file", http.StatusBadRequest) - return - } - defer resp.Body.Close() - - var filename string - urlExt := filepath.Ext(parsedURL.Path) - if customFilename != "" { - filename = customFilename - if filepath.Ext(filename) == "" && urlExt != "" { - filename += urlExt - } - } else { - parts := strings.Split(parsedURL.Path, "/") - filename = parts[len(parts)-1] - if filename == "" { - filename = "file_from_url" - } - } - - id, warningMsg, err := processUpload(resp.Body, filename) - if err != nil { - renderError(w, err.Error(), http.StatusInternalServerError) - return - } - - redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) -} - -func ytdlpHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Redirect(w, r, "/upload", http.StatusSeeOther) - return - } - - videoURL := r.FormValue("url") - if videoURL == "" { - renderError(w, "No URL provided", http.StatusBadRequest) - return - } - - outTemplate := filepath.Join(config.UploadDir, "%(title)s.%(ext)s") - filenameCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, "--get-filename", videoURL) - filenameBytes, err := filenameCmd.Output() - if err != nil { - renderError(w, fmt.Sprintf("Failed to get filename: %v", err), http.StatusInternalServerError) - return - } - expectedFullPath := strings.TrimSpace(string(filenameBytes)) - expectedFilename := filepath.Base(expectedFullPath) - - finalFilename, finalPath, conflictID, err := checkFileConflictStrict(expectedFilename) - if err != nil { - renderError(w, err.Error(), http.StatusConflict) - return - } - if conflictID != 0 { - redirectWithWarning(w, r, fmt.Sprintf("/file/%d", conflictID), "") - return - } - - downloadCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, videoURL) - downloadCmd.Stdout = os.Stdout - downloadCmd.Stderr = os.Stderr - if err := downloadCmd.Run(); err != nil { - renderError(w, fmt.Sprintf("Failed to download video: %v", err), http.StatusInternalServerError) - return - } - - if expectedFullPath != finalPath { - if err := os.Rename(expectedFullPath, finalPath); err != nil { - renderError(w, fmt.Sprintf("Failed to move downloaded file: %v", err), http.StatusInternalServerError) - return - } - } - - tempPath := finalPath + ".tmp" - if err := os.Rename(finalPath, tempPath); err != nil { - renderError(w, fmt.Sprintf("Failed to create temp file for processing: %v", err), http.StatusInternalServerError) - return - } - - processedPath, warningMsg, err := processVideoFile(tempPath, finalPath) - if err != nil { - os.Remove(tempPath) - renderError(w, fmt.Sprintf("Failed to process video: %v", err), http.StatusInternalServerError) - return - } - - id, err := saveFileToDatabase(finalFilename, processedPath) - if err != nil { - os.Remove(processedPath) - renderError(w, err.Error(), http.StatusInternalServerError) - return - } - - redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) -} - -func processVideoFile(tempPath, finalPath string) (string, string, error) { - ext := strings.ToLower(filepath.Ext(finalPath)) - - // Handle CBZ files - if ext == ".cbz" { - if err := os.Rename(tempPath, finalPath); err != nil { - return "", "", fmt.Errorf("failed to move file: %v", err) - } - if err := generateCBZThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil { - log.Printf("Warning: could not generate CBZ thumbnail: %v", err) - } - return finalPath, "", nil - } - - // Handle video files - codec, err := detectVideoCodec(tempPath) - if err != nil { - return "", "", err - } - if codec == "hevc" || codec == "h265" { - warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility." - if err := reencodeHEVCToH264(tempPath, finalPath); err != nil { - return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err) - } - os.Remove(tempPath) - - if err := generateThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil { - log.Printf("Warning: could not generate thumbnail after HEVC re-encode: %v", err) - } - - return finalPath, warningMsg, nil - } - if err := os.Rename(tempPath, finalPath); err != nil { - return "", "", fmt.Errorf("failed to move file: %v", err) - } - - // Generate thumbnail for video files - if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" || ext == ".webm" || ext == ".m4v" { - if err := generateThumbnail(finalPath, config.UploadDir, filepath.Base(finalPath)); err != nil { - log.Printf("Warning: could not generate thumbnail: %v", err) - } - } - - return finalPath, "", nil -} - -func saveFileToDatabase(filename, path string) (int64, error) { - relPath, err := filepath.Rel(config.UploadDir, path) - if err != nil { - return 0, fmt.Errorf("failed to compute relative path: %v", err) - } - - res, err := db.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, relPath) - if err != nil { - return 0, fmt.Errorf("failed to save file to database: %v", err) - } - id, err := res.LastInsertId() - if err != nil { - return 0, fmt.Errorf("failed to get inserted ID: %v", err) - } - computeProperties(id, path) - return id, nil -} - - -func detectVideoCodec(filePath string) (string, error) { - cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath) - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to probe video codec: %v", err) - } - return strings.TrimSpace(string(out)), nil -} - -func reencodeHEVCToH264(inputPath, outputPath string) error { - cmd := exec.Command("ffmpeg", "-i", inputPath, - "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23", - "-c:a", "aac", "-movflags", "+faststart", outputPath) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - return cmd.Run() -} -\ No newline at end of file diff --git a/include-viewer.go b/include-viewer.go @@ -1,393 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "html" - "log" - "net" - "net/http" - "net/url" - "strconv" - "strings" -) - -func getPreviousTagValue(category string, excludeFileID int) (string, error) { - var value string - err := db.QueryRow(` - SELECT t.value - FROM tags t - JOIN categories c ON c.id = t.category_id - JOIN file_tags ft ON ft.tag_id = t.id - JOIN files f ON f.id = ft.file_id - WHERE c.name = ? AND ft.file_id != ? - ORDER BY ft.rowid DESC - LIMIT 1 - `, category, excludeFileID).Scan(&value) - - if err == sql.ErrNoRows { - return "", fmt.Errorf("no previous tag found for category: %s", category) - } - if err != nil { - return "", err - } - - return value, nil -} - -func getPreviousFileTags(excludeFileID int) ([]struct{ cat, val string }, error) { - rows, err := db.Query(` - SELECT c.name, t.value - FROM tags t - JOIN categories c ON c.id = t.category_id - JOIN file_tags ft ON ft.tag_id = t.id - WHERE ft.file_id = ( - SELECT file_id FROM file_tags - WHERE file_id != ? - ORDER BY rowid DESC - LIMIT 1 - ) - `, excludeFileID) - if err != nil { - return nil, err - } - defer rows.Close() - - var tags []struct{ cat, val string } - for rows.Next() { - var cat, val string - if err := rows.Scan(&cat, &val); err != nil { - return nil, err - } - tags = append(tags, struct{ cat, val string }{cat, val}) - } - if len(tags) == 0 { - return nil, fmt.Errorf("no tags found on previous file") - } - return tags, nil -} - -func getFileTagsByID(fileID int) ([]struct{ cat, val string }, error) { - rows, err := db.Query(` - SELECT c.name, t.value - FROM tags t - JOIN categories c ON c.id = t.category_id - JOIN file_tags ft ON ft.tag_id = t.id - WHERE ft.file_id = ? - `, fileID) - if err != nil { - return nil, err - } - defer rows.Close() - - var tags []struct{ cat, val string } - for rows.Next() { - var cat, val string - if err := rows.Scan(&cat, &val); err != nil { - return nil, err - } - tags = append(tags, struct{ cat, val string }{cat, val}) - } - if len(tags) == 0 { - return nil, fmt.Errorf("no tags found on file id %d", fileID) - } - return tags, nil -} - -func fileHandler(w http.ResponseWriter, r *http.Request) { - idStr := strings.TrimPrefix(r.URL.Path, "/file/") - if strings.Contains(idStr, "/") { - idStr = strings.SplitN(idStr, "/", 2)[0] - } - - var f File - err := db.QueryRow("SELECT id, filename, path, COALESCE(description, '') as description FROM files WHERE id=?", idStr).Scan(&f.ID, &f.Filename, &f.Path, &f.Description) - if err != nil { - log.Printf("Error: fileHandler: file not found for id=%s: %v", idStr, err) - renderError(w, "File not found", http.StatusNotFound) - return - } - - f.Tags = make(map[string][]string) - rows, err := db.Query(` - SELECT c.name, t.value - FROM tags t - JOIN categories c ON c.id = t.category_id - JOIN file_tags ft ON ft.tag_id = t.id - WHERE ft.file_id=?`, f.ID) - if err != nil { - log.Printf("Warning: fileHandler: failed to query tags for file id=%d: %v", f.ID, err) - } else { - for rows.Next() { - var cat, val string - if err := rows.Scan(&cat, &val); err != nil { - log.Printf("Warning: fileHandler: failed to scan tag row for file id=%d: %v", f.ID, err) - continue - } - f.Tags[cat] = append(f.Tags[cat], val) - } - rows.Close() - } - - if r.Method == http.MethodPost { - if r.FormValue("action") == "update_description" { - description := r.FormValue("description") - if len(description) > 2048 { - description = description[:2048] - } - - if _, err := db.Exec("UPDATE files SET description = ? WHERE id = ?", description, f.ID); err != nil { - log.Printf("Error: fileHandler: failed to update description for file id=%d: %v", f.ID, err) - renderError(w, "Failed to update description", http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - - cat := strings.TrimSpace(r.FormValue("category")) - val := strings.TrimSpace(r.FormValue("value")) - - if cat == "!" || (strings.HasPrefix(cat, "!") && len(cat) > 1) { - var sourceTags []struct{ cat, val string } - var sourceDesc string - - if cat == "!" { - var err error - sourceTags, err = getPreviousFileTags(f.ID) - if err != nil { - log.Printf("Warning: fileHandler: could not copy tags from previous file for file id=%d: %v", f.ID, err) - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - sourceDesc = "previous file" - } else { - sourceID, err := strconv.Atoi(cat[1:]) - if err != nil { - log.Printf("Warning: fileHandler: invalid file ID in category %q for file id=%d", cat, f.ID) - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - sourceTags, err = getFileTagsByID(sourceID) - if err != nil { - log.Printf("Warning: fileHandler: could not copy tags from file %d for file id=%d: %v", sourceID, f.ID, err) - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - sourceDesc = fmt.Sprintf("file %d", sourceID) - } - - for _, tag := range sourceTags { - _, tagID, err := getOrCreateCategoryAndTag(tag.cat, tag.val) - if err != nil { - log.Printf("Error: fileHandler: failed to create tag %s:%s while copying from %s for file id=%d: %v", tag.cat, tag.val, sourceDesc, f.ID, err) - continue - } - if _, err = db.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID); err != nil { - log.Printf("Error: fileHandler: failed to add tag %s:%s to file id=%d: %v", tag.cat, tag.val, f.ID, err) - } - } - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - - if cat == "" || val == "" { - log.Printf("Warning: fileHandler: empty category/value submitted for file id=%d", f.ID) - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - - if val == "!" { - previousVal, err := getPreviousTagValue(cat, f.ID) - if err != nil { - log.Printf("Warning: fileHandler: no previous tag found for category %q for file id=%d: %v", cat, f.ID, err) - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - val = previousVal - } - _, tagID, err := getOrCreateCategoryAndTag(cat, val) - if err != nil { - log.Printf("Error: fileHandler: failed to create tag %s:%s for file id=%d: %v", cat, val, f.ID, err) - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - if _, err = db.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID); err != nil { - log.Printf("Error: fileHandler: failed to add tag %s:%s to file id=%d: %v", cat, val, f.ID, err) - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) - return - } - - catRows, err := db.Query(` - SELECT DISTINCT c.name - FROM categories c - JOIN tags t ON t.category_id = c.id - JOIN file_tags ft ON ft.tag_id = t.id - ORDER BY c.name - `) - if err != nil { - log.Printf("Warning: fileHandler: failed to query categories for file id=%d: %v", f.ID, err) - } - var cats []string - if catRows != nil { - for catRows.Next() { - var c string - if err := catRows.Scan(&c); err != nil { - log.Printf("Warning: fileHandler: failed to scan category row: %v", err) - continue - } - cats = append(cats, c) - } - catRows.Close() - } - - propRows, err := db.Query(` - SELECT key, value FROM file_properties - WHERE file_id = ? - ORDER BY key, value - `, f.ID) - if err != nil { - log.Printf("Warning: fileHandler: failed to query properties for file id=%d: %v", f.ID, err) - } - fileProps := make(map[string]string) - if propRows != nil { - for propRows.Next() { - var k, v string - if err := propRows.Scan(&k, &v); err != nil { - log.Printf("Warning: fileHandler: failed to scan property row for file id=%d: %v", f.ID, err) - continue - } - fileProps[k] = v - } - propRows.Close() - } - - pageData := buildPageDataWithIP(f.Filename, struct { - File File - Categories []string - EscapedFilename string - Properties map[string]string - }{f, cats, url.PathEscape(f.Filename), fileProps}) - - renderTemplate(w, "file.html", pageData) -} - -func buildPageDataWithIP(title string, data interface{}) PageData { - pageData := buildPageData(title, data) - ip, err := getLocalIP() - if err != nil { - log.Printf("Warning: buildPageDataWithIP: could not determine local IP: %v", err) - } - pageData.IP = ip - pageData.Port = strings.TrimPrefix(config.ServerPort, ":") - return pageData -} - -func getLocalIP() (string, error) { - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - for _, addr := range addrs { - if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { - if ipnet.IP.To4() != nil { - return ipnet.IP.String(), nil - } - } - } - return "", fmt.Errorf("no connected network interface found") -} - -func tagActionHandler(w http.ResponseWriter, r *http.Request, parts []string) { - fileID := parts[2] - - if r.Method != http.MethodPost { - http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) - return - } - - cat := strings.TrimSpace(html.UnescapeString(r.FormValue("category"))) - val := strings.TrimSpace(html.UnescapeString(r.FormValue("value"))) - - if cat != "" && val != "" { - var tagID int - if err := db.QueryRow(` - SELECT t.id - FROM tags t - JOIN categories c ON c.id=t.category_id - WHERE c.name=? AND t.value=?`, cat, val).Scan(&tagID); err != nil && err != sql.ErrNoRows { - log.Printf("Warning: tagActionHandler: failed to look up tag %s:%s for file id=%s: %v", cat, val, fileID, err) - } - if tagID != 0 { - if _, err := db.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID); err != nil { - log.Printf("Error: tagActionHandler: failed to delete file_tag for file id=%s tag id=%d: %v", fileID, tagID, err) - } - } - } - http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) -} - -func getOrCreateCategoryAndTag(category, value string) (int, int, error) { - category = strings.TrimSpace(category) - value = strings.TrimSpace(value) - var catID int - err := db.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) - if err == sql.ErrNoRows { - res, err := db.Exec("INSERT INTO categories(name) VALUES(?)", category) - if err != nil { - return 0, 0, err - } - cid, _ := res.LastInsertId() - catID = int(cid) - } else if err != nil { - return 0, 0, err - } - - var tagID int - if value != "" { - err = db.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) - if err == sql.ErrNoRows { - res, err := db.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) - if err != nil { - return 0, 0, err - } - tid, _ := res.LastInsertId() - tagID = int(tid) - } else if err != nil { - return 0, 0, err - } - } - - return catID, tagID, nil -} - -func listFilesHandler(w http.ResponseWriter, r *http.Request) { - page := pageFromRequest(r) - perPage := perPageFromConfig(50) - - tagged, taggedTotal, err := getTaggedFilesPaginated(page, perPage) - if err != nil { - log.Printf("Warning: listFilesHandler: failed to get tagged files: %v", err) - } - untagged, untaggedTotal, err := getUntaggedFilesPaginated(page, perPage) - if err != nil { - log.Printf("Warning: listFilesHandler: failed to get untagged files: %v", err) - } - - // Use the larger total for pagination - total := taggedTotal - if untaggedTotal > total { - total = untaggedTotal - } - - pageData := buildPageDataWithPagination("File Browser", ListData{ - Tagged: tagged, - Untagged: untagged, - Breadcrumbs: []Breadcrumb{}, - }, page, total, perPage, r) - - renderTemplate(w, "list.html", pageData) -} -\ No newline at end of file diff --git a/internal/app/admin.go b/internal/app/admin.go @@ -0,0 +1,302 @@ +package app + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type AdminPageData struct { + Config Config + Error string + Success string + OrphanData OrphanData + ActiveTab string + MissingThumbnails []VideoFile + RecentFiles []File +} + +type SedRule struct { + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` +} + +func renderAdminPage(w http.ResponseWriter, r *http.Request, data AdminPageData) { + if data.ActiveTab == "" { + data.ActiveTab = r.FormValue("active_tab") + } + pageData := buildPageData("Admin", data) + renderTemplate(w, "admin.html", pageData) +} + +func getRecentFiles() []File { + rows, err := DB.Query("SELECT id, filename FROM files ORDER BY id DESC LIMIT 20") + if err != nil { + log.Printf("Warning: getRecentFiles: failed to query recent files: %v", err) + return nil + } + defer rows.Close() + var files []File + for rows.Next() { + var f File + rows.Scan(&f.ID, &f.Filename) + files = append(files, f) + } + return files +} + +func currentAdminState(r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) AdminPageData { + return AdminPageData{ + Config: Cfg, + OrphanData: orphanData, + ActiveTab: r.FormValue("active_tab"), + MissingThumbnails: missingThumbnails, + RecentFiles: getRecentFiles(), + } +} + +func validateConfig(newConfig Config) error { + if newConfig.GallerySize == "" { + return fmt.Errorf("gallery size cannot be empty") + } + if newConfig.ItemsPerPage == "" { + return fmt.Errorf("items per page cannot be empty") + } + return nil +} + +func adminHandler(w http.ResponseWriter, r *http.Request) { + // Get orphaned files + orphanData, err := getOrphanedFiles(Cfg.UploadDir) + if err != nil { + log.Printf("Warning: adminHandler: failed to get orphaned files: %v", err) + } + + // Get video files for thumbnails + missingThumbnails, err := getMissingThumbnailVideos() + if err != nil { + log.Printf("Warning: adminHandler: failed to get missing thumbnails: %v", err) + } + + switch r.Method { + case http.MethodPost: + switch r.FormValue("action") { + case "save", "": + handleSaveSettings(w, r, orphanData, missingThumbnails) + + case "backup": + err := backupDatabase(Cfg.DatabasePath) + if err != nil { + log.Printf("Error: adminHandler: database backup failed: %v", err) + } + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = errorString(err) + data.Success = successString(err, "Database backup created successfully!") + renderAdminPage(w, r, data) + + case "vacuum": + err := vacuumDatabase() + if err != nil { + log.Printf("Error: adminHandler: database vacuum failed: %v", err) + } + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = errorString(err) + data.Success = successString(err, "Database vacuum completed successfully!") + renderAdminPage(w, r, data) + + case "save_aliases": + handleSaveAliases(w, r, orphanData, missingThumbnails) + + case "save_sed_rules": + handleSaveSedRules(w, r, orphanData, missingThumbnails) + + case "compute_properties": + handleComputeProperties(w, r, orphanData, missingThumbnails) + } + + default: + renderAdminPage(w, r, currentAdminState(r, orphanData, missingThumbnails)) + } +} + +func parseAliasesFromForm(r *http.Request) []TagAliasGroup { + var groups []TagAliasGroup + for i := 0; ; i++ { + category := strings.TrimSpace(r.FormValue(fmt.Sprintf("aliases[%d][category]", i))) + if category == "" { + break + } + var aliases []string + for j := 0; ; j++ { + v := strings.TrimSpace(r.FormValue(fmt.Sprintf("aliases[%d][aliases][%d]", i, j))) + if v == "" { + break + } + aliases = append(aliases, v) + } + if len(aliases) >= 2 { + groups = append(groups, TagAliasGroup{Category: category, Aliases: aliases}) + } else { + log.Printf("Warning: parseAliasesFromForm: alias group for category %q has fewer than 2 aliases (%d) and was discarded", category, len(aliases)) + } + } + return groups +} + +func parseSedRulesFromForm(r *http.Request) ([]SedRule, error) { + var rules []SedRule + for i := 0; ; i++ { + name := strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][name]", i))) + if name == "" { + break + } + command := strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][command]", i))) + if command == "" { + return nil, fmt.Errorf("rule %s is missing a command", strconv.Itoa(i+1)) + } + rules = append(rules, SedRule{ + Name: name, + Description: strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][description]", i))), + Command: command, + }) + } + return rules, nil +} + +func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { + Cfg.TagAliases = parseAliasesFromForm(r) + + if err := SaveConfig(DB, Cfg); err != nil { + log.Printf("Error: handleSaveAliases: failed to save configuration: %v", err) + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = "Failed to save configuration: " + err.Error() + renderAdminPage(w, r, data) + return + } + + data := currentAdminState(r, orphanData, missingThumbnails) + data.Success = "Tag aliases saved successfully!" + renderAdminPage(w, r, data) +} + +func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { + newConfig := Cfg // preserve runtime fields + newConfig.GallerySize = strings.TrimSpace(r.FormValue("gallery_size")) + newConfig.ItemsPerPage = strings.TrimSpace(r.FormValue("items_per_page")) + + if err := validateConfig(newConfig); err != nil { + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = err.Error() + renderAdminPage(w, r, data) + return + } + + Cfg = newConfig + + if err := SaveConfig(DB, Cfg); err != nil { + log.Printf("Error: handleSaveSettings: failed to save configuration: %v", err) + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = "Failed to save configuration: " + err.Error() + renderAdminPage(w, r, data) + return + } + + data := currentAdminState(r, orphanData, missingThumbnails) + data.Success = "Settings saved successfully!" + renderAdminPage(w, r, data) +} + +func handleSaveSedRules(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { + rules, err := parseSedRulesFromForm(r) + if err != nil { + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = err.Error() + renderAdminPage(w, r, data) + return + } + + Cfg.SedRules = rules + + if err := SaveConfig(DB, Cfg); err != nil { + log.Printf("Error: handleSaveSedRules: failed to save configuration: %v", err) + data := currentAdminState(r, orphanData, missingThumbnails) + data.Error = "Failed to save configuration: " + err.Error() + renderAdminPage(w, r, data) + return + } + + data := currentAdminState(r, orphanData, missingThumbnails) + data.Success = "Sed rules saved successfully!" + renderAdminPage(w, r, data) +} + +func backupDatabase(dbPath string) error { + if dbPath == "" { + return fmt.Errorf("database path not configured") + } + + backupDir := filepath.Join(filepath.Dir(dbPath), "backups") + if err := os.MkdirAll(backupDir, 0755); err != nil { + return fmt.Errorf("failed to create backups directory: %w", err) + } + + dbName := strings.TrimSuffix(filepath.Base(dbPath), filepath.Ext(dbPath)) + timestamp := time.Now().Format("20060102_150405") + backupPath := filepath.Join(backupDir, fmt.Sprintf("%s_backup_%s.DB", dbName, timestamp)) + + input, err := os.Open(dbPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer input.Close() + + output, err := os.Create(backupPath) + if err != nil { + return fmt.Errorf("failed to create backup file: %w", err) + } + defer output.Close() + + if _, err := io.Copy(output, input); err != nil { + return fmt.Errorf("failed to copy database: %w", err) + } + + log.Printf("Info: backupDatabase: backup written to %s", backupPath) + return nil +} + +func vacuumDatabase() error { + if _, err := DB.Exec("VACUUM;"); err != nil { + return fmt.Errorf("VACUUM failed: %w", err) + } + + log.Printf("Info: vacuumDatabase: VACUUM completed successfully") + return nil +} + +func getFilesInDB() (map[string]bool, error) { + rows, err := DB.Query("SELECT filename FROM files") + if err != nil { + return nil, err + } + defer rows.Close() + + fileMap := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + fileMap[name] = true + } + if err := rows.Err(); err != nil { + return nil, err + } + return fileMap, nil +} diff --git a/internal/app/admin_orphans.go b/internal/app/admin_orphans.go @@ -0,0 +1,73 @@ +package app + +import ( + "log" + "net/http" + "os" +) + +type OrphanData struct { + Orphans []string // on disk, not in DB + ReverseOrphans []string // in DB, not on disk +} + +func getOrphanedFiles(uploadDir string) (OrphanData, error) { + diskFiles, err := getFilesOnDisk(uploadDir) + if err != nil { + return OrphanData{}, err + } + dbFiles, err := getFilesInDB() + if err != nil { + return OrphanData{}, err + } + + // Build a set of disk files for reverse lookup + diskFileSet := make(map[string]bool, len(diskFiles)) + for _, f := range diskFiles { + diskFileSet[f] = true + } + + var orphans []string + for _, f := range diskFiles { + if !dbFiles[f] { + orphans = append(orphans, f) + } + } + + var reverseOrphans []string + for f := range dbFiles { + if !diskFileSet[f] { + reverseOrphans = append(reverseOrphans, f) + } + } + + return OrphanData{ + Orphans: orphans, + ReverseOrphans: reverseOrphans, + }, nil +} + +func orphansHandler(w http.ResponseWriter, r *http.Request) { + orphanData, err := getOrphanedFiles(Cfg.UploadDir) + if err != nil { + log.Printf("Error: orphansHandler: failed to read orphaned files: %v", err) + renderError(w, "Error reading orphaned files", http.StatusInternalServerError) + return + } + pageData := buildPageData("Orphaned Files", orphanData) + renderTemplate(w, "orphans.html", pageData) +} + +func getFilesOnDisk(uploadDir string) ([]string, error) { + entries, err := os.ReadDir(uploadDir) + if err != nil { + return nil, err + } + var files []string + for _, e := range entries { + if !e.IsDir() { + files = append(files, e.Name()) + } + } + return files, nil +} diff --git a/internal/app/admin_thumbnails.go b/internal/app/admin_thumbnails.go @@ -0,0 +1,219 @@ +package app + +import ( + "bytes" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type VideoFile struct { + ID int + Filename string + Path string + HasThumbnail bool + ThumbnailPath string + EscapedFilename string +} + +func generateThumbnailAtTime(videoPath, uploadDir, filename, timestamp string) error { + thumbDir := filepath.Join(uploadDir, "thumbnails") + if err := os.MkdirAll(thumbDir, 0755); err != nil { + return fmt.Errorf("failed to create thumbnails directory: %v", err) + } + + thumbPath := filepath.Join(thumbDir, filename+".jpg") + + var stderr bytes.Buffer + cmd := exec.Command("ffmpeg", "-y", "-ss", timestamp, "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", thumbPath) + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to generate thumbnail at %s: %v\nffmpeg output: %s", timestamp, err, stderr.String()) + } + + return nil +} + +func getVideoFiles() ([]VideoFile, error) { + videoExts := []string{".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"} + + rows, err := DB.Query(`SELECT id, filename, path FROM files ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var videos []VideoFile + for rows.Next() { + var v VideoFile + if err := rows.Scan(&v.ID, &v.Filename, &v.Path); err != nil { + log.Printf("Warning: getVideoFiles: failed to scan row: %v", err) + continue + } + + // Check if it's a video file + isVideo := false + ext := strings.ToLower(filepath.Ext(v.Filename)) + for _, vidExt := range videoExts { + if ext == vidExt { + isVideo = true + break + } + } + + if !isVideo { + continue + } + + v.EscapedFilename = url.PathEscape(v.Filename) + thumbPath := filepath.Join(Cfg.UploadDir, "thumbnails", v.Filename+".jpg") + v.ThumbnailPath = "/uploads/thumbnails/" + v.EscapedFilename + ".jpg" + + if _, err := os.Stat(thumbPath); err == nil { + v.HasThumbnail = true + } + + videos = append(videos, v) + } + + return videos, nil +} + +func thumbnailsHandler(w http.ResponseWriter, r *http.Request) { + allVideos, err := getVideoFiles() + if err != nil { + log.Printf("Error: thumbnailsHandler: failed to get video files: %v", err) + renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) + return + } + + missing, err := getMissingThumbnailVideos() + if err != nil { + log.Printf("Error: thumbnailsHandler: failed to get missing thumbnail videos: %v", err) + renderError(w, "Failed to get video files: "+err.Error(), http.StatusInternalServerError) + return + } + + pageData := buildPageData("Thumbnail Management", struct { + AllVideos []VideoFile + MissingThumbnails []VideoFile + Error string + Success string + }{ + AllVideos: allVideos, + MissingThumbnails: missing, + Error: r.URL.Query().Get("error"), + Success: r.URL.Query().Get("success"), + }) + + renderTemplate(w, "thumbnails.html", pageData) +} + +func generateThumbnailHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } + + action := r.FormValue("action") + redirectTo := r.FormValue("redirect") + if redirectTo == "" { + redirectTo = "thumbnails" + } + + redirectBase := "/" + redirectTo + + switch action { + case "generate_all": + missing, err := getMissingThumbnailVideos() + if err != nil { + log.Printf("Error: generateThumbnailHandler: failed to get missing thumbnails: %v", err) + http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to get videos: "+err.Error()), http.StatusSeeOther) + return + } + + successCount := 0 + var errors []string + + for _, v := range missing { + if err := generateThumbnail(v.Path, Cfg.UploadDir, v.Filename); err != nil { + log.Printf("Error: generateThumbnailHandler: failed to generate thumbnail for %s: %v", v.Filename, err) + errors = append(errors, fmt.Sprintf("%s: %v", v.Filename, err)) + } else { + successCount++ + } + } + + if len(errors) > 0 { + http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Generated %d thumbnails", successCount))+"&error="+url.QueryEscape(fmt.Sprintf("Failed: %s", strings.Join(errors, "; "))), http.StatusSeeOther) + } else { + http.Redirect(w, r, redirectBase+"?success="+url.QueryEscape(fmt.Sprintf("Successfully generated %d thumbnails", successCount)), http.StatusSeeOther) + } + + case "generate_single": + fileID := r.FormValue("file_id") + timestamp := strings.TrimSpace(r.FormValue("timestamp")) + + if timestamp == "" { + timestamp = "00:00:05" + } + + var filename, path string + err := DB.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&filename, &path) + if err != nil { + log.Printf("Error: generateThumbnailHandler: file not found for id=%s: %v", fileID, err) + http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("File not found"), http.StatusSeeOther) + return + } + + if !filepath.IsAbs(path) { + path = filepath.Join(Cfg.UploadDir, path) + } + + if err = generateThumbnailAtTime(path, Cfg.UploadDir, filename, timestamp); err != nil { + log.Printf("Error: generateThumbnailHandler: failed to generate thumbnail for file id=%s at %s: %v", fileID, timestamp, err) + http.Redirect(w, r, redirectBase+"?error="+url.QueryEscape("Failed to generate thumbnail: "+err.Error()), http.StatusSeeOther) + return + } + + if redirectTo == "admin" { + http.Redirect(w, r, "/admin?success="+url.QueryEscape(fmt.Sprintf("Thumbnail generated for file %s at %s", fileID, timestamp)), http.StatusSeeOther) + } else { + http.Redirect(w, r, fmt.Sprintf("/file/%s?success=%s", fileID, url.QueryEscape(fmt.Sprintf("Thumbnail generated at %s", timestamp))), http.StatusSeeOther) + } + + default: + http.Redirect(w, r, redirectBase, http.StatusSeeOther) + } +} + +func generateThumbnail(videoPath, uploadDir, filename string) error { + if err := generateThumbnailAtTime(videoPath, uploadDir, filename, "00:00:05"); err != nil { + log.Printf("Warning: generateThumbnail: seek to 5s failed for %s, retrying from start: %v", filename, err) + return generateThumbnailAtTime(videoPath, uploadDir, filename, "00:00:00") + } + + return nil +} + +func getMissingThumbnailVideos() ([]VideoFile, error) { + allVideos, err := getVideoFiles() + if err != nil { + return nil, err + } + + var missing []VideoFile + for _, v := range allVideos { + if !v.HasThumbnail { + missing = append(missing, v) + } + } + + return missing, nil +} diff --git a/internal/app/bulk.go b/internal/app/bulk.go @@ -0,0 +1,546 @@ +package app + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "strconv" + "strings" +) + +type TagPair struct { + Category string + Value string +} + +type BulkTagFormData struct { + Categories []string + RecentFiles []File + Error string + Success string + FormData struct { + FileRange string + Category string + Value string + Operation string + TagQuery string + SelectionMode string + } +} + +func applyBulkTagOperations(fileIDs []int, category, value, operation string) error { + category = strings.TrimSpace(category) + value = strings.TrimSpace(value) + if category == "" { + return fmt.Errorf("category cannot be empty") + } + + if operation == "add" && value == "" { + return fmt.Errorf("value cannot be empty when adding tags") + } + + tx, err := DB.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %v", err) + } + defer tx.Rollback() + + var catID int + err = tx.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("failed to query category: %v", err) + } + + if catID == 0 { + if operation == "remove" { + return fmt.Errorf("cannot remove non-existent category: %s", category) + } + res, err := tx.Exec("INSERT INTO categories(name) VALUES(?)", category) + if err != nil { + return fmt.Errorf("failed to create category: %v", err) + } + cid, _ := res.LastInsertId() + catID = int(cid) + } + + var tagID int + if value != "" { + err = tx.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) + if err != nil && err != sql.ErrNoRows { + return fmt.Errorf("failed to query tag: %v", err) + } + + if tagID == 0 { + if operation == "remove" { + return fmt.Errorf("cannot remove non-existent tag: %s=%s", category, value) + } + res, err := tx.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) + if err != nil { + return fmt.Errorf("failed to create tag: %v", err) + } + tid, _ := res.LastInsertId() + tagID = int(tid) + } + } + + for _, fileID := range fileIDs { + if operation == "add" { + _, err = tx.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", fileID, tagID) + } else if operation == "remove" { + if value != "" { + _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID) + } else { + _, err = tx.Exec(`DELETE FROM file_tags WHERE file_id=? AND tag_id IN (SELECT t.id FROM tags t WHERE t.category_id=?)`, fileID, catID) + } + } else { + return fmt.Errorf("invalid operation: %s (must be 'add' or 'remove')", operation) + } + if err != nil { + return fmt.Errorf("failed to %s tag for file %d: %v", operation, fileID, err) + } + } + + return tx.Commit() +} + +func getBulkTagFormData() BulkTagFormData { + catRows, err := DB.Query("SELECT name FROM categories ORDER BY name") + if err != nil { + log.Printf("Error: getBulkTagFormData: failed to query categories: %v", err) + } + var cats []string + if catRows != nil { + for catRows.Next() { + var c string + catRows.Scan(&c) + cats = append(cats, c) + } + catRows.Close() + } + + return BulkTagFormData{ + Categories: cats, + RecentFiles: getRecentFiles(), + FormData: struct { + FileRange string + Category string + Value string + Operation string + TagQuery string + SelectionMode string + }{Operation: "add"}, + } +} + +func bulkTagHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + formData := getBulkTagFormData() + pageData := buildPageData("Bulk Tag Editor", formData) + renderTemplate(w, "bulk-tag.html", pageData) + return + } + if r.Method == http.MethodPost { + rangeStr := strings.TrimSpace(r.FormValue("file_range")) + tagQuery := strings.TrimSpace(r.FormValue("tag_query")) + selectionMode := r.FormValue("selection_mode") + category := strings.TrimSpace(r.FormValue("category")) + value := strings.TrimSpace(r.FormValue("value")) + operation := r.FormValue("operation") + + formData := getBulkTagFormData() + formData.FormData.FileRange = rangeStr + formData.FormData.TagQuery = tagQuery + formData.FormData.SelectionMode = selectionMode + formData.FormData.Category = category + formData.FormData.Value = value + formData.FormData.Operation = operation + + createErrorResponse := func(errorMsg string) { + formData.Error = errorMsg + pageData := buildPageData("Bulk Tag Editor", formData) + renderTemplate(w, "bulk-tag.html", pageData) + } + + // Validate selection mode + if selectionMode == "" { + selectionMode = "range" // default + } + + // Validate inputs based on selection mode + if selectionMode == "range" && rangeStr == "" { + createErrorResponse("File range cannot be empty") + return + } + if selectionMode == "tags" && tagQuery == "" { + createErrorResponse("Tag query cannot be empty") + return + } + if category == "" { + createErrorResponse("Category cannot be empty") + return + } + if operation == "add" && value == "" { + createErrorResponse("Value cannot be empty when adding tags") + return + } + + // Get file IDs based on selection mode + var fileIDs []int + var err error + + if selectionMode == "range" { + fileIDs, err = parseFileIDRange(rangeStr) + if err != nil { + createErrorResponse(fmt.Sprintf("Invalid file range: %v", err)) + return + } + } else if selectionMode == "tags" { + fileIDs, err = getFileIDsFromTagQuery(tagQuery) + if err != nil { + createErrorResponse(fmt.Sprintf("Tag query error: %v", err)) + return + } + if len(fileIDs) == 0 { + createErrorResponse("No files match the tag query") + return + } + } else { + createErrorResponse("Invalid selection mode") + return + } + + validFiles, err := validateFileIDs(fileIDs) + if err != nil { + createErrorResponse(fmt.Sprintf("File validation error: %v", err)) + return + } + + err = applyBulkTagOperations(fileIDs, category, value, operation) + if err != nil { + createErrorResponse(fmt.Sprintf("Tag operation failed: %v", err)) + return + } + + // Build success message + var successMsg string + var selectionDesc string + if selectionMode == "range" { + selectionDesc = fmt.Sprintf("file range '%s'", rangeStr) + } else { + selectionDesc = fmt.Sprintf("tag query '%s'", tagQuery) + } + + if operation == "add" { + successMsg = fmt.Sprintf("Tag '%s: %s' added to %d files matching %s", + category, value, len(validFiles), selectionDesc) + } else { + if value != "" { + successMsg = fmt.Sprintf("Tag '%s: %s' removed from %d files matching %s", + category, value, len(validFiles), selectionDesc) + } else { + successMsg = fmt.Sprintf("All '%s' category tags removed from %d files matching %s", + category, len(validFiles), selectionDesc) + } + } + + // Add file list + var filenames []string + for _, f := range validFiles { + filenames = append(filenames, f.Filename) + } + if len(filenames) <= 5 { + successMsg += fmt.Sprintf(": %s", strings.Join(filenames, ", ")) + } else { + successMsg += fmt.Sprintf(": %s and %d more", strings.Join(filenames[:5], ", "), len(filenames)-5) + } + + formData.Success = successMsg + pageData := buildPageData("Bulk Tag Editor", formData) + renderTemplate(w, "bulk-tag.html", pageData) + return + } + renderError(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +func parseFileIDRange(rangeStr string) ([]int, error) { + var fileIDs []int + parts := strings.Split(rangeStr, ",") + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + if strings.Contains(part, "-") { + rangeParts := strings.Split(part, "-") + if len(rangeParts) != 2 { + return nil, fmt.Errorf("invalid range format: %s", part) + } + + start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0])) + if err != nil { + return nil, fmt.Errorf("invalid start ID in range %s: %v", part, err) + } + + end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1])) + if err != nil { + return nil, fmt.Errorf("invalid end ID in range %s: %v", part, err) + } + + if start > end { + return nil, fmt.Errorf("invalid range %s: start must be <= end", part) + } + + for i := start; i <= end; i++ { + fileIDs = append(fileIDs, i) + } + } else { + id, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid file ID: %s", part) + } + fileIDs = append(fileIDs, id) + } + } + + uniqueIDs := make(map[int]bool) + var result []int + for _, id := range fileIDs { + if !uniqueIDs[id] { + uniqueIDs[id] = true + result = append(result, id) + } + } + + return result, nil +} + +func getFileIDsFromTagQuery(query string) ([]int, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, fmt.Errorf("empty query") + } + + // Check if query contains OR operator + if strings.Contains(strings.ToUpper(query), " OR ") { + return getFileIDsFromORQuery(query) + } + + // Otherwise treat as AND query (comma-separated or single tag) + return getFileIDsFromANDQuery(query) +} + +// getFileIDsFromANDQuery handles comma-separated tags (AND logic) +func getFileIDsFromANDQuery(query string) ([]int, error) { + tagPairs := strings.Split(query, ",") + var tags []TagPair + + for _, pair := range tagPairs { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + + parts := strings.SplitN(pair, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair) + } + + tags = append(tags, TagPair{ + Category: strings.TrimSpace(parts[0]), + Value: strings.TrimSpace(parts[1]), + }) + } + + if len(tags) == 0 { + return nil, fmt.Errorf("no valid tags found in query") + } + + // Query database for files matching ALL tags + return findFilesWithAllTags(tags) +} + +// getFileIDsFromORQuery handles OR-separated tags +func getFileIDsFromORQuery(query string) ([]int, error) { + // Split on " OR " case-insensitively by working on the uppercased copy for + // index finding, but extracting substrings from the original query. + upperQuery := strings.ToUpper(query) + const sep = " OR " + var rawPairs []string + start := 0 + for { + idx := strings.Index(upperQuery[start:], sep) + if idx < 0 { + rawPairs = append(rawPairs, query[start:]) + break + } + rawPairs = append(rawPairs, query[start:start+idx]) + start += idx + len(sep) + } + + var tags []TagPair + for _, pair := range rawPairs { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + + parts := strings.SplitN(pair, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid tag format '%s', expected 'category:value'", pair) + } + + tags = append(tags, TagPair{ + Category: strings.TrimSpace(parts[0]), + Value: strings.TrimSpace(parts[1]), + }) + } + + if len(tags) == 0 { + return nil, fmt.Errorf("no valid tags found in query") + } + + // Query database for files matching ANY tag + return findFilesWithAnyTag(tags) +} + +func validateFileIDs(fileIDs []int) ([]File, error) { + if len(fileIDs) == 0 { + return nil, fmt.Errorf("no file IDs provided") + } + + placeholders := make([]string, len(fileIDs)) + args := make([]interface{}, len(fileIDs)) + for i, id := range fileIDs { + placeholders[i] = "?" + args[i] = id + } + + query := fmt.Sprintf("SELECT id, filename, path FROM files WHERE id IN (%s) ORDER BY id", + strings.Join(placeholders, ",")) + + rows, err := DB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("database error: %v", err) + } + defer rows.Close() + + var files []File + foundIDs := make(map[int]bool) + + for rows.Next() { + var f File + err := rows.Scan(&f.ID, &f.Filename, &f.Path) + if err != nil { + return nil, fmt.Errorf("error scanning file: %v", err) + } + files = append(files, f) + foundIDs[f.ID] = true + } + + var missingIDs []int + for _, id := range fileIDs { + if !foundIDs[id] { + missingIDs = append(missingIDs, id) + } + } + + if len(missingIDs) > 0 { + return files, fmt.Errorf("file IDs not found: %v", missingIDs) + } + + return files, nil +} + +func findFilesWithAnyTag(tags []TagPair) ([]int, error) { + if len(tags) == 0 { + return nil, fmt.Errorf("no tags specified") + } + + // Build query with OR conditions + query := ` + SELECT DISTINCT f.id + FROM files f + INNER JOIN file_tags ft ON f.id = ft.file_id + INNER JOIN tags t ON ft.tag_id = t.id + INNER JOIN categories c ON t.category_id = c.id + WHERE ` + + var conditions []string + var args []interface{} + + for _, tag := range tags { + conditions = append(conditions, "(c.name = ? AND t.value = ?)") + args = append(args, tag.Category, tag.Value) + } + + query += strings.Join(conditions, " OR ") + query += " ORDER BY f.id" + + rows, err := DB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("database query failed: %w", err) + } + defer rows.Close() + + var fileIDs []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan error: %w", err) + } + fileIDs = append(fileIDs, id) + } + + return fileIDs, rows.Err() +} + +func findFilesWithAllTags(tags []TagPair) ([]int, error) { + if len(tags) == 0 { + return nil, fmt.Errorf("no tags specified") + } + + // Build query with subqueries for each tag + query := ` + SELECT f.id + FROM files f + WHERE ` + + var conditions []string + var args []interface{} + + for _, tag := range tags { + conditions = append(conditions, ` + EXISTS ( + SELECT 1 FROM file_tags ft + JOIN tags t ON ft.tag_id = t.id + JOIN categories c ON t.category_id = c.id + WHERE ft.file_id = f.id + AND c.name = ? + AND t.value = ? + )`) + args = append(args, tag.Category, tag.Value) + } + + query += strings.Join(conditions, " AND ") + query += " ORDER BY f.id" + + rows, err := DB.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("database query failed: %w", err) + } + defer rows.Close() + + var fileIDs []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan error: %w", err) + } + fileIDs = append(fileIDs, id) + } + + return fileIDs, rows.Err() +} diff --git a/internal/app/cbz.go b/internal/app/cbz.go @@ -0,0 +1,355 @@ +package app + +import ( + "archive/zip" + "fmt" + "image" + "image/jpeg" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" +) + +type CBZImage struct { + Filename string + Index int +} + +// generateCBZThumbnail creates a 2x2 collage thumbnail from a CBZ file +func generateCBZThumbnail(cbzPath, uploadDir, filename string) error { + + thumbDir := filepath.Join(uploadDir, "thumbnails") + + if err := os.MkdirAll(thumbDir, 0755); err != nil { + return fmt.Errorf("failed to create thumbnails directory: %v", err) + } + + thumbPath := filepath.Join(thumbDir, filename+".jpg") + + // Open the CBZ (ZIP) file + r, err := zip.OpenReader(cbzPath) + if err != nil { + return fmt.Errorf("failed to open CBZ: %v", err) + } + defer r.Close() + + // Get list of image files from the archive + var imageFiles []*zip.File + for _, f := range r.File { + if !f.FileInfo().IsDir() { + ext := strings.ToLower(filepath.Ext(f.Name)) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { + imageFiles = append(imageFiles, f) + } + } + } + + if len(imageFiles) == 0 { + return fmt.Errorf("no images found in CBZ") + } + + // Sort files by name to get consistent ordering + sort.Slice(imageFiles, func(i, j int) bool { + return imageFiles[i].Name < imageFiles[j].Name + }) + + // Select up to 4 images evenly distributed + var selectedFiles []*zip.File + if len(imageFiles) <= 4 { + selectedFiles = imageFiles + } else { + // Pick 4 images evenly distributed through the comic + step := len(imageFiles) / 4 + for i := 0; i < 4; i++ { + selectedFiles = append(selectedFiles, imageFiles[i*step]) + } + } + + // Load the selected images + var images []image.Image + for _, f := range selectedFiles { + rc, err := f.Open() + if err != nil { + log.Printf("Warning: generateCBZThumbnail: failed to open %s: %v", f.Name, err) + continue + } + + img, _, err := image.Decode(rc) + rc.Close() + if err != nil { + log.Printf("Warning: generateCBZThumbnail: failed to decode %s: %v", f.Name, err) + continue + } + images = append(images, img) + } + + if len(images) == 0 { + return fmt.Errorf("failed to decode any images from CBZ") + } + + // Create collage + collage := createCollage(images, 400) // 400px target width + + // Save as JPEG + outFile, err := os.Create(thumbPath) + if err != nil { + return fmt.Errorf("failed to create thumbnail file: %v", err) + } + defer outFile.Close() + + if err := jpeg.Encode(outFile, collage, &jpeg.Options{Quality: 85}); err != nil { + return fmt.Errorf("failed to encode JPEG: %v", err) + } + + return nil +} + +// createCollage creates a 2x2 grid from up to 4 images +func createCollage(images []image.Image, targetWidth int) image.Image { + // Calculate cell size (half of target width) + cellSize := targetWidth / 2 + + // Create output image + collageImg := image.NewRGBA(image.Rect(0, 0, targetWidth, targetWidth)) + + // Fill with white background + for y := 0; y < targetWidth; y++ { + for x := 0; x < targetWidth; x++ { + collageImg.Set(x, y, image.White) + } + } + + // Positions for the 2x2 grid + positions := []image.Point{ + {0, 0}, // Top-left + {cellSize, 0}, // Top-right + {0, cellSize}, // Bottom-left + {cellSize, cellSize}, // Bottom-right + } + + // Draw each image + for i, img := range images { + if i >= 4 { + break + } + + // Resize image to fit cell + resized := resizeImage(img, cellSize, cellSize) + + // Draw at position + pos := positions[i] + drawImage(collageImg, resized, pos.X, pos.Y) + } + + return collageImg +} + +// resizeImage resizes an image to fit within maxWidth x maxHeight while maintaining aspect ratio +func resizeImage(img image.Image, maxWidth, maxHeight int) image.Image { + bounds := img.Bounds() + srcWidth := bounds.Dx() + srcHeight := bounds.Dy() + + // Calculate scale to fit + scaleX := float64(maxWidth) / float64(srcWidth) + scaleY := float64(maxHeight) / float64(srcHeight) + scale := scaleX + if scaleY < scale { + scale = scaleY + } + + newWidth := int(float64(srcWidth) * scale) + newHeight := int(float64(srcHeight) * scale) + + // Create new image + dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) + + // Simple nearest-neighbor scaling + for y := 0; y < newHeight; y++ { + for x := 0; x < newWidth; x++ { + srcX := int(float64(x) / scale) + srcY := int(float64(y) / scale) + dst.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) + } + } + + return dst +} + +// drawImage draws src onto dst at the given position +func drawImage(dst *image.RGBA, src image.Image, x, y int) { + bounds := src.Bounds() + for dy := 0; dy < bounds.Dy(); dy++ { + for dx := 0; dx < bounds.Dx(); dx++ { + dst.Set(x+dx, y+dy, src.At(bounds.Min.X+dx, bounds.Min.Y+dy)) + } + } +} + +// getCBZImages returns a list of images in a CBZ file +func getCBZImages(cbzPath string) ([]CBZImage, error) { + r, err := zip.OpenReader(cbzPath) + if err != nil { + return nil, err + } + defer r.Close() + + var images []CBZImage + for i, f := range r.File { + if !f.FileInfo().IsDir() { + ext := strings.ToLower(filepath.Ext(f.Name)) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { + images = append(images, CBZImage{ + Filename: f.Name, + Index: i, + }) + } + } + } + + // Sort by filename for consistent ordering + sort.Slice(images, func(i, j int) bool { + return images[i].Filename < images[j].Filename + }) + + // Re-index after sorting + for i := range images { + images[i].Index = i + } + + return images, nil +} + +// serveCBZImage extracts and serves a specific image from a CBZ file +func serveCBZImage(w http.ResponseWriter, cbzPath string, imageIndex int) error { + r, err := zip.OpenReader(cbzPath) + if err != nil { + return err + } + defer r.Close() + + // Get sorted list of images + var imageFiles []*zip.File + for _, f := range r.File { + if !f.FileInfo().IsDir() { + ext := strings.ToLower(filepath.Ext(f.Name)) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" { + imageFiles = append(imageFiles, f) + } + } + } + + sort.Slice(imageFiles, func(i, j int) bool { + return imageFiles[i].Name < imageFiles[j].Name + }) + + if imageIndex < 0 || imageIndex >= len(imageFiles) { + return fmt.Errorf("image index out of range") + } + + targetFile := imageFiles[imageIndex] + + // Set content type based on extension + ext := strings.ToLower(filepath.Ext(targetFile.Name)) + switch ext { + case ".jpg", ".jpeg": + w.Header().Set("Content-Type", "image/jpeg") + case ".png": + w.Header().Set("Content-Type", "image/png") + case ".webp": + w.Header().Set("Content-Type", "image/webp") + } + + rc, err := targetFile.Open() + if err != nil { + return err + } + defer rc.Close() + + _, err = io.Copy(w, rc) + return err +} + +// cbzViewerHandler handles the CBZ gallery viewer +func cbzViewerHandler(w http.ResponseWriter, r *http.Request) { + // Extract file ID from URL + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/cbz/"), "/") + if len(parts) < 1 { + renderError(w, "Invalid CBZ viewer path", http.StatusBadRequest) + return + } + + fileID := parts[0] + + // Get the file from database + var f File + err := DB.QueryRow("SELECT id, filename, path, COALESCE(description, '') FROM files WHERE id = ?", fileID). + Scan(&f.ID, &f.Filename, &f.Path, &f.Description) + if err != nil { + log.Printf("Error: cbzViewerHandler: file not found for id=%s: %v", fileID, err) + renderError(w, "File not found", http.StatusNotFound) + return + } + + cbzPath := filepath.Join(Cfg.UploadDir, f.Path) + + // Check if requesting a specific image + if len(parts) >= 3 && parts[1] == "image" { + imageIndex := 0 + fmt.Sscanf(parts[2], "%d", &imageIndex) + + if err := serveCBZImage(w, cbzPath, imageIndex); err != nil { + log.Printf("Error: cbzViewerHandler: failed to serve image index %d from %s: %v", imageIndex, cbzPath, err) + renderError(w, "Failed to serve image", http.StatusInternalServerError) + } + return + } + + // Get list of images + images, err := getCBZImages(cbzPath) + if err != nil { + log.Printf("Error: cbzViewerHandler: failed to read CBZ contents at %s: %v", cbzPath, err) + renderError(w, "Failed to read CBZ contents", http.StatusInternalServerError) + return + } + + // Determine which image to display + currentIndex := 0 + if len(parts) >= 2 { + fmt.Sscanf(parts[1], "%d", &currentIndex) + } + + if currentIndex < 0 { + currentIndex = 0 + } + if currentIndex >= len(images) { + currentIndex = len(images) - 1 + } + + // Prepare data for template + type CBZViewData struct { + File File + Images []CBZImage + CurrentIndex int + TotalImages int + HasPrev bool + HasNext bool + } + + viewData := CBZViewData{ + File: f, + Images: images, + CurrentIndex: currentIndex, + TotalImages: len(images), + HasPrev: currentIndex > 0, + HasNext: currentIndex < len(images)-1, + } + + pageData := buildPageData(f.Filename, viewData) + + renderTemplate(w, "cbz_viewer.html", pageData) +} diff --git a/internal/app/db.go b/internal/app/db.go @@ -0,0 +1,214 @@ +package app + +import ( + "database/sql" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +type Config struct { + // Values from CLI arguments + DatabasePath string + UploadDir string + ServerPort string + // Values from database + GallerySize string + ItemsPerPage string + TagAliases []TagAliasGroup + SedRules []SedRule +} + +type TagAliasGroup struct { + Category string `json:"category"` + Aliases []string `json:"aliases"` +} + +// InitDatabase opens the database connection and creates tables if needed +func InitDatabase(dbPath string) (*sql.DB, error) { + DB, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000") + if err != nil { + return nil, err + } + + if err := createTables(DB); err != nil { + DB.Close() + return nil, err + } + + return DB, nil +} + +// createTables creates all necessary database tables +func createTables(DB *sql.DB) error { + schema := ` + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT, + path TEXT, + description TEXT DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE + ); + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER, + value TEXT, + UNIQUE(category_id, value) + ); + CREATE TABLE IF NOT EXISTS file_tags ( + file_id INTEGER, + tag_id INTEGER, + UNIQUE(file_id, tag_id) + ); + CREATE TABLE IF NOT EXISTS file_properties ( + file_id INTEGER, + key TEXT, + value TEXT, + UNIQUE(file_id, key) + ); + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY CHECK (id = 1), + content TEXT DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS tag_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL, + aliases TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sed_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + command TEXT NOT NULL + ); + ` + + _, err := DB.Exec(schema) + return err +} + +func LoadConfig(DB *sql.DB) (Config, error) { + cfg := Config{ + GallerySize: "400px", + ItemsPerPage: "100", + TagAliases: []TagAliasGroup{}, + SedRules: []SedRule{}, + } + + rows, err := DB.Query(`SELECT key, value FROM settings`) + if err != nil { + return cfg, err + } + defer rows.Close() + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return cfg, err + } + switch key { + case "gallery_size": + if value != "" { + cfg.GallerySize = value + } + case "items_per_page": + if value != "" { + cfg.ItemsPerPage = value + } + } + } + if err := rows.Err(); err != nil { + return cfg, err + } + + aliasRows, err := DB.Query(`SELECT category, aliases FROM tag_aliases ORDER BY id`) + if err != nil { + return cfg, err + } + defer aliasRows.Close() + for aliasRows.Next() { + var category, aliasesStr string + if err := aliasRows.Scan(&category, &aliasesStr); err != nil { + return cfg, err + } + cfg.TagAliases = append(cfg.TagAliases, TagAliasGroup{ + Category: category, + Aliases: strings.Split(aliasesStr, "|"), + }) + } + if err := aliasRows.Err(); err != nil { + return cfg, err + } + + sedRows, err := DB.Query(`SELECT name, description, command FROM sed_rules ORDER BY id`) + if err != nil { + return cfg, err + } + defer sedRows.Close() + for sedRows.Next() { + var rule SedRule + if err := sedRows.Scan(&rule.Name, &rule.Description, &rule.Command); err != nil { + return cfg, err + } + cfg.SedRules = append(cfg.SedRules, rule) + } + if err := sedRows.Err(); err != nil { + return cfg, err + } + + return cfg, nil +} + +func SaveConfig(DB *sql.DB, cfg Config) error { + tx, err := DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, kv := range [][2]string{ + {"gallery_size", cfg.GallerySize}, + {"items_per_page", cfg.ItemsPerPage}, + } { + if _, err := tx.Exec(` + INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `, kv[0], kv[1]); err != nil { + return err + } + } + + if _, err := tx.Exec(`DELETE FROM tag_aliases`); err != nil { + return err + } + for _, group := range cfg.TagAliases { + aliasesStr := strings.Join(group.Aliases, "|") + if _, err := tx.Exec( + `INSERT INTO tag_aliases (category, aliases) VALUES (?, ?)`, + group.Category, aliasesStr, + ); err != nil { + return err + } + } + + if _, err := tx.Exec(`DELETE FROM sed_rules`); err != nil { + return err + } + for _, rule := range cfg.SedRules { + if _, err := tx.Exec( + `INSERT INTO sed_rules (name, description, command) VALUES (?, ?, ?)`, + rule.Name, rule.Description, rule.Command, + ); err != nil { + return err + } + } + + return tx.Commit() +} diff --git a/internal/app/files.go b/internal/app/files.go @@ -0,0 +1,205 @@ +package app + +import ( + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" +) + +func fileRouter(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + + if len(parts) >= 4 && parts[3] == "delete" { + fileDeleteHandler(w, r, parts) + return + } + + if len(parts) >= 4 && parts[3] == "rename" { + fileRenameHandler(w, r, parts) + return + } + + if len(parts) >= 5 && parts[3] == "tag" && parts[4] == "delete" { + tagActionHandler(w, r, parts) + return + } + + fileHandler(w, r) +} + +func fileDeleteHandler(w http.ResponseWriter, r *http.Request, parts []string) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) + return + } + + fileID := parts[2] + + var currentFile File + err := DB.QueryRow("SELECT id, filename, path FROM files WHERE id=?", fileID).Scan(&currentFile.ID, &currentFile.Filename, &currentFile.Path) + if err != nil { + log.Printf("Error: fileDeleteHandler: file not found for id=%s: %v", fileID, err) + renderError(w, "File not found", http.StatusNotFound) + return + } + + tx, err := DB.Begin() + if err != nil { + log.Printf("Error: fileDeleteHandler: failed to start transaction for file id=%s: %v", fileID, err) + renderError(w, "Failed to start transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if _, err = tx.Exec("DELETE FROM file_tags WHERE file_id=?", fileID); err != nil { + log.Printf("Error: fileDeleteHandler: failed to delete file_tags for file id=%s: %v", fileID, err) + renderError(w, "Failed to delete file tags", http.StatusInternalServerError) + return + } + + if _, err = tx.Exec("DELETE FROM file_properties WHERE file_id=?", fileID); err != nil { + log.Printf("Error: fileDeleteHandler: failed to delete file_properties for file id=%s: %v", fileID, err) + renderError(w, "Failed to delete file properties", http.StatusInternalServerError) + return + } + + if _, err = tx.Exec("DELETE FROM files WHERE id=?", fileID); err != nil { + log.Printf("Error: fileDeleteHandler: failed to delete files record for file id=%s: %v", fileID, err) + renderError(w, "Failed to delete file record", http.StatusInternalServerError) + return + } + + if err = tx.Commit(); err != nil { + log.Printf("Error: fileDeleteHandler: failed to commit transaction for file id=%s: %v", fileID, err) + renderError(w, "Failed to commit transaction", http.StatusInternalServerError) + return + } + + absPath := filepath.Join(Cfg.UploadDir, currentFile.Path) + if err = os.Remove(absPath); err != nil { + log.Printf("Warning: fileDeleteHandler: failed to delete physical file %s: %v", absPath, err) + } + + // Delete thumbnail if it exists + thumbPath := filepath.Join(Cfg.UploadDir, "thumbnails", currentFile.Filename+".jpg") + if _, err := os.Stat(thumbPath); err == nil { + if err := os.Remove(thumbPath); err != nil { + log.Printf("Warning: fileDeleteHandler: failed to delete thumbnail %s: %v", thumbPath, err) + } + } + + http.Redirect(w, r, "/?deleted="+currentFile.Filename, http.StatusSeeOther) +} + +func fileRenameHandler(w http.ResponseWriter, r *http.Request, parts []string) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/file/"+parts[2], http.StatusSeeOther) + return + } + + fileID := parts[2] + newFilename := sanitizeFilename(strings.TrimSpace(r.FormValue("newfilename"))) + + if newFilename == "" { + renderError(w, "New filename cannot be empty", http.StatusBadRequest) + return + } + + var currentFilename, currentRelPath string + err := DB.QueryRow("SELECT filename, path FROM files WHERE id=?", fileID).Scan(&currentFilename, &currentRelPath) + if err != nil { + log.Printf("Error: fileRenameHandler: file not found for id=%s: %v", fileID, err) + renderError(w, "File not found", http.StatusNotFound) + return + } + + if currentFilename == newFilename { + http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) + return + } + + currentAbsPath := filepath.Join(Cfg.UploadDir, currentRelPath) + newPath := filepath.Join(Cfg.UploadDir, newFilename) + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + renderError(w, "A file with that name already exists", http.StatusConflict) + return + } + + if err := os.Rename(currentAbsPath, newPath); err != nil { + log.Printf("Error: fileRenameHandler: failed to rename %s -> %s: %v", currentAbsPath, newPath, err) + renderError(w, "Failed to rename physical file: "+err.Error(), http.StatusInternalServerError) + return + } + + thumbOld := filepath.Join(Cfg.UploadDir, "thumbnails", currentFilename+".jpg") + thumbNew := filepath.Join(Cfg.UploadDir, "thumbnails", newFilename+".jpg") + + if _, err := os.Stat(thumbOld); err == nil { + if err := os.Rename(thumbOld, thumbNew); err != nil { + log.Printf("Error: fileRenameHandler: failed to rename thumbnail %s -> %s: %v", thumbOld, thumbNew, err) + if renameErr := os.Rename(newPath, currentAbsPath); renameErr != nil { + log.Printf("Error: fileRenameHandler: failed to roll back file rename %s -> %s: %v", newPath, currentAbsPath, renameErr) + } + renderError(w, "Failed to rename thumbnail: "+err.Error(), http.StatusInternalServerError) + return + } + } + + newRelPath, err := filepath.Rel(Cfg.UploadDir, newPath) + if err != nil { + log.Printf("Error: fileRenameHandler: failed to compute relative path for %s: %v", newPath, err) + newRelPath = newFilename + } + + _, err = DB.Exec("UPDATE files SET filename=?, path=? WHERE id=?", newFilename, newRelPath, fileID) + if err != nil { + log.Printf("Error: fileRenameHandler: failed to update database for file id=%s: %v", fileID, err) + if renameErr := os.Rename(newPath, currentAbsPath); renameErr != nil { + log.Printf("Error: fileRenameHandler: failed to roll back file rename %s -> %s: %v", newPath, currentAbsPath, renameErr) + } + if _, statErr := os.Stat(thumbNew); statErr == nil { + if renameErr := os.Rename(thumbNew, thumbOld); renameErr != nil { + log.Printf("Error: fileRenameHandler: failed to roll back thumbnail rename %s -> %s: %v", thumbNew, thumbOld, renameErr) + } + } + renderError(w, "Failed to update database", http.StatusInternalServerError) + return + } + + // Recompute properties in case the extension changed + if _, err := DB.Exec("DELETE FROM file_properties WHERE file_id = ?", fileID); err != nil { + log.Printf("Warning: fileRenameHandler: failed to delete old properties for file id=%s: %v", fileID, err) + } + if id, err := strconv.ParseInt(fileID, 10, 64); err == nil { + computeProperties(id, newPath) + } else { + log.Printf("Warning: fileRenameHandler: failed to parse file id %s for property recompute: %v", fileID, err) + } + + http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) +} + +func checkFileConflictStrict(filename string) (string, string, int64, error) { + finalPath := filepath.Join(Cfg.UploadDir, filename) + if _, err := os.Stat(finalPath); err == nil { + existingID, dbErr := getFileIDByName(filename) + if dbErr != nil { + log.Printf("Warning: checkFileConflictStrict: file %s exists on disk but not in DB: %v", filename, dbErr) + return "", "", 0, fmt.Errorf("a file with that name already exists") + } + return "", "", existingID, nil + } else if !os.IsNotExist(err) { + return "", "", 0, fmt.Errorf("failed to check for existing file: %v", err) + } + return filename, finalPath, 0, nil +} + +func getFileIDByName(filename string) (int64, error) { + var id int64 + err := DB.QueryRow("SELECT id FROM files WHERE filename = ?", filename).Scan(&id) + return id, err +} diff --git a/internal/app/filters.go b/internal/app/filters.go @@ -0,0 +1,307 @@ +package app + +import ( + "fmt" + "log" + "net/http" + "net/url" + "strings" +) + +type Breadcrumb struct { + Name string + URL string + Count int // 0 = not shown +} + +type filter struct { + Category string + Value string + Values []string // Expanded values including aliases + IsPreviews bool // New field to indicate preview mode + IsProperty bool +} + +func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) { + page := pageFromRequest(r) + perPage := perPageFromConfig(50) + + files, total, err := getUntaggedFilesPaginated(page, perPage) + if err != nil { + log.Printf("Error: untaggedFilesHandler: failed to get untagged files: %v", err) + } + pageData := buildPageDataWithPagination("Untagged Files", files, page, total, perPage, r) + renderTemplate(w, "untagged.html", pageData) +} + +func buildTagFilterWhere(filters []filter) (string, []interface{}) { + where := " WHERE 1=1" + var args []interface{} + + for _, f := range filters { + if f.IsProperty { + where += ` + AND EXISTS ( + SELECT 1 + FROM file_properties fp + WHERE fp.file_id = f.id AND fp.key = ? AND fp.value = ? + )` + args = append(args, f.Category, f.Value) + } else if f.Value == "unassigned" { + where += ` + AND NOT EXISTS ( + SELECT 1 + FROM file_tags ft + JOIN tags t ON ft.tag_id = t.id + JOIN categories c ON c.id = t.category_id + WHERE ft.file_id = f.id AND c.name = ? + )` + args = append(args, f.Category) + } else { + // Build OR clause for aliases + placeholders := make([]string, len(f.Values)) + for i := range f.Values { + placeholders[i] = "?" + } + + where += fmt.Sprintf(` + AND EXISTS ( + SELECT 1 + FROM file_tags ft + JOIN tags t ON ft.tag_id = t.id + JOIN categories c ON c.id = t.category_id + WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) + )`, strings.Join(placeholders, ",")) + + args = append(args, f.Category) + for _, v := range f.Values { + args = append(args, v) + } + } + } + + return where, args +} + +// parseFilterSegments splits filter path into individual filter structs +func parseFilterSegments(fullPath, firstKind string) ([]filter, []Breadcrumb, error) { + rawSegments := strings.Split(fullPath, "/and/") + + breadcrumbs := []Breadcrumb{ + {Name: "home", URL: "/"}, + {Name: "tags", URL: "/tags"}, + } + + var filters []filter + currentPath := "/" + firstKind + + for i, seg := range rawSegments { + var kind, category, value string + + if i == 0 { + // First segment has no explicit kind prefix — the caller supplies it. + parts := strings.SplitN(seg, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, nil, fmt.Errorf("invalid %s segment: %q", firstKind, seg) + } + kind, category, value = firstKind, parts[0], parts[1] + } else { + // Subsequent segments are "kind/category/value". + parts := strings.SplitN(seg, "/", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, nil, fmt.Errorf("invalid filter segment: %q", seg) + } + kind, category, value = parts[0], parts[1], parts[2] + if kind != "tag" && kind != "property" { + return nil, nil, fmt.Errorf("unknown filter kind %q in segment: %q", kind, seg) + } + } + + f := filter{ + Category: category, + Value: value, + IsProperty: kind == "property", + IsPreviews: kind == "tag" && value == "previews", + } + + if kind == "tag" && value != "unassigned" && value != "previews" { + f.Values = expandTagWithAliases(category, value) + } + + filters = append(filters, f) + + // Build the cumulative breadcrumb URL for this filter step. + if i == 0 { + currentPath += "/" + category + "/" + value + } else { + currentPath += "/and/" + kind + "/" + category + "/" + value + } + + // Add a category/key breadcrumb (deduplicated). + categoryExists := false + for _, bc := range breadcrumbs { + if bc.Name == category { + categoryExists = true + break + } + } + if !categoryExists { + anchorURL := "/tags#tag-" + category + if kind == "property" { + anchorURL = "/properties#prop-" + category + } + breadcrumbs = append(breadcrumbs, Breadcrumb{ + Name: category, + URL: anchorURL, + }) + } + + breadcrumbs = append(breadcrumbs, Breadcrumb{ + Name: value, + URL: currentPath, + }) + } + + return filters, breadcrumbs, nil +} + +func tagFilterHandler(w http.ResponseWriter, r *http.Request) { + page := pageFromRequest(r) + perPage := perPageFromConfig(50) + + fullPath := strings.TrimPrefix(r.URL.Path, "/tag/") + + filters, breadcrumbs, err := parseFilterSegments(fullPath, "tag") + if err != nil { + renderError(w, "Invalid filter path", http.StatusBadRequest) + return + } + + // Check if we're in preview mode for any filter. + hasPreviewFilter := false + for _, f := range filters { + if f.IsPreviews { + hasPreviewFilter = true + break + } + } + + if hasPreviewFilter { + files, err := getPreviewFiles(filters) + if err != nil { + log.Printf("Error: tagFilterHandler: failed to fetch preview files: %v", err) + renderError(w, "Failed to fetch preview files", http.StatusInternalServerError) + return + } + + title := "Tagged: " + buildFilterTitle(filters, " + ") + pageData := buildPageDataWithPagination(title, ListData{ + Tagged: files, + Untagged: nil, + Breadcrumbs: []Breadcrumb{}, + }, 1, len(files), len(files), r) + if len(breadcrumbs) > 0 { + breadcrumbs[len(breadcrumbs)-1].Count = len(files) + } + pageData.Breadcrumbs = breadcrumbs + + renderTemplate(w, "list.html", pageData) + return + } + + // Build the shared WHERE clause once and reuse it for both queries. + where, whereArgs := buildTagFilterWhere(filters) + + var total int + countArgs := append([]interface{}(nil), whereArgs...) // copy; count query does not need pagination args + err = DB.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total) + if err != nil { + log.Printf("Error: tagFilterHandler: failed to count files: %v", err) + renderError(w, "Failed to count files", http.StatusInternalServerError) + return + } + + offset := (page - 1) * perPage + dataArgs := append(append([]interface{}(nil), whereArgs...), perPage, offset) + files, err := queryFilesWithTags( + `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f`+ + where+` ORDER BY f.id DESC LIMIT ? OFFSET ?`, + dataArgs..., + ) + if err != nil { + log.Printf("Error: tagFilterHandler: failed to fetch files: %v", err) + renderError(w, "Failed to fetch files", http.StatusInternalServerError) + return + } + + title := "Tagged: " + buildFilterTitle(filters, ", ") + pageData := buildPageDataWithPagination(title, ListData{ + Tagged: files, + Untagged: nil, + Breadcrumbs: []Breadcrumb{}, + }, page, total, perPage, r) + if len(breadcrumbs) > 0 { + breadcrumbs[len(breadcrumbs)-1].Count = total + } + pageData.Breadcrumbs = breadcrumbs + + renderTemplate(w, "list.html", pageData) +} + +func buildFilterTitle(filters []filter, sep string) string { + var parts []string + for _, f := range filters { + parts = append(parts, fmt.Sprintf("%s: %s", f.Category, f.Value)) + } + return strings.Join(parts, sep) +} + +func expandTagWithAliases(category, value string) []string { + values := []string{value} + + for _, group := range Cfg.TagAliases { + if group.Category != category { + continue + } + + // Check if the value is in this alias group + found := false + for _, alias := range group.Aliases { + if strings.EqualFold(alias, value) { + found = true + break + } + } + + if found { + // Add all aliases from this group + for _, alias := range group.Aliases { + if !strings.EqualFold(alias, value) { + values = append(values, alias) + } + } + break + } + } + + return values +} + +func queryFilesWithTags(query string, args ...interface{}) ([]File, error) { + rows, err := DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var files []File + for rows.Next() { + var f File + if err := rows.Scan(&f.ID, &f.Filename, &f.Path, &f.Description); err != nil { + return nil, err + } + f.EscapedFilename = url.PathEscape(f.Filename) + files = append(files, f) + } + return files, nil +} diff --git a/internal/app/general.go b/internal/app/general.go @@ -0,0 +1,159 @@ +package app + +import ( + "log" + "net/http" + "net/url" + "strings" +) + +type TagDisplay struct { + Value string + Count int +} + +type ListData struct { + Tagged []File + Untagged []File + Breadcrumbs []Breadcrumb +} + +type PageData struct { + Title string + Data interface{} + Query string + IP string + Port string + Files []File + Tags map[string][]TagDisplay + Properties map[string][]PropertyDisplay + Breadcrumbs []Breadcrumb + Pagination *Pagination + GallerySize string + UntaggedCount int +} + +type File struct { + ID int + Filename string + EscapedFilename string + Path string + Description string + Tags map[string][]string +} + +func sanitizeFilename(filename string) string { + if filename == "" { + return "file" + } + filename = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(filename, "/", "_"), "\\", "_"), "..", "_") + if filename == "" { + return "file" + } + return filename +} + +func renderError(w http.ResponseWriter, message string, statusCode int) { + http.Error(w, message, statusCode) +} + +func renderTemplate(w http.ResponseWriter, tmplName string, data PageData) { + if err := Tmpl.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("Error: renderTemplate: failed to execute template %s: %v", tmplName, err) + renderError(w, "Template rendering failed", http.StatusInternalServerError) + } +} + +func redirectWithWarning(w http.ResponseWriter, r *http.Request, baseURL, warningMsg string) { + redirectURL := baseURL + if warningMsg != "" { + redirectURL += "?warning=" + url.QueryEscape(warningMsg) + } + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} + +func errorString(err error) string { + if err != nil { + return err.Error() + } + return "" +} + +func successString(err error, msg string) string { + if err == nil { + return msg + } + return "" +} + +func buildPageData(title string, data interface{}) PageData { + tagMap, err := getTagData() + if err != nil { + log.Printf("Warning: buildPageData: failed to load tag data for page %q: %v", title, err) + } + propMap, err := getPropertyNav() + if err != nil { + log.Printf("Warning: buildPageData: failed to load property nav for page %q: %v", title, err) + } + return PageData{ + Title: title, + Data: data, + Tags: tagMap, + Properties: propMap, + GallerySize: Cfg.GallerySize, + UntaggedCount: getUntaggedCount(), + } +} + +func getTagData() (map[string][]TagDisplay, error) { + rows, err := DB.Query(` + SELECT c.name, t.value, COUNT(ft.file_id) + FROM tags t + JOIN categories c ON c.id = t.category_id + LEFT JOIN file_tags ft ON ft.tag_id = t.id + GROUP BY t.id + HAVING COUNT(ft.file_id) > 0 + ORDER BY c.name, t.value`) + if err != nil { + return nil, err + } + defer rows.Close() + + tagMap := make(map[string][]TagDisplay) + for rows.Next() { + var cat, val string + var count int + if err := rows.Scan(&cat, &val, &count); err != nil { + log.Printf("Warning: getTagData: failed to scan row: %v", err) + continue + } + tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count}) + } + if err := rows.Err(); err != nil { + return tagMap, err + } + return tagMap, nil +} + +func tagsHandler(w http.ResponseWriter, r *http.Request) { + pageData := buildPageData("All Tags", nil) + pageData.Data = pageData.Tags + renderTemplate(w, "tags.html", pageData) +} + +func getUntaggedCount() int { + var count int + + err := DB.QueryRow(` + SELECT COUNT(*) + FROM files f + LEFT JOIN file_tags ft ON ft.file_id = f.id + WHERE ft.file_id IS NULL + `).Scan(&count) + + if err != nil { + log.Printf("Warning: getUntaggedCount: %v", err) + return 0 + } + return count +} diff --git a/internal/app/notes.go b/internal/app/notes.go @@ -0,0 +1,444 @@ +package app + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "sort" + "strconv" + "strings" +) + +type notesAnalysis struct { + Stats map[string]int + Categories []string + LineCount int +} + +type Note struct { + Category string + Value string + Original string // The full line as stored +} + +// GetNotes retrieves the notes content from database +func GetNotes(DB *sql.DB) (string, error) { + var content string + err := DB.QueryRow("SELECT content FROM notes WHERE id = 1").Scan(&content) + if err == sql.ErrNoRows { + return "", nil + } + return content, err +} + +// SaveNotes saves the notes content to database with sorting and deduplication +func SaveNotes(DB *sql.DB, content string) error { + // Process: deduplicate and sort + processed := ProcessNotes(content) + + _, err := DB.Exec(` + INSERT INTO notes (id, content, updated_at) + VALUES (1, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + content = excluded.content, + updated_at = excluded.updated_at + `, processed) + + return err +} + +// ProcessNotes deduplicates and sorts lines alphabetically +func ProcessNotes(content string) string { + lines := strings.Split(content, "\n") + + // Deduplicate using map + seen := make(map[string]bool) + var unique []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue // Skip empty lines + } + if !seen[trimmed] { + seen[trimmed] = true + unique = append(unique, trimmed) + } + } + + // Sort alphabetically (case-insensitive) + sort.Slice(unique, func(i, j int) bool { + return strings.ToLower(unique[i]) < strings.ToLower(unique[j]) + }) + + return strings.Join(unique, "\n") +} + +// ApplySedRule applies a sed command to content +func ApplySedRule(content, sedCmd string) (string, error) { + // Create temp file for input + tmpIn, err := os.CreateTemp("", "notes-in-*.txt") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %v", err) + } + defer os.Remove(tmpIn.Name()) + defer tmpIn.Close() + + if _, err := tmpIn.WriteString(content); err != nil { + return "", fmt.Errorf("failed to write temp file: %v", err) + } + tmpIn.Close() + + // Run sed command + cmd := exec.Command("sed", sedCmd, tmpIn.Name()) + + // Capture both stdout and stderr + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + // Include stderr in error message for debugging + errMsg := stderr.String() + if errMsg != "" { + return "", fmt.Errorf("sed failed: %s (command: sed %s)", errMsg, sedCmd) + } + return "", fmt.Errorf("sed failed: %v (command: sed %s)", err, sedCmd) + } + + return stdout.String(), nil +} + +// ParseNote parses a line into category and value +func ParseNote(line string) Note { + parts := strings.SplitN(line, ">", 2) + + note := Note{Original: line} + + if len(parts) == 2 { + note.Category = strings.TrimSpace(parts[0]) + note.Value = strings.TrimSpace(parts[1]) + } else { + note.Value = strings.TrimSpace(line) + } + + return note +} + +// FilterNotes filters notes by search term +func FilterNotes(content, searchTerm string) string { + if searchTerm == "" { + return content + } + + lines := strings.Split(content, "\n") + var filtered []string + + searchLower := strings.ToLower(searchTerm) + + for _, line := range lines { + if strings.Contains(strings.ToLower(line), searchLower) { + filtered = append(filtered, line) + } + } + + return strings.Join(filtered, "\n") +} + +// FilterByCategory filters notes by category +func FilterByCategory(content, category string) string { + if category == "" { + return content + } + + lines := strings.Split(content, "\n") + var filtered []string + + for _, line := range lines { + note := ParseNote(line) + if note.Category == category { + filtered = append(filtered, line) + } + } + + return strings.Join(filtered, "\n") +} + +func analyzeNotes(content string) notesAnalysis { + lines := strings.Split(content, "\n") + + totalLines := 0 + categorizedLines := 0 + categoryMap := make(map[string]bool) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + totalLines++ + note := ParseNote(trimmed) + if note.Category != "" { + categorizedLines++ + categoryMap[note.Category] = true + } + } + + categories := make([]string, 0, len(categoryMap)) + for cat := range categoryMap { + categories = append(categories, cat) + } + sort.Strings(categories) + + return notesAnalysis{ + Stats: map[string]int{ + "total_lines": totalLines, + "categorized_lines": categorizedLines, + "uncategorized": totalLines - categorizedLines, + "unique_categories": len(categories), + }, + Categories: categories, + LineCount: totalLines, + } +} + +// notesViewHandler displays the notes editor page +func notesViewHandler(w http.ResponseWriter, r *http.Request) { + content, err := GetNotes(DB) + if err != nil { + log.Printf("Error: notesViewHandler: failed to load notes: %v", err) + http.Error(w, "Failed to load notes", http.StatusInternalServerError) + return + } + + analysis := analyzeNotes(content) + + notesData := struct { + Content string + Stats map[string]int + Categories []string + SedRules []SedRule + }{ + Content: content, + Stats: analysis.Stats, + Categories: analysis.Categories, + SedRules: Cfg.SedRules, + } + + pageData := buildPageData("Notes", notesData) + + if err := Tmpl.ExecuteTemplate(w, "notes.html", pageData); err != nil { + log.Printf("Error: notesViewHandler: template error: %v", err) + http.Error(w, "Template error", http.StatusInternalServerError) + } +} + +// notesSaveHandler saves the notes content +func notesSaveHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + content := r.FormValue("content") + + // Process (deduplicate and sort) before saving + if err := SaveNotes(DB, content); err != nil { + log.Printf("Error: notesSaveHandler: failed to save notes: %v", err) + http.Error(w, "Failed to save notes", http.StatusInternalServerError) + return + } + + // Return success response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Notes saved successfully", + }) +} + +// notesFilterHandler filters notes by search term or category +func notesFilterHandler(w http.ResponseWriter, r *http.Request) { + content, err := GetNotes(DB) + if err != nil { + log.Printf("Error: notesFilterHandler: failed to load notes: %v", err) + http.Error(w, "Failed to load notes", http.StatusInternalServerError) + return + } + + searchTerm := r.URL.Query().Get("search") + category := r.URL.Query().Get("category") + + // Filter by search term + if searchTerm != "" { + content = FilterNotes(content, searchTerm) + } + + // Filter by category + if category != "" { + content = FilterByCategory(content, category) + } + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(content)) +} + +// notesStatsHandler returns statistics about the notes +func notesStatsHandler(w http.ResponseWriter, r *http.Request) { + content, err := GetNotes(DB) + if err != nil { + log.Printf("Error: notesStatsHandler: failed to load notes: %v", err) + http.Error(w, "Failed to load notes", http.StatusInternalServerError) + return + } + + stats := analyzeNotes(content).Stats + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) +} + +// notesApplySedHandler applies a sed rule to the notes +func notesApplySedHandler(w http.ResponseWriter, r *http.Request) { + log.Printf("Info: notesApplySedHandler called - Method: %s, Path: %s", r.Method, r.URL.Path) + + if r.Method != http.MethodPost { + log.Printf("Warning: notesApplySedHandler: wrong method: %s", r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "Method not allowed", + }) + return + } + + content := r.FormValue("content") + ruleIndexStr := r.FormValue("rule_index") + log.Printf("Info: notesApplySedHandler: content length=%d, rule_index=%s", len(content), ruleIndexStr) + + ruleIndex, err := strconv.Atoi(ruleIndexStr) + if err != nil || ruleIndex < 0 || ruleIndex >= len(Cfg.SedRules) { + log.Printf("Warning: notesApplySedHandler: invalid rule index: %s (error: %v, len(rules)=%d)", ruleIndexStr, err, len(Cfg.SedRules)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "Invalid rule index", + }) + return + } + + rule := Cfg.SedRules[ruleIndex] + log.Printf("Info: notesApplySedHandler: applying rule: %s (command: %s)", rule.Name, rule.Command) + result, err := ApplySedRule(content, rule.Command) + if err != nil { + log.Printf("Error: notesApplySedHandler: sed rule error: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + // Return the processed content + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{ + "success": true, + "content": result, + "stats": analyzeNotes(result).Stats, + } + log.Printf("Info: notesApplySedHandler: sed rule success, returning %d bytes", len(result)) + json.NewEncoder(w).Encode(response) +} + +// notesPreviewHandler previews an operation without saving +func notesPreviewHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + content := r.FormValue("content") + + // Process (deduplicate and sort) + processed := ProcessNotes(content) + analysis := analyzeNotes(processed) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "content": processed, + "stats": analysis.Stats, + "lineCount": analysis.LineCount, + }) +} + +// notesExportHandler exports notes as plain text file +func notesExportHandler(w http.ResponseWriter, r *http.Request) { + content, err := GetNotes(DB) + if err != nil { + log.Printf("Error: notesExportHandler: failed to load notes: %v", err) + http.Error(w, "Failed to load notes", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Disposition", "attachment; filename=notes.txt") + w.Write([]byte(content)) +} + +// notesImportHandler imports notes from uploaded file +func notesImportHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + file, _, err := r.FormFile("file") + if err != nil { + log.Printf("Warning: notesImportHandler: failed to read uploaded file: %v", err) + http.Error(w, "Failed to read file", http.StatusBadRequest) + return + } + defer file.Close() + + // Read file content + buf := new(strings.Builder) + if _, err := io.Copy(buf, file); err != nil { + log.Printf("Error: notesImportHandler: failed to read file content: %v", err) + http.Error(w, "Failed to read file", http.StatusInternalServerError) + return + } + + content := buf.String() + + // Option to merge or replace + mergeMode := r.FormValue("merge") == "true" + + if mergeMode { + // Merge with existing content + existingContent, err := GetNotes(DB) + if err != nil { + log.Printf("Warning: notesImportHandler: failed to load existing notes for merge: %v", err) + } + content = existingContent + "\n" + content + } + + // Save (will auto-process) + if err := SaveNotes(DB, content); err != nil { + log.Printf("Error: notesImportHandler: failed to save imported notes: %v", err) + http.Error(w, "Failed to save notes", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/notes", http.StatusSeeOther) +} diff --git a/internal/app/pagination.go b/internal/app/pagination.go @@ -0,0 +1,207 @@ +package app + +import ( + "database/sql" + "net/http" + "net/url" + "strconv" + "strings" +) + +type Pagination struct { + CurrentPage int + TotalPages int + HasPrev bool + HasNext bool + PrevPage int + NextPage int + PerPage int + PageBaseURL string +} + +func pageFromRequest(r *http.Request) int { + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + return p + } + return 1 +} + +func perPageFromConfig(fallback int) int { + if n, err := strconv.Atoi(Cfg.ItemsPerPage); err == nil && n > 0 { + return n + } + return fallback +} + +func getUntaggedFilesPaginated(page, perPage int) ([]File, int, error) { + // Get total count + var total int + err := DB.QueryRow(`SELECT COUNT(*) FROM files f LEFT JOIN file_tags ft ON ft.file_id = f.id WHERE ft.file_id IS NULL`).Scan(&total) + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * perPage + files, err := queryFilesWithTags(` + SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description + FROM files f + LEFT JOIN file_tags ft ON ft.file_id = f.id + WHERE ft.file_id IS NULL + ORDER BY f.id DESC + LIMIT ? OFFSET ? + `, perPage, offset) + + return files, total, err +} + +func buildPageDataWithPagination(title string, data interface{}, page, total, perPage int, r *http.Request) PageData { + pd := buildPageData(title, data) + pd.Pagination = calculatePagination(page, total, perPage) + pd.Pagination.PageBaseURL = pageBaseURL(r) + return pd +} + +// pageBaseURL returns a URL base suitable for appending page=N. +// It preserves all existing query parameters except 'page'. +// e.g. /search?q=cats → "?q=cats&" +// /browse → "?" +func pageBaseURL(r *http.Request) string { + params := r.URL.Query() + params.Del("page") + if encoded := params.Encode(); encoded != "" { + return "?" + encoded + "&" + } + return "?" +} + +func calculatePagination(page, total, perPage int) *Pagination { + totalPages := (total + perPage - 1) / perPage + if totalPages < 1 { + totalPages = 1 + } + + return &Pagination{ + CurrentPage: page, + TotalPages: totalPages, + HasPrev: page > 1, + HasNext: page < totalPages, + PrevPage: page - 1, + NextPage: page + 1, + PerPage: perPage, + } +} + +func getSearchResultsPaginated(query string, page, perPage int) ([]File, int, error) { + sqlPattern := "%" + strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(query), "*", "%"), "?", "_") + "%" + + var total int + err := DB.QueryRow(` + SELECT COUNT(*) + FROM ( + SELECT f.id + FROM files f + LEFT JOIN file_tags ft ON ft.file_id = f.id + LEFT JOIN tags t ON t.id = ft.tag_id + WHERE LOWER(f.filename) LIKE ? + OR LOWER(f.description) LIKE ? + OR LOWER(t.value) LIKE ? + GROUP BY f.id + ) matched + `, sqlPattern, sqlPattern, sqlPattern).Scan(&total) + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * perPage + rows, err := DB.Query(` + SELECT + f.id, + f.filename, + f.path, + COALESCE(f.description, '') AS description, + c.name AS category, + t.value AS tag + FROM ( + SELECT f2.id + FROM files f2 + LEFT JOIN file_tags ft2 ON ft2.file_id = f2.id + LEFT JOIN tags t2 ON t2.id = ft2.tag_id + WHERE LOWER(f2.filename) LIKE ? + OR LOWER(f2.description) LIKE ? + OR LOWER(t2.value) LIKE ? + GROUP BY f2.id + ORDER BY f2.id DESC + LIMIT ? OFFSET ? + ) matched + JOIN files f + ON f.id = matched.id + LEFT JOIN file_tags ft + ON ft.file_id = f.id + LEFT JOIN tags t + ON t.id = ft.tag_id + LEFT JOIN categories c + ON c.id = t.category_id + ORDER BY f.id DESC, c.name, t.value + `, sqlPattern, sqlPattern, sqlPattern, perPage, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + fileMap := make(map[int]*File) + files := make([]File, 0, perPage) + + for rows.Next() { + var ( + id int + filename, path, description sql.NullString + category, tag sql.NullString + ) + + if err := rows.Scan(&id, &filename, &path, &description, &category, &tag); err != nil { + return nil, 0, err + } + f, exists := fileMap[id] + if !exists { + file := File{ + ID: id, + Filename: filename.String, + Path: path.String, + EscapedFilename: url.PathEscape(filename.String), + Description: description.String, + Tags: make(map[string][]string), + } + files = append(files, file) + f = &files[len(files)-1] + fileMap[id] = f + } + if category.Valid && tag.Valid && tag.String != "" { + f.Tags[category.String] = append(f.Tags[category.String], tag.String) + } + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + return files, total, nil +} + +func getTaggedFilesPaginated(page, perPage int) ([]File, int, error) { + // Get total count + var total int + err := DB.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f JOIN file_tags ft ON ft.file_id = f.id`).Scan(&total) + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * perPage + files, err := queryFilesWithTags(` + SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description + FROM files f + JOIN file_tags ft ON ft.file_id = f.id + ORDER BY f.id DESC + LIMIT ? OFFSET ? + `, perPage, offset) + + return files, total, err +} diff --git a/internal/app/previews.go b/internal/app/previews.go @@ -0,0 +1,127 @@ +package app + +import ( + "fmt" + "strings" +) + +// getPreviewFiles returns one representative file for each tag value in the specified category +func getPreviewFiles(filters []filter) ([]File, error) { + // Find the preview filter category + var previewCategory string + for _, f := range filters { + if f.IsPreviews { + previewCategory = f.Category + break + } + } + + if previewCategory == "" { + return []File{}, nil + } + + // First, get all tag values for the preview category that have files + tagQuery := ` + SELECT DISTINCT t.value + FROM tags t + JOIN categories c ON t.category_id = c.id + JOIN file_tags ft ON ft.tag_id = t.id + WHERE c.name = ? + ORDER BY t.value` + + tagRows, err := DB.Query(tagQuery, previewCategory) + if err != nil { + return nil, fmt.Errorf("failed to query tag values: %w", err) + } + defer tagRows.Close() + + var tagValues []string + for tagRows.Next() { + var tagValue string + if err := tagRows.Scan(&tagValue); err != nil { + return nil, fmt.Errorf("failed to scan tag value: %w", err) + } + tagValues = append(tagValues, tagValue) + } + + if len(tagValues) == 0 { + return []File{}, nil + } + + // For each tag value, find one representative file + var allFiles []File + for _, tagValue := range tagValues { + // Build query for this specific tag value with all filters applied + query := `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description + FROM files f + WHERE 1=1` + args := []interface{}{} + + // Apply all filters (including the preview category with this specific value) + for _, filter := range filters { + if filter.IsPreviews { + // For the preview filter, use the current tag value we're iterating over + query += ` + AND EXISTS ( + SELECT 1 + FROM file_tags ft + JOIN tags t ON ft.tag_id = t.id + JOIN categories c ON c.id = t.category_id + WHERE ft.file_id = f.id AND c.name = ? AND t.value = ? + )` + args = append(args, filter.Category, tagValue) + } else if filter.IsProperty { + query += ` + AND EXISTS ( + SELECT 1 + FROM file_properties fp + WHERE fp.file_id = f.id AND fp.key = ? AND fp.value = ? + )` + args = append(args, filter.Category, filter.Value) + } else if filter.Value == "unassigned" { + query += ` + AND NOT EXISTS ( + SELECT 1 + FROM file_tags ft + JOIN tags t ON ft.tag_id = t.id + JOIN categories c ON c.id = t.category_id + WHERE ft.file_id = f.id AND c.name = ? + )` + args = append(args, filter.Category) + } else { + // Normal filter with aliases + placeholders := make([]string, len(filter.Values)) + for i := range filter.Values { + placeholders[i] = "?" + } + + query += fmt.Sprintf(` + AND EXISTS ( + SELECT 1 + FROM file_tags ft + JOIN tags t ON ft.tag_id = t.id + JOIN categories c ON c.id = t.category_id + WHERE ft.file_id = f.id AND c.name = ? AND t.value IN (%s) + )`, strings.Join(placeholders, ",")) + + args = append(args, filter.Category) + for _, v := range filter.Values { + args = append(args, v) + } + } + } + + query += ` ORDER BY f.id DESC LIMIT 1` + + files, err := queryFilesWithTags(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query files for tag %s: %w", tagValue, err) + } + + if len(files) > 0 { + allFiles = append(allFiles, files[0]) + } + } + + return allFiles, nil +} diff --git a/internal/app/properties.go b/internal/app/properties.go @@ -0,0 +1,305 @@ +package app + +import ( + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type PropertyDisplay struct { + Value string + Count int +} + +func computeProperties(fileID int64, filePath string) { + ext := strings.ToLower(filepath.Ext(filePath)) + + setProperty(fileID, "filetype", strings.TrimPrefix(ext, ".")) + + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + computeImageProperties(fileID, filePath) + case ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v": + computeVideoProperties(fileID, filePath) + } +} + +func setProperty(fileID int64, key, value string) { + if value == "" { + return + } + _, err := DB.Exec( + `INSERT OR IGNORE INTO file_properties (file_id, key, value) VALUES (?, ?, ?)`, + fileID, key, value, + ) + if err != nil { + log.Printf("Warning: failed to set property %s for file %d: %v", key, fileID, err) + } +} + +func computeImageProperties(fileID int64, filePath string) { + f, err := os.Open(filePath) + if err != nil { + log.Printf("Warning: could not open image for properties %s: %v", filePath, err) + return + } + defer f.Close() + + cfg, _, err := image.DecodeConfig(f) + if err != nil { + log.Printf("Warning: could not decode image Cfg %s: %v", filePath, err) + return + } + + w, h := cfg.Width, cfg.Height + + var orientation string + switch { + case w > h: + orientation = "landscape" + case h > w: + orientation = "portrait" + default: + orientation = "square" + } + setProperty(fileID, "orientation", orientation) + + mp := w * h + var tier string + switch { + case mp < 1_000_000: + tier = "small" + case mp < 4_000_000: + tier = "medium" + case mp < 12_000_000: + tier = "large" + default: + tier = "huge" + } + setProperty(fileID, "resolution", tier) +} + +func computeVideoProperties(fileID int64, filePath string) { + cmd := exec.Command("ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=nokey=1:noprint_wrappers=1", + filePath, + ) + out, err := cmd.Output() + if err != nil { + log.Printf("Warning: ffprobe failed for %s: %v", filePath, err) + return + } + + seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil { + log.Printf("Warning: could not parse duration for %s: %v", filePath, err) + return + } + + var bucket string + switch { + case seconds < 60: + bucket = "tiny" + case seconds < 300: + bucket = "short" + case seconds < 2700: + bucket = "moderate" + default: + bucket = "long" + } + setProperty(fileID, "duration", bucket) +} + +func computeMissingProperties() (int, error) { + rows, err := DB.Query(` + SELECT f.id, f.path + FROM files f + WHERE NOT EXISTS ( + SELECT 1 FROM file_properties fp WHERE fp.file_id = f.id + ) + `) + if err != nil { + return 0, fmt.Errorf("failed to query files: %w", err) + } + + type fileRow struct { + id int64 + path string + } + var files []fileRow + for rows.Next() { + var r fileRow + if err := rows.Scan(&r.id, &r.path); err != nil { + continue + } + files = append(files, r) + } + rows.Close() + + if err := rows.Err(); err != nil { + return 0, err + } + + for _, f := range files { + computeProperties(f.id, f.path) + } + return len(files), nil +} + +func getPropertyNav() (map[string][]PropertyDisplay, error) { + rows, err := DB.Query(` + SELECT fp.key, fp.value, COUNT(*) as cnt + FROM file_properties fp + JOIN files f ON f.id = fp.file_id + GROUP BY fp.key, fp.value + ORDER BY fp.key, fp.value + `) + if err != nil { + return nil, err + } + defer rows.Close() + + propMap := make(map[string][]PropertyDisplay) + for rows.Next() { + var key, val string + var count int + if err := rows.Scan(&key, &val, &count); err != nil { + continue + } + propMap[key] = append(propMap[key], PropertyDisplay{Value: val, Count: count}) + } + return propMap, nil +} + +func propertyFilterHandler(w http.ResponseWriter, r *http.Request) { + trimmed := strings.TrimPrefix(r.URL.Path, "/property/") + + // If path contains /and/ delegate to the shared multi-filter + if strings.Contains(trimmed, "/and/") { + page := pageFromRequest(r) + perPage := perPageFromConfig(50) + + filters, breadcrumbs, err := parseFilterSegments(trimmed, "property") + if err != nil { + renderError(w, "Invalid filter path", http.StatusBadRequest) + return + } + + where, whereArgs := buildTagFilterWhere(filters) + + var total int + countArgs := append([]interface{}(nil), whereArgs...) + err = DB.QueryRow(`SELECT COUNT(DISTINCT f.id) FROM files f`+where, countArgs...).Scan(&total) + if err != nil { + log.Printf("Error: propertyFilterHandler: failed to count files: %v", err) + renderError(w, "Failed to count files", http.StatusInternalServerError) + return + } + + offset := (page - 1) * perPage + dataArgs := append(append([]interface{}(nil), whereArgs...), perPage, offset) + files, err := queryFilesWithTags( + `SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description FROM files f`+ + where+` ORDER BY f.id DESC LIMIT ? OFFSET ?`, + dataArgs..., + ) + if err != nil { + log.Printf("Error: propertyFilterHandler: failed to fetch files: %v", err) + renderError(w, "Failed to fetch files", http.StatusInternalServerError) + return + } + + title := buildFilterTitle(filters, ", ") + pageData := buildPageDataWithPagination(title, ListData{ + Tagged: files, + Untagged: nil, + Breadcrumbs: []Breadcrumb{}, + }, page, total, perPage, r) + pageData.Breadcrumbs = breadcrumbs + + renderTemplate(w, "list.html", pageData) + return + } + + parts := strings.SplitN(trimmed, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + renderError(w, "Invalid property filter path", http.StatusBadRequest) + return + } + + key := parts[0] + value := parts[1] + + page := pageFromRequest(r) + perPage := perPageFromConfig(50) + + var total int + err := DB.QueryRow(` + SELECT COUNT(DISTINCT f.id) + FROM files f + JOIN file_properties fp ON fp.file_id = f.id + WHERE fp.key = ? AND fp.value = ? + `, key, value).Scan(&total) + if err != nil { + renderError(w, "Failed to count files", http.StatusInternalServerError) + return + } + + offset := (page - 1) * perPage + files, err := queryFilesWithTags(` + SELECT f.id, f.filename, f.path, COALESCE(f.description, '') as description + FROM files f + JOIN file_properties fp ON fp.file_id = f.id + WHERE fp.key = ? AND fp.value = ? + ORDER BY f.id DESC + LIMIT ? OFFSET ? + `, key, value, perPage, offset) + if err != nil { + renderError(w, "Failed to fetch files", http.StatusInternalServerError) + return + } + + breadcrumbs := []Breadcrumb{ + {Name: "home", URL: "/"}, + {Name: "properties", URL: "/properties"}, + {Name: key, URL: "/properties#prop-" + key}, + {Name: value, URL: r.URL.Path}, + } + + title := fmt.Sprintf("%s: %s", key, value) + pageData := buildPageDataWithPagination(title, ListData{ + Tagged: files, + Breadcrumbs: breadcrumbs, + }, page, total, perPage, r) + pageData.Breadcrumbs = breadcrumbs + + renderTemplate(w, "list.html", pageData) +} + +func propertiesIndexHandler(w http.ResponseWriter, r *http.Request) { + pageData := buildPageData("Properties", nil) + pageData.Data = pageData.Properties + renderTemplate(w, "properties.html", pageData) +} + +func handleComputeProperties(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { + count, err := computeMissingProperties() + data := currentAdminState(r, orphanData, missingThumbnails) + if err != nil { + data.Error = "Property computation failed: " + err.Error() + } else { + data.Success = fmt.Sprintf("Computed properties for %d files.", count) + } + renderAdminPage(w, r, data) +} diff --git a/internal/app/routes.go b/internal/app/routes.go @@ -0,0 +1,37 @@ +package app + +import ( + "net/http" +) + +// RegisterRoutes sets up all HTTP routes for the application +func RegisterRoutes() { + // Page handlers + http.HandleFunc("/", listFilesHandler) + http.HandleFunc("/add", uploadHandler) + http.HandleFunc("/add-yt", ytdlpHandler) + http.HandleFunc("/add-local", localFileHandler) + http.HandleFunc("/admin", adminHandler) + http.HandleFunc("/bulk-tag", bulkTagHandler) + http.HandleFunc("/cbz/", cbzViewerHandler) + http.HandleFunc("/file/", fileRouter) + http.HandleFunc("/notes", notesViewHandler) + http.HandleFunc("/notes/apply-sed", notesApplySedHandler) + http.HandleFunc("/notes/export", notesExportHandler) + http.HandleFunc("/notes/filter", notesFilterHandler) + http.HandleFunc("/notes/import", notesImportHandler) + http.HandleFunc("/notes/preview", notesPreviewHandler) + http.HandleFunc("/notes/save", notesSaveHandler) + http.HandleFunc("/notes/stats", notesStatsHandler) + http.HandleFunc("/properties", propertiesIndexHandler) + http.HandleFunc("/property/", propertyFilterHandler) + http.HandleFunc("/search/", searchHandler) + http.HandleFunc("/tag/", tagFilterHandler) + http.HandleFunc("/tags", tagsHandler) + http.HandleFunc("/thumbnails/generate", generateThumbnailHandler) + http.HandleFunc("/untagged", untaggedFilesHandler) + http.HandleFunc("/upload-url", uploadFromURLHandler) + // Static file serving + http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(Cfg.UploadDir)))) + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) +} diff --git a/internal/app/search.go b/internal/app/search.go @@ -0,0 +1,34 @@ +package app + +import ( + "fmt" + "net/http" + "strings" +) + +func searchHandler(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/search/")) + page := pageFromRequest(r) + perPage := perPageFromConfig(50) + + var files []File + var total int + var searchTitle string + + if query != "" { + var err error + files, total, err = getSearchResultsPaginated(query, page, perPage) + if err != nil { + renderError(w, "Search failed: "+err.Error(), http.StatusInternalServerError) + return + } + searchTitle = fmt.Sprintf("Search Results for: %s", query) + } else { + searchTitle = "Search Files" + } + + pageData := buildPageDataWithPagination(searchTitle, files, page, total, perPage, r) + pageData.Query = query + pageData.Files = files + renderTemplate(w, "search.html", pageData) +} diff --git a/internal/app/templates.go b/internal/app/templates.go @@ -0,0 +1,42 @@ +package app + +import ( + "fmt" + "html/template" + "net/url" + "strings" +) + +// InitTemplates loads and parses all HTML templates with helper functions +func InitTemplates() (*template.Template, error) { + return template.New("").Funcs(template.FuncMap{ + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "pathEscape": url.PathEscape, + "hasPrefix": func(s, prefix string) bool { + return strings.HasPrefix(s, prefix) + }, + "hasAnySuffix": func(s string, suffixes ...string) bool { + for _, suf := range suffixes { + if strings.HasSuffix(strings.ToLower(s), suf) { + return true + } + } + return false + }, + "dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("dict requires an even number of args") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict keys must be strings") + } + dict[key] = values[i+1] + } + return dict, nil + }, + }).ParseGlob("templates/*.html") +} diff --git a/internal/app/upload_local.go b/internal/app/upload_local.go @@ -0,0 +1,68 @@ +package app + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +func localFileHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/upload", http.StatusSeeOther) + return + } + + rawPath := strings.TrimSpace(r.FormValue("filepath")) + if rawPath == "" { + renderError(w, "No file path provided", http.StatusBadRequest) + return + } + + // Resolve to an absolute, cleaned path to prevent traversal tricks + absPath, err := filepath.Abs(rawPath) + if err != nil { + renderError(w, "Invalid file path", http.StatusBadRequest) + return + } + + // Confirm the file actually exists and is a regular file (not a dir/symlink) + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + renderError(w, fmt.Sprintf("File not found: %s", absPath), http.StatusBadRequest) + } else { + renderError(w, fmt.Sprintf("Cannot access file: %v", err), http.StatusInternalServerError) + } + return + } + if !info.Mode().IsRegular() { + renderError(w, "Path must point to a regular file", http.StatusBadRequest) + return + } + + f, err := os.Open(absPath) + if err != nil { + renderError(w, fmt.Sprintf("Failed to open file: %v", err), http.StatusInternalServerError) + return + } + defer f.Close() + + deleteSource := r.FormValue("delete_source") == "on" + + id, warningMsg, err := processUpload(f, filepath.Base(absPath)) + if err != nil { + renderError(w, err.Error(), http.StatusInternalServerError) + return + } + + if deleteSource { + f.Close() + if removeErr := os.Remove(absPath); removeErr != nil { + warningMsg = strings.TrimPrefix(fmt.Sprintf("%s; could not delete source file: %v", warningMsg, removeErr), "; ") + } + } + + redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) +} diff --git a/internal/app/uploads.go b/internal/app/uploads.go @@ -0,0 +1,331 @@ +package app + +import ( + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +func uploadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + pageData := buildPageData("Add File", nil) + renderTemplate(w, "add.html", pageData) + return + } + + // Parse the multipart form (with max memory limit, e.g., 32MB) + err := r.ParseMultipartForm(32 << 20) + if err != nil { + renderError(w, "Failed to parse form", http.StatusBadRequest) + return + } + + files := r.MultipartForm.File["file"] + if len(files) == 0 { + renderError(w, "No files uploaded", http.StatusBadRequest) + return + } + + var warnings []string + var lastID int64 + + // Process each file + for _, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + renderError(w, "Failed to open uploaded file", http.StatusInternalServerError) + return + } + defer file.Close() + + id, warningMsg, err := processUpload(file, fileHeader.Filename) + if err != nil { + renderError(w, err.Error(), http.StatusInternalServerError) + return + } + + lastID = id + + if warningMsg != "" { + warnings = append(warnings, warningMsg) + } + } + + var warningMsg string + if len(warnings) > 0 { + warningMsg = strings.Join(warnings, "; ") + } + + redirectTarget := "/untagged" + if len(files) == 1 && lastID != 0 { + redirectTarget = "/file/" + strconv.FormatInt(lastID, 10) + } + + redirectWithWarning(w, r, redirectTarget, warningMsg) +} + +func processUpload(src io.Reader, filename string) (int64, string, error) { + finalFilename, finalPath, conflictID, err := checkFileConflictStrict(filename) + if err != nil { + return 0, "", err + } + if conflictID != 0 { + return conflictID, "", nil + } + + tempPath := finalPath + ".tmp" + tempFile, err := os.Create(tempPath) + if err != nil { + return 0, "", fmt.Errorf("failed to create temp file: %v", err) + } + + _, err = io.Copy(tempFile, src) + tempFile.Close() + if err != nil { + os.Remove(tempPath) + return 0, "", fmt.Errorf("failed to copy file data: %v", err) + } + + ext := strings.ToLower(filepath.Ext(filename)) + videoExts := map[string]bool{ + ".mp4": true, ".mov": true, ".avi": true, + ".mkv": true, ".webm": true, ".m4v": true, + } + + var processedPath string + var warningMsg string + + if videoExts[ext] || ext == ".cbz" { + // Process videos and CBZ files + processedPath, warningMsg, err = processVideoFile(tempPath, finalPath) + if err != nil { + os.Remove(tempPath) + return 0, "", err + } + } else { + // Non-video, non-CBZ → just rename temp file to final + if err := os.Rename(tempPath, finalPath); err != nil { + return 0, "", fmt.Errorf("failed to move file: %v", err) + } + processedPath = finalPath + } + + id, err := saveFileToDatabase(finalFilename, processedPath) + if err != nil { + os.Remove(processedPath) + return 0, "", err + } + + return id, warningMsg, nil +} + +func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/upload", http.StatusSeeOther) + return + } + + fileURL := r.FormValue("fileurl") + if fileURL == "" { + renderError(w, "No URL provided", http.StatusBadRequest) + return + } + + customFilename := strings.TrimSpace(r.FormValue("filename")) + + parsedURL, err := url.ParseRequestURI(fileURL) + if err != nil || !(parsedURL.Scheme == "http" || parsedURL.Scheme == "https") { + renderError(w, "Invalid URL", http.StatusBadRequest) + return + } + + resp, err := http.Get(fileURL) + if err != nil || resp.StatusCode != http.StatusOK { + renderError(w, "Failed to download file", http.StatusBadRequest) + return + } + defer resp.Body.Close() + + var filename string + urlExt := filepath.Ext(parsedURL.Path) + if customFilename != "" { + filename = customFilename + if filepath.Ext(filename) == "" && urlExt != "" { + filename += urlExt + } + } else { + parts := strings.Split(parsedURL.Path, "/") + filename = parts[len(parts)-1] + if filename == "" { + filename = "file_from_url" + } + } + + id, warningMsg, err := processUpload(resp.Body, filename) + if err != nil { + renderError(w, err.Error(), http.StatusInternalServerError) + return + } + + redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) +} + +func ytdlpHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/upload", http.StatusSeeOther) + return + } + + videoURL := r.FormValue("url") + if videoURL == "" { + renderError(w, "No URL provided", http.StatusBadRequest) + return + } + + outTemplate := filepath.Join(Cfg.UploadDir, "%(title)s.%(ext)s") + filenameCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, "--get-filename", videoURL) + filenameBytes, err := filenameCmd.Output() + if err != nil { + renderError(w, fmt.Sprintf("Failed to get filename: %v", err), http.StatusInternalServerError) + return + } + expectedFullPath := strings.TrimSpace(string(filenameBytes)) + expectedFilename := filepath.Base(expectedFullPath) + + finalFilename, finalPath, conflictID, err := checkFileConflictStrict(expectedFilename) + if err != nil { + renderError(w, err.Error(), http.StatusConflict) + return + } + if conflictID != 0 { + redirectWithWarning(w, r, fmt.Sprintf("/file/%d", conflictID), "") + return + } + + downloadCmd := exec.Command("yt-dlp", "--playlist-items", "1", "-f", "mp4", "-o", outTemplate, videoURL) + downloadCmd.Stdout = os.Stdout + downloadCmd.Stderr = os.Stderr + if err := downloadCmd.Run(); err != nil { + renderError(w, fmt.Sprintf("Failed to download video: %v", err), http.StatusInternalServerError) + return + } + + if expectedFullPath != finalPath { + if err := os.Rename(expectedFullPath, finalPath); err != nil { + renderError(w, fmt.Sprintf("Failed to move downloaded file: %v", err), http.StatusInternalServerError) + return + } + } + + tempPath := finalPath + ".tmp" + if err := os.Rename(finalPath, tempPath); err != nil { + renderError(w, fmt.Sprintf("Failed to create temp file for processing: %v", err), http.StatusInternalServerError) + return + } + + processedPath, warningMsg, err := processVideoFile(tempPath, finalPath) + if err != nil { + os.Remove(tempPath) + renderError(w, fmt.Sprintf("Failed to process video: %v", err), http.StatusInternalServerError) + return + } + + id, err := saveFileToDatabase(finalFilename, processedPath) + if err != nil { + os.Remove(processedPath) + renderError(w, err.Error(), http.StatusInternalServerError) + return + } + + redirectWithWarning(w, r, fmt.Sprintf("/file/%d", id), warningMsg) +} + +func processVideoFile(tempPath, finalPath string) (string, string, error) { + ext := strings.ToLower(filepath.Ext(finalPath)) + + // Handle CBZ files + if ext == ".cbz" { + if err := os.Rename(tempPath, finalPath); err != nil { + return "", "", fmt.Errorf("failed to move file: %v", err) + } + if err := generateCBZThumbnail(finalPath, Cfg.UploadDir, filepath.Base(finalPath)); err != nil { + log.Printf("Warning: could not generate CBZ thumbnail: %v", err) + } + return finalPath, "", nil + } + + // Handle video files + codec, err := detectVideoCodec(tempPath) + if err != nil { + return "", "", err + } + if codec == "hevc" || codec == "h265" { + warningMsg := "The video uses HEVC and has been re-encoded to H.264 for browser compatibility." + if err := reencodeHEVCToH264(tempPath, finalPath); err != nil { + return "", "", fmt.Errorf("failed to re-encode HEVC video: %v", err) + } + os.Remove(tempPath) + + if err := generateThumbnail(finalPath, Cfg.UploadDir, filepath.Base(finalPath)); err != nil { + log.Printf("Warning: could not generate thumbnail after HEVC re-encode: %v", err) + } + + return finalPath, warningMsg, nil + } + if err := os.Rename(tempPath, finalPath); err != nil { + return "", "", fmt.Errorf("failed to move file: %v", err) + } + + // Generate thumbnail for video files + if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" || ext == ".webm" || ext == ".m4v" { + if err := generateThumbnail(finalPath, Cfg.UploadDir, filepath.Base(finalPath)); err != nil { + log.Printf("Warning: could not generate thumbnail: %v", err) + } + } + + return finalPath, "", nil +} + +func saveFileToDatabase(filename, path string) (int64, error) { + relPath, err := filepath.Rel(Cfg.UploadDir, path) + if err != nil { + return 0, fmt.Errorf("failed to compute relative path: %v", err) + } + + res, err := DB.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, relPath) + if err != nil { + return 0, fmt.Errorf("failed to save file to database: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get inserted ID: %v", err) + } + computeProperties(id, path) + return id, nil +} + +func detectVideoCodec(filePath string) (string, error) { + cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", filePath) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to probe video codec: %v", err) + } + return strings.TrimSpace(string(out)), nil +} + +func reencodeHEVCToH264(inputPath, outputPath string) error { + cmd := exec.Command("ffmpeg", "-i", inputPath, + "-c:v", "libx264", "-profile:v", "baseline", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-movflags", "+faststart", outputPath) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + return cmd.Run() +} diff --git a/internal/app/vars.go b/internal/app/vars.go @@ -0,0 +1,13 @@ +package app + +import ( + "database/sql" + "html/template" +) + +// Shared application state used across handlers in this package. +var ( + DB *sql.DB + Tmpl *template.Template + Cfg Config +) diff --git a/internal/app/viewer.go b/internal/app/viewer.go @@ -0,0 +1,393 @@ +package app + +import ( + "database/sql" + "fmt" + "html" + "log" + "net" + "net/http" + "net/url" + "strconv" + "strings" +) + +func getPreviousTagValue(category string, excludeFileID int) (string, error) { + var value string + err := DB.QueryRow(` + SELECT t.value + FROM tags t + JOIN categories c ON c.id = t.category_id + JOIN file_tags ft ON ft.tag_id = t.id + JOIN files f ON f.id = ft.file_id + WHERE c.name = ? AND ft.file_id != ? + ORDER BY ft.rowid DESC + LIMIT 1 + `, category, excludeFileID).Scan(&value) + + if err == sql.ErrNoRows { + return "", fmt.Errorf("no previous tag found for category: %s", category) + } + if err != nil { + return "", err + } + + return value, nil +} + +func getPreviousFileTags(excludeFileID int) ([]struct{ cat, val string }, error) { + rows, err := DB.Query(` + SELECT c.name, t.value + FROM tags t + JOIN categories c ON c.id = t.category_id + JOIN file_tags ft ON ft.tag_id = t.id + WHERE ft.file_id = ( + SELECT file_id FROM file_tags + WHERE file_id != ? + ORDER BY rowid DESC + LIMIT 1 + ) + `, excludeFileID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []struct{ cat, val string } + for rows.Next() { + var cat, val string + if err := rows.Scan(&cat, &val); err != nil { + return nil, err + } + tags = append(tags, struct{ cat, val string }{cat, val}) + } + if len(tags) == 0 { + return nil, fmt.Errorf("no tags found on previous file") + } + return tags, nil +} + +func getFileTagsByID(fileID int) ([]struct{ cat, val string }, error) { + rows, err := DB.Query(` + SELECT c.name, t.value + FROM tags t + JOIN categories c ON c.id = t.category_id + JOIN file_tags ft ON ft.tag_id = t.id + WHERE ft.file_id = ? + `, fileID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []struct{ cat, val string } + for rows.Next() { + var cat, val string + if err := rows.Scan(&cat, &val); err != nil { + return nil, err + } + tags = append(tags, struct{ cat, val string }{cat, val}) + } + if len(tags) == 0 { + return nil, fmt.Errorf("no tags found on file id %d", fileID) + } + return tags, nil +} + +func fileHandler(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/file/") + if strings.Contains(idStr, "/") { + idStr = strings.SplitN(idStr, "/", 2)[0] + } + + var f File + err := DB.QueryRow("SELECT id, filename, path, COALESCE(description, '') as description FROM files WHERE id=?", idStr).Scan(&f.ID, &f.Filename, &f.Path, &f.Description) + if err != nil { + log.Printf("Error: fileHandler: file not found for id=%s: %v", idStr, err) + renderError(w, "File not found", http.StatusNotFound) + return + } + + f.Tags = make(map[string][]string) + rows, err := DB.Query(` + SELECT c.name, t.value + FROM tags t + JOIN categories c ON c.id = t.category_id + JOIN file_tags ft ON ft.tag_id = t.id + WHERE ft.file_id=?`, f.ID) + if err != nil { + log.Printf("Warning: fileHandler: failed to query tags for file id=%d: %v", f.ID, err) + } else { + for rows.Next() { + var cat, val string + if err := rows.Scan(&cat, &val); err != nil { + log.Printf("Warning: fileHandler: failed to scan tag row for file id=%d: %v", f.ID, err) + continue + } + f.Tags[cat] = append(f.Tags[cat], val) + } + rows.Close() + } + + if r.Method == http.MethodPost { + if r.FormValue("action") == "update_description" { + description := r.FormValue("description") + if len(description) > 2048 { + description = description[:2048] + } + + if _, err := DB.Exec("UPDATE files SET description = ? WHERE id = ?", description, f.ID); err != nil { + log.Printf("Error: fileHandler: failed to update description for file id=%d: %v", f.ID, err) + renderError(w, "Failed to update description", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + + cat := strings.TrimSpace(r.FormValue("category")) + val := strings.TrimSpace(r.FormValue("value")) + + if cat == "!" || (strings.HasPrefix(cat, "!") && len(cat) > 1) { + var sourceTags []struct{ cat, val string } + var sourceDesc string + + if cat == "!" { + var err error + sourceTags, err = getPreviousFileTags(f.ID) + if err != nil { + log.Printf("Warning: fileHandler: could not copy tags from previous file for file id=%d: %v", f.ID, err) + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + sourceDesc = "previous file" + } else { + sourceID, err := strconv.Atoi(cat[1:]) + if err != nil { + log.Printf("Warning: fileHandler: invalid file ID in category %q for file id=%d", cat, f.ID) + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + sourceTags, err = getFileTagsByID(sourceID) + if err != nil { + log.Printf("Warning: fileHandler: could not copy tags from file %d for file id=%d: %v", sourceID, f.ID, err) + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + sourceDesc = fmt.Sprintf("file %d", sourceID) + } + + for _, tag := range sourceTags { + _, tagID, err := getOrCreateCategoryAndTag(tag.cat, tag.val) + if err != nil { + log.Printf("Error: fileHandler: failed to create tag %s:%s while copying from %s for file id=%d: %v", tag.cat, tag.val, sourceDesc, f.ID, err) + continue + } + if _, err = DB.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID); err != nil { + log.Printf("Error: fileHandler: failed to add tag %s:%s to file id=%d: %v", tag.cat, tag.val, f.ID, err) + } + } + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + + if cat == "" || val == "" { + log.Printf("Warning: fileHandler: empty category/value submitted for file id=%d", f.ID) + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + + if val == "!" { + previousVal, err := getPreviousTagValue(cat, f.ID) + if err != nil { + log.Printf("Warning: fileHandler: no previous tag found for category %q for file id=%d: %v", cat, f.ID, err) + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + val = previousVal + } + _, tagID, err := getOrCreateCategoryAndTag(cat, val) + if err != nil { + log.Printf("Error: fileHandler: failed to create tag %s:%s for file id=%d: %v", cat, val, f.ID, err) + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + if _, err = DB.Exec("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?, ?)", f.ID, tagID); err != nil { + log.Printf("Error: fileHandler: failed to add tag %s:%s to file id=%d: %v", cat, val, f.ID, err) + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + http.Redirect(w, r, "/file/"+idStr, http.StatusSeeOther) + return + } + + catRows, err := DB.Query(` + SELECT DISTINCT c.name + FROM categories c + JOIN tags t ON t.category_id = c.id + JOIN file_tags ft ON ft.tag_id = t.id + ORDER BY c.name + `) + if err != nil { + log.Printf("Warning: fileHandler: failed to query categories for file id=%d: %v", f.ID, err) + } + var cats []string + if catRows != nil { + for catRows.Next() { + var c string + if err := catRows.Scan(&c); err != nil { + log.Printf("Warning: fileHandler: failed to scan category row: %v", err) + continue + } + cats = append(cats, c) + } + catRows.Close() + } + + propRows, err := DB.Query(` + SELECT key, value FROM file_properties + WHERE file_id = ? + ORDER BY key, value + `, f.ID) + if err != nil { + log.Printf("Warning: fileHandler: failed to query properties for file id=%d: %v", f.ID, err) + } + fileProps := make(map[string]string) + if propRows != nil { + for propRows.Next() { + var k, v string + if err := propRows.Scan(&k, &v); err != nil { + log.Printf("Warning: fileHandler: failed to scan property row for file id=%d: %v", f.ID, err) + continue + } + fileProps[k] = v + } + propRows.Close() + } + + pageData := buildPageDataWithIP(f.Filename, struct { + File File + Categories []string + EscapedFilename string + Properties map[string]string + }{f, cats, url.PathEscape(f.Filename), fileProps}) + + renderTemplate(w, "file.html", pageData) +} + +func buildPageDataWithIP(title string, data interface{}) PageData { + pageData := buildPageData(title, data) + ip, err := getLocalIP() + if err != nil { + log.Printf("Warning: buildPageDataWithIP: could not determine local IP: %v", err) + } + pageData.IP = ip + pageData.Port = strings.TrimPrefix(Cfg.ServerPort, ":") + return pageData +} + +func getLocalIP() (string, error) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "", err + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String(), nil + } + } + } + return "", fmt.Errorf("no connected network interface found") +} + +func tagActionHandler(w http.ResponseWriter, r *http.Request, parts []string) { + fileID := parts[2] + + if r.Method != http.MethodPost { + http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) + return + } + + cat := strings.TrimSpace(html.UnescapeString(r.FormValue("category"))) + val := strings.TrimSpace(html.UnescapeString(r.FormValue("value"))) + + if cat != "" && val != "" { + var tagID int + if err := DB.QueryRow(` + SELECT t.id + FROM tags t + JOIN categories c ON c.id=t.category_id + WHERE c.name=? AND t.value=?`, cat, val).Scan(&tagID); err != nil && err != sql.ErrNoRows { + log.Printf("Warning: tagActionHandler: failed to look up tag %s:%s for file id=%s: %v", cat, val, fileID, err) + } + if tagID != 0 { + if _, err := DB.Exec("DELETE FROM file_tags WHERE file_id=? AND tag_id=?", fileID, tagID); err != nil { + log.Printf("Error: tagActionHandler: failed to delete file_tag for file id=%s tag id=%d: %v", fileID, tagID, err) + } + } + } + http.Redirect(w, r, "/file/"+fileID, http.StatusSeeOther) +} + +func getOrCreateCategoryAndTag(category, value string) (int, int, error) { + category = strings.TrimSpace(category) + value = strings.TrimSpace(value) + var catID int + err := DB.QueryRow("SELECT id FROM categories WHERE name=?", category).Scan(&catID) + if err == sql.ErrNoRows { + res, err := DB.Exec("INSERT INTO categories(name) VALUES(?)", category) + if err != nil { + return 0, 0, err + } + cid, _ := res.LastInsertId() + catID = int(cid) + } else if err != nil { + return 0, 0, err + } + + var tagID int + if value != "" { + err = DB.QueryRow("SELECT id FROM tags WHERE category_id=? AND value=?", catID, value).Scan(&tagID) + if err == sql.ErrNoRows { + res, err := DB.Exec("INSERT INTO tags(category_id, value) VALUES(?, ?)", catID, value) + if err != nil { + return 0, 0, err + } + tid, _ := res.LastInsertId() + tagID = int(tid) + } else if err != nil { + return 0, 0, err + } + } + + return catID, tagID, nil +} + +func listFilesHandler(w http.ResponseWriter, r *http.Request) { + page := pageFromRequest(r) + perPage := perPageFromConfig(50) + + tagged, taggedTotal, err := getTaggedFilesPaginated(page, perPage) + if err != nil { + log.Printf("Warning: listFilesHandler: failed to get tagged files: %v", err) + } + untagged, untaggedTotal, err := getUntaggedFilesPaginated(page, perPage) + if err != nil { + log.Printf("Warning: listFilesHandler: failed to get untagged files: %v", err) + } + + // Use the larger total for pagination + total := taggedTotal + if untaggedTotal > total { + total = untaggedTotal + } + + pageData := buildPageDataWithPagination("File Browser", ListData{ + Tagged: tagged, + Untagged: untagged, + Breadcrumbs: []Breadcrumb{}, + }, page, total, perPage, r) + + renderTemplate(w, "list.html", pageData) +} diff --git a/main.go b/main.go @@ -1,20 +1,14 @@ package main import ( - "database/sql" "flag" "fmt" - "html/template" "log" "net/http" "os" "path/filepath" -) -var ( - db *sql.DB - tmpl *template.Template - config Config + "tagliatelle/internal/app" ) func main() { @@ -36,38 +30,38 @@ func main() { // Initialize database var err error - db, err = InitDatabase(dbPath) + app.DB, err = app.InitDatabase(dbPath) if err != nil { log.Fatalf("Failed to initialize database: %v", err) } - defer db.Close() + defer app.DB.Close() // Load config from database (gallery size, items per page, aliases, sed rules) - config, err = LoadConfig(db) + app.Cfg, err = app.LoadConfig(app.DB) if err != nil { log.Fatalf("Failed to load config from database: %v", err) } // Inject runtime values (not stored in DB) - config.DatabasePath = dbPath - config.UploadDir = uploadDir - config.ServerPort = serverPort + app.Cfg.DatabasePath = dbPath + app.Cfg.UploadDir = uploadDir + app.Cfg.ServerPort = serverPort // Initialize templates - tmpl, err = InitTemplates() + app.Tmpl, err = app.InitTemplates() if err != nil { log.Fatalf("Failed to load templates: %v", err) } // Register all routes - RegisterRoutes() + app.RegisterRoutes() // Start server - log.Printf("Server started at http://localhost%s", config.ServerPort) - log.Printf("Database: %s", config.DatabasePath) - log.Printf("Upload directory: %s", config.UploadDir) - - if err := http.ListenAndServe(config.ServerPort, nil); err != nil { + log.Printf("Server started at http://localhost%s", app.Cfg.ServerPort) + log.Printf("Database: %s", app.Cfg.DatabasePath) + log.Printf("Upload directory: %s", app.Cfg.UploadDir) + + if err := http.ListenAndServe(app.Cfg.ServerPort, nil); err != nil { log.Fatalf("Server failed to start: %v", err) } }