Compare commits

...
2 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 dee2d5e30b fix(faerdige): en opgave lukket efter midnat lå under "I går"
Release / Build and publish image (push) Failing after 9m56s
Fundet af prøverne, som for første gang kørte forbi midnat.

Dagen blev læst som `completed_at.slice(0, 10)` — de ti første tegn af et
tidsstempel, der er i UTC. Overskriften sammenlignede med *lokal* dato. I dansk
sommertid er de to ikke den samme dag i de første to timer efter midnat, så en
opgave lukket 00.30 blev sat under "I går", indtil klokken blev to. Tyve timer i
døgnet er de ens, hvilket er derfor prøven har været grøn hver eneste gang før.
Dagen udregnes nu lokalt, som den overskrift den skal svare til.

Og prøven for invitationslinket kunne kun bestå seks dage om ugen. Den ledte
efter "males inden fredag" som én tekst, men hurtig tilføjelse læser "fredag" som
forfaldsdatoen og tager ordet ud af titlen — tilbage står "males inden" plus et
mærke, hvis ord skifter med ugedagen. Kørt på en torsdag hedder mærket "I
morgen", og så fandt den ingenting. Prøven handler om invitationer, ikke om
datoer, så den ser nu på titlen alene.

Uddraget i notelisten viste `![](/api/v1/attachments/01a0…` for hver note med et
billede i — den eneste tekst i listen, ingen havde skrevet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 00:17:50 +02:00
Kristian WindandClaude Opus 5 2bd8e48aba fix(notes): en note med tre prikker i titlen blev smidt væk ved importen
Release / Build and publish image (push) Failing after 9m54s
`skipFile` afviste ethvert navn, der indeholdt "..", for at stoppe en zip i at
skrive uden for sin egen mappe. Det er den rigtige idé rettet mod det forkerte:
det beskriver ikke en sti, der klatrer ud af arkivet, men ethvert filnavn med to
prikker ved siden af hinanden. En ellipse i en overskrift er almindelig, og noten
"Så blev det endelig jul! Stay tuned... ☕️" blev derfor aldrig oprettet.

Lydløst, oven i købet: filen blev sprunget over, før den blev talt, så svaret
sagde det samme, som hvis den var kommet med. Én note ud af tolv hundrede, og den
blev kun fundet ved at tælle efter, hvor mange der kom ud i den anden ende — 1140
hvor der skulle stå 1141.

Nu læses ".." som et stiled, ikke som to tegn, og en Markdown-fil, filteret
afviser, tælles som sprunget over. En import, der taber noget, skal sige det.

