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:
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", ¤tIndex)
- }
-
- 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(¤tFile.ID, ¤tFile.Filename, ¤tFile.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(¤tFilename, ¤tRelPath)
- 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", ¤tIndex)
+ }
+
+ 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(¤tFile.ID, ¤tFile.Filename, ¤tFile.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(¤tFilename, ¤tRelPath)
+ 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)
}
}