13 Commits

Author SHA1 Message Date
2c48c75387 fix: use FindDataRoot in FindOrInitSQLiteStore for consistent database location
Some checks failed
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Has been cancelled
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Has been cancelled
Build and Push Docker Container / build-and-push (push) Successful in 17m44s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Failing after 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Failing after 1m0s
2026-04-02 14:26:13 +02:00
e04a44cdcf remove line count from readme 2026-04-02 13:41:47 +02:00
b6c8a158af docs: simplify README and extract full reference to USAGE.md
- Condensed README to quick overview (~40 lines) with key features
- Added install instructions for Alpine, Arch, source, and Docker
- Created USAGE.md with full command reference, server mode, OIDC, and permission model docs
- New features documented: server mode, OIDC authentication, per-node permissions, multiplatform auto-builds

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:38:38 +02:00
5f0f8f3396 fix: update Dockerfile Go version to 1.25 and disable toolchain auto-download
All checks were successful
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m4s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m6s
Build and Push Docker Container / build-and-push (push) Successful in 17m50s
- Bump golang:1.24-alpine to golang:1.25-alpine to match go.mod requirement
- Set GOTOOLCHAIN=local to prevent segfault from toolchain download in Docker

This fixes the multiplatform build failure where go mod download would
attempt to download Go 1.25.0 in the container and crash.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:25:19 +02:00
24fb3a8b62 fix: prevent segfault in login command when session doesn't exist
LoadSession() was returning nil when the session file didn't exist (first login).
The login command then tried to dereference nil when setting session.Token,
causing a panic. Now LoadSession() returns an empty Session with the path set,
so callers can always use the returned session.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:23:34 +02:00
89432e608b feat: replace in_namespace relation with ownership-based namespace membership
Remove the in_namespace edge relation. A node now belongs to a namespace if that
namespace has has_ownership on it. This simplifies the model: namespace membership
is determined by the ownership chain rather than a separate relation type.

Changes:
- Remove RelInNamespace constant
- Add Namespace fields to AddInput, UpdateInput, and ListFilter
- Update Add() to resolve namespace from input and assign it as owner
- Update List() to filter by namespace ownership instead of in_namespace edges
- Update() can now transfer nodes between namespaces via ownership transfer
- Remove in_namespace self-references from ensureNamespace/ensureGlobalNamespace

The ownership chain now fully describes both permissions and namespace membership,
reducing redundancy. All tests pass with the new model.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:20:03 +02:00
63044a697d feat: change node ownership to namespace instead of creator
Some checks failed
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 44s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 51s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m5s
Build and Push Docker Container / build-and-push (push) Failing after 10m5s
When a node is created in a namespace, the namespace now owns it rather than the creator. This allows namespaces to manage content ownership. Namespace nodes themselves remain owned by their creator. Users retain transitive ownership through their default namespace: user→has_ownership→namespace→has_ownership→node.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:41:13 +02:00
ea8a9ca0c3 feat: add global user namespace for shared access
Create a special _global namespace that every new user automatically
gets can_create_rel access to (inclusive of can_read). This provides
a shared space where all users can see and interact with published nodes.

- ensureGlobalNamespace creates _global on first use with self-ownership
- ensureUser grants can_create_rel to each new user on the global namespace
- User visibility still relies on existing post-BFS global readability

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:31:04 +02:00
bcd52e0ae6 update project page url for package
Some checks failed
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 43s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 59s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 59s
Build and Push Docker Container / build-and-push (push) Failing after 10m8s
2026-04-02 05:09:02 +02:00
4912f37688 remove claude markdown 2026-04-02 05:08:44 +02:00
f2521be158 feat: add due date filters for list command
- --due: show only nodes with a due date set
- --due-within N: show only nodes due within N days (includes overdue)

Implemented in service layer with post-fetch filtering, threaded through
API client and server, and exposed via CLI flags.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:06:35 +02:00
8357c80e75 feat: add due date to list sorting priority
Sort nodes by: status → priority → due date (soonest first).
Nodes without due dates sort last.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:57:51 +02:00
a30c703efa feat: add colored due date indicators to list and show views
- List view: show relative due date (+12d, -3d, today) after namespace
- Show view: colorize full due date based on urgency
- Color scheme: red (overdue), yellow (due within 3 days), green (4+ days)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:54:15 +02:00
20 changed files with 510 additions and 414 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.ax .ax
quicknote.md quicknote.md
plan.md plan.md
CLAUDE.md

View File

