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:
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"}}