Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1612f53966 | ||
|
|
4d5f1a56b1 | ||
|
|
274e392708 | ||
|
|
84ca506961 | ||
|
|
6db61b0cc7 | ||
|
|
57689ddfbb | ||
|
|
7770ba9844 | ||
|
|
55b8e438ad | ||
|
|
1607a22f0c |
@@ -103,7 +103,7 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| CalDAV server (two-way VTODO) | Done, tested |
|
| CalDAV server (two-way VTODO) | Done, tested |
|
||||||
| Mail-to-task | Done, tested |
|
| Mail-to-task | Done, tested |
|
||||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
|
||||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||||
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
|
||||||
| Times on tasks, set by dragging in the week grid | Done, tested |
|
| Times on tasks, set by dragging in the week grid | Done, tested |
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Then enable the APIs and scopes for whichever you want:
|
|||||||
|
|
||||||
| For | Enable | Scope |
|
| For | Enable | Scope |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
| Mailboxes | Gmail API | `gmail.modify` |
|
||||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||||
|
|
||||||
| Variable | What it does |
|
| Variable | What it does |
|
||||||
|
|||||||
@@ -39,8 +39,17 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||||
out as Web Push to an iPhone or a Mac.
|
out as Web Push to an iPhone or a Mac.
|
||||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||||
open standards rather than private formats.
|
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||||
|
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||||
|
of only filling up.
|
||||||
|
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
|
||||||
|
rather than private formats.
|
||||||
|
- **Six themes**, three dark and three light — including *Charcoal*, a warm
|
||||||
|
near-black after Apple Notes — and a size and typeface chosen apart from them.
|
||||||
|
- **Holds through a short network drop.** A save rides out a few seconds of no
|
||||||
|
signal and goes through when it comes back; a reload opens from what it last saw
|
||||||
|
rather than the browser's offline page. It installs as a PWA.
|
||||||
|
|
||||||
## What it looks like
|
## What it looks like
|
||||||
|
|
||||||
@@ -60,6 +69,12 @@ columns.*
|
|||||||
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
||||||
meet it.*
|
meet it.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
|
||||||
|
amber where the accent green usually is. Three dark themes and three light, and a
|
||||||
|
typeface and size chosen apart from them.*
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||||
|
|||||||
@@ -1250,7 +1250,7 @@ paths:
|
|||||||
|
|
||||||
The one click that opens Google's consent screen already exists. What it
|
The one click that opens Google's consent screen already exists. What it
|
||||||
needs behind it is a registered client, and no app can conjure one: Google
|
needs behind it is a registered client, and no app can conjure one: Google
|
||||||
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
|
issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||||
restricted scope, so an id shipped inside a public image would be both
|
restricted scope, so an id shipped inside a public image would be both
|
||||||
extractable from it and unusable without Google's security review of every
|
extractable from it and unusable without Google's security review of every
|
||||||
deployment that used it.
|
deployment that used it.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 220 KiB |
@@ -1,14 +1,20 @@
|
|||||||
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||||
// it asks for.
|
// it asks for.
|
||||||
//
|
//
|
||||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
// The one thing verdande writes back is the star. A starred message becomes a
|
||||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
// task, and once it has, the star comes off — exactly as the IMAP side takes the
|
||||||
// message does not delete the task, completing the task does not unstar the
|
// flag off a mail it has imported, and for the same reason: the star meant "turn
|
||||||
// message. Two-way would mean deciding what to do when both sides changed, and the
|
// this into a task", the task now exists, so the star is no longer true. Nothing
|
||||||
// only honest answer to that is a synchronisation model far bigger than the feature.
|
// else is touched. Completing the task does not reach back to the mail; the mail
|
||||||
|
// is a pointer the task was made from, not a copy the two have to be kept in step.
|
||||||
//
|
//
|
||||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
// That one write is why the scope is gmail.modify rather than gmail.readonly.
|
||||||
// asking for less is what makes the consent screen something a person can agree to.
|
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
|
||||||
|
// can do it — so the consent screen asks for more than it used to, and the trade
|
||||||
|
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
|
||||||
|
// than one that fills with stars nothing ever clears. Still not send, still not
|
||||||
|
// delete — modify is labels, and the only label verdande removes is the one that
|
||||||
|
// triggered the task.
|
||||||
//
|
//
|
||||||
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
||||||
// The names below are aliases so that every call site — and this package's own
|
// The names below are aliases so that every call site — and this package's own
|
||||||
@@ -27,8 +33,10 @@ import (
|
|||||||
"github.com/kristianwind/verdande/internal/google"
|
"github.com/kristianwind/verdande/internal/google"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
// Read mail and change its labels, and only mail. Not profile, not contacts, not
|
||||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
// send, not delete. The single write is removing the STARRED label from a message
|
||||||
|
// that has become a task; see the package comment for why that costs modify.
|
||||||
|
const Scope = "https://www.googleapis.com/auth/gmail.modify"
|
||||||
|
|
||||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||||
|
|
||||||
@@ -169,6 +177,25 @@ func (c *Client) get(ctx context.Context, url string, out any) error {
|
|||||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unstar takes the star off the given messages — one call for all of them, the way
|
||||||
|
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
|
||||||
|
// not ten.
|
||||||
|
//
|
||||||
|
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
|
||||||
|
// label would need one, and verdande removes none. batchModify answers 204 with no
|
||||||
|
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
|
||||||
|
// an error on Google's side and nothing worth making a round trip to hear.
|
||||||
|
func (c *Client) Unstar(ctx context.Context, ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body := struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||||
|
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
|
||||||
|
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
|
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
|
||||||
// falling back to the address when there is no display name.
|
// falling back to the address when there is no display name.
|
||||||
func SenderName(from string) string {
|
func SenderName(from string) string {
|
||||||
|
|||||||
@@ -1,16 +1,81 @@
|
|||||||
package gmail
|
package gmail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The scope is the one thing verdande chooses about the connection, and it is the
|
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||||
// one the person reads on the consent screen. The flow itself is exercised in
|
// one the person reads on the consent screen. The flow itself is exercised in
|
||||||
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
// internal/google; what belongs here is that Gmail asks for modify — read plus the
|
||||||
func TestTheScopeIsReadOnlyMail(t *testing.T) {
|
// one write it makes, taking the star off an imported message — and nothing wider.
|
||||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
func TestTheScopeIsModifyMail(t *testing.T) {
|
||||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
if !strings.HasSuffix(Scope, "gmail.modify") {
|
||||||
|
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
|
||||||
|
}
|
||||||
|
// Modify is the ceiling. Send, or the full account, would be a different promise
|
||||||
|
// to the person on the consent screen than the one this feature needs.
|
||||||
|
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
|
||||||
|
if strings.Contains(Scope, wider) {
|
||||||
|
t.Errorf("scope = %q; must not include %q", Scope, wider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unstar removes the star from a batch in one call, and it addresses the messages
|
||||||
|
// it was given and only the STARRED label.
|
||||||
|
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotBody struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
AddLabelIDs []string `json:"addLabelIds"`
|
||||||
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &gotBody)
|
||||||
|
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unstar: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
|
||||||
|
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
|
||||||
|
}
|
||||||
|
if strings.Join(gotBody.IDs, ",") != "a,b" {
|
||||||
|
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
|
||||||
|
}
|
||||||
|
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
|
||||||
|
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
|
||||||
|
}
|
||||||
|
if len(gotBody.AddLabelIDs) != 0 {
|
||||||
|
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
|
||||||
|
// there is nothing to say to it anyway.
|
||||||
|
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
called = true
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
|
||||||
|
t.Fatalf("Unstar(nil): %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("Unstar(nil) made a request; it should make none")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,40 @@ func Get(ctx context.Context, client *http.Client, accessToken, url string, out
|
|||||||
return json.NewDecoder(resp.Body).Decode(out)
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
|
||||||
|
// body coming back. A write call — modifying labels on a message — answers with
|
||||||
|
// 204 No Content when it worked, so anything 2xx is success and only the body is
|
||||||
|
// decoded, and only when the caller asked for one.
|
||||||
|
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("google: %s", resp.Status)
|
||||||
|
}
|
||||||
|
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
func randomString(n int) (string, error) {
|
func randomString(n int) (string, error) {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -251,10 +251,12 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
|||||||
// takes a context and a user rather than a request.
|
// takes a context and a user rather than a request.
|
||||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||||
//
|
//
|
||||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
// The star is taken off an imported message now, so on a starred trigger the mail
|
||||||
// asking for less is what makes the consent screen something a person can agree
|
// leaves the query on its own and this list is a safety net rather than the only
|
||||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
// record. It still earns its place: it covers the window between making the task
|
||||||
// record has to live on this side, and a record on this side has to be bounded.
|
// and the star coming off, and it is the whole of the dedup for a label trigger,
|
||||||
|
// where the label is the person's and not ours to remove. A record on this side
|
||||||
|
// has to be bounded.
|
||||||
//
|
//
|
||||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||||
// which was not a bound but a way of forgetting.
|
// which was not a bound but a way of forgetting.
|
||||||
@@ -381,6 +383,29 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The star comes off every message this query returned that now has a task —
|
||||||
|
// the ones made this run and the ones a run before it already made. Gated to the
|
||||||
|
// triggers that are about the star: for a label trigger the star is the person's
|
||||||
|
// own and not ours to touch, and dedup there rests on the seen list alone.
|
||||||
|
//
|
||||||
|
// Best effort, exactly like the IMAP unflag: a task made is not undone by a star
|
||||||
|
// that would not come off. The likeliest reason it will not, right after this
|
||||||
|
// ships, is a connection still holding the old read-only grant — the write is a
|
||||||
|
// 403 until the account is reconnected, and this logs it rather than failing the
|
||||||
|
// sync. It self-heals: the mail stays starred, stays in the next query, and is
|
||||||
|
// tried again once the grant is there.
|
||||||
|
if box.Trigger == "starred" || box.Trigger == "both" {
|
||||||
|
unstar := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if seen[id] {
|
||||||
|
unstar = append(unstar, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Unstar(ctx, unstar); err != nil {
|
||||||
|
s.log.Warn("gmail unstar", "err", err, "user", user.ID, "count", len(unstar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if created > 0 {
|
if created > 0 {
|
||||||
// The ids this run saw, on top of the ones already known.
|
// The ids this run saw, on top of the ones already known.
|
||||||
//
|
//
|
||||||
@@ -442,8 +467,8 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
//
|
//
|
||||||
// The one click that opens Google's consent screen already exists; what it needs
|
// The one click that opens Google's consent screen already exists; what it needs
|
||||||
// behind it is a registered client, and that is not something any app can conjure:
|
// behind it is a registered client, and that is not something any app can conjure:
|
||||||
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
|
// Google issues no Gmail access to an unregistered client, and `gmail.modify` is a
|
||||||
// a restricted scope, so an id shipped inside a public image would be both
|
// restricted scope, so an id shipped inside a public image would be both
|
||||||
// extractable from it and unusable without Google's security review of every
|
// extractable from it and unusable without Google's security review of every
|
||||||
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
||||||
// recreate the container to paste the result was not.
|
// recreate the container to paste the result was not.
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Gmail failure has to survive being reported.
|
// A Gmail failure has to survive being reported.
|
||||||
@@ -150,6 +153,84 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
|||||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A message that becomes a task has its star taken off — the write that the whole
|
||||||
|
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
|
||||||
|
// the way the IMAP side unflags what it imports.
|
||||||
|
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
|
||||||
|
var unstarred struct {
|
||||||
|
hit bool
|
||||||
|
body struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
|
||||||
|
unstarred.hit = true
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &unstarred.body)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
|
||||||
|
writeTestJSON(w, map[string]any{
|
||||||
|
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
|
||||||
|
"payload": map[string]any{"headers": []map[string]string{
|
||||||
|
{"name": "From", "value": "Anders <anders@example.dk>"},
|
||||||
|
{"name": "Subject", "value": "Møde på fredag"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/messages"):
|
||||||
|
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer google.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.GmailClientID = "test-client"
|
||||||
|
cfg.GmailClientSecret = "test-secret"
|
||||||
|
cfg.GoogleTokenURL = google.URL + "/token"
|
||||||
|
cfg.GmailAPIURL = google.URL
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A live access token, so the run goes straight to the list without a refresh.
|
||||||
|
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
||||||
|
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
||||||
|
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if created, _ := body["created"].(float64); created != 1 {
|
||||||
|
t.Fatalf("created = %v, want 1", body["created"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !unstarred.hit {
|
||||||
|
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
|
||||||
|
}
|
||||||
|
if strings.Join(unstarred.body.IDs, ",") != "m1" {
|
||||||
|
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
|
||||||
|
}
|
||||||
|
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
|
||||||
|
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
func mustParse(t *testing.T, raw string) *url.URL {
|
func mustParse(t *testing.T, raw string) *url.URL {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
|
|||||||
@@ -992,12 +992,22 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/themes.webp"
|
src="screenshots/themes.webp"
|
||||||
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
|
alt="The settings page showing six themes as swatches and, below them, four looks written in the typeface each one selects."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
|
<figcaption>Six themes and four looks: how bright, and how it reads.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img
|
||||||
|
src="screenshots/notes-charcoal.webp"
|
||||||
|
alt="Notes in the Charcoal theme: a warm near-black modelled on Apple Notes, the note list on the left and a note open on the right, with amber where the accent green usually is."
|
||||||
|
width="1400"
|
||||||
|
height="900"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>Charcoal — the newest theme, a warm near-black after Apple Notes, amber for the accent.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -1120,6 +1130,15 @@
|
|||||||
by itself — without touching when it is actually due.
|
by itself — without touching when it is actually due.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Holds through a drop</h3>
|
||||||
|
<p>
|
||||||
|
A tunnel, a lift, a base station handing over — the app does not go
|
||||||
|
blank when the signal does. A save you make rides out a few seconds of
|
||||||
|
no connection and goes through when it comes back, and a reload opens
|
||||||
|
from what it last saw rather than the browser's offline page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="group-label">Connected to what you already use</p>
|
<p class="group-label">Connected to what you already use</p>
|
||||||
@@ -1154,9 +1173,10 @@
|
|||||||
<h3>Your mail, as an inbox</h3>
|
<h3>Your mail, as an inbox</h3>
|
||||||
<p>
|
<p>
|
||||||
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
||||||
it becomes a task. Forward one to your own address instead and the
|
it becomes a task — and the star or flag comes off once it has, so a
|
||||||
subject is parsed like anything else you type. One way: unstarring it
|
starred inbox empties as the tasks are made instead of only filling up.
|
||||||
later does nothing to the task you made.
|
Forward one to your own address instead and the subject is parsed like
|
||||||
|
anything else you type.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -1237,6 +1257,25 @@
|
|||||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul class="lately">
|
<ul class="lately">
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.42</span>
|
||||||
|
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||||
|
Notes, with amber for the accent. And the app now holds through a short
|
||||||
|
network drop: a save rides out a few seconds of no signal, and a reload
|
||||||
|
opens from what it last saw rather than the browser's offline page.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.42</span>
|
||||||
|
A starred Gmail message loses its star once it has become a task, the way
|
||||||
|
a flagged IMAP mail already did — so a starred inbox empties itself as the
|
||||||
|
tasks are made instead of only filling up.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.42</span>
|
||||||
|
Notes read as three lines, the last one the project they are filed under;
|
||||||
|
the calendar lightened to pastel days with today marked at the top; and
|
||||||
|
two-factor set-up shows a QR code to scan.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="ver">0.41</span>
|
<span class="ver">0.41</span>
|
||||||
A task you have delegated is marked where you meet it, not only on the
|
A task you have delegated is marked where you meet it, not only on the
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 38 KiB |
@@ -298,11 +298,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
|||||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||||
|
|
||||||
// The service worker is what Web Push needs; without it a subscription cannot
|
// The service worker is what Web Push needs — and, since it also caches, what
|
||||||
// be created at all.
|
// carries the app through a short network drop. SvelteKit serves it under this
|
||||||
const worker = await request.get('/sw.js');
|
// name and registers it itself; the old hand-registered /sw.js is gone.
|
||||||
|
const worker = await request.get('/service-worker.js');
|
||||||
expect(worker.ok()).toBeTruthy();
|
expect(worker.ok()).toBeTruthy();
|
||||||
expect(await worker.text()).toContain("addEventListener('push'");
|
// The source is minified, so the quotes around the event names are whatever the
|
||||||
|
// minifier chose — match either kind rather than one it might change under us.
|
||||||
|
const source = await worker.text();
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
|
||||||
|
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app survives a network drop: reloaded with the network cut, it opens from
|
||||||
|
* the cache and still shows the tasks it last saw, instead of the browser's own
|
||||||
|
* "you are offline" page.
|
||||||
|
*
|
||||||
|
* This is the whole point of the service worker's cache — not offline authoring,
|
||||||
|
* which the app does not claim, but a reload through a tunnel or a handover coming
|
||||||
|
* back to the app rather than to a dead tab. The retry in api.js covers the write
|
||||||
|
* side of the same drop; this covers the read side.
|
||||||
|
*/
|
||||||
|
test.describe('med service worker', () => {
|
||||||
|
// The rest of the suite blocks the worker (see playwright.config.js); this one
|
||||||
|
// test is about the worker, so it turns it back on for its own context.
|
||||||
|
test.use({ serviceWorkers: 'allow' });
|
||||||
|
|
||||||
|
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
|
||||||
|
// First load, online: the worker installs, and once it controls the page the
|
||||||
|
// shell and the reads it serves are in the cache.
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait until the worker actually controls this page. Until it does, a reload
|
||||||
|
// still goes to the network and the test proves nothing — the first load
|
||||||
|
// registers the worker but the page that triggered the install is not itself
|
||||||
|
// controlled until it activates and claims.
|
||||||
|
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
// A controlled reload, still online: now the navigation and its API reads pass
|
||||||
|
// through the worker and land in the cache, ready for the drop.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||||
|
|
||||||
|
// The network is gone.
|
||||||
|
await context.setOffline(true);
|
||||||
|
try {
|
||||||
|
await page.reload();
|
||||||
|
// The shell came from the cache — the sidebar is here, not a browser error
|
||||||
|
// page — and so did the task list the app renders into it.
|
||||||
|
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
// Never leave the shared context offline for whatever test runs next.
|
||||||
|
await context.setOffline(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export default defineConfig({
|
|||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
// Off by default. The app now ships a caching service worker, and once it
|
||||||
|
// controls a page Playwright's page.route() no longer sees the fetches it
|
||||||
|
// makes — so a test that forces a 500 to check the error path gets the real
|
||||||
|
// 200 through the worker instead, and reads as a broken feature. The one test
|
||||||
|
// that needs the real worker turns it back on for its own context.
|
||||||
|
serviceWorkers: 'block',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
// Pinned, because the app reads both. The sign-up form sends
|
// Pinned, because the app reads both. The sign-up form sends
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ const SHOTS = [
|
|||||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||||
|
// Charcoal, the newest theme — a warm near-black modelled on Apple Notes, with
|
||||||
|
// amber where the green usually is. Today and the notes list are the two views
|
||||||
|
// that show a theme's surfaces, text and one accent at once, so those are the two
|
||||||
|
// the landing page carries it in.
|
||||||
|
{ name: 'today-charcoal', path: '/', theme: 'charcoal' },
|
||||||
|
{ name: 'notes-charcoal', path: '/noter', theme: 'charcoal', prepare: openMeetingNote },
|
||||||
// The new work, and in the light theme, which is what most people want to look
|
// The new work, and in the light theme, which is what most people want to look
|
||||||
// at a landing page in. The calendar drawing tasks to their length, and a note
|
// at a landing page in. The calendar drawing tasks to their length, and a note
|
||||||
// somebody shared into the "Shared with me" group.
|
// somebody shared into the "Shared with me" group.
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
|
// Empty the offline data cache before leaving: the service worker keeps the
|
||||||
|
// last-seen task and note lists so a network drop does not blank the app, and
|
||||||
|
// the next person to sign in on this browser must not find them.
|
||||||
|
try {
|
||||||
|
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
|
||||||
|
await caches?.delete('verdande-api');
|
||||||
|
} catch {
|
||||||
|
// No service worker / no Cache API (older browser, private mode): nothing
|
||||||
|
// was cached, so nothing to clear.
|
||||||
|
}
|
||||||
location.href = '/';
|
location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,13 +228,21 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: var(--s3) var(--s2);
|
padding: var(--s3) var(--s2);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
transition:
|
transition:
|
||||||
opacity var(--medium) var(--ease),
|
opacity var(--medium) var(--ease),
|
||||||
transform var(--medium) var(--ease);
|
transform var(--medium) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
/* Hover only where there is a real pointer. On a touch screen a :hover on the tap
|
||||||
background: var(--surface);
|
target makes iOS spend the first tap applying the hover and only open on the
|
||||||
|
second — the "double-tap" on iPhone. With no hover to apply, the first tap opens
|
||||||
|
the task. The rounded corners are on the row itself, so the highlight is a
|
||||||
|
rounded box like a note in the list. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The exit: a short lift and fade, so completing something reads as the task
|
/* The exit: a short lift and fade, so completing something reads as the task
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Connectivity, as the app experiences it — not as the browser reports it.
|
* Connectivity, as the app experiences it — not as the browser reports it.
|
||||||
*
|
*
|
||||||
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
|
* This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a
|
||||||
* handing over. It is deliberately NOT offline support. The app is already loaded
|
* tunnel, a base station handing over. The READ side lives in the service worker,
|
||||||
* and its data is already in memory, so there is nothing to cache and nothing to
|
* which keeps the last-seen task and note lists so a reload through a drop is not a
|
||||||
* open from cold; what a blip needs is for the save you just made to survive it,
|
* blank screen. This store is the other half: it is what keeps the save you just made
|
||||||
* not for the whole app to run without a server.
|
* from being reverted under your hands while the network is briefly gone.
|
||||||
*
|
*
|
||||||
* The request layer drives this: it retries a failed request for a short window
|
* The request layer drives this: it retries a failed request for a short window
|
||||||
* (see api.js) and tells this store while it is trying, so the interface can say
|
* (see api.js) and tells this store while it is trying, so the interface can say
|
||||||
|
|||||||
@@ -660,7 +660,7 @@ export const da = {
|
|||||||
// --- settings: integrations --------------------------------------------------------
|
// --- settings: integrations --------------------------------------------------------
|
||||||
'int.mailboxes': 'Postkasser',
|
'int.mailboxes': 'Postkasser',
|
||||||
'int.mailboxesHint':
|
'int.mailboxesHint':
|
||||||
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave. Én vej: fjerner du flaget, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
|
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave — flaget fjernes, når opgaven er oprettet. Kun én vej: gør du selv noget ved mailen bagefter, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
|
||||||
'int.addMailbox': 'Tilføj en postkasse',
|
'int.addMailbox': 'Tilføj en postkasse',
|
||||||
'int.host': 'IMAP-server',
|
'int.host': 'IMAP-server',
|
||||||
'int.username': 'Brugernavn',
|
'int.username': 'Brugernavn',
|
||||||
@@ -676,7 +676,7 @@ export const da = {
|
|||||||
'int.fetching': 'Henter …',
|
'int.fetching': 'Henter …',
|
||||||
'int.gmail': 'Gmail',
|
'int.gmail': 'Gmail',
|
||||||
'int.gmailHint':
|
'int.gmailHint':
|
||||||
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
|
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave — stjernen fjernes så (en etiket bliver, hvor den er). Det du selv gør ved mailen bagefter, ændrer ikke opgaven.',
|
||||||
'int.connectGmail': 'Forbind Gmail',
|
'int.connectGmail': 'Forbind Gmail',
|
||||||
'int.fromEnv':
|
'int.fromEnv':
|
||||||
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
|
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ export const en = {
|
|||||||
// --- settings: integrations --------------------------------------------------------
|
// --- settings: integrations --------------------------------------------------------
|
||||||
'int.mailboxes': 'Mailboxes',
|
'int.mailboxes': 'Mailboxes',
|
||||||
'int.mailboxesHint':
|
'int.mailboxesHint':
|
||||||
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task. One way: removing the flag does nothing to the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
|
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task — the flag comes off once it has. One way only: what you do to the mail afterwards does not change the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
|
||||||
'int.addMailbox': 'Add a mailbox',
|
'int.addMailbox': 'Add a mailbox',
|
||||||
'int.host': 'IMAP server',
|
'int.host': 'IMAP server',
|
||||||
'int.username': 'Username',
|
'int.username': 'Username',
|
||||||
@@ -672,7 +672,7 @@ export const en = {
|
|||||||
'int.fetching': 'Fetching …',
|
'int.fetching': 'Fetching …',
|
||||||
'int.gmail': 'Gmail',
|
'int.gmail': 'Gmail',
|
||||||
'int.gmailHint':
|
'int.gmailHint':
|
||||||
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
|
'Star an email, or give it a label, and it becomes a task — the star then comes off (a label stays where it is). What you do to the mail afterwards does not change the task.',
|
||||||
'int.connectGmail': 'Connect Gmail',
|
'int.connectGmail': 'Connect Gmail',
|
||||||
'int.fromEnv':
|
'int.fromEnv':
|
||||||
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
|
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ export function permission() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registration() {
|
async function registration() {
|
||||||
// `ready` waits for an active worker; register() alone resolves while the
|
// SvelteKit registers src/service-worker.js itself on load, so normally there
|
||||||
// worker is still installing, and subscribing against that fails.
|
// is already a registration here. Register it by hand only if there is not yet
|
||||||
await navigator.serviceWorker.register('/sw.js');
|
// one — a push subscribe that races the app's own registration would otherwise
|
||||||
|
// find nothing. `ready` waits for an active worker; register() alone resolves
|
||||||
|
// while the worker is still installing, and subscribing against that fails.
|
||||||
|
const existing = await navigator.serviceWorker.getRegistration();
|
||||||
|
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
|
||||||
return navigator.serviceWorker.ready;
|
return navigator.serviceWorker.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,7 @@
|
|||||||
|
|
||||||
<!-- The registration behind the button, for whoever can do something about
|
<!-- The registration behind the button, for whoever can do something about
|
||||||
it. Google issues no Gmail access to an unregistered client, and
|
it. Google issues no Gmail access to an unregistered client, and
|
||||||
gmail.readonly is a restricted scope — so this step cannot be skipped by
|
gmail.modify is a restricted scope — so this step cannot be skipped by
|
||||||
any app. It can stop requiring a redeploy, which is what this is. -->
|
any app. It can stop requiring a redeploy, which is what this is. -->
|
||||||
{#if app.user?.is_admin && client}
|
{#if app.user?.is_admin && client}
|
||||||
{#if client.from_env}
|
{#if client.from_env}
|
||||||
|
|||||||
@@ -1495,8 +1495,21 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
/* Pointer only. On a touch screen a :hover on the tap target costs a tap — iOS
|
||||||
background: var(--surface);
|
spends the first applying the hover and opens on the second. Gated, the first
|
||||||
|
tap opens the note. The row's buttons are shown outright on touch below, since
|
||||||
|
there is no hover to reveal them. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.star,
|
||||||
|
.archive-one {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.on {
|
.row.on {
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/* global self, caches, fetch, Response */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* verdande's service worker: Web Push, and a cache that holds through a short
|
||||||
|
* network drop.
|
||||||
|
*
|
||||||
|
* It used to do Push and nothing else, with a note that a cache serving yesterday's
|
||||||
|
* data was worse than an honest "cannot connect". The cache here does not do that.
|
||||||
|
* It is network-FIRST for everything that can go stale — a reader online always gets
|
||||||
|
* the live answer, and the cache is only reached for when the network is not — so it
|
||||||
|
* is not a copy of the app you work in offline, it is the app surviving a tunnel, a
|
||||||
|
* lift, a base-station handover. The five themes' worth of live task list stays live;
|
||||||
|
* it just no longer goes blank the moment the signal does.
|
||||||
|
*
|
||||||
|
* Writes are never touched here — they go straight to the network, and api.js already
|
||||||
|
* retries them across a short drop. Caching a write would mean a queue and conflicts,
|
||||||
|
* which is a different and much larger feature.
|
||||||
|
*/
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
// Versioned, so a deploy's worth of new hashed assets lands in a fresh cache and the
|
||||||
|
// old one is dropped on activate — the app can never be served half of two builds.
|
||||||
|
const SHELL = `verdande-shell-${version}`;
|
||||||
|
// Not versioned: the data does not belong to a build, and clearing it every deploy
|
||||||
|
// would throw away the very copy that lets a reload survive a drop. Cleared on sign-out.
|
||||||
|
const API = 'verdande-api';
|
||||||
|
|
||||||
|
// The shell: every hashed build asset, the static files, and the SPA fallback so a
|
||||||
|
// navigation can be answered from the cache when the network cannot answer it.
|
||||||
|
const SHELL_ASSETS = [...build, ...files, '/'];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(SHELL);
|
||||||
|
await cache.addAll(SHELL_ASSETS);
|
||||||
|
// Take over at the next opportunity rather than waiting for every tab to
|
||||||
|
// close — paired with clients.claim below and the versioned cache, a deploy
|
||||||
|
// reaches the user on their next load.
|
||||||
|
await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
for (const key of await caches.keys()) {
|
||||||
|
// Drop old shell versions; keep this shell and the data cache.
|
||||||
|
if (key !== SHELL && key !== API) await caches.delete(key);
|
||||||
|
}
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAsset = (pathname) => build.includes(pathname) || files.includes(pathname);
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Writes go to the network, full stop — the app retries them itself, and a cached
|
||||||
|
// write is a promise this cannot keep. Cross-origin is none of our business.
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Hashed build assets and static files never change for a given URL: cache-first,
|
||||||
|
// so a repeat load and an offline load are both instant.
|
||||||
|
if (isAsset(url.pathname)) {
|
||||||
|
event.respondWith(cacheFirst(request, SHELL));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The data: network-first so an online reader always sees the live list, with the
|
||||||
|
// last good copy kept for when the network is gone.
|
||||||
|
if (url.pathname.startsWith('/api/v1/')) {
|
||||||
|
event.respondWith(networkFirst(request, API));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A navigation (reload, or opening the app): the live page when there is one, the
|
||||||
|
// cached shell when there is not — so the app opens through a drop instead of
|
||||||
|
// showing the browser's dinosaur.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(navigate(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
const res = await fetch(request);
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
try {
|
||||||
|
const res = await fetch(request);
|
||||||
|
// Only a real answer is worth keeping — a 401 or a 500 must not overwrite the
|
||||||
|
// last good copy, or an expired session would be served back offline as fact.
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(request) {
|
||||||
|
try {
|
||||||
|
return await fetch(request);
|
||||||
|
} catch {
|
||||||
|
const cache = await caches.open(SHELL);
|
||||||
|
const shell = await cache.match('/');
|
||||||
|
return shell ?? Response.error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign-out asks for the data cache to be emptied: the next person on this browser must
|
||||||
|
// not find the last one's tasks sitting in it.
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'clear-api-cache') {
|
||||||
|
event.waitUntil(caches.delete(API));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Web Push (unchanged in behaviour, moved here from the old static/sw.js) --------
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = event.data ? event.data.json() : {};
|
||||||
|
} catch {
|
||||||
|
// A push whose body is not our JSON still deserves to be shown: browsers show
|
||||||
|
// their own "site updated in the background" notice otherwise, which looks like
|
||||||
|
// a bug in the app.
|
||||||
|
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = payload.title || 'verdande';
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: '/icon.svg',
|
||||||
|
badge: '/icon.svg',
|
||||||
|
// The tag collapses repeats: five comments on one task should replace each
|
||||||
|
// other rather than stack into five separate notifications.
|
||||||
|
tag: payload.tag || payload.url || 'verdande',
|
||||||
|
data: { url: payload.url || '/' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const target = event.notification.data?.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clientList = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
includeUncontrolled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus a tab that already has the app open rather than opening a second one.
|
||||||
|
// Somebody with the app pinned does not want a new tab per reminder.
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (new URL(client.url).origin === self.location.origin) {
|
||||||
|
await client.focus();
|
||||||
|
if ('navigate' in client) await client.navigate(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self.clients.openWindow(target);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* verdande's service worker.
|
|
||||||
*
|
|
||||||
* It exists for Web Push and nothing else. There is deliberately no offline cache:
|
|
||||||
* the app is a live view of a task list that other people are editing, and a cached
|
|
||||||
* shell serving yesterday's data is worse than a browser saying it cannot connect.
|
|
||||||
* Adding one later means versioning and invalidating it, which is a real feature
|
|
||||||
* rather than a few lines here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Take over as soon as the browser is willing. Without these two, the first push
|
|
||||||
// after granting permission arrives with no worker in control to show it.
|
|
||||||
self.addEventListener('install', () => self.skipWaiting());
|
|
||||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
|
||||||
let payload = {};
|
|
||||||
try {
|
|
||||||
payload = event.data ? event.data.json() : {};
|
|
||||||
} catch {
|
|
||||||
// A push whose body is not our JSON still deserves to be shown: browsers
|
|
||||||
// display their own "site updated in the background" notice otherwise, which
|
|
||||||
// looks like a bug in the app.
|
|
||||||
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = payload.title || 'verdande';
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(title, {
|
|
||||||
body: payload.body || '',
|
|
||||||
icon: '/icon.svg',
|
|
||||||
badge: '/icon.svg',
|
|
||||||
// The tag collapses repeats: five comments on one task should replace each
|
|
||||||
// other rather than stack into five separate notifications.
|
|
||||||
tag: payload.tag || payload.url || 'verdande',
|
|
||||||
data: { url: payload.url || '/' }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
const target = event.notification.data?.url || '/';
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const clientList = await self.clients.matchAll({
|
|
||||||
type: 'window',
|
|
||||||
includeUncontrolled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focus a tab that already has the app open rather than opening a second
|
|
||||||
// one. Somebody with the app pinned does not want a new tab per reminder.
|
|
||||||
for (const client of clientList) {
|
|
||||||
if (new URL(client.url).origin === self.location.origin) {
|
|
||||||
await client.focus();
|
|
||||||
if ('navigate' in client) await client.navigate(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await self.clients.openWindow(target);
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
});
|
|
||||||