10 Commits

Author SHA1 Message Date
a3dc264efd Merge feature/phase2-sqlite-sync into main 2026-02-03 22:04:17 +03:00
20056f3593 Embed assets and fix offline/sync/pricing issues 2026-02-03 21:58:02 +03:00
Mikhail Chusavitin
8a37542929 docs: add release notes for v0.2.7 2026-02-03 11:39:23 +03:00
Mikhail Chusavitin
0eb6730a55 fix: Windows compatibility and localhost binding
**Windows compatibility:**
- Added filepath.Join for all template and static paths
- Fixes "path not found" errors on Windows

**Localhost binding:**
- Changed default host from 0.0.0.0 to 127.0.0.1
- Browser always opens on 127.0.0.1 (localhost)
- Setup mode now listens on 127.0.0.1:8080
- Updated config.example.yaml with comment about 0.0.0.0

This ensures the app works correctly on Windows and opens
browser on the correct localhost address.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:38:28 +03:00
Mikhail Chusavitin
e2d056e7cb feat: add Windows support to build system
- Add make build-windows for Windows AMD64
- Update make build-all to include Windows
- Update release script to package Windows binary as .zip
- Add Windows installation instructions to docs
- Windows binary: qfs-windows-amd64.exe (~17MB)

All platforms now supported:
- Linux AMD64 (.tar.gz)
- macOS Intel/ARM (.tar.gz)
- Windows AMD64 (.zip)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:04:04 +03:00
Mikhail Chusavitin
1bce8086d6 feat: add release build script for multi-platform binaries
- Add scripts/release.sh for automated release builds
- Creates tar.gz packages for Linux and macOS
- Generates SHA256 checksums
- Add 'make release' target
- Add releases/ to .gitignore

Usage:
  make release  # Build and package for all platforms

