Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968a29ca67 | ||
|
|
fcd6dca852 |
@@ -2079,6 +2079,53 @@ paths:
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/push/test:
|
||||
post:
|
||||
tags: [notifications]
|
||||
summary: Send one notification to your own devices, and say what happened.
|
||||
description: |
|
||||
The answer to "no notifications arrive". A push that a service refuses is
|
||||
the ordinary failure — a stale subscription, a browser that revoked
|
||||
permission without telling the server — and every one of them looks
|
||||
exactly like silence from the outside.
|
||||
|
||||
It goes through the same path a real notification takes, so what it proves
|
||||
is the thing that has to work. The title and body come from the caller
|
||||
because the dictionaries live in the interface.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title: { type: string, maxLength: 200 }
|
||||
body: { type: string, maxLength: 500 }
|
||||
responses:
|
||||
"200":
|
||||
description: What each device did with it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
subscriptions:
|
||||
type: integer
|
||||
description: Devices registered for this account.
|
||||
sent:
|
||||
type: integer
|
||||
failed:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
service:
|
||||
type: string
|
||||
description: The push service's host — never the endpoint, which is a credential.
|
||||
reason:
|
||||
type: string
|
||||
description: What the service said.
|
||||
|
||||
/api/v1/sections/{sectionID}:
|
||||
parameters:
|
||||
- { name: sectionID, in: path, required: true, schema: { type: string } }
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// --- MCP -----------------------------------------------------------------------------
|
||||
@@ -1468,3 +1470,62 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||
t.Error("the second rotation returned the same address")
|
||||
}
|
||||
}
|
||||
|
||||
// "Der kommer ingen notifikationer" må kunne besvares.
|
||||
//
|
||||
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
|
||||
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
|
||||
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
|
||||
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
|
||||
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
|
||||
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
|
||||
"title": "Prøve", "body": "kom den frem?",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["subscriptions"] != float64(0) {
|
||||
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
|
||||
}
|
||||
|
||||
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
|
||||
// som et navngivent afslag og ikke som tavshed.
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
|
||||
UserID: user.ID,
|
||||
Endpoint: "https://push.example.invalid/abc",
|
||||
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
|
||||
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
|
||||
if body["subscriptions"] != float64(1) {
|
||||
t.Fatalf("subscriptions = %v", body["subscriptions"])
|
||||
}
|
||||
failed, _ := body["failed"].([]any)
|
||||
if len(failed) != 1 {
|
||||
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
|
||||
}
|
||||
first, _ := failed[0].(map[string]any)
|
||||
if first["service"] != "push.example.invalid" {
|
||||
t.Errorf("service = %v", first["service"])
|
||||
}
|
||||
if first["reason"] == "" || first["reason"] == nil {
|
||||
t.Error("afslaget kom uden en grund")
|
||||
}
|
||||
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
|
||||
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
|
||||
if strings.Contains(fmt.Sprint(first), "/abc") {
|
||||
t.Errorf("endepunktet lækkede med ud: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -106,18 +108,44 @@ func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
s.pushToUser(userID, title, body, projectID)
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
// pushOutcome is what happened, per device.
|
||||
//
|
||||
// It exists because "nothing arrives" was unanswerable. A push that a service
|
||||
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
|
||||
// service will not take, a browser that revoked permission without telling the
|
||||
// server — and every one of them looked exactly like silence.
|
||||
type pushOutcome struct {
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
Sent int `json:"sent"`
|
||||
Failed []pushFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// pushFailure names the service and quotes what it said.
|
||||
//
|
||||
// The host and not the endpoint: the rest of that URL is the credential the push
|
||||
// service issued for this device, and it does not belong in an answer, a log line
|
||||
// or a screenshot.
|
||||
type pushFailure struct {
|
||||
Service string `json:"service"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
var out pushOutcome
|
||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return
|
||||
return out
|
||||
}
|
||||
out.Subscriptions = len(subs)
|
||||
|
||||
public, private, err := s.vapidKeys(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("vapid keys", "err", err)
|
||||
return
|
||||
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
|
||||
return out
|
||||
}
|
||||
|
||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||
@@ -128,13 +156,101 @@ func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
})
|
||||
if err == nil {
|
||||
out.Sent++
|
||||
continue
|
||||
}
|
||||
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
|
||||
// Warn, not Debug.
|
||||
//
|
||||
// This was the one line that would have said why no notification ever
|
||||
// arrived, and at Debug it is not printed on an instance running at the
|
||||
// default level. A delivery that fails silently and a delivery that does
|
||||
// not happen look the same from the outside, and the difference is the
|
||||
// whole diagnosis.
|
||||
reason := pushReason(sub.Endpoint, err)
|
||||
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
|
||||
out.Failed = append(out.Failed, pushFailure{
|
||||
Service: pushHost(sub.Endpoint),
|
||||
Reason: reason,
|
||||
})
|
||||
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
||||
s.log.Warn("record push failure", "err", recErr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pushReason is what went wrong, with the endpoint taken back out of it.
|
||||
//
|
||||
// Go wraps a transport failure as `*url.Error`, and its message is `Post
|
||||
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
|
||||
// endpoint, which is the credential the push service issued for that device. It
|
||||
// would have gone into the answer, the log line and any screenshot of either. A
|
||||
// test written to prove the host was reported instead is what found it.
|
||||
//
|
||||
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
|
||||
// anyway: the next error type to carry it will not come with a warning.
|
||||
func pushReason(endpoint string, err error) string {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && urlErr.Err != nil {
|
||||
err = urlErr.Err
|
||||
}
|
||||
reason := err.Error()
|
||||
if endpoint != "" {
|
||||
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// pushHost is the service an endpoint belongs to, and nothing else from it.
|
||||
func pushHost(endpoint string) string {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
return "ukendt"
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
// handlePushTest sends one notification to this account's devices and says what
|
||||
// happened to it.
|
||||
//
|
||||
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
|
||||
// settings page said "on for this device" as soon as a subscription existed, and a
|
||||
// refusal from the push service went into a Debug line nobody sees. There was no
|
||||
// way to tell "the browser never subscribed" from "the service refused it" from
|
||||
// "nothing has come due yet" — three different problems with one appearance.
|
||||
//
|
||||
// It goes through the same path as a real notification rather than a shortcut, so
|
||||
// what it proves is the thing that actually has to work.
|
||||
//
|
||||
// The words come from the client, which is where the dictionaries are. A server
|
||||
// that carries its own copy of "This is a test" carries it in one language, and
|
||||
// this one deliberately keeps its prose in the interface — see
|
||||
// `internal/i18n`. The text is only ever shown back to the person who asked for
|
||||
// it, on their own devices.
|
||||
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
title = "verdande"
|
||||
}
|
||||
// Bounded: this is written into a notification on somebody's lock screen, and
|
||||
// a megabyte of title is not a notification.
|
||||
if len(title) > 200 {
|
||||
title = title[:200]
|
||||
}
|
||||
body := strings.TrimSpace(req.Body)
|
||||
if len(body) > 500 {
|
||||
body = body[:500]
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
|
||||
}
|
||||
|
||||
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||
|
||||
@@ -295,6 +295,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/key", s.handlePushKey)
|
||||
r.Post("/subscribe", s.handleSubscribePush)
|
||||
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
||||
// One notification, to this account's own devices, reporting what
|
||||
// each of them did with it. The answer to "der kommer ikke nogen".
|
||||
r.Post("/test", s.handlePushTest)
|
||||
})
|
||||
|
||||
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
||||
|
||||
@@ -3303,3 +3303,78 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Notelisten står i grupper.
|
||||
*
|
||||
* Den føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række sagde
|
||||
* tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste række,
|
||||
* også når tyve noter deler dag, og projektet fik en linje for sig selv.
|
||||
*
|
||||
* Datoen er flyttet op i en overskrift, favoritterne har fået deres egen gruppe —
|
||||
* de lå allerede først, der stod bare ikke hvorfor — og arkivet grupperes på samme
|
||||
* måde, for det er dér, de tolv hundrede importerede ligger.
|
||||
*/
|
||||
test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
for (const title of ['Gruppe én', 'Gruppe to']) {
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type(title);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.type('Brødtekst i ' + title);
|
||||
await expect(ed.locator('h1')).toHaveText(title);
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
await page.reload();
|
||||
|
||||
// Skrevet i dag, så de står under dagens overskrift.
|
||||
const today = page.getByRole('heading', { name: /^I dag/ });
|
||||
await expect(today, 'der er ingen datogruppe').toBeVisible();
|
||||
|
||||
// En favorit får sin egen gruppe. Uden overskriften ligger den bare først, og
|
||||
// der står ikke hvorfor.
|
||||
await expect(page.getByRole('heading', { name: /Favoritter/i })).toBeHidden();
|
||||
const row = page.locator('li').filter({ hasText: 'Gruppe én' }).first();
|
||||
await row.hover();
|
||||
await row.getByRole('button', { name: /favorit/i }).click();
|
||||
const favourites = page.getByRole('heading', { name: /Favoritter/i });
|
||||
await expect(favourites).toBeVisible();
|
||||
|
||||
// Og den ligger under den, ikke under dagen.
|
||||
const inFavourites = page
|
||||
.locator('h3.group', { hasText: /Favoritter/i })
|
||||
.locator('+ ul li')
|
||||
.filter({ hasText: 'Gruppe én' });
|
||||
await expect(inFavourites).toBeVisible();
|
||||
|
||||
// Grupper kan foldes sammen. Ikke husket på tværs af besøg: en gruppe, der
|
||||
// åbner foldet uden at nogen bad om det, er en liste, der ser tom ud.
|
||||
await favourites.getByRole('button').click();
|
||||
await expect(inFavourites).toBeHidden();
|
||||
await favourites.getByRole('button').click();
|
||||
await expect(inFavourites).toBeVisible();
|
||||
|
||||
// En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
|
||||
// matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget
|
||||
// andet end det, der blev spurgt om.
|
||||
await page.getByPlaceholder('Søg i noter').fill('Brødtekst');
|
||||
await expect(page.locator('li').filter({ hasText: 'Gruppe to' }).first()).toBeVisible();
|
||||
await expect(page.locator('h3.group')).toHaveCount(0);
|
||||
await page.getByPlaceholder('Søg i noter').fill('');
|
||||
|
||||
// Arkivet grupperes på samme måde. Det er dér, det betyder mest.
|
||||
await row.hover();
|
||||
await row.getByRole('button', { name: /Arkivér/i }).click();
|
||||
await page.getByRole('button', { name: 'Vis arkivet' }).click();
|
||||
await expect(
|
||||
page.locator('h3.group'),
|
||||
'arkivet står ugrupperet'
|
||||
).not.toHaveCount(0);
|
||||
await expect(page.locator('li').filter({ hasText: 'Gruppe én' }).first()).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -366,6 +366,8 @@ export const api = {
|
||||
pushKey: () => get('/push/key'),
|
||||
subscribePush: (subscription) => post('/push/subscribe', subscription),
|
||||
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
|
||||
// Teksten kommer herfra, fordi ordbøgerne gør. Serveren har ingen.
|
||||
testPush: (title, body) => post('/push/test', { title, body }),
|
||||
|
||||
// --- integrations
|
||||
getMailAddress: () => get('/mail-address'),
|
||||
|
||||
@@ -75,6 +75,11 @@ export const da = {
|
||||
'notes.syntaxProject': 'projekt',
|
||||
'notes.syntaxNote': 'en anden note',
|
||||
'notes.yesterday': 'i går',
|
||||
'notes.groupFavourites': 'Favoritter',
|
||||
'notes.groupToday': 'I dag',
|
||||
'notes.groupYesterday': 'I går',
|
||||
'notes.groupWeek': 'Denne uge',
|
||||
'notes.groupUnknown': 'Uden dato',
|
||||
'notes.title': 'Noter',
|
||||
'notes.new': 'Ny note',
|
||||
'notes.search': 'Søg i noter',
|
||||
@@ -242,6 +247,13 @@ export const da = {
|
||||
'push.turnOff': 'Slå fra',
|
||||
'push.turnOn': 'Slå til på denne enhed',
|
||||
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
|
||||
'push.test': 'Send en prøve',
|
||||
'push.testing': 'Sender …',
|
||||
'push.testTitle': 'Prøve fra verdande',
|
||||
'push.testBody': 'Kom den frem, virker notifikationer på denne enhed.',
|
||||
'push.testSent': 'Sendt til {n} enhed(er). Kom den ikke frem, er det browseren eller styresystemet, der holder den tilbage — se om verdande må sende notifikationer.',
|
||||
'push.testNone': 'Ingen enheder er tilmeldt endnu. Slå notifikationer til her på enheden først.',
|
||||
'push.testFailed': '{service} afviste den: {reason}',
|
||||
'push.recent': 'Seneste',
|
||||
'push.none': 'Ingenting endnu.',
|
||||
'push.open': 'Åbn',
|
||||
|
||||
@@ -71,6 +71,11 @@ export const en = {
|
||||
'notes.syntaxProject': 'project',
|
||||
'notes.syntaxNote': 'another note',
|
||||
'notes.yesterday': 'yesterday',
|
||||
'notes.groupFavourites': 'Favourites',
|
||||
'notes.groupToday': 'Today',
|
||||
'notes.groupYesterday': 'Yesterday',
|
||||
'notes.groupWeek': 'This week',
|
||||
'notes.groupUnknown': 'No date',
|
||||
'notes.title': 'Notes',
|
||||
'notes.new': 'New note',
|
||||
'notes.search': 'Search notes',
|
||||
@@ -238,6 +243,13 @@ export const en = {
|
||||
'push.turnOff': 'Turn off',
|
||||
'push.turnOn': 'Turn on for this device',
|
||||
'push.perBrowser': 'Applies only to the browser you are in right now.',
|
||||
'push.test': 'Send a test',
|
||||
'push.testing': 'Sending …',
|
||||
'push.testTitle': 'Test from verdande',
|
||||
'push.testBody': 'If this arrived, notifications work on this device.',
|
||||
'push.testSent': 'Sent to {n} device(s). If nothing arrived, the browser or the operating system is holding it back — check that verdande is allowed to send notifications.',
|
||||
'push.testNone': 'No devices are registered yet. Turn notifications on here first.',
|
||||
'push.testFailed': '{service} refused it: {reason}',
|
||||
'push.recent': 'Recent',
|
||||
'push.none': 'Nothing yet.',
|
||||
'push.open': 'Open',
|
||||
|
||||
@@ -35,6 +35,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
let testing = $state(false);
|
||||
let testResult = $state(null);
|
||||
|
||||
/**
|
||||
* Én notifikation, hele vejen gennem den rigtige rute, og et svar på hvad der
|
||||
* skete med den.
|
||||
*
|
||||
* Den siger ikke "sendt" og lader det være. Kommer den frem, ved man det, fordi
|
||||
* den ligger på skærmen; kommer den ikke, står der her, hvem der afviste den og
|
||||
* med hvilke ord — og hvis ingen afviste den, står der, at det så er browseren
|
||||
* eller styresystemet, der holder den tilbage. Det er de tre steder, den kan
|
||||
* blive væk, og nu peger svaret på ét af dem.
|
||||
*/
|
||||
async function testPush() {
|
||||
testing = true;
|
||||
testResult = null;
|
||||
try {
|
||||
const r = await api.testPush(t('push.testTitle'), t('push.testBody'));
|
||||
if (!r.subscriptions) {
|
||||
testResult = { bad: true, text: t('push.testNone') };
|
||||
} else if (r.failed?.length) {
|
||||
testResult = {
|
||||
bad: true,
|
||||
text: r.failed
|
||||
.map((f) => t('push.testFailed', { service: f.service, reason: f.reason }))
|
||||
.join(' ')
|
||||
};
|
||||
} else {
|
||||
testResult = { bad: false, text: t('push.testSent', { n: r.sent }) };
|
||||
}
|
||||
} catch (e) {
|
||||
testResult = { bad: true, text: humanMessage(e) };
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePush() {
|
||||
busy = true;
|
||||
try {
|
||||
@@ -165,7 +202,18 @@
|
||||
<p class="hint">{t('push.on')}</p>
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
|
||||
<!-- "Til" er ikke det samme som "virker".
|
||||
Ruden sagde til, så snart der var et abonnement, og en push, tjenesten
|
||||
afviste, gik i en logline på Debug, som ingen ser. Tre forskellige
|
||||
problemer — browseren tilmeldte sig aldrig, tjenesten afviste den,
|
||||
der er ikke noget forfaldent endnu — så ens ud fra den her side. -->
|
||||
<button class="secondary" onclick={testPush} disabled={busy || testing}>
|
||||
{testing ? t('push.testing') : t('push.test')}
|
||||
</button>
|
||||
</div>
|
||||
{#if testResult}
|
||||
<p class="hint" class:bad={testResult.bad}>{testResult.text}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="row">
|
||||
<button class="primary" onclick={enablePush} disabled={busy}>
|
||||
@@ -261,6 +309,10 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.what {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
|
||||
let notes = $state([]);
|
||||
@@ -71,6 +72,100 @@
|
||||
const field = order === 'created' ? 'created_at' : 'updated_at';
|
||||
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
|
||||
});
|
||||
|
||||
// --- listen i grupper ----------------------------------------------------------
|
||||
//
|
||||
// Listen føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række
|
||||
// sagde tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste
|
||||
// række — også når tyve noter deler dag — og projektet fik en linje for sig selv.
|
||||
// Tre linjer pr. note, gange tolv hundrede.
|
||||
//
|
||||
// Gentagelserne flyttes derhen, hvor de siger noget. Datoen bliver til
|
||||
// overskriften over en gruppe, og så forsvinder den fra rækken; projektet bliver
|
||||
// en lille mærkat ude til højre. Noten går fra tre linjer til to, og luften
|
||||
// kommer af, at der er mindre at skrive — ikke af at listen bliver længere.
|
||||
|
||||
/** Grupper, der er foldet sammen. Kun i denne session. */
|
||||
let folded = $state(new Set());
|
||||
|
||||
function toggleGroup(key) {
|
||||
const next = new Set(folded);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
folded = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvilken dato en gruppe måles på: den, der er sorteret efter.
|
||||
*
|
||||
* Ellers ville listen stå i én rækkefølge og være grupperet efter en anden, og
|
||||
* så er overskrifterne løgn — en note ville stå under "August" mellem to fra
|
||||
* juli, fordi den var *rørt* i august og *oprettet* i juli.
|
||||
*/
|
||||
const groupDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
|
||||
|
||||
/**
|
||||
* Hvad en gruppe hedder. I dag, i går, denne uge — og derefter måneden.
|
||||
*
|
||||
* Nøglen og etiketten er to ting: etiketten er sproget, og nøglen er den, der
|
||||
* afgør om to noter hører sammen. Måneden som nøgle må bære året med sig, eller
|
||||
* ville august 2026 og august 2025 blive én bunke.
|
||||
*/
|
||||
function bucketOf(note) {
|
||||
if (order === 'title') {
|
||||
// Sorteret på navn er der ingen dato at gruppere på, så det bliver
|
||||
// forbogstavet — og datoen kommer tilbage i rækken, hvor den så er den
|
||||
// eneste, der siger noget.
|
||||
const first = (note.title || '').trim().charAt(0).toUpperCase();
|
||||
const letter = /\p{L}|\p{N}/u.test(first) ? first : '#';
|
||||
return { key: 'a-' + letter, label: letter };
|
||||
}
|
||||
|
||||
const at = new Date(groupDate(note));
|
||||
if (Number.isNaN(at.getTime())) return { key: 'ukendt', label: t('notes.groupUnknown') };
|
||||
|
||||
const now = new Date();
|
||||
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const days = Math.floor((midnight - new Date(at.getFullYear(), at.getMonth(), at.getDate())) / 86400000);
|
||||
|
||||
if (days <= 0) return { key: 'i-dag', label: t('notes.groupToday') };
|
||||
if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
|
||||
if (days < 7) return { key: 'ugen', label: t('notes.groupWeek') };
|
||||
|
||||
const label = at.toLocaleDateString(tag(), { month: 'long', year: 'numeric' });
|
||||
return { key: `m-${at.getFullYear()}-${at.getMonth()}`, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen, delt op.
|
||||
*
|
||||
* En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
|
||||
* matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget,
|
||||
* der ikke er svaret på det, der blev spurgt om.
|
||||
*/
|
||||
let groups = $derived.by(() => {
|
||||
if (query.trim()) return [{ key: 'fundet', label: '', notes: ordered, plain: true }];
|
||||
|
||||
const out = [];
|
||||
const favourites = ordered.filter((n) => n.pinned);
|
||||
if (favourites.length) {
|
||||
out.push({ key: 'favoritter', label: t('notes.groupFavourites'), notes: favourites, star: true });
|
||||
}
|
||||
|
||||
let current = null;
|
||||
for (const note of ordered) {
|
||||
if (note.pinned) continue;
|
||||
const bucket = bucketOf(note);
|
||||
if (!current || current.key !== bucket.key) {
|
||||
current = { key: bucket.key, label: bucket.label, notes: [] };
|
||||
out.push(current);
|
||||
}
|
||||
current.notes.push(note);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
/** Projektets egen farve, som i sidebjælken. */
|
||||
const projectColour = (id) => colorVar(app.projects.find((p) => p.id === id)?.color);
|
||||
let selected = $state(null);
|
||||
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
|
||||
let selectedId = $state(null);
|
||||
@@ -258,6 +353,25 @@
|
||||
* which is exactly the thing the editor was built to stop showing. What is
|
||||
* wanted here is the sentence, not the notation.
|
||||
*/
|
||||
/**
|
||||
* Uddraget, uden titlen foran.
|
||||
*
|
||||
* Titlen *er* første linje — det er hele reglen for, hvad en note hedder — så et
|
||||
* uddrag, der begynder fra toppen, siger navnet én gang til. Det var svært at se,
|
||||
* så længe en dato stod imellem dem; nu står de oven på hinanden, og så står der
|
||||
* "Verdande" og under det "Verdande Forbindelse til connectoren".
|
||||
*/
|
||||
function preview(note) {
|
||||
const body = note.preview ?? note.body ?? '';
|
||||
const nl = body.indexOf('\n');
|
||||
const rest = nl === -1 ? '' : body.slice(nl + 1);
|
||||
// Kun hvis den første linje faktisk *er* titlen. Et uddrag fra serveren er
|
||||
// afkortet, så en lang første linje kan være hele uddraget — og så er der
|
||||
// ikke andet at vise end den.
|
||||
const withoutTitle = plain(rest);
|
||||
return withoutTitle || plain(body);
|
||||
}
|
||||
|
||||
function plain(body) {
|
||||
return (body ?? '')
|
||||
// Et billede har ingen ord i sig. Uden det her stod der
|
||||
@@ -515,69 +629,99 @@
|
||||
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
|
||||
</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each ordered as note (note.id)}
|
||||
<li>
|
||||
<div class="rowline">
|
||||
<button
|
||||
class="row"
|
||||
class:on={selectedId === note.id}
|
||||
class:picked={picked.has(note.id)}
|
||||
onclick={(e) => rowClick(e, note)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, note)}
|
||||
ondragend={() => (dragging = null)}
|
||||
>
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
<!-- Dato og begyndelse på samme linje, som Apple Noter gør det: to
|
||||
linjer pr. note frem for tre, og datoen er dét, man skanner
|
||||
efter, når man leder efter noget, man skrev i tirsdags. -->
|
||||
<span class="under">
|
||||
<!-- Datoen, der svarer til den rækkefølge, listen står i.
|
||||
Den viste altid `updated_at`, og for tolv hundrede
|
||||
importerede noter er det i aftes — så en note fra 2017
|
||||
stod som "i går", og den dato, man sorterede efter, var
|
||||
ingen steder at se. -->
|
||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||
<span class="preview">{plain(note.preview ?? note.body)}</span>
|
||||
</span>
|
||||
{#if note.project_id}
|
||||
<span class="filed">{projectName(note.project_id)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||
der er tre hundrede bred — og de bruges sjældent, mens
|
||||
titlen og uddraget læses hele tiden. -->
|
||||
<div class="rowactions">
|
||||
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
||||
til flere; det her er til den ene, man står med — og i
|
||||
arkivet er det vejen tilbage. -->
|
||||
<button
|
||||
class="archive-one"
|
||||
onclick={() => archiveOne(note)}
|
||||
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="star"
|
||||
class:on={note.pinned}
|
||||
onclick={() => favourite(note)}
|
||||
aria-pressed={note.pinned}
|
||||
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||
>
|
||||
{note.pinned ? '★' : '☆'}
|
||||
</button>
|
||||
{#each groups as group (group.key)}
|
||||
<!-- Overskriften klæber, mens man ruller. Med tolv hundrede noter er det
|
||||
forskellen på at rulle og på at lede: man kan altid se, hvilken måned
|
||||
man er nede i. -->
|
||||
{#if !group.plain}
|
||||
<h3 class="group">
|
||||
<!-- Ingen `aria-label`. Den ville erstatte knappens indhold som dens
|
||||
navn, og knappen *er* overskriften — så overskriften ville hedde
|
||||
"Fold I dag sammen" i stedet for "I dag". `aria-expanded` siger
|
||||
tilstanden, som den gør på gruppehovederne i sidebjælken. -->
|
||||
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
|
||||
{#if group.star}
|
||||
<span class="mark favmark" aria-hidden="true">★</span>
|
||||
{:else}
|
||||
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
|
||||
{/if}
|
||||
{group.label}
|
||||
<span class="count">{group.notes.length}</span>
|
||||
</button>
|
||||
</h3>
|
||||
{/if}
|
||||
{#if !folded.has(group.key)}
|
||||
<ul>
|
||||
{#each group.notes as note (note.id)}
|
||||
<li>
|
||||
<div class="rowline">
|
||||
<button
|
||||
class="row"
|
||||
class:on={selectedId === note.id}
|
||||
class:picked={picked.has(note.id)}
|
||||
onclick={(e) => rowClick(e, note)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, note)}
|
||||
ondragend={() => (dragging = null)}
|
||||
>
|
||||
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
|
||||
i gruppens overskrift og ikke her — undtagen når der er sorteret
|
||||
på navn, for så er der ingen overskrift, der siger den, og så er
|
||||
den det eneste tilbage, der gør. -->
|
||||
<span class="head">
|
||||
<strong>{note.title || t('notes.untitled')}</strong>
|
||||
{#if order === 'title'}
|
||||
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
|
||||
{/if}
|
||||
{#if note.project_id}
|
||||
<!-- Projektets egen farve, den samme som prikken i sidebjælken.
|
||||
Mærkaten sagde det samme som alle de andre i en grå, der var
|
||||
lysere end uddraget — og projektet er dét, man skanner efter,
|
||||
når man leder i tolv hundrede noter. Nu kan Hjem kendes fra
|
||||
Claude uden at læse ordet. -->
|
||||
<span class="filed" style="--project: {projectColour(note.project_id)}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{projectName(note.project_id)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="preview">{preview(note)}</span>
|
||||
</button>
|
||||
<!-- De to knapper står oven på hinanden i en smal søjle.
|
||||
Ved siden af hinanden tog de toogfyrre pixels af en liste,
|
||||
der er tre hundrede bred — og de bruges sjældent, mens
|
||||
titlen og uddraget læses hele tiden. -->
|
||||
<div class="rowactions">
|
||||
<!-- Én note lagt væk, uden at markere den først. Markeringen er
|
||||
til flere; det her er til den ene, man står med — og i
|
||||
arkivet er det vejen tilbage. -->
|
||||
<button
|
||||
class="archive-one"
|
||||
onclick={() => archiveOne(note)}
|
||||
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="star"
|
||||
class:on={note.pinned}
|
||||
onclick={() => favourite(note)}
|
||||
aria-pressed={note.pinned}
|
||||
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||
>
|
||||
{note.pinned ? '★' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -925,19 +1069,99 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- grupperne ----------------------------------------------------------
|
||||
*
|
||||
* Overskriften klæber. Med tolv hundrede noter er det forskellen på at rulle og
|
||||
* på at lede: man kan altid se, hvilken måned man er nede i.
|
||||
*
|
||||
* Baggrunden er ikke gennemsigtig. En klæbende overskrift uden en er en
|
||||
* overskrift med rækkerne kørende igennem sig. */
|
||||
h3.group {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
h3.group button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
h3.group button:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
h3.group .mark {
|
||||
flex: none;
|
||||
width: 0.75em;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
color: var(--ink-faint);
|
||||
transition: transform var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
h3.group .mark.closed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Sit eget navn og ikke `.star`. Rækkens favoritknap hedder også det og er
|
||||
gennemsigtig, indtil man peger på rækken — så overskriftens stjerne arvede
|
||||
den gennemsigtighed og stod der aldrig. To ting, der ligner hinanden, er
|
||||
ikke det samme, og et delt klassenavn er en påstand om, at de er. */
|
||||
h3.group .favmark {
|
||||
color: var(--accent);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h3.group .count {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Stregen mellem rækkerne er væk.
|
||||
*
|
||||
* Grupperne giver strukturen nu, og en streg pr. note er tolv hundrede streger.
|
||||
* Luften kommer af, at rækken siger to ting i stedet for tre — ikke af at listen
|
||||
* bliver længere. */
|
||||
.row {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: var(--s2) var(--s2) var(--s3);
|
||||
/* Plads til højre for de to knapper, der ligger oven på rækken. Uden den
|
||||
løber uddraget ind under stjernen — usynligt, indtil man peger på rækken
|
||||
og knappen dukker op oven i ordene. */
|
||||
padding: var(--s2) 26px calc(var(--s2) + 2px) var(--s2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
li:last-child .row {
|
||||
border-bottom: 0;
|
||||
/* Titel, dato og projekt på én linje. Projektet ude til højre, hvor øjet kan
|
||||
løbe ned ad søjlen uden at læse resten. */
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
@@ -964,33 +1188,56 @@
|
||||
|
||||
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
|
||||
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
|
||||
.under {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
min-width: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.when {
|
||||
flex: none;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* `display: block`, og det er ikke pynt.
|
||||
*
|
||||
* Uddraget lå før inde i en flex-boks, der ejede afkortningen. Da datoen flyttede
|
||||
* op i gruppens overskrift, forsvandt den boks — og et inline-element klippes
|
||||
* ikke af `overflow: hidden`, så den sidste sætning løb videre ind under
|
||||
* favoritstjernen. Reglerne skal stå på det element, der faktisk skal klippes. */
|
||||
.preview {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hvor den ligger, med en mappe foran — Apple Noters egen måde at sige det på,
|
||||
og den eneste linje her, der ikke er notens eget indhold. */
|
||||
/* Projektets egen farve, den samme som prikken i sidebjælken.
|
||||
*
|
||||
* Mærkaten sagde det samme som alle de andre i en grå, der var lysere end
|
||||
* uddraget — og projektet er dét, man skanner efter, når man leder i tolv
|
||||
* hundrede noter. En prik i projektets farve og en tekst, der ikke er lysere end
|
||||
* det, den står ved siden af: Hjem kan kendes fra Claude uden at ordet læses.
|
||||
*
|
||||
* Prikken bærer farven, ikke teksten. Fem projekters farver som skriftfarve på
|
||||
* en lys flade er fem forskellige grader af læsbarhed, og den lyseste af dem er
|
||||
* ikke læsbar. */
|
||||
.filed {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375em;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.filed .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--project, var(--line-strong));
|
||||
}
|
||||
|
||||
.filed::before {
|
||||
|
||||
Reference in New Issue
Block a user