@@ -1,47 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Axolotl (`ax`) is a CLI-native issue tracker built in Go, using a local SQLite file (`.ax.db`) as its database. It's designed for use by individuals and AI agents, with JSON output support for machine integration.
## Commands
```bash
go build -o ax . # Build the binary
go test ./... # Run all tests (e2e_test.go covers most functionality)
go test -run TestName . # Run a single test by name
```
## Architecture
The codebase has three distinct layers:
### 1. `cmd/` — CLI layer (Cobra)
Parses flags into typed input structs and calls the service layer. `root.go` handles alias expansion (including `$me`, `$@`, `$1`-`$N` variable substitution) and wires up the `NodeService`. Also contains `output.go` for colored terminal output and JSON serialization.
### 2. `service/` — Business logic
`NodeService` is the central interface (`service/node_service.go`). The implementation (`node_service_impl.go`) enforces:
- Permission model via `getPermContext()` — BFS from the user's own node following permission rels
- Blocker validation (can't close an issue with open blockers)
- `@mention` extraction → automatic edge creation
- Single-value relation enforcement (`assignee`, `in_namespace`)
- Auto-creation of referenced user/namespace nodes
### 3. `store/` — Persistence and configuration
`GraphStore` interface wraps SQLite with graph primitives: nodes, tags, and typed directed edges. Schema is 3 tables (`nodes`, `tags`, `rels`). All multi-step ops use `store.Transaction()`. Also contains `Config` for user settings, aliases, and session management.
## Core Data Model
**Node**: a graph node with a 5-char ID, title, content, `Tags []string`, and `Relations map[string][]string`.
**Property tags** use the `_key::value` pattern: `_type::issue|note|user|namespace`, `_status::open|done`, `_prio::high|medium|low`.
**Relation types** (`models/rel_type.go`): `blocks`, `subtask`, `related`, `assignee` (single-value), `in_namespace` (single-value), `created`, `mentions`, `can_read`, `can_create_rel`, `can_write`, `has_ownership`.
**Permission model**: Four inclusive levels (14). Transitive via BFS from user's self-owned node. `can_read`=1, `can_create_rel`=2, `can_write`=3, `has_ownership`=4. Creator auto-gets `has_ownership` on every new node. Users self-own. Deleting a node cascades to all nodes it owns. User/namespace nodes are globally readable.
## Config
The CLI searches upward from CWD for an `.ax` directory (like git), falling back to `~/.config/ax/` for config and `~/.local/share/ax/` for data. The `AX_USER` env var overrides the configured username. The database file `ax.db` is similarly discovered by walking upward.

View File

@@ -1,8 +1,8 @@
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
ENV GOTOOLCHAIN=auto ENV GOTOOLCHAIN=local
COPY src/go.mod src/go.sum ./ COPY src/go.mod src/go.sum ./
RUN go mod download RUN go mod download

280
README.md
View File

@@ -1,266 +1,52 @@
# Axolotl # Axolotl
CLI-native lightweight issue tracker for you and your agents. A SQLite-based CLI-native issue tracker for you and your agents. Single binary, SQLite-backed, a few lines of Go.
single portable binary, built from ~1300 lines of Go code.
## Features ## Install
- **Issues with dependencies** - blocks, subtask, related relations **Alpine Linux** (apk):
- **Tagging system** - flexible tags with `_key::value` property pattern Download from the Gitea package registry. Have a look
- **Namespacing** - organize issues by project or team [here](https://g.eliaskohout.de/eliaskohout/-/packages/alpine/axolotl/).
- **Due dates** - track deadlines
- **Thread-safe** - WAL mode for concurrent access
- **Multiuser support** - @mentions and assignments, inbox per user
- **JSON output** - all commands support `--json` for agent integration
- **Alias system** - define custom command shortcuts with argument expansion
- **Single binary** - no dependencies, portable `.ax.db` file
## Installation **Arch Linux** (pacman):
Download from the Gitea package registry. Have a look
[here](https://g.eliaskohout.de/eliaskohout/-/packages/arch/axolotl/).
**From source:**
```bash ```bash
go build -o ax . go build -o ax ./src
``` ```
**Docker (server mode):**
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
Packages are built automatically on every version tag for `linux/amd64` and `linux/arm64`.
## Quick Start ## Quick Start
```bash ```bash
# Initialize a new database ax init . # create .ax.db in current dir
ax init . ax add "Fix login bug" --prio high # create an issue
ax list --status open # list open issues
# Create an issue ax show abc12 # show issue details
ax add "Implement feature X" --tag backend --prio high ax update abc12 --status done # close issue
ax inbox # your @mention inbox
# Create with relations
ax add "Fix bug in auth" --rel blocks:abc12
# List open issues
ax list --status open
# Show issue details
ax show abc12
# Update an issue
ax update abc12 --status done
# View your inbox
ax inbox
# Define an alias
ax alias mywork "list --namespace myproject --status open" --desc "My project tasks"
``` ```
## Commands ## Key Features
### `ax init [path]` - **Graph relations** — `blocks`, `subtask`, `related`, `assignee`
- **Namespaces** — organize issues by project or team
- **Permissions** — per-node access control (`can_read`, `can_write`, `has_ownership`)
- **Aliases** — custom shortcuts with `$me`, `$1`, `$@` expansion
- **JSON output** — `--json` flag on all commands for agent integration
- **Multiuser** — `@mention` auto-creates inbox entries; `AX_USER` to switch users
- **Server mode** — HTTP JSON API with optional OIDC authentication (`ax serve` / `ax login`)
- **Portable** — single `.ax.db` file, no server required
Create a new `.ax.db` database in the specified directory (default: current). For full command reference and examples, see [USAGE.md](USAGE.md).
### `ax add <title> [flags]`
Create a new node.
| Flag | Description |
|------|-------------|
| `--type` | Node type: `issue` (default), `note`, `user`, `namespace` |
| `--status` | Status: `open` (default), `done` |
| `--prio` | Priority: `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Content/body text |
| `--rel` | Add relation `type:id` (repeatable) |
### `ax update <id> [flags]`
Update a node.
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | New namespace |
| `--assignee` | New assignee |
| `--due` | New due date |
| `--clear-due` | Clear due date |
| `--content` | New content |
| `--tag` | Add tag (repeatable) |
| `--tag-remove` | Remove tag (repeatable) |
| `--rel` | Add relation `type:id` (repeatable) |
| `--rel-remove` | Remove relation `type:id` (repeatable) |
### `ax show <id>`
Display node details.
### `ax list [flags]`
Query and list nodes.
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f|--force]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command] [flags]`
Manage aliases.
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create alias
ax alias mywork # show alias command
ax alias mywork "list --tag work2" # update alias
ax alias del mywork # delete alias
```
**Default aliases:**
| Alias | Command | Description |
|-------|---------|-------------|
| `mine` | `list --assignee $me --type issue --status open` | Show open issues assigned to you |
| `due` | `list --type issue --status open` | Show open issues |
| `inbox` | `list --mention $me` | Show your inbox |
**Alias argument expansion:**
| Variable | Expands to |
|----------|------------|
| `$me` | Current username |
| `$@` | All arguments |
| `$1`, `$2`, ... | Positional arguments |
```bash
# Create alias with argument expansion
ax alias find "list --tag $1 --status $2"
ax find backend open # expands to: list --tag backend --status open
```
## Relations
Relations connect nodes together:
| Type | Meaning | Behavior |
|------|---------|----------|
| `blocks` | A blocks B — B can't close until A is done | Enforced on status=done |
| `subtask` | A is a subtask of B | |
| `related` | A is related to B | |
| `assignee` | A is assigned to user | Single-value; set via `--assignee` flag |
| `in_namespace` | A belongs to namespace | Single-value; set via `--namespace` flag |
```bash
# Block an issue (B can't close until A is done)
ax update A --rel blocks:B
# Assign to user
ax update abc12 --assignee alice
# Create subtask
ax update abc12 --rel subtask:parent12
```
## Tags and Properties
Tags are flexible labels. Tags with pattern `_key::value` are properties:
```bash
# Regular tag
ax add "Task" --tag backend
# Property tags (set via flags)
ax add "Task" --type issue --status open --prio high
# Equivalent to: --tag _type::issue --tag _status::open --tag _prio::high
```
**Built-in properties:**
| Property | Values | Required |
|----------|--------|----------|
| `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) |
| `_status` | `open`, `done` | No |
| `_prio` | `high`, `medium`, `low` | No |
## Mentions and Inbox
Use `@username` in title or content to automatically add to user's inbox:
```bash
ax add "Review PR @alice" --content "@bob please check"
# Both alice and bob get this in their inbox
```
View inbox:
```bash
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json` for machine-readable output:
```bash
ax list --status open --json
ax show abc12 --json
```
Example output:
```json
{
"id": "abc12",
"title": "Implement feature",
"content": "Description here",
"created_at": "2026-03-25T10:00:00Z",
"updated_at": "2026-03-25T10:00:00Z",
"tags": ["_type::issue", "_status::open", "backend"],
"relations": {
"blocks": ["def34"]
}
}
```
## Configuration
`ax` stores user configuration in a JSON file. It searches for `.axconfig` in the
current directory and parent directories (like git finds `.git`), falling back to
`~/.config/ax/config.json`.
**Config file format:**
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Database Location
`ax` searches for `.ax.db` in the current directory and parent directories,
similar to how git finds `.git`. This allows you to run commands from any
subdirectory.
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
## License ## License

194
USAGE.md Normal file
View File

@@ -0,0 +1,194 @@
# Axolotl Usage Reference
## Commands
### `ax init [path]`
Create a new `.ax.db` in the specified directory (default: current).
### `ax add <title> [flags]`
| Flag | Description |
|------|-------------|
| `--type` | `issue` (default), `note`, `user`, `namespace` |
| `--status` | `open` (default), `done` |
| `--prio` | `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Body text |
| `--rel` | Relation `type:id` (repeatable) |
### `ax update <id> [flags]`
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | Transfer to namespace |
| `--assignee` | Assign to user |
| `--due` / `--clear-due` | Set or clear due date |
| `--content` | New body text |
| `--tag` / `--tag-remove` | Add or remove tag |
| `--rel` / `--rel-remove` | Add or remove relation `type:id` |
### `ax show <id>`
Display node details.
### `ax list [flags]`
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command]`
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create/update alias
ax alias del mywork # delete alias
```
**Built-in aliases:** `mine`, `due`, `inbox`
**Argument expansion:** `$me` → current user, `$@` → all args, `$1`/`$2`/… → positional
## Relations
| Type | Description |
|------|-------------|
| `blocks` | Prevents target from closing until this is done |
| `subtask` | Marks as subtask of target |
| `related` | General association |
| `assignee` | Assigns to a user (single-value) |
```bash
ax update A --rel blocks:B # A blocks B
ax update abc12 --assignee alice # assign to alice
```
## Tags and Properties
Tags follow the `_key::value` pattern for properties:
| Property | Values |
|----------|--------|
| `_type` | `issue`, `note`, `user`, `namespace` |
| `_status` | `open`, `done` |
| `_prio` | `high`, `medium`, `low` |
## Mentions and Inbox
Use `@username` in title or content to add to a user's inbox:
```bash
ax add "Review PR @alice" # alice gets an inbox entry
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json`:
```bash
ax list --status open --json
ax show abc12 --json
```
## Configuration
`ax` searches upward from CWD for `.axconfig`, falling back to `~/.config/ax/config.json`.
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Server Mode
`ax` can run as a shared HTTP JSON API server:
```bash
ax serve # starts server on configured host:port (default: 0.0.0.0:7000)
```
The server exposes the same operations (add, list, show, update, delete) over HTTP. Clients connect by setting `remote.host` / `remote.port` in their config — the CLI then transparently routes calls to the server instead of a local database.
### OIDC Authentication
The server supports OIDC for authentication. Configure in `.axconfig`:
```json
{
"serve": { "host": "0.0.0.0", "port": 7000 },
"oidc": {
"issuer": "https://your-idp.example.com",
"client_id": "axolotl",
"client_secret": "secret",
"public_url": "https://ax.example.com",
"user_claim": "preferred_username"
}
}
```
Client login:
```bash
ax login # opens browser for OIDC flow, saves session token
```
Without OIDC configured, the server accepts an `X-Ax-User` header for the username (development/trusted networks only).
### Docker
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
The image runs `ax serve` and exposes port 7000. Mount a volume at `/data` to persist the database.
## Permission Model
Every node has per-node access control. Permissions are transitive via BFS from the requesting user's own node.
| Level | Relation | Grants |
|-------|----------|--------|
| 1 | `can_read` | Read / show / list |
| 2 | `can_create_rel` | Create relations pointing to this node |
| 3 | `can_write` | Update title, content, tags |
| 4 | `has_ownership` | Full control including delete and granting access |
- Creators automatically get `has_ownership` on nodes they create.
- Namespace nodes own regular nodes within them; users own their namespaces.
- Deleting an owner cascades to all nodes it owns.
- User nodes and namespace nodes are globally readable.
```bash
# Grant bob read access to a node
ax update <bob-user-id> --rel can_read:<node-id>
# Grant bob write access
ax update <bob-user-id> --rel can_write:<node-id>
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
## Database Location
`ax` searches for `.ax.db` upward from CWD (like git finds `.git`), so commands work from any subdirectory.

View File

@@ -4,7 +4,7 @@ pkgver=0.1.0
pkgrel=1 pkgrel=1
pkgdesc="CLI-native issue tracker using SQLite" pkgdesc="CLI-native issue tracker using SQLite"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
url="https://g.eliaskohout.de/eliaskohout/axolotl" url="https://g.eliaskohout.de/eliaskohout/ax"
license=('MIT') license=('MIT')
makedepends=('go') makedepends=('go')
source=("$pkgname-$pkgver.tar.gz::https://g.eliaskohout.de/eliaskohout/axolotl/archive/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::https://g.eliaskohout.de/eliaskohout/axolotl/archive/v$pkgver.tar.gz")

View File

@@ -43,7 +43,7 @@ var addCmd = &cobra.Command{
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""}) input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""})
} }
if cNamespace != "" { if cNamespace != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelInNamespace, Target: cNamespace}) input.Namespace = cNamespace
} }
if cAssignee != "" { if cAssignee != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee}) input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee})

