taggart

Simple golang tagging filesystem webapp
Log | Files | Refs

commit 825223523ded0b0724265c48363bbe4beefbaaa4
parent 9c9e978702daa6d0a7afda89099854004057d9cd
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Thu, 25 Sep 2025 22:32:32 +0100

Cascading tags menu in global header bar

The CSS is awful but works, I'll fix it later I swear
I guess the code can be more efficient too..?

I moved the functions to the bottom so the main loop was closer to the top

Diffstat:
Mmain.go | 324++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mstatic/style.css | 48++++++++++++++++++++++++------------------------
Mtemplates/_header.html | 22+++++++++++++++++++---
3 files changed, 217 insertions(+), 177 deletions(-)

diff --git a/main.go b/main.go @@ -53,129 +53,7 @@ type PageData struct { IP string Port string Files []File -} - -// sanitizeFilename removes problematic characters from filenames -func sanitizeFilename(filename string) string { - if filename == "" { - return "file" - } - - filename = strings.ReplaceAll(filename, "/", "_") - filename = strings.ReplaceAll(filename, "\\", "_") - filename = strings.ReplaceAll(filename, "..", "_") - - if filename == "" { - return "file" - } - return filename -} - -// detectVideoCodec uses ffprobe to detect the video codec -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 -} - -// reencodeHEVCToH264 converts HEVC videos to H.264 for browser compatibility -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() -} - -// processVideoFile handles codec detection and re-encoding if needed -// Returns final path, warning message (if any), and error -func processVideoFile(tempPath, finalPath string) (string, string, error) { - codec, err := detectVideoCodec(tempPath) - if err != nil { - return "", "", err - } - - // If HEVC, re-encode to H.264 - 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) // Clean up temp file - return finalPath, warningMsg, nil - } - - // If not HEVC, just move temp file to final destination - if err := os.Rename(tempPath, finalPath); err != nil { - return "", "", fmt.Errorf("failed to move file: %v", err) - } - - return finalPath, "", nil -} - -// saveFileToDatabase adds file record to database and returns the ID -func saveFileToDatabase(filename, path string) (int64, error) { - res, err := db.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, path) - 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) - } - - return id, nil -} - -// processUploadedFile handles the complete file processing workflow -func processUploadedFile(src io.Reader, originalFilename string) (int64, string, string, error) { - // Strict duplicate check — error out if file already exists - finalFilename, finalPath, err := checkFileConflictStrict(originalFilename) - if err != nil { - return 0, "", "", err - } - // Create temporary file - tempPath := finalPath + ".tmp" - tempFile, err := os.Create(tempPath) - if err != nil { - return 0, "", "", fmt.Errorf("failed to create temp file: %v", err) - } - - // Copy data to temp file - _, err = io.Copy(tempFile, src) - tempFile.Close() - if err != nil { - os.Remove(tempPath) - return 0, "", "", fmt.Errorf("failed to copy file data: %v", err) - } - - // Process video (codec detection and potential re-encoding) - processedPath, warningMsg, err := processVideoFile(tempPath, finalPath) - if err != nil { - os.Remove(tempPath) - return 0, "", "", err - } - - // Save to database - id, err := saveFileToDatabase(finalFilename, processedPath) - if err != nil { - os.Remove(processedPath) - return 0, "", "", err - } - - return id, finalFilename, warningMsg, nil + Tags map[string][]TagDisplay } func main() { @@ -363,6 +241,7 @@ func uploadFromURLHandler(w http.ResponseWriter, r *http.Request) { // List all files, plus untagged files func listFilesHandler(w http.ResponseWriter, r *http.Request) { + tagMap, _ := getTagData() // Tagged files rows, _ := db.Query(` SELECT DISTINCT f.id, f.filename, f.path, COALESCE(f.description, '') as description @@ -397,11 +276,12 @@ func listFilesHandler(w http.ResponseWriter, r *http.Request) { } pageData := PageData{ - Title: "Home", - Data: struct { - Tagged []File - Untagged []File - }{tagged, untagged}, + Title: "Home", + Data: struct { + Tagged []File + Untagged []File + }{tagged, untagged}, + Tags: tagMap, } tmpl.ExecuteTemplate(w, "list.html", pageData) @@ -429,9 +309,11 @@ func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) { files = append(files, f) } + tagMap, _ := getTagData() pageData := PageData{ Title: "Untagged Files", Data: files, + Tags: tagMap, } tmpl.ExecuteTemplate(w, "untagged.html", pageData) @@ -440,9 +322,11 @@ func untaggedFilesHandler(w http.ResponseWriter, r *http.Request) { // Add a file func uploadHandler(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { + tagMap, _ := getTagData() pageData := PageData{ Title: "Add File", Data: nil, + Tags: tagMap, // <- header menu } tmpl.ExecuteTemplate(w, "add.html", pageData) return @@ -725,8 +609,10 @@ func fileHandler(w http.ResponseWriter, r *http.Request) { // Escape filename for copy/paste escaped := url.PathEscape(f.Filename) + tagMap, _ := getTagData() pageData := PageData{ Title: f.Filename, + Tags: tagMap, Data: struct { File File Categories []string @@ -762,30 +648,15 @@ func tagActionHandler(w http.ResponseWriter, r *http.Request, parts []string) { // Show all tags func tagsHandler(w http.ResponseWriter, r *http.Request) { - rows, _ := 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`) - defer rows.Close() + tagMap, _ := getTagData() - tagMap := make(map[string][]TagDisplay) - for rows.Next() { - var cat, val string - var count int - rows.Scan(&cat, &val, &count) - tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count}) - } - - pageData := PageData{ - Title: "All Tags", - Data: tagMap, - } + pageData := PageData{ + Title: "All Tags", + Data: tagMap, + Tags: tagMap, + } - tmpl.ExecuteTemplate(w, "tags.html", pageData) + tmpl.ExecuteTemplate(w, "tags.html", pageData) } // Filter files by tags @@ -851,8 +722,10 @@ func tagFilterHandler(w http.ResponseWriter, r *http.Request) { } title := "Tagged: " + strings.Join(titleParts, ", ") + tagMap, _ := getTagData() pageData := PageData{ Title: title, + Tags: tagMap, Data: struct { Tagged []File Untagged []File @@ -975,8 +848,10 @@ func settingsHandler(w http.ResponseWriter, r *http.Request) { } // Show settings form + tagMap, _ := getTagData() pageData := PageData{ Title: "Settings", + Tags: tagMap, Data: struct { Config Config Error string @@ -1298,8 +1173,10 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) { } recentRows.Close() + tagMap, _ := getTagData() pageData := PageData{ Title: "Bulk Tag Editor", + Tags: tagMap, Data: struct { Categories []string RecentFiles []File @@ -1504,4 +1381,151 @@ func bulkTagHandler(w http.ResponseWriter, r *http.Request) { } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +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 + rows.Scan(&cat, &val, &count) + tagMap[cat] = append(tagMap[cat], TagDisplay{Value: val, Count: count}) + } + return tagMap, nil +} + +// sanitizeFilename removes problematic characters from filenames +func sanitizeFilename(filename string) string { + if filename == "" { + return "file" + } + + filename = strings.ReplaceAll(filename, "/", "_") + filename = strings.ReplaceAll(filename, "\\", "_") + filename = strings.ReplaceAll(filename, "..", "_") + + if filename == "" { + return "file" + } + return filename +} + +// detectVideoCodec uses ffprobe to detect the video codec +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 +} + +// reencodeHEVCToH264 converts HEVC videos to H.264 for browser compatibility +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() +} + +// processVideoFile handles codec detection and re-encoding if needed +// Returns final path, warning message (if any), and error +func processVideoFile(tempPath, finalPath string) (string, string, error) { + codec, err := detectVideoCodec(tempPath) + if err != nil { + return "", "", err + } + + // If HEVC, re-encode to H.264 + 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) // Clean up temp file + return finalPath, warningMsg, nil + } + + // If not HEVC, just move temp file to final destination + if err := os.Rename(tempPath, finalPath); err != nil { + return "", "", fmt.Errorf("failed to move file: %v", err) + } + + return finalPath, "", nil +} + +// saveFileToDatabase adds file record to database and returns the ID +func saveFileToDatabase(filename, path string) (int64, error) { + res, err := db.Exec("INSERT INTO files (filename, path, description) VALUES (?, ?, '')", filename, path) + 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) + } + + return id, nil +} + +// processUploadedFile handles the complete file processing workflow +func processUploadedFile(src io.Reader, originalFilename string) (int64, string, string, error) { + // Strict duplicate check — error out if file already exists + finalFilename, finalPath, err := checkFileConflictStrict(originalFilename) + if err != nil { + return 0, "", "", err + } + // Create temporary file + tempPath := finalPath + ".tmp" + tempFile, err := os.Create(tempPath) + if err != nil { + return 0, "", "", fmt.Errorf("failed to create temp file: %v", err) + } + + // Copy data to temp file + _, err = io.Copy(tempFile, src) + tempFile.Close() + if err != nil { + os.Remove(tempPath) + return 0, "", "", fmt.Errorf("failed to copy file data: %v", err) + } + + // Process video (codec detection and potential re-encoding) + processedPath, warningMsg, err := processVideoFile(tempPath, finalPath) + if err != nil { + os.Remove(tempPath) + return 0, "", "", err + } + + // Save to database + id, err := saveFileToDatabase(finalFilename, processedPath) + if err != nil { + os.Remove(processedPath) + return 0, "", "", err + } + + return id, finalFilename, warningMsg, nil } \ No newline at end of file diff --git a/static/style.css b/static/style.css @@ -1,31 +1,31 @@ /* main body styling */ -body, div#search-container form input {background: #1a1a1a; color: #cfcfcf; font-family: sans-serif; margin: 0} -a {color: lightblue; text-decoration: none} -a:hover {text-decoration: underline} +body,div#search-container form input{background:#1a1a1a;color:#cfcfcf;font-family:sans-serif;margin:0} +a{color:#add8e6;text-decoration:none} +a:hover{text-decoration:underline} /* nav menu */ -nav {display: flex; border-bottom: 1px solid gray; height: 50px} -nav ul {flex: 1 1 auto; list-style: none; margin: 0; padding: 0} -nav ul li {display: inline-block; margin: 0; padding: 0; border-right: 1px solid gray} -nav ul li a, nav ul li strong {padding: 15px; display: block} +nav{display:flex;border-bottom:1px solid gray;height:50px} +nav ul li a,nav ul li strong{padding:15px;display:block} +nav ul li a:hover{background-color:#2a2a2a} +nav ul li ul li a,nav>ul>li>a{display:block;padding:.5em 1em;text-decoration:none} +nav ul li ul li a{color:#add8e6;background:#1a1a1a} +nav ul li ul li ul li a{white-space:nowrap} +nav ul li ul li ul{top:0;left:100%;display:none} +nav ul li ul li,nav>ul>li{position:relative} +nav ul li ul li:hover>ul,nav ul li:hover>ul{display:block} +nav ul li ul{display:none;position:absolute;top:100%;left:0;z-index:1000;padding:0} +nav ul li{border-right:1px solid gray} +nav ul,nav ul li{margin:0;padding:0} +nav ul{flex:1 1 auto;list-style:none} +nav ul.sub-menu li a:first-letter{text-transform:capitalize} /* search bar */ div#search-container form input {border: 1px solid gray; padding: 8px; margin-top: 7px} -span#searchToggle {cursor: pointer; color: lightblue; padding: 8px} -div#searchToggleContainer {display: none; background-color: #2a2a2a} +div#search-container form input{border:1px solid gray;padding:8px;margin-top:7px} +div#searchToggleContainer{display:none;background-color:#2a2a2a} +span#searchToggle{cursor:pointer;color:#add8e6;padding:8px} /* gallery styling */ -div.gallery {} -div.gallery-item {display: inline-block; width: 250px} -div.gallery-item a {display: inline-block; overflow: hidden; text-overflow:ellipsis; width: 250px} - -/* cascading menu */ -ul.tag-menu,ul.tag-menu ul{list-style:none;margin:0;padding:0} -ul.tag-menu li{position:relative} -ul.tag-menu>li{display:inline-block;margin-right:20px} -ul.tag-menu li a{text-decoration:none;padding:5px 10px;display:block;background:#eee;color:#333} -ul.tag-menu li ul{display:none;position:absolute;top:100%;left:0;min-width:150px;z-index:1000} -ul.tag-menu li ul li,ul.tag-menu li:hover>ul{display:block} -ul.tag-menu li ul li ul{left:100%;top:0} -ul.tag-menu li ul li a{background:#f9f9f9} -ul.tag-menu li ul li a:hover{background:#ddd} -\ No newline at end of file +div.gallery-item a{overflow:hidden;text-overflow:ellipsis} +div.gallery-item,div.gallery-item a,nav ul li,nav>ul>li{display:inline-block} +div.gallery-item,div.gallery-item a{width:250px;display:inline-block} +\ No newline at end of file diff --git a/templates/_header.html b/templates/_header.html @@ -12,11 +12,27 @@ <li><strong>&num;Taggart</strong></li> <li><a href="/add">Add files</a></li> <li><a href="/">Files</a></li> -<li><a href="/tags">Tags</a></li> -<li><a href="/bulk-tag">Bulk Tag Editor</a></li> -<li><a href="/untagged">Untagged</a></li> +<li> + <a href="/tags">Tags</a> + <ul class="sub-menu"> + {{range $cat, $tags := .Tags}} + <li> + <a href="#">{{$cat}}</a> + <ul> + {{range $tags}} + <li><a href="/tag/{{$cat}}/{{.Value}}">{{.Value}} ({{.Count}})</a></li> + {{end}} + <li><a href="/tag/{{$cat}}/unassigned">Unassigned</a></li> + </ul> + </li> + {{end}} + <li><a href="/bulk-tag">Bulk Editor</a></li> + <li><a href="/untagged">Untagged</a></li> + </ul> +</li> <li><a href="/settings">Settings</a></li> </ul> + <div id="search-container"> <form method="get" action="/search"><input type="text" name="q" value="{{.Query}}" placeholder="Search..."><span id="searchToggle">[?]</span> </form>