commit ae1a1984a16c5943ed8c1452d2701aba6c80b364
parent 4e0b131b481e8d668d2ab90542ca719f8ce7acbb
Author: breadcat <breadcat@users.noreply.github.com>
Date: Sat, 14 Mar 2026 01:46:35 +0000
Replace config.json with arguments and store settings in sqlite DB
Diffstat:
7 files changed, 277 insertions(+), 115 deletions(-)
diff --git a/include-admin.go b/include-admin.go
@@ -2,12 +2,12 @@ package main
import (
"database/sql"
- "encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
)
@@ -38,52 +38,13 @@ func currentAdminState(r *http.Request, orphanData OrphanData, missingThumbnails
}
}
-func loadConfig() error {
- config = Config{
- DatabasePath: "./database.db",
- UploadDir: "uploads",
- ServerPort: ":8080",
- InstanceName: "Tagliatelle",
- GallerySize: "400px",
- ItemsPerPage: "100",
- TagAliases: []TagAliasGroup{},
- SedRules: []SedRule{},
- }
-
- if data, err := os.ReadFile("config.json"); err == nil {
- if err := json.Unmarshal(data, &config); err != nil {
- return err
- }
- }
-
- return os.MkdirAll(config.UploadDir, 0755)
-}
-
-func saveConfig() error {
- data, err := json.MarshalIndent(config, "", " ")
- if err != nil {
- return err
- }
- return os.WriteFile("config.json", data, 0644)
-}
-
func validateConfig(newConfig Config) error {
- if newConfig.DatabasePath == "" {
- return fmt.Errorf("database path cannot be empty")
- }
-
- if newConfig.UploadDir == "" {
- return fmt.Errorf("upload directory cannot be empty")
- }
-
- if newConfig.ServerPort == "" || !strings.HasPrefix(newConfig.ServerPort, ":") {
- return fmt.Errorf("server port must be in format ':8080'")
+ if newConfig.GallerySize == "" {
+ return fmt.Errorf("gallery size cannot be empty")
}
-
- if err := os.MkdirAll(newConfig.UploadDir, 0755); err != nil {
- return fmt.Errorf("cannot create upload directory: %v", err)
+ if newConfig.ItemsPerPage == "" {
+ return fmt.Errorf("items per page cannot be empty")
}
-
return nil
}
@@ -129,22 +90,52 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
}
}
-func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) {
- aliasesJSON := r.FormValue("aliases_json")
+func parseAliasesFromForm(r *http.Request) []TagAliasGroup {
+ var groups []TagAliasGroup
+ for i := 0; ; i++ {
+ category := strings.TrimSpace(r.FormValue(fmt.Sprintf("aliases[%d][category]", i)))
+ if category == "" {
+ break
+ }
+ var aliases []string
+ for j := 0; ; j++ {
+ v := strings.TrimSpace(r.FormValue(fmt.Sprintf("aliases[%d][aliases][%d]", i, j)))
+ if v == "" {
+ break
+ }
+ aliases = append(aliases, v)
+ }
+ if len(aliases) >= 2 {
+ groups = append(groups, TagAliasGroup{Category: category, Aliases: aliases})
+ }
+ }
+ return groups
+}
- var aliases []TagAliasGroup
- if aliasesJSON != "" {
- if err := json.Unmarshal([]byte(aliasesJSON), &aliases); err != nil {
- data := currentAdminState(r, orphanData, missingThumbnails)
- data.Error = "Invalid aliases JSON: " + err.Error()
- renderAdminPage(w, r, data)
- return
+func parseSedRulesFromForm(r *http.Request) ([]SedRule, error) {
+ var rules []SedRule
+ for i := 0; ; i++ {
+ name := strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][name]", i)))
+ if name == "" {
+ break
}
+ command := strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][command]", i)))
+ if command == "" {
+ return nil, fmt.Errorf("rule %s is missing a command", strconv.Itoa(i+1))
+ }
+ rules = append(rules, SedRule{
+ Name: name,
+ Description: strings.TrimSpace(r.FormValue(fmt.Sprintf("sed_rules[%d][description]", i))),
+ Command: command,
+ })
}
+ return rules, nil
+}
- config.TagAliases = aliases
+func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) {
+ config.TagAliases = parseAliasesFromForm(r)
- if err := saveConfig(); err != nil {
+ if err := SaveConfig(db, config); err != nil {
data := currentAdminState(r, orphanData, missingThumbnails)
data.Error = "Failed to save configuration: " + err.Error()
renderAdminPage(w, r, data)
@@ -157,15 +148,9 @@ func handleSaveAliases(w http.ResponseWriter, r *http.Request, orphanData Orphan
}
func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphanData OrphanData, missingThumbnails []VideoFile) {
- newConfig := Config{
- DatabasePath: strings.TrimSpace(r.FormValue("database_path")),
- UploadDir: strings.TrimSpace(r.FormValue("upload_dir")),
- ServerPort: strings.TrimSpace(r.FormValue("server_port")),
- InstanceName: strings.TrimSpace(r.FormValue("instance_name")),
- GallerySize: strings.TrimSpace(r.FormValue("gallery_size")),
- ItemsPerPage: strings.TrimSpace(r.FormValue("items_per_page")),
- TagAliases: config.TagAliases, // Preserve existing aliases
- }
+ newConfig := config // preserve runtime fields
+ newConfig.GallerySize = strings.TrimSpace(r.FormValue("gallery_size"))
+ newConfig.ItemsPerPage = strings.TrimSpace(r.FormValue("items_per_page"))
if err := validateConfig(newConfig); err != nil {
data := currentAdminState(r, orphanData, missingThumbnails)
@@ -174,12 +159,9 @@ func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphanData Orpha
return
}
- needsRestart := newConfig.DatabasePath != config.DatabasePath ||
- newConfig.ServerPort != config.ServerPort
-
config = newConfig
- if err := saveConfig(); err != nil {
+ if err := SaveConfig(db, config); err != nil {
data := currentAdminState(r, orphanData, missingThumbnails)
data.Error = "Failed to save configuration: " + err.Error()
renderAdminPage(w, r, data)
@@ -187,30 +169,22 @@ func handleSaveSettings(w http.ResponseWriter, r *http.Request, orphanData Orpha
}
data := currentAdminState(r, orphanData, missingThumbnails)
- if needsRestart {
- data.Success = "Settings saved successfully! Please restart the server for database/port changes to take effect."
- } else {
- data.Success = "Settings saved successfully!"
- }
+ data.Success = "Settings saved successfully!"
renderAdminPage(w, r, data)
}
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 {
- data := currentAdminState(r, orphanData, missingThumbnails)
- data.Error = "Invalid sed rules JSON: " + err.Error()
- renderAdminPage(w, r, data)
- return
- }
+ rules, err := parseSedRulesFromForm(r)
+ if err != nil {
+ data := currentAdminState(r, orphanData, missingThumbnails)
+ data.Error = err.Error()
+ renderAdminPage(w, r, data)
+ return
}
- config.SedRules = sedRules
+ config.SedRules = rules
- if err := saveConfig(); err != nil {
+ if err := SaveConfig(db, config); err != nil {
data := currentAdminState(r, orphanData, missingThumbnails)
data.Error = "Failed to save configuration: " + err.Error()
renderAdminPage(w, r, data)
diff --git a/include-db.go b/include-db.go
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
+ "strings"
_ "github.com/mattn/go-sqlite3"
)
@@ -56,8 +57,141 @@ func createTables(db *sql.DB) error {
content TEXT DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL DEFAULT ''
+ );
+ CREATE TABLE IF NOT EXISTS tag_aliases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL,
+ aliases TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS sed_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ command TEXT NOT NULL
+ );
`
_, err := db.Exec(schema)
return err
}
+
+func LoadConfig(db *sql.DB) (Config, error) {
+ cfg := Config{
+ GallerySize: "400px",
+ ItemsPerPage: "100",
+ TagAliases: []TagAliasGroup{},
+ SedRules: []SedRule{},
+ }
+
+ rows, err := db.Query(`SELECT key, value FROM settings`)
+ if err != nil {
+ return cfg, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var key, value string
+ if err := rows.Scan(&key, &value); err != nil {
+ return cfg, err
+ }
+ switch key {
+ case "gallery_size":
+ if value != "" {
+ cfg.GallerySize = value
+ }
+ case "items_per_page":
+ if value != "" {
+ cfg.ItemsPerPage = value
+ }
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return cfg, err
+ }
+
+ aliasRows, err := db.Query(`SELECT category, aliases FROM tag_aliases ORDER BY id`)
+ if err != nil {
+ return cfg, err
+ }
+ defer aliasRows.Close()
+ for aliasRows.Next() {
+ var category, aliasesStr string
+ if err := aliasRows.Scan(&category, &aliasesStr); err != nil {
+ return cfg, err
+ }
+ cfg.TagAliases = append(cfg.TagAliases, TagAliasGroup{
+ Category: category,
+ Aliases: strings.Split(aliasesStr, "|"),
+ })
+ }
+ if err := aliasRows.Err(); err != nil {
+ return cfg, err
+ }
+
+ sedRows, err := db.Query(`SELECT name, description, command FROM sed_rules ORDER BY id`)
+ if err != nil {
+ return cfg, err
+ }
+ defer sedRows.Close()
+ for sedRows.Next() {
+ var rule SedRule
+ if err := sedRows.Scan(&rule.Name, &rule.Description, &rule.Command); err != nil {
+ return cfg, err
+ }
+ cfg.SedRules = append(cfg.SedRules, rule)
+ }
+ if err := sedRows.Err(); err != nil {
+ return cfg, err
+ }
+
+ return cfg, nil
+}
+
+func SaveConfig(db *sql.DB, cfg Config) error {
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ for _, kv := range [][2]string{
+ {"gallery_size", cfg.GallerySize},
+ {"items_per_page", cfg.ItemsPerPage},
+ } {
+ if _, err := tx.Exec(`
+ INSERT INTO settings (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
+ `, kv[0], kv[1]); err != nil {
+ return err
+ }
+ }
+
+ if _, err := tx.Exec(`DELETE FROM tag_aliases`); err != nil {
+ return err
+ }
+ for _, group := range cfg.TagAliases {
+ aliasesStr := strings.Join(group.Aliases, "|")
+ if _, err := tx.Exec(
+ `INSERT INTO tag_aliases (category, aliases) VALUES (?, ?)`,
+ group.Category, aliasesStr,
+ ); err != nil {
+ return err
+ }
+ }
+
+ if _, err := tx.Exec(`DELETE FROM sed_rules`); err != nil {
+ return err
+ }
+ for _, rule := range cfg.SedRules {
+ if _, err := tx.Exec(
+ `INSERT INTO sed_rules (name, description, command) VALUES (?, ?, ?)`,
+ rule.Name, rule.Description, rule.Command,
+ ); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
diff --git a/include-types.go b/include-types.go
@@ -10,14 +10,15 @@ 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"`
- TagAliases []TagAliasGroup `json:"tag_aliases"`
- SedRules []SedRule `json:"sed_rules"`
+ // Values from CLI arguments
+ DatabasePath string
+ UploadDir string
+ ServerPort string
+ // Values from database
+ GallerySize string
+ ItemsPerPage string
+ TagAliases []TagAliasGroup
+ SedRules []SedRule
}
type Breadcrumb struct {
@@ -41,9 +42,9 @@ type PropertyDisplay struct {
}
type ListData struct {
- Tagged []File
- Untagged []File
- Breadcrumbs []Breadcrumb
+ Tagged []File
+ Untagged []File
+ Breadcrumbs []Breadcrumb
}
type PageData struct {
@@ -93,10 +94,10 @@ type BulkTagFormData struct {
Error string
Success string
FormData struct {
- FileRange string
- Category string
- Value string
- Operation string
+ FileRange string
+ Category string
+ Value string
+ Operation string
TagQuery string
SelectionMode string
}
diff --git a/main.go b/main.go
@@ -2,10 +2,13 @@ package main
import (
"database/sql"
+ "flag"
+ "fmt"
"html/template"
"log"
"net/http"
"os"
+ "path/filepath"
)
var (
@@ -15,22 +18,40 @@ var (
)
func main() {
- // Load configuration
- if err := loadConfig(); err != nil {
- log.Fatalf("Failed to load config: %v", err)
+ // CLI flags
+ dataDir := flag.String("d", ".", "Data directory (stores tagliatelle.db and uploads/ subfolder)")
+ port := flag.String("p", "8080", "Port to listen on")
+ flag.Parse()
+
+ // Derive paths from -d
+ dbPath := filepath.Join(*dataDir, "tagliatelle.db")
+ uploadDir := filepath.Join(*dataDir, "uploads")
+ serverPort := fmt.Sprintf(":%s", *port)
+
+ // Create necessary directories
+ if err := os.MkdirAll(uploadDir, 0755); err != nil {
+ log.Fatalf("Failed to create upload directory: %v", err)
}
+ os.MkdirAll("static", 0755)
// Initialize database
var err error
- db, err = InitDatabase(config.DatabasePath)
+ db, err = InitDatabase(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
- // Create necessary directories
- os.MkdirAll(config.UploadDir, 0755)
- os.MkdirAll("static", 0755)
+ // Load config from database (gallery size, items per page, aliases, sed rules)
+ config, err = LoadConfig(db)
+ if err != nil {
+ log.Fatalf("Failed to load config from database: %v", err)
+ }
+
+ // Inject runtime values (not stored in DB)
+ config.DatabasePath = dbPath
+ config.UploadDir = uploadDir
+ config.ServerPort = serverPort
// Initialize templates
tmpl, err = InitTemplates()
diff --git a/readme.md b/readme.md
@@ -11,7 +11,7 @@ Very rough around the edges, but functional. Primarily intended for personal use
```
cd tagliatelle
go get github.com/mattn/go-sqlite3
-go run .
+go run . -d your_directory -p 8080
```
Then access the server via a web browser, the default port is 8080.
diff --git a/static/sed-rules.js b/static/sed-rules.js
@@ -94,13 +94,27 @@ function setupSedRulesForm() {
}
}
- // Update hidden field with JSON
- document.getElementById('sed_rules_json').value = JSON.stringify(sedRules);
-
- // Let the form submit normally (don't prevent default)
+ // Remove any previously-injected hidden fields from a prior submit
+ this.querySelectorAll('input[data-generated]').forEach(el => el.remove());
+
+ // Append one hidden field per value — no JSON
+ sedRules.forEach((rule, i) => {
+ appendHidden(this, `sed_rules[${i}][name]`, rule.name);
+ appendHidden(this, `sed_rules[${i}][description]`, rule.description);
+ appendHidden(this, `sed_rules[${i}][command]`, rule.command);
+ });
});
}
+function appendHidden(form, name, value) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = value;
+ input.dataset.generated = '1';
+ form.appendChild(input);
+}
+
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
diff --git a/static/tag-alias.js b/static/tag-alias.js
@@ -93,18 +93,36 @@ function updateAlias(groupIndex, aliasIndex, value) {
}
document.getElementById('aliases-form').addEventListener('submit', function(e) {
- // Filter out empty groups and aliases
+ // Filter out incomplete groups (need a category and at least 2 aliases)
const cleanedGroups = aliasGroups
.filter(group => group.category && group.aliases && group.aliases.length > 0)
.map(group => ({
category: group.category.trim(),
aliases: group.aliases.filter(a => a && a.trim()).map(a => a.trim())
}))
- .filter(group => group.aliases.length >= 2); // Need at least 2 values to be an alias
+ .filter(group => group.aliases.length >= 2);
- document.getElementById('aliases_json').value = JSON.stringify(cleanedGroups);
+ // Remove any previously-injected hidden fields from a prior submit
+ this.querySelectorAll('input[data-generated]').forEach(el => el.remove());
+
+ // Append one hidden field per value — no JSON
+ cleanedGroups.forEach((group, gi) => {
+ appendHidden(this, `aliases[${gi}][category]`, group.category);
+ group.aliases.forEach((alias, ai) => {
+ appendHidden(this, `aliases[${gi}][aliases][${ai}]`, alias);
+ });
+ });
});
+function appendHidden(form, name, value) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = value;
+ input.dataset.generated = '1';
+ form.appendChild(input);
+}
+
// Initial render
document.addEventListener('DOMContentLoaded', function() {
renderAliasGroups();