View File

@@ -11,6 +11,8 @@ import (
var lTags, lRels []string var lTags, lRels []string
var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string
var lDue bool
var lDueWithin int
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
Use: "list", Short: "List nodes", Use: "list", Short: "List nodes",
@@ -22,6 +24,11 @@ var listCmd = &cobra.Command{
} }
var filter service.ListFilter var filter service.ListFilter
filter.HasDueDate = lDue
if lDueWithin >= 0 {
n := lDueWithin
filter.DueWithin = &n
}
// --tag is an alias for a label filter with no target. // --tag is an alias for a label filter with no target.
for _, tag := range lTags { for _, tag := range lTags {
@@ -39,7 +46,7 @@ var listCmd = &cobra.Command{
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""})
} }
if lNamespace != "" { if lNamespace != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: lNamespace}) filter.Namespace = lNamespace
} }
if lAssignee != "" { if lAssignee != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee})
@@ -76,4 +83,6 @@ func init() {
f.StringVar(&lNamespace, "namespace", "", "filter by namespace") f.StringVar(&lNamespace, "namespace", "", "filter by namespace")
f.StringVar(&lAssignee, "assignee", "", "filter by assignee") f.StringVar(&lAssignee, "assignee", "", "filter by assignee")
f.StringVar(&lMention, "mention", "", "filter by mention") f.StringVar(&lMention, "mention", "", "filter by mention")
f.BoolVar(&lDue, "due", false, "filter to nodes with a due date")
f.IntVar(&lDueWithin, "due-within", -1, "filter to nodes due within N days (includes overdue)")
} }