Output: releases/v0.2.5/*.tar.gz + SHA256SUMS.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:58:41 +03:00
Mikhail Chusavitin
0bdd163728 feat: add version flag and Makefile for release builds
- Add -version flag to show build version
- Add Makefile with build targets:
  - make build-release: optimized build with version
  - make build-all: cross-compile for Linux/macOS
  - make run/test/clean: dev commands
- Update documentation with build commands
- Version is embedded via ldflags during build

Usage:
  make build-release  # Build with version
  ./bin/qfs -version  # Show version

Version format: v0.2.5-1-gfa0f5e3 (tag-commits-hash)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:57:22 +03:00
Mikhail Chusavitin
fa0f5e321d refactor: rename binary from quoteforge to qfs
- Rename cmd/server to cmd/qfs for shorter binary name
- Update all documentation references (README, CLAUDE.md, etc.)
- Update build commands to output bin/qfs
- Binary name now matches directory name

Usage:
  go run ./cmd/qfs              # Development
  go build -o bin/qfs ./cmd/qfs # Production

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:55:14 +03:00
Mikhail Chusavitin
502832ac9a Merge feature/phase2-sqlite-sync into main
This merge brings Phase 2.5 (Full Offline Mode) with the following improvements:

- Local-first architecture: all operations work through SQLite
- Background sync worker for automatic synchronization
- Sync queue (pending_changes table) for reliable data push
- LocalConfigurationService for offline-capable CRUD operations
- Pre-create pricelist check before configuration creation
- RefreshPrices works in offline mode using local_components
- UI improvements: sync status indicator, pricelist badge, unified admin tabs
- Fixed online mode: automatic MariaDB connection on startup
- Fixed nil pointer dereference in PricingHandler alert methods
- Improved setup flow with restart requirement notification

Phase 2.5 is now complete. Ready for production.
2026-02-03 10:51:48 +03:00
Mikhail Chusavitin
8d84484412 fix: fix online mode after offline-first architecture changes
- Fix nil pointer dereference in PricingHandler alert methods
- Add automatic MariaDB connection on startup if settings exist
- Update setupRouter to accept mariaDB as parameter
- Fix offline mode checks: use h.db instead of h.alertService
- Update setup handler to show restart required message
- Add warning status support in setup.html UI

This ensures that after saving connection settings, the application
works correctly in online mode after restart. All repositories are
properly initialized with MariaDB connection on startup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:50:07 +03:00
22 changed files with 707 additions and 113 deletions

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ Network Trash Folder
Temporary Items
.apdisk
releases/

View File

@@ -134,9 +134,22 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
## Commands
```bash
go run ./cmd/server # Dev server
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
# Development
go run ./cmd/qfs # Dev server
make run # Dev server (via Makefile)
# Production build
make build-release # Optimized build with version (recommended)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
# Cron jobs
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
go run ./cmd/cron -job=update-prices # Recalculate all prices
go run ./cmd/cron -job=update-popularity # Update popularity scores
# Check version
./bin/qfs -version
```
## Code Style

View File

@@ -60,7 +60,7 @@ localConfigService := services.NewLocalConfigurationService(
### Шаг 1: Обновить main.go
```go
// В cmd/server/main.go
// В cmd/qfs/main.go
syncService := sync.NewService(pricelistRepo, configRepo, local)
// Создать isOnline функцию
@@ -165,7 +165,7 @@ type PendingChange struct {
```bash
# Compile
go build ./cmd/server
go build ./cmd/qfs
# Run
./quoteforge

View File

@@ -89,7 +89,7 @@ mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
- `cmd/server/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
- `migrations/004_add_price_updated_at.sql` - SQL миграция
- `CLAUDE.md` - обновлена документация

97
Makefile Normal file
View File

@@ -0,0 +1,97 @@
.PHONY: build build-release clean test run version
# Get version from git
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS := -s -w -X main.Version=$(VERSION)
# Binary name
BINARY := qfs
# Build for development (with debug info)
build:
go build -o bin/$(BINARY) ./cmd/qfs
# Build for release (optimized, with version)
build-release:
@echo "Building $(BINARY) version $(VERSION)..."
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY) ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)"
@./bin/$(BINARY) -version
# Build release for Linux (cross-compile)
build-linux:
@echo "Building $(BINARY) for Linux..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-linux-amd64"
# Build release for macOS (cross-compile)
build-macos:
@echo "Building $(BINARY) for macOS..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/qfs
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-darwin-amd64"
@echo "✓ Built: bin/$(BINARY)-darwin-arm64"
# Build release for Windows (cross-compile)
build-windows:
@echo "Building $(BINARY) for Windows..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-windows-amd64.exe ./cmd/qfs
@echo "✓ Built: bin/$(BINARY)-windows-amd64.exe"
# Build all platforms
build-all: build-release build-linux build-macos build-windows
# Create release packages for all platforms
release:
@./scripts/release.sh
# Show version
version:
@echo "Version: $(VERSION)"
# Clean build artifacts
clean:
rm -rf bin/
rm -f $(BINARY)
# Run tests
test:
go test -v ./...
# Run development server
run:
go run ./cmd/qfs
# Run with auto-restart (requires entr: brew install entr)
watch:
find . -name '*.go' | entr -r go run ./cmd/qfs
# Install dependencies
deps:
go mod download
go mod tidy
# Help
help:
@echo "QuoteForge Server (qfs) - Build Commands"
@echo ""
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@echo " build Build for development (with debug info)"
@echo " build-release Build optimized release (default)"
@echo " build-linux Cross-compile for Linux"
@echo " build-macos Cross-compile for macOS (Intel + Apple Silicon)"
@echo " build-windows Cross-compile for Windows"
@echo " build-all Build for all platforms"
@echo " release Create release packages for all platforms"
@echo " version Show current version"
@echo " clean Remove build artifacts"
@echo " test Run tests"
@echo " run Run development server"
@echo " watch Run with auto-restart (requires entr)"
@echo " deps Install/update dependencies"
@echo " help Show this help"
@echo ""
@echo "Current version: $(VERSION)"

View File

@@ -82,7 +82,7 @@ auth:
### 3. Миграции базы данных
```bash
go run ./cmd/server -migrate
go run ./cmd/qfs -migrate
```
### 4. Импорт метаданных компонентов
@@ -95,11 +95,27 @@ go run ./cmd/importer
```bash
# Development
go run ./cmd/server
go run ./cmd/qfs
# Production
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
./bin/quoteforge
# Production (with Makefile - recommended)
make build-release # Builds with version info
./bin/qfs -version # Check version
# Production (manual)
VERSION=$(git describe --tags --always --dirty)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
./bin/qfs -version
```
**Makefile команды:**
```bash
make build-release # Оптимизированная сборка с версией
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
make build-windows # Только для Windows
make run # Запуск dev сервера
make test # Запуск тестов
make clean # Очистка bin/
make help # Показать все команды
```
Приложение будет доступно по адресу: http://localhost:8080
@@ -209,13 +225,13 @@ go run ./cmd/cron -job=update-popularity
```bash
# Запуск в режиме разработки (hot reload)
go run ./cmd/server
go run ./cmd/qfs
# Запуск тестов
go test ./...
# Сборка для Linux
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
```
## Переменные окружения

60
RELEASE_v0.2.6.md Normal file
View File

@@ -0,0 +1,60 @@
## Release v0.2.6 - Build & Release Improvements
Minor release focused on developer experience and release automation.
### Changes
**Build System**
- 🎯 Renamed binary from `quoteforge` to `qfs` (shorter, easier to type)
- 🏷️ Added `-version` flag to display build version
- 📦 Added Makefile with build targets for all platforms
- 🚀 Added automated release script for multi-platform binaries
**New Commands**
```bash
make build-release # Optimized build with version info
make build-all # Build for Linux + macOS (Intel/ARM)
make release # Create release packages with checksums
./bin/qfs -version # Show version
```
**Release Artifacts**
- Linux AMD64
- macOS Intel (AMD64)
- macOS Apple Silicon (ARM64)
- Windows AMD64
- SHA256 checksums
### Installation
Download the appropriate binary for your platform:
```bash
# Linux
wget https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-linux-amd64.tar.gz
tar -xzf qfs-v0.2.6-linux-amd64.tar.gz
chmod +x qfs-linux-amd64
./qfs-linux-amd64
# macOS Intel
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-darwin-amd64.tar.gz
tar -xzf qfs-v0.2.6-darwin-amd64.tar.gz
chmod +x qfs-darwin-amd64
./qfs-darwin-amd64
# macOS Apple Silicon
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-darwin-arm64.tar.gz
tar -xzf qfs-v0.2.6-darwin-arm64.tar.gz
chmod +x qfs-darwin-arm64
./qfs-darwin-arm64
# Windows
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-windows-amd64.zip
unzip qfs-v0.2.6-windows-amd64.zip
qfs-windows-amd64.exe
```
### Breaking Changes
None - fully backward compatible with v0.2.5
### Full Changelog
https://git.mchus.pro/mchus/QuoteForge/compare/v0.2.5...v0.2.6

57
RELEASE_v0.2.7.md Normal file
View File

@@ -0,0 +1,57 @@
## Release v0.2.7 - Windows Support & Localhost Binding
Bug fix release improving Windows compatibility and default network binding.
### Changes
**Windows Support**
- ✅ Fixed template loading errors on Windows
- ✅ All file paths now use `filepath.Join` for cross-platform compatibility
- ✅ Binary now works correctly on Windows without path errors
**Localhost Binding**
- ✅ Server now binds to `127.0.0.1` by default (instead of `0.0.0.0`)
- ✅ Browser always opens to `http://127.0.0.1:8080`
- ✅ Setup mode properly opens browser automatically
- ✅ More secure default - only accessible from local machine
> **Note:** To bind to all network interfaces (for network access), set `host: "0.0.0.0"` in config.yaml
### Installation
Download the appropriate binary for your platform:
```bash
# Linux
wget https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-linux-amd64.tar.gz
tar -xzf qfs-v0.2.7-linux-amd64.tar.gz
chmod +x qfs-linux-amd64
./qfs-linux-amd64
# macOS Intel
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-darwin-amd64.tar.gz
tar -xzf qfs-v0.2.7-darwin-amd64.tar.gz
chmod +x qfs-darwin-amd64
./qfs-darwin-amd64
# macOS Apple Silicon
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-darwin-arm64.tar.gz
tar -xzf qfs-v0.2.7-darwin-arm64.tar.gz
chmod +x qfs-darwin-arm64
./qfs-darwin-arm64
# Windows
# Download and extract: https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-windows-amd64.zip
# Run: qfs-windows-amd64.exe
```
### Breaking Changes
None - fully backward compatible with v0.2.6
### Upgrade Notes
- If you use config.yaml with `host: "0.0.0.0"`, the app will respect that setting
- Default behavior now binds only to localhost for security
- Windows users no longer need workarounds for path errors
### Full Changelog
https://git.mchus.pro/mchus/QuoteForge/compare/v0.2.6...v0.2.7

21
assets_embed.go Normal file
View File

@@ -0,0 +1,21 @@
package quoteforge
import (
"embed"
"io/fs"
)
// TemplatesFS contains HTML templates embedded into the binary.
//
//go:embed web/templates/*.html web/templates/partials/*.html
var TemplatesFS embed.FS
// StaticFiles contains static assets (CSS, JS, etc.) embedded into the binary.
//
//go:embed web/static/*
var StaticFiles embed.FS
// StaticFS returns a filesystem rooted at web/static for serving static assets.
func StaticFS() (fs.FS, error) {
return fs.Sub(StaticFiles, "web/static")
}

View File

@@ -7,12 +7,15 @@ import (
"log/slog"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"syscall"
"time"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/handlers"
@@ -25,6 +28,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -34,11 +38,21 @@ const (
localDBPath = "./data/settings.db"
)
// Version is set via ldflags during build
var Version = "dev"
func main() {
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
migrate := flag.Bool("migrate", false, "run database migrations")
version := flag.Bool("version", false, "show version information")
flag.Parse()
// Show version if requested
if *version {
fmt.Printf("qfs version %s\n", Version)
os.Exit(0)
}
// Initialize local SQLite database (always used)
local, err := localdb.New(localDBPath)
if err != nil {
@@ -56,38 +70,54 @@ func main() {
// Load config for server settings (optional)
cfg, err := config.Load(*configPath)
if err != nil {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", *configPath)
cfg = &config.Config{}
if os.IsNotExist(err) {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", *configPath)
cfg = &config.Config{}
} else {
slog.Error("failed to load config", "path", *configPath, "error", err)
os.Exit(1)
}
}
setConfigDefaults(cfg)
setupLogger(cfg.Logging)
// Create connection manager (lazy connection, no connect on startup)
// Create connection manager and try to connect immediately if settings exist
connMgr := db.NewConnectionManager(local)
slog.Info("starting in offline-first mode")
dbUser := local.GetDBUser()
// In offline-first mode, use default user ID
// EnsureDBUser will be called lazily when sync happens
dbUserID := uint(1)
// Try to connect to MariaDB on startup
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
mariaDB = nil
} else {
slog.Info("successfully connected to MariaDB on startup")
// Ensure DB user exists and get their ID
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
slog.Error("failed to ensure DB user", "error", err)
// Continue with default ID
dbUserID = uint(1)
}
}
slog.Info("starting QuoteForge server",
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"db_user", dbUser,
"db_user_id", dbUserID,
"online", mariaDB != nil,
)
if *migrate {
slog.Info("running database migrations...")
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Error("cannot run migrations: database not available", "error", err)
if mariaDB == nil {
slog.Error("cannot run migrations: database not available")
os.Exit(1)
}
slog.Info("running database migrations...")
if err := models.Migrate(mariaDB); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
@@ -100,7 +130,7 @@ func main() {
}
gin.SetMode(cfg.Server.Mode)
router, syncService, err := setupRouter(cfg, local, connMgr, dbUserID)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
@@ -128,6 +158,18 @@ func main() {
}
}()
// Automatically open browser after server starts (with a small delay)
go func() {
time.Sleep(1 * time.Second)
// Always use localhost for browser, even if server binds to 0.0.0.0
browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
slog.Info("Opening browser to", "url", browserURL)
err := openBrowser(browserURL)
if err != nil {
slog.Warn("Failed to open browser", "error", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
@@ -151,7 +193,7 @@ func main() {
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
cfg.Server.Host = "127.0.0.1"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
@@ -189,7 +231,9 @@ func setConfigDefaults(cfg *config.Config) {
func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1)
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig)
// In setup mode, we don't have a connection manager yet (will restart after setup)
templatesPath := filepath.Join("web", "templates")
setupHandler, err := handlers.NewSetupHandler(local, nil, templatesPath, restartSig)
if err != nil {
slog.Error("failed to create setup handler", "error", err)
os.Exit(1)
@@ -199,7 +243,12 @@ func runSetupMode(local *localdb.LocalDB) {
router := gin.New()
router.Use(gin.Recovery())
router.Static("/static", "web/static")
staticPath := filepath.Join("web", "static")
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// Setup routes only
router.GET("/", func(c *gin.Context) {
@@ -218,9 +267,8 @@ func runSetupMode(local *localdb.LocalDB) {
})
})
addr := ":8080"
addr := "127.0.0.1:8080"
slog.Info("starting setup mode server", "address", addr)
slog.Info("open http://localhost:8080/setup to configure database connection")
srv := &http.Server{
Addr: addr,
@@ -234,6 +282,17 @@ func runSetupMode(local *localdb.LocalDB) {
}
}()
// Open browser to setup page
go func() {
time.Sleep(1 * time.Second)
browserURL := "http://127.0.0.1:8080/setup"
slog.Info("Opening browser to setup page", "url", browserURL)
err := openBrowser(browserURL)
if err != nil {
slog.Warn("Failed to open browser", "error", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -300,10 +359,8 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil
}
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, dbUserID uint) (*gin.Engine, *sync.Service, error) {
// Don't connect to MariaDB on startup (offline-first architecture)
// Connection will be established lazily when needed
var mariaDB *gorm.DB
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
// mariaDB may be nil if we're in offline mode
// Repositories
var componentRepo *repository.ComponentRepository
@@ -363,25 +420,28 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Local-first configuration service (replaces old ConfigurationService)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Use filepath.Join for cross-platform path compatibility
templatesPath := filepath.Join("web", "templates")
// Handlers
componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, "web/templates")
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
}
// Setup handler (for reconfiguration) - no restart signal in normal mode
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil)
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil)
if err != nil {
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
}
// Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
if err != nil {
return nil, nil, err
}
@@ -393,8 +453,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.Use(middleware.CORS())
router.Use(middleware.OfflineDetector(connMgr, local))
// Static files
router.Static("/static", "web/static")
// Static files (use filepath.Join for Windows compatibility)
staticPath := filepath.Join("web", "static")
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
router.Static("/static", staticPath)
} else if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
}
// Health check
router.GET("/health", func(c *gin.Context) {
@@ -404,6 +469,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
// Restart endpoint (for development purposes)
router.POST("/api/restart", func(c *gin.Context) {
// This will cause the server to restart by exiting
// The restartProcess function will be called to restart the process
slog.Info("Restart requested via API")
go func() {
time.Sleep(100 * time.Millisecond)
restartProcess()
}()
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
})
// DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64
@@ -688,6 +765,25 @@ func restartProcess() {
}
}
func openBrowser(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "windows":
cmd = "cmd"
args = []string{"/c", "start", url}
case "darwin":
cmd = "open"
args = []string{url}
default: // "linux", "freebsd", "openbsd", "netbsd"
cmd = "xdg-open"
args = []string{url}
}
return exec.Command(cmd, args...).Start()
}
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()

View File

@@ -2,7 +2,7 @@
# Copy this file to config.yaml and update values
server:
host: "0.0.0.0"
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
port: 8080
mode: "release" # debug | release
read_timeout: "30s"

View File

@@ -106,7 +106,7 @@ func Load(path string) (*Config, error) {
func (c *Config) setDefaults() {
if c.Server.Host == "" {
c.Server.Host = "0.0.0.0"
c.Server.Host = "127.0.0.1"
}
if c.Server.Port == 0 {
c.Server.Port = 8080

View File

@@ -4,10 +4,10 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ComponentHandler struct {
@@ -40,7 +40,13 @@ func (h *ComponentHandler) List(c *gin.Context) {
}
// If offline mode (empty result), fallback to local components
if result.Total == 0 && h.localDB != nil {
isOffline := false
if v, ok := c.Get("is_offline"); ok {
if b, ok := v.(bool); ok {
isOffline = b
}
}
if isOffline && result.Total == 0 && h.localDB != nil {
localFilter := localdb.ComponentFilter{
Category: filter.Category,
Search: filter.Search,

View File

@@ -639,6 +639,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
}
func (h *PricingHandler) ListAlerts(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusOK, gin.H{
"alerts": []interface{}{},
"total": 0,
"page": 1,
"per_page": 20,
"offline": true,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -664,6 +676,15 @@ func (h *PricingHandler) ListAlerts(c *gin.Context) {
}
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
@@ -679,6 +700,15 @@ func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
}
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
@@ -694,6 +724,15 @@ func (h *PricingHandler) ResolveAlert(c *gin.Context) {
}
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})

View File

@@ -3,13 +3,17 @@ package handlers
import (
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -17,11 +21,12 @@ import (
type SetupHandler struct {
localDB *localdb.LocalDB
connMgr *db.ConnectionManager
templates map[string]*template.Template
restartSig chan struct{}
}
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
@@ -31,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
// Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html")
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath)
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
}
if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err)
}
@@ -39,6 +50,7 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
return &SetupHandler{
localDB: localDB,
connMgr: connMgr,
templates: templates,
restartSig: restartSig,
}, nil
@@ -181,12 +193,23 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
return
}
// Try to connect immediately to verify settings
if h.connMgr != nil {
if err := h.connMgr.TryConnect(); err != nil {
slog.Warn("failed to connect after saving settings", "error", err)
} else {
slog.Info("successfully connected to database after saving settings")
}
}
// Always restart to properly initialize all services with the new connection
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Settings saved. Restarting application...",
"success": true,
"message": "Settings saved. Please restart the application to apply changes.",
"restart_required": true,
})
// Signal restart after response is sent
// Signal restart after response is sent (if restart signal is configured)
if h.restartSig != nil {
go func() {
time.Sleep(500 * time.Millisecond) // Give time for response to be sent

View File

@@ -4,13 +4,15 @@ import (
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
)
// SyncHandler handles sync API endpoints
@@ -25,7 +27,13 @@ type SyncHandler struct {
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
// Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
tmpl, err := template.ParseFiles(partialPath)
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.ParseFiles(partialPath)
} else {
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
}
if err != nil {
return nil, err
}
@@ -40,14 +48,14 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
}
// GetStatus returns current sync status
@@ -79,14 +87,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
needComponentSync := h.localDB.NeedComponentSync(24)
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
})
}
@@ -169,11 +177,11 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
// SyncAllResponse represents result of full sync
type SyncAllResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"`
Success bool `json:"success"`
Message string `json:"message"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"`
}
// SyncAll syncs both components and pricelists
@@ -216,8 +224,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil {
slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pricelist sync failed: " + err.Error(),
"success": false,
"error": "Pricelist sync failed: " + err.Error(),
"components_synced": componentsSynced,
})
return
@@ -294,9 +302,9 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
// SyncInfoResponse represents sync information
type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
ErrorCount int `json:"error_count"`
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"`
}

View File

@@ -2,12 +2,14 @@ package handlers
import (
"html/template"
"os"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type WebHandler struct {
@@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html")
useDisk := false
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
useDisk = true
}
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/"+page,
)
}
if err != nil {
return nil, err
}
@@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.html")
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
var indexTmpl *template.Template
var err error
if useDisk {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
} else {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/index.html",
"web/templates/components_list.html",
)
}
if err != nil {
return nil, err
}
@@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
partials := []string{"components_list.html"}
for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/"+partial,
)
}
if err != nil {
return nil, err
}

View File

@@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri
case models.PriceMethodAverage:
return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil
return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian:
fallthrough
default:
@@ -149,17 +149,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er
}
return &PriceStats{
QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price,
LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75),
QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price,
LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75),
}, nil
}

View File

@@ -9,14 +9,14 @@ import (
)
var (
ErrEmptyQuote = errors.New("quote cannot be empty")
ErrEmptyQuote = errors.New("quote cannot be empty")
ErrComponentNotFound = errors.New("component not found")
ErrNoPriceAvailable = errors.New("no price available for component")
)
type QuoteService struct {
componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository
componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository
pricingService *pricing.Service
}
@@ -43,11 +43,11 @@ type QuoteItem struct {
}
type QuoteValidationResult struct {
Valid bool `json:"valid"`
Items []QuoteItem `json:"items"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Total float64 `json:"total"`
Valid bool `json:"valid"`
Items []QuoteItem `json:"items"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Total float64 `json:"total"`
}
type QuoteRequest struct {
@@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available")
}
result := &QuoteValidationResult{
Valid: true,

View File

@@ -134,12 +134,16 @@ func (s *Service) SyncPricelists() (int, error) {
synced := 0
var latestLocalID uint
var latestServerID uint
for _, pl := range serverPricelists {
// Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil {
// Already synced, track latest
latestLocalID = existing.ID
// Already synced, track latest by server ID
if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = existing.ID
}
continue
}
@@ -167,7 +171,10 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
}
latestLocalID = localPL.ID
if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = localPL.ID
}
synced++
}

100
scripts/release.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/bin/bash
set -e
# QuoteForge Release Build Script
# Creates binaries for all platforms and packages them for release
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Get version from git
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
if [[ $VERSION == *"dirty"* ]]; then
echo -e "${RED}✗ Error: Working directory has uncommitted changes${NC}"
echo " Commit your changes first"
exit 1
fi
echo -e "${GREEN}Building QuoteForge version: ${VERSION}${NC}"
echo ""
# Create release directory
RELEASE_DIR="releases/${VERSION}"
mkdir -p "${RELEASE_DIR}"
# Build for all platforms
echo -e "${YELLOW}→ Building binaries...${NC}"
make build-all
# Package binaries with checksums
echo ""
echo -e "${YELLOW}→ Creating release packages...${NC}"
# Linux AMD64
if [ -f "bin/qfs-linux-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
fi
# macOS Intel
if [ -f "bin/qfs-darwin-amd64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
fi
# macOS Apple Silicon
if [ -f "bin/qfs-darwin-arm64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
fi
# Windows AMD64
if [ -f "bin/qfs-windows-amd64.exe" ]; then
cd bin
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
cd ..
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
fi
# Generate checksums
echo ""
echo -e "${YELLOW}→ Generating checksums...${NC}"
cd "${RELEASE_DIR}"
shasum -a 256 *.tar.gz *.zip > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * | grep -v SHA256SUMS > SHA256SUMS.txt
cd ../..
echo -e "${GREEN} ✓ SHA256SUMS.txt${NC}"
# List release files
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}Release ${VERSION} built successfully!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "Files in ${RELEASE_DIR}:"
ls -lh "${RELEASE_DIR}"
echo ""
# Show next steps
echo -e "${YELLOW}Next steps:${NC}"
echo " 1. Create git tag:"
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
echo ""
echo " 2. Push tag to remote:"
echo " git push origin ${VERSION}"
echo ""
echo " 3. Create release on git.mchus.pro:"
echo " - Go to: https://git.mchus.pro/mchus/QuoteForge/releases"
echo " - Click 'New Release'"
echo " - Select tag: ${VERSION}"
echo " - Upload files from: ${RELEASE_DIR}/"
echo ""
echo -e "${GREEN}Done!${NC}"

View File

@@ -87,12 +87,14 @@
<script>
function showStatus(message, type) {
const status = document.getElementById('status');
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800');
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800', 'bg-yellow-100', 'text-yellow-800');
if (type === 'success') {
status.classList.add('bg-green-100', 'text-green-800');
} else if (type === 'error') {
status.classList.add('bg-red-100', 'text-red-800');
} else if (type === 'warning') {
status.classList.add('bg-yellow-100', 'text-yellow-800');
} else {
status.classList.add('bg-blue-100', 'text-blue-800');
}
@@ -171,12 +173,21 @@
if (data.success) {
showStatus('✓ ' + data.message, 'success');
// Wait for restart and redirect to home
setTimeout(() => {
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
// Poll until server is back
checkServerReady();
}, 2000);
// Check if restart is required
if (data.restart_required) {
// In normal mode, restart must be done manually
setTimeout(() => {
showStatus('⚠️ Пожалуйста, перезапустите приложение вручную для применения изменений', 'warning');
}, 2000);
} else {
// In setup mode, auto-restart is happening
setTimeout(() => {
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
// Poll until server is back
checkServerReady();
}, 2000);
}
} else {
showStatus(data.error, 'error');
}