Compare commits

..

5 Commits

Author SHA1 Message Date
joakim f05d6e154e gradient gutters 2026-02-17 21:57:34 +01:00
joakim 4dfef88f19 refactor: use Vite built-in DEV flag instead of VITE_OAUTH_ENABLED
import.meta.env.DEV is already true during `bun run dev` and false in
production builds, so a separate env var is unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:19:34 +01:00
joakim d51c6da18d feat: replace mock mode with real backend dev mode
Add --dev flag to `opal server start` that disables auth (injects
userID=1 for all requests) and exposes a /auth/dev-session endpoint,
so the frontend can develop against a real backend without OAuth
config. Remove VITE_MOCK_MODE and all mock data/branches from the
frontend stores. Add scripts/dev.sh to start both services locally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:07:34 +01:00
joakim 80ea17227d fix: prevent nil-panic on server and improve OAuth callback handling
Load config eagerly during server startup so sortByUrgency never
hits a nil config. Add nil-guard in BuildUrgencyCoefficients as
belt-and-suspenders defense. Fix OAuth callback to support both
GET and POST, and resolve issuer URLs properly with path.Dir.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:40:53 +01:00
joakim c5a963bfd9 fix: make LoadConfig read-only to prevent panic on read-only filesystems
LoadConfig() tried to create directories and write opal.yml as a side
effect of loading config. On the server (where /etc/opal is in systemd
ReadOnlyPaths), this failed, returning nil. All internal GetConfig()
callers discarded the error, passing nil to BuildUrgencyCoefficients()
which panicked on nil dereference.

Redesign the config system with layered, read-only loading:
- Defaults (always present) → YAML file (if exists) → OPAL_ env vars
- LoadConfig never writes to the filesystem or returns nil
- File creation moved to explicit InitConfig() for CLI first-run/setup
- SaveConfig uses yaml.Marshal instead of manual field-by-field Viper
  calls, eliminating the three-place maintenance burden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:04:54 +01:00