View File

@@ -7,8 +7,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"sort" "sort"
"strings" "strings"
"time"
"github.com/fatih/color" "github.com/fatih/color"
) )
@@ -47,7 +49,7 @@ var (
"low": {" ", "low", cDim}, "low": {" ", "low", cDim},
"": {" ", "n/a", cDim}, "": {" ", "n/a", cDim},
} }
relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007", "in_namespace": "\uf07b"} relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007"}
prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1} prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1}
statusRanks = map[string]int{"open": 2, "": 1, "done": 0} statusRanks = map[string]int{"open": 2, "": 1, "done": 0}
) )
@@ -74,28 +76,31 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
if si != sj { if si != sj {
return statusRanks[si] > statusRanks[sj] return statusRanks[si] > statusRanks[sj]
} }
return prioRanks[nodes[i].GetProperty("prio")] > prioRanks[nodes[j].GetProperty("prio")] pi, pj := prioRanks[nodes[i].GetProperty("prio")], prioRanks[nodes[j].GetProperty("prio")]
if pi != pj {
return pi > pj
}
di, dj := nodes[i].DueDate, nodes[j].DueDate
if di == nil && dj == nil {
return false
}
if di == nil {
return false
}
if dj == nil {
return true
}
return di.Before(dj.Time) // soonest due date first
}) })
for _, n := range nodes { for _, n := range nodes {
n_rels := n.Relations
ns_rel_node_ids := n_rels[string(models.RelInNamespace)]
ns_rel_node_titles := make([]string, 0, len(ns_rel_node_ids))
for _, id := range ns_rel_node_ids {
ns_rel_node, err := svc.GetByID(id)
if err != nil {
ns_rel_node_titles = append(ns_rel_node_titles, id)
continue
}
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
}
fmt.Fprintf(w, " %s %s %s %s %s %s", fmt.Fprintf(w, " %s %s %s %s %s %s",
cDim.Sprint(n.ID), cDim.Sprint(n.ID),
render(prioRM, n.GetProperty("prio"), true), render(prioRM, n.GetProperty("prio"), true),
render(statusRM, n.GetProperty("status"), true), render(statusRM, n.GetProperty("status"), true),
render(typeRM, n.GetProperty("type"), true), render(typeRM, n.GetProperty("type"), true),
cTitle.Sprint(truncate(n.Title, 80)), cTitle.Sprint(truncate(n.Title, 80)),
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"), dueDateShort(n.DueDate),
) )
tags := n.GetDisplayTags() tags := n.GetDisplayTags()
if len(tags) > 0 { if len(tags) > 0 {
@@ -117,7 +122,7 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false)) fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false)) fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
if n.DueDate != nil { if n.DueDate != nil {
fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate.Format("2006-01-02")) fmt.Fprintf(w, " Due: %s\n", dueDateLong(n.DueDate))
} }
fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt)) fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt))
fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt)) fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))
@@ -202,6 +207,57 @@ func render(rm RenderMap, key string, short bool) string {
return v.c.Sprint(v.l) return v.c.Sprint(v.l)
} }
// dueDateShort returns a "+12d" / "-3d" style string colored by urgency.
func dueDateShort(d *models.Date) string {
if d == nil {
return ""
}
now := time.Now().UTC().Truncate(24 * time.Hour)
due := d.UTC().Truncate(24 * time.Hour)
days := int(math.Round(due.Sub(now).Hours() / 24))
var label string
if days == 0 {
label = "today"
} else if days > 0 {
label = fmt.Sprintf("+%dd", days)
} else {
label = fmt.Sprintf("%dd", days)
}
var c *color.Color
switch {
case days < 0:
c = cBad
case days <= 3:
c = cWarn
default:
c = cGood
}
return c.Sprint(label)
}
// dueDateLong returns the full date string colored by urgency.
func dueDateLong(d *models.Date) string {
if d == nil {
return ""
}
now := time.Now().UTC().Truncate(24 * time.Hour)
due := d.UTC().Truncate(24 * time.Hour)
days := int(math.Round(due.Sub(now).Hours() / 24))
var c *color.Color
switch {
case days < 0:
c = cBad
case days <= 3:
c = cWarn
default:
c = cGood
}
return c.Sprintf("%s %s", iconCalendar, d.Format("2006-01-02"))
}
func truncate(s string, max int) string { func truncate(s string, max int) string {
if len(s) <= max { if len(s) <= max {
return s return s

View File

@@ -61,7 +61,7 @@ var updateCmd = &cobra.Command{
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""}) input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""})
} }
if cmd.Flags().Changed("namespace") { if cmd.Flags().Changed("namespace") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelInNamespace, Target: uNamespace}) input.Namespace = &uNamespace
} }
if cmd.Flags().Changed("assignee") { if cmd.Flags().Changed("assignee") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee}) input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee})

