Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc46a5c20 | ||
|
|
8c748c2a88 | ||
|
|
c4e9e1519b | ||
|
|
3f4af6c0ab | ||
|
|
7ab68b0b58 | ||
|
|
8a6e1bc39e |
@@ -100,6 +100,8 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
|||||||
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
|
||||||
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
| `DELETE` | `/projects/{id}/members/{userID}` | |
|
||||||
| `GET` | `/projects/{id}/activity` | |
|
| `GET` | `/projects/{id}/activity` | |
|
||||||
|
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
|
||||||
|
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
|
||||||
|
|
||||||
### Views, filters and labels
|
### Views, filters and labels
|
||||||
|
|
||||||
|
|||||||
+89
-2
@@ -533,7 +533,11 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
description: Where it goes when the text names no project.
|
description: Where it goes when the text names no project.
|
||||||
responses:
|
responses:
|
||||||
"201": { $ref: "#/components/responses/Task" }
|
"201":
|
||||||
|
description: Created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/QuickAddResult" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
/api/v1/tasks/quick-add/preview:
|
/api/v1/tasks/quick-add/preview:
|
||||||
@@ -756,6 +760,35 @@ paths:
|
|||||||
"201": { $ref: "#/components/responses/Project" }
|
"201": { $ref: "#/components/responses/Project" }
|
||||||
"422": { $ref: "#/components/responses/Validation" }
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
|
/api/v1/projects/reorder:
|
||||||
|
post:
|
||||||
|
tags: [projects]
|
||||||
|
summary: Set the order projects appear in.
|
||||||
|
description: |
|
||||||
|
The whole list at once rather than one move at a time. A person has a
|
||||||
|
handful of projects, so sending the order you want is simpler than
|
||||||
|
midpoints and cannot land half-applied.
|
||||||
|
|
||||||
|
Projects you do not own are skipped: `sort_order` is a column on the
|
||||||
|
project rather than a preference per viewer, so a shared one stays where
|
||||||
|
its owner put it.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ids]
|
||||||
|
properties:
|
||||||
|
ids:
|
||||||
|
type: array
|
||||||
|
maxItems: 500
|
||||||
|
items: { type: string }
|
||||||
|
description: Project ids, top to bottom.
|
||||||
|
responses:
|
||||||
|
"204": { description: Reordered. }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
/api/v1/projects/{projectID}:
|
/api/v1/projects/{projectID}:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
@@ -785,6 +818,42 @@ paths:
|
|||||||
"204": { description: Deleted. }
|
"204": { description: Deleted. }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/trash/projects:
|
||||||
|
get:
|
||||||
|
tags: [projects]
|
||||||
|
summary: Projects you have deleted and can still bring back.
|
||||||
|
description: |
|
||||||
|
Only your own. Restoring has been possible since the trash existed, but
|
||||||
|
the endpoint takes an id — and the id is what you no longer have once the
|
||||||
|
project has gone from the interface.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
projects:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/Project"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
deleted_at: { type: string, format: date-time }
|
||||||
|
purge_after:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: |
|
||||||
|
When it stops being recoverable. Sent rather than
|
||||||
|
left for the client to work out, because the
|
||||||
|
retention window is the server's and a second
|
||||||
|
copy of it will one day disagree.
|
||||||
|
task_count:
|
||||||
|
type: integer
|
||||||
|
description: Tasks that went with it, and would come back with it.
|
||||||
|
|
||||||
/api/v1/trash/projects/{projectID}/restore:
|
/api/v1/trash/projects/{projectID}/restore:
|
||||||
parameters:
|
parameters:
|
||||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||||
@@ -2070,7 +2139,12 @@ components:
|
|||||||
TaskInput:
|
TaskInput:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
project_id: { type: string, description: "Omitted on create, the task goes to the Inbox." }
|
project_id:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Omitted on create, the task goes to the Inbox. On update it moves the
|
||||||
|
task — which needs editor rights on the destination.
|
||||||
|
|
||||||
section_id: { type: string }
|
section_id: { type: string }
|
||||||
parent_id: { type: string }
|
parent_id: { type: string }
|
||||||
content: { type: string }
|
content: { type: string }
|
||||||
@@ -2086,6 +2160,19 @@ components:
|
|||||||
items: { type: string }
|
items: { type: string }
|
||||||
sort_order: { type: number }
|
sort_order: { type: number }
|
||||||
|
|
||||||
|
QuickAddResult:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/Task"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
unknown_project:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Present when the text named a `#project` that does not exist. The
|
||||||
|
task is still created, in the Inbox — a thought is not thrown away
|
||||||
|
over a typo — but the caller is told, because the alternative is
|
||||||
|
that the name vanishes from the title and nothing says why.
|
||||||
|
|
||||||
ParsedTask:
|
ParsedTask:
|
||||||
type: object
|
type: object
|
||||||
description: What the quick-add parser made of a line.
|
description: What the quick-add parser made of a line.
|
||||||
|
|||||||
@@ -929,3 +929,227 @@ func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
|
|||||||
t.Error("an SMTP host is set, so configured should be true")
|
t.Error("an SMTP host is set, so configured should be true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 200 with the app shell is an answer that says "yes, that exists" to anything
|
||||||
|
// that asks. For /.well-known/ that is actively harmful: it is a reserved
|
||||||
|
// namespace, and a client probing it is asking a yes-or-no question.
|
||||||
|
//
|
||||||
|
// Claude asks /.well-known/oauth-authorization-server before connecting to an
|
||||||
|
// MCP server. It read the shell as "there is an authorization server here" and
|
||||||
|
// then failed trying to register with it, reporting a broken sign-in service on
|
||||||
|
// a server that has none and needs none.
|
||||||
|
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/.well-known/oauth-authorization-server",
|
||||||
|
"/.well-known/oauth-protected-resource",
|
||||||
|
"/.well-known/openid-configuration",
|
||||||
|
"/.well-known/anything-at-all",
|
||||||
|
} {
|
||||||
|
resp, err := ts.client.Get(ts.URL + path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("%s: status %d, want 404", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), "<!doctype html") {
|
||||||
|
t.Errorf("%s: answered with the app shell", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one well-known path that is real still works — it is a route, not a
|
||||||
|
// fallthrough, so the rule above must not have swallowed it.
|
||||||
|
func TestWellKnownCalDAVStillRedirects(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
resp, err := client.Get(ts.URL + "/.well-known/caldav")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
t.Error("CalDAV discovery was caught by the well-known rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- what the API accepts, it must actually do -------------------------------------
|
||||||
|
|
||||||
|
// project_id on an update used to be validated and then dropped: the destination
|
||||||
|
// was permission-checked, which reads as "understood", and the task never moved
|
||||||
|
// while the answer said OK. Both the REST path and the MCP tool did it.
|
||||||
|
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
if task["project_id"] == projectID {
|
||||||
|
t.Fatal("the task started in the destination; the test proves nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"project_id": projectID})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
|
||||||
|
}
|
||||||
|
if updated["project_id"] != projectID {
|
||||||
|
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
|
||||||
|
updated["project_id"], projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it really is there, not merely reported as there.
|
||||||
|
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if reread["project_id"] != projectID {
|
||||||
|
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
|
||||||
|
taskID := created["id"].(string)
|
||||||
|
|
||||||
|
moved := ts.callTool(t, "update_task", map[string]any{
|
||||||
|
"task_id": taskID, "project_id": projectID,
|
||||||
|
})
|
||||||
|
if moved["project_id"] != projectID {
|
||||||
|
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project you cannot write to is refused rather than quietly ignored.
|
||||||
|
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
mine := task["project_id"]
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||||
|
map[string]any{"project_id": theirs["id"]})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if reread["project_id"] != mine {
|
||||||
|
t.Error("the task moved into somebody else's project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown #project is still not an error — the thought is kept rather than
|
||||||
|
// thrown away over a typo — but it is no longer silent. It used to vanish from
|
||||||
|
// the title while the task went to the Inbox, with nothing saying why.
|
||||||
|
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["unknown_project"] != "3Dekoration" {
|
||||||
|
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
|
||||||
|
}
|
||||||
|
if body["content"] != "Lav plakat" {
|
||||||
|
t.Errorf("content = %v", body["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
|
||||||
|
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||||
|
map[string]any{"text": "Lav plakat #Dekoration"})
|
||||||
|
if _, present := body["unknown_project"]; present {
|
||||||
|
t.Errorf("reported an unknown project for one that exists: %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||||
|
note, _ := created["note"].(string)
|
||||||
|
if !strings.Contains(note, "3Dekoration") {
|
||||||
|
t.Errorf("note = %q, want it to name the project that does not exist", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
|
||||||
|
// with seven children arrived as eight unrelated tasks.
|
||||||
|
func TestMCPCreateMakesSubtasks(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
parent := ts.callTool(t, "create_task", map[string]any{
|
||||||
|
"content": "Runen v2", "project_id": project["id"],
|
||||||
|
})
|
||||||
|
parentID := parent["id"].(string)
|
||||||
|
|
||||||
|
child := ts.callTool(t, "create_task", map[string]any{
|
||||||
|
"content": "Tegn ikonet", "parent_id": parentID,
|
||||||
|
})
|
||||||
|
if child["parent_id"] != parentID {
|
||||||
|
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
|
||||||
|
}
|
||||||
|
// A sub-task joins its parent's project whatever else was asked for.
|
||||||
|
if child["project_id"] != project["id"] {
|
||||||
|
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
|
||||||
|
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
|
||||||
|
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
|
||||||
|
child := ts.callTool(t, "create_task", map[string]any{
|
||||||
|
"content": "Tegn ikonet", "parent_id": parent["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// A tool that refuses reports it inside the result with isError, not as a
|
||||||
|
// JSON-RPC error — the call itself was well formed, the arguments were not.
|
||||||
|
response := ts.rpc(t, "tools/call", map[string]any{
|
||||||
|
"name": "create_task",
|
||||||
|
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
|
||||||
|
})
|
||||||
|
result, _ := response["result"].(map[string]any)
|
||||||
|
if result == nil || result["isError"] != true {
|
||||||
|
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -57,7 +58,8 @@ func (s *Server) buildMCP() *mcp.Server {
|
|||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||||
"content": mcp.Str("The task title, if not using `text`."),
|
"content": mcp.Str("The task title, if not using `text`."),
|
||||||
"project_id": mcp.Str("Which project. Defaults to the Inbox."),
|
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||||
|
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||||
"description": mcp.Str("Longer notes on the task."),
|
"description": mcp.Str("Longer notes on the task."),
|
||||||
@@ -253,6 +255,7 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
|
ParentID string `json:"parent_id"`
|
||||||
DueDate string `json:"due_date"`
|
DueDate string `json:"due_date"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -268,6 +271,11 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
task := &store.Task{CreatedBy: userID, Priority: 4}
|
task := &store.Task{CreatedBy: userID, Priority: 4}
|
||||||
labels := params.Labels
|
labels := params.Labels
|
||||||
|
|
||||||
|
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
|
||||||
|
// title while the task went to the Inbox, with nothing anywhere saying so —
|
||||||
|
// invisible in a chat, where nobody sees the row that was written.
|
||||||
|
unknownProject := ""
|
||||||
|
|
||||||
if text := strings.TrimSpace(params.Text); text != "" {
|
if text := strings.TrimSpace(params.Text); text != "" {
|
||||||
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||||
task.Content = parsed.Content
|
task.Content = parsed.Content
|
||||||
@@ -280,6 +288,8 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
if parsed.Project != "" && params.ProjectID == "" {
|
if parsed.Project != "" && params.ProjectID == "" {
|
||||||
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
||||||
task.ProjectID = id
|
task.ProjectID = id
|
||||||
|
} else {
|
||||||
|
unknownProject = parsed.Project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,6 +315,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
if params.ProjectID != "" {
|
if params.ProjectID != "" {
|
||||||
task.ProjectID = params.ProjectID
|
task.ProjectID = params.ProjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A sub-task belongs to its parent's project, whatever else was asked for:
|
||||||
|
// the two cannot disagree, and the parent is the more specific instruction.
|
||||||
|
// Without this the tools could not build a tree at all — an import of a
|
||||||
|
// parent with seven children arrived as eight unrelated tasks.
|
||||||
|
if params.ParentID != "" {
|
||||||
|
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
|
||||||
|
}
|
||||||
|
if parent.ParentID != "" {
|
||||||
|
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
|
||||||
|
}
|
||||||
|
task.ParentID = parent.ID
|
||||||
|
task.ProjectID = parent.ProjectID
|
||||||
|
}
|
||||||
|
|
||||||
if task.ProjectID == "" {
|
if task.ProjectID == "" {
|
||||||
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -319,10 +346,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
||||||
return map[string]any{
|
|
||||||
|
out := map[string]any{
|
||||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||||
"due_date": task.DueDate, "project_id": task.ProjectID,
|
"due_date": task.DueDate, "project_id": task.ProjectID,
|
||||||
}, nil
|
}
|
||||||
|
if task.ParentID != "" {
|
||||||
|
out["parent_id"] = task.ParentID
|
||||||
|
}
|
||||||
|
if unknownProject != "" {
|
||||||
|
// Phrased for a model to relay: it says what happened and what to do,
|
||||||
|
// so the person is told rather than left with a task in the wrong place.
|
||||||
|
out["note"] = fmt.Sprintf(
|
||||||
|
"There is no project called %q, so this went to the Inbox. "+
|
||||||
|
"Create the project and move it, or say which project you meant.",
|
||||||
|
unknownProject)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||||
@@ -364,8 +404,11 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
if params.Labels != nil {
|
if params.Labels != nil {
|
||||||
update.SetLabels, update.Labels = true, *params.Labels
|
update.SetLabels, update.Labels = true, *params.Labels
|
||||||
}
|
}
|
||||||
|
// Moved before the field update, and through the same helper the REST handler
|
||||||
|
// uses. This used to check the caller's rights on the destination and then
|
||||||
|
// drop the argument, so the task never moved and the answer still said OK.
|
||||||
if params.ProjectID != nil {
|
if params.ProjectID != nil {
|
||||||
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
|
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
|
||||||
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,9 +422,13 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
|||||||
}
|
}
|
||||||
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
||||||
|
|
||||||
|
// project_id is in the answer because it is one of the things that can be
|
||||||
|
// changed here: a caller that asked for a move can see whether it happened
|
||||||
|
// rather than taking "OK" on trust.
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||||
"due_date": task.DueDate, "labels": task.Labels,
|
"due_date": task.DueDate, "labels": task.Labels,
|
||||||
|
"project_id": task.ProjectID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -68,6 +69,39 @@ type projectRequest struct {
|
|||||||
Archived *bool `json:"archived"`
|
Archived *bool `json:"archived"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reorderProjectsRequest struct {
|
||||||
|
// IDs in the order they should appear, top to bottom.
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReorderProjects sets the sidebar order.
|
||||||
|
//
|
||||||
|
// The whole list rather than one move: a person has a handful of projects, and
|
||||||
|
// sending the order you want is both simpler to reason about and impossible to
|
||||||
|
// get into the half-ordered state a sequence of individual moves can produce if
|
||||||
|
// one of them fails.
|
||||||
|
//
|
||||||
|
// Projects the caller does not own are skipped — sort_order is a column on the
|
||||||
|
// project, so a shared one sits where its owner put it.
|
||||||
|
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req reorderProjectsRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A ceiling rather than a guess at what is reasonable: this is a bound on
|
||||||
|
// the transaction, not an opinion about how many projects somebody may have.
|
||||||
|
if len(req.IDs) > 500 {
|
||||||
|
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||||
|
s.internal(w, "reorder projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
var req projectRequest
|
var req projectRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
@@ -151,6 +185,38 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type trashedProjectJSON struct {
|
||||||
|
projectJSON
|
||||||
|
DeletedAt string `json:"deleted_at"`
|
||||||
|
// PurgeAfter is when it stops being recoverable. Sent rather than left for
|
||||||
|
// the frontend to work out, because the retention window is the server's and
|
||||||
|
// a copy of it in the interface is a copy that will one day disagree.
|
||||||
|
PurgeAfter string `json:"purge_after"`
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTrashedProjects is what makes restoring reachable. The restore
|
||||||
|
// endpoint has always existed; without this, using it meant knowing the id of
|
||||||
|
// something that had already vanished from the interface.
|
||||||
|
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "list trashed projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]trashedProjectJSON, 0, len(trashed))
|
||||||
|
for _, t := range trashed {
|
||||||
|
out = append(out, trashedProjectJSON{
|
||||||
|
projectJSON: toProjectJSON(t.Project),
|
||||||
|
DeletedAt: t.DeletedAt.Format(time.RFC3339),
|
||||||
|
PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339),
|
||||||
|
TaskCount: t.TaskCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||||
projectID := chi.URLParam(r, "projectID")
|
projectID := chi.URLParam(r, "projectID")
|
||||||
// Permission cannot be checked through ProjectRole here: a trashed project is
|
// Permission cannot be checked through ProjectRole here: a trashed project is
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restoring has been possible since the trash existed. Finding what to restore
|
||||||
|
// has not: the endpoint takes an id, and the id is exactly what you no longer
|
||||||
|
// have once the project has gone from the interface.
|
||||||
|
func TestTheTrashCanBeListedAndRestored(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ferie"})
|
||||||
|
id := created["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "book fly", "project_id": id})
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+id, nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gone from the ordinary listing.
|
||||||
|
_, live := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
for _, raw := range live["projects"].([]any) {
|
||||||
|
if raw.(map[string]any)["id"] == id {
|
||||||
|
t.Fatal("a deleted project is still in the project list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And findable in the trash, with enough to decide whether to bring it back.
|
||||||
|
_, trash := ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
list, _ := trash["projects"].([]any)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("want one project in the trash, got %v", list)
|
||||||
|
}
|
||||||
|
entry := list[0].(map[string]any)
|
||||||
|
if entry["name"] != "Ferie" {
|
||||||
|
t.Errorf("name = %v", entry["name"])
|
||||||
|
}
|
||||||
|
if entry["task_count"] != float64(1) {
|
||||||
|
t.Errorf("task_count = %v, want 1 — the count is what says whether it matters",
|
||||||
|
entry["task_count"])
|
||||||
|
}
|
||||||
|
if entry["purge_after"] == nil || entry["deleted_at"] == nil {
|
||||||
|
t.Errorf("no deadline was reported: %v", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp, _ := ts.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("restore: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back, with its task.
|
||||||
|
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+id, nil)
|
||||||
|
if got, _ := tasks["tasks"].([]any); len(got) != 1 {
|
||||||
|
t.Errorf("the task did not come back with the project: %v", got)
|
||||||
|
}
|
||||||
|
_, trash = ts.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||||
|
t.Errorf("still in the trash after restoring: %v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's deleted project is not in your trash, and you cannot restore it.
|
||||||
|
func TestTheTrashIsPerOwner(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, created := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
id := created["id"].(string)
|
||||||
|
ts.do(t, "DELETE", "/api/v1/projects/"+id, nil)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, trash := other.do(t, "GET", "/api/v1/trash/projects", nil)
|
||||||
|
if list, _ := trash["projects"].([]any); len(list) != 0 {
|
||||||
|
t.Errorf("somebody else's deleted project is visible: %v", list)
|
||||||
|
}
|
||||||
|
resp, _ := other.do(t, "POST", "/api/v1/trash/projects/"+id+"/restore", nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
|
||||||
|
ids = append(ids, body["id"].(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to front.
|
||||||
|
reversed := []string{ids[2], ids[1], ids[0]}
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
projects, _ := list["projects"].([]any)
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for _, raw := range projects {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
// The Inbox sorts ahead of everything by its own rule; it is not part of
|
||||||
|
// what was reordered.
|
||||||
|
if p["is_inbox"] == true {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got = append(got, p["id"].(string))
|
||||||
|
}
|
||||||
|
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
|
||||||
|
t.Errorf("order = %v, want %v", got, reversed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort_order is a column on the project, not a per-viewer preference, so
|
||||||
|
// reordering must not rearrange a project somebody else owns.
|
||||||
|
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||||
|
theirID := theirs["id"].(string)
|
||||||
|
|
||||||
|
_, before := other.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
var wanted float64
|
||||||
|
for _, raw := range before["projects"].([]any) {
|
||||||
|
if p := raw.(map[string]any); p["id"] == theirID {
|
||||||
|
wanted = p["sort_order"].(float64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask, as somebody else, to put their project first.
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
|
||||||
|
"ids": []string{theirID},
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, after := other.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
for _, raw := range after["projects"].([]any) {
|
||||||
|
if p := raw.(map[string]any); p["id"] == theirID {
|
||||||
|
if p["sort_order"] != wanted {
|
||||||
|
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
|
||||||
|
wanted, p["sort_order"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Route("/projects", func(r chi.Router) {
|
r.Route("/projects", func(r chi.Router) {
|
||||||
r.Get("/", s.handleListProjects)
|
r.Get("/", s.handleListProjects)
|
||||||
r.Post("/", s.handleCreateProject)
|
r.Post("/", s.handleCreateProject)
|
||||||
|
// Above /{projectID}, or chi reads "reorder" as an id.
|
||||||
|
r.Post("/reorder", s.handleReorderProjects)
|
||||||
|
|
||||||
r.Route("/{projectID}", func(r chi.Router) {
|
r.Route("/{projectID}", func(r chi.Router) {
|
||||||
// Read access is the floor for the whole subtree; the writes
|
// Read access is the floor for the whole subtree; the writes
|
||||||
@@ -285,9 +287,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restoring is outside the block above: a trashed project is invisible
|
// The trash is outside the block above: a deleted project is invisible
|
||||||
// to the permission check by design, so the handler resolves ownership
|
// to the permission check by design, so these handlers resolve
|
||||||
// itself.
|
// ownership themselves.
|
||||||
|
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||||
|
|
||||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||||
@@ -364,6 +367,21 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /.well-known/ is reserved by RFC 8615 and is never a route in the app.
|
||||||
|
// Handing back the shell there does not merely waste a response: it tells
|
||||||
|
// whoever asked that the thing they were looking for exists.
|
||||||
|
//
|
||||||
|
// That is not hypothetical. Claude probes
|
||||||
|
// /.well-known/oauth-authorization-server before connecting to an MCP server,
|
||||||
|
// read 200 and a page of HTML as "there is an authorization server here", and
|
||||||
|
// then failed trying to register with it — reporting a broken sign-in service
|
||||||
|
// on a server that has none and needs none. A 404 means "no OAuth here", the
|
||||||
|
// client stops asking, and the key in the URL is used as intended.
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if s.web == nil {
|
if s.web == nil {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -249,6 +251,16 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A change of project is a move, not a field: it has to renumber the task
|
||||||
|
// among its new neighbours. Done before the rest so a request that both moves
|
||||||
|
// and edits ends up entirely applied or entirely refused.
|
||||||
|
if req.ProjectID != nil {
|
||||||
|
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u := store.TaskUpdate{
|
u := store.TaskUpdate{
|
||||||
Description: req.Description, Priority: req.Priority,
|
Description: req.Description, Priority: req.Priority,
|
||||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||||
@@ -473,11 +485,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectID := req.ProjectID
|
projectID := req.ProjectID
|
||||||
|
// Set when the text named a project that does not exist. The task is still
|
||||||
|
// created — see below — but the caller is told, because the alternative is
|
||||||
|
// what it used to be: "#Dekoration" disappears from the title, the task lands
|
||||||
|
// somewhere else, and nothing anywhere says why.
|
||||||
|
unknownProject := ""
|
||||||
if parsed.Project != "" {
|
if parsed.Project != "" {
|
||||||
// An unknown "#name" is not an error: the task still gets created, in the
|
// An unknown "#name" is not an error: the task still gets created, in the
|
||||||
// Inbox, rather than a thought being thrown away over a typo.
|
// Inbox, rather than a thought being thrown away over a typo.
|
||||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||||
projectID = id
|
projectID = id
|
||||||
|
} else {
|
||||||
|
unknownProject = parsed.Project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
@@ -510,6 +529,16 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.Labels = parsed.Labels
|
t.Labels = parsed.Labels
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||||
|
|
||||||
|
if unknownProject != "" {
|
||||||
|
// Alongside the task rather than instead of it: the task was created, and
|
||||||
|
// this says what could not be honoured while creating it.
|
||||||
|
writeJSON(w, http.StatusCreated, struct {
|
||||||
|
taskJSON
|
||||||
|
UnknownProject string `json:"unknown_project"`
|
||||||
|
}{toTaskJSON(*t), unknownProject})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +554,42 @@ func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// --- helpers --------------------------------------------------------------------
|
// --- helpers --------------------------------------------------------------------
|
||||||
|
|
||||||
|
// moveTaskToProject moves a task to another project, if that is really what was
|
||||||
|
// asked for.
|
||||||
|
//
|
||||||
|
// Shared by the REST handler and the MCP tool, because they had drifted: both
|
||||||
|
// accepted project_id on an update, both checked the caller's rights on the
|
||||||
|
// destination — which reads as "understood" — and then neither passed it on,
|
||||||
|
// because store.TaskUpdate has no such field. The task stayed where it was and
|
||||||
|
// the response said OK. An argument that is validated and then dropped is worse
|
||||||
|
// than one that is refused.
|
||||||
|
//
|
||||||
|
// A move rather than a field assignment: sort_order is per project, so landing
|
||||||
|
// in a new one means being given a position among its tasks.
|
||||||
|
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
|
||||||
|
if projectID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current, err := s.db.GetTask(ctx, taskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
if current.ProjectID == projectID {
|
||||||
|
return nil // already there; not worth a write or a renumber
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No neighbours: it lands at the start of the destination, which is the only
|
||||||
|
// answer available when the caller did not say where. Section is cleared —
|
||||||
|
// a section belongs to the project being left behind.
|
||||||
|
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateTaskContent(content string, priority *int) map[string]string {
|
func validateTaskContent(content string, priority *int) map[string]string {
|
||||||
fields := map[string]string{}
|
fields := map[string]string{}
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -234,6 +234,55 @@ func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrashedProject is a deleted project, with when it went and when it goes for good.
|
||||||
|
type TrashedProject struct {
|
||||||
|
Project
|
||||||
|
DeletedAt time.Time
|
||||||
|
TaskCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrashedProjects returns what the caller owns and has deleted.
|
||||||
|
//
|
||||||
|
// Restoring has been possible since the trash existed, but only if you already
|
||||||
|
// knew the project's id — which is exactly what you no longer have once it has
|
||||||
|
// gone from the interface. A recovery window nobody can reach is not one.
|
||||||
|
//
|
||||||
|
// Owned, not shared: a member of a project somebody else deleted has no business
|
||||||
|
// bringing it back.
|
||||||
|
func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||||
|
p.archived, p.sort_order, p.created_at, p.updated_at, p.deleted_at,
|
||||||
|
(SELECT count(*) FROM tasks t
|
||||||
|
WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count
|
||||||
|
FROM projects p
|
||||||
|
WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL
|
||||||
|
ORDER BY p.deleted_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []TrashedProject{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t TrashedProject
|
||||||
|
var created, updated, deleted int64
|
||||||
|
var icon sql.NullString
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID,
|
||||||
|
&t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted,
|
||||||
|
&t.TaskCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Icon = icon.String
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
t.DeletedAt = time.Unix(deleted, 0).UTC()
|
||||||
|
t.Role = RoleOwner
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreProject brings a project back, along with the tasks that were deleted with
|
// RestoreProject brings a project back, along with the tasks that were deleted with
|
||||||
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
||||||
// and separately, and restoring a project should not undo that too.
|
// and separately, and restoring a project should not undo that too.
|
||||||
@@ -507,3 +556,30 @@ func joinComma(parts []string) string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReorderProjects writes the given order, and only for projects the user owns.
|
||||||
|
//
|
||||||
|
// The whole list at once, rather than the midpoint-between-neighbours dance the
|
||||||
|
// tasks do. A person has a handful of projects, not a thousand, so writing
|
||||||
|
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
|
||||||
|
// failure the task ordering has to respace a section to recover from.
|
||||||
|
//
|
||||||
|
// Scoped to ownership because sort_order lives on the project row rather than
|
||||||
|
// per viewer: a shared project sits where its owner put it, and a member
|
||||||
|
// reordering their sidebar must not rearrange somebody else's. Ids that are not
|
||||||
|
// the caller's are skipped rather than refused, so one shared project in the
|
||||||
|
// list does not fail the whole drag.
|
||||||
|
func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE projects SET sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
|
||||||
|
float64(i), now, id, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
|||||||
for (const href of hrefs) {
|
for (const href of hrefs) {
|
||||||
await sidebar.locator(`a[href="${href}"]`).first().click();
|
await sidebar.locator(`a[href="${href}"]`).first().click();
|
||||||
|
|
||||||
|
// Wait for the navigation before looking at the page. Without this the
|
||||||
|
// assertion can be satisfied by the heading of the page you just left —
|
||||||
|
// which is not a hypothetical: a change that removed a project page's
|
||||||
|
// heading entirely passed here on a fast machine and failed on CI.
|
||||||
|
await page.waitForURL(`**${href}`);
|
||||||
|
|
||||||
// Something with a heading rendered. A route written outside the routes
|
// Something with a heading rendered. A route written outside the routes
|
||||||
// tree lands on the SPA shell and renders nothing — which is exactly how
|
// tree lands on the SPA shell and renders nothing — which is exactly how
|
||||||
// the label route broke, and it answered 200 the whole time.
|
// the label route broke, and it answered 200 the whole time.
|
||||||
@@ -180,6 +186,47 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
expect(await worker.text()).toContain("addEventListener('push'");
|
expect(await worker.text()).toContain("addEventListener('push'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A project can be renamed, deleted and brought back.
|
||||||
|
*
|
||||||
|
* The round trip matters more than the three steps: deleting used to be a
|
||||||
|
* one-way door in the interface — the API could restore, but only from an id
|
||||||
|
* that had already disappeared from the screen.
|
||||||
|
*/
|
||||||
|
test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Ferie');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
|
||||||
|
|
||||||
|
// The heading is the rename affordance: click it, and it becomes a field.
|
||||||
|
await page.getByRole('button', { name: 'Ferie' }).click();
|
||||||
|
const title = page.getByLabel('Projektets navn');
|
||||||
|
await title.fill('Sommerferie');
|
||||||
|
await title.press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||||
|
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||||
|
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||||
|
|
||||||
|
// And it is reachable again, which is the half that did not exist.
|
||||||
|
await page.goto('/indstillinger/data');
|
||||||
|
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
|
||||||
|
await expect(trash.getByText('Sommerferie')).toBeVisible();
|
||||||
|
|
||||||
|
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The MCP connector address must not fall through to the app shell.
|
* The MCP connector address must not fall through to the app shell.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ export const api = {
|
|||||||
createProject: (data) => post('/projects', data),
|
createProject: (data) => post('/projects', data),
|
||||||
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
||||||
deleteProject: (id) => del(`/projects/${id}`),
|
deleteProject: (id) => del(`/projects/${id}`),
|
||||||
|
reorderProjects: (ids) => post('/projects/reorder', { ids }),
|
||||||
|
listTrashedProjects: () => get('/trash/projects'),
|
||||||
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
|
||||||
|
|
||||||
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
listSections: (projectId) => get(`/projects/${projectId}/sections`),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app, sidebar } from '$lib/stores.svelte.js';
|
||||||
import { api } from '$lib/api.js';
|
import { api } from '$lib/api.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -33,8 +33,80 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
|
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
|
||||||
let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared));
|
// Sorted here rather than relying on the order the server sent, so a drag
|
||||||
|
// settles the moment it is dropped instead of after the round trip.
|
||||||
|
let own = $derived(
|
||||||
|
app.projects
|
||||||
|
.filter((p) => !p.is_inbox && !p.shared)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
);
|
||||||
let current = $derived($page.url.pathname);
|
let current = $derived($page.url.pathname);
|
||||||
|
|
||||||
|
// --- resizing -------------------------------------------------------------------
|
||||||
|
|
||||||
|
let resizing = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointer events rather than mouse events, so a trackpad, a mouse and a stylus
|
||||||
|
* all work. Capturing the pointer means the drag keeps following even when it
|
||||||
|
* leaves the handle, which at 4px wide it does constantly.
|
||||||
|
*/
|
||||||
|
function startResize(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize(event) {
|
||||||
|
if (!resizing) return;
|
||||||
|
sidebar.setWidth(event.clientX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endResize(event) {
|
||||||
|
if (!resizing) return;
|
||||||
|
resizing = false;
|
||||||
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reordering ---------------------------------------------------------------
|
||||||
|
|
||||||
|
let draggingId = $state(null);
|
||||||
|
let overId = $state(null);
|
||||||
|
let overBelow = $state(false);
|
||||||
|
|
||||||
|
function onDragStart(event, project) {
|
||||||
|
draggingId = project.id;
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Firefox refuses to start a drag unless something is set on the transfer.
|
||||||
|
event.dataTransfer.setData('text/plain', project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(event, project) {
|
||||||
|
if (!draggingId || draggingId === project.id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
overId = project.id;
|
||||||
|
overBelow = event.clientY > box.top + box.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(event, target) {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = draggingId;
|
||||||
|
const below = overBelow;
|
||||||
|
draggingId = null;
|
||||||
|
overId = null;
|
||||||
|
if (!id || id === target.id) return;
|
||||||
|
|
||||||
|
const without = own.filter((p) => p.id !== id);
|
||||||
|
const at = without.findIndex((p) => p.id === target.id);
|
||||||
|
if (at < 0) return;
|
||||||
|
|
||||||
|
const ordered = [...without];
|
||||||
|
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
|
||||||
|
await app.reorderProjects(ordered.map((p) => p.id));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="sidebar" aria-label="Hovedmenu">
|
<nav class="sidebar" aria-label="Hovedmenu">
|
||||||
@@ -90,8 +162,21 @@
|
|||||||
{#each own as project (project.id)}
|
{#each own as project (project.id)}
|
||||||
<a
|
<a
|
||||||
href="/projekt/{project.id}"
|
href="/projekt/{project.id}"
|
||||||
|
class="sortable"
|
||||||
class:active={current === `/projekt/${project.id}`}
|
class:active={current === `/projekt/${project.id}`}
|
||||||
|
class:dragging={draggingId === project.id}
|
||||||
|
class:drop-above={overId === project.id && !overBelow}
|
||||||
|
class:drop-below={overId === project.id && overBelow}
|
||||||
onclick={onnavigate}
|
onclick={onnavigate}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => onDragStart(e, project)}
|
||||||
|
ondragend={() => {
|
||||||
|
draggingId = null;
|
||||||
|
overId = null;
|
||||||
|
}}
|
||||||
|
ondragover={(e) => onDragOver(e, project)}
|
||||||
|
ondragleave={() => (overId = null)}
|
||||||
|
ondrop={(e) => onDrop(e, project)}
|
||||||
>
|
>
|
||||||
<span class="dot" aria-hidden="true"></span>
|
<span class="dot" aria-hidden="true"></span>
|
||||||
{project.name}
|
{project.name}
|
||||||
@@ -182,6 +267,29 @@
|
|||||||
<span class="signout">Log ud</span>
|
<span class="signout">Log ud</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||||
|
A drag handle with no keyboard equivalent is a setting some people simply
|
||||||
|
cannot reach. -->
|
||||||
|
<div
|
||||||
|
class="resize"
|
||||||
|
class:resizing
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Bredde på sidebjælken"
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={startResize}
|
||||||
|
onpointermove={onResize}
|
||||||
|
onpointerup={endResize}
|
||||||
|
onpointercancel={endResize}
|
||||||
|
ondblclick={() => sidebar.reset()}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'ArrowLeft') sidebar.setWidth(sidebar.width - 16);
|
||||||
|
else if (e.key === 'ArrowRight') sidebar.setWidth(sidebar.width + 16);
|
||||||
|
else if (e.key === 'Home') sidebar.reset();
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -196,6 +304,45 @@
|
|||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
|
||||||
|
The visible line stays the border; this only widens what the pointer hits. */
|
||||||
|
.resize {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: -3px;
|
||||||
|
width: 7px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize:hover::after,
|
||||||
|
.resize:focus-visible::after,
|
||||||
|
.resize.resizing::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
/* The drawer has a fixed width and no room to drag against. */
|
||||||
|
.resize {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -280,6 +427,39 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||||
|
sort_order is a column on the project, not a preference per viewer. */
|
||||||
|
a.sortable {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sortable.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A line in the gap rather than a highlighted row: the gap is the target. */
|
||||||
|
a.sortable::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: var(--s2);
|
||||||
|
right: var(--s2);
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sortable.drop-above::before {
|
||||||
|
top: -1px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sortable.drop-below::before {
|
||||||
|
bottom: -1px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|||||||
@@ -246,6 +246,29 @@ class AppState {
|
|||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the projects in the given order.
|
||||||
|
*
|
||||||
|
* The whole list rather than one moved item: it is a handful of rows, and
|
||||||
|
* sending the order you want cannot land in the half-applied state a sequence
|
||||||
|
* of individual moves can. `sort_order` is set here as well as on the server,
|
||||||
|
* so the sidebar settles immediately rather than after a round trip.
|
||||||
|
*/
|
||||||
|
async reorderProjects(ids) {
|
||||||
|
const previous = this.projects;
|
||||||
|
const rank = new Map(ids.map((id, i) => [id, i]));
|
||||||
|
|
||||||
|
this.projects = this.projects.map((p) =>
|
||||||
|
rank.has(p.id) ? { ...p, sort_order: rank.get(p.id) } : p
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await api.reorderProjects(ids);
|
||||||
|
} catch (e) {
|
||||||
|
this.projects = previous;
|
||||||
|
this.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- toasts -------------------------------------------------------------------
|
// --- toasts -------------------------------------------------------------------
|
||||||
|
|
||||||
toast(message) {
|
toast(message) {
|
||||||
@@ -263,6 +286,58 @@ class AppState {
|
|||||||
|
|
||||||
export const app = new AppState();
|
export const app = new AppState();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How wide the sidebar is, and whether it is showing at all.
|
||||||
|
*
|
||||||
|
* Kept in localStorage rather than on the account: it is a property of the
|
||||||
|
* screen you are sitting at, not of you. The same person on a laptop and on a
|
||||||
|
* wide monitor wants different answers, and syncing it would make one of those
|
||||||
|
* two wrong every time they switch.
|
||||||
|
*/
|
||||||
|
const SIDEBAR_MIN = 200;
|
||||||
|
const SIDEBAR_MAX = 480;
|
||||||
|
const SIDEBAR_DEFAULT = 268;
|
||||||
|
|
||||||
|
function storedNumber(key, fallback) {
|
||||||
|
if (typeof localStorage === 'undefined') return fallback;
|
||||||
|
const raw = Number(localStorage.getItem(key));
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SidebarLayout {
|
||||||
|
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
|
||||||
|
collapsed = $state(
|
||||||
|
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
|
||||||
|
);
|
||||||
|
|
||||||
|
setWidth(px) {
|
||||||
|
// Clamped here rather than in the drag handler, so every caller gets the
|
||||||
|
// same rule and a stored value from an older build cannot make the
|
||||||
|
// sidebar unusably narrow.
|
||||||
|
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
|
||||||
|
this.#save('verdande:sidebar-width', String(this.width));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.collapsed = !this.collapsed;
|
||||||
|
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.setWidth(SIDEBAR_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#save(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// Private browsing; the choice simply will not persist.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sidebar = new SidebarLayout();
|
||||||
|
|
||||||
/** Theme, kept in localStorage and applied to the document element. */
|
/** Theme, kept in localStorage and applied to the document element. */
|
||||||
export const theme = {
|
export const theme = {
|
||||||
get current() {
|
get current() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { app, theme } from '$lib/stores.svelte.js';
|
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
@@ -66,11 +66,20 @@
|
|||||||
{:else if !app.user}
|
{:else if !app.user}
|
||||||
<SignIn />
|
<SignIn />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="shell" class:sidebar-open={sidebarOpen}>
|
<div
|
||||||
|
class="shell"
|
||||||
|
class:sidebar-open={sidebarOpen}
|
||||||
|
class:sidebar-collapsed={sidebar.collapsed}
|
||||||
|
style="--sidebar-width: {sidebar.width}px"
|
||||||
|
>
|
||||||
<Sidebar onnavigate={() => (sidebarOpen = false)} />
|
<Sidebar onnavigate={() => (sidebarOpen = false)} />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
<!-- Two controls, not one: on a phone the sidebar is a drawer that
|
||||||
|
opens over the page, on a desktop it is a column that folds away.
|
||||||
|
Same words, different actions, so each is shown only where it
|
||||||
|
applies. -->
|
||||||
<button
|
<button
|
||||||
class="menu"
|
class="menu"
|
||||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||||
@@ -82,6 +91,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="fold"
|
||||||
|
onclick={() => sidebar.toggle()}
|
||||||
|
aria-label={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||||
|
aria-pressed={sidebar.collapsed}
|
||||||
|
title={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
|
<path d="M9 4v16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="search" onclick={() => (paletteOpen = true)}>
|
<button class="search" onclick={() => (paletteOpen = true)}>
|
||||||
<span>Søg</span>
|
<span>Søg</span>
|
||||||
<kbd>⌘K</kbd>
|
<kbd>⌘K</kbd>
|
||||||
@@ -173,7 +195,32 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fold {
|
||||||
|
display: grid;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
transition: color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folded away rather than merely narrow: width 0 with the border gone, so
|
||||||
|
nothing of it is left to catch the eye. */
|
||||||
|
.shell.sidebar-collapsed :global(.sidebar) {
|
||||||
|
width: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
border-right: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.menu svg,
|
.menu svg,
|
||||||
|
.fold svg,
|
||||||
.theme svg {
|
.theme svg {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -277,6 +324,11 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The drawer is the whole answer here; folding a drawer means nothing. */
|
||||||
|
.fold {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.scrim {
|
.scrim {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -292,5 +344,15 @@
|
|||||||
.shell.sidebar-open :global(.sidebar) {
|
.shell.sidebar-open :global(.sidebar) {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A collapsed desktop sidebar must not follow you to a phone, where the
|
||||||
|
drawer would then open to nothing. */
|
||||||
|
.shell.sidebar-collapsed :global(.sidebar) {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||||
|
padding-right: var(--s3);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -101,6 +101,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let exportProject = $state('');
|
let exportProject = $state('');
|
||||||
|
|
||||||
|
// --- the trash -------------------------------------------------------------
|
||||||
|
|
||||||
|
let trashed = $state([]);
|
||||||
|
let loadingTrash = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.listTrashedProjects()
|
||||||
|
.then((r) => (trashed = r.projects))
|
||||||
|
.catch((e) => app.toast(humanMessage(e)))
|
||||||
|
.finally(() => (loadingTrash = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function restore(project) {
|
||||||
|
const previous = trashed;
|
||||||
|
trashed = trashed.filter((p) => p.id !== project.id);
|
||||||
|
try {
|
||||||
|
await api.restoreProject(project.id);
|
||||||
|
await app.refreshProjects();
|
||||||
|
} catch (e) {
|
||||||
|
trashed = previous;
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
|
||||||
|
function daysLeft(iso) {
|
||||||
|
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -146,6 +176,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>Papirkurv</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som
|
||||||
|
de var — bortset fra opgaver, du havde slettet hver for sig først; dem var
|
||||||
|
der en grund til.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loadingTrash}
|
||||||
|
<p class="empty">…</p>
|
||||||
|
{:else if trashed.length === 0}
|
||||||
|
<p class="empty">Papirkurven er tom.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each trashed as project (project.id)}
|
||||||
|
<li>
|
||||||
|
<div class="what">
|
||||||
|
<span class="title">{project.name}</span>
|
||||||
|
<span class="meta">
|
||||||
|
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
|
||||||
|
{daysLeft(project.purge_after)} dage tilbage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="use" onclick={() => restore(project)}>Hent tilbage</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>Eksport</h2>
|
<h2>Eksport</h2>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
/** One project: its sections, its tasks, and who it is shared with. */
|
/** One project: its sections, its tasks, and who it is shared with. */
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
@@ -65,6 +66,41 @@
|
|||||||
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
|
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
|
||||||
let unsectioned = $derived(open.filter((t) => !t.section_id));
|
let unsectioned = $derived(open.filter((t) => !t.section_id));
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
|
||||||
|
/** Renames on blur. A no-op when nothing changed, so tabbing through is free. */
|
||||||
|
async function rename(input) {
|
||||||
|
const name = input.value.trim();
|
||||||
|
editing = false;
|
||||||
|
if (!name || name === project.name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
project = await api.updateProject(project.id, { name });
|
||||||
|
// The sidebar reads its own copy, so it has to be told.
|
||||||
|
await app.refreshProjects();
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
const days = 30;
|
||||||
|
const confirmed = confirm(
|
||||||
|
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
||||||
|
`Det havner i papirkurven og kan hentes tilbage under ` +
|
||||||
|
`Indstillinger → Data og skabeloner i ${days} dage.`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteProject(project.id);
|
||||||
|
await app.refreshProjects();
|
||||||
|
goto('/');
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function invite(event) {
|
async function invite(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
inviteError = '';
|
inviteError = '';
|
||||||
@@ -87,7 +123,38 @@
|
|||||||
<div class="view">
|
<div class="view">
|
||||||
{#if project}
|
{#if project}
|
||||||
<header>
|
<header>
|
||||||
<h1>{project.name}</h1>
|
<!-- A heading first, a field second. Swapping the h1 out for an input
|
||||||
|
outright cost the page its heading — which the sidebar smoke test
|
||||||
|
caught, and which is a real loss: every view here has one, and it is
|
||||||
|
what a screen reader announces on arrival. So the heading stays and
|
||||||
|
becomes editable on click. -->
|
||||||
|
{#if editing}
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
class="title"
|
||||||
|
autofocus
|
||||||
|
value={project.name}
|
||||||
|
aria-label="Projektets navn"
|
||||||
|
onblur={(e) => rename(e.currentTarget)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') e.currentTarget.blur();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.currentTarget.value = project.name;
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<h1 class:renameable={isOwner}>
|
||||||
|
{#if isOwner}
|
||||||
|
<button onclick={() => (editing = true)} title="Klik for at omdøbe">
|
||||||
|
{project.name}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{project.name}
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
{/if}
|
||||||
<div class="views" role="group" aria-label="Visning">
|
<div class="views" role="group" aria-label="Visning">
|
||||||
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
||||||
<button
|
<button
|
||||||
@@ -103,6 +170,14 @@
|
|||||||
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isOwner && !project.is_inbox}
|
||||||
|
<button class="remove" onclick={remove} aria-label="Slet projektet">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showShare}
|
{#if showShare}
|
||||||
@@ -225,6 +300,72 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The heading's button carries no button-ness: it is the heading, and the only
|
||||||
|
hint that it does anything is the underline on hover. */
|
||||||
|
h1.renameable button {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.renameable button:hover {
|
||||||
|
border-bottom-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Matches the heading it stands in for, so the swap does not move the page. */
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--s1) var(--s2);
|
||||||
|
margin-left: calc(var(--s2) * -1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title:focus {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
flex: none;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
transition:
|
||||||
|
color var(--fast) var(--ease),
|
||||||
|
background var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
.views {
|
.views {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
|||||||
Reference in New Issue
Block a user