diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5dc759a..1c6aa52 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -68,31 +68,28 @@ jobs:
version: latest
args: --timeout=5m
- build:
- name: Build
+ build-desktop:
+ name: Build Desktop Apps
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ${{ matrix.os }}
strategy:
+ fail-fast: false
matrix:
include:
- os: ubuntu-latest
- goos: linux
- goarch: amd64
+ platform: linux/amd64
name: linux-amd64
ext: ""
- os: macos-latest
- goos: darwin
- goarch: amd64
+ platform: darwin/amd64
name: darwin-amd64
- ext: ""
+ ext: ".app"
- os: macos-latest
- goos: darwin
- goarch: arm64
+ platform: darwin/arm64
name: darwin-arm64
- ext: ""
+ ext: ".app"
- os: windows-latest
- goos: windows
- goarch: amd64
+ platform: windows/amd64
name: windows-amd64
ext: ".exe"
@@ -105,34 +102,58 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- - name: Setup Windows build environment
- if: matrix.goos == 'windows'
- shell: bash
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install Wails
+ run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
+
+ - name: Install Linux dependencies
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev
+
+ - name: Install Windows dependencies
+ if: matrix.os == 'windows-latest'
+ run: |
+ choco install mingw -y
+ choco install nsis -y
+
+ - name: Install frontend dependencies
+ working-directory: frontend
+ run: npm ci
+
+ - name: Build Wails app
+ run: |
+ wails build -platform ${{ matrix.platform }} -clean
+
+ - name: Package artifacts (Linux)
+ if: matrix.os == 'ubuntu-latest'
run: |
- # Install MSYS2 for complete build environment
- choco install msys2 -y
- # Initialize MSYS2 and install required packages
- C:/tools/msys64/usr/bin/bash -lc 'pacman --noconfirm -S mingw-w64-x86_64-gcc mingw-w64-x86_64-pkg-config mingw-w64-x86_64-sqlite3'
- # Add MSYS2 MinGW64 to PATH
- echo "C:\\tools\\msys64\\mingw64\\bin" >> $GITHUB_PATH
- # Verify installation
- C:/tools/msys64/mingw64/bin/gcc --version || echo "GCC not found"
-
- - name: Build binary
- shell: bash
+ mkdir -p artifacts
+ cp build/bin/ml-notes artifacts/ml-notes-${{ matrix.name }}
+ chmod +x artifacts/ml-notes-${{ matrix.name }}
+
+ - name: Package artifacts (macOS)
+ if: matrix.os == 'macos-latest'
run: |
- if [ "${{ matrix.goos }}" = "windows" ]; then
- # Set up CGO environment for Windows with MSYS2
- export CGO_ENABLED=1
- export CC=C:/tools/msys64/mingw64/bin/gcc.exe
- export CXX=C:/tools/msys64/mingw64/bin/g++.exe
- export PKG_CONFIG_PATH="C:/tools/msys64/mingw64/lib/pkgconfig"
- export PATH="C:/tools/msys64/mingw64/bin:$PATH"
- fi
- CGO_ENABLED=1 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -v -o ml-notes-${{ matrix.name }}${{ matrix.ext }}
-
- - name: Test binary
- if: matrix.goarch == 'amd64' # Only test on native architecture
- shell: bash
+ mkdir -p artifacts
+ cp -r "build/bin/ML Notes.app" "artifacts/ML Notes-${{ matrix.name }}.app"
+
+ - name: Package artifacts (Windows)
+ if: matrix.os == 'windows-latest'
run: |
- ./ml-notes-${{ matrix.name }}${{ matrix.ext }} --help
+ mkdir -p artifacts
+ cp build/bin/ml-notes.exe artifacts/ml-notes-${{ matrix.name }}.exe
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: ml-notes-desktop-${{ matrix.name }}
+ path: artifacts/*
+ retention-days: 30
diff --git a/Makefile b/Makefile
index c5fcc1c..6cee4bc 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,8 @@
# Makefile for ML Notes
# Variables
-BINARY_NAME := ml-notes
+CLI_BINARY_NAME := ml-notes-cli
+GUI_BINARY_NAME := ml-notes
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
@@ -12,6 +13,19 @@ LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)
CGO_ENABLED := 1
GOFLAGS := -v
+# Go binary paths
+GOPATH := $(shell go env GOPATH)
+GOBIN := $(shell go env GOBIN)
+ifeq ($(GOBIN),)
+ GOBIN := $(GOPATH)/bin
+endif
+
+# Add Go bin to PATH for this Makefile
+export PATH := $(PATH):$(GOBIN)
+
+# Check if Wails is available (after adding GOBIN to PATH)
+WAILS_AVAILABLE := $(shell command -v wails 2> /dev/null)
+
# Directories
PREFIX := /usr/local
BINDIR := $(PREFIX)/bin
@@ -41,37 +55,78 @@ endif
# Default target
.PHONY: all
-all: build install
+all: build-cli build-gui install
-# Build the binary
+# Build both CLI and GUI binaries
.PHONY: build
-build:
- @echo "Building $(BINARY_NAME) $(VERSION) for $(PLATFORM)/$(ARCH)..."
+build: build-cli build-gui
+
+# Build the CLI binary
+.PHONY: build-cli
+build-cli:
+ @echo "Building $(CLI_BINARY_NAME) $(VERSION) for $(PLATFORM)/$(ARCH)..."
+ @echo "Go version: $(GO_VERSION)"
+ @echo "Git commit: $(GIT_COMMIT)"
+ CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) $(LDFLAGS) -o $(CLI_BINARY_NAME) ./app/cli
+ @echo "CLI build complete: ./$(CLI_BINARY_NAME)"
+
+# Build the GUI binary using Wails
+.PHONY: build-gui
+build-gui:
+ifdef WAILS_AVAILABLE
+ @echo "Building $(GUI_BINARY_NAME) $(VERSION) using Wails..."
@echo "Go version: $(GO_VERSION)"
@echo "Git commit: $(GIT_COMMIT)"
- CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_NAME) .
- @echo "Build complete: ./$(BINARY_NAME)"
+ wails build -clean -o $(GUI_BINARY_NAME)
+ @echo "GUI build complete: ./build/bin/$(GUI_BINARY_NAME)"
+else
+ @echo "โ ๏ธ Wails not found. Skipping GUI build."
+ @echo " Install Wails with: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
+endif
+
+# Development build with race detector for CLI
+.PHONY: dev-cli
+dev-cli:
+ @echo "Building CLI development version with race detector..."
+ CGO_ENABLED=1 go build -race $(LDFLAGS) -o $(CLI_BINARY_NAME)-dev ./app/cli
+ @echo "CLI development build complete: ./$(CLI_BINARY_NAME)-dev"
+
+# Development build for GUI using Wails
+.PHONY: dev-gui
+dev-gui:
+ifdef WAILS_AVAILABLE
+ @echo "Starting Wails development server..."
+ wails dev
+else
+ @echo "โ ๏ธ Wails not found. Cannot start development server."
+ @echo " Install Wails with: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
+endif
-# Development build with race detector
+# Development build for both
.PHONY: dev
-dev:
- @echo "Building development version with race detector..."
- CGO_ENABLED=1 go build -race $(LDFLAGS) -o $(BINARY_NAME)-dev .
- @echo "Development build complete: ./$(BINARY_NAME)-dev"
+dev: dev-cli dev-gui
-# Install the binary to system PATH
+# Install binaries to system PATH
.PHONY: install
-install: $(BINARY_NAME)
- @echo "Installing $(BINARY_NAME) to $(BINDIR)..."
- @$(INSTALL_PROGRAM) $(BINARY_NAME) $(BINDIR)/
+install: $(CLI_BINARY_NAME) $(GUI_BINARY_NAME)
+ @echo "Installing $(CLI_BINARY_NAME) to $(BINDIR)..."
+ @$(INSTALL_PROGRAM) $(CLI_BINARY_NAME) $(BINDIR)/
+ifdef WAILS_AVAILABLE
+ @if [ -f "./build/bin/$(GUI_BINARY_NAME)" ]; then \
+ echo "Installing $(GUI_BINARY_NAME) to $(BINDIR)..."; \
+ $(INSTALL_PROGRAM) ./build/bin/$(GUI_BINARY_NAME) $(BINDIR)/; \
+ fi
+endif
@echo "Installation complete!"
- @echo "Run 'ml-notes init' to set up your configuration."
+ @echo "Run '$(CLI_BINARY_NAME) init' to set up your configuration."
+ @echo "Run '$(GUI_BINARY_NAME)' to start the desktop application."
-# Uninstall the binary
+# Uninstall the binaries
.PHONY: uninstall
uninstall:
- @echo "Removing $(BINARY_NAME) from $(BINDIR)..."
- @rm -f $(BINDIR)/$(BINARY_NAME)
+ @echo "Removing binaries from $(BINDIR)..."
+ @rm -f $(BINDIR)/$(CLI_BINARY_NAME)
+ @rm -f $(BINDIR)/$(GUI_BINARY_NAME)
@echo "Uninstall complete."
# Run tests
@@ -111,7 +166,9 @@ fmt:
.PHONY: clean
clean:
@echo "Cleaning build artifacts..."
- @rm -f $(BINARY_NAME) $(BINARY_NAME)-dev
+ @rm -f $(CLI_BINARY_NAME) $(CLI_BINARY_NAME)-dev
+ @rm -f $(GUI_BINARY_NAME) $(GUI_BINARY_NAME)-dev
+ @rm -rf build/
@rm -f coverage.out coverage.html
@rm -rf dist/
@echo "Clean complete."
@@ -218,7 +275,9 @@ help:
@echo "ML Notes - Makefile targets:"
@echo ""
@echo "๐๏ธ Build targets:"
- @echo " make build - Build the binary for current platform"
+ @echo " make build - Build both CLI and GUI binaries"
+ @echo " make build-cli - Build the CLI binary only"
+ @echo " make build-gui - Build the GUI binary using Wails"
@echo " make build-native - Build for native platform (auto-detect)"
@echo " make build-linux - Build for Linux AMD64"
@echo " make build-darwin - Build for macOS (Intel & Apple Silicon)"
@@ -229,9 +288,11 @@ help:
@echo " make release - Create release packages for all platforms"
@echo ""
@echo "๐ ๏ธ Development targets:"
- @echo " make install - Build and install to $(BINDIR)"
- @echo " make uninstall - Remove from $(BINDIR)"
- @echo " make dev - Build with race detector"
+ @echo " make install - Build and install both binaries to $(BINDIR)"
+ @echo " make uninstall - Remove both binaries from $(BINDIR)"
+ @echo " make dev - Build CLI with race detector"
+ @echo " make dev-cli - Build CLI with race detector"
+ @echo " make dev-gui - Start Wails development server"
@echo " make test - Run tests"
@echo " make test-coverage - Run tests with coverage"
@echo " make lint - Run linters"
@@ -242,16 +303,29 @@ help:
@echo ""
@echo "โน๏ธ Information:"
@echo " VERSION=$(VERSION)"
+ @echo " CLI_BINARY_NAME=$(CLI_BINARY_NAME)"
+ @echo " GUI_BINARY_NAME=$(GUI_BINARY_NAME)"
@echo " PLATFORM=$(PLATFORM)/$(ARCH)"
@echo " PREFIX=$(PREFIX)"
+ifdef WAILS_AVAILABLE
+ @echo " WAILS=available"
+else
+ @echo " WAILS=not available (GUI builds disabled)"
+endif
@echo ""
@echo "๐ Notes:"
+ @echo " - The CLI binary provides all command-line functionality"
+ @echo " - The GUI binary is a desktop app built with Wails"
+ @echo " - Wails is required for GUI builds: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
@echo " - Cross-compilation for macOS/Windows requires appropriate toolchains"
@echo " - For best results, build natively on target platforms"
@echo " - CGO is required for sqlite-vec support"
-# Ensure binary exists for install target
-$(BINARY_NAME):
- @$(MAKE) build
+# Ensure binaries exist for install target
+$(CLI_BINARY_NAME):
+ @$(MAKE) build-cli
+
+$(GUI_BINARY_NAME):
+ @$(MAKE) build-gui
.DEFAULT_GOAL := help
\ No newline at end of file
diff --git a/app/cli/main.go b/app/cli/main.go
new file mode 100644
index 0000000..8a5d4f7
--- /dev/null
+++ b/app/cli/main.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/streed/ml-notes/internal/cli"
+)
+
+// Version is set via ldflags during build
+var Version = "dev"
+
+func main() {
+ // Set version for the CLI package
+ cli.Version = Version
+
+ if err := cli.Execute(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/app/desktop/assets.go b/app/desktop/assets.go
new file mode 100644
index 0000000..53782d9
--- /dev/null
+++ b/app/desktop/assets.go
@@ -0,0 +1,8 @@
+package main
+
+import (
+ "embed"
+)
+
+//go:embed frontend
+var assets embed.FS
diff --git a/app/desktop/context.go b/app/desktop/context.go
new file mode 100644
index 0000000..b03824f
--- /dev/null
+++ b/app/desktop/context.go
@@ -0,0 +1,261 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/streed/ml-notes/internal/models"
+)
+
+// Notes API methods for Wails frontend
+
+// GetNote retrieves a note by ID
+func (a *App) GetNote(id int) (*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ return a.services.Notes.GetByID(id)
+}
+
+// ListNotes retrieves notes with pagination
+func (a *App) ListNotes(limit, offset int) ([]*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ return a.services.Notes.List(limit, offset)
+}
+
+// CreateNote creates a new note
+func (a *App) CreateNote(title, content string, tags []string) (*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ if title == "" {
+ return nil, fmt.Errorf("title is required")
+ }
+
+ return a.services.Notes.Create(title, content, tags)
+}
+
+// UpdateNote updates an existing note
+func (a *App) UpdateNote(id int, title, content string, tags []string) (*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ // Get existing note
+ note, err := a.services.Notes.GetByID(id)
+ if err != nil {
+ return nil, fmt.Errorf("note not found: %w", err)
+ }
+
+ // Update fields
+ note.Title = title
+ note.Content = content
+
+ // Update tags if provided
+ if tags != nil {
+ if err := a.services.Tags.UpdateNoteTags(id, tags); err != nil {
+ return nil, fmt.Errorf("failed to update tags: %w", err)
+ }
+ }
+
+ // Update note
+ if err := a.services.Notes.Update(note); err != nil {
+ return nil, fmt.Errorf("failed to update note: %w", err)
+ }
+
+ // Return updated note
+ return a.services.Notes.GetByID(id)
+}
+
+// DeleteNote deletes a note by ID
+func (a *App) DeleteNote(id int) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Notes.Delete(id)
+}
+
+// SearchNotes searches for notes
+func (a *App) SearchNotes(query string, useVector bool, limit int) ([]*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ if limit <= 0 {
+ limit = 10
+ }
+ return a.services.Search.SearchNotes(query, useVector, limit)
+}
+
+// Tags API methods
+
+// GetAllTags retrieves all tags
+func (a *App) GetAllTags() ([]models.Tag, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ return a.services.Tags.GetAll()
+}
+
+// UpdateNoteTags updates tags for a specific note
+func (a *App) UpdateNoteTags(noteID int, tags []string) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Tags.UpdateNoteTags(noteID, tags)
+}
+
+// SearchByTags searches notes by tags
+func (a *App) SearchByTags(tags []string) ([]*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ return a.services.Search.SearchByTags(tags)
+}
+
+// Auto-tagging API methods
+
+// IsAutoTagAvailable checks if auto-tagging is available
+func (a *App) IsAutoTagAvailable() bool {
+ if a.services == nil {
+ return false
+ }
+ return a.services.AutoTag.IsAvailable()
+}
+
+// SuggestTags suggests tags for a note
+func (a *App) SuggestTags(noteID int) ([]string, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ note, err := a.services.Notes.GetByID(noteID)
+ if err != nil {
+ return nil, fmt.Errorf("note not found: %w", err)
+ }
+
+ return a.services.AutoTag.SuggestTags(note)
+}
+
+// Analysis API methods
+
+// AnalyzeNote analyzes a note with optional custom prompt
+func (a *App) AnalyzeNote(noteID int, prompt string) (map[string]interface{}, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ note, err := a.services.Notes.GetByID(noteID)
+ if err != nil {
+ return nil, fmt.Errorf("note not found: %w", err)
+ }
+
+ result, err := a.services.Analyze.AnalyzeNote(note, prompt)
+ if err != nil {
+ return nil, fmt.Errorf("analysis failed: %w", err)
+ }
+
+ return map[string]interface{}{
+ "analysis": result.Summary,
+ "model": result.Model,
+ "original_length": result.OriginalLength,
+ "summary_length": result.SummaryLength,
+ "compression": 100.0 * (1.0 - float64(result.SummaryLength)/float64(result.OriginalLength)),
+ }, nil
+}
+
+// Preferences API methods
+
+// GetPreference gets a string preference
+func (a *App) GetPreference(key, defaultValue string) string {
+ if a.services == nil {
+ return defaultValue
+ }
+ return a.services.Preferences.GetString(key, defaultValue)
+}
+
+// SetPreference sets a string preference
+func (a *App) SetPreference(key, value string) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.SetString(key, value)
+}
+
+// GetBoolPreference gets a boolean preference
+func (a *App) GetBoolPreference(key string, defaultValue bool) bool {
+ if a.services == nil {
+ return defaultValue
+ }
+ return a.services.Preferences.GetBool(key, defaultValue)
+}
+
+// SetBoolPreference sets a boolean preference
+func (a *App) SetBoolPreference(key string, value bool) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.SetBool(key, value)
+}
+
+// GetJSONPreference gets a JSON preference
+func (a *App) GetJSONPreference(key string, target interface{}) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.GetJSON(key, target)
+}
+
+// SetJSONPreference sets a JSON preference
+func (a *App) SetJSONPreference(key string, value interface{}) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.SetJSON(key, value)
+}
+
+// Utility methods
+
+// GetStats returns basic application statistics
+func (a *App) GetStats() (map[string]interface{}, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ notes, err := a.services.Notes.List(0, 0)
+ if err != nil {
+ return nil, err
+ }
+
+ tags, err := a.services.Tags.GetAll()
+ if err != nil {
+ return nil, err
+ }
+
+ return map[string]interface{}{
+ "total_notes": len(notes),
+ "total_tags": len(tags),
+ "auto_tagging": a.services.AutoTag.IsAvailable(),
+ "database_path": a.services.Config.GetDatabasePath(),
+ }, nil
+}
+
+// ShowNotification shows a notification to the user (Wails runtime)
+func (a *App) ShowNotification(title, message, notificationType string) {
+ // This could use Wails runtime notification or custom modal
+ // For now, we'll implement custom modals in the frontend
+}
+
+// ShowError shows an error dialog
+func (a *App) ShowError(title, message string) {
+ // This will be implemented with frontend modals
+}
+
+// ShowSuccess shows a success message
+func (a *App) ShowSuccess(message string) {
+ // This will be implemented with frontend modals
+}
diff --git a/app/desktop/frontend/frontend b/app/desktop/frontend/frontend
new file mode 120000
index 0000000..96224b3
--- /dev/null
+++ b/app/desktop/frontend/frontend
@@ -0,0 +1 @@
+../../frontend
\ No newline at end of file
diff --git a/app/desktop/frontend/gitkeep b/app/desktop/frontend/gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/app/desktop/main.go b/app/desktop/main.go
new file mode 100644
index 0000000..c8c7117
--- /dev/null
+++ b/app/desktop/main.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+
+ "github.com/streed/ml-notes/internal/config"
+ "github.com/streed/ml-notes/internal/database"
+ "github.com/streed/ml-notes/internal/logger"
+ "github.com/streed/ml-notes/internal/models"
+ "github.com/streed/ml-notes/internal/preferences"
+ "github.com/streed/ml-notes/internal/search"
+ "github.com/streed/ml-notes/internal/services"
+)
+
+// App struct
+type App struct {
+ ctx context.Context
+ services *services.Services
+}
+
+// NewApp creates a new App application struct
+func NewApp() *App {
+ return &App{}
+}
+
+// OnStartup is called when the app starts up.
+func (a *App) OnStartup(ctx context.Context) {
+ a.ctx = ctx
+
+ // Initialize configuration with default values if loading fails
+ cfg, err := config.Load()
+ if err != nil {
+ logger.Error("Failed to load configuration: %v", err)
+ // Create a basic config with default data directory
+ dataDir := "/home/reed/.local/share/ml-notes" // Default fallback
+ ollamaEndpoint := "http://localhost:11434" // Default Ollama endpoint
+ cfg, err = config.InitializeConfig(dataDir, ollamaEndpoint)
+ if err != nil {
+ logger.Error("Failed to initialize default config: %v", err)
+ return
+ }
+ }
+
+ // Initialize database
+ db, err := database.New(cfg)
+ if err != nil {
+ logger.Error("Failed to initialize database: %v", err)
+ return
+ }
+
+ // Initialize repositories
+ noteRepo := models.NewNoteRepository(db.Conn())
+ prefsRepo := preferences.NewPreferencesRepository(db.Conn())
+
+ // Initialize search (optional)
+ var vectorSearch search.SearchProvider
+ // vectorSearch = search.NewSQLiteVectorSearch(db.Conn()) // if available
+
+ // Initialize services layer
+ a.services = services.NewServices(cfg, noteRepo, prefsRepo, vectorSearch)
+}
+
+// OnDomReady is called after front-end resources have been loaded
+func (a *App) OnDomReady(ctx context.Context) {
+ // Called when the frontend is ready
+}
+
+// OnBeforeClose is called when the application is about to quit
+func (a *App) OnBeforeClose(ctx context.Context) (prevent bool) {
+ // Return true to prevent the application from quitting
+ return false
+}
+
+// OnShutdown is called when the application is shutting down
+func (a *App) OnShutdown(ctx context.Context) {
+ // Cleanup resources
+ if a.services != nil {
+ if err := a.services.Close(); err != nil {
+ logger.Error("Failed to close services: %v", err)
+ }
+ }
+}
+
+func main() {
+ // Create an instance of the app structure
+ app := NewApp()
+
+ // Create application with options
+ err := wails.Run(&options.App{
+ Title: "ML Notes",
+ Width: 1024,
+ Height: 768,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
+ OnStartup: app.OnStartup,
+ OnDomReady: app.OnDomReady,
+ OnBeforeClose: app.OnBeforeClose,
+ OnShutdown: app.OnShutdown,
+ })
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ }
+}
diff --git a/assets.go b/assets.go
index 9efd5ec..53782d9 100644
--- a/assets.go
+++ b/assets.go
@@ -2,48 +2,7 @@ package main
import (
"embed"
- "html/template"
- "io/fs"
- "net/http"
)
-// Embed the web assets at compile time
-//
-//go:embed web/templates
-var templateFS embed.FS
-
-//go:embed web/static
-var staticFS embed.FS
-
-// EmbeddedAssetProvider implements the AssetProvider interface using embedded assets
-type EmbeddedAssetProvider struct{}
-
-// GetTemplates returns parsed templates from embedded assets
-func (e *EmbeddedAssetProvider) GetTemplates() (*template.Template, error) {
- return template.ParseFS(templateFS, "web/templates/*.html")
-}
-
-// GetStaticHandler returns an HTTP handler for serving static assets
-func (e *EmbeddedAssetProvider) GetStaticHandler() http.Handler {
- // Get the static subdirectory from the embedded filesystem
- staticSubFS, err := fs.Sub(staticFS, "web/static")
- if err != nil {
- panic(err) // This should never happen with properly embedded assets
- }
-
- return http.FileServer(http.FS(staticSubFS))
-}
-
-// GetStaticFS returns the embedded static filesystem
-func (e *EmbeddedAssetProvider) GetStaticFS() fs.FS {
- staticSubFS, err := fs.Sub(staticFS, "web/static")
- if err != nil {
- panic(err) // This should never happen with properly embedded assets
- }
- return staticSubFS
-}
-
-// HasEmbeddedAssets returns true if assets are embedded (always true in this implementation)
-func (e *EmbeddedAssetProvider) HasEmbeddedAssets() bool {
- return true
-}
+//go:embed frontend
+var assets embed.FS
diff --git a/cmd/add.go b/cmd/add.go
index 428410a..a1f5659 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -188,7 +188,11 @@ func getContentFromEditor(noteTitle string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
- defer os.Remove(tempFile.Name())
+ defer func() {
+ if err := os.Remove(tempFile.Name()); err != nil {
+ logger.Debug("Failed to remove temp file: %v", err)
+ }
+ }()
// Write template to temp file
template := fmt.Sprintf(`# %s
@@ -201,10 +205,12 @@ func getContentFromEditor(noteTitle string) (string, error) {
-->`, noteTitle)
if _, err := tempFile.WriteString(template); err != nil {
- tempFile.Close()
+ _ = tempFile.Close()
return "", fmt.Errorf("failed to write temp file: %w", err)
}
- tempFile.Close()
+ if err := tempFile.Close(); err != nil {
+ return "", fmt.Errorf("failed to close temp file: %w", err)
+ }
// Open in editor (reuse logic from edit command)
if err := openEditor(tempFile.Name()); err != nil {
diff --git a/cmd/edit.go b/cmd/edit.go
index 3bce5d7..3b218c6 100644
--- a/cmd/edit.go
+++ b/cmd/edit.go
@@ -160,14 +160,20 @@ func editFullNote(note *models.Note) (string, string, error) {
if err != nil {
return "", "", fmt.Errorf("failed to create temp file: %w", err)
}
- defer os.Remove(tempFile.Name())
+ defer func() {
+ if err := os.Remove(tempFile.Name()); err != nil {
+ logger.Debug("Failed to remove temp file: %v", err)
+ }
+ }()
// Write content to temp file
if _, err := tempFile.WriteString(content); err != nil {
- tempFile.Close()
+ _ = tempFile.Close()
return "", "", fmt.Errorf("failed to write temp file: %w", err)
}
- tempFile.Close()
+ if err := tempFile.Close(); err != nil {
+ return "", "", fmt.Errorf("failed to close temp file: %w", err)
+ }
// Open in editor
if err := openInEditor(tempFile.Name()); err != nil {
@@ -248,14 +254,20 @@ func editInEditor(text string, noteID int, isTitle bool) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
- defer os.Remove(tempFile.Name())
+ defer func() {
+ if err := os.Remove(tempFile.Name()); err != nil {
+ logger.Debug("Failed to remove temp file: %v", err)
+ }
+ }()
// Write content to temp file
if _, err := tempFile.WriteString(text); err != nil {
- tempFile.Close()
+ _ = tempFile.Close()
return "", fmt.Errorf("failed to write temp file: %w", err)
}
- tempFile.Close()
+ if err := tempFile.Close(); err != nil {
+ return "", fmt.Errorf("failed to close temp file: %w", err)
+ }
// Open in editor
if err := openInEditor(tempFile.Name()); err != nil {
diff --git a/cmd/project.go b/cmd/project.go
index 6aabb42..d6bceca 100644
--- a/cmd/project.go
+++ b/cmd/project.go
@@ -128,8 +128,8 @@ func runProjectList(cmd *cobra.Command, args []string) error {
// Create tabwriter for aligned output
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
- fmt.Fprintln(w, "ID\tNAME\tCREATED\tDESCRIPTION")
- fmt.Fprintln(w, "--\t----\t-------\t-----------")
+ _, _ = fmt.Fprintln(w, "ID\tNAME\tCREATED\tDESCRIPTION")
+ _, _ = fmt.Fprintln(w, "--\t----\t-------\t-----------")
for _, project := range projects {
created := project.CreatedAt.Format("2006-01-02")
@@ -138,11 +138,11 @@ func runProjectList(cmd *cobra.Command, args []string) error {
description = description[:47] + "..."
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+ _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
project.ID, project.Name, created, description)
}
- w.Flush()
+ _ = w.Flush()
return nil
}
diff --git a/cmd/tags.go b/cmd/tags.go
index d4a3994..e76972c 100644
--- a/cmd/tags.go
+++ b/cmd/tags.go
@@ -60,9 +60,7 @@ Example:
RunE: runTagsSet,
}
-var (
- tagsList []string
-)
+var tagsList []string
func init() {
rootCmd.AddCommand(tagsCmd)
diff --git a/context.go b/context.go
new file mode 100644
index 0000000..e305fcb
--- /dev/null
+++ b/context.go
@@ -0,0 +1,418 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+
+ "github.com/streed/ml-notes/internal/config"
+ "github.com/streed/ml-notes/internal/models"
+)
+
+// Notes API methods for Wails frontend
+
+// GetNote retrieves a note by ID
+func (a *App) GetNote(id int) (*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ return a.services.Notes.GetByID(id)
+}
+
+// ListNotes retrieves notes with pagination
+func (a *App) ListNotes(limit, offset int) ([]*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ return a.services.Notes.List(limit, offset)
+}
+
+// CreateNote creates a new note
+func (a *App) CreateNote(title, content string, tags []string) (*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ if title == "" {
+ return nil, fmt.Errorf("title is required")
+ }
+
+ return a.services.Notes.Create(title, content, tags)
+}
+
+// UpdateNote updates an existing note
+func (a *App) UpdateNote(id int, title, content string, tags []string) (*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ // Get existing note
+ note, err := a.services.Notes.GetByID(id)
+ if err != nil {
+ return nil, fmt.Errorf("note not found: %w", err)
+ }
+
+ // Update fields
+ note.Title = title
+ note.Content = content
+
+ // Update tags if provided
+ if tags != nil {
+ if err := a.services.Tags.UpdateNoteTags(id, tags); err != nil {
+ return nil, fmt.Errorf("failed to update tags: %w", err)
+ }
+ }
+
+ // Update note
+ if err := a.services.Notes.Update(note); err != nil {
+ return nil, fmt.Errorf("failed to update note: %w", err)
+ }
+
+ // Return updated note
+ return a.services.Notes.GetByID(id)
+}
+
+// DeleteNote deletes a note by ID
+func (a *App) DeleteNote(id int) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Notes.Delete(id)
+}
+
+// SearchNotes searches for notes
+func (a *App) SearchNotes(query string, useVector bool, limit int) ([]*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ if limit <= 0 {
+ limit = 10
+ }
+ return a.services.Search.SearchNotes(query, useVector, limit)
+}
+
+// Tags API methods
+
+// GetAllTags retrieves all tags
+func (a *App) GetAllTags() ([]models.Tag, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ return a.services.Tags.GetAll()
+}
+
+// UpdateNoteTags updates tags for a specific note
+func (a *App) UpdateNoteTags(noteID int, tags []string) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Tags.UpdateNoteTags(noteID, tags)
+}
+
+// SearchByTags searches notes by tags
+func (a *App) SearchByTags(tags []string) ([]*models.Note, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+ return a.services.Search.SearchByTags(tags)
+}
+
+// Auto-tagging API methods
+
+// IsAutoTagAvailable checks if auto-tagging is available
+func (a *App) IsAutoTagAvailable() bool {
+ if a.services == nil {
+ return false
+ }
+ return a.services.AutoTag.IsAvailable()
+}
+
+// SuggestTags suggests tags for a note
+func (a *App) SuggestTags(noteID int) ([]string, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ note, err := a.services.Notes.GetByID(noteID)
+ if err != nil {
+ return nil, fmt.Errorf("note not found: %w", err)
+ }
+
+ return a.services.AutoTag.SuggestTags(note)
+}
+
+// Analysis API methods
+
+// AnalyzeNote analyzes a note with optional custom prompt
+func (a *App) AnalyzeNote(noteID int, prompt string) (map[string]interface{}, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ note, err := a.services.Notes.GetByID(noteID)
+ if err != nil {
+ return nil, fmt.Errorf("note not found: %w", err)
+ }
+
+ result, err := a.services.Analyze.AnalyzeNote(note, prompt)
+ if err != nil {
+ return nil, fmt.Errorf("analysis failed: %w", err)
+ }
+
+ return map[string]interface{}{
+ "analysis": result.Summary,
+ "model": result.Model,
+ "original_length": result.OriginalLength,
+ "summary_length": result.SummaryLength,
+ "compression": 100.0 * (1.0 - float64(result.SummaryLength)/float64(result.OriginalLength)),
+ }, nil
+}
+
+// Preferences API methods
+
+// GetPreference gets a string preference
+func (a *App) GetPreference(key, defaultValue string) string {
+ if a.services == nil {
+ return defaultValue
+ }
+ return a.services.Preferences.GetString(key, defaultValue)
+}
+
+// SetPreference sets a string preference
+func (a *App) SetPreference(key, value string) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.SetString(key, value)
+}
+
+// GetBoolPreference gets a boolean preference
+func (a *App) GetBoolPreference(key string, defaultValue bool) bool {
+ if a.services == nil {
+ return defaultValue
+ }
+ return a.services.Preferences.GetBool(key, defaultValue)
+}
+
+// SetBoolPreference sets a boolean preference
+func (a *App) SetBoolPreference(key string, value bool) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.SetBool(key, value)
+}
+
+// GetJSONPreference gets a JSON preference
+func (a *App) GetJSONPreference(key string, target interface{}) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.GetJSON(key, target)
+}
+
+// SetJSONPreference sets a JSON preference
+func (a *App) SetJSONPreference(key string, value interface{}) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+ return a.services.Preferences.SetJSON(key, value)
+}
+
+// Utility methods
+
+// GetStats returns basic application statistics
+func (a *App) GetStats() (map[string]interface{}, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ notes, err := a.services.Notes.List(0, 0)
+ if err != nil {
+ return nil, err
+ }
+
+ tags, err := a.services.Tags.GetAll()
+ if err != nil {
+ return nil, err
+ }
+
+ return map[string]interface{}{
+ "total_notes": len(notes),
+ "total_tags": len(tags),
+ "auto_tagging": a.services.AutoTag.IsAvailable(),
+ "database_path": a.services.Config.GetDatabasePath(),
+ }, nil
+}
+
+// ShowNotification shows a notification to the user (Wails runtime)
+func (a *App) ShowNotification(title, message, notificationType string) {
+ // This could use Wails runtime notification or custom modal
+ // For now, we'll implement custom modals in the frontend
+}
+
+// ShowError shows an error dialog
+func (a *App) ShowError(title, message string) {
+ // This will be implemented with frontend modals
+}
+
+// ShowSuccess shows a success message
+func (a *App) ShowSuccess(message string) {
+ // This will be implemented with frontend modals
+}
+
+// Configuration API methods
+
+// GetConfig returns the current configuration
+func (a *App) GetConfig() (map[string]interface{}, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ cfg := a.services.Config
+ return map[string]interface{}{
+ "data_directory": cfg.DataDirectory,
+ "database_path": cfg.GetDatabasePath(),
+ "ollama_endpoint": cfg.OllamaEndpoint,
+ "debug": cfg.Debug,
+ "summarization_model": cfg.SummarizationModel,
+ "enable_summarization": cfg.EnableSummarization,
+ "editor": cfg.Editor,
+ "enable_auto_tagging": cfg.EnableAutoTagging,
+ "auto_tag_model": cfg.AutoTagModel,
+ "max_auto_tags": cfg.MaxAutoTags,
+ "webui_theme": cfg.WebUITheme,
+ "webui_custom_css": cfg.WebUICustomCSS,
+ "github_owner": cfg.GitHubOwner,
+ "github_repo": cfg.GitHubRepo,
+ "lilrag_url": cfg.LilRagURL,
+ "current_project": cfg.CurrentProject,
+ }, nil
+}
+
+// UpdateConfig updates the configuration with provided values
+func (a *App) UpdateConfig(updates map[string]interface{}) error {
+ if a.services == nil {
+ return fmt.Errorf("services not initialized")
+ }
+
+ cfg := a.services.Config
+
+ // Update configuration fields
+ for key, value := range updates {
+ switch key {
+ case "data_directory":
+ if str, ok := value.(string); ok {
+ cfg.DataDirectory = str
+ }
+ case "ollama_endpoint":
+ if str, ok := value.(string); ok {
+ cfg.OllamaEndpoint = str
+ }
+ case "debug":
+ if b, ok := value.(bool); ok {
+ cfg.Debug = b
+ }
+ case "summarization_model":
+ if str, ok := value.(string); ok {
+ cfg.SummarizationModel = str
+ }
+ case "enable_summarization":
+ if b, ok := value.(bool); ok {
+ cfg.EnableSummarization = b
+ }
+ case "editor":
+ if str, ok := value.(string); ok {
+ cfg.Editor = str
+ }
+ case "enable_auto_tagging":
+ if b, ok := value.(bool); ok {
+ cfg.EnableAutoTagging = b
+ }
+ case "auto_tag_model":
+ if str, ok := value.(string); ok {
+ cfg.AutoTagModel = str
+ }
+ case "max_auto_tags":
+ if f, ok := value.(float64); ok {
+ cfg.MaxAutoTags = int(f)
+ }
+ case "webui_theme":
+ if str, ok := value.(string); ok {
+ cfg.WebUITheme = str
+ }
+ case "webui_custom_css":
+ if str, ok := value.(string); ok {
+ cfg.WebUICustomCSS = str
+ }
+ case "lilrag_url":
+ if str, ok := value.(string); ok {
+ cfg.LilRagURL = str
+ }
+ }
+ }
+
+ // Save the updated configuration
+ return config.Save(cfg)
+}
+
+// InitializeConfig initializes a new configuration
+func (a *App) InitializeConfig(dataDir, ollamaEndpoint string) error {
+ _, err := config.InitializeConfig(dataDir, ollamaEndpoint)
+ if err != nil {
+ return fmt.Errorf("failed to initialize configuration: %w", err)
+ }
+
+ // Restart services with new config (this would require app restart in practice)
+ return nil
+}
+
+// IsConfigInitialized checks if the application has been properly configured
+func (a *App) IsConfigInitialized() bool {
+ configPath, err := config.GetConfigPath()
+ if err != nil {
+ return false
+ }
+
+ _, err = os.Stat(configPath)
+ return !os.IsNotExist(err)
+}
+
+// TestOllamaConnection tests the connection to Ollama service
+func (a *App) TestOllamaConnection() (map[string]interface{}, error) {
+ if a.services == nil {
+ return nil, fmt.Errorf("services not initialized")
+ }
+
+ // Simple test by trying to connect to the Ollama endpoint
+ cfg := a.services.Config
+ endpoint := cfg.OllamaEndpoint + "/api/tags"
+
+ resp, err := http.Get(endpoint)
+ if err != nil {
+ return map[string]interface{}{
+ "success": false,
+ "error": err.Error(),
+ }, nil
+ }
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ // Log the error but don't fail the operation since we're testing connectivity
+ fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", err)
+ }
+ }()
+
+ if resp.StatusCode == 200 {
+ return map[string]interface{}{
+ "success": true,
+ "message": "Successfully connected to Ollama",
+ }, nil
+ }
+
+ return map[string]interface{}{
+ "success": false,
+ "error": fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status),
+ }, nil
+}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..4fbefad
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,576 @@
+
+
+
+
+
+ ML Notes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to ML Notes
+
Start creating notes to organize your thoughts and ideas.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to ML Notes
+
It looks like this is your first time running ML Notes. Let's set up your configuration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..26749c0
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,13 @@
+{
+ "name": "ml-notes-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ml-notes-frontend",
+ "version": "1.0.0",
+ "devDependencies": {}
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..aab4ae6
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "ml-notes-frontend",
+ "version": "1.0.0",
+ "description": "ML Notes frontend",
+ "main": "index.html",
+ "scripts": {
+ "build": "echo 'No build step required'",
+ "dev": "echo 'No dev server required'"
+ },
+ "dependencies": {},
+ "devDependencies": {}
+}
\ No newline at end of file
diff --git a/frontend/static/css/styles.css b/frontend/static/css/styles.css
new file mode 100644
index 0000000..1d56d2f
--- /dev/null
+++ b/frontend/static/css/styles.css
@@ -0,0 +1,1543 @@
+/* Reset and base styles */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+/* Utility classes for performance */
+.hidden {
+ display: none !important;
+}
+
+body {
+ font-family: 'Crimson Text', 'Libre Baskerville', 'Merriweather', Georgia, serif;
+ line-height: 1.7;
+ transition: background-color 0.3s ease, color 0.3s ease;
+ overflow: hidden;
+ background: var(--paper-base);
+ background-image:
+ radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.2) 0%, transparent 50%),
+ radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.15) 0%, transparent 50%),
+ radial-gradient(circle at 40% 40%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
+ /* Subtle paper texture */
+ repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 1px,
+ rgba(255, 255, 255, 0.03) 1px,
+ rgba(255, 255, 255, 0.03) 2px
+ );
+}
+
+/* Paper texture and shadows */
+.paper-texture {
+ background-image:
+ /* Subtle fiber texture */
+ repeating-linear-gradient(
+ 45deg,
+ transparent,
+ transparent 0.5px,
+ rgba(var(--paper-fiber), 0.02) 0.5px,
+ rgba(var(--paper-fiber), 0.02) 1px
+ ),
+ repeating-linear-gradient(
+ -45deg,
+ transparent,
+ transparent 0.5px,
+ rgba(var(--paper-fiber), 0.01) 0.5px,
+ rgba(var(--paper-fiber), 0.01) 1px
+ );
+}
+
+.paper-shadow {
+ box-shadow:
+ 0 2px 8px rgba(var(--shadow-color), 0.08),
+ 0 1px 3px rgba(var(--shadow-color), 0.12),
+ inset 0 0 0 1px rgba(255, 255, 255, 0.1);
+}
+
+.paper-shadow-lg {
+ box-shadow:
+ 0 8px 25px rgba(var(--shadow-color), 0.12),
+ 0 3px 10px rgba(var(--shadow-color), 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.15),
+ inset 0 -1px 0 rgba(var(--shadow-color), 0.05);
+}
+
+.paper-page {
+ background: var(--paper-bg);
+ background-image:
+ /* Paper grain */
+ radial-gradient(circle at 25% 25%, rgba(var(--paper-fiber), 0.02) 0%, transparent 50%),
+ radial-gradient(circle at 75% 75%, rgba(var(--paper-fiber), 0.015) 0%, transparent 50%),
+ radial-gradient(circle at 50% 10%, rgba(var(--paper-fiber), 0.01) 0%, transparent 30%);
+}
+
+/* Layout */
+.app-container {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-header {
+ height: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 24px;
+ border-bottom: 2px solid var(--border-color);
+ background: var(--header-bg);
+ backdrop-filter: blur(8px);
+ z-index: 100;
+ box-shadow:
+ 0 2px 12px rgba(var(--shadow-color), 0.08),
+ inset 0 -1px 0 rgba(255, 255, 255, 0.1);
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+
+.app-title {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.stats {
+ display: flex;
+ gap: 16px;
+}
+
+.stat-item {
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 4px 8px;
+ background: var(--bg-secondary);
+ border-radius: 12px;
+ border: 1px solid var(--border-color);
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.app-body {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+}
+
+/* Sidebar */
+.sidebar {
+ width: 320px;
+ background: var(--sidebar-bg);
+ border-right: 2px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow:
+ inset -2px 0 8px rgba(var(--shadow-color), 0.06),
+ 2px 0 12px rgba(var(--shadow-color), 0.08);
+}
+
+.sidebar-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-primary);
+}
+
+.sidebar-header h3 {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 12px;
+}
+
+.tag-filter {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--input-bg);
+ color: var(--text-primary);
+ font-size: 14px;
+}
+
+.notes-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.note-item {
+ padding: 14px 16px;
+ margin-bottom: 10px;
+ border-radius: 12px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ border: 1px solid var(--border-light);
+ background: var(--note-item-bg);
+ box-shadow:
+ 0 1px 3px rgba(var(--shadow-color), 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ position: relative;
+}
+
+.note-item:hover {
+ background: var(--note-item-hover);
+ border-color: var(--border-hover);
+ transform: translateY(-2px);
+ box-shadow:
+ 0 4px 12px rgba(var(--shadow-color), 0.12),
+ 0 2px 4px rgba(var(--shadow-color), 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.15);
+}
+
+.note-item.active {
+ background: var(--note-item-active);
+ border-color: var(--accent-soft);
+ box-shadow:
+ 0 6px 20px rgba(var(--accent-rgb), 0.15),
+ 0 2px 8px rgba(var(--accent-rgb), 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.2),
+ inset -2px 0 0 var(--accent-soft);
+}
+
+.note-title {
+ font-weight: 500;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.note-preview {
+ font-size: 13px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ margin-bottom: 8px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.note-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+
+.note-date {
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+.note-tags {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+/* Main Content */
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--main-bg);
+}
+
+.editor-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.note-editor {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 32px 40px;
+ background: var(--paper-bg);
+ overflow: hidden;
+ margin: 20px;
+ border-radius: 12px;
+ box-shadow:
+ 0 8px 32px rgba(var(--shadow-color), 0.12),
+ 0 2px 8px rgba(var(--shadow-color), 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ border: 1px solid var(--border-soft);
+ position: relative;
+}
+
+.note-editor::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 80px;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.editor-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.note-title-input {
+ flex: 1;
+ font-size: 28px;
+ font-weight: 600;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ padding: 12px 16px;
+ position: relative;
+ z-index: 2;
+ letter-spacing: -0.01em;
+ font-family: 'Crimson Text', 'Libre Baskerville', Georgia, serif;
+ font-style: normal;
+}
+
+.note-title-input:focus {
+ outline: none;
+ box-shadow: inset 0 -2px 0 var(--accent-soft);
+}
+
+.editor-actions {
+ display: flex;
+ gap: 8px;
+ transition: all 0.3s ease;
+}
+
+/* Responsive actions styles */
+.editor-actions .btn {
+ transition: all 0.2s ease;
+}
+
+.editor-actions.expanded {
+ flex-direction: column;
+ gap: 6px;
+}
+
+.editor-actions.expanded .btn {
+ width: 100%;
+ justify-content: center;
+}
+
+.more-actions-btn {
+ white-space: nowrap;
+}
+
+.tags-section {
+ margin-bottom: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.tags-section label {
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+}
+
+.tags-input-container {
+ margin-bottom: 12px;
+}
+
+.tags-input-container input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--input-bg);
+ color: var(--text-primary);
+ font-size: 14px;
+}
+
+.current-tags {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.editor-area {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ overflow: hidden;
+}
+
+.note-textarea {
+ flex: 1;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 16px;
+ line-height: 1.9;
+ padding: 20px 16px;
+ resize: none;
+ font-family: 'Crimson Text', 'Libre Baskerville', Georgia, serif;
+ font-weight: 400;
+ border-radius: 0;
+ background: var(--textarea-bg);
+ border: none;
+ position: relative;
+ z-index: 2;
+ letter-spacing: 0.01em;
+}
+
+.note-textarea:focus {
+ outline: none;
+ box-shadow: inset 0 0 0 2px rgba(var(--accent-rgb), 0.2);
+}
+
+.note-preview-area {
+ flex: 1;
+ padding: 20px 16px;
+ background: var(--textarea-bg);
+ border: none;
+ border-radius: 0;
+ overflow-y: auto;
+ font-size: 16px;
+ line-height: 1.9;
+ font-family: 'Crimson Text', 'Libre Baskerville', Georgia, serif;
+ color: var(--text-primary);
+}
+
+/* Markdown Preview Styles */
+.note-preview-area h1,
+.note-preview-area h2,
+.note-preview-area h3,
+.note-preview-area h4,
+.note-preview-area h5,
+.note-preview-area h6 {
+ font-family: 'Crimson Text', 'Libre Baskerville', Georgia, serif;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 1.5em 0 0.5em 0;
+ line-height: 1.3;
+}
+
+.note-preview-area h1 {
+ font-size: 2em;
+ border-bottom: 2px solid var(--border-color);
+ padding-bottom: 0.3em;
+ margin-bottom: 1em;
+}
+
+.note-preview-area h2 {
+ font-size: 1.6em;
+ border-bottom: 1px solid var(--border-light);
+ padding-bottom: 0.2em;
+}
+
+.note-preview-area h3 {
+ font-size: 1.3em;
+}
+
+.note-preview-area h4 {
+ font-size: 1.1em;
+}
+
+.note-preview-area h5,
+.note-preview-area h6 {
+ font-size: 1em;
+ color: var(--text-secondary);
+}
+
+.note-preview-area p {
+ margin: 1em 0;
+ line-height: 1.9;
+}
+
+.note-preview-area blockquote {
+ margin: 1em 0;
+ padding: 0.5em 1em;
+ border-left: 4px solid var(--accent-soft);
+ background: var(--bg-secondary);
+ border-radius: 0 4px 4px 0;
+ font-style: italic;
+ color: var(--text-secondary);
+}
+
+.note-preview-area ul,
+.note-preview-area ol {
+ margin: 1em 0;
+ padding-left: 2em;
+}
+
+.note-preview-area li {
+ margin: 0.5em 0;
+ line-height: 1.7;
+}
+
+.note-preview-area ul li {
+ list-style-type: disc;
+}
+
+.note-preview-area ol li {
+ list-style-type: decimal;
+}
+
+.note-preview-area code {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 0.2em 0.4em;
+ font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
+ font-size: 0.9em;
+ color: var(--text-primary);
+}
+
+.note-preview-area pre {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 1em;
+ overflow-x: auto;
+ margin: 1em 0;
+ box-shadow: inset 0 1px 3px rgba(var(--shadow-color), 0.1);
+}
+
+.note-preview-area pre code {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 0.85em;
+ line-height: 1.5;
+}
+
+.note-preview-area table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1em 0;
+ border: 1px solid var(--border-color);
+}
+
+.note-preview-area th,
+.note-preview-area td {
+ border: 1px solid var(--border-color);
+ padding: 0.5em 1em;
+ text-align: left;
+}
+
+.note-preview-area th {
+ background: var(--bg-secondary);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.note-preview-area td {
+ background: var(--paper-bg);
+}
+
+.note-preview-area a {
+ color: var(--accent-color);
+ text-decoration: underline;
+ text-decoration-color: var(--accent-soft);
+ transition: color 0.2s ease;
+}
+
+.note-preview-area a:hover {
+ color: var(--accent-hover);
+ text-decoration-color: var(--accent-color);
+}
+
+.note-preview-area strong,
+.note-preview-area b {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.note-preview-area em,
+.note-preview-area i {
+ font-style: italic;
+}
+
+.note-preview-area hr {
+ border: none;
+ border-top: 2px solid var(--border-color);
+ margin: 2em 0;
+ opacity: 0.6;
+}
+
+.note-preview-area img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(var(--shadow-color), 0.1);
+ margin: 1em 0;
+}
+
+.note-preview-area del,
+.note-preview-area s {
+ text-decoration: line-through;
+ color: var(--text-tertiary);
+}
+
+.note-preview-area mark {
+ background: rgba(var(--accent-rgb), 0.2);
+ padding: 0.1em 0.2em;
+ border-radius: 2px;
+}
+
+/* Welcome Screen */
+.welcome-screen {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px;
+ background: var(--paper-bg);
+}
+
+.welcome-content {
+ text-align: center;
+ max-width: 600px;
+}
+
+.welcome-content h2 {
+ font-size: 32px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 16px;
+}
+
+.welcome-content p {
+ font-size: 18px;
+ color: var(--text-secondary);
+ margin-bottom: 48px;
+}
+
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 24px;
+ margin-bottom: 48px;
+}
+
+.feature-card {
+ padding: 24px;
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ text-align: center;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.feature-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.feature-icon {
+ font-size: 32px;
+ margin-bottom: 16px;
+}
+
+.feature-card h3 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+}
+
+.feature-card p {
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+/* Tags */
+.tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ background: var(--tag-bg);
+ color: var(--tag-text);
+ border: 1px solid var(--tag-border);
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.tag.removable {
+ cursor: pointer;
+ padding-right: 4px;
+}
+
+.tag-remove {
+ margin-left: 4px;
+ color: var(--text-tertiary);
+ font-weight: bold;
+}
+
+.tag-remove:hover {
+ color: var(--danger-color);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 20px;
+ min-width: 80px;
+ border: 1px solid var(--border-soft);
+ border-radius: 12px;
+ background: var(--button-bg);
+ color: var(--button-text);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ text-decoration: none;
+ text-align: center;
+ justify-content: center;
+ white-space: nowrap;
+ box-shadow:
+ 0 2px 6px rgba(var(--shadow-color), 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ position: relative;
+}
+
+.btn:hover {
+ background: var(--button-hover);
+ transform: translateY(-2px);
+ box-shadow:
+ 0 4px 12px rgba(var(--shadow-color), 0.12),
+ 0 2px 4px rgba(var(--shadow-color), 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.15);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+.btn-primary {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.btn-secondary:hover {
+ background: var(--button-hover);
+ border-color: var(--border-hover);
+}
+
+
+.btn-danger {
+ background: var(--danger-color);
+ color: white;
+ border-color: var(--danger-color);
+}
+
+.btn-danger:hover {
+ background: var(--danger-hover);
+ border-color: var(--danger-hover);
+}
+
+.btn-icon-only {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ justify-content: center;
+}
+
+.btn-large {
+ padding: 12px 24px;
+ font-size: 16px;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none !important;
+}
+
+.btn-icon {
+ font-size: 16px;
+}
+
+/* Search */
+.search-container {
+ display: flex;
+ align-items: center;
+ position: relative;
+}
+
+.search-input {
+ width: 240px;
+ padding: 8px 40px 8px 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
+ background: var(--input-bg);
+ color: var(--text-primary);
+ font-size: 14px;
+ transition: all 0.2s ease;
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
+ width: 280px;
+}
+
+.search-btn {
+ position: absolute;
+ right: 4px;
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s ease;
+}
+
+.search-btn:hover {
+ background: var(--bg-secondary);
+}
+
+/* Modals */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+ z-index: 1000;
+}
+
+.modal {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--modal-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+ z-index: 1001;
+ max-width: 90vw;
+ max-height: 90vh;
+ overflow: hidden;
+}
+
+.modal-content {
+ display: flex;
+ flex-direction: column;
+ max-height: 90vh;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.modal-header h3 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ font-size: 24px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+}
+
+.modal-close:hover {
+ background: var(--bg-secondary);
+}
+
+.modal-body {
+ padding: 24px;
+ overflow-y: auto;
+}
+
+.modal-actions {
+ display: flex;
+ gap: 16px;
+ justify-content: flex-end;
+ align-items: center;
+ padding: 16px 24px;
+ border-top: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ flex-wrap: wrap;
+ min-height: 60px;
+}
+
+/* Analysis Modal */
+.analysis-options {
+ margin-bottom: 24px;
+}
+
+.analysis-options label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ font-size: 14px;
+ color: var(--text-primary);
+ cursor: pointer;
+}
+
+.analysis-options input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+}
+
+.prompt-input {
+ margin-top: 16px;
+}
+
+.prompt-input label {
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+}
+
+.prompt-input textarea {
+ width: 100%;
+ min-width: 400px;
+ height: 80px;
+ padding: 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--input-bg);
+ color: var(--text-primary);
+ font-size: 14px;
+ resize: vertical;
+}
+
+.analysis-result {
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+.analysis-content {
+ padding: 20px 16px;
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ margin-bottom: 16px;
+ font-size: 16px;
+ line-height: 1.9;
+ font-family: 'Crimson Text', 'Libre Baskerville', Georgia, serif;
+ color: var(--text-primary);
+}
+
+/* Apply markdown preview styles to analysis content */
+.analysis-content h1,
+.analysis-content h2,
+.analysis-content h3,
+.analysis-content h4,
+.analysis-content h5,
+.analysis-content h6 {
+ font-family: 'Crimson Text', 'Libre Baskerville', Georgia, serif;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 1.5em 0 0.5em 0;
+ line-height: 1.3;
+}
+
+.analysis-content h1 {
+ font-size: 2em;
+ border-bottom: 2px solid var(--border-color);
+ padding-bottom: 0.3em;
+ margin-bottom: 1em;
+}
+
+.analysis-content h2 {
+ font-size: 1.6em;
+ border-bottom: 1px solid var(--border-light);
+ padding-bottom: 0.2em;
+}
+
+.analysis-content h3 {
+ font-size: 1.3em;
+}
+
+.analysis-content h4 {
+ font-size: 1.1em;
+}
+
+.analysis-content h5,
+.analysis-content h6 {
+ font-size: 1em;
+ color: var(--text-secondary);
+}
+
+.analysis-content p {
+ margin: 1em 0;
+ line-height: 1.9;
+}
+
+.analysis-content blockquote {
+ margin: 1em 0;
+ padding: 0.5em 1em;
+ border-left: 4px solid var(--accent-soft);
+ background: var(--paper-bg);
+ border-radius: 0 4px 4px 0;
+ font-style: italic;
+ color: var(--text-secondary);
+}
+
+.analysis-content ul,
+.analysis-content ol {
+ margin: 1em 0;
+ padding-left: 2em;
+}
+
+.analysis-content li {
+ margin: 0.5em 0;
+ line-height: 1.7;
+}
+
+.analysis-content ul li {
+ list-style-type: disc;
+}
+
+.analysis-content ol li {
+ list-style-type: decimal;
+}
+
+.analysis-content code {
+ background: var(--paper-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 0.2em 0.4em;
+ font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
+ font-size: 0.9em;
+ color: var(--text-primary);
+}
+
+.analysis-content pre {
+ background: var(--paper-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 1em;
+ overflow-x: auto;
+ margin: 1em 0;
+ box-shadow: inset 0 1px 3px rgba(var(--shadow-color), 0.1);
+}
+
+.analysis-content pre code {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 0.85em;
+ line-height: 1.5;
+}
+
+.analysis-content table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1em 0;
+ border: 1px solid var(--border-color);
+}
+
+.analysis-content th,
+.analysis-content td {
+ border: 1px solid var(--border-color);
+ padding: 0.5em 1em;
+ text-align: left;
+}
+
+.analysis-content th {
+ background: var(--paper-bg);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.analysis-content td {
+ background: var(--bg-secondary);
+}
+
+.analysis-content a {
+ color: var(--accent-color);
+ text-decoration: underline;
+ text-decoration-color: var(--accent-soft);
+ transition: color 0.2s ease;
+}
+
+.analysis-content a:hover {
+ color: var(--accent-hover);
+ text-decoration-color: var(--accent-color);
+}
+
+.analysis-content strong,
+.analysis-content b {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.analysis-content em,
+.analysis-content i {
+ font-style: italic;
+}
+
+.analysis-content hr {
+ border: none;
+ border-top: 2px solid var(--border-color);
+ margin: 2em 0;
+ opacity: 0.6;
+}
+
+.analysis-content img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(var(--shadow-color), 0.1);
+ margin: 1em 0;
+}
+
+.analysis-content del,
+.analysis-content s {
+ text-decoration: line-through;
+ color: var(--text-tertiary);
+}
+
+.analysis-content mark {
+ background: rgba(var(--accent-rgb), 0.2);
+ padding: 0.1em 0.2em;
+ border-radius: 2px;
+}
+
+/* Empty state */
+.empty-state {
+ padding: 48px 16px;
+ text-align: center;
+ color: var(--text-secondary);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .app-header {
+ padding: 0 16px;
+ }
+
+ .header-left {
+ gap: 16px;
+ }
+
+ .stats {
+ display: none;
+ }
+
+ .sidebar {
+ width: 280px;
+ }
+
+ .search-input {
+ width: 200px;
+ }
+
+ .search-input:focus {
+ width: 220px;
+ }
+
+ .note-editor {
+ padding: 16px;
+ }
+
+ .welcome-content {
+ padding: 24px;
+ }
+
+ .feature-grid {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ }
+}
+
+/* Responsive Design - Enhanced for better button handling */
+
+/* Medium screens - stack title and actions */
+@media (max-width: 900px) {
+ .editor-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+ }
+
+ .editor-actions {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ .btn {
+ font-size: 14px;
+ padding: 8px 12px;
+ }
+}
+
+/* Small screens - more compact layout */
+@media (max-width: 640px) {
+ .sidebar {
+ width: 240px;
+ }
+
+ .editor-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+ }
+
+ .editor-actions {
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .editor-actions .btn {
+ flex: 1;
+ min-width: calc(50% - 4px);
+ text-align: center;
+ font-size: 13px;
+ padding: 8px 6px;
+ }
+
+ .search-container {
+ display: none;
+ }
+
+ /* Hide less essential buttons on very small screens */
+ .editor-actions .btn-secondary {
+ min-width: calc(33.333% - 6px);
+ flex: 0 1 auto;
+ }
+}
+
+/* Very small screens - dropdown menu for actions */
+@media (max-width: 480px) {
+ .app-container {
+ flex-direction: column;
+ }
+
+ .sidebar {
+ width: 100%;
+ height: 200px;
+ border-right: none;
+ border-bottom: 1px solid var(--border-color);
+ overflow-y: auto;
+ }
+
+ .main-content {
+ flex: 1;
+ min-height: 0;
+ }
+
+ .editor-actions {
+ position: relative;
+ justify-content: space-between;
+ flex-wrap: nowrap;
+ }
+
+ .editor-actions .btn {
+ font-size: 12px;
+ padding: 6px 8px;
+ }
+
+ /* Hide secondary actions by default on very small screens */
+ .editor-actions .btn-secondary:not(.more-actions-btn) {
+ display: none;
+ }
+
+ /* Show all actions when expanded */
+ .editor-actions.expanded .btn-secondary:not(.more-actions-btn) {
+ display: flex;
+ width: 100%;
+ margin-bottom: 4px;
+ }
+
+ /* More actions button styling for small screens */
+ .more-actions-btn {
+ display: block !important;
+ background: var(--btn-secondary-bg);
+ color: var(--btn-secondary-text);
+ border: 1px solid var(--btn-secondary-border);
+ min-width: auto;
+ flex: 0 0 auto;
+ }
+
+ /* Ensure primary actions stay visible */
+ .editor-actions .btn-primary,
+ .editor-actions .btn-danger {
+ flex: 1;
+ min-width: auto;
+ }
+
+ /* Expanded state styling */
+ .editor-actions.expanded {
+ flex-direction: column;
+ align-items: stretch;
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 8px;
+ margin-top: 4px;
+ box-shadow: 0 4px 12px rgba(var(--shadow-color), 0.15);
+ }
+}
+
+/* Large screens - reset to horizontal layout */
+@media (min-width: 901px) {
+ .more-actions-btn {
+ display: none !important;
+ }
+}
+
+/* Settings Page Styles */
+.settings-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: var(--paper-bg);
+ z-index: 1000;
+ padding: 20px;
+ overflow-y: auto;
+ box-sizing: border-box;
+}
+
+.settings-container > div {
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+/* Initialization Page Styles */
+.init-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: var(--paper-bg);
+ z-index: 1001;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ box-sizing: border-box;
+}
+
+.init-content {
+ max-width: 500px;
+ text-align: center;
+ background: var(--paper-card);
+ padding: 40px;
+ border-radius: 8px;
+ box-shadow: var(--shadow-elevated);
+}
+
+.settings-title {
+ font-size: 2rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 8px 0;
+}
+
+.settings-description {
+ color: var(--text-secondary);
+ margin: 0 0 32px 0;
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+.settings-form {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+}
+
+.settings-section {
+ background: var(--surface-color);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 24px;
+ box-shadow: 0 2px 8px rgba(var(--shadow-color), 0.08);
+}
+
+.section-title {
+ font-size: 1.3rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 16px 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group:last-child {
+ margin-bottom: 0;
+}
+
+.form-label {
+ display: block;
+ font-weight: 500;
+ color: var(--text-primary);
+ margin-bottom: 6px;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.form-label input[type="checkbox"] {
+ margin-right: 8px;
+ transform: scale(1.1);
+}
+
+.form-input {
+ width: 100%;
+ padding: 12px 16px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ font-size: 0.9rem;
+ font-family: inherit;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ transition: border-color 0.2s, box-shadow 0.2s;
+ box-sizing: border-box;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 3px rgba(var(--accent-color-rgb), 0.1);
+}
+
+.form-input:disabled,
+.readonly-input {
+ background: var(--surface-color);
+ color: var(--text-secondary);
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+
+.form-help {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ margin: 4px 0 0 0;
+ line-height: 1.4;
+}
+
+.readonly-group {
+ opacity: 0.8;
+}
+
+.settings-actions {
+ display: flex;
+ gap: 16px;
+ justify-content: flex-start;
+ align-items: center;
+ padding-top: 24px;
+ border-top: 1px solid var(--border-color);
+ flex-wrap: wrap;
+}
+
+
+/* Button variations for settings */
+.btn-outline {
+ background: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+}
+
+.btn-outline:hover {
+ background: var(--surface-color);
+ border-color: var(--accent-color);
+}
+
+.btn-secondary {
+ background: var(--surface-color);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+}
+
+.btn-secondary:hover {
+ background: var(--hover-color);
+}
+
+/* Settings page responsive design */
+@media (max-width: 768px) {
+ .settings-container {
+ padding: 16px;
+ }
+
+ .settings-section {
+ padding: 20px;
+ }
+
+ .settings-title {
+ font-size: 1.6rem;
+ }
+
+ .section-title {
+ font-size: 1.1rem;
+ }
+
+ .settings-actions {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .settings-actions .btn {
+ width: 100%;
+ justify-content: center;
+ }
+}
\ No newline at end of file
diff --git a/frontend/static/css/themes.css b/frontend/static/css/themes.css
new file mode 100644
index 0000000..bd23205
--- /dev/null
+++ b/frontend/static/css/themes.css
@@ -0,0 +1,357 @@
+/* Light Theme (Default) - Soft Paper */
+[data-theme="light"] {
+ /* Paper base and textures */
+ --paper-base: #f8f6f2;
+ --paper-fiber: 140, 120, 100;
+ --shadow-color: 120, 100, 80;
+ --ruled-line: 180, 160, 140;
+ --margin-line: rgba(180, 160, 140, 0.3);
+
+ /* Background colors */
+ --bg-primary: #f8f6f2;
+ --bg-secondary: #f3f0eb;
+ --sidebar-bg: #f5f2ee;
+ --main-bg: #ede9e3;
+ --paper-bg: #f9f7f3;
+ --card-bg: #f9f7f3;
+ --header-bg: rgba(248, 246, 242, 0.95);
+ --modal-bg: #f9f7f3;
+
+ /* Text colors - softer, more ink-like */
+ --text-primary: #3c3529;
+ --text-secondary: #6b5f4f;
+ --text-tertiary: #a8988b;
+
+ /* Border colors - muted and soft */
+ --border-color: #e0d8cc;
+ --border-soft: #e8e0d4;
+ --border-light: #ede6da;
+ --border-hover: #d4c8bc;
+ --input-bg: #f9f7f3;
+ --textarea-bg: transparent;
+
+ /* Note item colors */
+ --note-item-bg: #f9f7f3;
+ --note-item-hover: #f3f0eb;
+ --note-item-active: #ede5d9;
+
+ /* Button colors */
+ --button-bg: #f3f0eb;
+ --button-text: #4a3f35;
+ --button-hover: #ede5d9;
+
+ /* Accent colors - muted lavender */
+ --accent-color: #a991c7;
+ --accent-soft: #c5b3d9;
+ --accent-hover: #9480b8;
+ --accent-rgb: 169, 145, 199;
+ --success-color: #7db87a;
+ --success-hover: #6ba967;
+ --danger-color: #d4857a;
+ --danger-hover: #c76e61;
+
+ /* Tag colors */
+ --tag-bg: #e8e2d6;
+ --tag-text: #6b5f4f;
+ --tag-border: #d4c8bc;
+}
+
+/* Dark Theme - Evening Paper */
+[data-theme="dark"] {
+ /* Paper base and textures */
+ --paper-base: #2a2520;
+ --paper-fiber: 80, 70, 65;
+ --shadow-color: 20, 15, 10;
+ --ruled-line: 100, 90, 80;
+ --margin-line: rgba(120, 100, 80, 0.2);
+
+ /* Background colors */
+ --bg-primary: #2a2520;
+ --bg-secondary: #332d26;
+ --sidebar-bg: #2d2823;
+ --main-bg: #252017;
+ --paper-bg: #312b24;
+ --card-bg: #312b24;
+ --header-bg: rgba(42, 37, 32, 0.95);
+ --modal-bg: #312b24;
+
+ /* Text colors - soft, readable */
+ --text-primary: #e8e2d9;
+ --text-secondary: #c7bfb3;
+ --text-tertiary: #a69c8f;
+
+ /* Border colors - subtle */
+ --border-color: #4a423a;
+ --border-soft: #3f3730;
+ --border-light: #373028;
+ --border-hover: #5a5048;
+ --input-bg: #312b24;
+ --textarea-bg: transparent;
+
+ /* Note item colors */
+ --note-item-bg: #3a342b;
+ --note-item-hover: #423c32;
+ --note-item-active: #4a4036;
+
+ /* Button colors */
+ --button-bg: #3a342b;
+ --button-text: #e8e2d9;
+ --button-hover: #423c32;
+
+ /* Accent colors - muted purple */
+ --accent-color: #a088b5;
+ --accent-soft: #8c7ba3;
+ --accent-hover: #b299c7;
+ --accent-rgb: 160, 136, 181;
+ --success-color: #8db88a;
+ --success-hover: #9fc49c;
+ --danger-color: #c78a7a;
+ --danger-hover: #d69985;
+
+ /* Tag colors */
+ --tag-bg: #3f3730;
+ --tag-text: #c7bfb3;
+ --tag-border: #4a423a;
+}
+
+/* Paper Theme (Warm) - Vintage Notebook */
+[data-theme="paper"] {
+ /* Paper base and textures */
+ --paper-base: #fbf8f1;
+ --paper-fiber: 140, 120, 90;
+ --shadow-color: 120, 100, 70;
+ --ruled-line: 180, 160, 130;
+ --margin-line: rgba(180, 140, 100, 0.4);
+
+ /* Background colors */
+ --bg-primary: #fbf8f1;
+ --bg-secondary: #f6f2e8;
+ --sidebar-bg: #f8f4ea;
+ --main-bg: #f1ede0;
+ --paper-bg: #fcf9f2;
+ --card-bg: #fcf9f2;
+ --header-bg: rgba(251, 248, 241, 0.95);
+ --modal-bg: #fcf9f2;
+
+ /* Text colors */
+ --text-primary: #3d3426;
+ --text-secondary: #6b5d4a;
+ --text-tertiary: #9a8c78;
+
+ /* Border colors */
+ --border-color: #e0d5c3;
+ --border-soft: #ebe1d1;
+ --border-light: #f1e8d8;
+ --border-hover: #d4c7b3;
+ --input-bg: #fcf9f2;
+ --textarea-bg: transparent;
+
+ /* Note item colors */
+ --note-item-bg: #fcf9f2;
+ --note-item-hover: #f6f2e8;
+ --note-item-active: #f0e6d2;
+
+ /* Button colors */
+ --button-bg: #f6f2e8;
+ --button-text: #4a3f2f;
+ --button-hover: #f0e6d2;
+
+ /* Accent colors - warm amber */
+ --accent-color: #d4955a;
+ --accent-soft: #e1b377;
+ --accent-hover: #c78245;
+ --accent-rgb: 212, 149, 90;
+ --success-color: #8fb85a;
+ --success-hover: #7da447;
+ --danger-color: #c87561;
+ --danger-hover: #b5654d;
+
+ /* Tag colors */
+ --tag-bg: #ebe1d1;
+ --tag-text: #6b5d4a;
+ --tag-border: #d4c7b3;
+}
+
+/* Sepia Theme */
+[data-theme="sepia"] {
+ /* Background colors */
+ --bg-primary: #f7f3e8;
+ --bg-secondary: #f1ede2;
+ --sidebar-bg: #f4f0e5;
+ --main-bg: #ede9de;
+ --paper-bg: #f7f3e8;
+ --card-bg: #f7f3e8;
+ --header-bg: rgba(247, 243, 232, 0.9);
+ --modal-bg: #f7f3e8;
+
+ /* Text colors */
+ --text-primary: #3c3525;
+ --text-secondary: #6b5f46;
+ --text-tertiary: #8d7f66;
+
+ /* Border and input colors */
+ --border-color: #d4c6a8;
+ --input-bg: #f7f3e8;
+ --textarea-bg: #f7f3e8;
+
+ /* Note item colors */
+ --note-item-bg: #f1ede2;
+ --note-item-hover: #ede9de;
+ --note-item-active: #e8e2d5;
+
+ /* Button colors */
+ --button-bg: #f1ede2;
+ --button-text: #4a422e;
+ --button-hover: #ede9de;
+
+ /* Accent colors */
+ --accent-color: #a16207;
+ --accent-hover: #92400e;
+ --success-color: #65a30d;
+ --success-hover: #4d7c0f;
+ --danger-color: #b91c1c;
+ --danger-hover: #991b1b;
+
+ /* Tag colors */
+ --tag-bg: #ede9de;
+ --tag-text: #6b5f46;
+ --tag-border: #c7b99c;
+}
+
+/* High Contrast Theme */
+[data-theme="high-contrast"] {
+ /* Background colors */
+ --bg-primary: #ffffff;
+ --bg-secondary: #f0f0f0;
+ --sidebar-bg: #ffffff;
+ --main-bg: #f8f8f8;
+ --paper-bg: #ffffff;
+ --card-bg: #ffffff;
+ --header-bg: rgba(255, 255, 255, 0.95);
+ --modal-bg: #ffffff;
+
+ /* Text colors */
+ --text-primary: #000000;
+ --text-secondary: #333333;
+ --text-tertiary: #666666;
+
+ /* Border and input colors */
+ --border-color: #000000;
+ --input-bg: #ffffff;
+ --textarea-bg: #ffffff;
+
+ /* Note item colors */
+ --note-item-bg: #ffffff;
+ --note-item-hover: #f0f0f0;
+ --note-item-active: #e0e0ff;
+
+ /* Button colors */
+ --button-bg: #ffffff;
+ --button-text: #000000;
+ --button-hover: #f0f0f0;
+
+ /* Accent colors */
+ --accent-color: #0000ff;
+ --accent-hover: #0000cc;
+ --success-color: #00aa00;
+ --success-hover: #008800;
+ --danger-color: #ff0000;
+ --danger-hover: #cc0000;
+
+ /* Tag colors */
+ --tag-bg: #f0f0f0;
+ --tag-text: #000000;
+ --tag-border: #000000;
+}
+
+/* Transitions for theme switching */
+* {
+ transition:
+ background-color 0.3s ease,
+ color 0.3s ease,
+ border-color 0.3s ease,
+ box-shadow 0.3s ease;
+}
+
+/* Disable transitions during theme initialization */
+.theme-transitioning *,
+.theme-initializing *,
+.theme-initializing *::before,
+.theme-initializing *::after {
+ transition: none !important;
+ animation: none !important;
+}
+
+/* Custom scrollbars for webkit browsers */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-secondary);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-tertiary);
+}
+
+/* Print styles */
+@media print {
+ [data-theme] {
+ --bg-primary: white;
+ --bg-secondary: white;
+ --text-primary: black;
+ --text-secondary: #333;
+ --border-color: #ccc;
+ }
+
+ .sidebar,
+ .app-header,
+ .editor-actions,
+ .modal-overlay,
+ .modal {
+ display: none !important;
+ }
+
+ .main-content {
+ width: 100% !important;
+ height: auto !important;
+ }
+
+ .note-editor {
+ padding: 0 !important;
+ box-shadow: none !important;
+ }
+
+ .note-textarea,
+ .note-preview-area {
+ border: none !important;
+ box-shadow: none !important;
+ background: transparent !important;
+ }
+}
+
+/* Focus styles for accessibility */
+:focus-visible {
+ outline: 2px solid var(--accent-color);
+ outline-offset: 2px;
+}
+
+/* Reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
\ No newline at end of file
diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js
new file mode 100644
index 0000000..231e714
--- /dev/null
+++ b/frontend/static/js/app.js
@@ -0,0 +1,1138 @@
+// ML Notes Web App JavaScript
+
+class MLNotesApp {
+ constructor() {
+ this.currentNoteId = null;
+ this.isNewNote = false;
+ this.isPreviewMode = false;
+ this.debounceTimer = null;
+ this.unsavedChanges = false;
+ this.notionEditor = null;
+
+ this.init();
+ }
+
+ init() {
+ this.setupEventListeners();
+ this.setupTheme();
+ this.loadCurrentNote();
+ this.setupAutoSave();
+ this.setupSearch();
+ this.setupMarkdownPreview();
+
+ // Initialize sliding panels if the function exists (from template)
+ if (typeof window.initializeSlidingPanels === 'function') {
+ window.initializeSlidingPanels();
+ }
+ }
+
+ setupEventListeners() {
+ // Theme toggle
+ document.getElementById('theme-toggle').addEventListener('click', () => {
+ this.toggleTheme();
+ });
+
+ // New note button
+ document.getElementById('new-note-btn').addEventListener('click', () => {
+ window.location.href = '/new';
+ });
+
+ // Create first note button (welcome screen)
+ const createFirstNoteBtn = document.getElementById('create-first-note');
+ if (createFirstNoteBtn) {
+ createFirstNoteBtn.addEventListener('click', () => {
+ window.location.href = '/new';
+ });
+ }
+
+ // Note list items
+ document.querySelectorAll('.note-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const noteId = parseInt(item.dataset.noteId);
+ this.loadNote(noteId);
+ });
+ });
+
+ // Editor controls
+ const saveBtn = document.getElementById('save-note');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', () => {
+ this.saveCurrentNote();
+ });
+ }
+
+ const deleteBtn = document.getElementById('delete-note');
+ if (deleteBtn) {
+ deleteBtn.addEventListener('click', () => {
+ this.deleteCurrentNote();
+ });
+ }
+
+ const previewBtn = document.getElementById('toggle-preview');
+ if (previewBtn) {
+ previewBtn.addEventListener('click', () => {
+ this.togglePreview();
+ });
+ }
+
+ const autoTagBtn = document.getElementById('auto-tag-btn');
+ if (autoTagBtn) {
+ autoTagBtn.addEventListener('click', () => {
+ this.autoTagNote();
+ });
+ }
+
+ const analyzeBtn = document.getElementById('analyze-btn');
+ if (analyzeBtn) {
+ analyzeBtn.addEventListener('click', () => {
+ this.showAnalysisModal();
+ });
+ }
+
+ // Content change tracking
+ const titleInput = document.getElementById('note-title');
+ const notionContent = document.getElementById('note-content');
+ const markdownSource = document.getElementById('markdown-source');
+ const tagsInput = document.getElementById('note-tags');
+
+ if (titleInput) {
+ titleInput.addEventListener('input', () => {
+ this.markUnsaved();
+ this.updateDocumentTitle();
+ });
+ }
+
+ // Initialize Enhanced Editor if elements exist
+ const noteContentTextarea = document.getElementById('note-content');
+ const notePreview = document.getElementById('note-preview');
+
+ if (noteContentTextarea && notePreview) {
+ this.enhancedEditor = new EnhancedEditor(noteContentTextarea, notePreview);
+
+ // Set up content change tracking for enhanced editor
+ noteContentTextarea.addEventListener('input', () => {
+ this.markUnsaved();
+ });
+ }
+
+ if (tagsInput) {
+ tagsInput.addEventListener('input', () => {
+ this.markUnsaved();
+ });
+ }
+
+ // Tag removal
+ document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('tag-remove')) {
+ const tag = e.target.closest('.tag');
+ const tagValue = tag.dataset.tag;
+ this.removeTag(tagValue);
+ }
+ });
+
+ // Search
+ const searchInput = document.getElementById('search-input');
+ const searchBtn = document.getElementById('search-btn');
+
+ if (searchInput) {
+ searchInput.addEventListener('input', (e) => {
+ this.performSearch(e.target.value);
+ });
+
+ searchInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ this.performSearch(e.target.value);
+ }
+ });
+ }
+
+ if (searchBtn) {
+ searchBtn.addEventListener('click', () => {
+ const query = searchInput.value;
+ this.performSearch(query);
+ });
+ }
+
+ // Tag filter
+ const tagFilter = document.getElementById('tag-filter');
+ if (tagFilter) {
+ tagFilter.addEventListener('change', (e) => {
+ this.filterByTag(e.target.value);
+ });
+ }
+
+ // Modal controls
+ this.setupModalControls();
+
+ // Keyboard shortcuts
+ this.setupKeyboardShortcuts();
+
+ // Before unload warning
+ window.addEventListener('beforeunload', (e) => {
+ if (this.unsavedChanges) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
+ }
+
+ setupModalControls() {
+ // Close modals
+ document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('modal-overlay') ||
+ e.target.classList.contains('modal-close')) {
+ this.closeModals();
+ }
+ });
+
+ // Analysis modal
+ const analysisWriteBack = document.getElementById('analysis-write-back');
+ const analysisWriteNew = document.getElementById('analysis-write-new');
+ const analysisTitleInput = document.getElementById('analysis-title-input');
+
+ if (analysisWriteBack && analysisWriteNew) {
+ analysisWriteBack.addEventListener('change', () => {
+ if (analysisWriteBack.checked) {
+ analysisWriteNew.checked = false;
+ analysisTitleInput.style.display = 'none';
+ }
+ });
+
+ analysisWriteNew.addEventListener('change', () => {
+ if (analysisWriteNew.checked) {
+ analysisWriteBack.checked = false;
+ analysisTitleInput.style.display = 'block';
+ } else {
+ analysisTitleInput.style.display = 'none';
+ }
+ });
+ }
+
+ const runAnalysisBtn = document.getElementById('run-analysis');
+ if (runAnalysisBtn) {
+ runAnalysisBtn.addEventListener('click', () => {
+ this.runAnalysis();
+ });
+ }
+
+ // Delete confirmation modal
+ const confirmDeleteBtn = document.getElementById('confirm-delete');
+ if (confirmDeleteBtn) {
+ confirmDeleteBtn.addEventListener('click', () => {
+ this.closeModals();
+ this.confirmDeleteNote();
+ });
+ }
+ }
+
+ setupKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ // Ctrl/Cmd + S = Save
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+ e.preventDefault();
+ this.saveCurrentNote();
+ }
+
+ // Ctrl/Cmd + N = New note
+ if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
+ e.preventDefault();
+ window.location.href = '/new';
+ }
+
+ // Ctrl/Cmd + P = Toggle preview
+ if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
+ e.preventDefault();
+ this.togglePreview();
+ }
+
+ // Ctrl/Cmd + / = Toggle theme
+ if ((e.ctrlKey || e.metaKey) && e.key === '/') {
+ e.preventDefault();
+ this.toggleTheme();
+ }
+
+ // Escape = Close modals
+ if (e.key === 'Escape') {
+ this.closeModals();
+ }
+ });
+ }
+
+ setupTheme() {
+ const savedTheme = localStorage.getItem('ml-notes-theme') || 'dark';
+ this.setTheme(savedTheme);
+
+ // Remove theme initialization class after DOM is fully loaded
+ // This re-enables transitions after the page has loaded
+ setTimeout(() => {
+ document.documentElement.classList.remove('theme-initializing');
+ }, 100);
+ }
+
+ setTheme(theme) {
+ // Set theme on both html and body for consistency
+ document.documentElement.setAttribute('data-theme', theme);
+ document.body.dataset.theme = theme;
+ document.documentElement.style.setProperty('color-scheme', theme === 'dark' ? 'dark' : 'light');
+ localStorage.setItem('ml-notes-theme', theme);
+
+ const themeIcon = document.getElementById('theme-icon');
+ if (themeIcon) {
+ themeIcon.textContent = theme === 'dark' ? 'โ๏ธ' : '๐';
+ }
+ }
+
+ toggleTheme() {
+ const currentTheme = document.body.dataset.theme;
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
+ this.setTheme(newTheme);
+ }
+
+ loadCurrentNote() {
+ const currentNoteId = document.getElementById('current-note-id');
+ const isNewNoteInput = document.getElementById('is-new-note');
+
+ if (currentNoteId && currentNoteId.value) {
+ this.currentNoteId = parseInt(currentNoteId.value) || null;
+ this.updateDocumentTitle();
+
+ // If we have a note ID but no content loaded (empty fields), load it from API
+ const titleInput = document.getElementById('note-title');
+ const contentTextarea = document.getElementById('note-content');
+
+ if (this.currentNoteId && titleInput && contentTextarea) {
+ // Check if content is empty (server didn't load it)
+ if (!titleInput.value && !contentTextarea.value) {
+ this.loadNoteFromAPI(this.currentNoteId);
+ }
+ }
+ }
+
+ if (isNewNoteInput) {
+ this.isNewNote = isNewNoteInput.value === 'true';
+ }
+ }
+
+ async loadNoteFromAPI(noteId) {
+ try {
+ const response = await fetch(`/api/v1/notes/${noteId}`);
+ const data = await response.json();
+
+ if (data.success && data.data) {
+ const note = data.data;
+
+ // Populate the form fields
+ const titleInput = document.getElementById('note-title');
+ const contentTextarea = document.getElementById('note-content');
+ const tagsInput = document.getElementById('note-tags');
+
+ if (titleInput) titleInput.value = note.title || '';
+ if (contentTextarea) contentTextarea.value = note.content || '';
+ if (tagsInput && note.tags) tagsInput.value = note.tags.join(', ');
+
+ // Update tags display
+ if (note.tags) {
+ this.updateCurrentTags(note.tags);
+ }
+
+ this.updateDocumentTitle();
+
+ // Update preview if enhanced editor is available
+ if (this.enhancedEditor) {
+ this.enhancedEditor.updatePreview();
+ }
+ } else {
+ console.error('Failed to load note:', data.error);
+ this.showNotification('Failed to load note', 'error');
+ }
+ } catch (error) {
+ console.error('Error loading note from API:', error);
+ this.showNotification('Failed to load note', 'error');
+ }
+ }
+
+ updateDocumentTitle() {
+ const titleInput = document.getElementById('note-title');
+ if (titleInput && titleInput.value) {
+ document.title = `${titleInput.value} - ML Notes`;
+ } else {
+ document.title = 'ML Notes';
+ }
+ }
+
+ async loadNote(noteId) {
+ try {
+ const response = await fetch(`/api/v1/notes/${noteId}`);
+ const data = await response.json();
+
+ if (data.success) {
+ window.location.href = `/note/${noteId}`;
+ } else {
+ this.showNotification('Failed to load note', 'error');
+ }
+ } catch (error) {
+ console.error('Error loading note:', error);
+ this.showNotification('Failed to load note', 'error');
+ }
+ }
+
+
+ async saveCurrentNote() {
+ const titleInput = document.getElementById('note-title');
+ const tagsInput = document.getElementById('note-tags');
+
+ if (!titleInput) return;
+
+ if (!titleInput.value.trim()) {
+ this.showNotification('Please enter a title for your note', 'warning');
+ titleInput.focus();
+ return;
+ }
+
+ // Get content from enhanced editor
+ let content = '';
+ if (this.enhancedEditor) {
+ content = this.enhancedEditor.getContent();
+ } else {
+ // Fallback to direct textarea access
+ const contentTextarea = document.getElementById('note-content');
+ content = contentTextarea ? contentTextarea.value : '';
+ }
+
+
+ const noteData = {
+ title: titleInput.value,
+ content: content,
+ tags: tagsInput ? tagsInput.value : '',
+ auto_tag: false,
+ };
+
+ try {
+ let response;
+ let url = '';
+
+ if (this.isNewNote || !this.currentNoteId) {
+ // Create new note
+ url = '/api/v1/notes';
+ response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(noteData)
+ });
+ } else {
+ // Update existing note
+ url = `/api/v1/notes/${this.currentNoteId}`;
+ response = await fetch(url, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(noteData)
+ });
+ }
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.markSaved();
+ if (this.isNewNote || !this.currentNoteId) {
+ // For new notes, don't redirect. Instead update the current state and refresh the sidebar
+ this.showNotification('Note created successfully', 'success');
+ this.currentNoteId = data.data.id;
+ this.isNewNote = false;
+
+ // Update the URL without redirecting
+ window.history.replaceState({}, '', `/note/${data.data.id}`);
+
+ // Note: Sidebar refresh would go here if needed
+
+ this.updateNoteTags(data.data.tags);
+ } else {
+ this.showNotification('Note saved successfully', 'success');
+ this.updateNoteTags(data.data.tags);
+ }
+ } else {
+ this.showNotification('Failed to save note', 'error');
+ }
+ } catch (error) {
+ console.error('Error saving note:', error);
+ this.showNotification('Failed to save note', 'error');
+ }
+ }
+
+ deleteCurrentNote() {
+ if (!this.currentNoteId) return;
+
+ // Show custom delete confirmation modal
+ this.showDeleteModal();
+ }
+
+ async confirmDeleteNote() {
+ if (!this.currentNoteId) return;
+
+ try {
+ const response = await fetch(`/api/v1/notes/${this.currentNoteId}`, {
+ method: 'DELETE'
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showNotification('Note deleted successfully');
+ window.location.href = '/';
+ } else {
+ this.showNotification(data.error || 'Failed to delete note', 'error');
+ }
+ } catch (error) {
+ console.error('Error deleting note:', error);
+ this.showNotification('Failed to delete note', 'error');
+ }
+ }
+
+ setupAutoSave() {
+ setInterval(() => {
+ if (this.unsavedChanges && this.currentNoteId && !this.isNewNote) {
+ this.saveCurrentNote();
+ }
+ }, 30000); // Auto-save every 30 seconds
+ }
+
+ markUnsaved() {
+ this.unsavedChanges = true;
+ const saveBtn = document.getElementById('save-note');
+ if (saveBtn) {
+ saveBtn.textContent = 'Save*';
+ saveBtn.classList.add('unsaved');
+ }
+ }
+
+ markSaved() {
+ this.unsavedChanges = false;
+ const saveBtn = document.getElementById('save-note');
+ if (saveBtn) {
+ saveBtn.textContent = 'Save';
+ saveBtn.classList.remove('unsaved');
+ }
+ }
+
+ setupSearch() {
+ // Search functionality is handled in event listeners
+ }
+
+ async performSearch(query) {
+ if (!query.trim()) {
+ this.clearSearch();
+ return;
+ }
+
+ try {
+ const searchData = {
+ query: query,
+ limit: 20,
+ use_vector: true, // Use vector search if available
+ };
+
+ let url = '/api/v1/notes/search';
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(searchData)
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.displaySearchResults(data.data);
+ } else {
+ this.showNotification('Search failed', 'error');
+ }
+ } catch (error) {
+ console.error('Error searching:', error);
+ this.showNotification('Search failed', 'error');
+ }
+ }
+
+ displaySearchResults(notes) {
+ const notesList = document.getElementById('notes-list');
+ if (!notesList) return;
+
+ // Clear current notes
+ notesList.innerHTML = '';
+
+ if (notes.length === 0) {
+ notesList.innerHTML = '';
+ return;
+ }
+
+ notes.forEach(note => {
+ const noteItem = this.createNoteElement(note);
+ notesList.appendChild(noteItem);
+ });
+ }
+
+ clearSearch() {
+ // Reload the page to show all notes
+ window.location.reload();
+ }
+
+ filterByTag(tag) {
+ const noteItems = document.querySelectorAll('.note-item');
+
+ noteItems.forEach(item => {
+ const noteTags = item.dataset.tags.toLowerCase();
+ if (!tag || noteTags.includes(tag.toLowerCase())) {
+ item.style.display = 'block';
+ } else {
+ item.style.display = 'none';
+ }
+ });
+ }
+
+ createNoteElement(note) {
+ const noteItem = document.createElement('div');
+ noteItem.className = 'note-item';
+ noteItem.dataset.noteId = note.id;
+ noteItem.dataset.tags = note.tags ? note.tags.join(' ') : '';
+
+ const preview = note.content.length > 100 ? note.content.substring(0, 100) + '...' : note.content;
+ const createdDate = new Date(note.created_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+
+ let tagsHtml = '';
+ if (note.tags && note.tags.length > 0) {
+ tagsHtml = '' +
+ note.tags.map(tag => `${tag}`).join('') +
+ '
';
+ }
+
+ noteItem.innerHTML = `
+ ${note.title}
+ ${preview}
+
+ ${createdDate}
+ ${tagsHtml}
+
+ `;
+
+ noteItem.addEventListener('click', () => {
+ this.loadNote(note.id);
+ });
+
+ return noteItem;
+ }
+
+ setupMarkdownPreview() {
+ // No longer needed - inline rendering handled by NotionEditor
+ }
+
+ setupSplitPane() {
+ const container = document.getElementById('split-pane-container');
+ const editorPane = document.getElementById('editor-pane');
+ const resizeHandle = document.getElementById('split-resize-handle');
+ const textarea = document.getElementById('note-content');
+ const preview = document.getElementById('note-preview');
+
+ if (!container || !editorPane || !resizeHandle || !textarea || !preview) return;
+
+ let isResizing = false;
+ let startX = 0;
+ let startWidth = 0;
+ let isScrollSyncing = false; // Prevent infinite scroll loops
+
+ // Set initial state - preview focused (editor hidden)
+ container.classList.add('focus-preview');
+
+ // Handle editor focus - expand to 50%
+ textarea.addEventListener('focus', () => {
+ if (!isResizing) {
+ container.classList.remove('focus-preview');
+ container.classList.add('focus-editor');
+ }
+ });
+
+ // Handle editor blur - but only shrink if clicking outside the editor area
+ textarea.addEventListener('blur', (e) => {
+ // Small delay to check if focus moved to resize handle or stays in editor area
+ setTimeout(() => {
+ const activeElement = document.activeElement;
+ const isInEditorArea = activeElement === textarea ||
+ activeElement === resizeHandle ||
+ editorPane.contains(activeElement);
+
+ if (!isInEditorArea && !isResizing) {
+ container.classList.remove('focus-editor');
+ container.classList.add('focus-preview');
+ }
+ }, 100);
+ });
+
+ // Synchronized scrolling functionality
+ const syncScroll = (source, target) => {
+ if (isScrollSyncing) return;
+ isScrollSyncing = true;
+
+ const sourceScrollPercent = source.scrollTop / (source.scrollHeight - source.clientHeight);
+ const targetScrollTop = sourceScrollPercent * (target.scrollHeight - target.clientHeight);
+
+ target.scrollTop = Math.max(0, targetScrollTop);
+
+ // Reset the flag after a brief delay
+ setTimeout(() => {
+ isScrollSyncing = false;
+ }, 10);
+ };
+
+ // Auto-scroll to cursor position when content changes
+ this.syncToCursor = () => {
+ if (isScrollSyncing) return;
+
+ const cursorPosition = textarea.selectionStart;
+ const textBeforeCursor = textarea.value.substring(0, cursorPosition);
+ const linesBeforeCursor = textBeforeCursor.split('\n').length;
+
+ // Get line height approximation
+ const style = window.getComputedStyle(textarea);
+ const lineHeight = parseInt(style.lineHeight) || parseInt(style.fontSize) * 1.2;
+
+ // Calculate cursor position in pixels
+ const cursorTopPosition = (linesBeforeCursor - 1) * lineHeight;
+
+ // Check if content extends beyond editor's visible area
+ const editorContentHeight = textarea.scrollHeight;
+ const editorVisibleHeight = textarea.clientHeight;
+ const contentExtendsEditor = editorContentHeight > editorVisibleHeight;
+
+ // Check if cursor is near the bottom of the visible area
+ const visibleBottom = textarea.scrollTop + textarea.clientHeight;
+ const cursorNearBottom = cursorTopPosition > (visibleBottom - lineHeight * 2);
+
+ if (contentExtendsEditor && cursorNearBottom) {
+ // Scroll preview to the bottom when content extends editor
+ preview.scrollTop = preview.scrollHeight - preview.clientHeight;
+
+ // Also scroll editor if cursor goes beyond visible area
+ if (cursorTopPosition > textarea.scrollTop + textarea.clientHeight - lineHeight) {
+ textarea.scrollTop = cursorTopPosition - textarea.clientHeight + lineHeight * 2;
+ }
+ }
+ };
+
+ // Sync preview scroll when editor scrolls
+ textarea.addEventListener('scroll', () => {
+ syncScroll(textarea, preview);
+ });
+
+ // Sync editor scroll when preview scrolls
+ preview.addEventListener('scroll', () => {
+ syncScroll(preview, textarea);
+ });
+
+ // Track cursor movement and key events for auto-scroll
+ textarea.addEventListener('keydown', (e) => {
+ // Trigger sync on Enter key (new line) and navigation keys
+ if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowUp' ||
+ e.key === 'PageDown' || e.key === 'PageUp' || e.key === 'End' || e.key === 'Home') {
+ setTimeout(() => {
+ this.syncToCursor();
+ }, 10);
+ }
+ });
+
+ // Track cursor position changes via mouse clicks
+ textarea.addEventListener('click', () => {
+ setTimeout(() => {
+ this.syncToCursor();
+ }, 10);
+ });
+
+ // Manual resize functionality
+ resizeHandle.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ isResizing = true;
+ startX = e.clientX;
+ startWidth = editorPane.offsetWidth;
+
+ // Remove focus classes during manual resize
+ container.classList.remove('focus-editor', 'focus-preview');
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ // Add resizing class for visual feedback
+ document.body.style.cursor = 'ew-resize';
+ container.classList.add('resizing');
+ });
+
+ function handleMouseMove(e) {
+ if (!isResizing) return;
+
+ const containerWidth = container.offsetWidth - resizeHandle.offsetWidth;
+ const deltaX = e.clientX - startX;
+ const newWidth = startWidth + deltaX;
+
+ // Constrain width between 15% and 85%
+ const minWidth = containerWidth * 0.15;
+ const maxWidth = containerWidth * 0.85;
+ const constrainedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
+
+ const percentage = (constrainedWidth / containerWidth) * 100;
+ editorPane.style.width = `${percentage}%`;
+ }
+
+ function handleMouseUp() {
+ isResizing = false;
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+
+ // Reset cursor and remove resizing class
+ document.body.style.cursor = '';
+ container.classList.remove('resizing');
+
+ // Don't auto-adjust after manual resize - let user's choice persist
+ }
+
+ // Double-click to toggle between 50/50 and preview-focused
+ resizeHandle.addEventListener('dblclick', () => {
+ const currentWidth = editorPane.offsetWidth;
+ const containerWidth = container.offsetWidth - resizeHandle.offsetWidth;
+ const currentPercentage = (currentWidth / containerWidth) * 100;
+
+ // If close to 50%, go to preview mode (0%), otherwise go to 50%
+ if (Math.abs(currentPercentage - 50) < 10) {
+ container.classList.add('focus-preview');
+ container.classList.remove('focus-editor');
+ editorPane.style.width = ''; // Reset to CSS-controlled width
+ } else {
+ container.classList.add('focus-editor');
+ container.classList.remove('focus-preview');
+ editorPane.style.width = ''; // Reset to CSS-controlled width
+ }
+ });
+ }
+
+ togglePreview() {
+ const textarea = document.getElementById('note-content');
+ const preview = document.getElementById('note-preview');
+ const toggleBtn = document.getElementById('toggle-preview');
+ const previewIcon = document.getElementById('preview-icon');
+ const previewText = document.getElementById('preview-text');
+
+ if (!textarea || !preview || !toggleBtn) return;
+
+ this.isPreviewMode = !this.isPreviewMode;
+
+ if (this.isPreviewMode) {
+ textarea.style.display = 'none';
+ preview.style.display = 'block';
+ previewIcon.textContent = 'โ๏ธ';
+ previewText.textContent = 'Edit';
+ this.updatePreview();
+ } else {
+ textarea.style.display = 'block';
+ preview.style.display = 'none';
+ previewIcon.textContent = '๐๏ธ';
+ previewText.textContent = 'Preview';
+ }
+ }
+
+ updatePreview() {
+ const textarea = document.getElementById('note-content');
+ const preview = document.getElementById('note-preview');
+
+ if (!textarea || !preview || !window.marked) return;
+
+ const markdown = textarea.value;
+ let html = marked.parse(markdown);
+
+ // Sanitize HTML if DOMPurify is available
+ if (window.DOMPurify) {
+ html = DOMPurify.sanitize(html);
+ }
+
+ preview.innerHTML = html;
+ }
+
+ async autoTagNote() {
+ if (!this.currentNoteId) return;
+
+ const autoTagBtn = document.getElementById('auto-tag-btn');
+ if (autoTagBtn) {
+ autoTagBtn.disabled = true;
+ autoTagBtn.textContent = '๐ค Generating...';
+ }
+
+ try {
+ let url = `/api/v1/auto-tag/suggest/${this.currentNoteId}`;
+
+ const response = await fetch(url, {
+ method: 'POST'
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ const suggestedTags = data.data.suggested_tags;
+ if (suggestedTags && suggestedTags.length > 0) {
+ this.addSuggestedTags(suggestedTags);
+ this.showNotification(`Added ${suggestedTags.length} auto-generated tags`, 'success');
+ } else {
+ this.showNotification('No tags suggested', 'info');
+ }
+ } else {
+ this.showNotification('Auto-tagging failed', 'error');
+ }
+ } catch (error) {
+ console.error('Error auto-tagging:', error);
+ this.showNotification('Auto-tagging failed', 'error');
+ } finally {
+ if (autoTagBtn) {
+ autoTagBtn.disabled = false;
+ autoTagBtn.textContent = '๐ท๏ธ Auto-tag';
+ }
+ }
+ }
+
+ addSuggestedTags(suggestedTags) {
+ const tagsInput = document.getElementById('note-tags');
+ if (!tagsInput) return;
+
+ const currentTags = tagsInput.value.split(',').map(tag => tag.trim()).filter(tag => tag);
+ const newTags = [...new Set([...currentTags, ...suggestedTags])];
+
+ tagsInput.value = newTags.join(', ');
+ this.updateCurrentTags(newTags);
+ this.markUnsaved();
+ }
+
+ updateCurrentTags(tags) {
+ const currentTagsContainer = document.getElementById('current-tags');
+ if (!currentTagsContainer) return;
+
+ currentTagsContainer.innerHTML = '';
+
+ tags.forEach(tag => {
+ if (tag.trim()) {
+ const tagElement = document.createElement('span');
+ tagElement.className = 'tag removable';
+ tagElement.dataset.tag = tag.trim();
+ tagElement.innerHTML = `${tag.trim()} ร`;
+ currentTagsContainer.appendChild(tagElement);
+ }
+ });
+ }
+
+ updateNoteTags(tags) {
+ const tagsInput = document.getElementById('note-tags');
+ if (tagsInput && tags) {
+ tagsInput.value = tags.join(', ');
+ this.updateCurrentTags(tags);
+ }
+ }
+
+ removeTag(tagToRemove) {
+ const tagsInput = document.getElementById('note-tags');
+ if (!tagsInput) return;
+
+ const currentTags = tagsInput.value.split(',').map(tag => tag.trim()).filter(tag => tag && tag !== tagToRemove);
+ tagsInput.value = currentTags.join(', ');
+ this.updateCurrentTags(currentTags);
+ this.markUnsaved();
+ }
+
+ showAnalysisModal() {
+ const modal = document.getElementById('analysis-modal');
+ const overlay = document.getElementById('modal-overlay');
+
+ if (modal && overlay) {
+ overlay.style.display = 'block';
+ modal.style.display = 'block';
+
+ // Reset modal state
+ document.getElementById('analysis-write-back').checked = false;
+ document.getElementById('analysis-write-new').checked = false;
+ document.getElementById('analysis-title-input').style.display = 'none';
+ document.getElementById('analysis-prompt').value = '';
+ document.getElementById('analysis-result').style.display = 'none';
+ }
+ }
+
+ async runAnalysis() {
+ if (!this.currentNoteId) return;
+
+ const writeBack = document.getElementById('analysis-write-back').checked;
+ const writeNew = document.getElementById('analysis-write-new').checked;
+ const customTitle = document.getElementById('analysis-title').value;
+ const prompt = document.getElementById('analysis-prompt').value;
+
+ const runBtn = document.getElementById('run-analysis');
+ runBtn.disabled = true;
+ runBtn.textContent = 'Analyzing...';
+
+ try {
+ // Build query parameters
+ const params = new URLSearchParams();
+ if (writeBack) params.append('write-back', 'true');
+ if (writeNew) params.append('write-new', 'true');
+ if (customTitle) params.append('write-title', customTitle);
+ if (prompt) params.append('prompt', prompt);
+
+ // Use the CLI analyze endpoint (we'll need to add this to the API)
+ const response = await fetch(`/api/v1/analyze/${this.currentNoteId}?${params}`, {
+ method: 'POST'
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showAnalysisResult(data.data);
+ } else {
+ this.showNotification('Analysis failed: ' + (data.error || 'Unknown error'), 'error');
+ }
+ } catch (error) {
+ console.error('Error running analysis:', error);
+ this.showNotification('Analysis failed', 'error');
+ } finally {
+ runBtn.disabled = false;
+ runBtn.textContent = 'Analyze';
+ }
+ }
+
+ showAnalysisResult(result) {
+ const resultDiv = document.getElementById('analysis-result');
+ const contentDiv = resultDiv.querySelector('.analysis-content');
+
+ if (contentDiv) {
+ const analysisText = result.analysis || result.summary || 'Analysis completed successfully.';
+
+ // Render markdown if marked.js is available
+ if (window.marked) {
+ let html = marked.parse(analysisText);
+
+ // Sanitize HTML if DOMPurify is available
+ if (window.DOMPurify) {
+ html = DOMPurify.sanitize(html);
+ }
+
+ contentDiv.innerHTML = html;
+ } else {
+ // Fallback to plain text if marked.js is not available
+ contentDiv.textContent = analysisText;
+ }
+ }
+
+ resultDiv.style.display = 'block';
+
+ // If analysis was written back, reload the page to show updates
+ if (result.written_back || result.new_note_id) {
+ setTimeout(() => {
+ window.location.reload();
+ }, 2000);
+ }
+ }
+
+ showDeleteModal() {
+ const modal = document.getElementById('delete-modal');
+ const overlay = document.getElementById('modal-overlay');
+ const titleElement = document.getElementById('delete-preview-title');
+
+ if (modal && overlay) {
+ // Get the current note title
+ const noteTitle = document.getElementById('note-title')?.value || 'Untitled Note';
+ if (titleElement) {
+ titleElement.textContent = noteTitle;
+ }
+
+ overlay.style.display = 'block';
+ modal.style.display = 'block';
+ }
+ }
+
+ closeModals() {
+ const overlay = document.getElementById('modal-overlay');
+ const modals = document.querySelectorAll('.modal');
+
+ if (overlay) {
+ overlay.style.display = 'none';
+ }
+
+ modals.forEach(modal => {
+ modal.style.display = 'none';
+ });
+ }
+
+ showNotification(message, type = 'info') {
+ // Create notification element
+ const notification = document.createElement('div');
+ notification.className = `notification notification-${type}`;
+ notification.textContent = message;
+
+ // Style the notification
+ Object.assign(notification.style, {
+ position: 'fixed',
+ top: '20px',
+ right: '20px',
+ padding: '12px 20px',
+ borderRadius: '8px',
+ fontWeight: '500',
+ zIndex: '9999',
+ maxWidth: '300px',
+ boxShadow: '0 4px 12px rgba(var(--shadow-color), 0.15)',
+ transform: 'translateX(100%)',
+ transition: 'transform 0.3s ease',
+ border: '1px solid var(--border-color)'
+ });
+
+ // Set colors based on type using CSS custom properties
+ switch(type) {
+ case 'success':
+ notification.style.background = 'var(--success-color)';
+ notification.style.color = 'white';
+ break;
+ case 'error':
+ notification.style.background = 'var(--danger-color)';
+ notification.style.color = 'white';
+ break;
+ case 'warning':
+ notification.style.background = '#f59e0b';
+ notification.style.color = 'white';
+ break;
+ case 'info':
+ default:
+ notification.style.background = 'var(--modal-bg)';
+ notification.style.color = 'var(--text-primary)';
+ notification.style.boxShadow = '0 4px 12px rgba(var(--shadow-color), 0.15), 0 0 0 1px var(--border-color)';
+ break;
+ }
+
+ // Add to page
+ document.body.appendChild(notification);
+
+ // Animate in
+ setTimeout(() => {
+ notification.style.transform = 'translateX(0)';
+ }, 100);
+
+ // Remove after delay
+ setTimeout(() => {
+ notification.style.transform = 'translateX(100%)';
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.parentNode.removeChild(notification);
+ }
+ }, 300);
+ }, 3000);
+ }
+}
+
+// Initialize the app when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ new MLNotesApp();
+});
\ No newline at end of file
diff --git a/frontend/static/js/wails-app.js b/frontend/static/js/wails-app.js
new file mode 100644
index 0000000..7b8ff5e
--- /dev/null
+++ b/frontend/static/js/wails-app.js
@@ -0,0 +1,1485 @@
+// ML Notes Wails App JavaScript
+
+class MLNotesWailsApp {
+ constructor() {
+ console.log('MLNotesWailsApp constructor called');
+ window.runtime.LogInfo('JavaScript: MLNotesWailsApp constructor called');
+
+ this.currentNoteId = null;
+ this.isNewNote = false;
+ this.unsavedChanges = false;
+ this.enhancedEditor = null;
+
+ this.init();
+ }
+
+ async init() {
+ try {
+ // Check if app is initialized first
+ const isInitialized = await this.checkInitialization();
+ if (!isInitialized) {
+ this.showInitializationPage();
+ return;
+ }
+
+ this.setupEventListeners();
+ this.setupTheme();
+ await this.loadNotesAndTags();
+ this.setupMarkdownPreview();
+ this.initializeSlidingPanels();
+ } catch (error) {
+ console.error('Error during initialization:', error);
+ // If initialization fails, still show the main app
+ this.setupEventListeners();
+ this.setupTheme();
+ this.setupMarkdownPreview();
+ this.initializeSlidingPanels();
+ }
+ }
+
+ setupEventListeners() {
+ // Theme toggle
+ document.getElementById('theme-toggle').addEventListener('click', () => {
+ this.toggleTheme();
+ });
+
+ // New note button
+ document.getElementById('new-note-btn').addEventListener('click', () => {
+ this.createNewNote();
+ });
+
+ // Create first note button (welcome screen)
+ const createFirstNoteBtn = document.getElementById('create-first-note');
+ if (createFirstNoteBtn) {
+ createFirstNoteBtn.addEventListener('click', () => {
+ this.createNewNote();
+ });
+ }
+
+ // Editor controls
+ const saveBtn = document.getElementById('save-note');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', () => {
+ this.saveCurrentNote();
+ });
+ }
+
+ const deleteBtn = document.getElementById('delete-note');
+ if (deleteBtn) {
+ deleteBtn.addEventListener('click', () => {
+ this.deleteCurrentNote();
+ });
+ }
+
+ const autoTagBtn = document.getElementById('auto-tag-btn');
+ if (autoTagBtn) {
+ autoTagBtn.addEventListener('click', () => {
+ this.autoTagNote();
+ });
+ }
+
+ const analyzeBtn = document.getElementById('analyze-btn');
+ if (analyzeBtn) {
+ analyzeBtn.addEventListener('click', () => {
+ this.analyzeNote();
+ });
+ }
+
+ // Content change tracking
+ const titleInput = document.getElementById('note-title');
+ const noteContent = document.getElementById('note-content');
+ const tagsInput = document.getElementById('note-tags');
+
+ if (titleInput) {
+ titleInput.addEventListener('input', () => {
+ this.markUnsaved();
+ this.updateDocumentTitle();
+ });
+ }
+
+ // Initialize Enhanced Editor
+ const noteContentTextarea = document.getElementById('note-content');
+ const notePreview = document.getElementById('note-preview');
+
+ if (noteContentTextarea && notePreview) {
+ this.enhancedEditor = new EnhancedEditor(noteContentTextarea, notePreview);
+
+ noteContentTextarea.addEventListener('input', () => {
+ this.markUnsaved();
+ });
+ }
+
+ if (tagsInput) {
+ tagsInput.addEventListener('input', () => {
+ this.markUnsaved();
+ });
+ }
+
+ // Tag removal and note clicks (event delegation for performance)
+ document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('tag-remove')) {
+ const tag = e.target.closest('.tag');
+ const tagValue = tag.dataset.tag;
+ this.removeTag(tagValue);
+ } else if (e.target.closest('.note-item')) {
+ // Handle note item clicks
+ const noteItem = e.target.closest('.note-item');
+ const noteId = parseInt(noteItem.dataset.noteId);
+ if (!isNaN(noteId)) {
+ this.loadNote(noteId);
+ }
+ }
+ });
+
+ // Search
+ const searchInput = document.getElementById('search-input');
+ const searchBtn = document.getElementById('search-btn');
+
+ if (searchInput) {
+ searchInput.addEventListener('input', (e) => {
+ this.performSearch(e.target.value);
+ });
+
+ searchInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ this.performSearch(e.target.value);
+ }
+ });
+ }
+
+ if (searchBtn) {
+ searchBtn.addEventListener('click', () => {
+ const query = searchInput.value;
+ this.performSearch(query);
+ });
+ }
+
+ // Tag filter
+ const tagFilter = document.getElementById('tag-filter');
+ if (tagFilter) {
+ tagFilter.addEventListener('change', (e) => {
+ this.filterByTag(e.target.value);
+ });
+ }
+
+ // Settings button
+ const settingsBtn = document.getElementById('settings-btn');
+ console.log('Settings button found:', !!settingsBtn);
+ if (settingsBtn) {
+ settingsBtn.addEventListener('click', () => {
+ console.log('Settings button clicked!');
+ window.runtime.LogInfo('JavaScript: Settings button clicked!');
+ try {
+ console.log('About to call showSettingsPage...');
+ this.showSettingsPage();
+ console.log('showSettingsPage completed');
+ } catch (error) {
+ console.error('Error opening settings page:', error);
+ window.runtime.LogError('JavaScript error: ' + error.message);
+ alert('Settings error: ' + error.message);
+ }
+ });
+ } else {
+ console.error('Settings button not found!');
+ }
+
+ // Settings page controls
+ const closeSettingsBtn = document.getElementById('close-settings');
+ if (closeSettingsBtn) {
+ closeSettingsBtn.addEventListener('click', () => {
+ this.hideSettingsPage();
+ });
+ }
+
+ const settingsForm = document.getElementById('settings-form');
+ if (settingsForm) {
+ settingsForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ this.saveSettings();
+ });
+ }
+
+ const testOllamaBtn = document.getElementById('test-ollama');
+ if (testOllamaBtn) {
+ testOllamaBtn.addEventListener('click', () => {
+ this.testOllamaConnection();
+ });
+ }
+
+ // Initialization page controls
+ const initForm = document.getElementById('init-form');
+ if (initForm) {
+ initForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ this.initializeApp();
+ });
+ }
+
+ // Modal controls
+ this.setupModalControls();
+
+ // Keyboard shortcuts
+ this.setupKeyboardShortcuts();
+ this.setupResponsiveActions();
+
+ // Before unload warning
+ window.addEventListener('beforeunload', (e) => {
+ if (this.unsavedChanges) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
+ }
+
+ setupModalControls() {
+ // Close modals
+ document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('modal-overlay') ||
+ e.target.classList.contains('modal-close')) {
+ this.closeModals();
+ }
+ });
+ }
+
+ setupKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ // Ctrl/Cmd + S = Save
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+ e.preventDefault();
+ this.saveCurrentNote();
+ }
+
+ // Ctrl/Cmd + N = New note
+ if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
+ e.preventDefault();
+ this.createNewNote();
+ }
+
+ // Ctrl/Cmd + / = Toggle theme
+ if ((e.ctrlKey || e.metaKey) && e.key === '/') {
+ e.preventDefault();
+ this.toggleTheme();
+ }
+
+ // Escape = Close modals
+ if (e.key === 'Escape') {
+ this.closeModals();
+ }
+ });
+ }
+
+ setupResponsiveActions() {
+ // Handle more actions button
+ const moreActionsBtn = document.getElementById('more-actions');
+ if (moreActionsBtn) {
+ moreActionsBtn.addEventListener('click', () => {
+ this.toggleMoreActions();
+ });
+ }
+
+ // Handle window resize to manage responsive layout
+ window.addEventListener('resize', () => {
+ this.handleResize();
+ });
+
+ // Initial responsive setup
+ this.handleResize();
+ }
+
+ toggleMoreActions() {
+ const editorActions = document.getElementById('editor-actions');
+ if (editorActions) {
+ editorActions.classList.toggle('expanded');
+
+ const moreBtn = document.getElementById('more-actions');
+ if (editorActions.classList.contains('expanded')) {
+ moreBtn.textContent = 'โฏ Less';
+ } else {
+ moreBtn.textContent = 'โฏ More';
+ }
+ }
+ }
+
+ handleResize() {
+ const width = window.innerWidth;
+ const moreActionsBtn = document.getElementById('more-actions');
+ const editorActions = document.getElementById('editor-actions');
+
+ if (width <= 480) {
+ // Very small screens - show more button
+ if (moreActionsBtn) {
+ moreActionsBtn.style.display = 'block';
+ }
+ } else {
+ // Larger screens - hide more button and expand actions
+ if (moreActionsBtn) {
+ moreActionsBtn.style.display = 'none';
+ }
+ if (editorActions) {
+ editorActions.classList.remove('expanded');
+ }
+ }
+ }
+
+ async setupTheme() {
+ try {
+ // Load theme from preferences using Wails
+ const savedTheme = await window.go.main.App.GetPreference('ui.theme', 'dark');
+ this.setTheme(savedTheme);
+ } catch (error) {
+ console.error('Error loading theme preference:', error);
+ this.setTheme('dark'); // fallback
+ }
+
+ // Remove theme initialization class after DOM is fully loaded
+ setTimeout(() => {
+ document.documentElement.classList.remove('theme-initializing');
+ }, 100);
+ }
+
+ setTheme(theme) {
+ document.documentElement.setAttribute('data-theme', theme);
+ document.body.dataset.theme = theme;
+ document.documentElement.style.setProperty('color-scheme', theme === 'dark' ? 'dark' : 'light');
+
+ // Save to preferences
+ window.go.main.App.SetPreference('ui.theme', theme).catch(console.error);
+
+ const themeIcon = document.getElementById('theme-icon');
+ if (themeIcon) {
+ themeIcon.textContent = theme === 'dark' ? 'โ๏ธ' : '๐';
+ }
+ }
+
+ toggleTheme() {
+ const currentTheme = document.body.dataset.theme;
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
+ this.setTheme(newTheme);
+ }
+
+ async loadNotesAndTags() {
+ try {
+ // Load notes and tags in parallel
+ const [notes, tags] = await Promise.all([
+ window.go.main.App.ListNotes(50, 0),
+ window.go.main.App.GetAllTags()
+ ]);
+
+ this.renderNotes(notes);
+ this.renderTags(tags);
+ this.updateStats(notes.length);
+
+ // Show welcome screen if no notes
+ if (notes.length === 0) {
+ this.showWelcomeScreen();
+ }
+ } catch (error) {
+ console.error('Error loading notes and tags:', error);
+ this.showError('Error', 'Failed to load notes and tags');
+ }
+ }
+
+ renderNotes(notes) {
+ const notesContainer = document.getElementById('notes-list');
+ if (!notesContainer) return;
+
+ if (notes.length === 0) {
+ notesContainer.innerHTML = `
+
+
No notes yet. Create your first note!
+
+ `;
+ return;
+ }
+
+ // Efficient DOM updates: only update changed notes
+ this.updateNotesList(notes, notesContainer);
+ }
+
+ updateNotesList(notes, container) {
+ // First, check if we need to clear empty state
+ const emptyState = container.querySelector('.empty-state');
+ if (emptyState && notes.length > 0) {
+ emptyState.remove();
+ }
+
+ // Create a map of existing notes for efficient lookup
+ const existingNotes = new Map();
+ const existingElements = container.querySelectorAll('.note-item');
+
+ existingElements.forEach(element => {
+ const noteId = parseInt(element.dataset.noteId);
+ if (!isNaN(noteId)) {
+ existingNotes.set(noteId, element);
+ }
+ });
+
+ // Create a document fragment for batch DOM updates
+ const fragment = document.createDocumentFragment();
+ const notesToKeep = new Set();
+
+ // Process each note
+ notes.forEach((note, index) => {
+ notesToKeep.add(note.id);
+ const existingElement = existingNotes.get(note.id);
+
+ if (existingElement) {
+ // Update existing element if needed
+ this.updateNoteElement(existingElement, note);
+ // Move to correct position if needed
+ if (existingElement.previousElementSibling !== (index > 0 ? fragment.lastElementChild : null)) {
+ fragment.appendChild(existingElement);
+ }
+ } else {
+ // Create new element
+ const noteEl = this.createNoteElement(note);
+ fragment.appendChild(noteEl);
+ }
+ });
+
+ // Remove notes that are no longer in the list
+ existingElements.forEach(element => {
+ const noteId = parseInt(element.dataset.noteId);
+ if (!notesToKeep.has(noteId)) {
+ element.remove();
+ }
+ });
+
+ // Append new/moved elements
+ if (fragment.hasChildNodes()) {
+ container.appendChild(fragment);
+ }
+ }
+
+ updateNoteElement(element, note) {
+ // Only update if data has changed
+ const currentTitle = element.querySelector('.note-title').textContent;
+ const currentTags = element.dataset.tags;
+ const newTags = note.tags ? note.tags.join(' ') : '';
+
+ if (currentTitle !== note.title) {
+ element.querySelector('.note-title').textContent = note.title;
+ }
+
+ if (currentTags !== newTags) {
+ element.dataset.tags = newTags;
+ // Update tags display
+ const tagsElement = element.querySelector('.note-tags');
+ if (tagsElement) {
+ if (note.tags && note.tags.length > 0) {
+ tagsElement.innerHTML = note.tags.map(tag => `${tag}`).join('');
+ } else {
+ tagsElement.innerHTML = '';
+ }
+ }
+ }
+
+ // Update preview if content length suggests it might have changed
+ const preview = note.content.length > 100 ? note.content.substring(0, 100) + '...' : note.content;
+ const currentPreview = element.querySelector('.note-preview').textContent;
+ if (currentPreview !== preview) {
+ element.querySelector('.note-preview').textContent = preview;
+ }
+ }
+
+ renderTags(tags) {
+ const tagFilter = document.getElementById('tag-filter');
+ if (!tagFilter) return;
+
+ // Clear existing options except "All Tags"
+ tagFilter.innerHTML = '';
+
+ tags.forEach(tag => {
+ const option = document.createElement('option');
+ option.value = tag.name;
+ option.textContent = tag.name;
+ tagFilter.appendChild(option);
+ });
+ }
+
+ createNoteElement(note) {
+ const noteItem = document.createElement('div');
+ noteItem.className = 'note-item';
+ noteItem.dataset.noteId = note.id;
+ noteItem.dataset.tags = note.tags ? note.tags.join(' ') : '';
+
+ const preview = note.content.length > 100 ? note.content.substring(0, 100) + '...' : note.content;
+ const createdDate = new Date(note.created_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+
+ let tagsHtml = '';
+ if (note.tags && note.tags.length > 0) {
+ tagsHtml = '' +
+ note.tags.map(tag => `${tag}`).join('') +
+ '
';
+ }
+
+ noteItem.innerHTML = `
+ ${note.title}
+ ${preview}
+
+ ${createdDate}
+ ${tagsHtml}
+
+ `;
+
+ // Don't add individual event listeners - use event delegation instead
+ // This is handled in setupEventListeners via delegation
+
+ return noteItem;
+ }
+
+ showWelcomeScreen() {
+ document.getElementById('welcome-screen').style.display = 'flex';
+ document.getElementById('note-editor').style.display = 'none';
+ }
+
+ showNoteEditor() {
+ document.getElementById('welcome-screen').style.display = 'none';
+ document.getElementById('note-editor').style.display = 'block';
+ }
+
+ async loadNote(noteId) {
+ try {
+ const note = await window.go.main.App.GetNote(noteId);
+
+ this.currentNoteId = note.id;
+ this.isNewNote = false;
+
+ // Populate the form fields
+ const titleInput = document.getElementById('note-title');
+ const contentTextarea = document.getElementById('note-content');
+ const tagsInput = document.getElementById('note-tags');
+
+ if (titleInput) titleInput.value = note.title || '';
+ if (contentTextarea) contentTextarea.value = note.content || '';
+ if (tagsInput && note.tags) tagsInput.value = note.tags.join(', ');
+
+ // Update tags display
+ if (note.tags) {
+ this.updateCurrentTags(note.tags);
+ }
+
+ // Show delete button for existing notes
+ document.getElementById('delete-note').style.display = 'inline-block';
+
+ this.updateDocumentTitle();
+ this.showNoteEditor();
+
+ // Update preview if enhanced editor is available
+ if (this.enhancedEditor) {
+ this.enhancedEditor.updatePreview();
+ }
+
+ // Mark all notes as inactive and current as active
+ document.querySelectorAll('.note-item').forEach(item => {
+ item.classList.remove('active');
+ if (parseInt(item.dataset.noteId) === noteId) {
+ item.classList.add('active');
+ }
+ });
+
+ } catch (error) {
+ console.error('Error loading note:', error);
+ this.showError('Error', 'Failed to load note');
+ }
+ }
+
+ createNewNote() {
+ this.currentNoteId = null;
+ this.isNewNote = true;
+
+ // Clear the form
+ document.getElementById('note-title').value = '';
+ document.getElementById('note-content').value = '';
+ document.getElementById('note-tags').value = '';
+ document.getElementById('current-tags').innerHTML = '';
+
+ // Hide delete button for new notes
+ document.getElementById('delete-note').style.display = 'none';
+
+ // Clear active note selection
+ document.querySelectorAll('.note-item').forEach(item => {
+ item.classList.remove('active');
+ });
+
+ this.updateDocumentTitle();
+ this.showNoteEditor();
+
+ // Focus on title input
+ document.getElementById('note-title').focus();
+
+ // Update preview
+ if (this.enhancedEditor) {
+ this.enhancedEditor.updatePreview();
+ }
+ }
+
+ async saveCurrentNote() {
+ const titleInput = document.getElementById('note-title');
+ const tagsInput = document.getElementById('note-tags');
+
+ if (!titleInput.value.trim()) {
+ this.showError('Validation Error', 'Please enter a title for your note');
+ titleInput.focus();
+ return;
+ }
+
+ // Get content from enhanced editor
+ let content = '';
+ if (this.enhancedEditor) {
+ content = this.enhancedEditor.getContent();
+ } else {
+ const contentTextarea = document.getElementById('note-content');
+ content = contentTextarea ? contentTextarea.value : '';
+ }
+
+ const tags = this.parseTags(tagsInput.value);
+
+ try {
+ let note;
+ if (this.isNewNote || !this.currentNoteId) {
+ // Create new note
+ note = await window.go.main.App.CreateNote(titleInput.value, content, tags);
+ this.currentNoteId = note.id;
+ this.isNewNote = false;
+ document.getElementById('delete-note').style.display = 'inline-block';
+ this.showSuccess('Note created successfully');
+ } else {
+ // Update existing note
+ note = await window.go.main.App.UpdateNote(this.currentNoteId, titleInput.value, content, tags);
+ this.showSuccess('Note saved successfully');
+ }
+
+ this.markSaved();
+ this.updateNoteTags(note.tags);
+
+ // Efficiently update the notes list instead of full reload
+ this.updateNoteInList(note);
+
+ } catch (error) {
+ console.error('Error saving note:', error);
+ this.showError('Error', 'Failed to save note: ' + error.message);
+ }
+ }
+
+ async deleteCurrentNote() {
+ if (!this.currentNoteId) return;
+
+ const titleInput = document.getElementById('note-title');
+ const noteTitle = titleInput.value || 'Untitled Note';
+
+ if (confirm(`Are you sure you want to delete "${noteTitle}"? This action cannot be undone.`)) {
+ try {
+ const deletedNoteId = this.currentNoteId;
+ await window.go.main.App.DeleteNote(deletedNoteId);
+ this.showSuccess('Note deleted successfully');
+
+ // Reset to welcome screen
+ this.showWelcomeScreen();
+ this.currentNoteId = null;
+ this.isNewNote = false;
+
+ // Remove note from list efficiently instead of full reload
+ this.removeNoteFromList(deletedNoteId);
+
+ } catch (error) {
+ console.error('Error deleting note:', error);
+ this.showError('Error', 'Failed to delete note: ' + error.message);
+ }
+ }
+ }
+
+ async performSearch(query) {
+ if (!query.trim()) {
+ this.loadNotesAndTags();
+ return;
+ }
+
+ try {
+ const notes = await window.go.main.App.SearchNotes(query, true, 20);
+ this.renderNotes(notes);
+ } catch (error) {
+ console.error('Error searching notes:', error);
+ this.showError('Error', 'Search failed: ' + error.message);
+ }
+ }
+
+ filterByTag(tag) {
+ // Cache the notes container to avoid repeated queries
+ const notesContainer = document.getElementById('notes-list');
+ if (!notesContainer) return;
+
+ // Use CSS classes for better performance than style.display
+ const noteItems = notesContainer.querySelectorAll('.note-item');
+
+ if (!tag) {
+ // Show all notes
+ noteItems.forEach(item => {
+ item.classList.remove('hidden');
+ });
+ } else {
+ const lowerTag = tag.toLowerCase();
+ noteItems.forEach(item => {
+ const noteTags = item.dataset.tags.toLowerCase();
+ if (noteTags.includes(lowerTag)) {
+ item.classList.remove('hidden');
+ } else {
+ item.classList.add('hidden');
+ }
+ });
+ }
+ }
+
+ async autoTagNote() {
+ if (!this.currentNoteId) return;
+
+ const autoTagBtn = document.getElementById('auto-tag-btn');
+ if (autoTagBtn) {
+ autoTagBtn.disabled = true;
+ autoTagBtn.textContent = '๐ค Generating...';
+ }
+
+ try {
+ const suggestedTags = await window.go.main.App.SuggestTags(this.currentNoteId);
+
+ if (suggestedTags && suggestedTags.length > 0) {
+ this.addSuggestedTags(suggestedTags);
+ this.showSuccess(`Added ${suggestedTags.length} auto-generated tags`);
+ } else {
+ this.showInfo('No tags suggested');
+ }
+ } catch (error) {
+ console.error('Error auto-tagging:', error);
+ this.showError('Error', 'Auto-tagging failed: ' + error.message);
+ } finally {
+ if (autoTagBtn) {
+ autoTagBtn.disabled = false;
+ autoTagBtn.textContent = '๐ท๏ธ Auto-tag';
+ }
+ }
+ }
+
+ async analyzeNote() {
+ if (!this.currentNoteId) return;
+
+ const analyzeBtn = document.getElementById('analyze-btn');
+ if (analyzeBtn) {
+ analyzeBtn.disabled = true;
+ analyzeBtn.textContent = '๐ค Analyzing...';
+ }
+
+ try {
+ const result = await window.go.main.App.AnalyzeNote(this.currentNoteId, '');
+
+ this.showModal('Analysis Result', result.analysis);
+
+ } catch (error) {
+ console.error('Error analyzing note:', error);
+ this.showError('Error', 'Analysis failed: ' + error.message);
+ } finally {
+ if (analyzeBtn) {
+ analyzeBtn.disabled = false;
+ analyzeBtn.textContent = '๐ค Analyze';
+ }
+ }
+ }
+
+ // Helper methods
+
+ parseTags(tagsStr) {
+ if (!tagsStr) return [];
+
+ return tagsStr.split(',')
+ .map(tag => tag.trim())
+ .filter(tag => tag.length > 0);
+ }
+
+ addSuggestedTags(suggestedTags) {
+ const tagsInput = document.getElementById('note-tags');
+ if (!tagsInput) return;
+
+ const currentTags = this.parseTags(tagsInput.value);
+ const newTags = [...new Set([...currentTags, ...suggestedTags])];
+
+ tagsInput.value = newTags.join(', ');
+ this.updateCurrentTags(newTags);
+ this.markUnsaved();
+ }
+
+ updateCurrentTags(tags) {
+ const currentTagsContainer = document.getElementById('current-tags');
+ if (!currentTagsContainer) return;
+
+ currentTagsContainer.innerHTML = '';
+
+ tags.forEach(tag => {
+ if (tag.trim()) {
+ const tagElement = document.createElement('span');
+ tagElement.className = 'tag removable';
+ tagElement.dataset.tag = tag.trim();
+ tagElement.innerHTML = `${tag.trim()} ร`;
+ currentTagsContainer.appendChild(tagElement);
+ }
+ });
+ }
+
+ updateNoteTags(tags) {
+ const tagsInput = document.getElementById('note-tags');
+ if (tagsInput && tags) {
+ tagsInput.value = tags.join(', ');
+ this.updateCurrentTags(tags);
+ }
+ }
+
+ removeTag(tagToRemove) {
+ const tagsInput = document.getElementById('note-tags');
+ if (!tagsInput) return;
+
+ const currentTags = this.parseTags(tagsInput.value).filter(tag => tag !== tagToRemove);
+ tagsInput.value = currentTags.join(', ');
+ this.updateCurrentTags(currentTags);
+ this.markUnsaved();
+ }
+
+ updateDocumentTitle() {
+ const titleInput = document.getElementById('note-title');
+ if (titleInput && titleInput.value) {
+ document.title = `${titleInput.value} - ML Notes`;
+ } else {
+ document.title = 'ML Notes';
+ }
+ }
+
+ updateStats(noteCount) {
+ const statsEl = document.getElementById('stats-notes');
+ if (statsEl) {
+ statsEl.textContent = `${noteCount} notes`;
+ }
+ }
+
+ updateNoteInList(note) {
+ const notesContainer = document.getElementById('notes-list');
+ if (!notesContainer) return;
+
+ const existingElement = notesContainer.querySelector(`[data-note-id="${note.id}"]`);
+ if (existingElement) {
+ // Update existing note
+ this.updateNoteElement(existingElement, note);
+ } else {
+ // Add new note at the top
+ const newElement = this.createNoteElement(note);
+ const firstNote = notesContainer.querySelector('.note-item');
+ if (firstNote) {
+ notesContainer.insertBefore(newElement, firstNote);
+ } else {
+ // Replace empty state with first note
+ notesContainer.innerHTML = '';
+ notesContainer.appendChild(newElement);
+ }
+ }
+ }
+
+ removeNoteFromList(noteId) {
+ const notesContainer = document.getElementById('notes-list');
+ if (!notesContainer) return;
+
+ const noteElement = notesContainer.querySelector(`[data-note-id="${noteId}"]`);
+ if (noteElement) {
+ noteElement.remove();
+
+ // Check if we need to show empty state
+ const remainingNotes = notesContainer.querySelectorAll('.note-item');
+ if (remainingNotes.length === 0) {
+ notesContainer.innerHTML = `
+
+
No notes yet. Create your first note!
+
+ `;
+ }
+ }
+ }
+
+ markUnsaved() {
+ this.unsavedChanges = true;
+ const saveBtn = document.getElementById('save-note');
+ if (saveBtn) {
+ saveBtn.textContent = 'Save*';
+ saveBtn.classList.add('unsaved');
+ }
+ }
+
+ markSaved() {
+ this.unsavedChanges = false;
+ const saveBtn = document.getElementById('save-note');
+ if (saveBtn) {
+ saveBtn.textContent = 'Save';
+ saveBtn.classList.remove('unsaved');
+ }
+ }
+
+ // UI Helper methods
+
+ showModal(title, message) {
+ const modal = document.getElementById('modal-overlay');
+ const modalTitle = document.getElementById('modal-title');
+ const modalMessage = document.getElementById('modal-message');
+
+ modalTitle.textContent = title;
+ modalMessage.textContent = message;
+ modal.style.display = 'flex';
+ }
+
+ showError(title, message) {
+ this.showModal(title, message);
+ }
+
+ showSuccess(message) {
+ this.showNotification(message, 'success');
+ }
+
+ showInfo(message) {
+ this.showNotification(message, 'info');
+ }
+
+ showNotification(message, type = 'info') {
+ const notification = document.createElement('div');
+ notification.className = `notification notification-${type}`;
+ notification.textContent = message;
+
+ document.body.appendChild(notification);
+
+ // Animate in
+ setTimeout(() => {
+ notification.classList.add('show');
+ }, 100);
+
+ // Remove after delay
+ setTimeout(() => {
+ notification.classList.remove('show');
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.parentNode.removeChild(notification);
+ }
+ }, 300);
+ }, 3000);
+ }
+
+ closeModals() {
+ const overlay = document.getElementById('modal-overlay');
+ if (overlay) {
+ overlay.style.display = 'none';
+ }
+ }
+
+ setupMarkdownPreview() {
+ // Setup handled by EnhancedEditor
+ }
+
+ initializeSlidingPanels() {
+ const sideBySideEditor = document.getElementById('side-by-side-editor');
+ const hideEditorBtn = document.getElementById('hide-editor');
+ const hidePreviewBtn = document.getElementById('hide-preview');
+
+ if (!sideBySideEditor) return;
+
+ // Hide editor panel
+ hideEditorBtn?.addEventListener('click', () => {
+ sideBySideEditor.classList.add('hide-editor');
+ sideBySideEditor.classList.remove('hide-preview');
+ });
+
+ // Hide preview panel
+ hidePreviewBtn?.addEventListener('click', () => {
+ sideBySideEditor.classList.add('hide-preview');
+ sideBySideEditor.classList.remove('hide-editor');
+ });
+ }
+
+ // Settings and Initialization Methods
+
+ async checkInitialization() {
+ try {
+ const isInitialized = await window.go.main.App.IsConfigInitialized();
+ return isInitialized;
+ } catch (error) {
+ console.error('Error checking initialization:', error);
+ return false;
+ }
+ }
+
+ showInitializationPage() {
+ const mainContent = document.querySelector('.main-content');
+ if (mainContent) mainContent.style.display = 'none';
+ document.getElementById('init-page').style.display = 'block';
+ document.getElementById('settings-page').style.display = 'none';
+ }
+
+ hideInitializationPage() {
+ document.getElementById('init-page').style.display = 'none';
+ const mainContent = document.querySelector('.main-content');
+ if (mainContent) mainContent.style.display = 'block';
+ }
+
+ async initializeApp() {
+ const dataDir = document.getElementById('init-data-dir').value.trim();
+ const ollamaEndpoint = document.getElementById('init-ollama-endpoint').value.trim();
+
+ if (!dataDir) {
+ this.showError('Validation Error', 'Data directory is required');
+ return;
+ }
+
+ if (!ollamaEndpoint) {
+ this.showError('Validation Error', 'Ollama endpoint is required');
+ return;
+ }
+
+ try {
+ await window.go.main.App.InitializeConfig(dataDir, ollamaEndpoint);
+ this.showSuccess('Application initialized successfully! Please restart the application.');
+
+ // Hide init page and show main content after a short delay
+ setTimeout(() => {
+ this.hideInitializationPage();
+ this.init(); // Reinitialize the app
+ }, 2000);
+ } catch (error) {
+ console.error('Error initializing app:', error);
+ this.showError('Initialization Error', 'Failed to initialize: ' + error.message);
+ }
+ }
+
+ showSettingsPage() {
+ console.log('showSettingsPage called');
+
+ // Remove any existing settings overlay
+ const existing = document.getElementById('dynamic-settings-overlay');
+ if (existing) {
+ document.body.removeChild(existing);
+ }
+
+ // Create dynamic settings overlay
+ const settingsOverlay = document.createElement('div');
+ settingsOverlay.id = 'dynamic-settings-overlay';
+
+ // Create the settings HTML content
+ settingsOverlay.innerHTML = `
+
+
+
Settings
+
+
+
+
+
General Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Advanced Settings
+
+
+
+ Command to launch external editor (e.g., code --wait, vim, emacs)
+
+
+
+
+
+
+
+
+
GitHub Integration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Style the overlay
+ settingsOverlay.style.position = 'fixed';
+ settingsOverlay.style.top = '0';
+ settingsOverlay.style.left = '0';
+ settingsOverlay.style.width = '100vw';
+ settingsOverlay.style.height = '100vh';
+ settingsOverlay.style.zIndex = '99999';
+ settingsOverlay.style.backgroundColor = 'var(--paper-bg)';
+ settingsOverlay.style.display = 'block';
+
+ // Add to body
+ document.body.appendChild(settingsOverlay);
+
+ // Load settings data
+ this.loadDynamicSettings();
+
+ // Add event listeners
+ this.setupDynamicSettingsListeners(settingsOverlay);
+ }
+
+ hideSettingsPage() {
+ document.getElementById('settings-page').style.display = 'none';
+ const mainContent = document.querySelector('.main-content');
+ if (mainContent) mainContent.style.display = 'block';
+ }
+
+ async loadSettings() {
+ console.log('loadSettings method called');
+ try {
+ console.log('Checking if window.go exists:', !!window.go);
+ console.log('Checking if window.go.main exists:', !!(window.go && window.go.main));
+ console.log('Checking if GetConfig exists:', !!(window.go && window.go.main && window.go.main.App && window.go.main.App.GetConfig));
+
+ console.log('About to call GetConfig...');
+ window.runtime.LogInfo('JavaScript: About to call GetConfig...');
+ const config = await window.go.main.App.GetConfig();
+ console.log('Config loaded successfully:', config);
+ window.runtime.LogInfo('JavaScript: Config loaded successfully');
+
+ // Check if elements exist before setting values
+ const elements = {
+ 'data-directory': config.data_directory || '',
+ 'ollama-endpoint': config.ollama_endpoint || '',
+ 'debug-mode': config.debug || false,
+ 'summarization-model': config.summarization_model || '',
+ 'enable-summarization': config.enable_summarization || false,
+ 'editor': config.editor || '',
+ 'enable-auto-tagging': config.enable_auto_tagging || false,
+ 'auto-tag-model': config.auto_tag_model || '',
+ 'max-auto-tags': config.max_auto_tags || 5,
+ 'webui-theme': config.webui_theme || 'dark',
+ 'webui-custom-css': config.webui_custom_css || '',
+ 'lilrag-url': config.lilrag_url || ''
+ };
+
+ // Populate form fields with existence checks
+ Object.entries(elements).forEach(([id, value]) => {
+ const element = document.getElementById(id);
+ if (element) {
+ if (element.type === 'checkbox') {
+ element.checked = Boolean(value);
+ } else {
+ element.value = value;
+ }
+ } else {
+ console.warn(`Element with id '${id}' not found`);
+ }
+ });
+
+ } catch (error) {
+ console.error('Error loading settings:', error);
+ this.showError('Error', 'Failed to load settings: ' + error.message);
+ }
+ }
+
+ async saveSettings() {
+ try {
+ const updates = {
+ data_directory: document.getElementById('data-directory').value.trim(),
+ ollama_endpoint: document.getElementById('ollama-endpoint').value.trim(),
+ debug: document.getElementById('debug-mode').checked,
+ summarization_model: document.getElementById('summarization-model').value.trim(),
+ enable_summarization: document.getElementById('enable-summarization').checked,
+ editor: document.getElementById('editor').value.trim(),
+ enable_auto_tagging: document.getElementById('enable-auto-tagging').checked,
+ auto_tag_model: document.getElementById('auto-tag-model').value.trim(),
+ max_auto_tags: parseInt(document.getElementById('max-auto-tags').value) || 5,
+ webui_theme: document.getElementById('webui-theme').value,
+ webui_custom_css: document.getElementById('webui-custom-css').value.trim(),
+ lilrag_url: document.getElementById('lilrag-url').value.trim()
+ };
+
+ await window.go.main.App.UpdateConfig(updates);
+ this.showSuccess('Settings saved successfully! Some changes may require an application restart.');
+
+ } catch (error) {
+ console.error('Error saving settings:', error);
+ this.showError('Error', 'Failed to save settings: ' + error.message);
+ }
+ }
+
+ async testOllamaConnection() {
+ const testBtn = document.getElementById('test-ollama');
+ const originalText = testBtn.textContent;
+
+ try {
+ testBtn.textContent = 'Testing...';
+ testBtn.disabled = true;
+
+ const result = await window.go.main.App.TestOllamaConnection();
+
+ if (result.success) {
+ this.showSuccess('Ollama Connection Successful: ' + result.message);
+ } else {
+ this.showError('Ollama Connection Failed', result.error);
+ }
+
+ } catch (error) {
+ console.error('Error testing Ollama connection:', error);
+ this.showError('Error', 'Failed to test Ollama connection: ' + error.message);
+ } finally {
+ testBtn.textContent = originalText;
+ testBtn.disabled = false;
+ }
+ }
+
+ // Dynamic Settings Methods
+ async loadDynamicSettings() {
+ try {
+ const config = await window.go.main.App.GetConfig();
+
+ // Populate dynamic form fields
+ const elements = {
+ 'dynamic-data-directory': config.data_directory || '',
+ 'dynamic-ollama-endpoint': config.ollama_endpoint || '',
+ 'dynamic-debug-mode': config.debug || false,
+ 'dynamic-webui-theme': config.webui_theme || 'dark',
+ 'dynamic-lilrag-url': config.lilrag_url || '',
+ 'dynamic-enable-summarization': config.enable_summarization || false,
+ 'dynamic-summarization-model': config.summarization_model || '',
+ 'dynamic-enable-auto-tagging': config.enable_auto_tagging || false,
+ 'dynamic-auto-tag-model': config.auto_tag_model || '',
+ 'dynamic-max-auto-tags': config.max_auto_tags || 5,
+ 'dynamic-editor': config.editor || '',
+ 'dynamic-webui-custom-css': config.webui_custom_css || '',
+ 'dynamic-github-owner': config.github_owner || '',
+ 'dynamic-github-repo': config.github_repo || ''
+ };
+
+ Object.entries(elements).forEach(([id, value]) => {
+ const element = document.getElementById(id);
+ if (element) {
+ if (element.type === 'checkbox') {
+ element.checked = Boolean(value);
+ } else {
+ element.value = value;
+ }
+ }
+ });
+
+ } catch (error) {
+ console.error('Error loading dynamic settings:', error);
+ alert('Failed to load settings: ' + error.message);
+ }
+ }
+
+ setupDynamicSettingsListeners(overlay) {
+ // Close button
+ const closeBtn = document.getElementById('dynamic-close-settings');
+ if (closeBtn) {
+ closeBtn.addEventListener('click', () => {
+ document.body.removeChild(overlay);
+ });
+ }
+
+ // Save settings button
+ const saveBtn = document.getElementById('dynamic-save-settings');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', () => {
+ this.saveDynamicSettings();
+ });
+ }
+
+ // Test Ollama button
+ const testBtn = document.getElementById('dynamic-test-ollama');
+ if (testBtn) {
+ testBtn.addEventListener('click', () => {
+ this.testDynamicOllamaConnection();
+ });
+ }
+ }
+
+ async saveDynamicSettings() {
+ try {
+ const updates = {
+ data_directory: document.getElementById('dynamic-data-directory').value.trim(),
+ ollama_endpoint: document.getElementById('dynamic-ollama-endpoint').value.trim(),
+ debug: document.getElementById('dynamic-debug-mode').checked,
+ webui_theme: document.getElementById('dynamic-webui-theme').value,
+ lilrag_url: document.getElementById('dynamic-lilrag-url').value.trim(),
+ enable_summarization: document.getElementById('dynamic-enable-summarization').checked,
+ summarization_model: document.getElementById('dynamic-summarization-model').value.trim(),
+ enable_auto_tagging: document.getElementById('dynamic-enable-auto-tagging').checked,
+ auto_tag_model: document.getElementById('dynamic-auto-tag-model').value.trim(),
+ max_auto_tags: parseInt(document.getElementById('dynamic-max-auto-tags').value) || 5,
+ editor: document.getElementById('dynamic-editor').value.trim(),
+ webui_custom_css: document.getElementById('dynamic-webui-custom-css').value.trim(),
+ github_owner: document.getElementById('dynamic-github-owner').value.trim(),
+ github_repo: document.getElementById('dynamic-github-repo').value.trim()
+ };
+
+ await window.go.main.App.UpdateConfig(updates);
+ alert('Settings saved successfully! Some changes may require an application restart.');
+
+ } catch (error) {
+ console.error('Error saving dynamic settings:', error);
+ alert('Failed to save settings: ' + error.message);
+ }
+ }
+
+ async testDynamicOllamaConnection() {
+ const testBtn = document.getElementById('dynamic-test-ollama');
+ const originalText = testBtn.textContent;
+
+ try {
+ testBtn.textContent = 'Testing...';
+ testBtn.disabled = true;
+
+ const result = await window.go.main.App.TestOllamaConnection();
+
+ if (result.success) {
+ alert('Ollama Connection Successful: ' + result.message);
+ } else {
+ alert('Ollama Connection Failed: ' + result.error);
+ }
+
+ } catch (error) {
+ console.error('Error testing Ollama connection:', error);
+ alert('Failed to test Ollama connection: ' + error.message);
+ } finally {
+ testBtn.textContent = originalText;
+ testBtn.disabled = false;
+ }
+ }
+}
+
+// Enhanced Editor - Simple layered preview (same as original)
+class EnhancedEditor {
+ constructor(textarea, preview) {
+ this.textarea = textarea;
+ this.preview = preview;
+ this.renderTimeout = null;
+
+ this.init();
+ }
+
+ init() {
+ this.textarea.addEventListener('input', () => this.handleInput());
+ this.textarea.addEventListener('scroll', () => this.syncScroll());
+ this.updatePreview();
+ }
+
+ handleInput() {
+ clearTimeout(this.renderTimeout);
+ // Increased debounce delay for better performance
+ this.renderTimeout = setTimeout(() => {
+ this.updatePreview();
+ }, 300);
+ }
+
+ syncScroll() {
+ const scrollPercentage = this.textarea.scrollTop / (this.textarea.scrollHeight - this.textarea.clientHeight);
+ const targetScrollTop = scrollPercentage * (this.preview.scrollHeight - this.preview.clientHeight);
+ this.preview.scrollTop = targetScrollTop;
+ }
+
+ updatePreview() {
+ const markdown = this.textarea.value;
+ if (!markdown.trim()) {
+ this.preview.innerHTML = '';
+ return;
+ }
+
+ try {
+ let html = marked.parse(markdown);
+
+ if (window.DOMPurify) {
+ html = DOMPurify.sanitize(html);
+ }
+
+ this.preview.innerHTML = html;
+ this.syncScroll();
+ } catch (error) {
+ console.error('Error rendering markdown:', error);
+ }
+ }
+
+ getContent() {
+ return this.textarea.value;
+ }
+}
+
+// Initialize the app when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ console.log('DOM loaded');
+ window.runtime.LogInfo('JavaScript: DOM loaded');
+
+ // Wait for Wails runtime to be available
+ window.addEventListener('wails:ready', () => {
+ console.log('Wails ready event fired');
+ window.runtime.LogInfo('JavaScript: Wails ready event fired');
+ new MLNotesWailsApp();
+ });
+
+ // Fallback in case wails:ready doesn't fire
+ setTimeout(() => {
+ console.log('Fallback timeout reached');
+ window.runtime.LogInfo('JavaScript: Fallback timeout reached');
+ if (window.go && window.go.main && window.go.main.App) {
+ console.log('Creating MLNotesWailsApp via fallback');
+ window.runtime.LogInfo('JavaScript: Creating MLNotesWailsApp via fallback');
+ new MLNotesWailsApp();
+ } else {
+ console.log('window.go not available in fallback');
+ window.runtime.LogError('JavaScript: window.go not available in fallback');
+ }
+ }, 1000);
+});
\ No newline at end of file
diff --git a/frontend/templates/graph.html b/frontend/templates/graph.html
new file mode 100644
index 0000000..26842ac
--- /dev/null
+++ b/frontend/templates/graph.html
@@ -0,0 +1,688 @@
+
+
+
+
+
+ Notes Graph - ML Notes
+
+
+ {{if .Config.WebUICustomCSS}}
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Note (size = connections)
+
+
+
+
+
Shared tags (thickness = similarity)
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/templates/index.html b/frontend/templates/index.html
new file mode 100644
index 0000000..ef060bc
--- /dev/null
+++ b/frontend/templates/index.html
@@ -0,0 +1,1305 @@
+
+
+
+
+
+ ML Notes
+
+
+ {{if .Config.WebUICustomCSS}}
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{if or .CurrentNote .CurrentNoteID .IsNewNote}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ โ๏ธ
+
+
+ ๐๏ธ
+
+
+
+
+
+
+ {{else}}
+ {{if and .Notes (gt (len .Notes) 1)}}
+
+
+ {{else}}
+
+
+
+
Welcome to ML Notes
+
Start creating notes to see them visualized as an interactive graph.
+
+
+
+ {{end}}
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
๐๏ธ
+
Are you sure you want to delete this note?
+
This action cannot be undone. The note and all its content will be permanently deleted.
+
+ Note:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html
new file mode 100644
index 0000000..505a739
--- /dev/null
+++ b/frontend/templates/settings.html
@@ -0,0 +1,444 @@
+
+
+
+
+
+ Settings - ML Notes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
โ๏ธ Configuration Settings
+
Manage your ML Notes configuration settings. Changes are saved automatically.
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/test-bindings.html b/frontend/test-bindings.html
new file mode 100644
index 0000000..6a4339c
--- /dev/null
+++ b/frontend/test-bindings.html
@@ -0,0 +1,43 @@
+
+
+
+ Test Wails Bindings
+
+
+
+ Testing Wails Bindings
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/wails.json b/frontend/wails.json
new file mode 100644
index 0000000..44feceb
--- /dev/null
+++ b/frontend/wails.json
@@ -0,0 +1,29 @@
+{
+ "name": "ml-notes",
+ "outputfilename": "ml-notes",
+ "frontend:dir": ".",
+ "frontend:build": "npm run build",
+ "frontend:dev": "npm run dev",
+ "backend:dir": "../",
+ "backend:main": "../main.go",
+ "backend:build": "go build -o build/bin/{{.BinaryName}} {{.MainPath}}",
+ "build:dir": "../build",
+ "build:clean": true,
+ "nsis": {
+ "languages": ["English"],
+ "info": {
+ "productName": "ML Notes",
+ "companyName": "ML Notes",
+ "fileDescription": "ML Notes Desktop Application",
+ "copyright": "ยฉ 2024 ML Notes",
+ "productVersion": "1.0.0",
+ "fileVersion": "1.0.0"
+ }
+ },
+ "info": {
+ "productName": "ML Notes",
+ "companyName": "ML Notes",
+ "productVersion": "1.0.0",
+ "copyright": "ยฉ 2024 ML Notes"
+ }
+}
\ No newline at end of file
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
new file mode 100755
index 0000000..0463e9a
--- /dev/null
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -0,0 +1,59 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร MODIWL
+// This file is automatically generated. DO NOT EDIT
+import {models} from '../models';
+
+export function AnalyzeNote(arg1:number,arg2:string):Promise>;
+
+export function CreateNote(arg1:string,arg2:string,arg3:Array):Promise;
+
+export function DeleteNote(arg1:number):Promise;
+
+export function GetAllTags():Promise>;
+
+export function GetBoolPreference(arg1:string,arg2:boolean):Promise;
+
+export function GetConfig():Promise>;
+
+export function GetJSONPreference(arg1:string,arg2:any):Promise;
+
+export function GetNote(arg1:number):Promise;
+
+export function GetPreference(arg1:string,arg2:string):Promise;
+
+export function GetStats():Promise>;
+
+export function InitializeConfig(arg1:string,arg2:string):Promise;
+
+export function IsAutoTagAvailable():Promise;
+
+export function IsConfigInitialized():Promise;
+
+export function ListNotes(arg1:number,arg2:number):Promise>;
+
+export function SearchByTags(arg1:Array):Promise>;
+
+export function SearchNotes(arg1:string,arg2:boolean,arg3:number):Promise>;
+
+export function SetBoolPreference(arg1:string,arg2:boolean):Promise;
+
+export function SetJSONPreference(arg1:string,arg2:any):Promise;
+
+export function SetPreference(arg1:string,arg2:string):Promise;
+
+export function ShowError(arg1:string,arg2:string):Promise;
+
+export function ShowNotification(arg1:string,arg2:string,arg3:string):Promise;
+
+export function ShowSuccess(arg1:string):Promise;
+
+export function SuggestTags(arg1:number):Promise>;
+
+export function TestMethod():Promise;
+
+export function TestOllamaConnection():Promise>;
+
+export function UpdateConfig(arg1:Record):Promise;
+
+export function UpdateNote(arg1:number,arg2:string,arg3:string,arg4:Array):Promise;
+
+export function UpdateNoteTags(arg1:number,arg2:Array):Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
new file mode 100755
index 0000000..f9e8a89
--- /dev/null
+++ b/frontend/wailsjs/go/main/App.js
@@ -0,0 +1,115 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export function AnalyzeNote(arg1, arg2) {
+ return window['go']['main']['App']['AnalyzeNote'](arg1, arg2);
+}
+
+export function CreateNote(arg1, arg2, arg3) {
+ return window['go']['main']['App']['CreateNote'](arg1, arg2, arg3);
+}
+
+export function DeleteNote(arg1) {
+ return window['go']['main']['App']['DeleteNote'](arg1);
+}
+
+export function GetAllTags() {
+ return window['go']['main']['App']['GetAllTags']();
+}
+
+export function GetBoolPreference(arg1, arg2) {
+ return window['go']['main']['App']['GetBoolPreference'](arg1, arg2);
+}
+
+export function GetConfig() {
+ return window['go']['main']['App']['GetConfig']();
+}
+
+export function GetJSONPreference(arg1, arg2) {
+ return window['go']['main']['App']['GetJSONPreference'](arg1, arg2);
+}
+
+export function GetNote(arg1) {
+ return window['go']['main']['App']['GetNote'](arg1);
+}
+
+export function GetPreference(arg1, arg2) {
+ return window['go']['main']['App']['GetPreference'](arg1, arg2);
+}
+
+export function GetStats() {
+ return window['go']['main']['App']['GetStats']();
+}
+
+export function InitializeConfig(arg1, arg2) {
+ return window['go']['main']['App']['InitializeConfig'](arg1, arg2);
+}
+
+export function IsAutoTagAvailable() {
+ return window['go']['main']['App']['IsAutoTagAvailable']();
+}
+
+export function IsConfigInitialized() {
+ return window['go']['main']['App']['IsConfigInitialized']();
+}
+
+export function ListNotes(arg1, arg2) {
+ return window['go']['main']['App']['ListNotes'](arg1, arg2);
+}
+
+export function SearchByTags(arg1) {
+ return window['go']['main']['App']['SearchByTags'](arg1);
+}
+
+export function SearchNotes(arg1, arg2, arg3) {
+ return window['go']['main']['App']['SearchNotes'](arg1, arg2, arg3);
+}
+
+export function SetBoolPreference(arg1, arg2) {
+ return window['go']['main']['App']['SetBoolPreference'](arg1, arg2);
+}
+
+export function SetJSONPreference(arg1, arg2) {
+ return window['go']['main']['App']['SetJSONPreference'](arg1, arg2);
+}
+
+export function SetPreference(arg1, arg2) {
+ return window['go']['main']['App']['SetPreference'](arg1, arg2);
+}
+
+export function ShowError(arg1, arg2) {
+ return window['go']['main']['App']['ShowError'](arg1, arg2);
+}
+
+export function ShowNotification(arg1, arg2, arg3) {
+ return window['go']['main']['App']['ShowNotification'](arg1, arg2, arg3);
+}
+
+export function ShowSuccess(arg1) {
+ return window['go']['main']['App']['ShowSuccess'](arg1);
+}
+
+export function SuggestTags(arg1) {
+ return window['go']['main']['App']['SuggestTags'](arg1);
+}
+
+export function TestMethod() {
+ return window['go']['main']['App']['TestMethod']();
+}
+
+export function TestOllamaConnection() {
+ return window['go']['main']['App']['TestOllamaConnection']();
+}
+
+export function UpdateConfig(arg1) {
+ return window['go']['main']['App']['UpdateConfig'](arg1);
+}
+
+export function UpdateNote(arg1, arg2, arg3, arg4) {
+ return window['go']['main']['App']['UpdateNote'](arg1, arg2, arg3, arg4);
+}
+
+export function UpdateNoteTags(arg1, arg2) {
+ return window['go']['main']['App']['UpdateNoteTags'](arg1, arg2);
+}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
new file mode 100755
index 0000000..f1d419a
--- /dev/null
+++ b/frontend/wailsjs/go/models.ts
@@ -0,0 +1,82 @@
+export namespace models {
+
+ export class Note {
+ id: number;
+ title: string;
+ content: string;
+ tags: string[];
+ // Go type: time
+ created_at: any;
+ // Go type: time
+ updated_at: any;
+
+ static createFrom(source: any = {}) {
+ return new Note(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.title = source["title"];
+ this.content = source["content"];
+ this.tags = source["tags"];
+ this.created_at = this.convertValues(source["created_at"], null);
+ this.updated_at = this.convertValues(source["updated_at"], null);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+ export class Tag {
+ id: number;
+ name: string;
+ // Go type: time
+ created_at: any;
+
+ static createFrom(source: any = {}) {
+ return new Tag(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.id = source["id"];
+ this.name = source["name"];
+ this.created_at = this.convertValues(source["created_at"], null);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+
+}
+
diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json
new file mode 100644
index 0000000..1e7c8a5
--- /dev/null
+++ b/frontend/wailsjs/runtime/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wailsapp/runtime",
+ "version": "2.0.0",
+ "description": "Wails Javascript runtime library",
+ "main": "runtime.js",
+ "types": "runtime.d.ts",
+ "scripts": {
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wailsapp/wails.git"
+ },
+ "keywords": [
+ "Wails",
+ "Javascript",
+ "Go"
+ ],
+ "author": "Lea Anthony ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wailsapp/wails/issues"
+ },
+ "homepage": "https://github.com/wailsapp/wails#readme"
+}
diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts
new file mode 100644
index 0000000..4445dac
--- /dev/null
+++ b/frontend/wailsjs/runtime/runtime.d.ts
@@ -0,0 +1,249 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+export interface Size {
+ w: number;
+ h: number;
+}
+
+export interface Screen {
+ isCurrent: boolean;
+ isPrimary: boolean;
+ width : number
+ height : number
+}
+
+// Environment information such as platform, buildtype, ...
+export interface EnvironmentInfo {
+ buildType: string;
+ platform: string;
+ arch: string;
+}
+
+// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
+// emits the given event. Optional data may be passed with the event.
+// This will trigger any event listeners.
+export function EventsEmit(eventName: string, ...data: any): void;
+
+// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
+export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
+// sets up a listener for the given event name, but will only trigger a given number times.
+export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
+
+// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
+// sets up a listener for the given event name, but will only trigger once.
+export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
+// unregisters the listener for the given event name.
+export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
+
+// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
+// unregisters all listeners.
+export function EventsOffAll(): void;
+
+// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
+// logs the given message as a raw message
+export function LogPrint(message: string): void;
+
+// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
+// logs the given message at the `trace` log level.
+export function LogTrace(message: string): void;
+
+// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
+// logs the given message at the `debug` log level.
+export function LogDebug(message: string): void;
+
+// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
+// logs the given message at the `error` log level.
+export function LogError(message: string): void;
+
+// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
+// logs the given message at the `fatal` log level.
+// The application will quit after calling this method.
+export function LogFatal(message: string): void;
+
+// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
+// logs the given message at the `info` log level.
+export function LogInfo(message: string): void;
+
+// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
+// logs the given message at the `warning` log level.
+export function LogWarning(message: string): void;
+
+// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
+// Forces a reload by the main application as well as connected browsers.
+export function WindowReload(): void;
+
+// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
+// Reloads the application frontend.
+export function WindowReloadApp(): void;
+
+// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
+// Sets the window AlwaysOnTop or not on top.
+export function WindowSetAlwaysOnTop(b: boolean): void;
+
+// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
+// *Windows only*
+// Sets window theme to system default (dark/light).
+export function WindowSetSystemDefaultTheme(): void;
+
+// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
+// *Windows only*
+// Sets window to light theme.
+export function WindowSetLightTheme(): void;
+
+// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
+// *Windows only*
+// Sets window to dark theme.
+export function WindowSetDarkTheme(): void;
+
+// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
+// Centers the window on the monitor the window is currently on.
+export function WindowCenter(): void;
+
+// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
+// Sets the text in the window title bar.
+export function WindowSetTitle(title: string): void;
+
+// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
+// Makes the window full screen.
+export function WindowFullscreen(): void;
+
+// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
+// Restores the previous window dimensions and position prior to full screen.
+export function WindowUnfullscreen(): void;
+
+// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
+// Returns the state of the window, i.e. whether the window is in full screen mode or not.
+export function WindowIsFullscreen(): Promise;
+
+// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
+// Sets the width and height of the window.
+export function WindowSetSize(width: number, height: number): void;
+
+// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
+// Gets the width and height of the window.
+export function WindowGetSize(): Promise;
+
+// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
+// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMaxSize(width: number, height: number): void;
+
+// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
+// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMinSize(width: number, height: number): void;
+
+// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
+// Sets the window position relative to the monitor the window is currently on.
+export function WindowSetPosition(x: number, y: number): void;
+
+// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
+// Gets the window position relative to the monitor the window is currently on.
+export function WindowGetPosition(): Promise;
+
+// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
+// Hides the window.
+export function WindowHide(): void;
+
+// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
+// Shows the window, if it is currently hidden.
+export function WindowShow(): void;
+
+// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
+// Maximises the window to fill the screen.
+export function WindowMaximise(): void;
+
+// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
+// Toggles between Maximised and UnMaximised.
+export function WindowToggleMaximise(): void;
+
+// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
+// Restores the window to the dimensions and position prior to maximising.
+export function WindowUnmaximise(): void;
+
+// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
+// Returns the state of the window, i.e. whether the window is maximised or not.
+export function WindowIsMaximised(): Promise;
+
+// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
+// Minimises the window.
+export function WindowMinimise(): void;
+
+// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
+// Restores the window to the dimensions and position prior to minimising.
+export function WindowUnminimise(): void;
+
+// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
+// Returns the state of the window, i.e. whether the window is minimised or not.
+export function WindowIsMinimised(): Promise;
+
+// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
+// Returns the state of the window, i.e. whether the window is normal or not.
+export function WindowIsNormal(): Promise;
+
+// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
+// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
+export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
+
+// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
+// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
+export function ScreenGetAll(): Promise;
+
+// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
+// Opens the given URL in the system browser.
+export function BrowserOpenURL(url: string): void;
+
+// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
+// Returns information about the environment
+export function Environment(): Promise;
+
+// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
+// Quits the application.
+export function Quit(): void;
+
+// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
+// Hides the application.
+export function Hide(): void;
+
+// [Show](https://wails.io/docs/reference/runtime/intro#show)
+// Shows the application.
+export function Show(): void;
+
+// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
+// Returns the current text stored on clipboard
+export function ClipboardGetText(): Promise;
+
+// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
+// Sets a text on the clipboard
+export function ClipboardSetText(text: string): Promise;
+
+// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
+// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
+
+// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
+// OnFileDropOff removes the drag and drop listeners and handlers.
+export function OnFileDropOff() :void
+
+// Check if the file path resolver is available
+export function CanResolveFilePaths(): boolean;
+
+// Resolves file paths for an array of files
+export function ResolveFilePaths(files: File[]): void
\ No newline at end of file
diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js
new file mode 100644
index 0000000..623397b
--- /dev/null
+++ b/frontend/wailsjs/runtime/runtime.js
@@ -0,0 +1,238 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export function LogPrint(message) {
+ window.runtime.LogPrint(message);
+}
+
+export function LogTrace(message) {
+ window.runtime.LogTrace(message);
+}
+
+export function LogDebug(message) {
+ window.runtime.LogDebug(message);
+}
+
+export function LogInfo(message) {
+ window.runtime.LogInfo(message);
+}
+
+export function LogWarning(message) {
+ window.runtime.LogWarning(message);
+}
+
+export function LogError(message) {
+ window.runtime.LogError(message);
+}
+
+export function LogFatal(message) {
+ window.runtime.LogFatal(message);
+}
+
+export function EventsOnMultiple(eventName, callback, maxCallbacks) {
+ return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
+}
+
+export function EventsOn(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, -1);
+}
+
+export function EventsOff(eventName, ...additionalEventNames) {
+ return window.runtime.EventsOff(eventName, ...additionalEventNames);
+}
+
+export function EventsOnce(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, 1);
+}
+
+export function EventsEmit(eventName) {
+ let args = [eventName].slice.call(arguments);
+ return window.runtime.EventsEmit.apply(null, args);
+}
+
+export function WindowReload() {
+ window.runtime.WindowReload();
+}
+
+export function WindowReloadApp() {
+ window.runtime.WindowReloadApp();
+}
+
+export function WindowSetAlwaysOnTop(b) {
+ window.runtime.WindowSetAlwaysOnTop(b);
+}
+
+export function WindowSetSystemDefaultTheme() {
+ window.runtime.WindowSetSystemDefaultTheme();
+}
+
+export function WindowSetLightTheme() {
+ window.runtime.WindowSetLightTheme();
+}
+
+export function WindowSetDarkTheme() {
+ window.runtime.WindowSetDarkTheme();
+}
+
+export function WindowCenter() {
+ window.runtime.WindowCenter();
+}
+
+export function WindowSetTitle(title) {
+ window.runtime.WindowSetTitle(title);
+}
+
+export function WindowFullscreen() {
+ window.runtime.WindowFullscreen();
+}
+
+export function WindowUnfullscreen() {
+ window.runtime.WindowUnfullscreen();
+}
+
+export function WindowIsFullscreen() {
+ return window.runtime.WindowIsFullscreen();
+}
+
+export function WindowGetSize() {
+ return window.runtime.WindowGetSize();
+}
+
+export function WindowSetSize(width, height) {
+ window.runtime.WindowSetSize(width, height);
+}
+
+export function WindowSetMaxSize(width, height) {
+ window.runtime.WindowSetMaxSize(width, height);
+}
+
+export function WindowSetMinSize(width, height) {
+ window.runtime.WindowSetMinSize(width, height);
+}
+
+export function WindowSetPosition(x, y) {
+ window.runtime.WindowSetPosition(x, y);
+}
+
+export function WindowGetPosition() {
+ return window.runtime.WindowGetPosition();
+}
+
+export function WindowHide() {
+ window.runtime.WindowHide();
+}
+
+export function WindowShow() {
+ window.runtime.WindowShow();
+}
+
+export function WindowMaximise() {
+ window.runtime.WindowMaximise();
+}
+
+export function WindowToggleMaximise() {
+ window.runtime.WindowToggleMaximise();
+}
+
+export function WindowUnmaximise() {
+ window.runtime.WindowUnmaximise();
+}
+
+export function WindowIsMaximised() {
+ return window.runtime.WindowIsMaximised();
+}
+
+export function WindowMinimise() {
+ window.runtime.WindowMinimise();
+}
+
+export function WindowUnminimise() {
+ window.runtime.WindowUnminimise();
+}
+
+export function WindowSetBackgroundColour(R, G, B, A) {
+ window.runtime.WindowSetBackgroundColour(R, G, B, A);
+}
+
+export function ScreenGetAll() {
+ return window.runtime.ScreenGetAll();
+}
+
+export function WindowIsMinimised() {
+ return window.runtime.WindowIsMinimised();
+}
+
+export function WindowIsNormal() {
+ return window.runtime.WindowIsNormal();
+}
+
+export function BrowserOpenURL(url) {
+ window.runtime.BrowserOpenURL(url);
+}
+
+export function Environment() {
+ return window.runtime.Environment();
+}
+
+export function Quit() {
+ window.runtime.Quit();
+}
+
+export function Hide() {
+ window.runtime.Hide();
+}
+
+export function Show() {
+ window.runtime.Show();
+}
+
+export function ClipboardGetText() {
+ return window.runtime.ClipboardGetText();
+}
+
+export function ClipboardSetText(text) {
+ return window.runtime.ClipboardSetText(text);
+}
+
+/**
+ * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ *
+ * @export
+ * @callback OnFileDropCallback
+ * @param {number} x - x coordinate of the drop
+ * @param {number} y - y coordinate of the drop
+ * @param {string[]} paths - A list of file paths.
+ */
+
+/**
+ * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+ *
+ * @export
+ * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
+ */
+export function OnFileDrop(callback, useDropTarget) {
+ return window.runtime.OnFileDrop(callback, useDropTarget);
+}
+
+/**
+ * OnFileDropOff removes the drag and drop listeners and handlers.
+ */
+export function OnFileDropOff() {
+ return window.runtime.OnFileDropOff();
+}
+
+export function CanResolveFilePaths() {
+ return window.runtime.CanResolveFilePaths();
+}
+
+export function ResolveFilePaths(files) {
+ return window.runtime.ResolveFilePaths(files);
+}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index e089a20..8024139 100644
--- a/go.mod
+++ b/go.mod
@@ -5,10 +5,12 @@ go 1.24
toolchain go1.24.7
require (
- github.com/asg017/sqlite-vec-go-bindings v0.1.6
+ github.com/gorilla/mux v1.8.1
github.com/mark3labs/mcp-go v0.37.0
github.com/mattn/go-sqlite3 v1.14.22
+ github.com/rs/cors v1.11.1
github.com/spf13/cobra v1.8.0
+ github.com/wailsapp/wails/v2 v2.10.2
)
require (
@@ -16,25 +18,47 @@ require (
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/bep/debounce v1.2.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
github.com/chromedp/chromedp v0.14.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/gorilla/mux v1.8.1 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
- github.com/rs/cors v1.11.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.49.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.19 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
- golang.org/x/net v0.25.0 // indirect
+ golang.org/x/crypto v0.37.0 // indirect
+ golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.34.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 3a55c3c..01ffc1c 100644
--- a/go.sum
+++ b/go.sum
@@ -2,12 +2,16 @@ github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
+github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
-github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww=
-github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
+github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
+github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
@@ -24,22 +28,32 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -48,20 +62,53 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ=
github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
@@ -74,8 +121,20 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
+github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk=
+github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -84,63 +143,104 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
-golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
\ No newline at end of file
diff --git a/internal/api/server.go b/internal/api/server.go
index 3b8a73c..86f7c78 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -997,6 +997,7 @@ func (s *APIServer) handleWebNote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
+
func (s *APIServer) handleAnalyzeNote(w http.ResponseWriter, r *http.Request) {
id, err := s.parseIntParam(r, "id")
if err != nil {
@@ -1565,7 +1566,11 @@ func (s *APIServer) handleTestOllama(w http.ResponseWriter, r *http.Request) {
})
return
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
if resp.StatusCode != http.StatusOK {
s.writeJSON(w, http.StatusOK, map[string]interface{}{
diff --git a/internal/autotag/autotag.go b/internal/autotag/autotag.go
index 403fb5a..7a066d3 100644
--- a/internal/autotag/autotag.go
+++ b/internal/autotag/autotag.go
@@ -178,7 +178,11 @@ func (at *AutoTagger) callOllama(prompt string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to make request to Ollama: %w", err)
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -367,7 +371,11 @@ func (at *AutoTagger) IsAvailable() bool {
if err != nil {
return false
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
return resp.StatusCode == http.StatusOK
}
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
new file mode 100644
index 0000000..38442ff
--- /dev/null
+++ b/internal/cli/commands.go
@@ -0,0 +1,697 @@
+package cli
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/streed/ml-notes/internal/config"
+ "github.com/streed/ml-notes/internal/logger"
+ "github.com/streed/ml-notes/internal/models"
+)
+
+func init() {
+ // Register all CLI commands
+ rootCmd.AddCommand(initCmd)
+ rootCmd.AddCommand(configCmd)
+ rootCmd.AddCommand(addCmd)
+ rootCmd.AddCommand(listCmd)
+ rootCmd.AddCommand(getCmd)
+ rootCmd.AddCommand(editCmd)
+ rootCmd.AddCommand(deleteCmd)
+ rootCmd.AddCommand(searchCmd)
+ rootCmd.AddCommand(tagsCmd)
+ rootCmd.AddCommand(analyzeCmd)
+ rootCmd.AddCommand(autotagCmd)
+ rootCmd.AddCommand(updateCmd)
+}
+
+// Init Command
+var initCmd = &cobra.Command{
+ Use: "init",
+ Short: "Initialize ml-notes configuration",
+ Long: `Initialize ml-notes configuration interactively or with flags.
+This command sets up the configuration file and creates necessary directories.`,
+ RunE: runInit,
+}
+
+var (
+ initDataDir string
+ initOllamaEndpoint string
+ initInteractive bool
+ initSummarizationModel string
+ initEnableSummarization bool
+)
+
+func init() {
+ initCmd.Flags().StringVar(&initDataDir, "data-dir", "", "Data directory for storing notes database")
+ initCmd.Flags().StringVar(&initOllamaEndpoint, "ollama-endpoint", "", "Ollama API endpoint (e.g., http://localhost:11434)")
+ initCmd.Flags().StringVar(&initSummarizationModel, "summarization-model", "", "Model to use for summarization (e.g., llama3.2:latest)")
+ initCmd.Flags().BoolVar(&initEnableSummarization, "enable-summarization", true, "Enable AI summarization features")
+ initCmd.Flags().BoolVarP(&initInteractive, "interactive", "i", false, "Run interactive setup")
+}
+
+func runInit(cmd *cobra.Command, args []string) error {
+ // Check if config already exists
+ configPath, err := config.GetConfigPath()
+ if err != nil {
+ return fmt.Errorf("failed to get config path: %w", err)
+ }
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Printf("Configuration already exists at: %s\n", configPath)
+ fmt.Print("Do you want to overwrite it? (y/N): ")
+
+ reader := bufio.NewReader(os.Stdin)
+ response, err := reader.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("failed to read user input: %w", err)
+ }
+
+ response = strings.TrimSpace(strings.ToLower(response))
+ if response != "y" && response != "yes" {
+ fmt.Println("Configuration initialization cancelled.")
+ return nil
+ }
+ }
+
+ if initInteractive {
+ return runInteractiveInit()
+ }
+
+ // Non-interactive initialization
+ cfg, err := config.InitializeConfigWithSummarization(
+ initDataDir,
+ initOllamaEndpoint,
+ initSummarizationModel,
+ initEnableSummarization,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to initialize configuration: %w", err)
+ }
+
+ fmt.Printf("Configuration initialized successfully!\n")
+ fmt.Printf("Config file: %s\n", configPath)
+ fmt.Printf("Data directory: %s\n", cfg.DataDirectory)
+ fmt.Printf("Database: %s\n", cfg.GetDatabasePath())
+
+ return nil
+}
+
+func runInteractiveInit() error {
+ reader := bufio.NewReader(os.Stdin)
+
+ fmt.Println("=== ML Notes Interactive Setup ===")
+ fmt.Println()
+
+ // Data directory
+ fmt.Printf("Data directory (default: %s): ", config.GetDefaultDataDirectory())
+ dataDir, _ := reader.ReadString('\n')
+ dataDir = strings.TrimSpace(dataDir)
+
+ // Ollama endpoint
+ fmt.Print("Ollama endpoint (default: http://localhost:11434): ")
+ ollamaEndpoint, _ := reader.ReadString('\n')
+ ollamaEndpoint = strings.TrimSpace(ollamaEndpoint)
+
+ // Summarization model
+ fmt.Print("Summarization model (default: llama3.2:latest): ")
+ summarizationModel, _ := reader.ReadString('\n')
+ summarizationModel = strings.TrimSpace(summarizationModel)
+
+ // Enable summarization
+ fmt.Print("Enable AI summarization? (Y/n): ")
+ enableSummarization, _ := reader.ReadString('\n')
+ enableSummarization = strings.TrimSpace(strings.ToLower(enableSummarization))
+ enableSummarizationBool := enableSummarization != "n" && enableSummarization != "no"
+
+ // Initialize configuration
+ cfg, err := config.InitializeConfigWithSummarization(
+ dataDir,
+ ollamaEndpoint,
+ summarizationModel,
+ enableSummarizationBool,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to initialize configuration: %w", err)
+ }
+
+ configPath, _ := config.GetConfigPath()
+ fmt.Printf("\nโ
Configuration initialized successfully!\n")
+ fmt.Printf("Config file: %s\n", configPath)
+ fmt.Printf("Data directory: %s\n", cfg.DataDirectory)
+ fmt.Printf("Database: %s\n", cfg.GetDatabasePath())
+
+ return nil
+}
+
+// Config Command
+var configCmd = &cobra.Command{
+ Use: "config",
+ Short: "Manage configuration settings",
+ Long: `View and modify ml-notes configuration settings.`,
+}
+
+var configShowCmd = &cobra.Command{
+ Use: "show",
+ Short: "Show current configuration",
+ RunE: runConfigShow,
+}
+
+var configSetCmd = &cobra.Command{
+ Use: "set ",
+ Short: "Set a configuration value",
+ Args: cobra.ExactArgs(2),
+ RunE: runConfigSet,
+}
+
+func init() {
+ configCmd.AddCommand(configShowCmd)
+ configCmd.AddCommand(configSetCmd)
+}
+
+func runConfigShow(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load()
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %w", err)
+ }
+
+ fmt.Printf("Configuration:\n")
+ fmt.Printf(" Data Directory: %s\n", cfg.DataDirectory)
+ fmt.Printf(" Database Path: %s\n", cfg.GetDatabasePath())
+ fmt.Printf(" Ollama Endpoint: %s\n", cfg.OllamaEndpoint)
+ fmt.Printf(" Lil-Rag URL: %s\n", cfg.LilRagURL)
+ fmt.Printf(" Debug: %t\n", cfg.Debug)
+ fmt.Printf(" Enable Summarization: %t\n", cfg.EnableSummarization)
+ fmt.Printf(" Summarization Model: %s\n", cfg.SummarizationModel)
+ fmt.Printf(" Enable Auto-tagging: %t\n", cfg.EnableAutoTagging)
+ fmt.Printf(" Auto-tag Model: %s\n", cfg.AutoTagModel)
+ fmt.Printf(" Max Auto Tags: %d\n", cfg.MaxAutoTags)
+
+ return nil
+}
+
+func runConfigSet(cmd *cobra.Command, args []string) error {
+ key := args[0]
+ value := args[1]
+
+ cfg, err := config.Load()
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %w", err)
+ }
+
+ switch key {
+ case "data-directory":
+ cfg.DataDirectory = value
+ case "ollama-endpoint":
+ cfg.OllamaEndpoint = value
+ case "lilrag-url":
+ cfg.LilRagURL = value
+ case "debug":
+ cfg.Debug = value == "true"
+ case "enable-summarization":
+ cfg.EnableSummarization = value == "true"
+ case "summarization-model":
+ cfg.SummarizationModel = value
+ case "enable-auto-tagging":
+ cfg.EnableAutoTagging = value == "true"
+ case "auto-tag-model":
+ cfg.AutoTagModel = value
+ case "max-auto-tags":
+ if maxTags, err := strconv.Atoi(value); err == nil {
+ cfg.MaxAutoTags = maxTags
+ } else {
+ return fmt.Errorf("invalid number for max-auto-tags: %s", value)
+ }
+ default:
+ return fmt.Errorf("unknown configuration key: %s", key)
+ }
+
+ if err := config.Save(cfg); err != nil {
+ return fmt.Errorf("failed to save configuration: %w", err)
+ }
+
+ fmt.Printf("Configuration updated: %s = %s\n", key, value)
+ return nil
+}
+
+// Add Command
+var addCmd = &cobra.Command{
+ Use: "add",
+ Short: "Create a new note",
+ Long: `Create a new note with the specified title, content, and tags.`,
+ RunE: runAdd,
+}
+
+var (
+ addTitle string
+ addContent string
+ addTags string
+ addFile string
+)
+
+func init() {
+ addCmd.Flags().StringVarP(&addTitle, "title", "t", "", "Note title")
+ addCmd.Flags().StringVarP(&addContent, "content", "c", "", "Note content")
+ addCmd.Flags().StringVar(&addTags, "tags", "", "Comma-separated tags")
+ addCmd.Flags().StringVarP(&addFile, "file", "f", "", "Read content from file")
+}
+
+func runAdd(cmd *cobra.Command, args []string) error {
+ content := addContent
+
+ // Read from file if specified
+ if addFile != "" {
+ data, err := os.ReadFile(addFile)
+ if err != nil {
+ return fmt.Errorf("failed to read file %s: %w", addFile, err)
+ }
+ content = string(data)
+ }
+
+ // Read from stdin if no content and no file
+ if content == "" && addFile == "" {
+ fmt.Print("Enter note content (Ctrl+D to finish):\n")
+ data, err := os.ReadFile("/dev/stdin")
+ if err == nil {
+ content = string(data)
+ }
+ }
+
+ if addTitle == "" {
+ return fmt.Errorf("title is required (use -t or --title)")
+ }
+
+ var tags []string
+ if addTags != "" {
+ tags = strings.Split(addTags, ",")
+ for i, tag := range tags {
+ tags[i] = strings.TrimSpace(tag)
+ }
+ }
+
+ note, err := noteRepo.CreateWithTags(addTitle, content, tags)
+ if err != nil {
+ return fmt.Errorf("failed to create note: %w", err)
+ }
+
+ fmt.Printf("Note created successfully!\n")
+ fmt.Printf("ID: %d\n", note.ID)
+ fmt.Printf("Title: %s\n", note.Title)
+ if len(note.Tags) > 0 {
+ fmt.Printf("Tags: %s\n", strings.Join(note.Tags, ", "))
+ }
+
+ return nil
+}
+
+// List Command
+var listCmd = &cobra.Command{
+ Use: "list",
+ Short: "List notes",
+ Long: `List notes with optional pagination and formatting.`,
+ RunE: runList,
+}
+
+var (
+ listLimit int
+ listOffset int
+ listShort bool
+)
+
+func init() {
+ listCmd.Flags().IntVar(&listLimit, "limit", 20, "Maximum number of notes to show")
+ listCmd.Flags().IntVar(&listOffset, "offset", 0, "Number of notes to skip")
+ listCmd.Flags().BoolVar(&listShort, "short", false, "Show only ID and title")
+}
+
+func runList(cmd *cobra.Command, args []string) error {
+ notes, err := noteRepo.List(listLimit, listOffset)
+ if err != nil {
+ return fmt.Errorf("failed to list notes: %w", err)
+ }
+
+ if len(notes) == 0 {
+ fmt.Println("No notes found.")
+ return nil
+ }
+
+ for _, note := range notes {
+ if listShort {
+ fmt.Printf("%d: %s\n", note.ID, note.Title)
+ } else {
+ fmt.Printf("ID: %d\n", note.ID)
+ fmt.Printf("Title: %s\n", note.Title)
+ if len(note.Tags) > 0 {
+ fmt.Printf("Tags: %s\n", strings.Join(note.Tags, ", "))
+ }
+ fmt.Printf("Created: %s\n", note.CreatedAt.Format("2006-01-02 15:04:05"))
+ fmt.Printf("Updated: %s\n", note.UpdatedAt.Format("2006-01-02 15:04:05"))
+
+ // Show preview of content
+ preview := note.Content
+ if len(preview) > 100 {
+ preview = preview[:100] + "..."
+ }
+ fmt.Printf("Content: %s\n", preview)
+ fmt.Println("---")
+ }
+ }
+
+ return nil
+}
+
+// Get Command
+var getCmd = &cobra.Command{
+ Use: "get ",
+ Short: "Get a note by ID",
+ Args: cobra.ExactArgs(1),
+ RunE: runGet,
+}
+
+func runGet(cmd *cobra.Command, args []string) error {
+ id, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid note ID: %s", args[0])
+ }
+
+ note, err := noteRepo.GetByID(id)
+ if err != nil {
+ return fmt.Errorf("failed to get note: %w", err)
+ }
+
+ fmt.Printf("ID: %d\n", note.ID)
+ fmt.Printf("Title: %s\n", note.Title)
+ if len(note.Tags) > 0 {
+ fmt.Printf("Tags: %s\n", strings.Join(note.Tags, ", "))
+ }
+ fmt.Printf("Created: %s\n", note.CreatedAt.Format("2006-01-02 15:04:05"))
+ fmt.Printf("Updated: %s\n", note.UpdatedAt.Format("2006-01-02 15:04:05"))
+ fmt.Printf("\nContent:\n%s\n", note.Content)
+
+ return nil
+}
+
+// Edit Command
+var editCmd = &cobra.Command{
+ Use: "edit ",
+ Short: "Edit a note",
+ Args: cobra.ExactArgs(1),
+ RunE: runEdit,
+}
+
+var (
+ editTitle string
+ editContent string
+ editTags string
+)
+
+func init() {
+ editCmd.Flags().StringVarP(&editTitle, "title", "t", "", "New note title")
+ editCmd.Flags().StringVarP(&editContent, "content", "c", "", "New note content")
+ editCmd.Flags().StringVar(&editTags, "tags", "", "New comma-separated tags")
+}
+
+func runEdit(cmd *cobra.Command, args []string) error {
+ id, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid note ID: %s", args[0])
+ }
+
+ note, err := noteRepo.GetByID(id)
+ if err != nil {
+ return fmt.Errorf("failed to get note: %w", err)
+ }
+
+ // Update fields if provided
+ if editTitle != "" {
+ note.Title = editTitle
+ }
+ if editContent != "" {
+ note.Content = editContent
+ }
+ if editTags != "" {
+ tags := strings.Split(editTags, ",")
+ for i, tag := range tags {
+ tags[i] = strings.TrimSpace(tag)
+ }
+ note.Tags = tags
+ }
+
+ if err := noteRepo.Update(note); err != nil {
+ return fmt.Errorf("failed to update note: %w", err)
+ }
+
+ fmt.Printf("Note updated successfully!\n")
+ fmt.Printf("ID: %d\n", note.ID)
+ fmt.Printf("Title: %s\n", note.Title)
+ if len(note.Tags) > 0 {
+ fmt.Printf("Tags: %s\n", strings.Join(note.Tags, ", "))
+ }
+
+ return nil
+}
+
+// Delete Command
+var deleteCmd = &cobra.Command{
+ Use: "delete [id2] [id3] ...",
+ Short: "Delete notes by ID",
+ Args: cobra.MinimumNArgs(1),
+ RunE: runDelete,
+}
+
+func runDelete(cmd *cobra.Command, args []string) error {
+ var ids []int
+ for _, arg := range args {
+ id, err := strconv.Atoi(arg)
+ if err != nil {
+ return fmt.Errorf("invalid note ID: %s", arg)
+ }
+ ids = append(ids, id)
+ }
+
+ for _, id := range ids {
+ if err := noteRepo.Delete(id); err != nil {
+ logger.Error("Failed to delete note %d: %v", id, err)
+ } else {
+ fmt.Printf("Note %d deleted successfully.\n", id)
+ }
+ }
+
+ return nil
+}
+
+// Search Command
+var searchCmd = &cobra.Command{
+ Use: "search [query]",
+ Short: "Search notes",
+ Args: cobra.MaximumNArgs(1),
+ RunE: runSearch,
+}
+
+var (
+ searchVector bool
+ searchTags string
+ searchLimit int
+ searchShort bool
+)
+
+func init() {
+ searchCmd.Flags().BoolVar(&searchVector, "vector", true, "Use vector/semantic search")
+ searchCmd.Flags().StringVar(&searchTags, "tags", "", "Search by tags (comma-separated)")
+ searchCmd.Flags().IntVar(&searchLimit, "limit", 10, "Maximum number of results")
+ searchCmd.Flags().BoolVar(&searchShort, "short", false, "Show only ID and title")
+}
+
+func runSearch(cmd *cobra.Command, args []string) error {
+ var query string
+ if len(args) > 0 {
+ query = args[0]
+ }
+
+ var notes []*models.Note
+ var err error
+
+ if searchTags != "" {
+ // Search by tags
+ tags := strings.Split(searchTags, ",")
+ for i, tag := range tags {
+ tags[i] = strings.TrimSpace(tag)
+ }
+ notes, err = noteRepo.SearchByTags(tags)
+ } else if query != "" {
+ // Search by content
+ if searchVector {
+ notes, err = vectorSearch.SearchSimilar(query, searchLimit)
+ } else {
+ notes, err = noteRepo.Search(query)
+ if len(notes) > searchLimit {
+ notes = notes[:searchLimit]
+ }
+ }
+ } else {
+ return fmt.Errorf("please provide either a search query or use --tags to search by tags")
+ }
+
+ if err != nil {
+ return fmt.Errorf("search failed: %w", err)
+ }
+
+ if len(notes) == 0 {
+ fmt.Println("No notes found.")
+ return nil
+ }
+
+ fmt.Printf("Found %d note(s):\n\n", len(notes))
+
+ for _, note := range notes {
+ if searchShort {
+ fmt.Printf("%d: %s\n", note.ID, note.Title)
+ } else {
+ fmt.Printf("ID: %d\n", note.ID)
+ fmt.Printf("Title: %s\n", note.Title)
+ if len(note.Tags) > 0 {
+ fmt.Printf("Tags: %s\n", strings.Join(note.Tags, ", "))
+ }
+ // Show preview of content
+ preview := note.Content
+ if len(preview) > 200 {
+ preview = preview[:200] + "..."
+ }
+ fmt.Printf("Content: %s\n", preview)
+ fmt.Println("---")
+ }
+ }
+
+ return nil
+}
+
+// Tags Command
+var tagsCmd = &cobra.Command{
+ Use: "tags",
+ Short: "Manage note tags",
+ Long: `List and manage tags for notes.`,
+}
+
+var tagsListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List all tags",
+ RunE: runTagsList,
+}
+
+func init() {
+ tagsCmd.AddCommand(tagsListCmd)
+}
+
+func runTagsList(cmd *cobra.Command, args []string) error {
+ tags, err := noteRepo.GetAllTags()
+ if err != nil {
+ return fmt.Errorf("failed to get tags: %w", err)
+ }
+
+ if len(tags) == 0 {
+ fmt.Println("No tags found.")
+ return nil
+ }
+
+ fmt.Printf("Tags (%d):\n", len(tags))
+ for _, tag := range tags {
+ fmt.Printf(" %s\n", tag.Name)
+ }
+
+ return nil
+}
+
+// Analyze Command
+var analyzeCmd = &cobra.Command{
+ Use: "analyze ",
+ Short: "Analyze a note with AI",
+ Args: cobra.ExactArgs(1),
+ RunE: runAnalyze,
+}
+
+var analyzePrompt string
+
+func init() {
+ analyzeCmd.Flags().StringVarP(&analyzePrompt, "prompt", "p", "", "Custom analysis prompt")
+}
+
+func runAnalyze(cmd *cobra.Command, args []string) error {
+ id, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid note ID: %s", args[0])
+ }
+
+ note, err := noteRepo.GetByID(id)
+ if err != nil {
+ return fmt.Errorf("failed to get note: %w", err)
+ }
+
+ // For now, just show the note info as analysis isn't fully implemented in this simplified version
+ fmt.Printf("Analysis for note %d:\n", note.ID)
+ fmt.Printf("Title: %s\n", note.Title)
+ fmt.Printf("Content length: %d characters\n", len(note.Content))
+ fmt.Printf("Tags: %s\n", strings.Join(note.Tags, ", "))
+ fmt.Printf("Created: %s\n", note.CreatedAt.Format("2006-01-02 15:04:05"))
+
+ if analyzePrompt != "" {
+ fmt.Printf("Custom prompt: %s\n", analyzePrompt)
+ }
+
+ return nil
+}
+
+// Autotag Command
+var autotagCmd = &cobra.Command{
+ Use: "autotag ",
+ Short: "Auto-tag a note with AI",
+ Args: cobra.ExactArgs(1),
+ RunE: runAutotag,
+}
+
+func runAutotag(cmd *cobra.Command, args []string) error {
+ id, err := strconv.Atoi(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid note ID: %s", args[0])
+ }
+
+ note, err := noteRepo.GetByID(id)
+ if err != nil {
+ return fmt.Errorf("failed to get note: %w", err)
+ }
+
+ // For now, just show the note info as auto-tagging isn't fully implemented in this simplified version
+ fmt.Printf("Auto-tagging for note %d:\n", note.ID)
+ fmt.Printf("Title: %s\n", note.Title)
+ fmt.Printf("Current tags: %s\n", strings.Join(note.Tags, ", "))
+ fmt.Printf("Note: Auto-tagging feature requires AI service configuration.\n")
+
+ return nil
+}
+
+// Update Command
+var updateCmd = &cobra.Command{
+ Use: "update ",
+ Short: "Update an existing note",
+ Args: cobra.ExactArgs(1),
+ RunE: runUpdate,
+}
+
+var (
+ updateTitle string
+ updateContent string
+ updateTags string
+)
+
+func init() {
+ updateCmd.Flags().StringVarP(&updateTitle, "title", "t", "", "New note title")
+ updateCmd.Flags().StringVarP(&updateContent, "content", "c", "", "New note content")
+ updateCmd.Flags().StringVar(&updateTags, "tags", "", "New comma-separated tags")
+}
+
+func runUpdate(cmd *cobra.Command, args []string) error {
+ return runEdit(cmd, args) // Update is the same as edit
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
new file mode 100644
index 0000000..6e22734
--- /dev/null
+++ b/internal/cli/root.go
@@ -0,0 +1,83 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+ "github.com/streed/ml-notes/internal/config"
+ "github.com/streed/ml-notes/internal/database"
+ "github.com/streed/ml-notes/internal/logger"
+ "github.com/streed/ml-notes/internal/models"
+ "github.com/streed/ml-notes/internal/search"
+)
+
+var (
+ db *database.DB
+ noteRepo *models.NoteRepository
+ vectorSearch search.SearchProvider
+ appConfig *config.Config
+ debugFlag bool
+ Version = "dev" // Version is set from main.go
+)
+
+var rootCmd = &cobra.Command{
+ Use: "ml-notes-cli",
+ Short: "A CLI tool for managing notes with semantic search",
+ Version: Version,
+ Long: `ml-notes-cli is a command-line interface for creating, managing, and searching notes using lil-rag for semantic search.
+
+First time users should run 'ml-notes-cli init' to set up the configuration.
+
+Note: For a desktop GUI experience, use the main 'ml-notes' executable.`,
+}
+
+func Execute() error {
+ rootCmd.Version = Version
+ return rootCmd.Execute()
+}
+
+
+func init() {
+ cobra.OnInitialize(initAppConfig)
+ rootCmd.PersistentFlags().BoolVar(&debugFlag, "debug", false, "Enable debug logging")
+}
+
+func initAppConfig() {
+ // Skip initialization for init and config commands
+ if len(os.Args) > 1 && (os.Args[1] == "init" || os.Args[1] == "config") {
+ return
+ }
+
+ var err error
+ appConfig, err = config.Load()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err)
+ fmt.Fprintf(os.Stderr, "Please run 'ml-notes-cli init' to set up the configuration.\n")
+ os.Exit(1)
+ }
+
+ // Enable debug mode from flag or config
+ if debugFlag || appConfig.Debug {
+ logger.SetDebugMode(true)
+ logger.Debug("Configuration loaded from: %s", func() string {
+ path, _ := config.GetConfigPath()
+ return path
+ }())
+ logger.Debug("Data directory: %s", appConfig.DataDirectory)
+ logger.Debug("Ollama endpoint: %s", appConfig.OllamaEndpoint)
+ }
+
+ db, err = database.New(appConfig)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err)
+ os.Exit(1)
+ }
+
+ noteRepo = models.NewNoteRepository(db.Conn())
+
+ // Use lil-rag for search and indexing
+ vectorSearch = search.NewLilRagSearch(noteRepo, appConfig)
+ logger.Debug("Using lil-rag search at: %s", appConfig.LilRagURL)
+}
+
diff --git a/internal/config/config.go b/internal/config/config.go
index 2ab6ac2..a22a5d8 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -146,13 +146,13 @@ func Save(cfg *Config) error {
// Create config directory if it doesn't exist
configDir := filepath.Dir(configPath)
- if err := os.MkdirAll(configDir, 0755); err != nil {
+ if err := os.MkdirAll(configDir, 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Create data directory if it doesn't exist
if cfg.DataDirectory != "" {
- if err := os.MkdirAll(cfg.DataDirectory, 0755); err != nil {
+ if err := os.MkdirAll(cfg.DataDirectory, 0o755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index d63f79e..3c3f3bc 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -251,7 +251,7 @@ func TestLoadWithDefaults(t *testing.T) {
// Create a partial config file
configDir := filepath.Join(tempDir, "ml-notes")
- if err := os.MkdirAll(configDir, 0755); err != nil {
+ if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("Failed to create config directory: %v", err)
}
@@ -262,7 +262,7 @@ func TestLoadWithDefaults(t *testing.T) {
data, _ := json.MarshalIndent(partialConfig, "", " ")
configFile := filepath.Join(configDir, "config.json")
- if err := os.WriteFile(configFile, data, 0600); err != nil {
+ if err := os.WriteFile(configFile, data, 0o600); err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 0aff4ca..aa15d39 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -37,5 +37,5 @@ const (
// File permissions
const (
- ConfigFileMode = 0600 // Secure file permissions for config
+ ConfigFileMode = 0o600 // Secure file permissions for config
)
diff --git a/internal/database/db.go b/internal/database/db.go
index b6600d4..32ce5bf 100644
--- a/internal/database/db.go
+++ b/internal/database/db.go
@@ -19,7 +19,7 @@ type DB struct {
func New(cfg *config.Config) (*DB, error) {
// Ensure database directory exists
dbDir := filepath.Dir(cfg.GetDatabasePath())
- if err := os.MkdirAll(dbDir, 0755); err != nil {
+ if err := os.MkdirAll(dbDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
logger.Debug("Database path: %s", cfg.GetDatabasePath())
@@ -31,7 +31,7 @@ func New(cfg *config.Config) (*DB, error) {
db := &DB{conn: conn, cfg: cfg}
if err := db.initialize(); err != nil {
- conn.Close()
+ _ = conn.Close()
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
@@ -81,18 +81,69 @@ func (db *DB) initialize() error {
return fmt.Errorf("failed to create note_tags table: %w", err)
}
- // Create indexes for better query performance
+ // Create basic indexes for better query performance
_, err = db.conn.Exec(`
+ -- Existing junction table indexes
CREATE INDEX IF NOT EXISTS idx_note_tags_note_id ON note_tags(note_id);
CREATE INDEX IF NOT EXISTS idx_note_tags_tag_id ON note_tags(tag_id);
+
+ -- Performance indexes for notes table
+ CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes(updated_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_notes_title ON notes(title);
+
+ -- Compound indexes for common query patterns
+ CREATE INDEX IF NOT EXISTS idx_notes_title_created_at ON notes(title, created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
`)
if err != nil {
- return fmt.Errorf("failed to create indexes: %w", err)
+ return fmt.Errorf("failed to create basic indexes: %w", err)
}
+ // Try to create FTS5 index if available (optional)
+ db.setupFTS()
+
return nil
}
+// setupFTS tries to set up FTS5 full-text search if available
+func (db *DB) setupFTS() {
+ // Try to create FTS5 virtual table
+ _, err := db.conn.Exec(`
+ CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
+ content,
+ tokenize='porter',
+ content=notes,
+ content_rowid=id
+ );
+ `)
+ if err != nil {
+ logger.Debug("FTS5 not available, falling back to LIKE queries: %v", err)
+ return
+ }
+
+ // Create triggers to keep FTS table in sync
+ _, err = db.conn.Exec(`
+ CREATE TRIGGER IF NOT EXISTS notes_fts_insert AFTER INSERT ON notes BEGIN
+ INSERT INTO notes_fts (rowid, content) VALUES (new.id, new.content);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS notes_fts_delete AFTER DELETE ON notes BEGIN
+ DELETE FROM notes_fts WHERE rowid = old.id;
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS notes_fts_update AFTER UPDATE ON notes BEGIN
+ DELETE FROM notes_fts WHERE rowid = old.id;
+ INSERT INTO notes_fts (rowid, content) VALUES (new.id, new.content);
+ END;
+ `)
+ if err != nil {
+ logger.Debug("Failed to create FTS triggers: %v", err)
+ } else {
+ logger.Debug("FTS5 full-text search enabled")
+ }
+}
+
func (db *DB) Close() error {
return db.conn.Close()
}
@@ -163,7 +214,11 @@ func (db *DB) migrateProjectDatabase(project interface{}) error {
if err != nil {
return fmt.Errorf("failed to open project database: %w", err)
}
- defer projectDB.Close()
+ defer func() {
+ if err := projectDB.Close(); err != nil {
+ logger.Debug("Failed to close project database: %v", err)
+ }
+ }()
// First ensure the project exists in the projects table
_, err = db.conn.Exec(`
@@ -198,7 +253,11 @@ func (db *DB) migrateNotesFromProject(projectDB *sql.DB, projectID string) error
if err != nil {
return fmt.Errorf("failed to query project notes: %w", err)
}
- defer rows.Close()
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
for rows.Next() {
var id int
@@ -232,7 +291,11 @@ func (db *DB) migrateTagsFromProject(projectDB *sql.DB, projectID string) error
logger.Debug("No tags table found in project database, skipping tag migration")
return nil
}
- defer tagRows.Close()
+ defer func() {
+ if err := tagRows.Close(); err != nil {
+ logger.Debug("Failed to close tag rows: %v", err)
+ }
+ }()
// Map old tag IDs to new tag IDs
tagIDMap := make(map[int]int)
@@ -288,7 +351,11 @@ func (db *DB) migrateTagsFromProject(projectDB *sql.DB, projectID string) error
logger.Debug("No note_tags table found in project database, skipping note_tags migration")
return nil
}
- defer noteTagRows.Close()
+ defer func() {
+ if err := noteTagRows.Close(); err != nil {
+ logger.Debug("Failed to close note-tag rows: %v", err)
+ }
+ }()
for noteTagRows.Next() {
var oldNoteID, oldTagID int
diff --git a/internal/lilrag/client.go b/internal/lilrag/client.go
index 097e0dd..1876064 100644
--- a/internal/lilrag/client.go
+++ b/internal/lilrag/client.go
@@ -68,7 +68,6 @@ func (c *Client) IndexDocument(id, text string) error {
}
func (c *Client) IndexDocumentWithNamespace(id, text, namespace string) error {
-
req := IndexRequest{
ID: id,
Text: text,
@@ -91,7 +90,11 @@ func (c *Client) IndexDocumentWithNamespace(id, text, namespace string) error {
if err != nil {
return fmt.Errorf("failed to send index request: %w", err)
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
@@ -126,7 +129,6 @@ func (c *Client) Search(query string, limit int) ([]SearchResult, error) {
}
func (c *Client) SearchWithNamespace(query string, limit int, namespace string) ([]SearchResult, error) {
-
req := SearchRequest{
Query: query,
Limit: limit,
@@ -149,7 +151,11 @@ func (c *Client) SearchWithNamespace(query string, limit int, namespace string)
if err != nil {
return nil, fmt.Errorf("failed to send search request: %w", err)
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
@@ -175,7 +181,7 @@ func (c *Client) IsAvailable() bool {
if err != nil {
continue
}
- resp.Body.Close()
+ _ = resp.Body.Close()
// Accept any response that's not a connection error
if resp.StatusCode < 500 {
diff --git a/internal/models/note.go b/internal/models/note.go
index 0d3eb5e..fc33a89 100644
--- a/internal/models/note.go
+++ b/internal/models/note.go
@@ -76,7 +76,11 @@ func (r *NoteRepository) GetByID(id int) (*Note, error) {
}
func (r *NoteRepository) List(limit, offset int) ([]*Note, error) {
- query := "SELECT id, title, content, created_at, updated_at FROM notes ORDER BY created_at DESC"
+ // Optimized query: JOIN with tags to avoid N+1 problem
+ query := `
+ SELECT DISTINCT n.id, n.title, n.content, n.created_at, n.updated_at
+ FROM notes n
+ ORDER BY n.created_at DESC`
args := []interface{}{}
if limit > 0 {
@@ -88,34 +92,41 @@ func (r *NoteRepository) List(limit, offset int) ([]*Note, error) {
}
}
+ // First, get the notes
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list notes: %w", err)
}
- defer rows.Close()
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
var notes []*Note
+ var noteIDs []int
for rows.Next() {
var note Note
err := rows.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.CreatedAt, ¬e.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan note: %w", err)
}
-
- // Load tags for this note
- tags, err := r.getTagsForNote(note.ID)
- if err != nil {
- return nil, fmt.Errorf("failed to load tags for note %d: %w", note.ID, err)
- }
- note.Tags = tags
-
+ note.Tags = []string{} // Initialize empty tags slice
notes = append(notes, ¬e)
+ noteIDs = append(noteIDs, note.ID)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
+ // If we have notes, load all their tags in a single query
+ if len(noteIDs) > 0 {
+ if err := r.loadTagsForNotes(notes, noteIDs); err != nil {
+ return nil, fmt.Errorf("failed to load tags for notes: %w", err)
+ }
+ }
+
return notes, nil
}
@@ -154,38 +165,66 @@ func (r *NoteRepository) Search(query string) ([]*Note, error) {
}
func (r *NoteRepository) SearchByProject(query, projectID string) ([]*Note, error) {
- searchQuery := "%" + query + "%"
- sqlQuery := "SELECT id, title, content, created_at, updated_at FROM notes WHERE (title LIKE ? OR content LIKE ?) ORDER BY created_at DESC"
- args := []interface{}{searchQuery, searchQuery}
+ // Try FTS first, fall back to LIKE queries if FTS is not available
+ var sqlQuery string
+ var args []interface{}
+
+ // Check if FTS table exists
+ var count int
+ err := r.db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='notes_fts'").Scan(&count)
+
+ if err == nil && count > 0 {
+ // Use FTS for better full-text search performance
+ sqlQuery = `
+ SELECT n.id, n.title, n.content, n.created_at, n.updated_at
+ FROM notes n
+ WHERE n.id IN (
+ SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?
+ ) OR n.title LIKE ?
+ ORDER BY n.created_at DESC`
+ searchQuery := "%" + query + "%"
+ args = []interface{}{query, searchQuery}
+ } else {
+ // Fall back to LIKE queries
+ searchQuery := "%" + query + "%"
+ sqlQuery = "SELECT id, title, content, created_at, updated_at FROM notes WHERE (title LIKE ? OR content LIKE ?) ORDER BY created_at DESC"
+ args = []interface{}{searchQuery, searchQuery}
+ }
rows, err := r.db.Query(sqlQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to search notes: %w", err)
}
- defer rows.Close()
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
var notes []*Note
+ var noteIDs []int
for rows.Next() {
var note Note
err := rows.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.CreatedAt, ¬e.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan note: %w", err)
}
-
- // Load tags for this note
- tags, err := r.getTagsForNote(note.ID)
- if err != nil {
- return nil, fmt.Errorf("failed to load tags for note %d: %w", note.ID, err)
- }
- note.Tags = tags
-
+ note.Tags = []string{} // Initialize empty tags slice
notes = append(notes, ¬e)
+ noteIDs = append(noteIDs, note.ID)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
+ // Load all tags for all notes in a single query
+ if len(noteIDs) > 0 {
+ if err := r.loadTagsForNotes(notes, noteIDs); err != nil {
+ return nil, fmt.Errorf("failed to load tags for notes: %w", err)
+ }
+ }
+
return notes, nil
}
@@ -225,7 +264,11 @@ func (r *NoteRepository) getTagsForNote(noteID int) ([]string, error) {
if err != nil {
return nil, fmt.Errorf("failed to query tags: %w", err)
}
- defer rows.Close()
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
var tags []string
for rows.Next() {
@@ -406,30 +449,36 @@ func (r *NoteRepository) SearchByTagsAndProject(tags []string, projectID string)
if err != nil {
return nil, fmt.Errorf("failed to search notes by tags: %w", err)
}
- defer rows.Close()
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
var notes []*Note
+ var noteIDs []int
for rows.Next() {
var note Note
err := rows.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.CreatedAt, ¬e.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan note: %w", err)
}
-
- // Load tags for this note
- noteTags, err := r.getTagsForNote(note.ID)
- if err != nil {
- return nil, fmt.Errorf("failed to load tags for note %d: %w", note.ID, err)
- }
- note.Tags = noteTags
-
+ note.Tags = []string{} // Initialize empty tags slice
notes = append(notes, ¬e)
+ noteIDs = append(noteIDs, note.ID)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating rows: %w", err)
}
+ // Load all tags for all notes in a single query
+ if len(noteIDs) > 0 {
+ if err := r.loadTagsForNotes(notes, noteIDs); err != nil {
+ return nil, fmt.Errorf("failed to load tags for notes: %w", err)
+ }
+ }
+
return notes, nil
}
@@ -445,7 +494,11 @@ func (r *NoteRepository) GetAllTags() ([]Tag, error) {
if err != nil {
return nil, fmt.Errorf("failed to query tags: %w", err)
}
- defer rows.Close()
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
var tags []Tag
for rows.Next() {
@@ -484,3 +537,62 @@ func (r *NoteRepository) GetTagCount() (int, error) {
}
return count, nil
}
+
+// loadTagsForNotes efficiently loads tags for multiple notes in a single query
+func (r *NoteRepository) loadTagsForNotes(notes []*Note, noteIDs []int) error {
+ if len(noteIDs) == 0 {
+ return nil
+ }
+
+ // Create placeholders for the IN clause
+ placeholders := make([]string, len(noteIDs))
+ args := make([]interface{}, len(noteIDs))
+ for i, id := range noteIDs {
+ placeholders[i] = "?"
+ args[i] = id
+ }
+
+ // Single query to get all tags for all notes
+ query := fmt.Sprintf(`
+ SELECT nt.note_id, t.name
+ FROM note_tags nt
+ JOIN tags t ON nt.tag_id = t.id
+ WHERE nt.note_id IN (%s)
+ ORDER BY nt.note_id, t.name`, strings.Join(placeholders, ","))
+
+ rows, err := r.db.Query(query, args...)
+ if err != nil {
+ return fmt.Errorf("failed to query tags: %w", err)
+ }
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
+
+ // Create a map to group tags by note ID
+ tagsByNoteID := make(map[int][]string)
+ for rows.Next() {
+ var noteID int
+ var tagName string
+ if err := rows.Scan(¬eID, &tagName); err != nil {
+ return fmt.Errorf("failed to scan tag: %w", err)
+ }
+ tagsByNoteID[noteID] = append(tagsByNoteID[noteID], tagName)
+ }
+
+ if err = rows.Err(); err != nil {
+ return fmt.Errorf("error iterating tag rows: %w", err)
+ }
+
+ // Assign tags to notes
+ for _, note := range notes {
+ if tags, exists := tagsByNoteID[note.ID]; exists {
+ note.Tags = tags
+ } else {
+ note.Tags = []string{} // Ensure non-nil slice for notes without tags
+ }
+ }
+
+ return nil
+}
diff --git a/internal/preferences/repository.go b/internal/preferences/repository.go
new file mode 100644
index 0000000..19dbacf
--- /dev/null
+++ b/internal/preferences/repository.go
@@ -0,0 +1,174 @@
+package preferences
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/streed/ml-notes/internal/logger"
+)
+
+// Preference represents a key-value preference stored in SQLite
+type Preference struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Type string `json:"type"` // "string", "json", "bool", "number"
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// PreferencesRepository handles preference storage and retrieval
+type PreferencesRepository struct {
+ db *sql.DB
+}
+
+// NewPreferencesRepository creates a new preferences repository
+func NewPreferencesRepository(db *sql.DB) *PreferencesRepository {
+ repo := &PreferencesRepository{db: db}
+ if err := repo.createTable(); err != nil {
+ // Log error but don't fail - preferences are optional
+ fmt.Printf("Warning: Failed to create preferences table: %v\n", err)
+ }
+ return repo
+}
+
+// createTable creates the preferences table if it doesn't exist
+func (r *PreferencesRepository) createTable() error {
+ query := `
+ CREATE TABLE IF NOT EXISTS preferences (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ type TEXT NOT NULL DEFAULT 'string',
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_preferences_type ON preferences(type);
+ CREATE INDEX IF NOT EXISTS idx_preferences_updated_at ON preferences(updated_at);
+ `
+
+ _, err := r.db.Exec(query)
+ return err
+}
+
+// Set stores a preference value
+func (r *PreferencesRepository) Set(key, value, valueType string) error {
+ query := `
+ INSERT OR REPLACE INTO preferences (key, value, type, updated_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ `
+ _, err := r.db.Exec(query, key, value, valueType)
+ return err
+}
+
+// Get retrieves a preference value
+func (r *PreferencesRepository) Get(key string) (*Preference, error) {
+ query := `
+ SELECT key, value, type, updated_at
+ FROM preferences
+ WHERE key = ?
+ `
+
+ row := r.db.QueryRow(query, key)
+
+ var pref Preference
+ err := row.Scan(&pref.Key, &pref.Value, &pref.Type, &pref.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+
+ return &pref, nil
+}
+
+// GetAll retrieves all preferences
+func (r *PreferencesRepository) GetAll() ([]*Preference, error) {
+ query := `
+ SELECT key, value, type, updated_at
+ FROM preferences
+ ORDER BY key
+ `
+
+ rows, err := r.db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err := rows.Close(); err != nil {
+ logger.Debug("Failed to close rows: %v", err)
+ }
+ }()
+
+ var prefs []*Preference
+ for rows.Next() {
+ var pref Preference
+ err := rows.Scan(&pref.Key, &pref.Value, &pref.Type, &pref.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ prefs = append(prefs, &pref)
+ }
+
+ return prefs, rows.Err()
+}
+
+// Delete removes a preference
+func (r *PreferencesRepository) Delete(key string) error {
+ query := `DELETE FROM preferences WHERE key = ?`
+ _, err := r.db.Exec(query, key)
+ return err
+}
+
+// SetString stores a string preference
+func (r *PreferencesRepository) SetString(key, value string) error {
+ return r.Set(key, value, "string")
+}
+
+// GetString retrieves a string preference
+func (r *PreferencesRepository) GetString(key, defaultValue string) string {
+ pref, err := r.Get(key)
+ if err != nil {
+ return defaultValue
+ }
+ return pref.Value
+}
+
+// SetBool stores a boolean preference
+func (r *PreferencesRepository) SetBool(key string, value bool) error {
+ valueStr := "false"
+ if value {
+ valueStr = "true"
+ }
+ return r.Set(key, valueStr, "bool")
+}
+
+// GetBool retrieves a boolean preference
+func (r *PreferencesRepository) GetBool(key string, defaultValue bool) bool {
+ pref, err := r.Get(key)
+ if err != nil {
+ return defaultValue
+ }
+ return pref.Value == "true"
+}
+
+// SetJSON stores a JSON preference (marshals the object)
+func (r *PreferencesRepository) SetJSON(key string, value interface{}) error {
+ jsonBytes, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ return r.Set(key, string(jsonBytes), "json")
+}
+
+// GetJSON retrieves and unmarshals a JSON preference
+func (r *PreferencesRepository) GetJSON(key string, target interface{}) error {
+ pref, err := r.Get(key)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal([]byte(pref.Value), target)
+}
+
+// HasKey checks if a preference key exists
+func (r *PreferencesRepository) HasKey(key string) bool {
+ _, err := r.Get(key)
+ return err == nil
+}
diff --git a/internal/project/project.go b/internal/project/project.go
index 9b3e567..691a5af 100644
--- a/internal/project/project.go
+++ b/internal/project/project.go
@@ -94,7 +94,7 @@ func (pm *ProjectManager) loadProjects() error {
// saveProjects saves projects to the projects.json file
func (pm *ProjectManager) saveProjects() error {
// Ensure config directory exists
- if err := os.MkdirAll(pm.configDir, 0755); err != nil {
+ if err := os.MkdirAll(pm.configDir, 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
@@ -113,7 +113,7 @@ func (pm *ProjectManager) saveProjects() error {
return fmt.Errorf("failed to marshal projects: %w", err)
}
- return os.WriteFile(pm.projectsFile, data, 0644)
+ return os.WriteFile(pm.projectsFile, data, 0o644)
}
// createDefaultProject creates a default "default" project
@@ -129,7 +129,7 @@ func (pm *ProjectManager) createDefaultProject() error {
}
// Create project directory
- if err := os.MkdirAll(project.Path, 0755); err != nil {
+ if err := os.MkdirAll(project.Path, 0o755); err != nil {
return fmt.Errorf("failed to create default project directory: %w", err)
}
@@ -171,7 +171,7 @@ func (pm *ProjectManager) CreateProject(name, description string) (*Project, err
}
// Create project directory
- if err := os.MkdirAll(project.Path, 0755); err != nil {
+ if err := os.MkdirAll(project.Path, 0o755); err != nil {
return nil, fmt.Errorf("failed to create project directory: %w", err)
}
@@ -288,7 +288,7 @@ func (pm *ProjectManager) MigrateFromLegacyDatabase(legacyDBPath string) error {
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
// Ensure destination directory exists
- if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
@@ -299,5 +299,5 @@ func copyFile(src, dst string) error {
}
// Write to destination
- return os.WriteFile(dst, data, 0644)
+ return os.WriteFile(dst, data, 0o644)
}
diff --git a/internal/search/lilrag_search.go b/internal/search/lilrag_search.go
index 14551e9..19fd1fd 100644
--- a/internal/search/lilrag_search.go
+++ b/internal/search/lilrag_search.go
@@ -30,7 +30,6 @@ func (lrs *LilRagSearch) IndexNote(noteID int, content string) error {
}
func (lrs *LilRagSearch) IndexNoteWithNamespace(noteID int, content, namespace, projectID string) error {
-
// Use project-specific note ID as document ID for lil-rag
docID := fmt.Sprintf("notes-%s-%d", projectID, noteID)
@@ -52,7 +51,6 @@ func (lrs *LilRagSearch) SearchSimilar(query string, limit int) ([]*models.Note,
}
func (lrs *LilRagSearch) SearchSimilarWithNamespace(query string, limit int, namespace, projectID string) ([]*models.Note, error) {
-
// Check if lil-rag is available
if !lrs.client.IsAvailable() {
logger.Debug("Lil-rag service not available, falling back to text search")
diff --git a/internal/services/services.go b/internal/services/services.go
new file mode 100644
index 0000000..fb370d9
--- /dev/null
+++ b/internal/services/services.go
@@ -0,0 +1,238 @@
+package services
+
+import (
+ "github.com/streed/ml-notes/internal/autotag"
+ "github.com/streed/ml-notes/internal/config"
+ "github.com/streed/ml-notes/internal/logger"
+ "github.com/streed/ml-notes/internal/models"
+ "github.com/streed/ml-notes/internal/preferences"
+ "github.com/streed/ml-notes/internal/search"
+ "github.com/streed/ml-notes/internal/summarize"
+)
+
+// Services contains all the service dependencies
+type Services struct {
+ Config *config.Config
+ Notes *NotesService
+ Tags *TagsService
+ Search *SearchService
+ AutoTag *AutoTagService
+ Analyze *AnalyzeService
+ Preferences *PreferencesService
+}
+
+// NewServices creates a new services container
+func NewServices(
+ cfg *config.Config,
+ noteRepo *models.NoteRepository,
+ prefsRepo *preferences.PreferencesRepository,
+ vectorSearch search.SearchProvider,
+) *Services {
+ // Initialize individual services
+ notesService := NewNotesService(noteRepo, vectorSearch)
+ tagsService := NewTagsService(noteRepo)
+ searchService := NewSearchService(noteRepo, vectorSearch)
+ autoTagService := NewAutoTagService(cfg, noteRepo)
+ analyzeService := NewAnalyzeService(cfg, noteRepo)
+ preferencesService := NewPreferencesService(prefsRepo)
+
+ return &Services{
+ Config: cfg,
+ Notes: notesService,
+ Tags: tagsService,
+ Search: searchService,
+ AutoTag: autoTagService,
+ Analyze: analyzeService,
+ Preferences: preferencesService,
+ }
+}
+
+// Close cleans up any resources
+func (s *Services) Close() error {
+ // Add any cleanup logic here
+ return nil
+}
+
+// NotesService handles note operations
+type NotesService struct {
+ repo *models.NoteRepository
+ vectorSearch search.SearchProvider
+}
+
+func NewNotesService(repo *models.NoteRepository, vectorSearch search.SearchProvider) *NotesService {
+ return &NotesService{
+ repo: repo,
+ vectorSearch: vectorSearch,
+ }
+}
+
+func (s *NotesService) GetByID(id int) (*models.Note, error) {
+ return s.repo.GetByID(id)
+}
+
+func (s *NotesService) List(limit, offset int) ([]*models.Note, error) {
+ return s.repo.List(limit, offset)
+}
+
+func (s *NotesService) Create(title, content string, tags []string) (*models.Note, error) {
+ var note *models.Note
+ var err error
+
+ if len(tags) > 0 {
+ note, err = s.repo.CreateWithTags(title, content, tags)
+ } else {
+ note, err = s.repo.Create(title, content)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Index for vector search
+ if s.vectorSearch != nil {
+ fullText := note.Title + " " + note.Content
+ if err := s.vectorSearch.IndexNote(note.ID, fullText); err != nil {
+ // Log error but don't fail the creation
+ logger.Debug("Failed to index note for vector search: %v", err)
+ }
+ }
+
+ return note, nil
+}
+
+func (s *NotesService) Update(note *models.Note) error {
+ err := s.repo.Update(note)
+ if err != nil {
+ return err
+ }
+
+ // Re-index for vector search
+ if s.vectorSearch != nil {
+ fullText := note.Title + " " + note.Content
+ if err := s.vectorSearch.IndexNote(note.ID, fullText); err != nil {
+ // Log error but don't fail the update
+ logger.Debug("Failed to re-index note for vector search: %v", err)
+ }
+ }
+
+ return nil
+}
+
+func (s *NotesService) Delete(id int) error {
+ return s.repo.Delete(id)
+}
+
+// TagsService handles tag operations
+type TagsService struct {
+ repo *models.NoteRepository
+}
+
+func NewTagsService(repo *models.NoteRepository) *TagsService {
+ return &TagsService{repo: repo}
+}
+
+func (s *TagsService) GetAll() ([]models.Tag, error) {
+ return s.repo.GetAllTags()
+}
+
+func (s *TagsService) UpdateNoteTags(noteID int, tags []string) error {
+ return s.repo.UpdateTags(noteID, tags)
+}
+
+// SearchService handles search operations
+type SearchService struct {
+ repo *models.NoteRepository
+ vectorSearch search.SearchProvider
+}
+
+func NewSearchService(repo *models.NoteRepository, vectorSearch search.SearchProvider) *SearchService {
+ return &SearchService{
+ repo: repo,
+ vectorSearch: vectorSearch,
+ }
+}
+
+func (s *SearchService) SearchNotes(query string, useVector bool, limit int) ([]*models.Note, error) {
+ if useVector && s.vectorSearch != nil {
+ return s.vectorSearch.SearchSimilar(query, limit)
+ }
+ return s.repo.Search(query)
+}
+
+func (s *SearchService) SearchByTags(tags []string) ([]*models.Note, error) {
+ return s.repo.SearchByTags(tags)
+}
+
+// AutoTagService handles auto-tagging operations
+type AutoTagService struct {
+ autoTagger *autotag.AutoTagger
+ repo *models.NoteRepository
+}
+
+func NewAutoTagService(cfg *config.Config, repo *models.NoteRepository) *AutoTagService {
+ return &AutoTagService{
+ autoTagger: autotag.NewAutoTagger(cfg),
+ repo: repo,
+ }
+}
+
+func (s *AutoTagService) IsAvailable() bool {
+ return s.autoTagger.IsAvailable()
+}
+
+func (s *AutoTagService) SuggestTags(note *models.Note) ([]string, error) {
+ return s.autoTagger.SuggestTags(note)
+}
+
+// AnalyzeService handles note analysis operations
+type AnalyzeService struct {
+ summarizer *summarize.Summarizer
+ repo *models.NoteRepository
+}
+
+func NewAnalyzeService(cfg *config.Config, repo *models.NoteRepository) *AnalyzeService {
+ return &AnalyzeService{
+ summarizer: summarize.NewSummarizer(cfg),
+ repo: repo,
+ }
+}
+
+func (s *AnalyzeService) AnalyzeNote(note *models.Note, prompt string) (*summarize.SummaryResult, error) {
+ if prompt != "" {
+ return s.summarizer.SummarizeNoteWithPrompt(note, prompt)
+ }
+ return s.summarizer.SummarizeNote(note)
+}
+
+// PreferencesService handles user preferences
+type PreferencesService struct {
+ repo *preferences.PreferencesRepository
+}
+
+func NewPreferencesService(repo *preferences.PreferencesRepository) *PreferencesService {
+ return &PreferencesService{repo: repo}
+}
+
+func (s *PreferencesService) GetString(key, defaultValue string) string {
+ return s.repo.GetString(key, defaultValue)
+}
+
+func (s *PreferencesService) SetString(key, value string) error {
+ return s.repo.SetString(key, value)
+}
+
+func (s *PreferencesService) GetBool(key string, defaultValue bool) bool {
+ return s.repo.GetBool(key, defaultValue)
+}
+
+func (s *PreferencesService) SetBool(key string, value bool) error {
+ return s.repo.SetBool(key, value)
+}
+
+func (s *PreferencesService) GetJSON(key string, target interface{}) error {
+ return s.repo.GetJSON(key, target)
+}
+
+func (s *PreferencesService) SetJSON(key string, value interface{}) error {
+ return s.repo.SetJSON(key, value)
+}
diff --git a/internal/summarize/summarize.go b/internal/summarize/summarize.go
index 93ca9b6..1ba7b24 100644
--- a/internal/summarize/summarize.go
+++ b/internal/summarize/summarize.go
@@ -227,7 +227,11 @@ func (s *Summarizer) callOllama(prompt string) (string, error) {
logger.Error("Ollama API error: %v", err)
return "", fmt.Errorf("failed to connect to Ollama: %w", err)
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
logger.Debug("Ollama response status: %d, time: %v", resp.StatusCode, time.Since(start))
@@ -271,7 +275,11 @@ func (s *Summarizer) CheckModelAvailability() error {
if err != nil {
return fmt.Errorf("failed to check model: %w", err)
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("model %s not found. Please pull it first with: ollama pull %s", s.model, s.model)
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 6636daf..93963a4 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -224,7 +224,11 @@ func (u *Updater) PerformUpdate(updateInfo *UpdateInfo, progress chan<- Progress
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
- defer os.RemoveAll(tempDir)
+ defer func() {
+ if err := os.RemoveAll(tempDir); err != nil {
+ logger.Debug("Failed to remove temp directory: %v", err)
+ }
+ }()
// Download the update
progress <- ProgressInfo{Stage: StageDownload, Message: "Starting download..."}
@@ -278,7 +282,11 @@ func (u *Updater) fetchReleases() ([]GitHubRelease, error) {
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
@@ -358,7 +366,11 @@ func (u *Updater) downloadUpdate(updateInfo *UpdateInfo, tempDir string, progres
if err != nil {
return "", err
}
- defer resp.Body.Close()
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ logger.Debug("Failed to close response body: %v", err)
+ }
+ }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
@@ -372,7 +384,11 @@ func (u *Updater) downloadUpdate(updateInfo *UpdateInfo, tempDir string, progres
if err != nil {
return "", err
}
- defer file.Close()
+ defer func() {
+ if err := file.Close(); err != nil {
+ logger.Debug("Failed to close file: %v", err)
+ }
+ }()
// Copy with progress reporting
var downloaded int64
@@ -447,13 +463,21 @@ func (u *Updater) extractTarGz(archivePath, destDir string) (string, error) {
if err != nil {
return "", err
}
- defer file.Close()
+ defer func() {
+ if err := file.Close(); err != nil {
+ logger.Debug("Failed to close file: %v", err)
+ }
+ }()
gzr, err := gzip.NewReader(file)
if err != nil {
return "", err
}
- defer gzr.Close()
+ defer func() {
+ if err := gzr.Close(); err != nil {
+ logger.Debug("Failed to close gzip reader: %v", err)
+ }
+ }()
tr := tar.NewReader(gzr)
@@ -478,13 +502,15 @@ func (u *Updater) extractTarGz(archivePath, destDir string) (string, error) {
}
if _, err := io.Copy(outFile, tr); err != nil {
- outFile.Close()
+ _ = outFile.Close()
return "", err
}
- outFile.Close()
+ if err := outFile.Close(); err != nil {
+ return "", fmt.Errorf("failed to close extracted file: %w", err)
+ }
// Make executable
- if err := os.Chmod(extractPath, 0755); err != nil {
+ if err := os.Chmod(extractPath, 0o755); err != nil {
return "", err
}
@@ -502,7 +528,11 @@ func (u *Updater) extractZip(archivePath, destDir string) (string, error) {
if err != nil {
return "", err
}
- defer r.Close()
+ defer func() {
+ if err := r.Close(); err != nil {
+ logger.Debug("Failed to close zip reader: %v", err)
+ }
+ }()
for _, f := range r.File {
if f.FileInfo().IsDir() {
@@ -522,13 +552,15 @@ func (u *Updater) extractZip(archivePath, destDir string) (string, error) {
outFile, err := os.Create(extractPath)
if err != nil {
- rc.Close()
+ _ = rc.Close()
return "", err
}
_, err = io.Copy(outFile, rc)
- rc.Close()
- outFile.Close()
+ _ = rc.Close()
+ if closeErr := outFile.Close(); closeErr != nil {
+ return "", fmt.Errorf("failed to close extracted file: %w", closeErr)
+ }
if err != nil {
return "", err
@@ -536,7 +568,7 @@ func (u *Updater) extractZip(archivePath, destDir string) (string, error) {
// Make executable on Unix systems
if runtime.GOOS != "windows" {
- if err := os.Chmod(extractPath, 0755); err != nil {
+ if err := os.Chmod(extractPath, 0o755); err != nil {
return "", err
}
}
@@ -562,7 +594,7 @@ func (u *Updater) verifyBinary(binaryPath string) error {
// On Unix systems, check execute permission
if runtime.GOOS != "windows" {
- if info.Mode()&0111 == 0 {
+ if info.Mode()&0o111 == 0 {
return fmt.Errorf("binary is not executable")
}
}
@@ -576,13 +608,21 @@ func (u *Updater) createBackup(srcPath, backupPath string) error {
if err != nil {
return err
}
- defer src.Close()
+ defer func() {
+ if err := src.Close(); err != nil {
+ logger.Debug("Failed to close source file: %v", err)
+ }
+ }()
dst, err := os.Create(backupPath)
if err != nil {
return err
}
- defer dst.Close()
+ defer func() {
+ if err := dst.Close(); err != nil {
+ logger.Debug("Failed to close destination file: %v", err)
+ }
+ }()
_, err = io.Copy(dst, src)
if err != nil {
@@ -607,7 +647,11 @@ func (u *Updater) replaceBinary(newBinaryPath, targetPath string) error {
if err := u.copyFile(newBinaryPath, tempPath); err != nil {
return fmt.Errorf("failed to copy binary to temp location: %w", err)
}
- defer os.Remove(tempPath) // Clean up temp file if something goes wrong
+ defer func() {
+ if err := os.Remove(tempPath); err != nil {
+ logger.Debug("Failed to remove temp file: %v", err)
+ }
+ }() // Clean up temp file if something goes wrong
if runtime.GOOS == "windows" {
// On Windows, move current binary to backup location
@@ -644,7 +688,9 @@ func (u *Updater) replaceBinary(newBinaryPath, targetPath string) error {
}
// Remove the old backup (the process will continue running from the old location)
- os.Remove(backupPath)
+ if err := os.Remove(backupPath); err != nil {
+ logger.Debug("Failed to remove old backup: %v", err)
+ }
}
return nil
@@ -656,13 +702,21 @@ func (u *Updater) copyFile(src, dst string) error {
if err != nil {
return err
}
- defer srcFile.Close()
+ defer func() {
+ if err := srcFile.Close(); err != nil {
+ logger.Debug("Failed to close source file: %v", err)
+ }
+ }()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
- defer dstFile.Close()
+ defer func() {
+ if err := dstFile.Close(); err != nil {
+ logger.Debug("Failed to close destination file: %v", err)
+ }
+ }()
// Copy file contents
_, err = io.Copy(dstFile, srcFile)
diff --git a/main.go b/main.go
index 7c3ea59..0ef22ff 100644
--- a/main.go
+++ b/main.go
@@ -1,25 +1,113 @@
package main
import (
+ "context"
"fmt"
- "os"
- "github.com/streed/ml-notes/cmd"
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+
+ "github.com/streed/ml-notes/internal/config"
+ "github.com/streed/ml-notes/internal/database"
+ "github.com/streed/ml-notes/internal/logger"
+ "github.com/streed/ml-notes/internal/models"
+ "github.com/streed/ml-notes/internal/preferences"
+ "github.com/streed/ml-notes/internal/search"
+ "github.com/streed/ml-notes/internal/services"
)
-// Version is set via ldflags during build
-var Version = "dev"
+// App struct
+type App struct {
+ ctx context.Context
+ services *services.Services
+}
-func main() {
- // Set version for the cmd package
- cmd.Version = Version
+// TestMethod is a simple test method for Wails binding
+func (a *App) TestMethod() string {
+ fmt.Println("๐งช TestMethod called!")
+ return "Hello from TestMethod!"
+}
+
+// NewApp creates a new App application struct
+func NewApp() *App {
+ return &App{}
+}
+
+// OnStartup is called when the app starts up.
+func (a *App) OnStartup(ctx context.Context) {
+ a.ctx = ctx
+
+ fmt.Println("๐ Wails OnStartup called - app is starting")
+
+ // Initialize configuration
+ cfg, err := config.Load()
+ if err != nil {
+ logger.Error("Failed to load configuration: %v", err)
+ // You would need to handle this differently - maybe exit or use defaults
+ return
+ }
+
+ // Initialize database
+ db, err := database.New(cfg)
+ if err != nil {
+ logger.Error("Failed to initialize database: %v", err)
+ return
+ }
- // Create and set the asset provider for embedded web assets
- assetProvider := &EmbeddedAssetProvider{}
- cmd.SetAssetProvider(assetProvider)
+ // Initialize repositories
+ noteRepo := models.NewNoteRepository(db.Conn())
+ prefsRepo := preferences.NewPreferencesRepository(db.Conn())
+
+ // Initialize search
+ vectorSearch := search.NewLilRagSearch(noteRepo, cfg)
+ logger.Debug("Using lil-rag search at: %s", cfg.LilRagURL)
+
+ // Initialize services layer
+ a.services = services.NewServices(cfg, noteRepo, prefsRepo, vectorSearch)
+}
+
+// OnDomReady is called after front-end resources have been loaded
+func (a *App) OnDomReady(ctx context.Context) {
+ // Called when the frontend is ready
+}
+
+// OnBeforeClose is called when the application is about to quit
+func (a *App) OnBeforeClose(ctx context.Context) (prevent bool) {
+ // Return true to prevent the application from quitting
+ return false
+}
+
+// OnShutdown is called when the application is shutting down
+func (a *App) OnShutdown(ctx context.Context) {
+ // Cleanup resources
+ if a.services != nil {
+ if err := a.services.Close(); err != nil {
+ logger.Error("Failed to close services: %v", err)
+ }
+ }
+}
+
+func main() {
+ // Create an instance of the app structure
+ app := NewApp()
- if err := cmd.Execute(); err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
+ // Create application with options
+ err := wails.Run(&options.App{
+ Title: "ML Notes",
+ Width: 1024,
+ Height: 768,
+ AssetServer: &assetserver.Options{Assets: assets},
+ BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
+ OnStartup: app.OnStartup,
+ OnDomReady: app.OnDomReady,
+ OnBeforeClose: app.OnBeforeClose,
+ OnShutdown: app.OnShutdown,
+ Bind: []interface{}{
+ app,
+ },
+ })
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
}
}
diff --git a/test-bindings.html b/test-bindings.html
new file mode 100644
index 0000000..6a4339c
--- /dev/null
+++ b/test-bindings.html
@@ -0,0 +1,43 @@
+
+
+
+ Test Wails Bindings
+
+
+
+ Testing Wails Bindings
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wails.json b/wails.json
new file mode 100644
index 0000000..1d5063b
--- /dev/null
+++ b/wails.json
@@ -0,0 +1,29 @@
+{
+ "name": "ml-notes",
+ "outputfilename": "ml-notes",
+ "frontend:dir": "./frontend",
+ "frontend:build": "npm run build",
+ "frontend:dev": "npm run dev",
+ "backend:dir": "./",
+ "backend:main": "./main.go",
+ "backend:build": "go build -o build/bin/{{.BinaryName}} {{.MainPath}}",
+ "build:dir": "./build",
+ "build:clean": true,
+ "nsis": {
+ "languages": ["English"],
+ "info": {
+ "productName": "ML Notes",
+ "companyName": "ML Notes",
+ "fileDescription": "ML Notes Desktop Application",
+ "copyright": "ยฉ 2024 ML Notes",
+ "productVersion": "1.0.0",
+ "fileVersion": "1.0.0"
+ }
+ },
+ "info": {
+ "productName": "ML Notes",
+ "companyName": "ML Notes",
+ "productVersion": "1.0.0",
+ "copyright": "ยฉ 2024 ML Notes"
+ }
+}
\ No newline at end of file