View File

@@ -33,9 +33,6 @@ func TestCRUD(t *testing.T) {
if len(n.Relations["created"]) == 0 { if len(n.Relations["created"]) == 0 {
t.Error("expected created relation to be set") t.Error("expected created relation to be set")
} }
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation to be set")
}
if n.CreatedAt == "" || n.UpdatedAt == "" { if n.CreatedAt == "" || n.UpdatedAt == "" {
t.Error("expected timestamps to be set") t.Error("expected timestamps to be set")
} }
@@ -73,9 +70,6 @@ func TestCRUD(t *testing.T) {
if n.Content != "some body" { if n.Content != "some body" {
t.Errorf("content: want %q, got %q", "some body", n.Content) t.Errorf("content: want %q, got %q", "some body", n.Content)
} }
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation")
}
if len(n.Relations["assignee"]) == 0 { if len(n.Relations["assignee"]) == 0 {
t.Error("expected assignee relation") t.Error("expected assignee relation")
} }

View File

@@ -117,8 +117,26 @@ func TestPermissions(t *testing.T) {
if !userNode.HasRelation("has_ownership", aliceUserID) { if !userNode.HasRelation("has_ownership", aliceUserID) {
t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations) t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations)
} }
if !userNode.HasRelation("has_ownership", aliceNodeID) { // Nodes are now owned by the namespace they belong to, not directly by the creator.
t.Errorf("expected alice to own her node, got relations: %v", userNode.Relations) // Alice's default namespace is owned by alice, so she retains transitive ownership.
if userNode.HasRelation("has_ownership", aliceNodeID) {
t.Errorf("alice should not directly own her node (namespace owns it), got relations: %v", userNode.Relations)
}
// Find alice's default namespace and verify it owns the node.
namespaces := alice.parseNodes(alice.mustAx("list", "--type", "namespace", "--json"))
var aliceNsID string
for _, ns := range namespaces {
if ns.Title == "alice" {
aliceNsID = ns.ID
}
}
if aliceNsID == "" {
t.Fatal("could not find alice's default namespace")
}
nsOut := alice.mustAx("show", aliceNsID, "--json")
nsNode := alice.parseNode(nsOut)
if !nsNode.HasRelation("has_ownership", aliceNodeID) {
t.Errorf("expected alice's namespace to own her node, got relations: %v", nsNode.Relations)
} }
}) })
@@ -150,10 +168,6 @@ func TestNamespaceExplicitCreate(t *testing.T) {
nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json")) nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json"))
if !nsNode.HasRelation("in_namespace", nsNode.ID) {
t.Errorf("expected namespace to have in_namespace pointing to itself, got relations: %v", nsNode.Relations)
}
users := env.parseNodes(env.mustAx("list", "--type", "user", "--json")) users := env.parseNodes(env.mustAx("list", "--type", "user", "--json"))
var userNode *NodeResponse var userNode *NodeResponse
for i := range users { for i := range users {

View File

@@ -89,7 +89,7 @@ func (n *Node) AddRelation(relType RelType, target string) {
if n.Relations == nil { if n.Relations == nil {
n.Relations = make(map[string][]string) n.Relations = make(map[string][]string)
} }
if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace { if relType == RelAssignee || relType == RelCreated {
n.Relations[string(relType)] = []string{target} n.Relations[string(relType)] = []string{target}
return return
} }

View File

@@ -13,7 +13,6 @@ const (
RelRelated RelType = "related" RelRelated RelType = "related"
RelCreated RelType = "created" RelCreated RelType = "created"
RelAssignee RelType = "assignee" RelAssignee RelType = "assignee"
RelInNamespace RelType = "in_namespace"
RelMentions RelType = "mentions" RelMentions RelType = "mentions"
// Permission rels (subject → object). Levels are inclusive and transitive. // Permission rels (subject → object). Levels are inclusive and transitive.

View File

@@ -6,6 +6,7 @@ import (
"axolotl/store" "axolotl/store"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv"
"strings" "strings"
) )
@@ -76,7 +77,7 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
} }
} }
if v := q.Get("namespace"); v != "" { if v := q.Get("namespace"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: v}) filter.Namespace = v
} }
if v := q.Get("assignee"); v != "" { if v := q.Get("assignee"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v})
@@ -84,6 +85,14 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
if v := q.Get("mention"); v != "" { if v := q.Get("mention"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
} }
if q.Get("has_due_date") == "true" {
filter.HasDueDate = true
}
if v := q.Get("due_within"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filter.DueWithin = &n
}
}
nodes, err := svc.List(filter) nodes, err := svc.List(filter)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
) )
type apiClient struct { type apiClient struct {
@@ -71,6 +72,9 @@ func (c *apiClient) GetByID(id string) (*models.Node, error) {
func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) { func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q := url.Values{} q := url.Values{}
if filter.Namespace != "" {
q.Set("namespace", filter.Namespace)
}
for _, r := range filter.Rels { for _, r := range filter.Rels {
if r.Target == "" { if r.Target == "" {
q.Add("rel", string(r.Type)) q.Add("rel", string(r.Type))
@@ -78,6 +82,12 @@ func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q.Add("rel", string(r.Type)+":"+r.Target) q.Add("rel", string(r.Type)+":"+r.Target)
} }
} }
if filter.HasDueDate {
q.Set("has_due_date", "true")
}
if filter.DueWithin != nil {
q.Set("due_within", strconv.Itoa(*filter.DueWithin))
}
path := "/nodes" path := "/nodes"
if len(q) > 0 { if len(q) > 0 {
path += "?" + q.Encode() path += "?" + q.Encode()

View File

@@ -37,17 +37,20 @@ type AddInput struct {
Title string Title string
Content string Content string
DueDate string DueDate string
Namespace string // namespace name or ID; defaults to the user's personal namespace
Rels []RelInput Rels []RelInput
} }
// UpdateInput describes changes to apply to an existing node. // UpdateInput describes changes to apply to an existing node.
// AddRels and RemoveRels accept both tag rels (Target == "") and edge rels. // AddRels and RemoveRels accept both tag rels (Target == "") and edge rels.
// Setting _status::done in AddRels is rejected when the node has open blockers. // Setting _status::done in AddRels is rejected when the node has open blockers.
// Adding assignee or in_namespace rels replaces the previous single target. // Adding an assignee rel replaces the previous single target.
// Setting Namespace transfers ownership from the current namespace to the new one.
type UpdateInput struct { type UpdateInput struct {
Title *string Title *string
Content *string Content *string
DueDate *string // nil = no change; pointer to "" = clear due date DueDate *string // nil = no change; pointer to "" = clear due date
Namespace *string // nil = no change; namespace name or ID to move node into
AddRels []RelInput AddRels []RelInput
RemoveRels []RelInput RemoveRels []RelInput
} }
@@ -56,7 +59,10 @@ type UpdateInput struct {
// Tag filters (Target == "") match by rel_name prefix. // Tag filters (Target == "") match by rel_name prefix.
// Edge filters (Target != "") are resolved to node IDs. // Edge filters (Target != "") are resolved to node IDs.
type ListFilter struct { type ListFilter struct {
Namespace string // when non-empty, only return nodes owned by this namespace
Rels []RelInput Rels []RelInput
HasDueDate bool // when true, only return nodes that have a due date set
DueWithin *int // when non-nil, only return nodes due within this many days (includes overdue)
} }
// RelInput is a typed, directed rel with a target that may be a name or node ID. // RelInput is a typed, directed rel with a target that may be a name or node ID.

View File

@@ -75,7 +75,7 @@ const (
// namespaces are globally readable and any node can reference them. // namespaces are globally readable and any node can reference them.
func isReferenceRel(t models.RelType) bool { func isReferenceRel(t models.RelType) bool {
switch t { switch t {
case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace: case models.RelAssignee, models.RelCreated, models.RelMentions:
return true return true
} }
return false return false
@@ -208,6 +208,28 @@ func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
} }
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
// Resolve namespace filter to owned node IDs.
var nsOwnedIDs map[string]bool
if filter.Namespace != "" {
nsID, _ := s.resolveIDByNameAndType(s.store, filter.Namespace, "namespace")
if nsID == "" {
if exists, _ := s.store.NodeExists(filter.Namespace); exists {
nsID = filter.Namespace
}
}
if nsID == "" {
return nil, nil // namespace not found
}
nsNode, err := s.store.GetNode(nsID)
if err != nil {
return nil, nil
}
nsOwnedIDs = make(map[string]bool)
for _, ownedID := range nsNode.Relations[string(models.RelHasOwnership)] {
nsOwnedIDs[ownedID] = true
}
}
var storeFilters []*models.Rel var storeFilters []*models.Rel
for _, ri := range filter.Rels { for _, ri := range filter.Rels {
if ri.Target == "" { if ri.Target == "" {
@@ -230,10 +252,34 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
} }
var result []*models.Node var result []*models.Node
for _, n := range nodes { for _, n := range nodes {
if pc.canRead(n.ID) { if !pc.canRead(n.ID) {
continue
}
if nsOwnedIDs != nil && !nsOwnedIDs[n.ID] {
continue
}
result = append(result, n) result = append(result, n)
} }
if filter.HasDueDate || filter.DueWithin != nil {
now := time.Now().UTC().Truncate(24 * time.Hour)
filtered := result[:0]
for _, n := range result {
if n.DueDate == nil {
continue
} }
if filter.DueWithin != nil {
due := n.DueDate.UTC().Truncate(24 * time.Hour)
cutoff := now.AddDate(0, 0, *filter.DueWithin)
if due.After(cutoff) {
continue
}
}
filtered = append(filtered, n)
}
result = filtered
}
return result, nil return result, nil
} }
@@ -303,13 +349,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
hasNamespace := false
for _, ri := range input.Rels {
if ri.Type == models.RelInNamespace && ri.Target != "" {
hasNamespace = true
}
}
dueDate, err := parseDueDate(input.DueDate) dueDate, err := parseDueDate(input.DueDate)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -369,17 +408,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
// Default namespace.
if !hasNamespace {
nsID, err := s.resolveNamespaceRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
return err
}
}
// Default created. // Default created.
if !hasCreated { if !hasCreated {
userID, err := s.resolveUserRef(st, s.userID) userID, err := s.resolveUserRef(st, s.userID)
@@ -391,32 +419,30 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
// Grant creator ownership of the new node. // Grant ownership of the new node.
// Namespace nodes are owned by their creator (user node).
// All other nodes are owned by the namespace they belong to — the user
// retains transitive ownership through the namespace's own ownership chain
// (e.g. user→has_ownership→default-ns→has_ownership→node).
creatorID, err := s.resolveUserRef(st, s.userID) creatorID, err := s.resolveUserRef(st, s.userID)
if err != nil { if err != nil {
return err return err
} }
if err := st.AddRel(creatorID, string(models.RelHasOwnership), id); err != nil { ownerID := creatorID
if tmp.GetProperty("type") != "namespace" {
nsRef := input.Namespace
if nsRef == "" {
nsRef = s.userID
}
nsID, err := s.resolveNamespaceRef(st, nsRef)
if err != nil {
return err return err
} }
ownerID = nsID
// Namespace bootstrap: when creating a namespace node directly, apply the }
// same setup as ensureNamespace — self in_namespace and creator ownership. if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil {
if tmp.GetProperty("type") == "namespace" {
if !hasNamespace {
// Replace the default namespace rel (user's ns) with self-reference.
userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace")
if userNsID != "" {
if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil {
return err return err
} }
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return err
}
}
// Creator already gets ownership via the block above; nothing more to do.
}
return nil return nil
}) })
@@ -438,8 +464,8 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return nil, err return nil, err
} }
// Field/tag changes and rel removals require can_write on the node. // Field/tag changes, rel removals, and namespace change require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil
for _, ri := range input.AddRels { for _, ri := range input.AddRels {
if ri.Target == "" { if ri.Target == "" {
needsWrite = true needsWrite = true
@@ -587,7 +613,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return err return err
} }
// Single-value relations replace the previous target. // Single-value relations replace the previous target.
if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace { if ri.Type == models.RelAssignee {
for _, oldTgt := range currentRels[string(ri.Type)] { for _, oldTgt := range currentRels[string(ri.Type)] {
if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil { if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil {
return err return err
@@ -619,6 +645,24 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
} }
} }
// Namespace change: transfer ownership from the current namespace to the new one.
if input.Namespace != nil {
newNsID, err := s.resolveNamespaceRef(st, *input.Namespace)
if err != nil {
return err
}
// Remove ownership from any current namespace owner.
currentOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: id}})
for _, owner := range currentOwners {
if owner.GetProperty("type") == "namespace" {
st.RemoveRel(owner.ID, string(models.RelHasOwnership), id) //nolint:errcheck
}
}
if err := st.AddRel(newNsID, string(models.RelHasOwnership), id); err != nil {
return err
}
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -738,8 +782,6 @@ func (s *nodeServiceImpl) resolveRelTarget(st store.GraphStore, ri RelInput) (st
switch ri.Type { switch ri.Type {
case models.RelAssignee, models.RelCreated, models.RelMentions: case models.RelAssignee, models.RelCreated, models.RelMentions:
return s.resolveUserRef(st, ri.Target) return s.resolveUserRef(st, ri.Target)
case models.RelInNamespace:
return s.resolveNamespaceRef(st, ri.Target)
default: default:
// Permission rels and all other edge rels expect raw node IDs. // Permission rels and all other edge rels expect raw node IDs.
return ri.Target, nil return ri.Target, nil
@@ -756,8 +798,6 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string)
switch relType { switch relType {
case models.RelAssignee, models.RelCreated, models.RelMentions: case models.RelAssignee, models.RelCreated, models.RelMentions:
nodeType = "user" nodeType = "user"
case models.RelInNamespace:
nodeType = "namespace"
default: default:
// Permission rels and other edge rels use raw node IDs. // Permission rels and other edge rels use raw node IDs.
return "", false return "", false
@@ -790,6 +830,34 @@ func (s *nodeServiceImpl) resolveUserRef(st store.GraphStore, ref string) (strin
return s.ensureUser(st, ref) return s.ensureUser(st, ref)
} }
const globalNamespace = "_global"
func (s *nodeServiceImpl) ensureGlobalNamespace(st store.GraphStore) (string, error) {
nsID, err := s.resolveIDByNameAndType(st, globalNamespace, "namespace")
if err != nil {
return "", err
}
if nsID != "" {
return nsID, nil
}
id, err := st.GenerateID()
if err != nil {
return "", err
}
now := time.Now().UTC().Format(time.RFC3339)
if err := st.AddNode(id, globalNamespace, "", nil, now, now); err != nil {
return "", err
}
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err
}
// Self-owned so no single user controls it.
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err
}
return id, nil
}
func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (string, error) { func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (string, error) {
userID, err := s.resolveIDByNameAndType(st, username, "user") userID, err := s.resolveIDByNameAndType(st, username, "user")
if err != nil { if err != nil {
@@ -813,6 +881,17 @@ func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (stri
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil { if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err return "", err
} }
// Every user gets can_create_rel access to the global namespace (inclusive
// of can_read), providing a shared space where all users can see and interact with
// nodes that are published there. User nodes themselves are already
// globally readable via the post-BFS identity override in getPermContext.
globalNsID, err := s.ensureGlobalNamespace(st)
if err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelCanCreateRel), globalNsID); err != nil {
return "", err
}
return id, nil return id, nil
} }
@@ -842,9 +921,6 @@ func (s *nodeServiceImpl) ensureNamespace(st store.GraphStore, name string) (str
if err := st.AddRel(id, "_type::namespace", ""); err != nil { if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err return "", err
} }
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
userID, err := s.resolveUserRef(st, s.userID) userID, err := s.resolveUserRef(st, s.userID)
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -74,31 +74,20 @@ func FindAndOpenSQLiteStore() (GraphStore, error) {
} }
// FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server // FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server
// mode: if no .ax.db is found it creates and initialises one in the current // mode: if no database is found it creates and initialises one in the
// working directory instead of returning an error. // ~/.local/share/ax/ directory instead of returning an error.
func FindOrInitSQLiteStore() (GraphStore, error) { func FindOrInitSQLiteStore() (GraphStore, error) {
dir, err := filepath.Abs(".") dataRoot, err := FindDataRoot(".local", "share")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to find data dir: %w", err)
}
dbPath := filepath.Join(dataRoot, "ax.db")
if _, err := os.Stat(dbPath); err != nil {
if err := InitSQLiteStore(dbPath); err != nil {
return nil, err return nil, err
} }
for {
dbpath := filepath.Join(dir, ".ax.db")
if _, err := os.Stat(dbpath); err == nil {
return NewSQLiteStore(dbpath)
} }
if parent := filepath.Dir(dir); parent == dir { return NewSQLiteStore(dbPath)
break
} else {
dir = parent
}
}
// Not found — create and initialise in CWD.
cwd, _ := filepath.Abs(".")
dbpath := filepath.Join(cwd, ".ax.db")
if err := InitSQLiteStore(dbpath); err != nil {
return nil, err
}
return NewSQLiteStore(dbpath)
} }
// NewSQLiteStore opens a SQLite database at the given path, runs a one-time // NewSQLiteStore opens a SQLite database at the given path, runs a one-time

View File

@@ -22,7 +22,7 @@ func LoadSession() (*Session, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, nil return &Session{path: path}, nil
} }
return nil, err return nil, err
} }