Compare commits
5 Commits
b3c30738bd
...
f05d6e154e
| Author | SHA1 | Date | |
|---|---|---|---|
| f05d6e154e | |||
| 4dfef88f19 | |||
| d51c6da18d | |||
| 80ea17227d | |||
| c5a963bfd9 |
@@ -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.
|
||||
@@ -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
@@ -244,16 +244,20 @@ func initializeApp() {
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Show first-run message after config is created
|
||||
if isFirstRun {
|
||||
showFirstRunMessage()
|
||||
}
|
||||
}
|
||||
|
||||
func showFirstRunMessage() {
|
||||
|
||||
+19
-2
@@ -97,21 +97,37 @@ var serverStartCmd = &cobra.Command{
|
||||
Examples:
|
||||
opal server start
|
||||
opal server start --addr :8080
|
||||
opal server start --dev
|
||||
opal server start --db /var/lib/opal/opal.db`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
addr, _ := cmd.Flags().GetString("addr")
|
||||
dbPath, _ := cmd.Flags().GetString("db")
|
||||
devMode, _ := cmd.Flags().GetBool("dev")
|
||||
|
||||
// Override DB path if specified
|
||||
if 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 {
|
||||
fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err)
|
||||
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
|
||||
if err := engine.InitDB(); err != nil {
|
||||
@@ -121,7 +137,7 @@ Examples:
|
||||
defer engine.CloseDB()
|
||||
|
||||
// Create and start server
|
||||
server := api.NewServer(addr)
|
||||
server := api.NewServer(addr, devMode)
|
||||
if err := server.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -188,6 +204,7 @@ func init() {
|
||||
|
||||
serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address")
|
||||
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("db", "d", "", "Database path (default: config directory)")
|
||||
|
||||
+11
-57
@@ -121,33 +121,6 @@ func runQuickSetup() {
|
||||
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
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err))
|
||||
@@ -158,8 +131,8 @@ func runQuickSetup() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save config
|
||||
if err := engine.SaveConfig(&cfg); err != nil {
|
||||
// Save default config
|
||||
if err := engine.SaveConfig(engine.DefaultConfig()); err != nil {
|
||||
wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -304,34 +277,15 @@ func runInteractiveSetup() {
|
||||
return
|
||||
}
|
||||
|
||||
// Create configuration
|
||||
cfg := &engine.Config{
|
||||
DefaultFilter: defaultFilter,
|
||||
DefaultSort: "due,priority",
|
||||
DefaultReport: reportNames[defaultReport],
|
||||
ColorOutput: colorOutput,
|
||||
WeekStartDay: weekStartDay,
|
||||
DefaultDueTime: "",
|
||||
NextLimit: 5,
|
||||
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,
|
||||
}
|
||||
// Create configuration from defaults, then apply user choices
|
||||
cfg := engine.DefaultConfig()
|
||||
cfg.DefaultFilter = defaultFilter
|
||||
cfg.DefaultReport = reportNames[defaultReport]
|
||||
cfg.ColorOutput = colorOutput
|
||||
cfg.WeekStartDay = weekStartDay
|
||||
cfg.SyncEnabled = syncEnabled
|
||||
cfg.SyncURL = syncURL
|
||||
cfg.SyncAPIKey = syncAPIKey
|
||||
|
||||
// Set directory overrides
|
||||
engine.SetConfigDirOverride(configDir)
|
||||
|
||||
@@ -39,7 +39,22 @@ func GetLoginURL(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// OAuthCallback handles the OAuth callback
|
||||
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 == "" {
|
||||
errorResponse(w, http.StatusBadRequest, "missing code parameter")
|
||||
return
|
||||
|
||||
@@ -73,6 +73,16 @@ func GetUserID(r *http.Request) int {
|
||||
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
|
||||
func CORSMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
@@ -13,13 +13,15 @@ import (
|
||||
type Server struct {
|
||||
router chi.Router
|
||||
addr string
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// NewServer creates a new API server
|
||||
func NewServer(addr string) *Server {
|
||||
func NewServer(addr string, devMode bool) *Server {
|
||||
s := &Server{
|
||||
router: chi.NewRouter(),
|
||||
addr: addr,
|
||||
devMode: devMode,
|
||||
}
|
||||
s.setupRoutes()
|
||||
return s
|
||||
@@ -39,15 +41,37 @@ func (s *Server) setupRoutes() {
|
||||
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)
|
||||
r.Get("/auth/login", handlers.GetLoginURL)
|
||||
r.Get("/auth/callback", handlers.OAuthCallback)
|
||||
r.Post("/auth/callback", handlers.OAuthCallback)
|
||||
r.Post("/auth/refresh", handlers.RefreshToken)
|
||||
r.Post("/auth/logout", handlers.Logout)
|
||||
}
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
if s.devMode {
|
||||
r.Use(DevAuthMiddleware())
|
||||
} else {
|
||||
r.Use(AuthMiddleware())
|
||||
}
|
||||
|
||||
// Tasks
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
|
||||
@@ -6,10 +6,23 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"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 {
|
||||
config *oauth2.Config
|
||||
cfg *Config
|
||||
@@ -22,8 +35,8 @@ func NewOAuthClient(cfg *Config) *OAuthClient {
|
||||
ClientSecret: cfg.OAuthClientSecret,
|
||||
RedirectURL: cfg.OAuthRedirectURI,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: cfg.OAuthIssuer + "../authorize/",
|
||||
TokenURL: cfg.OAuthIssuer + "../token/",
|
||||
AuthURL: issuerBase(cfg.OAuthIssuer) + "authorize/",
|
||||
TokenURL: issuerBase(cfg.OAuthIssuer) + "token/",
|
||||
},
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
},
|
||||
@@ -47,7 +60,7 @@ type UserInfo struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+141
-122
@@ -8,39 +8,40 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DefaultFilter string `mapstructure:"default_filter"`
|
||||
DefaultSort string `mapstructure:"default_sort"`
|
||||
DefaultReport string `mapstructure:"default_report"`
|
||||
ColorOutput bool `mapstructure:"color_output"`
|
||||
WeekStartDay string `mapstructure:"week_start_day"`
|
||||
DefaultDueTime string `mapstructure:"default_due_time"`
|
||||
DefaultFilter string `mapstructure:"default_filter" yaml:"default_filter"`
|
||||
DefaultSort string `mapstructure:"default_sort" yaml:"default_sort"`
|
||||
DefaultReport string `mapstructure:"default_report" yaml:"default_report"`
|
||||
ColorOutput bool `mapstructure:"color_output" yaml:"color_output"`
|
||||
WeekStartDay string `mapstructure:"week_start_day" yaml:"week_start_day"`
|
||||
DefaultDueTime string `mapstructure:"default_due_time" yaml:"default_due_time"`
|
||||
|
||||
// Urgency coefficients
|
||||
UrgencyDue float64 `mapstructure:"urgency_due_coefficient"`
|
||||
UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient"`
|
||||
UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient"`
|
||||
UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient"`
|
||||
UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient"`
|
||||
UrgencyActive float64 `mapstructure:"urgency_active_coefficient"`
|
||||
UrgencyAge float64 `mapstructure:"urgency_age_coefficient"`
|
||||
UrgencyAgeMax int `mapstructure:"urgency_age_max"`
|
||||
UrgencyTags float64 `mapstructure:"urgency_tags_coefficient"`
|
||||
UrgencyProject float64 `mapstructure:"urgency_project_coefficient"`
|
||||
UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient"`
|
||||
UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag"`
|
||||
UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient"`
|
||||
NextLimit int `mapstructure:"next_limit"`
|
||||
UrgencyDue float64 `mapstructure:"urgency_due_coefficient" yaml:"urgency_due_coefficient"`
|
||||
UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient" yaml:"urgency_priority_h_coefficient"`
|
||||
UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient" yaml:"urgency_priority_m_coefficient"`
|
||||
UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient" yaml:"urgency_priority_d_coefficient"`
|
||||
UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient" yaml:"urgency_priority_l_coefficient"`
|
||||
UrgencyActive float64 `mapstructure:"urgency_active_coefficient" yaml:"urgency_active_coefficient"`
|
||||
UrgencyAge float64 `mapstructure:"urgency_age_coefficient" yaml:"urgency_age_coefficient"`
|
||||
UrgencyAgeMax int `mapstructure:"urgency_age_max" yaml:"urgency_age_max"`
|
||||
UrgencyTags float64 `mapstructure:"urgency_tags_coefficient" yaml:"urgency_tags_coefficient"`
|
||||
UrgencyProject float64 `mapstructure:"urgency_project_coefficient" yaml:"urgency_project_coefficient"`
|
||||
UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient" yaml:"urgency_waiting_coefficient"`
|
||||
UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag" yaml:"urgency_urgent_tag"`
|
||||
UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient" yaml:"urgency_urgent_coefficient"`
|
||||
NextLimit int `mapstructure:"next_limit" yaml:"next_limit"`
|
||||
|
||||
// Sync settings
|
||||
SyncEnabled bool `mapstructure:"sync_enabled"`
|
||||
SyncURL string `mapstructure:"sync_url"`
|
||||
SyncAPIKey string `mapstructure:"sync_api_key"`
|
||||
SyncClientID string `mapstructure:"sync_client_id"`
|
||||
SyncStrategy string `mapstructure:"sync_strategy"`
|
||||
SyncQueueOffline bool `mapstructure:"sync_queue_offline"`
|
||||
SyncEnabled bool `mapstructure:"sync_enabled" yaml:"sync_enabled"`
|
||||
SyncURL string `mapstructure:"sync_url" yaml:"sync_url"`
|
||||
SyncAPIKey string `mapstructure:"sync_api_key" yaml:"sync_api_key"`
|
||||
SyncClientID string `mapstructure:"sync_client_id" yaml:"sync_client_id"`
|
||||
SyncStrategy string `mapstructure:"sync_strategy" yaml:"sync_strategy"`
|
||||
SyncQueueOffline bool `mapstructure:"sync_queue_offline" yaml:"sync_queue_offline"`
|
||||
}
|
||||
|
||||
var globalConfig *Config
|
||||
@@ -234,77 +235,105 @@ func IsFirstRun() bool {
|
||||
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) {
|
||||
if 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.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// Set defaults
|
||||
v.SetDefault("default_filter", "status:pending")
|
||||
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", "")
|
||||
// Layer 1: Hardcoded defaults
|
||||
setViperDefaults(v)
|
||||
|
||||
// Urgency defaults (adjusted for Opal's simpler model)
|
||||
v.SetDefault("urgency_due_coefficient", 12.0)
|
||||
v.SetDefault("urgency_priority_h_coefficient", 6.0)
|
||||
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
|
||||
// Layer 2: YAML config file (optional, read-only)
|
||||
if configPath, err := GetConfigPath(); err == nil {
|
||||
v.SetConfigFile(configPath)
|
||||
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{}
|
||||
if err := v.Unmarshal(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
@@ -314,52 +343,42 @@ func LoadConfig() (*Config, error) {
|
||||
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 {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
v.Set("default_filter", cfg.DefaultFilter)
|
||||
v.Set("default_sort", cfg.DefaultSort)
|
||||
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()
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if globalConfig != nil {
|
||||
return globalConfig, nil
|
||||
|
||||
@@ -170,8 +170,12 @@ func (t *Task) urgencyProject(coeff float64) float64 {
|
||||
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 {
|
||||
if cfg == nil {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
return &UrgencyCoefficients{
|
||||
Due: cfg.UrgencyDue,
|
||||
PriorityH: cfg.UrgencyPriorityH,
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
VITE_API_URL=https://opal.example.com/api
|
||||
VITE_AUTH_URL=https://auth.example.com
|
||||
|
||||
# OAuth
|
||||
VITE_OAUTH_ENABLED=true
|
||||
# OAuth (not needed for local dev — Vite's DEV mode auto-skips auth)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
@@ -17,17 +17,18 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
|
||||
*/
|
||||
|
||||
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
|
||||
* @returns {AuthState}
|
||||
*/
|
||||
function loadAuth() {
|
||||
// In mock mode, always return authenticated
|
||||
if (MOCK_MODE) {
|
||||
// In dev mode, auto-authenticate with a dev user.
|
||||
// API requests still go to the real backend (which runs with auth disabled).
|
||||
if (DEV_MODE) {
|
||||
return {
|
||||
accessToken: 'mock-token',
|
||||
accessToken: 'dev-token',
|
||||
refreshToken: '',
|
||||
expiresAt: 9999999999,
|
||||
user: { id: 1, username: 'dev', email: 'dev@localhost' },
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { tasks as tasksAPI } from '$lib/api/endpoints.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').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
|
||||
*/
|
||||
function createTasksStore() {
|
||||
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 {
|
||||
subscribe,
|
||||
|
||||
@@ -38,18 +21,6 @@ function createTasksStore() {
|
||||
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
|
||||
*/
|
||||
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 {
|
||||
const tasks = await tasksAPI.listByReport(reportName);
|
||||
set(tasks);
|
||||
@@ -65,55 +36,9 @@ function createTasksStore() {
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
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 {
|
||||
const created = await tasksAPI.parse(input);
|
||||
const result = await tasksAPI.parse(input);
|
||||
const created = result.task ?? result;
|
||||
update(tasks => [created, ...tasks]);
|
||||
return created;
|
||||
} 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]
|
||||
*/
|
||||
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 {
|
||||
const tasks = await tasksAPI.list(filters);
|
||||
set(tasks);
|
||||
@@ -151,13 +66,6 @@ function createTasksStore() {
|
||||
* @param {Partial<Task>} 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 {
|
||||
const created = await tasksAPI.create(task);
|
||||
update(tasks => [...tasks, created]);
|
||||
@@ -191,14 +99,6 @@ function createTasksStore() {
|
||||
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 {
|
||||
const updated = await tasksAPI.update(uuid, updates);
|
||||
// Sync with server response
|
||||
@@ -228,11 +128,6 @@ function createTasksStore() {
|
||||
// Optimistic removal
|
||||
update(tasks => tasks.filter(t => t.uuid !== uuid));
|
||||
|
||||
if (MOCK_MODE) {
|
||||
mockData = mockData.filter(t => t.uuid !== uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tasksAPI.delete(uuid);
|
||||
} catch (error) {
|
||||
@@ -250,20 +145,6 @@ function createTasksStore() {
|
||||
* @param {string} 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 {
|
||||
await tasksAPI.complete(uuid);
|
||||
update(tasks => tasks.filter(t => t.uuid !== uuid));
|
||||
|
||||
@@ -35,5 +35,12 @@
|
||||
". content ."
|
||||
". input .";
|
||||
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>
|
||||
|
||||
@@ -45,10 +45,11 @@
|
||||
|
||||
<style>
|
||||
.callback-page {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.callback-card {
|
||||
|
||||
@@ -53,10 +53,11 @@
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
|
||||
Executable
+32
@@ -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
|
||||
Reference in New Issue
Block a user