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 + + + + + + + + + + + +
+ +
+
+
๐Ÿ“ ML Notes
+
+ 0 notes +
+
+
+ +
+ + +
+ + +
+
+ +
+ + + + +
+
+
+
+

Welcome to ML Notes

+

Start creating notes to organize your thoughts and ideas.

+ +
+
+ + + + + + + + + +
+
+
+
+ + + + + + + + + + + \ 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 = '

No notes found.

'; + 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

+
+
+ +
+
+ +
+
+
+ +
+
+ +
+

AI Services

+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+

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}} + + + + + + + + +
+ +
+
+ ๐Ÿ“Š Notes Graph +
+ Loading... +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+ + + +
+ + +
+
+
+ Note (size = connections) +
+
+
+ Connected notes +
+
+
+ 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}} + + + + + + + + + + +
+ +
+
+ ๐Ÿ“ ML Notes +
+ {{.Stats.TotalNotes}} notes +
+
+
+ + + ๐Ÿ“Š + Graph + + + โš™๏ธ + Settings + +
+ + +
+ +
+
+ +
+ + + + +
+
+ {{if or .CurrentNote .CurrentNoteID .IsNewNote}} + +
+
+ +
+ {{if not .IsNewNote}} + + + {{end}} + + {{if not .IsNewNote}} + + {{end}} +
+
+ + +
+ +
+ +
+
+ {{if .CurrentNote}}{{range .CurrentNote.Tags}} + {{.}} ร— + {{end}}{{end}} +
+
+ + +
+
+
+
+ โœ๏ธ Write + +
+ +
+
+
+ ๐Ÿ‘๏ธ Preview + +
+
+
+
+ + + + +
+ + + +
+ {{else}} + {{if and .Notes (gt (len .Notes) 1)}} + +
+
+

๐Ÿ“Š Notes Graph

+
+ {{len .Notes}} notes + Full View +
+
+
+ + +
+
+ {{else}} + +
+
+

Welcome to ML Notes

+

Start creating notes to see them visualized as an interactive graph.

+ +
+
+ {{end}} + {{end}} +
+
+
+
+ + + + + + + + + + + + + + + + + + \ 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 + + + + + + + + +
+ +
+ +
+ ๐Ÿ“ Notes + ๐Ÿ“Š Graph + +
+
+ +
+ +
+
+

โš™๏ธ Configuration Settings

+

Manage your ML Notes configuration settings. Changes are saved automatically.

+ + + +
+ +
+

๐Ÿ” Vector Search & Embeddings

+
+ +

Enable semantic search using vector embeddings for better note discovery

+
+
+ + +

The model to use for generating text embeddings

+
+
+ + +

Number of dimensions for vector embeddings (must match your model)

+
+
+ + +
+

๐Ÿฆ™ Ollama Integration

+
+ + +

URL of your Ollama server for AI-powered features

+
+
+ + +
+

๐Ÿ“Š Summarization & Analysis

+
+ +

Enable AI-powered note summarization and analysis features

+
+
+ + +

The model to use for text summarization and analysis

+
+
+ + +
+

๐Ÿท๏ธ Auto-tagging

+
+ +

Automatically suggest and apply tags to notes using AI

+
+
+ + +

Maximum number of tags to automatically generate per note

+
+
+ + +
+

โœ๏ธ Editor Configuration

+
+ + +

Command to use for editing notes (leave empty for auto-detection)

+
+
+ + +
+

๐Ÿ”„ Update Configuration

+
+ + +

GitHub username/organization for application updates

+
+
+ + +

Repository name for application updates

+
+
+ + +
+

๐Ÿ”ง System Configuration

+
+ +

Enable verbose logging for troubleshooting

+
+
+ + +

Location where notes database and files are stored

+
+
+ + +

Full path to the notes database file

+
+
+ + +
+ + + +
+
+
+
+
+
+ + + + \ 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