tagliatelle

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

commit 796a9e158533372ed9fed4af99640ace4d8c1876
parent c06445458d80ed78a599d26779344cf4e5f5cf72
Author: breadcat <breadcat@users.noreply.github.com>
Date:   Tue, 17 Feb 2026 14:45:29 +0000

Add notes function

Ugly code, but it works

Diffstat:
Minclude-admin.go | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Minclude-db.go | 5+++++
Ainclude-notes.go | 454+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minclude-routes.go | 8++++++++
Minclude-types.go | 32++++++++++++++++++++++++++------
Mstatic/admin-tabs.js | 2+-
Astatic/notes.js | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/sed-rules.js | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mstatic/style.css | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/_header.html | 1+
Mtemplates/admin.html | 43+++++++++++++++++++++++++++++++++++++++++++
Atemplates/notes.html | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 1122 insertions(+), 14 deletions(-)

diff --git a/include-admin.go b/include-admin.go @@ -22,6 +22,7 @@ func loadConfig() error { GallerySize: "400px", ItemsPerPage: "100", TagAliases: []TagAliasGroup{}, + SedRules: []SedRule{}, } if data, err := ioutil.ReadFile("config.json"); err == nil { @@ -120,6 +121,11 @@ func adminHandler(w http.ResponseWriter, r *http.Request) { case "save_aliases": handleSaveAliases(w, r, orphanData, missingThumbnails) return + + case "save_sed_rules": + handleSaveSedRules(w, r, orphanData, missingThumbnails) + return + } default: @@ -350,14 +356,69 @@ func getFilesInDB() (map[string]bool, error) { } return fileMap, nil } + +func handleSaveSedRules(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) { + sedRulesJSON := r.FormValue("sed_rules_json") + + var sedRules []SedRule + if sedRulesJSON != "" { + if err := json.Unmarshal([]byte(sedRulesJSON), &sedRules); err != nil { + pageData := buildPageData("Admin", struct { + Config Config + Error string + Success string + OrphanData OrphanData + ActiveTab string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "Invalid sed rules JSON: " + err.Error(), + Success: "", + OrphanData: orphanData, + ActiveTab: r.FormValue("active_tab"), + MissingThumbnails: missingThumbnails, + }) + renderTemplate(w, "admin.html", pageData) + return + } } - defer rows.Close() - fileMap := make(map[string]bool) - for rows.Next() { - var name string - rows.Scan(&name) - fileMap[name] = true + config.SedRules = sedRules + + if err := saveConfig(); err != nil { + pageData := buildPageData("Admin", struct { + Config Config + Error string + Success string + OrphanData OrphanData + ActiveTab string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "Failed to save configuration: " + err.Error(), + Success: "", + OrphanData: orphanData, + ActiveTab: r.FormValue("active_tab"), + MissingThumbnails: missingThumbnails, + }) + renderTemplate(w, "admin.html", pageData) + return } - return fileMap, nil + + pageData := buildPageData("Admin", struct { + Config Config + Error string + Success string + OrphanData OrphanData + ActiveTab string + MissingThumbnails []VideoFile + }{ + Config: config, + Error: "", + Success: "Sed rules saved successfully!", + OrphanData: orphanData, + ActiveTab: r.FormValue("active_tab"), + MissingThumbnails: missingThumbnails, + }) + renderTemplate(w, "admin.html", pageData) } diff --git a/include-db.go b/include-db.go @@ -45,6 +45,11 @@ func createTables(db *sql.DB) error { tag_id INTEGER, UNIQUE(file_id, tag_id) ); + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY CHECK (id = 1), + content TEXT DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); ` _, err := db.Exec(schema) diff --git a/include-notes.go b/include-notes.go @@ -0,0 +1,454 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "sort" + "strconv" + "strings" +) + +// 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") +} + +// GetCategories returns a sorted list of unique categories +func GetCategories(content string) []string { + lines := strings.Split(content, "\n") + categoryMap := make(map[string]bool) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + note := ParseNote(trimmed) + if note.Category != "" { + categoryMap[note.Category] = true + } + } + + categories := make([]string, 0, len(categoryMap)) + for cat := range categoryMap { + categories = append(categories, cat) + } + + sort.Strings(categories) + return categories +} + +// GetNoteStats returns statistics about the notes +func GetNoteStats(content string) map[string]int { + lines := strings.Split(content, "\n") + + totalLines := 0 + categorizedLines := 0 + categories := make(map[string]bool) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + totalLines++ + note := ParseNote(trimmed) + + if note.Category != "" { + categorizedLines++ + categories[note.Category] = true + } + } + + return map[string]int{ + "total_lines": totalLines, + "categorized_lines": categorizedLines, + "uncategorized": totalLines - categorizedLines, + "unique_categories": len(categories), + } +} + +// CountLines returns the number of non-empty lines +func CountLines(content string) int { + lines := strings.Split(content, "\n") + count := 0 + for _, line := range lines { + if strings.TrimSpace(line) != "" { + count++ + } + } + return count +} + +// notesViewHandler displays the notes editor page +func notesViewHandler(w http.ResponseWriter, r *http.Request) { + content, err := GetNotes(db) + if err != nil { + http.Error(w, "Failed to load notes", http.StatusInternalServerError) + log.Printf("Error loading notes: %v", err) + return + } + + stats := GetNoteStats(content) + categories := GetCategories(content) + + notesData := struct { + Content string + Stats map[string]int + Categories []string + SedRules []SedRule + }{ + Content: content, + Stats: stats, + Categories: categories, + SedRules: config.SedRules, + } + + pageData := buildPageData("Notes", notesData) + + if err := tmpl.ExecuteTemplate(w, "notes.html", pageData); err != nil { + http.Error(w, "Template error", http.StatusInternalServerError) + log.Printf("Template error: %v", err) + } +} + +// 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 { + http.Error(w, "Failed to save notes", http.StatusInternalServerError) + log.Printf("Error saving notes: %v", err) + 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 { + 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 { + http.Error(w, "Failed to load notes", http.StatusInternalServerError) + return + } + + stats := GetNoteStats(content) + + 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("notesApplySedHandler called - Method: %s, Path: %s", r.Method, r.URL.Path) + + if r.Method != http.MethodPost { + log.Printf("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("Received: 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("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("Applying rule: %s (command: %s)", rule.Name, rule.Command) + result, err := ApplySedRule(content, rule.Command) + if err != nil { + log.Printf("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": GetNoteStats(result), + } + log.Printf("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) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "content": processed, + "stats": GetNoteStats(processed), + "lineCount": CountLines(processed), + }) +} + +// notesExportHandler exports notes as plain text file +func notesExportHandler(w http.ResponseWriter, r *http.Request) { + content, err := GetNotes(db) + if err != nil { + 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 { + 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 { + 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, _ := GetNotes(db) + content = existingContent + "\n" + content + } + + // Save (will auto-process) + if err := SaveNotes(db, content); err != nil { + http.Error(w, "Failed to save notes", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/notes", http.StatusSeeOther) +} diff --git a/include-routes.go b/include-routes.go @@ -14,6 +14,14 @@ func RegisterRoutes() { 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("/search", searchHandler) http.HandleFunc("/tag/", tagFilterHandler) http.HandleFunc("/tags", tagsHandler) diff --git a/include-types.go b/include-types.go @@ -10,13 +10,14 @@ type File struct { } type Config struct { - DatabasePath string `json:"database_path"` - UploadDir string `json:"upload_dir"` - ServerPort string `json:"server_port"` - InstanceName string `json:"instance_name"` - GallerySize string `json:"gallery_size"` - ItemsPerPage string `json:"items_per_page"` + DatabasePath string `json:"database_path"` + UploadDir string `json:"upload_dir"` + ServerPort string `json:"server_port"` + InstanceName string `json:"instance_name"` + GallerySize string `json:"gallery_size"` + ItemsPerPage string `json:"items_per_page"` TagAliases []TagAliasGroup `json:"tag_aliases"` + SedRules []SedRule `json:"sed_rules"` } type Breadcrumb struct { @@ -103,3 +104,22 @@ type OrphanData struct { Orphans []string // on disk, not in DB ReverseOrphans []string // in DB, not on disk } + +type Note struct { + Category string + Value string + Original string // The full line as stored +} + +type Operation struct { + Name string + Description string + Type string // "sed", "regex", "builtin" + Command string +} + +type SedRule struct { + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` +} diff --git a/static/admin-tabs.js b/static/admin-tabs.js @@ -1,7 +1,7 @@ // Admin tab management function showAdminTab(tabName) { // Hide all content sections - const contents = ['settings', 'database', 'aliases', 'orphans', 'thumbnails']; + const contents = ['settings', 'database', 'aliases', 'sedrules', 'orphans', 'thumbnails']; contents.forEach(name => { const content = document.getElementById(`admin-content-${name}`); if (content) { diff --git a/static/notes.js b/static/notes.js @@ -0,0 +1,256 @@ +// notes.js - Notes editor JavaScript + +const editor = document.getElementById('editor'); +const preview = document.getElementById('preview'); +const searchInput = document.getElementById('search-input'); + +let originalContent = editor.value; +let currentContent = editor.value; + +// Initialize preview with clickable links +document.addEventListener('DOMContentLoaded', () => { + updatePreview(currentContent); +}); + +// Auto-update preview on typing (debounced) +let typingTimer; +editor.addEventListener('input', () => { + clearTimeout(typingTimer); + typingTimer = setTimeout(() => { + currentContent = editor.value; + updatePreview(currentContent); + }, 500); +}); + +// Search functionality +searchInput.addEventListener('input', () => { + const searchTerm = searchInput.value.toLowerCase(); + if (searchTerm === '') { + updatePreview(currentContent); + return; + } + + const lines = currentContent.split('\n'); + const filtered = lines.filter(line => + line.toLowerCase().includes(searchTerm) + ); + updatePreviewWithLinks(filtered.join('\n')); +}); + +// Convert URLs to clickable links and update preview +function updatePreview(content) { + updatePreviewWithLinks(content); +} + +function updatePreviewWithLinks(content) { + // Clear preview + preview.innerHTML = ''; + + if (!content) return; + + const lines = content.split('\n'); + const urlRegex = /(https?:\/\/[^\s]+)/g; + + lines.forEach((line, index) => { + const lineDiv = document.createElement('div'); + lineDiv.style.marginBottom = '0'; + + // Check if line contains URLs + if (urlRegex.test(line)) { + // Reset regex lastIndex + urlRegex.lastIndex = 0; + + let lastIndex = 0; + let match; + + while ((match = urlRegex.exec(line)) !== null) { + // Add text before URL + if (match.index > lastIndex) { + const textNode = document.createTextNode(line.substring(lastIndex, match.index)); + lineDiv.appendChild(textNode); + } + + // Add clickable link + const link = document.createElement('a'); + link.href = match[0]; + link.textContent = match[0]; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + // link.style.color = '#2563eb'; + link.style.textDecoration = 'underline'; + lineDiv.appendChild(link); + + lastIndex = match.index + match[0].length; + } + + // Add remaining text after last URL + if (lastIndex < line.length) { + const textNode = document.createTextNode(line.substring(lastIndex)); + lineDiv.appendChild(textNode); + } + } else { + // No URLs, just add plain text + lineDiv.textContent = line; + } + + preview.appendChild(lineDiv); + }); +} + +function updateStats(stats) { + document.getElementById('total-lines').textContent = stats.total_lines || 0; + document.getElementById('categorized-lines').textContent = stats.categorized_lines || 0; + document.getElementById('uncategorized-lines').textContent = stats.uncategorized || 0; + document.getElementById('unique-categories').textContent = stats.unique_categories || 0; +} + +function showMessage(text, type = 'success') { + const msg = document.getElementById('message'); + msg.textContent = text; + msg.className = `message ${type} show`; + setTimeout(() => { + msg.classList.remove('show'); + }, 3000); +} + +async function saveNotes() { + const content = editor.value; + + try { + const response = await fetch('/notes/save', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: `content=${encodeURIComponent(content)}` + }); + + const result = await response.json(); + + if (result.success) { + showMessage('Notes saved successfully!', 'success'); + originalContent = content; + // Reload to show sorted/deduped version + setTimeout(() => location.reload(), 1000); + } else { + showMessage('Failed to save notes', 'error'); + } + } catch (error) { + showMessage('Error: ' + error.message, 'error'); + } +} + +async function previewProcessing() { + const content = editor.value; + + try { + const response = await fetch('/notes/preview', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: `content=${encodeURIComponent(content)}` + }); + + const result = await response.json(); + + if (result.success) { + editor.value = result.content; + currentContent = result.content; + updatePreview(result.content); + updateStats(result.stats); + showMessage(`Processed: ${result.lineCount} lines after sort & dedupe`, 'success'); + } + } catch (error) { + showMessage('Error: ' + error.message, 'error'); + } +} + +async function applySedRule(ruleIndex) { + const content = editor.value; + + try { + const response = await fetch('/notes/apply-sed', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: `content=${encodeURIComponent(content)}&rule_index=${ruleIndex}` + }); + + // Get the response text first to see what we're actually receiving + const responseText = await response.text(); + console.log('Raw response:', responseText); + console.log('Response status:', response.status); + console.log('Content-Type:', response.headers.get('content-type')); + + // Try to parse as JSON + let result; + try { + result = JSON.parse(responseText); + } catch (parseError) { + console.error('JSON parse error:', parseError); + console.error('Response was:', responseText.substring(0, 200)); + showMessage('Server returned invalid response. Check console for details.', 'error'); + return; + } + + if (result.success) { + editor.value = result.content; + currentContent = result.content; + updatePreview(result.content); + updateStats(result.stats); + showMessage('Sed rule applied successfully!', 'success'); + } else { + showMessage(result.error || 'Sed rule failed', 'error'); + } + } catch (error) { + console.error('Fetch error:', error); + showMessage('Error: ' + error.message, 'error'); + } +} + +function filterByCategory() { + const category = document.getElementById('category-filter').value; + if (!category) { + updatePreview(currentContent); + return; + } + + const lines = currentContent.split('\n'); + + // Handle uncategorized filter + if (category === '__uncategorized__') { + const filtered = lines.filter(line => { + const trimmed = line.trim(); + if (!trimmed) return false; + // Line is uncategorized if it doesn't contain '>' or '>' is not a separator + return !trimmed.includes('>') || trimmed.indexOf('>') === trimmed.length - 1; + }); + updatePreviewWithLinks(filtered.join('\n')); + return; + } + + // Handle normal category filter + const filtered = lines.filter(line => { + if (line.includes('>')) { + const cat = line.split('>')[0].trim(); + return cat === category; + } + return false; + }); + + updatePreviewWithLinks(filtered.join('\n')); +} + +function clearFilters() { + searchInput.value = ''; + document.getElementById('category-filter').value = ''; + updatePreview(currentContent); +} + +function exportNotes() { + window.location.href = '/notes/export'; +} + +// Warn on unsaved changes +window.addEventListener('beforeunload', (e) => { + if (editor.value !== originalContent) { + e.preventDefault(); + e.returnValue = ''; + } +}); diff --git a/static/sed-rules.js b/static/sed-rules.js @@ -0,0 +1,108 @@ +// sed-rules.js - Manage sed rules in admin interface + +let sedRules = []; + +// Initialize on page load +document.addEventListener('DOMContentLoaded', function() { + sedRules = window.initialSedRules || []; + renderSedRules(); + setupSedRulesForm(); +}); + +function renderSedRules() { + const container = document.getElementById('sed-rules'); + if (!container) return; + + container.innerHTML = ''; + + sedRules.forEach((rule, index) => { + const ruleDiv = document.createElement('div'); + ruleDiv.style.cssText = 'border: 1px solid #ddd; padding: 15px; margin-bottom: 15px; border-radius: 5px; background-color: #f8f9fa;'; + + ruleDiv.innerHTML = ` + <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 10px;"> + <h4 style="margin: 0; color: #333;">Rule ${index + 1}</h4> + <button onclick="removeSedRule(${index})" style="background-color: #dc3545; color: white; padding: 5px 10px; border: none; border-radius: 3px; font-size: 12px; cursor: pointer;"> + Remove + </button> + </div> + + <div style="margin-bottom: 10px;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px; font-size: 13px;">Name:</label> + <input type="text" value="${escapeHtml(rule.name)}" + onchange="updateSedRule(${index}, 'name', this.value)" + placeholder="e.g., Remove URL Parameters" + style="width: 100%; padding: 6px; font-size: 13px; border: 1px solid #ccc; border-radius: 3px;"> + </div> + + <div style="margin-bottom: 10px;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px; font-size: 13px;">Description:</label> + <input type="text" value="${escapeHtml(rule.description)}" + onchange="updateSedRule(${index}, 'description', this.value)" + placeholder="e.g., Removes brandIds and productId from URLs" + style="width: 100%; padding: 6px; font-size: 13px; border: 1px solid #ccc; border-radius: 3px;"> + </div> + + <div style="margin-bottom: 0;"> + <label style="display: block; font-weight: bold; margin-bottom: 5px; font-size: 13px;">Sed Command:</label> + <input type="text" value="${escapeHtml(rule.command)}" + onchange="updateSedRule(${index}, 'command', this.value)" + placeholder="e.g., s?[?&]brandIds=[0-9]\\+&productId=[0-9]\\+??g" + style="width: 100%; padding: 6px; font-size: 13px; font-family: monospace; border: 1px solid #ccc; border-radius: 3px;"> + <small style="color: #666;">Sed command syntax (e.g., s/old/new/g)</small> + </div> + `; + + container.appendChild(ruleDiv); + }); +} + +function addSedRule() { + sedRules.push({ + name: '', + description: '', + command: '' + }); + renderSedRules(); +} + +function removeSedRule(index) { + if (confirm('Remove this sed rule?')) { + sedRules.splice(index, 1); + renderSedRules(); + } +} + +function updateSedRule(index, field, value) { + if (sedRules[index]) { + sedRules[index][field] = value; + } +} + +function setupSedRulesForm() { + const form = document.getElementById('sedrules-form'); + if (!form) return; + + form.addEventListener('submit', function(e) { + // Validate rules + for (let i = 0; i < sedRules.length; i++) { + const rule = sedRules[i]; + if (!rule.name || !rule.command) { + e.preventDefault(); + alert(`Rule ${i + 1} is incomplete. Please fill in Name and Sed Command.`); + return; + } + } + + // Update hidden field with JSON + document.getElementById('sed_rules_json').value = JSON.stringify(sedRules); + + // Let the form submit normally (don't prevent default) + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/static/style.css b/static/style.css @@ -5,6 +5,8 @@ input[type="url"], input[type="text"] {background:#1a1a1a;color:#cfcfcf;border:1 input[type="url"]:focus, input[type="text"]:focus{border:1px solid white;background-color:#3a3a3a} div#search-container form {border-left:1px solid gray} span.required {color: red} +textarea {background: unset; color: #cfcfcf} +textarea:focus {outline: none} /* nav menu */ nav ul{flex:1 1 auto;list-style:none} @@ -124,3 +126,48 @@ img.file-content-image {max-width:400px} .gallery-thumb img{width:100%;height:150px;object-fit:cover;display:block} .thumb-label{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);color:#fff;padding:4px;font-size:12px} .cbz-open-button{margin-top:15px} + +/* notes editor */ +.btn{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-weight:500;transition:all 0.2s} +.btn-primary:hover{background:#f0f0f0} +.btn-primary{background:#fff;color:#2563eb} +.btn-secondary:hover{background:#1e3a8a} +.btn-secondary{background:#1e40af;color:#fff;border:1px solid rgb(255 255 255 / .2)} +.btn-success:hover{background:#059669} +.btn-success{background:#10b981;color:#fff} +.category-header{font-weight:600;color:#2563eb;margin-bottom:8px;padding-bottom:4px;border-bottom:2px solid #2563eb} +.category-item{padding:4px 0;color:#374151} +.category-section{margin-bottom:20px} +.checkbox-label input{width:auto} +.checkbox-label{display:flex;align-items:center;gap:8px;margin-top:10px} +.custom-operation{display:flex;gap:10px;align-items:center;margin-top:10px} +.custom-sed-input{flex:1;padding:10px 12px;border:1px solid #d1d5db;border-radius:4px;font-family:'Monaco','Menlo','Consolas',monospace;font-size:13px} +.editor-container{display:grid;grid-template-columns:1fr 1fr;gap:0;min-height:500px} +.editor-pane{display:flex;flex-direction:column} +.import-form label{display:block;margin-bottom:8px;font-size:13px} +.import-form{margin-top:15px;padding:15px} +.message.error{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5} +.message.show{display:block} +.message.success{background:#d1fae5;color:#065f46;border:1px solid #6ee7b7} +.message{padding:12px 20px;margin:15px 30px;border-radius:4px;font-size:14px;display:none} +.operation-btn:hover{border-color:#2563eb;background:#eff6ff} +.operation-btn{padding:10px 15px;border:1px solid #d1d5db;border-radius:4px;cursor:pointer;text-align:left;transition:all 0.2s} +.operation-desc{font-size:12px} +.operation-name{font-weight:600;font-size:13px;margin-bottom:4px} +.operations-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:10px;margin-bottom:15px} +.operations-panel{padding:20px 30px} +.pane-header{padding:12px 20px;font-weight:600;font-size:14px} +.preview-content{flex:1;padding:20px;overflow-y:auto;font-family:'Monaco','Menlo','Consolas',monospace;font-size:13px;line-height:1.6;white-space:pre-wrap} +.search-box:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgb(37 99 235 / .1)} +.search-box{padding:8px 12px;border:1px solid #d1d5db;border-radius:4px;font-size:14px;width:250px} +.stat{display:flex;align-items:center;gap:8px} +.stat-label{font-size:13px} +.stats-bar{padding:15px 30px;display:flex;gap:30px;align-items:center} +.stat-value{font-weight:600;font-size:16px} +.toolbar label{font-size:13px} +.toolbar{padding:15px 30px;display:flex;gap:15px;align-items:center;flex-wrap:wrap} +.toolbar-actions{display:flex;gap:10px;margin-left:auto} +.toolbar-group{display:flex;gap:10px;align-items:center} +select:focus{outline:none;border-color:#2563eb} +select{padding:8px 12px;border:1px solid #d1d5db;border-radius:4px;font-size:14px;cursor:pointer} +textarea{flex:1;padding:20px;border:none;font-family:'Monaco','Menlo','Consolas',monospace;font-size:13px;line-height:1.6;resize:none} diff --git a/templates/_header.html b/templates/_header.html @@ -30,6 +30,7 @@ <li><a href="/bulk-tag">Bulk Editor</a></li> <li><a href="/untagged">Untagged</a></li> </ul></li> +<li><a href="/notes"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="000000" d="M7.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM7 10.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m.5 2.5a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1zm-1-11a.5.5 0 0 0-.5.5V3h-.5A1.5 1.5 0 0 0 4 4.5v12A1.5 1.5 0 0 0 5.5 18h6a.5.5 0 0 0 .354-.146l4-4A.5.5 0 0 0 16 13.5v-9A1.5 1.5 0 0 0 14.5 3H14v-.5a.5.5 0 0 0-1 0V3h-2.5v-.5a.5.5 0 0 0-1 0V3H7v-.5a.5.5 0 0 0-.5-.5m8 2a.5.5 0 0 1 .5.5V13h-2.5a1.5 1.5 0 0 0-1.5 1.5V17H5.5a.5.5 0 0 1-.5-.5v-12a.5.5 0 0 1 .5-.5zm-.207 10L12 16.293V14.5a.5.5 0 0 1 .5-.5z"/></svg><span>Notes</span></a></li> <li><a href="/admin"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="#000000" d="M9 6.5a4.5 4.5 0 0 1 6.352-4.102a.5.5 0 0 1 .148.809L13.207 5.5L14.5 6.793L16.793 4.5a.5.5 0 0 1 .809.147a4.5 4.5 0 0 1-5.207 6.216L6.03 17.311a2.357 2.357 0 0 1-3.374-3.293L9.082 7.36A4.52 4.52 0 0 1 9 6.5ZM13.5 3a3.5 3.5 0 0 0-3.387 4.386a.5.5 0 0 1-.125.473l-6.612 6.854a1.357 1.357 0 0 0 1.942 1.896l6.574-6.66a.5.5 0 0 1 .512-.124a3.5 3.5 0 0 0 4.521-4.044l-2.072 2.073a.5.5 0 0 1-.707 0l-2-2a.5.5 0 0 1 0-.708l2.073-2.072a3.518 3.518 0 0 0-.72-.074Z"/></svg><span>Admin</span></a></li> </ul> diff --git a/templates/admin.html b/templates/admin.html @@ -24,6 +24,9 @@ <button onclick="showAdminTab('aliases')" id="admin-tab-aliases" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> Aliases </button> + <button onclick="showAdminTab('sedrules')" id="admin-tab-sedrules" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> + Sed Rules + </button> <button onclick="showAdminTab('orphans')" id="admin-tab-orphans" class="admin-tab-btn" style="padding: 10px 20px; border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent;"> Orphans </button> @@ -155,6 +158,43 @@ </div> </div> +<!-- Sed Rules Tab --> +<div id="admin-content-sedrules" style="display: none;"> + <h2>Sed Rules for Notes</h2> + <p style="color: #666; margin-bottom: 20px;"> + Define sed rules that can be applied to notes in the Notes editor. + These rules will appear as buttons in the notes interface for quick text transformations. + </p> + + <div id="sedrules-section" style="max-width: 800px;"> + <div id="sed-rules"></div> + + <button onclick="addSedRule()" style="background-color: #28a745; color: white; padding: 8px 16px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; margin-top: 10px;"> + + Add Sed Rule + </button> + + <form method="post" action="/admin" id="sedrules-form" style="margin-top: 20px;"> + <input type="hidden" name="active_tab" value="sedrules"> + <input type="hidden" name="action" value="save_sed_rules"> + <input type="hidden" name="sed_rules_json" id="sed_rules_json"> + <button type="submit" style="background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;"> + Save Sed Rules + </button> + </form> + </div> + + <div style="margin-top: 30px; padding: 15px; background-color: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 4px;"> + <h4 style="margin-top: 0;">Example Sed Commands:</h4> + <ul style="font-family: monospace; font-size: 13px;"> + <li><code>s?[?&]brandIds=[0-9]\+&productId=[0-9]\+??g</code> - Remove URL parameters</li> + <li><code>s/[[:space:]]*$//</code> - Remove trailing whitespace</li> + <li><code>s/ \+/ /g</code> - Replace multiple spaces with single space</li> + <li><code>s/https\?:\/\/[^ ]*//g</code> - Remove URLs</li> + <li><code>/^$/d</code> - Delete empty lines</li> + </ul> + </div> +</div> + <!-- Orphans Tab --> <div id="admin-content-orphans" style="display: none;"> <h2>Orphaned Files</h2> @@ -294,7 +334,10 @@ </div> <script>window.initialAliasGroups = {{.Data.Config.TagAliases}};</script> +<script>window.initialSedRules = {{.Data.Config.SedRules}};</script> +<script>window.activeAdminTab = "{{.Data.ActiveTab}}";</script> <script src="/static/tag-alias.js" defer></script> +<script src="/static/sed-rules.js" defer></script> <script src="/static/admin-tabs.js" defer></script> {{template "_footer"}} \ No newline at end of file diff --git a/templates/notes.html b/templates/notes.html @@ -0,0 +1,105 @@ +{{template "_header" .}} + + <div class="toolbar"> + <div class="toolbar-group"> + <label>Search:</label> + <input type="text" class="search-box" id="search-input" placeholder="Search notes..."> + </div> + <div class="toolbar-group"> + <label>Filter by Category:</label> + <select id="category-filter" onchange="filterByCategory()"> + <option value="">All Categories</option> + <option value="__uncategorized__">Uncategorized</option> + {{range .Data.Categories}} + <option value="{{.}}">{{.}}</option> + {{end}} + </select> + </div> + <div class="toolbar-group"> + <button class="btn btn-secondary" onclick="clearFilters()">Clear</button> + <button class="btn btn-secondary" onclick="previewProcessing()">Preview</button> + </div> + <div class="toolbar-actions"> + <button class="btn btn-success" onclick="saveNotes()">Save</button> + </div> + </div> + + <div id="message" class="message"></div> + + <div class="editor-container"> + <div class="editor-pane"> + <div class="pane-header">Editor</div> + <textarea id="editor" placeholder="Enter your notes here... +Format: category > value +Example: +websites > https://example.com +tools > useful-tool +ideas > interesting concept">{{.Data.Content}}</textarea> + </div> + <div class="editor-pane"> + <div class="pane-header">Preview</div> + <div class="preview-content" id="preview">{{.Data.Content}}</div> + </div> + </div> + + +<details><summary>Statistics</summary> + <div class="stats-bar"> + <div class="stat"> + <span class="stat-label">Total Lines:</span> + <span class="stat-value" id="total-lines">{{.Data.Stats.total_lines}}</span> + </div> + <div class="stat"> + <span class="stat-label">Categorized:</span> + <span class="stat-value" id="categorized-lines">{{.Data.Stats.categorized_lines}}</span> + </div> + <div class="stat"> + <span class="stat-label">Uncategorized:</span> + <span class="stat-value" id="uncategorized-lines">{{.Data.Stats.uncategorized}}</span> + </div> + <div class="stat"> + <span class="stat-label">Categories:</span> + <span class="stat-value" id="unique-categories">{{.Data.Stats.unique_categories}}</span> + </div> + </div> +</details> + +<details><summary>Sed Operations</summary> + <div class="operations-panel"> + {{if .Data.SedRules}} + <div class="operations-grid"> + {{range $index, $rule := .Data.SedRules}} + <button class="operation-btn" onclick="applySedRule({{$index}})"> + <div class="operation-name">{{$rule.Name}}</div> + <div class="operation-desc">{{$rule.Description}}</div> + </button> + {{end}} + </div> + {{else}} + <div style="padding: 15px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 4px; color: #666;"> + No sed rules configured. Add sed rules in the <a href="/admin" style="color: #007bff;">Admin → Sed Rules</a> section. + </div> + {{end}} +</details> + +<details><summary>Import and Export</summary> + + <div class="import-form"> + <button class="btn btn-primary" onclick="exportNotes()">Export</button> + <h4 style="margin-bottom: 10px">Import Notes</h4> + <form action="/notes/import" method="POST" enctype="multipart/form-data"> + <input type="file" name="file" accept=".txt" required> + <label class="checkbox-label"> + <input type="checkbox" name="merge" value="true"> + <span>Merge with existing notes (instead of replacing)</span> + </label> + <button type="submit" class="btn btn-primary" style="margin-top: 10px;">Import</button> + </form> + </div> + +</details> + + </div> + + <script src="/static/notes.js" defer></script> +{{template "_footer"}}