Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2219f6af13 | ||
|
|
f64b7c1f01 | ||
|
|
868588de10 | ||
|
|
dcb71f4c14 | ||
|
|
968a29ca67 |
+7
-35
@@ -1,9 +1,10 @@
|
||||
# Calendar and CalDAV
|
||||
|
||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
||||
verdande, and none of the three is the same thing.
|
||||
Two ways to get your tasks into a calendar. Getting somebody else's calendar
|
||||
*into* verdande is the other direction and has its own page — see
|
||||
[Calendar](calendar.md).
|
||||
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Calendars in](calendar.md) |
|
||||
|---|---|---|---|
|
||||
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||
@@ -42,39 +43,10 @@ most people actually keep.
|
||||
|
||||
## Showing a Google calendar in verdande
|
||||
|
||||
The two sections above send your tasks *out*. This is the other direction: your
|
||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
||||
already have in it".
|
||||
Moved. Connecting a Google account, subscribing to an address, and what the grid
|
||||
does with either now live on their own page — see [Calendar](calendar.md).
|
||||
|
||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
||||
own colour for each one, beside your own tasks with due dates.
|
||||
|
||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
||||
|
||||
!!! warning "Read only"
|
||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
||||
can be changed. Your own tasks in the same grid still drag to another day as
|
||||
they always have.
|
||||
|
||||
Writing would need the `calendar.events` scope and an answer to what should
|
||||
happen when both sides moved the same meeting — which is a synchronisation
|
||||
model rather than a scope, and it is deliberately not built yet.
|
||||
|
||||
!!! note "The Internal client applies here too"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace, calendars included. A personal
|
||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
||||
this feature.
|
||||
|
||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
||||
says so rather than showing an empty month.
|
||||
This page is the other direction: verdande's own tasks, read by something else.
|
||||
|
||||
## Connecting a CalDAV client
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Calendar
|
||||
|
||||
Your dated tasks as a grid, with other people's calendars laid under them.
|
||||
|
||||
**Calendar** in the menu shows a month or a week. The week is a day: hours down the
|
||||
side, and everything with a time on it in its place. Two things at ten o'clock sit
|
||||
beside each other, because that is a clash you should be able to see rather than
|
||||
read.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Your tasks**, on the day they are due — and at the time, if they have one.
|
||||
- **Events from calendars you have connected**, read-only.
|
||||
|
||||
Drag a task to another day to move it. Drop it on the hour column and it takes that
|
||||
time; drop it in the band across the top and it keeps the day and loses the time.
|
||||
Events cannot be dragged: verdande holds a copy of somebody else's calendar, and a
|
||||
chip that lets itself be moved is a promise the server cannot keep.
|
||||
|
||||
A project's own calendar is under **Calendar** on the project page, with the same
|
||||
week and month.
|
||||
|
||||
## Subscribing to a calendar
|
||||
|
||||
**Settings → Integrations → Subscriptions.** Paste an address and it is fetched at
|
||||
once.
|
||||
|
||||
This works with anything that publishes an `.ics` file: public holidays, a club's
|
||||
fixtures, a shared calendar from work, your own calendar's secret address from
|
||||
Google, Apple or Fastmail.
|
||||
|
||||
- **`webcal://` addresses work.** They are the same URL over https with a scheme
|
||||
that makes a desktop hand it to the calendar app; it is rewritten for you.
|
||||
- **Several at a time.** Unlike the Google connection, which is one account, a
|
||||
subscription is one address and you can have as many as you like.
|
||||
- **Read-only, refreshed every fifteen minutes.**
|
||||
- The address is fetched once before it is stored, so a typo tells you now rather
|
||||
than by silently never filling.
|
||||
|
||||
!!! note "This is the way in for a private account"
|
||||
If the instance's Google registration is *Internal*, Google refuses accounts
|
||||
from outside the organisation with `org_internal` — no setting on your side
|
||||
changes that. Your calendar's own secret ICS address goes through this panel
|
||||
and works regardless.
|
||||
|
||||
## Connecting Google Calendar
|
||||
|
||||
**Settings → Integrations → Google Calendar.** It shares the OAuth client Gmail
|
||||
uses, so the instance needs that registration first — see
|
||||
[Configuration](configuration.md#google).
|
||||
|
||||
Two things have to be true in Google Cloud, and only the owner of the registration
|
||||
can do them:
|
||||
|
||||
1. **Google Calendar API** is enabled for the project.
|
||||
2. The calendar callback is listed as an authorised redirect URI. The exact string
|
||||
is printed in the panel — copy it from there rather than typing it. Google
|
||||
compares it exactly: scheme, host, path, no trailing slash. `redirect_uri_mismatch`
|
||||
almost always means one of those four differs.
|
||||
|
||||
Once connected, tick the calendars you want to see. The list is refreshed on every
|
||||
sync, so a calendar added or renamed since last time appears under its new name.
|
||||
|
||||
!!! warning "Written to the specification, not yet proven against Google"
|
||||
The Google Calendar half is implemented from Google's documentation and tested
|
||||
against a stand-in server. It has not been exercised against Google itself.
|
||||
The subscription route above and the outgoing feed below have.
|
||||
|
||||
## Your own calendar, elsewhere
|
||||
|
||||
Two ways out, for two different jobs:
|
||||
|
||||
- **[The ICS feed](caldav.md#subscribing-to-a-feed)** — a read-only address for Apple Calendar,
|
||||
Google or Thunderbird. Your tasks appear as dated entries.
|
||||
- **[CalDAV](caldav.md)** — two-way. Tick something off in Apple Reminders and it
|
||||
is ticked off here.
|
||||
|
||||
The feed address is a credential in itself: anybody holding it can read your tasks
|
||||
without logging in, because a calendar client cannot sign in. Rotate it from
|
||||
**Settings → Integrations** if it gets out.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **It does not write to a connected calendar.** Events are a copy.
|
||||
- **It does not expand repeating events** from a subscribed file. A weekly meeting
|
||||
in somebody else's calendar is stored as its first occurrence.
|
||||
- **It does not resolve named time zones** from a subscribed file. An event carrying
|
||||
`TZID=Europe/Copenhagen` shows the clock the file wrote, rather than one this
|
||||
server has moved.
|
||||
- **Only Google, and only reading**, for account connections. Everything else goes
|
||||
through a subscription.
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# Notes
|
||||
|
||||
Markdown in, Markdown out. Nothing in between.
|
||||
|
||||
A note is written as rich text — headings, lists, quotations, code — and stored as
|
||||
the Markdown it looks like. That is not a detail about the database: it is what
|
||||
makes a note yours. Export gives you the same text you were looking at, and on the
|
||||
day this program is gone, every note opens in any editor.
|
||||
|
||||
## Writing
|
||||
|
||||
Start typing. **The first line is the title** — always, and re-derived every time
|
||||
you save, so a note you rewrite the top of is called by what it now says rather
|
||||
than by what it once did.
|
||||
|
||||
| Style | Written as | Shortcut |
|
||||
|---|---|---|
|
||||
| Title | `# ` at the start of the line | pick it in **Aa** |
|
||||
| Heading | `## ` | |
|
||||
| Subheading | `### ` | |
|
||||
| Quote | `> ` | |
|
||||
| Code block | three backticks, with an optional language | |
|
||||
| Bullet and numbered lists | `- ` and `1. `, nested with Tab | |
|
||||
| Bold | two asterisks around the words | ⌘B |
|
||||
| Italic | one asterisk around the words | ⌘I |
|
||||
| Underline | a `u` tag around the words | ⌘U |
|
||||
| Strikethrough | two tildes around the words | |
|
||||
| Code | one backtick around the words | |
|
||||
|
||||
Every line becomes exactly one block. Markdown would normally join consecutive
|
||||
lines into a paragraph; here it does not, because you typed a line and expect to
|
||||
get it back.
|
||||
|
||||
Press the source button in the toolbar to see the note as the file it is. Edits there are edits to the
|
||||
note.
|
||||
|
||||
### Pictures
|
||||
|
||||
Paste one, or drag it onto the note. It is stored with the note and travels with
|
||||
it in an export. Every raster format works; SVG is stored but not shown inline —
|
||||
it is a document that can run script, and a note is not a place to run somebody
|
||||
else's.
|
||||
|
||||
### Code blocks
|
||||
|
||||
A fenced block is coloured for the language it names, and guesses when it does not.
|
||||
Each one carries a **Copy** button in its corner.
|
||||
|
||||
## Linking
|
||||
|
||||
Three kinds of reference, parsed out of the text as you write it:
|
||||
|
||||
| You type | It means |
|
||||
|---|---|
|
||||
| `#Firma` | The project — the same `#Firma` quick add understands, not a second kind of tag |
|
||||
| `[[Møde med Anders]]` | Another note, by its title |
|
||||
| A pasted task link | That task |
|
||||
|
||||
Links are an index over the text, rebuilt every time the note is saved. The text
|
||||
stays the truth.
|
||||
|
||||
**The interesting direction is backwards.** A task shows what has been written
|
||||
about it, and a project shows the notes filed in it — without anybody having to
|
||||
remember to attach anything. Write `#Firma` in a note and it appears on that
|
||||
project's page.
|
||||
|
||||
A link to something that has been deleted reads as a dead link. It is a fact about
|
||||
the note, and it should not vanish because something it mentioned did.
|
||||
|
||||
## Filing and sharing
|
||||
|
||||
A note belongs to a project, or to nobody.
|
||||
|
||||
- **In a project:** everybody who can read the project can read the note, and an
|
||||
editor can change it. Choose it under **Share in** at the foot of the note.
|
||||
- **In no project:** yours, and nobody else's.
|
||||
|
||||
There is no folder tree. Projects, groups and labels already exist, are already
|
||||
shared and already have roles — a second hierarchy to file things in is one too
|
||||
many, and a note in a project inherits every one of those answers.
|
||||
|
||||
## Finding
|
||||
|
||||
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
|
||||
week**, and then by month. Sort by created, last touched or name, and the grouping
|
||||
follows — sorted by name it groups by first letter instead.
|
||||
|
||||
Search finds a word wherever it is:
|
||||
|
||||
- **`gron` finds `grøn`.** Unicode does not consider `ø` an accented `o`, so
|
||||
stripping diacritics is not enough for Danish. A folded copy of every note is
|
||||
indexed beside the original.
|
||||
- **`inventory` finds `keepInventory`.** An index is built of whole words, and a
|
||||
decade of notes is full of commands and identifiers that are several words in
|
||||
one. When the index has nothing, the text itself is read.
|
||||
- Results are ranked by how well they match, with the title weighted ten times the
|
||||
body. A word in the title is what the note is about; the same word once in a long
|
||||
body is a mention.
|
||||
|
||||
## Putting notes away
|
||||
|
||||
Two different things, and the difference matters:
|
||||
|
||||
- **Archive** — this is finished and I do not want to read past it. It stays
|
||||
findable and comes back whenever you ask.
|
||||
- **Trash** — this was a mistake. It is gone in thirty days.
|
||||
|
||||
Select several with ⌘-click and shift-click to archive or delete them together.
|
||||
|
||||
## In and out
|
||||
|
||||
**Export** gives you a zip of Markdown files, one per note, with the dates in a
|
||||
YAML front matter block that Obsidian and Bear also read:
|
||||
|
||||
```markdown
|
||||
---
|
||||
created: 2019-04-02T10:15:00Z
|
||||
modified: 2024-11-30T21:04:00Z
|
||||
---
|
||||
|
||||
# Møde med Anders
|
||||
|
||||
Han vil gerne have kaffe hver uge.
|
||||
```
|
||||
|
||||
**Import** takes the same shape back. Pictures travel in the zip beside the notes,
|
||||
and the links in the text are relative — so a note and its pictures must be in the
|
||||
same archive, which matters when a large export is split into parts.
|
||||
|
||||
Two things to know before importing:
|
||||
|
||||
- **Nothing is merged.** Every run creates new notes. Importing the same archive
|
||||
twice gives you two of everything.
|
||||
- **The dates are read from the front matter.** Without them, a move of a thousand
|
||||
notes is a pile that was all written the same evening — and the order they were
|
||||
written in is half of what an archive is.
|
||||
|
||||
### From Apple Notes
|
||||
|
||||
`tools/apple-notes-til-markdown.py` in the repository exports Apple Notes to
|
||||
exactly this shape, front matter and pictures included. It is macOS-only, because
|
||||
Apple Notes is.
|
||||
|
||||
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
|
||||
uploadable parts, keeping each note with its own pictures.
|
||||
|
||||
## What is not encrypted
|
||||
|
||||
Note bodies are stored as plain text. **A note holding a password or an API key is
|
||||
holding it in plain text in every backup you can download.** The mailbox
|
||||
credentials, OAuth tokens and AI keys are sealed; note bodies are not, and neither
|
||||
are task titles, descriptions or comments.
|
||||
|
||||
Encrypting them is on the list and is bigger than it sounds — the full-text index
|
||||
and the folded search copy are both derived from the body, so both would have to go
|
||||
and search would have to be rewritten. Until then: keep secrets somewhere built for
|
||||
them.
|
||||
@@ -2079,6 +2079,48 @@ paths:
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/calendar/subscriptions:
|
||||
post:
|
||||
tags: [calendar]
|
||||
summary: Subscribe to a calendar you only have an address to.
|
||||
description: |
|
||||
An ICS or `webcal://` address. Several per person, unlike the Google
|
||||
connection, which is one — the address is the identity here, the way the
|
||||
token is for OAuth.
|
||||
|
||||
The address is fetched once before it is stored: an address that cannot be
|
||||
read is a typo far more often than a server that happens to be down, and
|
||||
saying so while somebody is still looking at the field is worth two
|
||||
seconds. `webcal://` is rewritten to `https://`, and the destination is
|
||||
checked against the private-network wall both when it is written down and
|
||||
again on the resolved address every time it is fetched.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
url: { type: string, description: "https:// or webcal://" }
|
||||
name:
|
||||
type: string
|
||||
description: Optional. The calendar's own name is used when this is empty.
|
||||
responses:
|
||||
"201":
|
||||
description: Subscribed, and fetched once.
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/calendar/subscriptions/{id}:
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string } }
|
||||
delete:
|
||||
tags: [calendar]
|
||||
summary: Stop subscribing. The cached events go with it.
|
||||
responses:
|
||||
"204": { description: Gone. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/push/test:
|
||||
post:
|
||||
tags: [notifications]
|
||||
|
||||
@@ -27,6 +27,9 @@ type testServer struct {
|
||||
*httptest.Server
|
||||
db *store.DB
|
||||
client *http.Client
|
||||
// api er den samme server, routeren hænger på. Et par prøver har brug for at
|
||||
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
|
||||
api *Server
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
@@ -62,7 +65,8 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
// Discard: a passing test should print nothing, and these handlers log freely.
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
srv := httptest.NewServer(New(cfg, db, log, nil))
|
||||
api := New(cfg, db, log, nil)
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
@@ -75,6 +79,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
// The cookie jar is what makes this a session test rather than a
|
||||
// sequence of unrelated requests.
|
||||
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
||||
api: api,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,17 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/gcal"
|
||||
"github.com/kristianwind/verdande/internal/ics"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -205,9 +212,21 @@ type calendarStatusJSON struct {
|
||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
||||
// an interface that quietly looks editable is one somebody will try to edit.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
|
||||
// Abonnementer står ved siden af Google-kontoen og ikke inde i den: man kan
|
||||
// have det ene uden det andet, og det er hele pointen med dem — en privat
|
||||
// konto, der ikke kan bruge en Internal OAuth-klient, kan abonnere.
|
||||
Subscriptions []store.CalendarAccount `json:"subscriptions"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
// Læst først, så de også kommer med, når der ingen Google-konto er.
|
||||
subscriptions, err := s.db.Subscriptions(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "subscriptions", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
|
||||
out := calendarStatusJSON{
|
||||
@@ -223,6 +242,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
out.Subscriptions = subscriptions
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
@@ -253,6 +273,7 @@ func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if calendars != nil {
|
||||
out.Calendars = calendars
|
||||
}
|
||||
out.Subscriptions = subscriptions
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -383,12 +404,23 @@ func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
// Returns how many events were written. Also used by the background job, which is
|
||||
// why it takes a context and a user rather than a request.
|
||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
||||
// Abonnementer først, og deres fejl tæller ikke mod Google.
|
||||
//
|
||||
// De to har intet med hinanden at gøre ud over at ende samme sted: et
|
||||
// abonnement er en adresse, der hentes, hvor Google er et token, der udløber.
|
||||
// En kalender, hvis vært er nede, må ikke få det til at se ud som om
|
||||
// Google-forbindelsen er væk — og omvendt.
|
||||
written, err := s.syncSubscriptions(ctx, user)
|
||||
if err != nil {
|
||||
s.log.Warn("calendar subscriptions", "err", err, "user", user.ID)
|
||||
}
|
||||
|
||||
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return written, err
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
||||
return written, nil // not connected; nothing to do and nothing to complain about
|
||||
}
|
||||
|
||||
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
||||
@@ -414,7 +446,6 @@ func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, erro
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
for _, calendar := range calendars {
|
||||
if !calendar.Shown {
|
||||
continue
|
||||
@@ -546,3 +577,232 @@ func validDay(s string) bool {
|
||||
_, err := time.Parse(calendarDateLayout, s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// --- abonnementer -----------------------------------------------------------------
|
||||
|
||||
// maxICSBytes bounds one fetch. A published calendar of a decade of holidays is a
|
||||
// few hundred kilobytes; ten megabytes is not a calendar, and reading it into
|
||||
// memory on a schedule with nobody watching is how a homelab instance falls over
|
||||
// at three in the morning.
|
||||
const maxICSBytes = 10 << 20
|
||||
|
||||
// normaliseCalendarURL turns what somebody pastes into something that can be
|
||||
// fetched.
|
||||
//
|
||||
// `webcal://` is not a scheme anything speaks. It is a convention — the same URL
|
||||
// over https, with a scheme that makes a desktop hand it to the calendar app
|
||||
// instead of the browser — and every publisher writes their links that way. A
|
||||
// person pasting one has pasted the right address; refusing it would be refusing
|
||||
// them on a technicality.
|
||||
func normaliseCalendarURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if after, ok := strings.CutPrefix(raw, "webcal://"); ok {
|
||||
raw = "https://" + after
|
||||
}
|
||||
if !strings.HasPrefix(raw, "https://") && !strings.HasPrefix(raw, "http://") {
|
||||
return "", errors.New("must be an https:// or webcal:// address")
|
||||
}
|
||||
// The same wall the AI provider's base_url goes through, and for the same
|
||||
// reason: this is an address a user typed, and the server is about to fetch it
|
||||
// on a schedule of their choosing.
|
||||
if reason := safedial.CheckURL(raw); reason != "" {
|
||||
return "", errors.New(reason)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// icsClient is the client a subscribed calendar is fetched with.
|
||||
//
|
||||
// safedial, checked on the *resolved* address at connect time and on every
|
||||
// redirect hop — because a name that passed the check when it was pasted can
|
||||
// answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||
//
|
||||
// The field exists so a test can put an ordinary client here: httptest listens on
|
||||
// loopback, which is exactly what this refuses, so a test of the *format* cannot
|
||||
// otherwise reach its own server. It is never set outside a test, and the wall it
|
||||
// bypasses is tested where it belongs — `normaliseCalendarURL` refuses a private
|
||||
// address where it is written down, and `internal/push` proves the connect-time
|
||||
// half.
|
||||
func (s *Server) icsClient() *http.Client {
|
||||
if s.icsFetch != nil {
|
||||
return s.icsFetch
|
||||
}
|
||||
return safedial.Client(30 * time.Second)
|
||||
}
|
||||
|
||||
// fetchICS reads a subscribed calendar.
|
||||
//
|
||||
// safedial, not a plain client — checked on the resolved address at connect time
|
||||
// and on every redirect hop, because a name that passed the check when it was
|
||||
// pasted can answer 127.0.0.1 when the sweep runs at three in the morning.
|
||||
func (s *Server) fetchICS(ctx context.Context, url string) (ics.Subscribed, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.5")
|
||||
req.Header.Set("User-Agent", "verdande/"+Version)
|
||||
|
||||
resp, err := s.icsClient().Do(req)
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return ics.Subscribed{}, fmt.Errorf("%s said %s", resp.Request.URL.Host, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1))
|
||||
if err != nil {
|
||||
return ics.Subscribed{}, err
|
||||
}
|
||||
if len(body) > maxICSBytes {
|
||||
return ics.Subscribed{}, errors.New("that calendar is too large")
|
||||
}
|
||||
return ics.ParseCalendar(string(body)), nil
|
||||
}
|
||||
|
||||
// syncSubscriptions refreshes every calendar this person subscribes to by address.
|
||||
//
|
||||
// One failing address does not stop the others. A subscription is somebody else's
|
||||
// server, and the ordinary state of somebody else's server is that it is fine
|
||||
// until one morning it is not — a holiday feed that 404s must not take the
|
||||
// football club's fixtures down with it.
|
||||
func (s *Server) syncSubscriptions(ctx context.Context, user *store.User) (int, error) {
|
||||
subs, err := s.db.Subscriptions(ctx, user.ID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
var failures []string
|
||||
|
||||
for _, sub := range subs {
|
||||
if ctx.Err() != nil {
|
||||
return written, ctx.Err()
|
||||
}
|
||||
unlock := s.lockSync("calendar:" + sub.ID)
|
||||
|
||||
calendarID, err := s.db.SubscribedCalendarID(ctx, sub.ID)
|
||||
if err != nil {
|
||||
unlock()
|
||||
failures = append(failures, sub.URL)
|
||||
continue
|
||||
}
|
||||
|
||||
cal, err := s.fetchICS(ctx, sub.URL)
|
||||
if err != nil {
|
||||
unlock()
|
||||
s.log.Warn("subscribed calendar", "err", err, "url", sub.URL, "user", user.ID)
|
||||
failures = append(failures, sub.URL)
|
||||
continue
|
||||
}
|
||||
|
||||
// The name the file calls itself, once. A subscription is added under its
|
||||
// address because that is all there is to go on before it has been fetched.
|
||||
if err := s.db.RenameSubscribedCalendar(ctx, sub.ID, cal.Name); err != nil {
|
||||
s.log.Warn("name subscribed calendar", "err", err)
|
||||
}
|
||||
|
||||
rows := make([]store.CalendarEvent, 0, len(cal.Events))
|
||||
for _, e := range cal.Events {
|
||||
// Outside the window is not stored. The grid asks about a window, the
|
||||
// sweep replaces a window, and a decade of holidays in one file would
|
||||
// otherwise be a decade of rows rewritten every fifteen minutes.
|
||||
if e.EndDay < from || e.StartDay > to {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, store.CalendarEvent{
|
||||
RemoteID: e.UID, Summary: e.Summary, Location: e.Location, URL: e.URL,
|
||||
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||
StartDay: e.StartDay, EndDay: e.EndDay, AllDay: e.AllDay,
|
||||
})
|
||||
}
|
||||
if err := s.db.ReplaceCalendarEvents(ctx, calendarID, from, to, rows); err != nil {
|
||||
unlock()
|
||||
return written, err
|
||||
}
|
||||
if err := s.db.MarkCalendarSynced(ctx, sub.ID, time.Now()); err != nil {
|
||||
s.log.Warn("mark subscription synced", "err", err)
|
||||
}
|
||||
written += len(rows)
|
||||
unlock()
|
||||
}
|
||||
|
||||
if len(failures) == len(subs) {
|
||||
return written, fmt.Errorf("none of the %d subscribed calendars could be read", len(subs))
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// handleSubscribeCalendar adds a calendar somebody only has an address to.
|
||||
//
|
||||
// Fetched once, here, before it is stored. An address that cannot be read is a
|
||||
// typo far more often than it is a server that happens to be down, and telling
|
||||
// somebody now — while they are looking at the field they just pasted into — is
|
||||
// worth the two seconds. A subscription that silently never fills is the same
|
||||
// silence the notifications had.
|
||||
func (s *Server) handleSubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
url, err := normaliseCalendarURL(req.URL)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"url": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cal, err := s.fetchICS(ctx, url)
|
||||
if err != nil {
|
||||
s.log.Warn("subscribe calendar", "err", err, "user", user.ID)
|
||||
writeFieldErrors(w, map[string]string{"url": "that address could not be read"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(cal.Name)
|
||||
}
|
||||
if name == "" {
|
||||
name = url
|
||||
}
|
||||
|
||||
account, err := s.db.SubscribeCalendar(r.Context(), user.ID, url, name)
|
||||
if err != nil {
|
||||
// The unique index is the one refusal worth naming: subscribing twice to
|
||||
// the same address is the same calendar fetched twice, with no way to say
|
||||
// which one counts.
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeFieldErrors(w, map[string]string{"url": "that calendar is already subscribed"})
|
||||
return
|
||||
}
|
||||
s.storeError(w, r, "subscribe calendar", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The events it already fetched, rather than making the person wait fifteen
|
||||
// minutes for the sweep to prove it worked.
|
||||
if _, err := s.syncSubscriptions(r.Context(), user); err != nil {
|
||||
s.log.Warn("first subscription sync", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, account)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnsubscribeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, chi.URLParam(r, "id")); err != nil {
|
||||
s.storeError(w, r, "unsubscribe calendar", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
@@ -1529,3 +1532,113 @@ func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
|
||||
t.Errorf("endepunktet lækkede med ud: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// Et abonnement på en kalender, man kun har en adresse til.
|
||||
//
|
||||
// Bedt om som "abonnering af flere kalendere (ics og webcal?)". Google-vejen er
|
||||
// OAuth og én konto pr. person; det her er en adresse, og man har flere. Det er
|
||||
// også den eneste vej for en privat konto, som en Internal-registrering afviser
|
||||
// med `org_internal`.
|
||||
//
|
||||
// Prøven går uden om handleren og ind i det, den kalder. Grunden er væggen:
|
||||
// `httptest` lytter på loopback, og safedial nægter — både når adressen skrives
|
||||
// ned og igen når den hentes. Det er rigtigt, og det skal ikke kunne slås fra fra
|
||||
// en prøve. Så adressereglerne prøves for sig i TestWebcalIsRewrittenToHTTPS, og
|
||||
// her prøves resten af vejen: hentning, format, navn, gitteret og afmeldingen.
|
||||
func TestSubscribingToACalendarByAddress(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
day := time.Now().AddDate(0, 0, 2).Format("20060102")
|
||||
feedBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Helligdage\r\n" +
|
||||
"BEGIN:VEVENT\r\nUID:[email protected]\r\n" +
|
||||
"DTSTART;VALUE=DATE:" + day + "\r\nDTEND;VALUE=DATE:" + day + "\r\n" +
|
||||
"SUMMARY:Grundlovsdag\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
var hits int
|
||||
feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "text/calendar")
|
||||
w.Write([]byte(feedBody))
|
||||
}))
|
||||
defer feed.Close()
|
||||
ts.api.icsFetch = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Tilføjet under sin adresse, som er alt der er at gå efter, før den er hentet.
|
||||
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, feed.URL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ts.api.syncSubscriptions(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits == 0 {
|
||||
t.Fatal("adressen blev aldrig hentet")
|
||||
}
|
||||
|
||||
// Navnet kommer fra filen. X-WR-CALNAME er det, alle udgivere bruger; der
|
||||
// findes ingen standardegenskab for det.
|
||||
_, status := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
subs, _ := status["subscriptions"].([]any)
|
||||
if len(subs) != 1 {
|
||||
t.Fatalf("%d abonnementer i svaret", len(subs))
|
||||
}
|
||||
first, _ := subs[0].(map[string]any)
|
||||
if first["account"] != "Helligdage" {
|
||||
t.Errorf("navnet er %v, want Helligdage", first["account"])
|
||||
}
|
||||
|
||||
// Og begivenheden står i gitteret.
|
||||
from := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
to := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
||||
_, events := ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||
list, _ := events["events"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("%d begivenheder efter abonnementet", len(list))
|
||||
}
|
||||
if e, _ := list[0].(map[string]any); e["summary"] != "Grundlovsdag" {
|
||||
t.Errorf("begivenheden er %v", e["summary"])
|
||||
}
|
||||
|
||||
// Den samme adresse to gange er den samme kalender hentet to gange, og der er
|
||||
// ingen måde at sige hvilken der gælder.
|
||||
if _, err := ts.db.SubscribeCalendar(t.Context(), user.ID, feed.URL, ""); !errors.Is(err, store.ErrDuplicate) {
|
||||
t.Errorf("den samme adresse blev accepteret to gange: %v", err)
|
||||
}
|
||||
|
||||
// Fjernet igen, og begivenhederne går med.
|
||||
resp, _ := ts.do(t, "DELETE", "/api/v1/calendar/subscriptions/"+first["id"].(string), nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unsubscribe: status %d", resp.StatusCode)
|
||||
}
|
||||
_, events = ts.do(t, "GET", "/api/v1/calendar/events?from="+from+"&to="+to, nil)
|
||||
if list, _ := events["events"].([]any); len(list) != 0 {
|
||||
t.Errorf("%d begivenheder blev tilbage efter afmeldingen", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// webcal:// er https med et andet skema foran. Det er ikke en protokol, noget
|
||||
// taler — det er en konvention, der får et skrivebord til at give adressen til
|
||||
// kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres
|
||||
// links sådan. At afvise den ville være at afvise folk på en teknikalitet.
|
||||
func TestWebcalIsRewrittenToHTTPS(t *testing.T) {
|
||||
got, err := normaliseCalendarURL("webcal://example.dk/kalender.ics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "https://example.dk/kalender.ics" {
|
||||
t.Errorf("blev til %q", got)
|
||||
}
|
||||
|
||||
// Og en adresse indad afvises, hvor den skrives — ikke først når den hentes.
|
||||
if _, err := normaliseCalendarURL("http://127.0.0.1:8080/x.ics"); err == nil {
|
||||
t.Error("en loopback-adresse blev godtaget")
|
||||
}
|
||||
if _, err := normaliseCalendarURL("file:///etc/passwd"); err == nil {
|
||||
t.Error("et skema, der ikke er http, blev godtaget")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ type Server struct {
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
loginLimiter *limiter
|
||||
// Sat kun af prøver; se icsClient().
|
||||
icsFetch *http.Client
|
||||
mailLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
@@ -392,6 +394,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||
r.Post("/sync", s.handleCalendarSyncNow)
|
||||
r.Get("/events", s.handleCalendarEvents)
|
||||
|
||||
// Abonnementer: en kalender, man kun har en adresse til. Flere pr.
|
||||
// person, hvor Google er én — det er adressen, der er identiteten,
|
||||
// hvor tokenet er det for OAuth.
|
||||
r.Post("/subscriptions", s.handleSubscribeCalendar)
|
||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
|
||||
@@ -367,3 +367,180 @@ func unescape(s string) string {
|
||||
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// --- reading a whole calendar ------------------------------------------------------
|
||||
|
||||
// Event is one VEVENT, reduced to what a grid needs — the same shape the Google
|
||||
// side already stores, so the two arrive in the database identically.
|
||||
//
|
||||
// Days at both ends, inclusive, because that is the question a grid asks: which
|
||||
// cells does this cover. StartsAt keeps the stamp as the file wrote it, offset and
|
||||
// all: it is what the chip shows, and reparsing it here would ask *this process*
|
||||
// what time zone it is in.
|
||||
type Event struct {
|
||||
UID string
|
||||
Summary string
|
||||
Location string
|
||||
URL string
|
||||
AllDay bool
|
||||
StartsAt string // RFC3339, empty for an all-day event
|
||||
EndsAt string
|
||||
StartDay string // YYYY-MM-DD
|
||||
EndDay string
|
||||
}
|
||||
|
||||
// Subscribed is what an .ics file somebody subscribed to says.
|
||||
//
|
||||
// Its own name rather than `Calendar`: that one is the *writing* side — what
|
||||
// verdande publishes as a feed — and the two are different documents that happen
|
||||
// to share a format. One name for both would be a promise that a round trip
|
||||
// through this package is lossless, which it is not and does not need to be.
|
||||
type Subscribed struct {
|
||||
Name string
|
||||
TimeZone string
|
||||
Events []Event
|
||||
}
|
||||
|
||||
// ParseCalendar reads an .ics file that somebody subscribed to.
|
||||
//
|
||||
// Forgiving in the same way ParseVTODO is, and for a sharper reason: this file was
|
||||
// written by somebody else's software and is fetched on a schedule with nobody
|
||||
// watching. One property this does not understand must not cost the other four
|
||||
// hundred events in the file — so anything unrecognised is skipped, and an event
|
||||
// without the two things a grid cannot do without (a uid and a start) is dropped
|
||||
// rather than stored half-made.
|
||||
//
|
||||
// # What it deliberately does not do
|
||||
//
|
||||
// **Recurrence is not expanded.** RRULE in a subscribed calendar means "and then
|
||||
// every Tuesday until 2031", and expanding it here would mean writing a year of
|
||||
// rows per rule and re-deciding what happens when the file changes. A recurring
|
||||
// event is stored as its first occurrence, which is honest — it is what the file
|
||||
// says on its own terms — and the day it matters, the answer is an expander shared
|
||||
// with `internal/recurrence`, not a second one written here.
|
||||
//
|
||||
// **Time zones are not resolved.** A DTSTART with TZID=Europe/Copenhagen is stored
|
||||
// with the offset the file carries or, when it carries none, as a local stamp. The
|
||||
// alternative is shipping a tz database and deciding what to do when the file
|
||||
// names a zone this build has never heard of. The day that matters, it is one
|
||||
// place.
|
||||
func ParseCalendar(body string) Subscribed {
|
||||
var cal Subscribed
|
||||
var current *Event
|
||||
depth := 0
|
||||
|
||||
for _, line := range unfold(body) {
|
||||
name, params, value := splitLine(line)
|
||||
upper := strings.ToUpper(name)
|
||||
|
||||
switch upper {
|
||||
case "BEGIN":
|
||||
if strings.EqualFold(value, "VEVENT") {
|
||||
current = &Event{}
|
||||
}
|
||||
depth++
|
||||
continue
|
||||
case "END":
|
||||
depth--
|
||||
if strings.EqualFold(value, "VEVENT") && current != nil {
|
||||
if current.UID != "" && current.StartDay != "" {
|
||||
cal.Events = append(cal.Events, *current)
|
||||
}
|
||||
current = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
// Calendar-level properties. X-WR-CALNAME is what every publisher uses
|
||||
// to say what the calendar is called; there is no standard property for
|
||||
// it, which is why a non-standard one is read here.
|
||||
switch upper {
|
||||
case "X-WR-CALNAME":
|
||||
cal.Name = unescape(value)
|
||||
case "X-WR-TIMEZONE":
|
||||
cal.TimeZone = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch upper {
|
||||
case "UID":
|
||||
current.UID = value
|
||||
case "SUMMARY":
|
||||
current.Summary = unescape(value)
|
||||
case "LOCATION":
|
||||
current.Location = unescape(value)
|
||||
case "URL":
|
||||
current.URL = value
|
||||
case "DTSTART":
|
||||
current.AllDay = isDateOnly(params, value)
|
||||
current.StartDay, current.StartsAt = readStamp(params, value)
|
||||
case "DTEND":
|
||||
day, at := readStamp(params, value)
|
||||
current.EndsAt = at
|
||||
// DTEND is exclusive for an all-day event: a one-day event ends on the
|
||||
// next morning. Stored inclusive, or every all-day event would cover one
|
||||
// cell too many — and a public holiday would eat the day after it.
|
||||
if current.AllDay {
|
||||
day = shiftDay(day, -1)
|
||||
}
|
||||
current.EndDay = day
|
||||
}
|
||||
}
|
||||
|
||||
// A missing DTEND means the event ends when it starts. Filled after the loop
|
||||
// rather than inside it, because DTEND may arrive before DTSTART in a file
|
||||
// nobody promised would be in order.
|
||||
for i := range cal.Events {
|
||||
if cal.Events[i].EndDay == "" || cal.Events[i].EndDay < cal.Events[i].StartDay {
|
||||
cal.Events[i].EndDay = cal.Events[i].StartDay
|
||||
}
|
||||
}
|
||||
return cal
|
||||
}
|
||||
|
||||
// isDateOnly reports whether a stamp is a day rather than a moment. VALUE=DATE
|
||||
// says so outright; an eight-character value says it by being one.
|
||||
func isDateOnly(params, value string) bool {
|
||||
if strings.Contains(strings.ToUpper(params), "VALUE=DATE") {
|
||||
return true
|
||||
}
|
||||
return len(value) == 8 && !strings.ContainsAny(value, "T")
|
||||
}
|
||||
|
||||
// readStamp returns the day and, for a timed event, the moment as RFC3339.
|
||||
//
|
||||
// Three shapes exist in the wild: 20260821 (a day), 20260821T140000Z (UTC), and
|
||||
// 20260821T140000 with a TZID parameter (a local time in a named zone). The third
|
||||
// is kept as written, without a zone: resolving it needs a tz database, and a
|
||||
// stamp that says 14:00 is closer to the truth than one this process has moved.
|
||||
func readStamp(params, value string) (day, at string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 8 {
|
||||
return "", ""
|
||||
}
|
||||
day = value[0:4] + "-" + value[4:6] + "-" + value[6:8]
|
||||
if len(value) < 15 || value[8] != 'T' {
|
||||
return day, ""
|
||||
}
|
||||
|
||||
clock := value[9:11] + ":" + value[11:13] + ":" + value[13:15]
|
||||
switch {
|
||||
case strings.HasSuffix(value, "Z"):
|
||||
return day, day + "T" + clock + "Z"
|
||||
default:
|
||||
// A local stamp, with or without a TZID. Written without an offset, which
|
||||
// is what the interface already treats as "the clock the file said".
|
||||
return day, day + "T" + clock
|
||||
}
|
||||
}
|
||||
|
||||
// shiftDay moves a YYYY-MM-DD by whole days.
|
||||
func shiftDay(day string, by int) string {
|
||||
t, err := time.Parse("2006-01-02", day)
|
||||
if err != nil {
|
||||
return day
|
||||
}
|
||||
return t.AddDate(0, 0, by).Format("2006-01-02")
|
||||
}
|
||||
|
||||
@@ -220,3 +220,101 @@ func TestFilename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En abonnementskalender, som den ser ud fra nogen andens software.
|
||||
//
|
||||
// Alle fire former, der findes i naturen, i én fil: en heldagsbegivenhed, en med
|
||||
// et UTC-stempel, en med TZID og lokal tid, og en over flere dage. Plus en, der
|
||||
// mangler det, et gitter ikke kan undvære, og en foldet linje — begge dele
|
||||
// kommer, og de kommer uden varsel.
|
||||
func TestParseCalendarReadsTheFourShapesThatExist(t *testing.T) {
|
||||
body := "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"X-WR-CALNAME:Helligdage\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART;VALUE=DATE:20260824\r\n" +
|
||||
"DTEND;VALUE=DATE:20260825\r\n" +
|
||||
"SUMMARY:Grundlovsdag\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART:20260825T120000Z\r\n" +
|
||||
"DTEND:20260825T133000Z\r\n" +
|
||||
`SUMMARY:Møde med en meget lang titel\, der er foldet over to lin` + "\r\n" + " jer\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART;TZID=Europe/Copenhagen:20260826T090000\r\n" +
|
||||
"DTEND;TZID=Europe/Copenhagen:20260826T100000\r\n" +
|
||||
"SUMMARY:Tandlæge\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:[email protected]\r\n" +
|
||||
"DTSTART;VALUE=DATE:20260901\r\n" +
|
||||
"DTEND;VALUE=DATE:20260904\r\n" +
|
||||
"SUMMARY:Ferie\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"SUMMARY:Uden uid og uden start\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
cal := ParseCalendar(body)
|
||||
|
||||
if cal.Name != "Helligdage" {
|
||||
t.Errorf("kalenderens navn = %q", cal.Name)
|
||||
}
|
||||
if len(cal.Events) != 4 {
|
||||
t.Fatalf("%d begivenheder, want 4 — den femte mangler både uid og start", len(cal.Events))
|
||||
}
|
||||
|
||||
// Heldag. DTEND er eksklusiv i filen: en endagsbegivenhed slutter næste morgen.
|
||||
// Gemmes inklusivt, ellers dækker hver eneste heldagsbegivenhed én celle for
|
||||
// meget — og en helligdag æder dagen efter sig.
|
||||
one := cal.Events[0]
|
||||
if !one.AllDay || one.StartDay != "2026-08-24" || one.EndDay != "2026-08-24" {
|
||||
t.Errorf("heldag: %+v", one)
|
||||
}
|
||||
if one.StartsAt != "" {
|
||||
t.Errorf("en heldagsbegivenhed fik et klokkeslæt: %q", one.StartsAt)
|
||||
}
|
||||
|
||||
// UTC, og den foldede linje samlet igen.
|
||||
two := cal.Events[1]
|
||||
if two.StartsAt != "2026-08-25T12:00:00Z" || two.EndsAt != "2026-08-25T13:30:00Z" {
|
||||
t.Errorf("utc: %+v", two)
|
||||
}
|
||||
if two.Summary != "Møde med en meget lang titel, der er foldet over to linjer" {
|
||||
t.Errorf("den foldede linje blev ikke samlet: %q", two.Summary)
|
||||
}
|
||||
|
||||
// Lokal tid med TZID: klokkeslættet står, som filen skrev det. At flytte det
|
||||
// her ville spørge *denne* proces, hvilken tidszone den er i.
|
||||
three := cal.Events[2]
|
||||
if three.StartsAt != "2026-08-26T09:00:00" {
|
||||
t.Errorf("lokal: %q", three.StartsAt)
|
||||
}
|
||||
|
||||
// Flere dage, begge ender inklusive.
|
||||
four := cal.Events[3]
|
||||
if four.StartDay != "2026-09-01" || four.EndDay != "2026-09-03" {
|
||||
t.Errorf("ferie: %s til %s", four.StartDay, four.EndDay)
|
||||
}
|
||||
}
|
||||
|
||||
// En fil, der ikke er en kalender, må ikke koste noget. Den hentes på et skema
|
||||
// uden nogen, der kigger med, og en fejl her ville være en sweep, der stopper.
|
||||
func TestParseCalendarSurvivesRubbish(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"",
|
||||
"ikke en kalender overhovedet",
|
||||
"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
|
||||
"BEGIN:VEVENT\r\nDTSTART:x\r\nEND:VEVENT\r\n",
|
||||
"BEGIN:VEVENT\r\nUID:a\r\nDTSTART:2026\r\nEND:VEVENT\r\n",
|
||||
} {
|
||||
if got := ParseCalendar(body); len(got.Events) != 0 {
|
||||
t.Errorf("%q gav %d begivenheder", body, len(got.Events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+159
-8
@@ -3,7 +3,9 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -14,6 +16,12 @@ type CalendarAccount struct {
|
||||
Provider string `json:"provider"`
|
||||
Account string `json:"account,omitempty"`
|
||||
|
||||
// URL er tom for en OAuth-konto og adressen for et abonnement. Den er
|
||||
// identiteten på et abonnement, som tokenet er det for Google: to rækker med
|
||||
// den samme adresse for den samme person ville være den samme kalender hentet
|
||||
// to gange, uden nogen måde at sige hvilken der gælder.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
RefreshToken string `json:"-"` // never leaves the server
|
||||
AccessToken string `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
@@ -65,10 +73,17 @@ type CalendarEvent struct {
|
||||
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
||||
access_token, expires_at, last_sync_at, created_at`
|
||||
|
||||
// Med adressen. Skrivningen tilføjer den selv, fordi indsættelsen navngiver
|
||||
// søjlerne; læsningen skal bede om den.
|
||||
const calendarAccountRead = calendarAccountColumns + `, url`
|
||||
|
||||
// CalendarAccountFor returns a person's connection to one provider, or nil.
|
||||
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
|
||||
// `url = ''`: en OAuth-konto. Uden det ville et abonnement hos den samme
|
||||
// udbyder kunne svare på spørgsmålet "hvad er personen forbundet til med
|
||||
// Google", og et abonnement har hverken token eller konto at svare med.
|
||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountRead+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`, userID, provider)
|
||||
a, err := db.scanCalendarAccount(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -106,27 +121,37 @@ func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error
|
||||
// struct with no id in it, and matching on the id would insert a second row
|
||||
// that the unique index then refuses — which reads to the person as "connecting
|
||||
// failed" when what happened is that it already had.
|
||||
// `WHERE url = ''` fordi indekset er delvist siden 0026.
|
||||
//
|
||||
// En OAuth-konto er én pr. person og udbyder — det er tokenet, der er
|
||||
// identiteten — mens et abonnement er én pr. adresse, og man har flere. De to
|
||||
// bor i samme tabel, fordi et abonnement *er* en kalender med begivenheder i,
|
||||
// og den halvdel er den samme. Målet for konflikten skal sige, hvilken af de to
|
||||
// slags rækker der tales om, ellers ved SQLite det ikke.
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, provider) DO UPDATE SET
|
||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, provider) WHERE url = '' DO UPDATE SET
|
||||
account = excluded.account,
|
||||
refresh_token = excluded.refresh_token,
|
||||
access_token = excluded.access_token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_sync_at = excluded.last_sync_at`,
|
||||
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
|
||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix(), a.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.URL != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The row that is actually there wins. On a reconnect the insert above updated
|
||||
// an existing row, so the id in hand is one nothing points at — and the
|
||||
// calendars are hung off the id, so writing them against it would attach them
|
||||
// to nothing.
|
||||
return db.QueryRowContext(ctx,
|
||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
|
||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ? AND url = ''`,
|
||||
a.UserID, a.Provider).Scan(&a.ID)
|
||||
}
|
||||
|
||||
@@ -362,7 +387,7 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
||||
var a CalendarAccount
|
||||
var expires, lastSync, created int64
|
||||
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
||||
&a.AccessToken, &expires, &lastSync, &created)
|
||||
&a.AccessToken, &expires, &lastSync, &created, &a.URL)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
@@ -378,3 +403,129 @@ func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
||||
a.CreatedAt = time.Unix(created, 0)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// --- abonnementer ---------------------------------------------------------------
|
||||
|
||||
// SubscribeCalendar adds a calendar somebody only has an address to.
|
||||
//
|
||||
// One row in `calendar_accounts` with a url and no tokens, and one row in
|
||||
// `calendars` hung off it — because a subscription *is* one calendar, where an
|
||||
// OAuth account is a list of them somebody picks from. Written together so a
|
||||
// subscription is never a row with nothing under it: the sweep looks for accounts,
|
||||
// and one without a calendar would be fetched for ever and stored nowhere.
|
||||
//
|
||||
// Shown from the start. Ticking a box after adding an address is a second step for
|
||||
// a decision the person already made by adding it.
|
||||
func (db *DB) SubscribeCalendar(ctx context.Context, userID, url, name string) (*CalendarAccount, error) {
|
||||
account := &CalendarAccount{
|
||||
ID: NewID(),
|
||||
UserID: userID,
|
||||
Provider: "ics",
|
||||
Account: name,
|
||||
URL: url,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendar_accounts (id, user_id, provider, account, url, created_at)
|
||||
VALUES (?, ?, 'ics', ?, ?, ?)`,
|
||||
account.ID, userID, name, url, account.CreatedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendars (id, account_id, remote_id, name, shown)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
NewID(), account.ID, url, name)
|
||||
return err
|
||||
})
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrDuplicate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// Subscriptions lists the calendars a person subscribes to by address.
|
||||
func (db *DB) Subscriptions(ctx context.Context, userID string) ([]CalendarAccount, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND url <> ''
|
||||
ORDER BY created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []CalendarAccount{}
|
||||
for rows.Next() {
|
||||
a, err := db.scanCalendarAccount(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AllSubscriptions is every subscription on the instance, for the sweep.
|
||||
//
|
||||
// The sweep runs for the server rather than for a session, so it cannot ask "whose
|
||||
// is this" the way a handler can — it is handed the user id along with the row and
|
||||
// writes against that.
|
||||
func (db *DB) AllSubscriptions(ctx context.Context) ([]CalendarAccount, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+calendarAccountRead+`
|
||||
FROM calendar_accounts WHERE url <> '' ORDER BY last_sync_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []CalendarAccount{}
|
||||
for rows.Next() {
|
||||
a, err := db.scanCalendarAccount(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RenameSubscribedCalendar writes the name the file calls itself.
|
||||
//
|
||||
// A subscription is added with the address as its name, because that is all there
|
||||
// is to go on before it has been fetched once. The file usually says better —
|
||||
// X-WR-CALNAME is what every publisher uses — and taking it means the list says
|
||||
// "Danske helligdage" instead of a URL nobody reads.
|
||||
//
|
||||
// Only when the person has not renamed it themselves: a name they typed is an
|
||||
// instruction, and a name from the file is a guess.
|
||||
func (db *DB) RenameSubscribedCalendar(ctx context.Context, accountID, name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil
|
||||
}
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE calendar_accounts SET account = ?
|
||||
WHERE id = ? AND (account = '' OR account = url)`, name, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE calendars SET name = ?
|
||||
WHERE account_id = ? AND (name = '' OR name = remote_id)`, name, accountID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// SubscribedCalendarID is the single calendar row under a subscription.
|
||||
func (db *DB) SubscribedCalendarID(ctx context.Context, accountID string) (string, error) {
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id FROM calendars WHERE account_id = ? LIMIT 1`, accountID).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Abonnement på en kalender, man kun har en adresse til.
|
||||
--
|
||||
-- Google-vejen er OAuth: én konto pr. person, hvor tokenet er identiteten, og
|
||||
-- `calendar_accounts_once` sørgede for netop det — én række pr. (person,
|
||||
-- udbyder). Et abonnement er en anden slags ting. Der er intet token og ingen
|
||||
-- konto, kun en adresse, og man har flere: helligdage, en fodboldklub, en
|
||||
-- fælleskalender fra arbejdet. Det er derfor det blev bedt om som "flere
|
||||
-- kalendere".
|
||||
--
|
||||
-- Samme tabel frem for en ny. Et abonnement er én kalender med begivenheder i, og
|
||||
-- det er præcis det, `calendars` og `calendar_events` allerede beskriver — med
|
||||
-- vinduet, `shown`, farven og hele fejemaskinen omkring sig. En tabel til ville
|
||||
-- være en anden vej til det samme sted, og to veje til ét sted er to steder at
|
||||
-- rette den dag noget om begivenheder ændrer sig.
|
||||
--
|
||||
-- Adressen bliver identiteten, som tokenet er det for Google. To rækker med den
|
||||
-- samme adresse for den samme person ville være den samme kalender hentet to
|
||||
-- gange, og der er ingen måde at sige hvilken der gælder.
|
||||
ALTER TABLE calendar_accounts ADD COLUMN url TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Det gamle indeks holder stadig for OAuth-rækkerne — dem uden adresse — og
|
||||
-- slipper abonnementerne fri. Delvise indekser er den ene ting SQLite kan her,
|
||||
-- som en tabelombygning ellers skulle til for.
|
||||
DROP INDEX calendar_accounts_once;
|
||||
|
||||
CREATE UNIQUE INDEX calendar_accounts_once
|
||||
ON calendar_accounts (user_id, provider) WHERE url = '';
|
||||
|
||||
CREATE UNIQUE INDEX calendar_subscription_once
|
||||
ON calendar_accounts (user_id, url) WHERE url <> '';
|
||||
@@ -16,6 +16,11 @@ var (
|
||||
ErrNotFound = errors.New("store: not found")
|
||||
ErrEmailInUse = errors.New("store: that email address already has an account")
|
||||
ErrInviteInvalid = errors.New("store: invite is not valid")
|
||||
// ErrDuplicate er en unik nøgle, der blev brudt, oversat til noget en handler
|
||||
// kan spørge om. Drivereren udstiller ikke sin egen fejltype brugbart, og en
|
||||
// handler, der matcher på tekst, er en handler, der går i stykker den dag
|
||||
// driveren omformulerer sig.
|
||||
ErrDuplicate = errors.New("store: already exists")
|
||||
)
|
||||
|
||||
type User struct {
|
||||
|
||||
+3
-1
@@ -50,10 +50,12 @@ nav:
|
||||
- Using it:
|
||||
- Signing in: signing-in.md
|
||||
- Quick add: quick-add.md
|
||||
- Notes: notes.md
|
||||
- Filters: filters.md
|
||||
- Importing from Todoist: import.md
|
||||
- Connecting things:
|
||||
- Calendar and CalDAV: caldav.md
|
||||
- Calendar: calendar.md
|
||||
- Feeds and CalDAV: caldav.md
|
||||
- Mail to task: mail.md
|
||||
- Mailboxes: mailboxes.md
|
||||
- Claude (MCP): mcp.md
|
||||
|
||||
@@ -3303,3 +3303,170 @@ 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([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Et projekts kalender kan vises som en uge.
|
||||
*
|
||||
* Ugen fandtes på Kommende og på Kalender, men ikke dér, hvor et projekt bor — og
|
||||
* det er dér, "hvornår sker de her fire ting" oftest bliver spurgt.
|
||||
*
|
||||
* Inde i kalendervisningen frem for som en fjerde `view_mode`. Den er projektets
|
||||
* og delt med alle, der kan se det, mens uge eller måned er et spørgsmål om, hvor
|
||||
* bred skærmen er, foran hvilken der sidder én person — og en fjerde værdi ville
|
||||
* kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads.
|
||||
*/
|
||||
test('et projekts kalender kan vises som en uge', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Ugeprojekt');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible();
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('male døren i dag kl 10');
|
||||
await box.press('Enter');
|
||||
await expect(page.getByText('male døren')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Kalender', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
// Døgnet, ikke en rude: opgaven står på sit klokkeslæt.
|
||||
const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' });
|
||||
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
|
||||
await expect(placed).toContainText('10:00');
|
||||
|
||||
// Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der
|
||||
// afgør det, og projektet er delt med andre skærme.
|
||||
await page.reload();
|
||||
await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
await expect(page.locator('.weekgrid')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En gentagelse siges på læserens sprog, også dem serveren kun kan på dansk.
|
||||
*
|
||||
* `recurrence.Describe` skriver dansk og kan ikke bare tage en locale med:
|
||||
* `toTaskJSON` har niogtyve kaldesteder, og mange er WebSocket-udsendelser, hvor én
|
||||
* nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at skrive
|
||||
* den i. Reglen er den samme uanset hvem der læser den, så beskrivelsen hører
|
||||
* hjemme dér, hvor læseren er.
|
||||
*
|
||||
* De to her er valgt, fordi de *ikke* står på listen over de fem almindelige, som
|
||||
* fladen altid har kunnet navngive. Før det her faldt de tilbage til serverens
|
||||
* tekst — og den er dansk, hvad end kontoen er sat til.
|
||||
*/
|
||||
test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
for (const [text, says] of [
|
||||
['vande planterne hver mandag', 'hver mandag'],
|
||||
['tømme opvasker hverdage', 'hverdage']
|
||||
]) {
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill(text);
|
||||
await box.press('Enter');
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// I indbakken og ikke på I dag: en ugentlig regel sætter den første
|
||||
// forfaldsdag til den næste sådan dag, som sjældent er i dag.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Hovedmenu' })
|
||||
.getByRole('link', { name: /^Indbakke/ })
|
||||
.click();
|
||||
|
||||
const name = text.split(' ').slice(0, 2).join(' ');
|
||||
const row = page.locator('.row', { hasText: name }).first();
|
||||
await expect(row).toBeVisible();
|
||||
await expect(
|
||||
row.locator('.repeat'),
|
||||
`"${text}" blev ikke læst som en gentagelse`
|
||||
).toContainText(says);
|
||||
|
||||
await page.goto('/');
|
||||
}
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -383,6 +383,10 @@ export const api = {
|
||||
// an event, only which calendars to look at.
|
||||
getCalendar: () => get('/calendar'),
|
||||
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
||||
// En kalender, man kun har en adresse til. Flere pr. person, hvor Google er
|
||||
// én: adressen er identiteten, som tokenet er det for OAuth.
|
||||
subscribeCalendar: (url, name) => post('/calendar/subscriptions', { url, name }),
|
||||
unsubscribeCalendar: (id) => del(`/calendar/subscriptions/${id}`),
|
||||
disconnectCalendar: () => del('/calendar'),
|
||||
authorizeCalendar: () => post('/calendar/authorize'),
|
||||
syncCalendar: () => post('/calendar/sync'),
|
||||
|
||||
@@ -64,7 +64,14 @@
|
||||
events = []
|
||||
} = $props();
|
||||
|
||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||
// Sat til i dag og lagt til rette af effekten nedenfor.
|
||||
//
|
||||
// Ikke `span === 'week' ? … : …` her: den læser `span`, som den er i det
|
||||
// øjeblik komponenten bliver til, og fanger den værdi for altid — hvilket
|
||||
// byggeriet har advaret om lige så længe. Så længe hver visning havde sin egen
|
||||
// komponent, gjorde det ingen forskel; det gjorde det i samme sekund, et
|
||||
// projekts kalender fik en uge/måned-vælger, der skifter `span` på den samme.
|
||||
let cursor = $state(new Date());
|
||||
|
||||
/**
|
||||
* The weekday names, from Intl rather than written out.
|
||||
@@ -101,13 +108,26 @@
|
||||
// week with a cursor on the 1st would otherwise show the week containing the
|
||||
// 1st rather than the week you were looking at, and switching back would land
|
||||
// on whichever month that week happened to start in.
|
||||
//
|
||||
// Med én undtagelse, og den er hele grunden til at det her er en effekt og ikke
|
||||
// en udregning: en månedsvisnings markør *er* den første i måneden. Skifter man
|
||||
// til uge, mens man står i den indeværende måned, er den uge man kigger på ikke
|
||||
// ugen omkring den 1. — det er den, i dag ligger i. Uden det her landede et
|
||||
// projekts kalender på "27. jul.–2. aug." for en person, der kiggede på august.
|
||||
$effect(() => {
|
||||
const aligned = startOfSpan(cursor);
|
||||
const anchor =
|
||||
span === 'week' && isMonthStart(cursor) && sameMonth(cursor, new Date())
|
||||
? new Date()
|
||||
: cursor;
|
||||
const aligned = startOfSpan(anchor);
|
||||
// Compared by value, not identity: assigning a fresh Date on every run would
|
||||
// re-trigger this effect forever, since the effect reads what it writes.
|
||||
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
|
||||
});
|
||||
|
||||
const isMonthStart = (d) => d.getDate() === 1;
|
||||
const sameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
||||
|
||||
function iso(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
|
||||
@@ -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',
|
||||
@@ -147,6 +152,25 @@ export const da = {
|
||||
'task.yesterday': 'I går',
|
||||
'task.overdue': '{n} dage forsinket',
|
||||
'task.repeats': 'Gentages {rule}',
|
||||
'repeat.weekdays': 'hverdage',
|
||||
'repeat.weekends': 'hver weekend',
|
||||
'repeat.and': 'og',
|
||||
'repeat.everyDay': 'hver {day}',
|
||||
'repeat.everyNthWeekOn': 'hver {n}. uge {day}',
|
||||
'repeat.everyOtherWeekOn': 'hver anden uge {day}',
|
||||
'repeat.monthlyOn': 'den {day}. hver måned',
|
||||
'repeat.every.day': 'hver dag',
|
||||
'repeat.every.week': 'hver uge',
|
||||
'repeat.every.month': 'hver måned',
|
||||
'repeat.every.year': 'hvert år',
|
||||
'repeat.everyOther.day': 'hver anden dag',
|
||||
'repeat.everyOther.week': 'hver anden uge',
|
||||
'repeat.everyOther.month': 'hver anden måned',
|
||||
'repeat.everyOther.year': 'hvert andet år',
|
||||
'repeat.everyN.day': 'hver {n}. dag',
|
||||
'repeat.everyN.week': 'hver {n}. uge',
|
||||
'repeat.everyN.month': 'hver {n}. måned',
|
||||
'repeat.everyN.year': 'hvert {n}. år',
|
||||
'task.assignee': 'Ansvarlig: {name}',
|
||||
'task.subtasks': '{done} af {total} undertasks er lukket',
|
||||
'task.attachments': '{n} vedhæftet',
|
||||
@@ -666,6 +690,13 @@ export const da = {
|
||||
'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.',
|
||||
'int.whichCalendars': 'Hvilke kalendere vises',
|
||||
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
|
||||
'int.subscriptions': 'Abonnementer',
|
||||
'int.subscriptionsHint': 'En kalender, du kun har en adresse til — helligdage, en klub, en fælleskalender fra arbejdet. Den hentes hvert kvarter og er skrivebeskyttet. webcal://-adresser virker; de er https med et andet skema foran.',
|
||||
'int.subscriptionURL': 'Kalenderens adresse',
|
||||
'int.subscribe': 'Abonnér',
|
||||
'int.subscribing': 'Henter …',
|
||||
'int.unsubscribe': 'Fjern',
|
||||
'int.unsubscribeQuestion': 'Fjern abonnementet på "{name}"? Begivenhederne forsvinder fra kalenderen.',
|
||||
'int.calendarLastSync': 'Sidst hentet {when}.',
|
||||
'int.calendarNeverSynced': 'Ikke hentet endnu.',
|
||||
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
||||
|
||||
@@ -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',
|
||||
@@ -143,6 +148,25 @@ export const en = {
|
||||
'task.yesterday': 'Yesterday',
|
||||
'task.overdue': '{n} days overdue',
|
||||
'task.repeats': 'Repeats {rule}',
|
||||
'repeat.weekdays': 'weekdays',
|
||||
'repeat.weekends': 'every weekend',
|
||||
'repeat.and': 'and',
|
||||
'repeat.everyDay': 'every {day}',
|
||||
'repeat.everyNthWeekOn': 'every {n} weeks on {day}',
|
||||
'repeat.everyOtherWeekOn': 'every other week on {day}',
|
||||
'repeat.monthlyOn': 'the {day}th of every month',
|
||||
'repeat.every.day': 'every day',
|
||||
'repeat.every.week': 'every week',
|
||||
'repeat.every.month': 'every month',
|
||||
'repeat.every.year': 'every year',
|
||||
'repeat.everyOther.day': 'every other day',
|
||||
'repeat.everyOther.week': 'every other week',
|
||||
'repeat.everyOther.month': 'every other month',
|
||||
'repeat.everyOther.year': 'every other year',
|
||||
'repeat.everyN.day': 'every {n} days',
|
||||
'repeat.everyN.week': 'every {n} weeks',
|
||||
'repeat.everyN.month': 'every {n} months',
|
||||
'repeat.everyN.year': 'every {n} years',
|
||||
'task.assignee': 'Assigned to {name}',
|
||||
'task.subtasks': '{done} of {total} sub-tasks are done',
|
||||
'task.attachments': '{n} attached',
|
||||
@@ -662,6 +686,13 @@ export const en = {
|
||||
'Uses the same OAuth client as Gmail above. The Google Calendar API has to be enabled, and this address has to be listed as an authorised redirect URI.',
|
||||
'int.whichCalendars': 'Which calendars are shown',
|
||||
'int.noCalendars': 'The account has no calendars to show.',
|
||||
'int.subscriptions': 'Subscriptions',
|
||||
'int.subscriptionsHint': 'A calendar you only have an address to — holidays, a club, a shared calendar from work. Fetched every fifteen minutes, and read-only. webcal:// addresses work; they are https with a different scheme in front.',
|
||||
'int.subscriptionURL': "The calendar's address",
|
||||
'int.subscribe': 'Subscribe',
|
||||
'int.subscribing': 'Fetching …',
|
||||
'int.unsubscribe': 'Remove',
|
||||
'int.unsubscribeQuestion': 'Stop subscribing to "{name}"? Its events go from the calendar.',
|
||||
'int.calendarLastSync': 'Last fetched {when}.',
|
||||
'int.calendarNeverSynced': 'Not fetched yet.',
|
||||
'int.calendarFetchedOne': '{n} event fetched.',
|
||||
|
||||
@@ -890,27 +890,55 @@ export const upcomingView = new UpcomingView();
|
||||
const CALENDAR_MODES = ['week', 'month'];
|
||||
|
||||
class CalendarViewMode {
|
||||
mode = $state(readCalendarMode());
|
||||
mode = $state('month');
|
||||
|
||||
// Nøglen som argument frem for som felt på en underklasse.
|
||||
//
|
||||
// Den vej blev prøvet og virkede ikke: en underklasses felter sættes *efter*
|
||||
// basens konstruktør er kørt, så en læsning i konstruktøren brugte basens nøgle
|
||||
// og skrivningen bagefter underklassens. Valget blev gemt ét sted og læst et
|
||||
// andet, og en uge, man havde valgt, var en måned igen efter en genindlæsning.
|
||||
// Et argument har ingen rækkefølge at tage fejl af.
|
||||
constructor(storageKey = 'verdande:calendar') {
|
||||
this.storageKey = storageKey;
|
||||
this.mode = readCalendarMode(storageKey);
|
||||
}
|
||||
|
||||
set(next) {
|
||||
if (!CALENDAR_MODES.includes(next)) return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:calendar', next);
|
||||
localStorage.setItem(this.storageKey, next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readCalendarMode() {
|
||||
function readCalendarMode(key) {
|
||||
if (typeof localStorage === 'undefined') return 'month';
|
||||
const stored = localStorage.getItem('verdande:calendar');
|
||||
const stored = localStorage.getItem(key);
|
||||
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
||||
}
|
||||
|
||||
export const calendarView = new CalendarViewMode();
|
||||
|
||||
/**
|
||||
* Måned eller uge på et projekts kalender.
|
||||
*
|
||||
* Sin egen nøgle, af samme grund som Kalenders er sin egen: det er tre steder med
|
||||
* hver sit spørgsmål. Kommende åbner på en liste over de næste syv dage, Kalender
|
||||
* *er* gitteret, og et projekts kalender er en del af en side, der også kan være
|
||||
* en liste og et board. At dele værdien ville betyde, at man valgte en uge ét sted
|
||||
* og fandt et andet ændret.
|
||||
*
|
||||
* Og ikke på projektet. `view_mode` er projektets — den er delt med alle, der kan
|
||||
* se det — mens uge eller måned er et spørgsmål om, hvor bred skærmen er, foran
|
||||
* hvilken der sidder én person. Det er den samme grund, som sidebjælkens bredde
|
||||
* ligger i localStorage af.
|
||||
*/
|
||||
export const projectCalendarView = new CalendarViewMode('verdande:project-calendar');
|
||||
|
||||
/**
|
||||
* Whether the lists show what has already been finished.
|
||||
*
|
||||
|
||||
+108
-2
@@ -73,8 +73,114 @@ export const REPEATS = [
|
||||
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
|
||||
];
|
||||
|
||||
/** What a repeating task says on its row. */
|
||||
/**
|
||||
* What a repeating task says on its row.
|
||||
*
|
||||
* Beskrevet her og ikke på serveren, og det er ikke en smagssag.
|
||||
*
|
||||
* `recurrence.Describe` skriver dansk. Den kan ikke bare tage en locale med:
|
||||
* `toTaskJSON` har niogtyve kaldesteder, og mange af dem er WebSocket-udsendelser,
|
||||
* hvor én nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at
|
||||
* skrive den i. Reglen ligger allerede i nyttelasten, og den er den samme uanset
|
||||
* hvem der læser den. Så beskrivelsen hører hjemme dér, hvor læseren er.
|
||||
*
|
||||
* Serverens tekst bliver som reserve. Den er stadig den, et API-kald og en
|
||||
* MCP-forbindelse får, og de har ingen flade at oversætte i.
|
||||
*/
|
||||
export function repeatLabel(rule, fallback = '') {
|
||||
if (!rule) return '';
|
||||
const known = REPEATS.find((option) => option.rule && option.rule === rule);
|
||||
return known ? t(known.label) : fallback;
|
||||
if (known) return t(known.label);
|
||||
return describeRule(rule) || fallback;
|
||||
}
|
||||
|
||||
/** Ugedagene i den rækkefølge RRULE navngiver dem. */
|
||||
const RRULE_DAYS = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 };
|
||||
|
||||
/**
|
||||
* Ugedagens navn på læserens sprog, fra Intl frem for fra en tabel.
|
||||
*
|
||||
* Ikke for at spare syv strenge pr. sprog — for at få de navne, sproget faktisk
|
||||
* bruger. Den 1. januar 2024 var en mandag; enhver mandag ville gøre det, og en
|
||||
* fast en holder det ude af tegningen.
|
||||
*/
|
||||
function dayName(code) {
|
||||
const index = RRULE_DAYS[code];
|
||||
if (index === undefined) return code;
|
||||
return new Intl.DateTimeFormat(tag(), { weekday: 'long' }).format(
|
||||
new Date(Date.UTC(2024, 0, 1 + index))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* En RRULE sagt med ord.
|
||||
*
|
||||
* Dækker det, verdandes egen hurtig tilføjelse kan lave, og falder tilbage til
|
||||
* reglen selv for alt andet — en kalenderklient kan sende ting, ingen her har
|
||||
* skrevet, og en halv oversættelse af dem ville være værre end den rå regel.
|
||||
*/
|
||||
export function describeRule(rule) {
|
||||
if (!rule) return '';
|
||||
const parts = {};
|
||||
for (const pair of rule.replace(/^RRULE:/i, '').split(';')) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key && value !== undefined) parts[key.toUpperCase()] = value;
|
||||
}
|
||||
|
||||
const interval = Number(parts.INTERVAL) > 0 ? Number(parts.INTERVAL) : 1;
|
||||
|
||||
if (parts.BYDAY) {
|
||||
if (parts.BYDAY === 'MO,TU,WE,TH,FR') return t('repeat.weekdays');
|
||||
if (parts.BYDAY === 'SA,SU') return t('repeat.weekends');
|
||||
|
||||
// Sorteret som ugen går, ikke som reglen tilfældigvis skrev dem: "mandag og
|
||||
// torsdag" og "torsdag og mandag" er den samme regel, og den ene af dem er
|
||||
// den, folk siger.
|
||||
const names = parts.BYDAY.split(',')
|
||||
.filter((code) => code in RRULE_DAYS)
|
||||
.sort((a, b) => RRULE_DAYS[a] - RRULE_DAYS[b])
|
||||
.map(dayName);
|
||||
if (!names.length) return '';
|
||||
|
||||
const joined =
|
||||
names.length === 1
|
||||
? names[0]
|
||||
: names.slice(0, -1).join(', ') + ' ' + t('repeat.and') + ' ' + names.at(-1);
|
||||
|
||||
// "hver mandag", ikke "hver uge mandag". En ugentlig regel, der navngiver
|
||||
// sine dage, har ikke også brug for ordet for intervallet.
|
||||
if (interval <= 1) return t('repeat.everyDay', { day: joined });
|
||||
// To har sit eget ord på begge sprog — "hver anden", "every other" — og det
|
||||
// er ikke tallet to sat ind i den samme sætning. Et sprog, der bøjer, kan
|
||||
// ikke sættes sammen af stumper.
|
||||
if (interval === 2) return t('repeat.everyOtherWeekOn', { day: joined });
|
||||
return t('repeat.everyNthWeekOn', { n: interval, day: joined });
|
||||
}
|
||||
|
||||
switch (parts.FREQ?.toUpperCase()) {
|
||||
case 'DAILY':
|
||||
return every(interval, 'day');
|
||||
case 'WEEKLY':
|
||||
return every(interval, 'week');
|
||||
case 'MONTHLY':
|
||||
if (parts.BYMONTHDAY) return t('repeat.monthlyOn', { day: parts.BYMONTHDAY });
|
||||
return every(interval, 'month');
|
||||
case 'YEARLY':
|
||||
return every(interval, 'year');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "hver dag", "hver anden uge", "hver tredje måned".
|
||||
*
|
||||
* Tallet står i teksten frem for at blive sat sammen af stumper: "every other
|
||||
* week" og "hver anden uge" er ikke to ord byttet om, og et sprog, der bøjer
|
||||
* enheden efter tallet, kan ikke sættes sammen af to nøgler.
|
||||
*/
|
||||
function every(interval, unit) {
|
||||
if (interval <= 1) return t(`repeat.every.${unit}`);
|
||||
if (interval === 2) return t(`repeat.everyOther.${unit}`);
|
||||
return t(`repeat.everyN.${unit}`, { n: interval });
|
||||
}
|
||||
|
||||
@@ -191,6 +191,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- abonnementer ---------------------------------------------------------
|
||||
let subscriptionURL = $state('');
|
||||
let subscribing = $state(false);
|
||||
let subscriptionError = $state('');
|
||||
|
||||
async function subscribe(event) {
|
||||
event.preventDefault();
|
||||
const url = subscriptionURL.trim();
|
||||
if (!url) return;
|
||||
subscribing = true;
|
||||
subscriptionError = '';
|
||||
try {
|
||||
await api.subscribeCalendar(url, '');
|
||||
subscriptionURL = '';
|
||||
calendar = await api.getCalendar();
|
||||
} catch (e) {
|
||||
// Feltets egen fejl frem for en toast: den hører til det felt, der lige
|
||||
// blev skrevet i, og en toast, der forsvinder, er en fejlmeddelelse, man
|
||||
// skal huske.
|
||||
subscriptionError = e.fields?.url ?? humanMessage(e);
|
||||
} finally {
|
||||
subscribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(sub) {
|
||||
if (!confirm(t('int.unsubscribeQuestion', { name: sub.account || sub.url }))) return;
|
||||
try {
|
||||
await api.unsubscribeCalendar(sub.id);
|
||||
calendar = await api.getCalendar();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendars(event) {
|
||||
event.preventDefault();
|
||||
savingCalendars = true;
|
||||
@@ -548,6 +583,48 @@
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Abonnementer står uden for Google-forgreningen ovenfor, og det er hele
|
||||
pointen: en konto, der ikke kan bruge instansens OAuth-klient — en privat
|
||||
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
||||
{#if calendar !== null}
|
||||
<div class="subs">
|
||||
<h3>{t('int.subscriptions')}</h3>
|
||||
<p class="hint">{t('int.subscriptionsHint')}</p>
|
||||
|
||||
{#if calendar.subscriptions?.length}
|
||||
<ul class="subs-list">
|
||||
{#each calendar.subscriptions as sub (sub.id)}
|
||||
<li>
|
||||
<span class="name">{sub.account || sub.url}</span>
|
||||
<span class="url mono">{sub.url}</span>
|
||||
<button class="danger" onclick={() => unsubscribe(sub)}>
|
||||
{t('int.unsubscribe')}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={subscribe}>
|
||||
<div class="field">
|
||||
<label for="calendar-url">{t('int.subscriptionURL')}</label>
|
||||
<input
|
||||
id="calendar-url"
|
||||
class="mono"
|
||||
bind:value={subscriptionURL}
|
||||
placeholder="https://… eller webcal://…"
|
||||
/>
|
||||
{#if subscriptionError}<p class="error">{subscriptionError}</p>{/if}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={subscribing}>
|
||||
{subscribing ? t('int.subscribing') : t('int.subscribe')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||
import { app, completedView, projectName, projectCalendarView } from '$lib/stores.svelte.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { eventName, eventDetail } from '$lib/events.js';
|
||||
import { TASK, SECTION, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
@@ -786,7 +786,26 @@
|
||||
onsectionadded={(section) => (sections = [...sections, section])}
|
||||
/>
|
||||
{:else if mode === 'calendar'}
|
||||
<CalendarView projectId={project.id} />
|
||||
<!-- Uge eller måned inde i kalendervisningen frem for som en fjerde
|
||||
`view_mode`.
|
||||
`view_mode` er projektets og delt med alle, der kan se det; uge eller
|
||||
måned er et spørgsmål om, hvor bred skærmen er, foran hvilken der
|
||||
sidder én person. Og en fjerde værdi ville kræve en tabelombygning:
|
||||
`CHECK (view_mode IN ('list','board','calendar'))` kan SQLite ikke
|
||||
ændre på plads. -->
|
||||
<div class="span" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
|
||||
<button
|
||||
class:active={projectCalendarView.mode === value}
|
||||
onclick={() => projectCalendarView.set(value)}
|
||||
aria-pressed={projectCalendarView.mode === value}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<CalendarView
|
||||
projectId={project.id}
|
||||
span={projectCalendarView.mode === 'week' ? 'week' : 'month'}
|
||||
/>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
@@ -1263,6 +1282,32 @@
|
||||
0 0 0 3px var(--ink);
|
||||
}
|
||||
|
||||
/* Den samme lille vælger som visningerne ovenfor, men ude til højre: den hører
|
||||
til kalenderen, ikke til siden, og at stille den ved siden af Liste/Board/
|
||||
Kalender ville lade som om der er fem visninger. */
|
||||
.span {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s1);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.span button {
|
||||
padding: 2px var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.span button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.span button.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
|
||||
Reference in New Issue
Block a user