Og move-ikonet i sidebjælken er væk. ⠿ sad i den to pixels brede rende til
venstre for teksten og lå oven i rækkens egen kant — idéen var rigtig, pladsen
var det ikke. Elementet bliver: det er dét, der ejer trækket, og gjorde man i
stedet hele rækken draggable, ville alt indeni også blive det, og en opgave
trukket hen på I dag holdt op med at lande. Nu er håndtaget usynligt og lidt
bredere, og hånden ved hover er hele forklaringen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 00:04:08 +02:00
6 changed files with 125 additions and 19 deletions
+37 -4
View File
@@ -353,7 +353,16 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
for _, f := range zr.File {
name := f.Name
if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") {
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
continue
}
// Counted, not passed over in silence. A Markdown file the filter refuses is
// a note that did not arrive, and the caller is told the same number either
// way unless it is said here — which is how one note went missing without
// anything looking wrong.
if skipFile(name) {
s.log.Warn("import skipped a note file", "file", name)
skipped++
continue
}
@@ -409,10 +418,34 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
// would double a whole library with each copy full of binary rubbish.
func skipFile(name string) bool {
return strings.HasPrefix(name, "__MACOSX/") ||
if strings.HasPrefix(name, "__MACOSX/") ||
strings.HasPrefix(path.Base(name), "._") ||
strings.HasPrefix(path.Base(name), ".") ||
strings.Contains(name, "..")
strings.HasPrefix(path.Base(name), ".") {
return true
}
// Traversal, read as path segments rather than as a substring.
//
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
// wrong thing: it does not describe a path that climbs out of the archive, it
// describes any name with two dots next to each other anywhere in it. A note
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
// silently, and without even counting as skipped, so the number the import
// reported still looked right. One note in twelve hundred, found only by
// counting what came out.
//
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
// starts at the root, is not — and those are what a zip uses to write outside
// the folder it was unpacked into.
if strings.HasPrefix(name, "/") {
return true
}
for _, part := range strings.Split(name, "/") {
if part == ".." {
return true
}
}
return false
}
// attachToNote records the files a note's text points at and rewrites the text to
@@ -0,0 +1,38 @@
package httpapi
import "testing"
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
//
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
// tælle efter, hvor mange der kom ud.
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
keep := []string{
"Så blev det endelig jul! Stay tuned... ☕️.md",
"Møde 12.8..md",
"vedhaeftninger/1-2-Pasted Graphic.png",
"noter/Referat....md",
}
for _, name := range keep {
if skipFile(name) {
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
}
}
drop := []string{
"../uden for.md",
"noter/../../etc/passwd",
"/etc/passwd",
"__MACOSX/._noget",
".skjult.md",
"noter/._AppleDouble.md",
}
for _, name := range drop {
if !skipFile(name) {
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
}
}
}
+9 -1
View File
@@ -655,7 +655,15 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('males inden fredag', { exact: true }).click();
// Titlen alene, ikke rækken med datomærket i.
//
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
// datoer; den skal ikke vide hvilken dag det er.
await page.getByText('males inden', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
+16 -12
View File
@@ -508,14 +508,22 @@
sidebar is already a drop target for tasks — a task dragged onto I dag
stopped landing, because the row had claimed the gesture. A grip claims
nothing until it is grabbed. -->
<!-- Håndtaget har ingen prik i sig længere.
⠿ sad i den to pixels brede rende til venstre for teksten og lå oven i
rækkens egen kant — idéen var rigtig, pladsen var det ikke. Men elementet
bliver: det er dét, der ejer trækket, og gjorde man i stedet hele rækken
draggable, ville alt indeni også blive det — og en opgave trukket hen på
I dag holdt op med at lande, fordi rækken havde taget gesten. Nu er
håndtaget usynligt og lidt bredere, og hånden, der kommer ved hover,
er hele forklaringen. -->
{#snippet grip(key)}
<span
class="grip"
draggable="true"
ondragstart={(e) => onNavDragStart(e, key)}
ondragend={clearNavDrag}
aria-hidden="true">⠿</span
>
aria-hidden="true"
></span>
{/snippet}
{#snippet projectRow(project, sortable)}
@@ -1004,19 +1012,15 @@
so nothing moves when it appears. */
.grip {
position: absolute;
left: -2px;
width: 12px;
text-align: center;
font-size: 10px;
line-height: 1;
color: var(--ink-faint);
left: 0;
top: 0;
bottom: 0;
width: 14px;
cursor: grab;
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.navrow:hover .grip {
opacity: 1;
.grip:active {
cursor: grabbing;
}
.navrow > :global(a) {
+20 -1
View File
@@ -35,10 +35,29 @@
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
* how anybody looking for something they just closed would describe it.
*/
/**
* Dagen en opgave blev lukket, målt hvor personen står.
*
* Ikke `completed_at.slice(0, 10)`. Tidsstemplet er i UTC, og de ti første tegn
* er derfor UTC's dato — mens overskriften nedenfor sammenligner med *lokal*
* dato. I to timer efter midnat i dansk sommertid er de to ikke den samme dag,
* så en opgave lukket 00.30 lå under "I går", indtil klokken blev to.
*
* Fundet af prøven, som kørte forbi midnat for første gang. Den havde været
* grøn hver eneste gang før, fordi de to datoer er ens tyve timer i døgnet.
*/
const localDay = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
let days = $derived.by(() => {
const groups = new Map();
for (const task of done) {
const key = (task.completed_at ?? '').slice(0, 10);
const key = localDay(task.completed_at);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(task);
}
+5 -1
View File
@@ -113,6 +113,10 @@
*/
function plain(body) {
return (body ?? '')
// Et billede har ingen ord i sig. Uden det her stod der
// "![](/api/v1/attachments/01a0…" i uddraget af hver note med et foto i,
// hvilket er den eneste tekst i listen, ingen har skrevet.
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*>\s?/gm, '')
@@ -447,7 +451,7 @@
border: 0;
background: none;
padding: 2px var(--s1);
max-width: 11ch;
max-width: 13ch;
}
h1 {