19 changed files with 807 additions and 712 deletions
-54
View File
@@ -1,54 +0,0 @@
# Notr
Simple Go application for organizing and referencing notes. Loosely based on Obsidian.
**Implementation:** See `jade-depo/` directory for the CLI tool.
## Workflow
I take notes in two primary ways:
### Phone
- Quick notes, on the go.
- View and search notes.
### Workstation
- Using NeoVim for notetaking
### Other infrastructure
- I host a VPS with a Nextcloud and Gitea instances.
## What I want
- A Obsidian Vault like structure. A folder where notes live.
- A file is a note
- Can also store attachments, such as images. These files can then be referenced in the relevant notes.
- Directories is the main organization method, although tags and links can seam-lesly cross directories boundries.
- Markdown syntax (this can be handled by NeoVim and a markdown editor on other devices.)
- Tags: Syntax +tag
- Note links for referencing other notes or any other vault files. Syntax uncertain. Obsidian uses [[]]?
- See reports about the vault. Tag report
- At some point I would like to have a web-app and host it on my server. This would integrate with my authentik service for auth, and would be a live view of a users vault
- OCR would be great.
## Implementation
I have a tendency to scope creep and never actually getting a usable product, so an important goal here is practicing getting a usable app up and running. This should not have to be the biggest project, so I'll try to predict the process:
### Version 0.1
Here I use other tools for the note-taking and accept that any searching is on a directory basis only.
- [ ] I create a directory in Nextcloud. This I will start using immediately.
- [ ] Find a good Markdown editor for android.
- [ ] Adopt any crutial Obsidian notes
### Version 1.0 ✓
This is where I can use Notr to find and search notes on my workstation. CLI implementation complete!
- [x] Process notes. Metadata and diffs
- [x] Search and Filter by tags
- [x] Search and Filter by content
- [x] Add, edit, delete notes
- [x] List all notes and tags
### Version 2.0
Here I can do the same on my phone.
Also:
- [ ] OCR
## Metadata approach
Multiple approaches possible.
+482
View File
@@ -0,0 +1,482 @@
# Config System Redesign
## 1. Problem Statement
`LoadConfig()` panics on the server because the config system assumes a writable
config directory. On the server, `OPAL_CONFIG_DIR=/etc/opal` is read-only
(systemd `ReadOnlyPaths`), `opal.yml` doesn't exist (Ansible only deploys
`opal.env`), and the attempt to create it fails. The nil `*Config` propagates
unchecked through `GetConfig()` callers to `BuildUrgencyCoefficients(nil)`,
causing a nil-pointer panic.
### Panic chain
```
sortByUrgency() report.go:426
cfg, _ := GetConfig() ← error discarded, cfg = nil
coeffs := BuildUrgencyCoefficients(cfg)
return &UrgencyCoefficients{
Due: cfg.UrgencyDue, ← nil dereference → PANIC
}
```
### Root cause
The config system was designed for the CLI (user-writable `~/.config/opal/`)
and has three interacting issues:
1. **Write-on-read**: `LoadConfig()` creates directories and writes a default
`opal.yml` as a side effect of *reading* config. This fails when the
filesystem is read-only.
2. **Error swallowing**: All internal callers use `cfg, _ := GetConfig()`,
turning a load failure into a nil-pointer panic instead of a graceful error.
3. **No mode awareness**: The same code path runs for CLI users (writable home
dir, interactive, config file is useful) and for the server (read-only
`/etc/opal`, headless, defaults are fine).
---
## 2. Current Architecture
```mermaid
graph TD
subgraph "Config Sources"
ENV["opal.env<br/>(systemd EnvironmentFile)"]
YML["opal.yml<br/>(Viper / YAML)"]
DEFAULTS["Hardcoded defaults<br/>(in LoadConfig)"]
end
subgraph "Loading Mechanisms"
AUTH_LOAD["auth.LoadConfig()<br/>os.Getenv() → auth.Config"]
ENGINE_LOAD["engine.LoadConfig()<br/>Viper → engine.Config"]
end
subgraph "Consumers"
SERVER["API Server<br/>(handlers, middleware)"]
CLI["CLI Commands<br/>(cmd/ package)"]
end
ENV --> AUTH_LOAD
YML --> ENGINE_LOAD
DEFAULTS --> ENGINE_LOAD
AUTH_LOAD --> SERVER
ENGINE_LOAD --> SERVER
ENGINE_LOAD --> CLI
```
### Two independent config subsystems
| Aspect | `engine.Config` | `auth.Config` |
|--------|----------------|---------------|
| **Source** | `opal.yml` via Viper | Environment variables |
| **Loaded by** | `engine.LoadConfig()` | `auth.LoadConfig()` |
| **Caching** | Singleton (`globalConfig`) | None (re-read each call) |
| **Write side effects** | Creates dir + file on load | None |
| **Error model** | Returns `(*Config, error)` | Returns `*Config` (no error) |
| **Used by** | CLI + Server (lazy) | Server only |
### `engine.Config` field categories
| Category | Fields | CLI | Server | Notes |
|----------|--------|-----|--------|-------|
| **Display** | `DefaultFilter`, `DefaultSort`, `DefaultReport`, `ColorOutput` | Yes | No | Terminal-only |
| **Date** | `WeekStartDay`, `DefaultDueTime` | Yes | Yes | Used by `ParseDate()` |
| **Urgency** | 13 urgency coefficients | Yes | Yes | Core scoring logic |
| **Limits** | `NextLimit` | Yes | Indirectly | Report limit |
| **Sync** | 6 sync fields | Yes | No | Client-side sync config |
Key observation: The server only needs **urgency coefficients** and **date
settings** from `engine.Config`. It doesn't need display preferences or sync
settings. But all 30 fields are loaded through the same mechanism.
### `engine.LoadConfig()` flow
```
1. Check globalConfig singleton → return if cached
2. GetConfigDir() → resolve directory path
3. os.MkdirAll(configDir) ← FAILS on read-only FS
4. Viper.ReadInConfig()
5. If read fails:
a. Viper.WriteConfigAs() ← FAILS on read-only FS
b. Re-read written file
6. Unmarshal → Config struct
7. Cache in globalConfig
```
Steps 3 and 5a are the failure points on the server.
### Caller error handling audit
| Caller | File:Line | Error handling |
|--------|-----------|----------------|
| `FormatTaskListWithFormat` | display.go:24 | `cfg, _ := GetConfig()`**ignored** |
| `formatMinimalLine` | display.go:119 | `cfg, _ := GetConfig()`**ignored** |
| `NextReport.SortFunc` | report.go:168 | `cfg, _ := GetConfig()`**ignored** |
| `NextReport.LimitFunc` | report.go:187 | `cfg, _ := GetConfig()`**ignored** |
| `sortByUrgency` | report.go:426 | `cfg, _ := GetConfig()`**ignored** |
| `PopulateUrgency` | task.go:769 | `cfg, _ := GetConfig()`**ignored** |
| `getWeekStart` | dateparse.go:484 | Checked — falls back to Monday |
| All cmd/ callers | root.go, sync.go | Checked — exits on error |
Every engine-internal caller except `dateparse.go` discards the error.
---
## 3. Additional Issues Found
### 3a. Config file clobbering
`LoadConfig()` line 296-306 catches **any** `ReadInConfig` error (not just
"file not found") and overwrites the config file with defaults. A YAML syntax
error in the user's `opal.yml` silently destroys their customizations.
### 3b. `SaveConfig()` manual field sync
Adding a new config field requires updating three places:
1. `Config` struct definition
2. `LoadConfig()``v.SetDefault(...)` call
3. `SaveConfig()``v.Set(...)` call
Missing any one of these causes silent data loss on save or missing defaults.
### 3c. `auth.LoadConfig()` silent parse failures
```go
jwtExpiry, _ := strconv.Atoi(getEnv("JWT_EXPIRY", "3600"))
```
If `JWT_EXPIRY=abc`, `Atoi` returns `(0, error)`, error is discarded, and
`JWTExpiry` silently becomes 0 (tokens expire immediately).
### 3d. Insecure JWT default
`JWT_SECRET` defaults to `"change-me-in-production"`. While `validateServerConfig()`
checks for empty values, it doesn't check for the insecure default.
### 3e. Sync API key in plaintext
`sync_api_key` is stored in `opal.yml` with no special file permissions.
### 3f. No env-var override of YAML config
Viper's `AutomaticEnv()` is never called, so there's no way to override YAML
config values via environment variables. This is an obstacle for 12-factor
server deployments.
---
## 4. Design Options
### Option A: Minimal fix — graceful fallback to defaults
**Change**: Make `LoadConfig()` return a `DefaultConfig()` when it can't read
or write the config file, instead of returning nil.
```go
func DefaultConfig() *Config {
return &Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
// ... all defaults ...
UrgencyDue: 12.0,
// ...
}
}
func LoadConfig() (*Config, error) {
if globalConfig != nil {
return globalConfig, nil
}
cfg, err := loadFromFile()
if err != nil {
// File not available — use defaults (common in server mode)
cfg = DefaultConfig()
}
globalConfig = cfg
return cfg, nil
}
```
**Pros**: Smallest change, fixes the panic, no behavior change for CLI users.
**Cons**: Doesn't address the architectural confusion. Error-swallowing callers
remain. Write-on-read side effect remains for CLI. Three-source config stays.
---
### Option B: Separate load paths for CLI and server
**Change**: Split `LoadConfig()` into two functions:
- `LoadConfigFromFile()` — current behavior for CLI (read/write YAML)
- `LoadConfigFromDefaults()` — returns `DefaultConfig()` for server
The server startup code calls `LoadConfigFromDefaults()` eagerly during init,
populating the singleton before any lazy `GetConfig()` call.
```go
// cmd/server.go — in serverStartCmd.Run, before InitDB()
engine.LoadConfigFromDefaults()
```
**Pros**: Fixes the panic. Server path never touches the filesystem for config.
Explicit about which mode is running. CLI behavior unchanged.
**Cons**: Two code paths to maintain. Server can't be customized without code
changes (urgency tuning, etc.).
---
### Option C: Read-only load with env-var overrides (recommended)
**Change**: Restructure `LoadConfig()` to:
1. Start with hardcoded defaults (always succeeds)
2. Layer YAML file on top **if it exists** (read-only, never create)
3. Layer environment variables on top (via Viper `AutomaticEnv`)
4. Never write to the filesystem as a side effect of loading
```go
func LoadConfig() (*Config, error) {
if globalConfig != nil {
return globalConfig, nil
}
v := viper.New()
v.SetConfigType("yaml")
// 1. Hardcoded defaults (always present)
setDefaults(v)
// 2. YAML file overlay (optional, read-only)
if configPath, err := GetConfigPath(); err == nil {
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// File exists but is malformed — report, don't clobber
if _, statErr := os.Stat(configPath); statErr == nil {
return nil, fmt.Errorf("config file %s is invalid: %w", configPath, err)
}
// File doesn't exist — that's fine, use defaults
}
}
}
// 3. Environment variable overlay
v.SetEnvPrefix("OPAL")
v.AutomaticEnv()
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
globalConfig = cfg
return cfg, nil
}
```
File creation moves to an explicit `InitConfig()` function, called only during
`opal setup` and CLI first-run:
```go
func InitConfig() error {
configDir, err := GetConfigDir()
if err != nil {
return err
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
return SaveConfig(DefaultConfig())
}
```
Environment variable mapping:
| YAML key | Env var |
|----------|---------|
| `urgency_due_coefficient` | `OPAL_URGENCY_DUE_COEFFICIENT` |
| `default_filter` | `OPAL_DEFAULT_FILTER` |
| `week_start_day` | `OPAL_WEEK_START_DAY` |
| ... | `OPAL_<UPPER_SNAKE>` |
**Pros**:
- Fixes the panic — loading never fails fatally (missing file = defaults)
- Malformed YAML is reported, not silently clobbered
- Server can tune urgency via `opal.env` without deploying `opal.yml`
- 12-factor compatible (env vars override everything)
- CLI behavior unchanged (reads existing `opal.yml` + env overrides)
- Single code path for both CLI and server
- No filesystem write side effects during load
**Cons**:
- `SaveConfig()` still needs the manual field-sync (separate improvement)
- Slightly more Viper configuration
---
### Option D: Full restructure with typed config sections
**Change**: Split `Config` into domain-specific sub-configs and unify the
loading mechanism for all config (merge `auth.Config` into the same system).
```go
type Config struct {
Display DisplayConfig `mapstructure:"display"`
Urgency UrgencyConfig `mapstructure:"urgency"`
Sync SyncConfig `mapstructure:"sync"`
Server ServerConfig `mapstructure:"server"` // absorbs auth.Config
}
```
**Pros**: Clean separation. Single config system. Extensible.
**Cons**: Large refactor. Breaks existing `opal.yml` format. `auth.Config`
works fine as-is for its purpose. Over-engineered for the current problem.
---
## 5. Recommendation
**Option C** — read-only load with env-var overrides.
It fixes the immediate panic, eliminates the write-on-read footgun, enables
server customization via environment variables, and does it with a focused
change to one function. It doesn't over-engineer or restructure things that
work.
### Implementation plan
#### Step 1: Extract `DefaultConfig()` constructor
Create a `DefaultConfig()` function that returns a `*Config` with all defaults
populated. This is the single source of truth for default values — used by
`LoadConfig()`, `InitConfig()`, and `SaveConfig()`.
**Files**: `internal/engine/config.go`
#### Step 2: Rewrite `LoadConfig()` to be read-only
Remove directory creation and file writing. Layer: defaults → YAML (if
exists) → env vars. Return `DefaultConfig()` on missing file instead of nil.
Return error only on malformed YAML (file exists but can't be parsed).
**Files**: `internal/engine/config.go`
#### Step 3: Extract `InitConfig()` for file creation
Move the "create config directory + write defaults" logic into `InitConfig()`.
Call it from `initializeApp()` (CLI first-run path) and `opal setup`.
**Files**: `internal/engine/config.go`, `cmd/root.go`, `cmd/setup.go`
#### Step 4: Fix error-swallowing callers
Two options, from least to most invasive:
**4a (recommended)**: Make `GetConfig()` never return nil. Since `LoadConfig()`
now always returns a valid `*Config` (defaults on failure), `GetConfig()` also
always returns non-nil. The `cfg, _ := GetConfig()` pattern becomes safe. The
error return is kept for callers that want to distinguish "loaded from file" vs
"using defaults" but nil is never returned.
**4b (defensive)**: Also make `BuildUrgencyCoefficients()` accept nil and return
default coefficients. Belt-and-suspenders.
**Files**: `internal/engine/config.go`, `internal/engine/urgency.go`
#### Step 5: Enable env-var overrides via Viper
Add `v.SetEnvPrefix("OPAL")` and `v.AutomaticEnv()` so that any config key
can be overridden by `OPAL_<KEY>`. Update `opal.env` in deployment docs to
show urgency tuning examples.
**Files**: `internal/engine/config.go`, `docs/deployment.md`
#### Step 6: Eliminate `SaveConfig()` field duplication
Replace the manual `v.Set()` calls with Viper's `mapstructure` round-trip or
a reflect-based helper so new fields are automatically included.
```go
func SaveConfig(cfg *Config) error {
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
// Use mapstructure tags to populate viper from struct
data, _ := mapstructure.Decode(cfg) // or manual marshal
for k, val := range data {
v.Set(k, val)
}
return v.WriteConfig()
}
```
Alternatively, marshal to YAML directly with `yaml.Marshal` and write the
bytes, bypassing Viper for the write path entirely.
**Files**: `internal/engine/config.go`
### Deployment changes
After this redesign, the Ansible role needs **no changes**`opal.env` is
sufficient. To customize urgency coefficients on the server, add lines to
`opal.env`:
```bash
# Server-side urgency tuning (optional)
OPAL_URGENCY_DUE_COEFFICIENT=12.0
OPAL_URGENCY_AGE_MAX=180
```
No `opal.yml` deployment needed. The server runs on defaults + env overrides.
---
## 6. Technical Decisions
### ADR-1: Config loading must not write to the filesystem
- **Context**: `LoadConfig()` creates directories and writes `opal.yml` as a
side effect. This fails on read-only filesystems (server) and is surprising
behavior for a "load" function.
- **Decision**: `LoadConfig()` becomes pure read. File creation moves to
`InitConfig()`, called explicitly during setup/first-run.
- **Alternatives**: Deploy `opal.yml` via Ansible; make `/etc/opal` writable.
- **Consequences**: CLI first-run code must call `InitConfig()` explicitly.
`IsFirstRun()` check remains in `initializeApp()`.
### ADR-2: Missing config file returns defaults, not an error
- **Context**: On the server, `opal.yml` doesn't exist and doesn't need to.
The current code treats this as a fatal error.
- **Decision**: Missing file = use defaults silently. Malformed file = return
error (don't clobber). `GetConfig()` never returns nil.
- **Alternatives**: Require `opal.yml` everywhere; use separate load functions
per mode.
- **Consequences**: Callers that discard errors (`cfg, _ := GetConfig()`) are
now safe. The error return still exists for callers that need to distinguish
"file loaded" from "using defaults".
### ADR-3: Environment variables can override any config key
- **Context**: The server is configured entirely via env vars (`opal.env` +
systemd). Currently urgency coefficients can only be set via `opal.yml`.
- **Decision**: Enable Viper's `AutomaticEnv()` with prefix `OPAL_`. Any YAML
key `foo_bar` can be overridden by `OPAL_FOO_BAR`.
- **Alternatives**: Add a server-specific config file; keep config values
hardcoded for server.
- **Consequences**: Layered config: defaults < YAML file < env vars. Aligns
with 12-factor app principles. Deployment can tune behavior without deploying
additional files.
### ADR-4: Do not merge auth.Config into engine.Config
- **Context**: `auth.Config` and `engine.Config` are completely independent
systems. Merging them would create a single unified config.
- **Decision**: Keep them separate. `auth.Config` loads from env vars, works
correctly, and is server-only. No reason to change it.
- **Alternatives**: Unified config struct with sections.
- **Consequences**: Two config types remain. This is acceptable because they
serve different purposes, have different lifecycles, and the current
`auth.Config` has no bugs.
+10 -6
View File
@@ -244,16 +244,20 @@ func initializeApp() {
os.Exit(1) os.Exit(1)
} }
// Load config // On first run, create the config file with defaults
if isFirstRun {
if err := engine.InitConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err)
os.Exit(1)
}
showFirstRunMessage()
}
// Load config (reads file if present, otherwise uses defaults)
if _, err := engine.LoadConfig(); err != nil { if _, err := engine.LoadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Show first-run message after config is created
if isFirstRun {
showFirstRunMessage()
}
} }
func showFirstRunMessage() { func showFirstRunMessage() {
+19 -2
View File
@@ -97,21 +97,37 @@ var serverStartCmd = &cobra.Command{
Examples: Examples:
opal server start opal server start
opal server start --addr :8080 opal server start --addr :8080
opal server start --dev
opal server start --db /var/lib/opal/opal.db`, opal server start --db /var/lib/opal/opal.db`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
addr, _ := cmd.Flags().GetString("addr") addr, _ := cmd.Flags().GetString("addr")
dbPath, _ := cmd.Flags().GetString("db") dbPath, _ := cmd.Flags().GetString("db")
devMode, _ := cmd.Flags().GetBool("dev")
// Override DB path if specified // Override DB path if specified
if dbPath != "" { if dbPath != "" {
os.Setenv("OPAL_DB_PATH", dbPath) os.Setenv("OPAL_DB_PATH", dbPath)
} }
// Validate server configuration // In dev mode, skip OAuth config validation
if devMode {
fmt.Println("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓")
fmt.Println("┃ ⚠ DEV MODE ENABLED ⚠ ┃")
fmt.Println("┃ Auth disabled — all requests use uid 1 ┃")
fmt.Println("┃ Do NOT use in production! ┃")
fmt.Println("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")
} else {
if err := validateServerConfig(); err != nil { if err := validateServerConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err) fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err)
os.Exit(1) os.Exit(1)
} }
}
// Load config (read-only — uses defaults if no opal.yml exists)
if _, err := engine.LoadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Initialize database // Initialize database
if err := engine.InitDB(); err != nil { if err := engine.InitDB(); err != nil {
@@ -121,7 +137,7 @@ Examples:
defer engine.CloseDB() defer engine.CloseDB()
// Create and start server // Create and start server
server := api.NewServer(addr) server := api.NewServer(addr, devMode)
if err := server.Start(); err != nil { if err := server.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -188,6 +204,7 @@ func init() {
serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address") serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address")
serverStartCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") serverStartCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)")
serverStartCmd.Flags().Bool("dev", false, "Enable dev mode (no auth, no OAuth env vars required)")
keygenCmd.Flags().StringP("name", "n", "", "Name for this API key (e.g., device name)") keygenCmd.Flags().StringP("name", "n", "", "Name for this API key (e.g., device name)")
keygenCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") keygenCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)")
+11 -57
View File
@@ -121,33 +121,6 @@ func runQuickSetup() {
os.Exit(1) os.Exit(1)
} }
// Create config
cfg := engine.Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
DefaultReport: "list",
ColorOutput: true,
WeekStartDay: "monday",
DefaultDueTime: "",
NextLimit: 5,
SyncEnabled: false,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Create directories // Create directories
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err)) wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err))
@@ -158,8 +131,8 @@ func runQuickSetup() {
os.Exit(1) os.Exit(1)
} }
// Save config // Save default config
if err := engine.SaveConfig(&cfg); err != nil { if err := engine.SaveConfig(engine.DefaultConfig()); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err)) wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err))
os.Exit(1) os.Exit(1)
} }
@@ -304,34 +277,15 @@ func runInteractiveSetup() {
return return
} }
// Create configuration // Create configuration from defaults, then apply user choices
cfg := &engine.Config{ cfg := engine.DefaultConfig()
DefaultFilter: defaultFilter, cfg.DefaultFilter = defaultFilter
DefaultSort: "due,priority", cfg.DefaultReport = reportNames[defaultReport]
DefaultReport: reportNames[defaultReport], cfg.ColorOutput = colorOutput
ColorOutput: colorOutput, cfg.WeekStartDay = weekStartDay
WeekStartDay: weekStartDay, cfg.SyncEnabled = syncEnabled
DefaultDueTime: "", cfg.SyncURL = syncURL
NextLimit: 5, cfg.SyncAPIKey = syncAPIKey
SyncEnabled: syncEnabled,
SyncURL: syncURL,
SyncAPIKey: syncAPIKey,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Set directory overrides // Set directory overrides
engine.SetConfigDirOverride(configDir) engine.SetConfigDirOverride(configDir)
+16 -1
View File
@@ -39,7 +39,22 @@ func GetLoginURL(w http.ResponseWriter, r *http.Request) {
// OAuthCallback handles the OAuth callback // OAuthCallback handles the OAuth callback
func OAuthCallback(w http.ResponseWriter, r *http.Request) { func OAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code") var code string
// Support both GET (direct OAuth redirect) and POST (frontend exchange)
if r.Method == http.MethodPost {
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
code = req.Code
} else {
code = r.URL.Query().Get("code")
}
if code == "" { if code == "" {
errorResponse(w, http.StatusBadRequest, "missing code parameter") errorResponse(w, http.StatusBadRequest, "missing code parameter")
return return
+10
View File
@@ -73,6 +73,16 @@ func GetUserID(r *http.Request) int {
return userID return userID
} }
// DevAuthMiddleware always injects userID=1 into context — for local dev only
func DevAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), userIDKey, 1)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// CORSMiddleware adds CORS headers for future web frontend // CORSMiddleware adds CORS headers for future web frontend
func CORSMiddleware() func(http.Handler) http.Handler { func CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
+25 -1
View File
@@ -13,13 +13,15 @@ import (
type Server struct { type Server struct {
router chi.Router router chi.Router
addr string addr string
devMode bool
} }
// NewServer creates a new API server // NewServer creates a new API server
func NewServer(addr string) *Server { func NewServer(addr string, devMode bool) *Server {
s := &Server{ s := &Server{
router: chi.NewRouter(), router: chi.NewRouter(),
addr: addr, addr: addr,
devMode: devMode,
} }
s.setupRoutes() s.setupRoutes()
return s return s
@@ -39,15 +41,37 @@ func (s *Server) setupRoutes() {
JSON(w, http.StatusOK, map[string]string{"status": "ok"}) JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}) })
if s.devMode {
// Dev mode: fake session endpoint so the frontend can "log in"
r.Get("/auth/dev-session", func(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, map[string]interface{}{
"access_token": "dev-token",
"refresh_token": "",
"expires_at": 9999999999,
"token_type": "bearer",
"user": map[string]interface{}{
"id": 1,
"username": "dev",
"email": "dev@localhost",
},
})
})
} else {
// OAuth endpoints (no auth required) // OAuth endpoints (no auth required)
r.Get("/auth/login", handlers.GetLoginURL) r.Get("/auth/login", handlers.GetLoginURL)
r.Get("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/callback", handlers.OAuthCallback) r.Post("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/refresh", handlers.RefreshToken) r.Post("/auth/refresh", handlers.RefreshToken)
r.Post("/auth/logout", handlers.Logout) r.Post("/auth/logout", handlers.Logout)
}
// Protected routes // Protected routes
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
if s.devMode {
r.Use(DevAuthMiddleware())
} else {
r.Use(AuthMiddleware()) r.Use(AuthMiddleware())
}
// Tasks // Tasks
r.Route("/tasks", func(r chi.Router) { r.Route("/tasks", func(r chi.Router) {
+16 -3
View File
@@ -6,10 +6,23 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"path"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
// issuerBase resolves ".." to get the base OAuth path from the issuer URL.
// e.g. "https://auth.example.com/application/o/app/" -> "https://auth.example.com/application/o/"
func issuerBase(issuer string) string {
u, err := url.Parse(issuer)
if err != nil {
return issuer
}
u.Path = path.Dir(path.Clean(u.Path)) + "/"
return u.String()
}
type OAuthClient struct { type OAuthClient struct {
config *oauth2.Config config *oauth2.Config
cfg *Config cfg *Config
@@ -22,8 +35,8 @@ func NewOAuthClient(cfg *Config) *OAuthClient {
ClientSecret: cfg.OAuthClientSecret, ClientSecret: cfg.OAuthClientSecret,
RedirectURL: cfg.OAuthRedirectURI, RedirectURL: cfg.OAuthRedirectURI,
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: cfg.OAuthIssuer + "../authorize/", AuthURL: issuerBase(cfg.OAuthIssuer) + "authorize/",
TokenURL: cfg.OAuthIssuer + "../token/", TokenURL: issuerBase(cfg.OAuthIssuer) + "token/",
}, },
Scopes: []string{"openid", "profile", "email"}, Scopes: []string{"openid", "profile", "email"},
}, },
@@ -47,7 +60,7 @@ type UserInfo struct {
} }
func (c *OAuthClient) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) { func (c *OAuthClient) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.cfg.OAuthIssuer+"../userinfo/", nil) req, err := http.NewRequestWithContext(ctx, "GET", issuerBase(c.cfg.OAuthIssuer)+"userinfo/", nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+141 -122
View File
@@ -8,39 +8,40 @@ import (
"time" "time"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.yaml.in/yaml/v3"
) )
type Config struct { type Config struct {
DefaultFilter string `mapstructure:"default_filter"` DefaultFilter string `mapstructure:"default_filter" yaml:"default_filter"`
DefaultSort string `mapstructure:"default_sort"` DefaultSort string `mapstructure:"default_sort" yaml:"default_sort"`
DefaultReport string `mapstructure:"default_report"` DefaultReport string `mapstructure:"default_report" yaml:"default_report"`
ColorOutput bool `mapstructure:"color_output"` ColorOutput bool `mapstructure:"color_output" yaml:"color_output"`
WeekStartDay string `mapstructure:"week_start_day"` WeekStartDay string `mapstructure:"week_start_day" yaml:"week_start_day"`
DefaultDueTime string `mapstructure:"default_due_time"` DefaultDueTime string `mapstructure:"default_due_time" yaml:"default_due_time"`
// Urgency coefficients // Urgency coefficients
UrgencyDue float64 `mapstructure:"urgency_due_coefficient"` UrgencyDue float64 `mapstructure:"urgency_due_coefficient" yaml:"urgency_due_coefficient"`
UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient"` UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient" yaml:"urgency_priority_h_coefficient"`
UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient"` UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient" yaml:"urgency_priority_m_coefficient"`
UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient"` UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient" yaml:"urgency_priority_d_coefficient"`
UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient"` UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient" yaml:"urgency_priority_l_coefficient"`
UrgencyActive float64 `mapstructure:"urgency_active_coefficient"` UrgencyActive float64 `mapstructure:"urgency_active_coefficient" yaml:"urgency_active_coefficient"`
UrgencyAge float64 `mapstructure:"urgency_age_coefficient"` UrgencyAge float64 `mapstructure:"urgency_age_coefficient" yaml:"urgency_age_coefficient"`
UrgencyAgeMax int `mapstructure:"urgency_age_max"` UrgencyAgeMax int `mapstructure:"urgency_age_max" yaml:"urgency_age_max"`
UrgencyTags float64 `mapstructure:"urgency_tags_coefficient"` UrgencyTags float64 `mapstructure:"urgency_tags_coefficient" yaml:"urgency_tags_coefficient"`
UrgencyProject float64 `mapstructure:"urgency_project_coefficient"` UrgencyProject float64 `mapstructure:"urgency_project_coefficient" yaml:"urgency_project_coefficient"`
UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient"` UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient" yaml:"urgency_waiting_coefficient"`
UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag"` UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag" yaml:"urgency_urgent_tag"`
UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient"` UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient" yaml:"urgency_urgent_coefficient"`
NextLimit int `mapstructure:"next_limit"` NextLimit int `mapstructure:"next_limit" yaml:"next_limit"`
// Sync settings // Sync settings
SyncEnabled bool `mapstructure:"sync_enabled"` SyncEnabled bool `mapstructure:"sync_enabled" yaml:"sync_enabled"`
SyncURL string `mapstructure:"sync_url"` SyncURL string `mapstructure:"sync_url" yaml:"sync_url"`
SyncAPIKey string `mapstructure:"sync_api_key"` SyncAPIKey string `mapstructure:"sync_api_key" yaml:"sync_api_key"`
SyncClientID string `mapstructure:"sync_client_id"` SyncClientID string `mapstructure:"sync_client_id" yaml:"sync_client_id"`
SyncStrategy string `mapstructure:"sync_strategy"` SyncStrategy string `mapstructure:"sync_strategy" yaml:"sync_strategy"`
SyncQueueOffline bool `mapstructure:"sync_queue_offline"` SyncQueueOffline bool `mapstructure:"sync_queue_offline" yaml:"sync_queue_offline"`
} }
var globalConfig *Config var globalConfig *Config
@@ -234,77 +235,105 @@ func IsFirstRun() bool {
return !ConfigExists() return !ConfigExists()
} }
// LoadConfig loads the configuration from file or creates default // DefaultConfig returns a Config populated with all default values.
// This is the single source of truth for defaults.
func DefaultConfig() *Config {
return &Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
DefaultReport: "list",
ColorOutput: true,
WeekStartDay: "monday",
DefaultDueTime: "",
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
NextLimit: 5,
SyncEnabled: false,
SyncURL: "",
SyncAPIKey: "",
SyncClientID: "",
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
}
}
// setViperDefaults registers all default values with a Viper instance.
func setViperDefaults(v *viper.Viper) {
d := DefaultConfig()
v.SetDefault("default_filter", d.DefaultFilter)
v.SetDefault("default_sort", d.DefaultSort)
v.SetDefault("default_report", d.DefaultReport)
v.SetDefault("color_output", d.ColorOutput)
v.SetDefault("week_start_day", d.WeekStartDay)
v.SetDefault("default_due_time", d.DefaultDueTime)
v.SetDefault("urgency_due_coefficient", d.UrgencyDue)
v.SetDefault("urgency_priority_h_coefficient", d.UrgencyPriorityH)
v.SetDefault("urgency_priority_m_coefficient", d.UrgencyPriorityM)
v.SetDefault("urgency_priority_d_coefficient", d.UrgencyPriorityD)
v.SetDefault("urgency_priority_l_coefficient", d.UrgencyPriorityL)
v.SetDefault("urgency_active_coefficient", d.UrgencyActive)
v.SetDefault("urgency_age_coefficient", d.UrgencyAge)
v.SetDefault("urgency_age_max", d.UrgencyAgeMax)
v.SetDefault("urgency_tags_coefficient", d.UrgencyTags)
v.SetDefault("urgency_project_coefficient", d.UrgencyProject)
v.SetDefault("urgency_waiting_coefficient", d.UrgencyWaiting)
v.SetDefault("urgency_urgent_tag", d.UrgencyUrgentTag)
v.SetDefault("urgency_urgent_coefficient", d.UrgencyUrgentCoeff)
v.SetDefault("next_limit", d.NextLimit)
v.SetDefault("sync_enabled", d.SyncEnabled)
v.SetDefault("sync_url", d.SyncURL)
v.SetDefault("sync_api_key", d.SyncAPIKey)
v.SetDefault("sync_client_id", d.SyncClientID)
v.SetDefault("sync_strategy", d.SyncStrategy)
v.SetDefault("sync_queue_offline", d.SyncQueueOffline)
}
// LoadConfig loads configuration using layered sources:
// 1. Hardcoded defaults (always present)
// 2. YAML config file (optional, read-only — never created as a side effect)
// 3. Environment variables with OPAL_ prefix (override everything)
//
// Returns a valid *Config even if no config file exists. Never returns nil.
// Returns an error only if the config file exists but is malformed.
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
if globalConfig != nil { if globalConfig != nil {
return globalConfig, nil return globalConfig, nil
} }
configDir, err := GetConfigDir()
if err != nil {
return nil, err
}
// Ensure config directory exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
v := viper.New() v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml") v.SetConfigType("yaml")
// Set defaults // Layer 1: Hardcoded defaults
v.SetDefault("default_filter", "status:pending") setViperDefaults(v)
v.SetDefault("default_sort", "due,priority")
v.SetDefault("default_report", "list")
v.SetDefault("color_output", true)
v.SetDefault("week_start_day", "monday")
v.SetDefault("default_due_time", "")
// Urgency defaults (adjusted for Opal's simpler model) // Layer 2: YAML config file (optional, read-only)
v.SetDefault("urgency_due_coefficient", 12.0) if configPath, err := GetConfigPath(); err == nil {
v.SetDefault("urgency_priority_h_coefficient", 6.0) v.SetConfigFile(configPath)
v.SetDefault("urgency_priority_m_coefficient", 3.9)
v.SetDefault("urgency_priority_d_coefficient", 1.8)
v.SetDefault("urgency_priority_l_coefficient", 0.0)
v.SetDefault("urgency_active_coefficient", 4.0)
v.SetDefault("urgency_age_coefficient", 2.0)
v.SetDefault("urgency_age_max", 365)
v.SetDefault("urgency_tags_coefficient", 1.0)
v.SetDefault("urgency_project_coefficient", 1.0)
v.SetDefault("urgency_waiting_coefficient", -3.0)
v.SetDefault("urgency_urgent_tag", "next")
v.SetDefault("urgency_urgent_coefficient", 15.0)
v.SetDefault("next_limit", 5)
// Sync defaults
v.SetDefault("sync_enabled", false)
v.SetDefault("sync_url", "")
v.SetDefault("sync_api_key", "")
v.SetDefault("sync_client_id", "")
v.SetDefault("sync_strategy", "last-write-wins")
v.SetDefault("sync_queue_offline", true)
// Try to read existing config
err = v.ReadInConfig()
if err != nil {
// Config doesn't exist, create it with defaults
// Write config with defaults (ignoring the error type check for now)
if err := v.WriteConfigAs(configPath); err != nil {
return nil, fmt.Errorf("failed to create config file: %w", err)
}
// Try reading again
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read newly created config: %w", err) // Distinguish "file doesn't exist" from "file is malformed"
if _, statErr := os.Stat(configPath); statErr == nil {
// File exists but couldn't be parsed
return nil, fmt.Errorf("config file %s is invalid: %w", configPath, err)
}
// File doesn't exist — that's fine, defaults apply
} }
} }
// Layer 3: Environment variable overrides (OPAL_DEFAULT_FILTER, etc.)
v.SetEnvPrefix("OPAL")
v.AutomaticEnv()
cfg := &Config{} cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil { if err := v.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err) return nil, fmt.Errorf("failed to unmarshal config: %w", err)
@@ -314,52 +343,42 @@ func LoadConfig() (*Config, error) {
return cfg, nil return cfg, nil
} }
// SaveConfig saves the configuration to file // InitConfig creates the config directory and writes a default opal.yml.
// This should be called explicitly during CLI first-run or setup, never as a
// side effect of loading config.
func InitConfig() error {
configDir, err := GetConfigDir()
if err != nil {
return err
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
return SaveConfig(DefaultConfig())
}
// SaveConfig writes the configuration to the opal.yml file.
func SaveConfig(cfg *Config) error { func SaveConfig(cfg *Config) error {
configPath, err := GetConfigPath() configPath, err := GetConfigPath()
if err != nil { if err != nil {
return err return err
} }
v := viper.New() data, err := yaml.Marshal(cfg)
v.SetConfigFile(configPath) if err != nil {
v.SetConfigType("yaml") return fmt.Errorf("failed to marshal config: %w", err)
}
v.Set("default_filter", cfg.DefaultFilter) if err := os.WriteFile(configPath, data, 0644); err != nil {
v.Set("default_sort", cfg.DefaultSort) return fmt.Errorf("failed to write config file: %w", err)
v.Set("default_report", cfg.DefaultReport)
v.Set("color_output", cfg.ColorOutput)
v.Set("week_start_day", cfg.WeekStartDay)
v.Set("default_due_time", cfg.DefaultDueTime)
// Urgency settings
v.Set("urgency_due_coefficient", cfg.UrgencyDue)
v.Set("urgency_priority_h_coefficient", cfg.UrgencyPriorityH)
v.Set("urgency_priority_m_coefficient", cfg.UrgencyPriorityM)
v.Set("urgency_priority_d_coefficient", cfg.UrgencyPriorityD)
v.Set("urgency_priority_l_coefficient", cfg.UrgencyPriorityL)
v.Set("urgency_active_coefficient", cfg.UrgencyActive)
v.Set("urgency_age_coefficient", cfg.UrgencyAge)
v.Set("urgency_age_max", cfg.UrgencyAgeMax)
v.Set("urgency_tags_coefficient", cfg.UrgencyTags)
v.Set("urgency_project_coefficient", cfg.UrgencyProject)
v.Set("urgency_waiting_coefficient", cfg.UrgencyWaiting)
v.Set("urgency_urgent_tag", cfg.UrgencyUrgentTag)
v.Set("urgency_urgent_coefficient", cfg.UrgencyUrgentCoeff)
v.Set("next_limit", cfg.NextLimit)
// Sync settings
v.Set("sync_enabled", cfg.SyncEnabled)
v.Set("sync_url", cfg.SyncURL)
v.Set("sync_api_key", cfg.SyncAPIKey)
v.Set("sync_client_id", cfg.SyncClientID)
v.Set("sync_strategy", cfg.SyncStrategy)
v.Set("sync_queue_offline", cfg.SyncQueueOffline)
return v.WriteConfig()
} }
// GetConfig returns the loaded config or loads it if not already loaded // Update the cached singleton
globalConfig = cfg
return nil
}
// GetConfig returns the loaded config or loads it if not already loaded.
// Never returns nil — falls back to defaults if no config file exists.
func GetConfig() (*Config, error) { func GetConfig() (*Config, error) {
if globalConfig != nil { if globalConfig != nil {
return globalConfig, nil return globalConfig, nil
+5 -1
View File
@@ -170,8 +170,12 @@ func (t *Task) urgencyProject(coeff float64) float64 {
return 0.0 return 0.0
} }
// BuildUrgencyCoefficients creates UrgencyCoefficients from config // BuildUrgencyCoefficients creates UrgencyCoefficients from config.
// If cfg is nil, uses DefaultConfig() to prevent nil-pointer panics.
func BuildUrgencyCoefficients(cfg *Config) *UrgencyCoefficients { func BuildUrgencyCoefficients(cfg *Config) *UrgencyCoefficients {
if cfg == nil {
cfg = DefaultConfig()
}
return &UrgencyCoefficients{ return &UrgencyCoefficients{
Due: cfg.UrgencyDue, Due: cfg.UrgencyDue,
PriorityH: cfg.UrgencyPriorityH, PriorityH: cfg.UrgencyPriorityH,
+1 -2
View File
@@ -2,5 +2,4 @@
VITE_API_URL=https://opal.example.com/api VITE_API_URL=https://opal.example.com/api
VITE_AUTH_URL=https://auth.example.com VITE_AUTH_URL=https://auth.example.com
# OAuth # OAuth (not needed for local dev — Vite's DEV mode auto-skips auth)
VITE_OAUTH_ENABLED=true
-315
View File
@@ -1,315 +0,0 @@
/**
* Mock task data for local development / design review.
* Covers a realistic spread of projects, priorities, tags, due dates, and statuses.
*/
const now = Math.floor(Date.now() / 1000);
const HOUR = 3600;
const DAY = 86400;
/** @type {import('$lib/api/types.js').Task[]} */
export const mockTasks = [
// ── Pending tasks ────────────────────────────────────────────
{
uuid: '11111111-1111-4111-a111-111111111101',
id: 1,
status: 'P',
description: 'Set up Caddy reverse proxy for opal-web',
project: 'Infrastructure',
priority: 3,
created: now - 7 * DAY,
modified: now - 1 * DAY,
start: now - 2 * HOUR,
end: null,
due: now + 2 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['devops', 'selfhosted'],
urgency: 14.2
},
{
uuid: '11111111-1111-4111-a111-111111111102',
id: 2,
status: 'P',
description: 'Write unit tests for task filter parsing',
project: 'Opal',
priority: 2,
created: now - 5 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: now + 5 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['testing', 'backend'],
urgency: 7.3
},
{
uuid: '11111111-1111-4111-a111-111111111103',
id: 3,
status: 'P',
description: 'Fix tag extraction for nested wiki-links',
project: 'Jade',
priority: 2,
created: now - 3 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug'],
urgency: 4.1
},
{
uuid: '11111111-1111-4111-a111-111111111104',
id: 4,
status: 'P',
description: 'Grocery run - farmers market',
project: null,
priority: 1,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now + 1 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['errand'],
urgency: 3.5
},
{
uuid: '11111111-1111-4111-a111-111111111105',
id: 5,
status: 'P',
description: 'Design task detail page for opal-web',
project: 'Opal',
priority: 3,
created: now - 2 * DAY,
modified: now - 2 * DAY,
start: null,
end: null,
due: now - 1 * DAY, // overdue
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend', 'design'],
urgency: 15.8
},
{
uuid: '11111111-1111-4111-a111-111111111106',
id: 6,
status: 'P',
description: 'Renew domain registration for jnss.me',
project: 'Infrastructure',
priority: 1,
created: now - 14 * DAY,
modified: now - 14 * DAY,
start: null,
end: null,
due: now + 30 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['admin'],
urgency: 2.4
},
{
uuid: '11111111-1111-4111-a111-111111111107',
id: 7,
status: 'P',
description: 'Add recurrence UI to task creation form',
project: 'Opal',
priority: 1,
created: now - 4 * DAY,
modified: now - 4 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend'],
urgency: 2.9
},
{
uuid: '11111111-1111-4111-a111-111111111108',
id: 8,
status: 'P',
description: 'Migrate Nextcloud to latest stable',
project: 'Infrastructure',
priority: 0,
created: now - 10 * DAY,
modified: now - 10 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['selfhosted', 'maintenance'],
urgency: 1.6
},
{
uuid: '11111111-1111-4111-a111-111111111109',
id: 9,
status: 'P',
description: 'Read "Designing Data-Intensive Applications" ch. 7',
project: null,
priority: 0,
created: now - 6 * DAY,
modified: now - 6 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['reading', 'learning'],
urgency: 1.2
},
{
uuid: '11111111-1111-4111-a111-111111111110',
id: 10,
status: 'P',
description: 'Review PR: sync conflict resolution strategy',
project: 'Opal',
priority: 2,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now, // due today
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['review', 'backend'],
urgency: 10.5
},
// ── Completed tasks ──────────────────────────────────────────
{
uuid: '22222222-2222-4222-a222-222222222201',
id: 11,
status: 'C',
description: 'Implement XDG directory support',
project: 'Opal',
priority: 2,
created: now - 14 * DAY,
modified: now - 7 * DAY,
start: null,
end: now - 7 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['backend', 'refactor'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222202',
id: 12,
status: 'C',
description: 'Set up Authentik OAuth provider',
project: 'Infrastructure',
priority: 3,
created: now - 21 * DAY,
modified: now - 10 * DAY,
start: null,
end: now - 10 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['auth', 'selfhosted'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222203',
id: 13,
status: 'C',
description: 'Build setup wizard for first-run',
project: 'Opal',
priority: 2,
created: now - 10 * DAY,
modified: now - 5 * DAY,
start: null,
end: now - 5 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['ux', 'backend'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222204',
id: 14,
status: 'C',
description: 'Fix PersistentPreRun initialization order',
project: 'Opal',
priority: 3,
created: now - 8 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug', 'backend'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222205',
id: 15,
status: 'C',
description: 'Write deployment guide with Caddy config',
project: 'Opal',
priority: 1,
created: now - 9 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['docs'],
urgency: 0
}
];
+5 -4
View File
@@ -17,17 +17,18 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
*/ */
const STORAGE_KEY = 'opal_auth'; const STORAGE_KEY = 'opal_auth';
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true'; const DEV_MODE = import.meta.env.DEV;
/** /**
* Load auth state from localStorage * Load auth state from localStorage
* @returns {AuthState} * @returns {AuthState}
*/ */
function loadAuth() { function loadAuth() {
// In mock mode, always return authenticated // In dev mode, auto-authenticate with a dev user.
if (MOCK_MODE) { // API requests still go to the real backend (which runs with auth disabled).
if (DEV_MODE) {
return { return {
accessToken: 'mock-token', accessToken: 'dev-token',
refreshToken: '', refreshToken: '',
expiresAt: 9999999999, expiresAt: 9999999999,
user: { id: 1, username: 'dev', email: 'dev@localhost' }, user: { id: 1, username: 'dev', email: 'dev@localhost' },
+3 -122
View File
@@ -1,35 +1,18 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import { tasks as tasksAPI } from '$lib/api/endpoints.js'; import { tasks as tasksAPI } from '$lib/api/endpoints.js';
import { queueChange } from '$lib/utils/sync-queue.js'; import { queueChange } from '$lib/utils/sync-queue.js';
import { generateUUID } from '$lib/utils/uuid.js';
/** /**
* @typedef {import('$lib/api/types.js').Task} Task * @typedef {import('$lib/api/types.js').Task} Task
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters * @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
*/ */
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true';
/** Report names that map to pending tasks in mock mode */
const PENDING_REPORTS = new Set(['list', 'next', 'active', 'ready', 'overdue', 'waiting', 'newest', 'oldest']);
/** /**
* Create tasks store * Create tasks store
*/ */
function createTasksStore() { function createTasksStore() {
const { subscribe, set, update } = writable(/** @type {Task[]} */ ([])); const { subscribe, set, update } = writable(/** @type {Task[]} */ ([]));
/** @type {Task[]} */
let mockData = [];
/** Ensure mock data is loaded */
async function ensureMockData() {
if (mockData.length === 0) {
const { mockTasks } = await import('$lib/mock/tasks.js');
mockData = [...mockTasks];
}
}
return { return {
subscribe, subscribe,
@@ -38,18 +21,6 @@ function createTasksStore() {
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed') * @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
*/ */
async loadReport(reportName) { async loadReport(reportName) {
if (MOCK_MODE) {
await ensureMockData();
if (reportName === 'completed') {
set(mockData.filter(t => t.status === 'C'));
} else if (PENDING_REPORTS.has(reportName)) {
set(mockData.filter(t => t.status === 'P'));
} else {
set(mockData.filter(t => t.status === 'P'));
}
return;
}
try { try {
const tasks = await tasksAPI.listByReport(reportName); const tasks = await tasksAPI.listByReport(reportName);
set(tasks); set(tasks);
@@ -65,55 +36,9 @@ function createTasksStore() {
* @returns {Promise<Task>} * @returns {Promise<Task>}
*/ */
async parseAndCreate(input) { async parseAndCreate(input) {
if (MOCK_MODE) {
await ensureMockData();
// Naive parse: non-modifier words become description
const words = input.split(/\s+/);
const descWords = [];
const task = /** @type {Task} */ ({
uuid: generateUUID(),
id: mockData.length + 1,
status: 'P',
description: '',
project: null,
priority: 1,
created: Date.now() / 1000,
modified: Date.now() / 1000,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: []
});
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (w.startsWith('project:')) {
task.project = w.slice(8);
} else if (w.startsWith('priority:') || w.startsWith('pri:')) {
const val = w.includes(':') ? w.split(':')[1] : '1';
/** @type {Record<string, number>} */
const map = { H: 3, M: 2, L: 0, h: 3, m: 2, l: 0 };
task.priority = /** @type {import('$lib/api/types.js').TaskPriority} */ ((map[val] ?? parseInt(val, 10)) || 1);
} else if (w.startsWith('+')) {
task.tags = [...(task.tags || []), w.slice(1)];
} else {
descWords.push(w);
}
}
task.description = descWords.join(' ') || 'New task';
mockData = [task, ...mockData];
update(tasks => [task, ...tasks]);
return task;
}
try { try {
const created = await tasksAPI.parse(input); const result = await tasksAPI.parse(input);
const created = result.task ?? result;
update(tasks => [created, ...tasks]); update(tasks => [created, ...tasks]);
return created; return created;
} catch (error) { } catch (error) {
@@ -123,20 +48,10 @@ function createTasksStore() {
}, },
/** /**
* Load all tasks from API (or mock data in dev) * Load all tasks from API
* @param {TaskFilters} [filters] * @param {TaskFilters} [filters]
*/ */
async load(filters = {}) { async load(filters = {}) {
if (MOCK_MODE) {
await ensureMockData();
let filtered = mockData;
if (filters.status) {
filtered = filtered.filter(t => t.status === filters.status);
}
set(filtered);
return;
}
try { try {
const tasks = await tasksAPI.list(filters); const tasks = await tasksAPI.list(filters);
set(tasks); set(tasks);
@@ -151,13 +66,6 @@ function createTasksStore() {
* @param {Partial<Task>} task * @param {Partial<Task>} task
*/ */
async add(task) { async add(task) {
if (MOCK_MODE) {
const fullTask = /** @type {Task} */ ({ ...task, id: mockData.length + 1 });
mockData = [...mockData, fullTask];
update(tasks => [...tasks, fullTask]);
return fullTask;
}
try { try {
const created = await tasksAPI.create(task); const created = await tasksAPI.create(task);
update(tasks => [...tasks, created]); update(tasks => [...tasks, created]);
@@ -191,14 +99,6 @@ function createTasksStore() {
return tasks; return tasks;
}); });
if (MOCK_MODE) {
const index = mockData.findIndex(t => t.uuid === uuid);
if (index >= 0) {
mockData[index] = { ...mockData[index], ...updates, modified: Date.now() / 1000 };
}
return;
}
try { try {
const updated = await tasksAPI.update(uuid, updates); const updated = await tasksAPI.update(uuid, updates);
// Sync with server response // Sync with server response
@@ -228,11 +128,6 @@ function createTasksStore() {
// Optimistic removal // Optimistic removal
update(tasks => tasks.filter(t => t.uuid !== uuid)); update(tasks => tasks.filter(t => t.uuid !== uuid));
if (MOCK_MODE) {
mockData = mockData.filter(t => t.uuid !== uuid);
return;
}
try { try {
await tasksAPI.delete(uuid); await tasksAPI.delete(uuid);
} catch (error) { } catch (error) {
@@ -250,20 +145,6 @@ function createTasksStore() {
* @param {string} uuid * @param {string} uuid
*/ */
async complete(uuid) { async complete(uuid) {
if (MOCK_MODE) {
const mi = mockData.findIndex(t => t.uuid === uuid);
if (mi >= 0) {
mockData[mi] = {
...mockData[mi],
status: /** @type {'P'|'C'} */ ('C'),
end: Date.now() / 1000,
modified: Date.now() / 1000
};
}
update(tasks => tasks.filter(t => t.uuid !== uuid));
return;
}
try { try {
await tasksAPI.complete(uuid); await tasksAPI.complete(uuid);
update(tasks => tasks.filter(t => t.uuid !== uuid)); update(tasks => tasks.filter(t => t.uuid !== uuid));
+7
View File
@@ -35,5 +35,12 @@
". content ." ". content ."
". input ."; ". input .";
overflow: hidden; overflow: hidden;
background: linear-gradient(
to right,
var(--bg-secondary),
var(--bg-primary) calc(50% - var(--content-max-width) / 2 + 60px),
var(--bg-primary) calc(50% + var(--content-max-width) / 2 - 60px),
var(--bg-secondary)
);
} }
</style> </style>
@@ -45,10 +45,11 @@
<style> <style>
.callback-page { .callback-page {
grid-column: 1 / -1;
grid-row: 1 / -1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh;
} }
.callback-card { .callback-card {
+2 -1
View File
@@ -53,10 +53,11 @@
<style> <style>
.login-page { .login-page {
grid-column: 1 / -1;
grid-row: 1 / -1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh;
} }
.login-card { .login-card {
Executable
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Start backend and frontend for local development.
# Backend runs with --dev (auth disabled), frontend points at localhost:8080.
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cleanup() {
echo ""
echo "Shutting down..."
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Start backend
echo "Starting backend (dev mode) on :8080..."
cd "$ROOT/opal-task"
go run . server start --dev --addr :8080 &
BACKEND_PID=$!
# Give the backend a moment to start before launching the frontend
sleep 1
# Start frontend
echo "Starting frontend..."
cd "$ROOT/opal-web"
bun run dev &
FRONTEND_PID=$!
wait