9ed6f49589d6fa054f256580c9e6b63188ca8266
136
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7f09b35db5 |
feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men det, spørgsmålet fik øje på, var rigtigt, og værre end det lød. Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede, uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv. Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til noget, der sker for en. Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den, der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles allerede uden. Det, der manglede, var besked og en udgang. `project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen til projektet, så beskeden bærer sit eget mål og krævede ingen særregel. Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget projekt; det ville være en overdragelse, og den findes ikke endnu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9b21b3eac5 |
feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.
`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.
De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.
Tre ting, der kostede en kørsel hver:
`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.
To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.
Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6cf184caa6 |
feat(plan): dagens plan står på I dag, og ikke kun i en besked
Planen var en notifikation og ikke andet. Klokken klippede den af efter én linje — "2 forfalder i dag, 1 er sprun…" — og klikket førte ingen steder hen, fordi en plan hverken er en note, en opgave eller et projekt. Det eneste sted, den landede hel, var push-beskeden på telefonen. Nu står den som et kort øverst på I dag, dér hvor man kigger, når man starter dagen. Beskeden er påmindelsen; kortet er stedet, den bliver. Teksten er gemt for dagen frem for regnet forfra, hver gang nogen kigger. En model pr. sidevisning er dyr for noget, der ikke har ændret sig — og vigtigere: en plan, der siger noget andet, når man kigger på den, end den sagde, da den kom, er værre end begge dele hver for sig. Kortet siger også, hvornår den blev lavet: en plan fra klokken syv er en anden slags oplysning klokken fire end klokken otte. En plan fra i går sendes ikke med. Et kort, der viser gårsdagens plan under overskriften "i dag", er værre end et tomt kort. "Forny planen" laver en ny, når dagen har flyttet sig, og sender ikke en besked om noget, man står og kigger på — den slags lærer folk at ignorere klokken. Og for den, der har morgenbeskeden slået fra, tilbyder kortet at lave planen på stedet, så funktionen ikke kun findes for dem, der vil vækkes. Klokken fører nu til I dag, og planen fylder de tre linjer, den er, i stedet for at blive klippet af. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2210dc082a |
feat(opdatering): en ny version siger til, i stedet for at vente på at
blive fundet Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis man i forvejen havde mistanke om, at der var noget at se efter — og "der er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter. Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder, frem for ud af programmet. Én besked pr. version, ikke én pr. time. En påmindelse hver time om det samme er den slags besked, folk slår fra — og så er den næste, der betyder noget, også slået fra. Versionen, der er sagt til om, skrives først, når beskederne er sendt: den anden vej rundt ville en fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde. Kun administratorer, af samme grund som versionsfeltet allerede er det: en besked om, at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan gøre noget ved den. Og et tjek, operatøren har slået fra, bliver ikke til en besked ad bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9fa47822c1 |
Merge pull request #69 from kristianwind/ai-dagens-plan-2
feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål |
||
|
|
366bed3a15 |
Merge pull request #68 from kristianwind/ai-foreslaar-du-bestemmer
feat(ai): forslag, man siger ja til — indbakken og noternes opgaver |
||
|
|
8139003b35 |
feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
De tre sidste af de fem. Alle tre samme sted hen: det, man ellers selv skulle sidde og gøre klokken syv om morgenen. Dagens plan. Én besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles i basen og gives til modellen som kendsgerninger, den ikke må regne om — en plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model, står tallene alene, og det er stadig det, man havde brug for at vide. Er der hverken noget, der forfalder, eller noget, der er sprunget over, kommer der ingen besked: en morgen uden noget at gøre er en god morgen, og en besked om ingenting lærer folk at lade være med at åbne beskeder. Timen er personens egen — klokken syv er syv forskellige steder — og jobbet kører hver time frem for på et klokkeslæt, så en instans, der sov, ikke springer dagen over. Flytter man timen, ryger dagens mærke, så man kan se virkningen i dag i stedet for i morgen. Mails, der giver mening. Opgaven hed, hvad mailen hed: "Anders Jensen: SV: SV: Vedr. levering uge 12". Nok til at genkende, ikke nok til at gøre noget ved. Nu skriver modellen den handling, mailen beder om, og lader være, hvis den ikke beder om nogen. Ét kald pr. gennemløb frem for ét pr. mail — femogtyve mails er ellers femogtyve gange ventetid — og emnet bliver stående i beskrivelsen, så en misforstået mail stadig kan læses. Falder modellen ud, hedder opgaven afsender og emne som før: en model, der er nede, må ikke kunne stoppe posten. Og svar på et spørgsmål. "Hvad lovede jeg Anders i august?" — svaret står spredt over tre noter, og ingen af dem indeholder ordet "lovede". Søgningen kræver, at alle ord optræder, hvilket er rigtigt for et søgefelt og forkert for et spørgsmål, så kandidaterne hentes ord for ord og de noter, flere ord rammer, kommer øverst. Modellen får kun dem: den slår ikke selv noget op, så et svar kan ikke handle om nogen andens noter. Og svaret bærer, hvad det står på — en model, der siger "det gjorde du den 14.", er kun brugbar, hvis man kan slå op i det, den læste det i. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
40102d6038 |
feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
To steder, hvor arbejdet ligger og venter på at blive skrevet færdigt. Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem ordentligt — og derfor også der, hvor de bliver liggende. At åbne hver enkelt og sætte projekt, dato og prioritet er ti klik pr. linje. Nu er det ét spørgsmål og et ja pr. linje. Og et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det. Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for bare at være forkert. Modellen skriver ikke noget. Den foreslår en *linje* i quick-add-syntaks, og forslaget bliver til noget, når nogen siger ja. Det er ikke forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår, skal have ret tit nok til at spare et par tastetryk. Linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren findes. Der er én fortolkning af "i morgen" i dette program, og forslaget går gennem den samme som alt andet — også når linjen er blevet rettet i hånden, inden den blev accepteret. Og et forslag, skrevet i det sprog man selv skriver i, kan læses og rettes af den, det bliver stillet til. Modellen får listen over de projekter, der findes, og må ikke nævne andre. Et `#Nyt projekt`, den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja — og et gæt må ikke kunne flytte en opgave hen, hvor ejeren ikke leder efter den. Testet. Forslagene til indbakken bærer et nummer tilbage til den opgave, de handler om. Rækkefølgen alene ville også kunne bruges og gør det som reserve, men en model, der springer en opgave over, ville så flytte alle de følgende forslag én plads — og "sæt en dato på den her" ville lande på en anden. Noten bliver ikke rørt, når der laves opgaver ud af den. Det er fristende at skrive linket ind i den, så de to peger på hinanden, men en tekst, et program skriver i uden at blive bedt om det, er en tekst, man holder op med at stole på. Opgaven bærer i stedet, hvor den kom fra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0cf5c8c281 |
feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
Beaconet er slået til som udgangspunkt. Det kan kun forsvares, hvis det bliver sagt af sig selv — og det blev det ikke. Det stod rigtigt og fyldestgørende under Indstillinger → Data, som er en side, der bliver åbnet af dem, der i forvejen har en mistanke om, at der er noget at læse. For alle andre var "du kan slå den fra" et tilbud til nogen, der ikke vidste, der var noget at slå fra. Nu møder en administrator beskeden første gang: hvad der bliver sendt, med de rigtige værdier i — det rigtige id, den rigtige version, den rigtige adresse — og to knapper. Værdierne står der frem for ordet "anonymt", fordi et løfte om telemetri er præcis så meget værd som læserens mulighed for at efterprøve det. `keep` er en peger og ikke en bool. En tom krop måtte ikke kunne betyde "slå det fra": et fravalg, ingen har truffet, er lige så meget et brudt løfte som et tilvalg, ingen har truffet. Og tre ting mere, som Yggdrasil har lært på den hårde måde: Et beacon, der ikke kan nå sin collector, fejlede i fuldstændig tavshed. Det prøvede igen i morgen, siden sagde "sidst sendt" med en dato, der bare blev ældre, og ingen opdagede det. Grunden bliver nu skrevet ned og vist. En installation, der opdaterer sig selv om natten, meldte gårsdagens version i op til et døgn, fordi døgnet blev talt fra sidste vellykkede melding. En version, der er skiftet, sender nu med det samme. Og det offentliggjorte tal har fået et gulv. Et tal, der falder, kan ikke offentliggøres som en kendsgerning — en dårlig udrulning, en DNS-ændring, der stille stopper meldingerne, og rækker, der er væk, ser alle sammen ud som "færre bruger det nu". Adressen bliver stående: Verdande melder til verdande.yggdrasilpanel.com, ikke til panelets egen collector. De to tæller hver sin slags installation, og en fælles tabel har intet felt til at holde dem ude fra hinanden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
445d9de57a |
Merge pull request #66 from kristianwind/indbakken-har-en-adresse-apps-kan-skubbe-til
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til |
||
|
|
d5ad459eba |
Merge pull request #65 from kristianwind/maerket-paa-ikonet
feat(beskeder): appens ikon bærer tallet på ulæste |
||
|
|
d063425cae |
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en mailserver op, og går gennem en indbakke, der kan være minutter om det. Det, der manglede, var en URL, man kan smide en linje tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan kalde en URL. Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej, der bare sender det, man har markeret. Første linje bliver til opgaven, resten til noten under den. Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i feltet øverst i fladen — dét er hele pointen med at have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder. Sit eget token frem for at låne mailens. De to er den samme slags hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en genvej på en telefon, der blev væk, skal kunne skiftes uden at mailadressen — som står i andres adressebøger — skifter med. Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge i et script på en anden maskine, og en, man ikke kan. Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet: forudindlæsning i en browser, en linkkontrol i en chat og en historik, der bliver besøgt igen, ville alle lave opgaver, ingen har bedt om. Sin egen kvote frem for postens, så en travl integration ikke kan lukke for den indgående post. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b041b32aa2 |
feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på fanen. Det halve af pointen med at få besked er at få den, mens man laver noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc. Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og kender tallet, og service-workeren, når den ikke er — og det er netop dér, mærket er det, der siger, at der er sket noget. Tallet kommer med i push-beskeden frem for at blive talt op af service-workeren. Den husker ingenting mellem to opvågninger og kunne derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en tælling, der fejler, udelader feltet frem for at sende et nul — så står mærket, som det stod, i stedet for at blive tørret af en optælling, der ikke lykkedes. Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud — det står i Dock'en, også når ingen er logget ind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d8f5f6e236 |
feat(noter): nævn en person i en note, og den er delt med dem
At skrive nogens navn i en note *er* at ville have dem til at læse den. Indtil nu var det to handlinger — skriv navnet, åbn panelet, find personen, del — og den anden blev glemt, så noten stod med en besked til en, der ikke kunne se den. Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem. Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter. Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der hed noget i den retning. Her er der ikke noget at gætte — der står et navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse. En mailadresse er ikke en omtale, det længste navn vinder, og ejefaldets s hører til sætningen. Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som links: en note gemmes ved hver tastepause, så uden sammenligningen var hver gemning en ny deling og en ny besked om det samme navn. Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet følger. Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste, der opfylder sætningen, er det rigtige at give. En rolle, ejeren har hævet i panelet, sænkes ikke igen af en omtale. Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte dig" og ikke også "rettede en note" om den samme gemning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ee9fbedfb |
feat(noter): del en note med en, der ikke har en konto endnu
En note kunne deles med dem, der allerede var på instansen — og kun med dem. Skulle en anden læse med, skulle man først invitere dem ind, vente på at de oprettede sig, finde noten frem og så dele den. Fire skridt og en ventetid for det, der er én sætning: lad Sofie se den her. Nu er der ét felt. Man skriver adressen, og serveren ved, om der er en konto bag: er der det, bliver det en deling med det samme, og ellers en invitation, der bliver til delingen i samme øjeblik, kontoen oprettes. Hvem der har en konto, er ikke noget, man skal vide for at dele en note. Rækken er den, der allerede fandtes. En invitation bar i forvejen en e-mail, en rolle og et forseglet token; det eneste, der manglede, var at kunne sige, hvad den var en invitation *til*. note_id ved siden af project_id, med fremmednøglen — dén, der rydder invitationen væk, når noten slettes, inden nogen når at tage imod en invitation til noget, der ikke er der længere. Det er ikke ny magt til nogen: enhver, der ejer et projekt, kunne i forvejen invitere en fremmed ind på instansen pr. e-mail. Kun ejeren kan invitere til en note, som kun ejeren kan dele den. De inviterede står i panelet ved siden af dem, der er kommet, og kan trækkes tilbage. Uden det er en invitation sendt i går usynlig, og så sender man den igen — endnu et link til den samme indbakke, og kun det ene af dem til at gøre ugyldigt. Og delingen siger selv til: den, der får en note delt, får besked om, at den er der. Klokken sorterer nu på id efter tid, fordi tiden er sekunder — en note, der bliver delt og rettet i samme håndbevægelse, stod ellers i den rækkefølge, basen nu engang fandt dem i. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab77024ec9 |
feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved det, man deler, skal man kunne se uden at lede efter det. Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke på serveren, så et engelsk interface får en engelsk klokke. Beskeder om den samme note bliver slået sammen, mens de er ulæste. En note gemmes på en tastepause, så en række pr. gem ville lave én person, der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er den næste rettelse en ny ting at få at vide — ikke en gentagelse. note_id får sin egen søjle ved siden af project_id og task_id frem for et generisk (kind, id)-par: de tre er ikke det samme slags nul, og fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et generisk par ikke have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
916a0c0224 |
feat(noter): en delt note tager de noter med, den peger på
Den anden halvdel af den melding, der begyndte det her: linkene i en delt note førte ingen steder hen, fordi det, de pegede på, ikke var delt. At dele en note er at dele det, den *siger*, og et link er en del af det. note_shares får en `linked`-søjle. 0 er en deling, nogen har valgt; 1 er en, der er udledt af et link. Skellet er hele modellen. Fladen kan sige *hvorfor* en person har en note — "fulgte med" er en anden sætning end "du delte den" — og de udledte rækker kan regnes forfra efter hver ændring, uden at røre dem, ejeren selv har sat. SyncLinkedShares regner billedet om fra bunden efter enhver af ejerens egne handlinger: en deling, en fjernet deling, en gemning der har ændret links eller titel, en sletning. Hele kæden følger med, ikke kun første led, med et loft på hundrede noter — "del denne ene note" må ikke kunne blive til fire hundrede. Rollen arves fra den note, man kom fra, og stærkest vinder, når en note kan nås ad to veje. Kun ejerens egne noter følger med, og det er ikke en optimering: et link peger på en titel, og en medredaktør må skrive titler. Kunne et link hente hvad som helst, kunne den, man har delt med, skrive [[Bankoplysninger]] og læse med. Derfor slås der kun op blandt ejerens egne noter, og derfor regner kun ejerens egne gemninger om. Begge dele har en Go-prøve. Det lukker ikke hullet helt: skriver medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note med dér. Det, der står imod, er synlighed — linket står som tekst i ejerens egen note, og panelet viser hver note, der er fulgt med, og hvem der har den. Udregningen åbner hver af ejerens noter, fordi titlerne er forseglet. Derfor to værn: har man ikke delt noget, svares der med det samme ud fra ét EXISTS, og en gemning regner kun om, når links eller titel faktisk har flyttet sig. Uden det første kostede "Ny note" halvfems millisekunder, og i det hul nåede e2e-prøven for monospace at taste sin titel ind i den forrige note, som editoren så tegnede væk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ |
||
|
|
094ea5cdd8 |
feat(upcoming): look up to 30 days ahead, and choose how far
Kommendes liste has always been the next seven days and nothing else. Seven days is the right walk to take in the morning, and it is still what the view opens on — but "when does this month get busy" is a different question, and the month grid answers it with shape rather than in order. So the list now takes a horizon: 7, 14 or 30 days, chosen with a switcher beside the view buttons. Only the list gets one. A week grid is a week and a month grid is a month; there is no fortnight for them to show, so the switcher is hidden in those modes. The choice lives in localStorage next to the mode, under its own key. It is the same kind of choice as list-or-grid — how you want to look, not a fact about the work — and it should survive a reload without following you to a phone where thirty day headings is a very long scroll. Once the strip is longer than a week the day headings carry the week number. Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not say which mandag. Server side the endpoint already took `?days=` and capped it at 31, so the window needed nothing — but its row limit was a constant 1000 written for a week. A month asked for with a week's ceiling would truncate the last days silently, and a day cut off the end of the answer looks exactly like a day with nothing due, so the limit now follows the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx |
||
|
|
84ca506961 |
feat(gmail): take the star off a mail once it has become a task
A starred Gmail message became a task and kept its star, so a starred inbox only grew — the IMAP side has always unflagged what it imports, and Gmail could not, because the scope was gmail.readonly. This gives Gmail the same one write: after the task is made, the STARRED label comes off the message, in one batchModify for the whole sweep. Nothing else is touched — not the body, not other labels, and a label trigger's label stays where it is, since that label is the person's own. The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope Google offers that can remove a label. An existing connection keeps working on its old read-only grant — the sync still reads, the unstar is a best-effort 403 that is logged, not fatal — and starts clearing stars once the account is reconnected and the new grant is in place. It self-heals: a mail that could not be unstarred stays starred, stays in the next query, and is tried again. The consent screen now asks for more than it used to; the settings copy, the docs and the scope table all say modify rather than read-only in the same change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f62ffc8d92 |
feat(2fa): show a scannable QR code when enabling two-factor auth
The setup screen showed the secret and the otpauth URI as text and asked you to scan — with nothing to scan. It renders the URI as a QR now, an SVG (one dark path on a white card, so it reads in either theme) generated from the barcode encoder already in the module graph via pquerna/otp. The secret stays below it for a device that cannot scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
247277e139 |
feat(users): always capitalise a display name's first letter
Andreas signed up as "andreas" and was shown that way everywhere. A name is now capitalised on the way in — the first letter of each word raised, the rest left as typed — in CreateUser (signup and invite-accept) and UpdateProfile, so display, mail and export all agree. Existing accounts are fixed by an idempotent startup pass (NormalizeUserNames), the same shape as SealNotes: the rule is Unicode-aware and per word, which SQLite's UPPER cannot spell, so it lives in Go rather than a migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9e3f20b050 |
feat(tasks): snooze — park a task at the bottom until a time
Snooze puts a task out of the way without finishing it or filing it: it sinks to
the bottom of its list and greys, and comes back on its own when its time passes.
No job wakes it — "snoozed" is just snoozed_until being still in the future, read
against the clock each time the list is ordered.
- `snoozed_until` on tasks (migration 0031); the list order sinks a still-snoozed
task below everything, and it rejoins its place the moment the time passes.
- POST /tasks/{id}/snooze {until} — an RFC3339 time to snooze to, or empty to wake.
Nothing else about the task moves; a snooze is about when you want to see it, not
when it is due.
- The task drawer gains quick picks (this evening / tomorrow / next week) and a
custom time, and a Wake now once it is snoozed. The row dims and carries a
"snoozed until X" mark, and the list keeps snoozed tasks at the foot — the client
sorts the same way the server does, so a drag-sorted list does not jump on reload.
Tests: snooze sinks a task below one made after it and waking returns it (Go); and
from the drawer the row greys, shows the mark, and un-greys on wake (e2e). Full
suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
60d66b728e |
fix(ics): a date-only task now shows in a subscribed calendar
The calendar feed emitted every task as a VTODO and, on top of that, an event only for tasks that carried a clock time. But a subscribed calendar in Apple Calendar or Google ignores VTODO entirely and draws only events — so a task with just a due date, which is most of them, appeared in the verdande app's own calendar and nowhere in the calendar it had been subscribed into. "Verdande does not update in my calendar, only in the verdande app." Every dated, unfinished task is an event now: a clock time makes a timed event as before, a bare date an all-day one (a DATE start and an exclusive next-day end, so it stays on its own day). Finished tasks stay off the calendar, as they were. Test updated: the sample's timed task and its two date-only tasks all become events now, the all-day ones in the DATE form, and the finished one still makes none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b1e7ba7c9 |
fix(push): Apple rejected the notifications; use the site's own URL as VAPID subject
Every Web Push to an iPhone or a Mac was refused, silently as far as the person waiting for the reminder could tell. The cause was the VAPID "sub" claim: on an instance with no mail configured it fell back to mailto:verdande@example.invalid, and Apple answers 403 Forbidden to a mailto whose domain is made up. Verified against web.push.apple.com on the live subscriptions — that mailto gets a 403, the instance's https URL a 201. So the subject is now a real mailto when SMTP.From is a real address, and otherwise the instance's own https base URL — which is a valid VAPID subject, points at something that exists, and every push service including Apple accepts. Two things found alongside it, both fixed: - A successful push did not clear the failure count. A subscription drops at ten failures, and all four live ones sat at nine from this bug — one flaky push from being deleted despite working. MarkPushDelivered zeroes the count and moves last_used_at on every delivery, so a subscription that recovers is not still carrying its old failures toward the cliff. - A reminder with no project made a notification whose URL was "/projekt/" — a dead link. Empty project now points at the app root. Test: the subject is a real mailto when there is one, and the https URL when there is not — including for the localhost default, which is not a real address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4bbc49fe7e |
fix(notes): export the notes in a stable order
AllNotes returns newest-first by the second, so two notes written in the same second have no order between them — and the notes export walks that list assigning filenames, giving the first note of a name the plain filename and the next the "(2)". With the order undecided, which of two same-titled notes got which name was a coin toss that landed differently on each export, and the export test failed roughly one run in three. Sort the export by when each note was made, with the id to break a same-second tie. The first note of a name keeps the name; the archive is the same twice running. Ids are time-ordered, so the tie-break agrees with creation order rather than fighting it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
91671a4f97 |
feat(notes): share a note with a person, not only through a project
Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.
A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.
The store:
- note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
notes shared directly with the reader.
- the effective role folds a direct share on top of the owner and project paths.
The rules, drawn where they protect the owner:
- read = viewer, edit text = editor, on any path.
- MOVE a note (change its project) is owner-only: an editor filing it into a
project the owner is not in would take the owner's own note away from them.
- DELETE is owner or project-editor — a direct share, however high its role,
does not reach to throwing the note away.
- SHARE and UNSHARE are the owner's alone, like a project's invitations.
- the picker is everyone on the instance (a closed, invited set) minus who is
already on the note; a made-up id and the owner themselves are refused.
The list:
- a "Delt med mig" group at the top, collapsible, that appears only when it has
something in it — the same rule Favoritter follows — so it costs nothing when
you share nothing. A shared note lives there, not also in a time bucket.
- every shared note carries a small chip in its owner's avatar colour, the
project mark's shape said for a person.
- no star or archive button on a note somebody else owns: those change the note
itself, and it is not yours to put away.
- the editor grows a "Del med personer" panel for the owner: who can see it, at
what role, and an add row drawn from the instance's other people.
The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.
Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.
Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
77994eff09 |
feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code stays valid for its whole thirty-second window, so one seen in flight — a phishing page, a proxy, over the shoulder — could be replayed until it expired. Tasks now record the counter of the last accepted step (totp_last_step), and a login accepts a code only when its step is strictly greater. The guard is in the UPDATE, not a read-then-write, so two logins racing with the same stolen code cannot both find the old value and both proceed — exactly one moves the row. auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the same one-step window; the code entered a little early or late spends the step it was issued at, not the step it was checked at, so a late-but-valid code is still single-use. Verified end to end: the same code presented to two logins in one window is accepted once and refused the second time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b4038356f0 |
feat(security): trust a forwarded IP only from a proxy, and set COOP
The rate limiter and the audit log key on the caller's address, and that address was read with chi's middleware.RealIP — which believes X-Forwarded-For from anyone. So a caller could set it to a new value on every request and get a fresh password-guess bucket each time, and could write any address they liked into the log the panel's watchers read. The one control meant for when a password is already gone was keyed on a number the attacker got to pick. Now the header is believed only when the machine that *connected* is a trusted proxy — by default anything on a private network, which is the shape of a reverse proxy or tunnel sharing the host or the docker bridge. A connection straight off the internet is trusted for nothing and is keyed on the address it actually came from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no proxy at all); VERDANDE_REAL_IP_HEADER names the header. Verified in the current deployment before the change: behind the Cloudflare Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared overwrites it. This makes that safety a property of the code, not of the deployment — expose the instance directly, or put a header-passing proxy in front, and it would have been a live hole. Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating the browsing context. verdande scripts no other window, so there is nothing to lose and a class of cross-window attacks to close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a87cfeb44 |
feat(security): HSTS og en lukket Permissions-Policy på hvert svar
To headere, appen ikke satte, og som begge følger af, hvad den allerede er. Sessionen bor i en __Host-cookie, der kun findes over HTTPS — hele modellen forudsætter TLS. Uden HSTS holder den forudsætning ikke mod en netværksangriber: den allerførste navigation kan strippes til http://, og cookien rækkes over i klartekst, før Secure-flaget nogensinde kommer i spil. HSTS lukker vinduet, og den er sat på nøjagtig samme betingelse som cookiens Secure-flag (secureCookies), så en dev-instans på ren HTTP aldrig bliver bedt om et certifikat, den ikke har. To år, underdomæner med; ingen preload — at blive hard-kodet ind i browsere er en beslutning for domænets ejer, ikke for en selv-hostet app. verdande beder aldrig om kamera, mikrofon eller placering, så den sikreste Permissions-Policy er at slå dem alle fra: et indsat element kan ikke bede om det, siden selv har afvist. Navngivet felt for felt frem for med et jokertegn, fordi jokertegns-formen ikke forstås ens af alle browsere, og en regel, en browser ignorerer, er ingen regel. Prøve på begge: HSTS følger HTTPS og udebliver over HTTP, og Permissions-Policy lukker for kameraet uanset skema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3e52eea2da |
feat(kvasir): én mail bliver til én opgave, uanset hvem der læser den
Fundamentet under Kvasir, og det er værd at have uden ham. Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere af samme postkasse — en anden assistent, en gentaget kørsel, et menneske — laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal faktura fra Browns" to gange i samme kørsel. Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`, `imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og det her skal virke uden. Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal være ude af stand til" er en betingelse frem for en aftale, nogen husker. Afgrænset til den, mailen kom til — to personer på samme instans, der begge modtager tråden, får hver sin opgave, og det er rigtigt. CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da af standser samme sted. `TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave, der blev lavet af en mail og smidt væk, er også et svar. |
||
|
|
27a7b04ff3 |
test: kassens rigtige tailnet-adresse ud af testen
Jeg skrev instansens faktiske Tailscale-adresse ind som prøvedata. Den er ubrugelig uden adgang til tailnettet, men den er stadig et kort over én persons opsætning — og af præcis den grund er arbejdspapiret om flytningen gitignoreret. Så skal en test ikke bære adressen i stedet. En neutral adresse fra samme område prøver det samme. |
||
|
|
b6cbc6cbc2 |
fix(ai): din egen model kunne ikke sættes op — tre fejl i én oplevelse
Rapporteret som "AI gemmer ikke mine oplysninger". Anden gang det symptom meldes, og en helt anden årsag end sidst — dengang delte scopes i user_settings én række og slettede hinanden. Udbyderen hedder "OpenAI-kompatibel (Ollama, LM Studio, …)" og feltet foreslår selv http://localhost:11434/v1. safedial nægter præcis den slags adresser. Så den ene udbyder, der findes for at køre sin egen model, kunne ikke sættes op, og formularen foreslog en adresse, serveren ville afvise. Serveren svarede 422 med en fejl på base_url. Formularen havde ingen plads til at vise en fejl på det felt, og fordi fejlobjektet ikke var tomt, blev der heller ikke vist en toast. Beskeden forsvandt, og det så ud, som om der ikke skete noget ved at trykke Gem. Tre rettelser: - Private adresser tillades for admins. Muren står, fordi en server, der henter en brugerangivet adresse, lader den, der angiver den, nå ting han ellers ikke kan. En admin på en selvhostet instans ejer allerede værten og dens netværk, så det giver ham intet nyt. For en inviteret bruger er den lukket som før. Tilladelsen regnes ud pr. forespørgsel, ikke gemt ved siden af adressen — den, der ikke længere er admin, skal ikke kunne nå indenfor på et gammelt flag. - Tailnet er ikke "indenfor". Halvdelen virkede allerede ved et tilfælde: Gos IsPrivate dækker RFC1918 og ikke det CGNAT-område, Tailscale låner, så 100.x slap igennem uden at nogen havde besluttet det. IPv6-halvdelen gjorde ikke — fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså tilgængelig eller ej alt efter adressefamilie. Begge områder er nu navngivet, hvilket gør IPv4-halvdelen bevidst frem for heldig. - Feltfejl kan ikke længere forsvinde. base_url og model har fået et sted at vise en, og en fejl på et felt, der ikke har et, råbes op som en toast frem for at blive tabt. |
||
|
|
c8d67dd07d |
feat(notes): notekroppe forsegles, og søgningen åbner dem selv
CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til. **Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst. **Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden: `notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk, og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads. **Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne; fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og delordssøgningen falder bort. bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest: titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren optælling laver, og den er let at lave og svær at få øje på. **En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste den rækkefølge væk, importen brugte en dag på at hente hjem. En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans, der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der siger fra. Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden: opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke projekter og opgaver en note peger på, men ikke hvad der står. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
264f48ae11 |
fix(quickadd): "hver anden tirsdag" blev til en dato og to ord i titlen
Release / Build and publish image (push) Failing after 10m3s
Vendingen er den, folk bruger, og recurrence-pakken har kunnet den siden den blev skrevet — den oversætter "hver anden" til "hver 2." netop for at slippe for at skrive hvert eneste mønster i to udgaver. Sætningen nåede bare aldrig frem til den. Mønsteret i hurtig tilføjelse tog et tal efter "hver", men ikke et ord, så det, der blev givet videre, var "hver anden". Det er ikke en regel. Så blev de to ord stående i titlen, "tirsdag" blev læst som en forfaldsdato, og opgaven gentog sig ikke — uden at noget sagde det. Sløjfen, der prøver sig frem, kunne ikke redde den: den fjerner ord fra enden, én ad gangen, og kan ikke hente et ord, mønsteret aldrig tog med. Fundet af en prøve, jeg skrev til noget andet, hvor jeg brugte vendingen som en selvfølge og opdagede, at den ikke var det. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dcb71f4c14 |
feat(calendar): abonnement på flere kalendere, ICS og webcal
Release / Build and publish image (push) Failing after 10m3s
Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op. **Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet, farven, `shown` 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. Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene ting, SQLite kan her, som ellers krævede en tabelombygning. **`webcal://` skrives om til `https://`.** 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. **Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så den, der står med feltet, får svaret med det samme — og igen på den *opslåede* adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen, kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et SSRF-fund i morges. **Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes, er den samme tavshed, notifikationerne havde. Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til den dag de skal. En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den, som filen skriver den, dækker hver eneste helligdag også dagen efter sig. Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons server, og den ordinære tilstand for en anden persons server er, at den er i orden indtil en morgen hvor den ikke er. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fcd6dca852 |
fix(push): "der kommer ikke nogen" kunne ikke besvares
Release / Build and publish image (push) Failing after 10m0s
Alt om det var usynligt. Indstillingssiden sagde "til for denne enhed", så snart der fandtes et abonnement — også når hver eneste push blev afvist. Og en afvist push gik i en logline på **Debug**, som ikke skrives på en instans, der kører på standardniveau. Så en levering, der fejler lydløst, og en levering, der aldrig sker, så ens ud, og forskellen er hele diagnosen. Tre forskellige problemer havde ét udseende: browseren tilmeldte sig aldrig, tjenesten afviste den, eller der var ikke noget forfaldent endnu. **En prøveknap** under Indstillinger → Notifikationer sender én notifikation gennem den rigtige rute — ikke en genvej, så det, den beviser, er det, der faktisk skal virke — og siger hvad hver enhed gjorde ved den. Kom den ikke frem, og afviste ingen den, står der, at det så er browseren eller styresystemet, der holder den tilbage. Nu peger svaret på ét af de tre steder. Loglinjen er hævet fra Debug til Warn. Det var den ene linje, der ville have sagt hvorfor. Teksten kommer fra klienten, fordi ordbøgerne gør. En server, der bærer sin egen kopi af "det her er en prøve", bærer den på ét sprog. **Og en lækage, prøven fandt.** Go pakker en transportfejl som `*url.Error`, og dens tekst er `Post "https://fcm.googleapis.com/fcm/send/dQw4…": dial tcp: …` — hele endepunktet, som er den legitimation, push-tjenesten udstedte til den enhed. Den var på vej ud i svaret, i loglinjen og i ethvert skærmbillede af nogen af dem. Prøven var skrevet for at bevise, at kun værten blev nævnt, og det var sådan den blev fundet. Fejlen pakkes ud, og adressen skrubbes bagefter alligevel: den næste fejltype, der bærer den med, kommer ikke med en advarsel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
be581eb8ae |
fix(search): et ord inde i et ord kunne ikke findes
Release / Build and publish image (push) Failing after 9m57s
Meldt ind som "søgning har ikke det store held": en note med `gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory". Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står lige der. Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk `keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af den, der ikke kan. Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer, identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`. Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et sted i noten, hvor som helst, i hvilket som helst ord. `fold` er `title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker titlen, brødteksten og "gron" for "grøn" på én gang. Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i mine opgaver" er den næste melding, og svaret på den ville være denne linje. Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede noter er det fire megabyte og et par millisekunder, og den kører kun, når den hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en større scanning. Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene, som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille forsvundet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b87c9ea6e2 |
fix(auth): en afvist passkey sagde "forkert e-mail eller adgangskode"
Release / Build and publish image (push) Failing after 10m0s
Der er hverken en e-mail eller en adgangskode i et passkey-login. Loginet er discoverable — enheden ved selv, hvilken konto nøglen hører til, og der bliver ikke tastet noget. Alligevel fik en afvisning fladens generiske 401-tekst, og den nævner begge dele. Det sendte en person ud at lede efter en tastefejl i sin konto, der aldrig havde været der. Det, der faktisk var sket, var at instansen var flyttet fra http til https: en passkey hører til præcis den adresse, den blev lavet på — `RPOrigins` bygges af `VERDANDE_BASE_URL` med skema og alt — så et login på det ene skema mod en server, der venter det andet, kan ikke verificeres. Beskeden siger nu det, og siger også hvor man skal kigge. Serveren har hele tiden svaret "that key was not accepted"; det var oversættelsen i fladen, der gjorde den til noget andet. Aflysning bliver behandlet som før — den er ikke en fejl, og en fejl for den ville læse som om nøglen blev afvist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cab4e703f5 |
fix(sikkerhed): CalDAV lod en viewer skrive, og Web Push kunne ringe indad
Release / Build and publish image (push) Failing after 10m0s
To huller fundet ved en gennemgang efter dagens arbejde. Begge har en prøve, der er set fejle før rettelsen. CalDAV PUT afgjorde adgang på det projekt, klienten *nævnte i adressen*, og tog derefter opgavens id fra den samme adresse. Intet bandt de to sammen. En viewer på et delt projekt kunne pege på et projekt, de selv ejer — indbakken, som alle har og alle må redigere — og lægge den delte opgaves id i filnavnet. Projekt- tjekket gik godt på indbakken, og skrivningen landede på en opgave, de kun må læse. GetTask lukkede det ikke: den svarer "må denne person se det", og det må en viewer. `CanEdit` er spørgsmålet, og det er det samme, handleUpdateTask altid har stillet — API-døren har spurgt hele tiden, CalDAV-døren spurgte ikke. Opgaven skal nu også ligge, hvor adressen siger. Uden det kan en redaktør på to projekter nå det ene gennem det andets adresse; intet eskaleres af det i sig selv, men en adresse, der har lov at lyve om hvor noget bor, er sådan sagen ovenfor kom ind. Web Push blev leveret med en almindelig http.Client. Adressen kommer fra browserens abonnement — altså fra den, der er logget ind — og serveren POSTer til den på et skema, de selv vælger: sæt en påmindelse et minut ude, og jobbet går igen hvert minut. Tilmeldingen afviser en privat adresse, og den kontrol er ikke nok alene: den kører på URL'en som skrevet, og et navn, den godkender, kan svare 127.0.0.1 næste gang det slås op. Det er præcis den sætning, `internal/safedial` selv står og advarer om. Leveringen går gennem safedial nu, som tjekker den opslåede adresse ved forbindelsen og hvert viderestillingshop på samme måde. Kontrollen ved tilmelding bliver, fordi den siger nej, mens nogen kigger på skærmen. Og en hærdning: /inbound/mail har fået et loft. Det er den svagere af de to offentlige skriveveje — beaconet skriver en række for en fremmed, denne skriver ingenting uden en 256-bit adresse, man skal kende i forvejen — men adressen deles med mailservere og står på en indstillingsside, og når den først er kendt, var der intet loft på, hvad der kunne laves med den. Tallet er sat generøst: modtageren er mailserveren, én adresse der leverer det hele. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b4448b245 |
fix(settings): AI, Gmail og kalenderen delte én række og slettede hinanden
Meldt ind som "AI-informationerne gemmes ikke". De blev gemt. De blev taget bagefter. `user_settings` blev oprettet med `user_id` som hele sin primærnøgle — én række per person, uanset scope — og upserten skrev `scope` om ved konflikt. Der er tre scopes i live: `ai`, `gmail` og `calendar`. De delte den ene række, så hver skrivning slettede de to andre, og hver eneste svarede 204. Det virker begge veje: at forbinde kalenderen slettede Gmail-forbindelsen, og Gmail slettede kalenderen. Fælden var set komme to gange. 0011 gav instansens indstillinger deres egen tabel delvis af den grund og skrev advarslen ned med rene ord; 0014 flyttede postkasserne ud og skrev, at advarslen "nær var trådt i for anden gang". Tredje gang var kalenderen, som lagde et `calendar`-scope ved siden af de to andre. Lektien værd at holde fast i: en advarsel i en migration bliver læst af den, der redigerer *den* migration, og det var ingen. Formen er rettet her, så der ikke er noget tilbage at advare om — `PRIMARY KEY (user_id, scope)`, og upserten rammer den nøgle. Tabellen bygges om, fordi SQLite ikke kan ændre en primærnøgle på plads. De rækker, der findes, kopieres som de er: hver person har højst én, og den holder det scope, der vandt sidst. Det, de tidligere skrivninger tog, er overskrevet og kan ikke hentes tilbage her — efter opdateringen skal AI-udbyderen sættes op igen, og Gmail-forbindelsen tjekkes. To prøver: én mod store-laget, hvor formen var fejlen, og én gennem handlerne, hvor den blev set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e46aaa462a |
feat(projects): sektioner kan trækkes op og ned, og båndet står ikke længere skævt
Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi det er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette en sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede område på vejen. Hele overskriften er håndtaget. En sektionsoverskrift indeholder ikke noget, der selv bliver trukket, så der er ingen geste at stjæle — hvilket er grunden til, at sidebjælkens rækker skulle have et greb, og grunden til at disse ikke skal. Ikke draggable mens den omdøbes: et draggable element lader ikke markøren vælge tekst i det felt, det indeholder. Serversiden er hele listen på én gang som projekter og grupper, og af samme grund: et projekt har en håndfuld sektioner, så 0, 1, 2 … er én lille transaktion uden præcision at løbe tør for, og en liste kan ikke ende halvt anvendt. `ReorderSections` er scopet til projektet og ikke til en ejer — en sektion har ingen ejer, den hører til et projekt, og om du må rykke rundt er et spørgsmål om det projekt, som handleren allerede har stillet. Id'er fra et andet projekt springes over af WHERE-klausulen; der er en prøve på præcis det, fordi ingenting ellers kontrollerer id'erne enkeltvis. Båndet stod fire pixels skævt. Det var trukket ud med --s2 og polstret med --s3 — samme idé gjort med to forskellige tal — så det blev otte pixels bredere end listen i hver side, og teksten landede fire pixels uden for den kolonne, rækkerne holder. Til venstre var det usynligt. Til højre var det Omdøb og Slet, der stod fire pixels forbi alt over og under sig, hvilket er præcis langt nok til at ligne en fejl og ikke langt nok til at se hvorfor. En række er `.row` med `padding: var(--s2)` inde i en boks uden, så båndet har nu samme polstring og ingen margen. Board-visningen er ikke rørt: der er kolonnerne vandrette, og det er en anden geste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aadfb197b6 |
feat(notes): arkiv, flere ad gangen, og en kopiér-knap på koden
Release / Build and publish image (push) Failing after 9m55s
**Arkivet, fordi papirkurven er det forkerte redskab.** Den siger "det her var en fejl, og om tredive dage er det væk" — så alt, der er værd at beholde, kan ikke komme i den. Et arkiv på tolv hundrede importerede noter er præcis det tilfælde, hvor de fleste er værd at beholde og næsten ingen er værd at kigge på; uden et sted at lægge dem er listen hele arkivet for evigt, og at finde ugens note betyder at læse forbi et årti. En kolonne, ikke en mappe. Noterne afviste et andet hierarki i 0018, og arkivering er ikke arkivering i den forstand: det er én oplysning om, hvorvidt en note er aktuel, og den skal kunne besvares ved siden af `deleted_at`, som er den anden af samme slags. `updated_at` røres ikke — at rydde op er ikke at skrive. **Flere ad gangen.** ⌘-klik lægger til, skift-klik tager intervallet i den rækkefølge, listen faktisk står i — ikke notens egen, som er en anden, når der er sorteret eller søgt. Trækker man en note, der er markeret, følger hele markeringen med; trækker man en, der ikke er, er det den ene, for ellers ville et træk i en tilfældig række flytte halvtreds noter, man havde glemt var markeret. Ét kald frem for et pr. note: efter en import af tolv hundrede er det at vælge halvtreds det almindelige tilfælde. Hver note kontrolleres stadig for sig — et sæt er en bekvemmelighed for den, der kalder, og aldrig en vej til noget, de ikke kunne have rørt én ad gangen. En kant *og* en tone på det markerede: `--accent-sunken` er meget lys i lyst tema, og tre markerede rækker var svære at skelne fra tre, der ikke var — en dårlig ting at være i tvivl om lige inden man trykker Slet. **Kopiér-knappen spiste næsten den kode, den skulle kopiere.** Den ligger inde i `<pre>` for at kunne stå i hjørnet af blokken, og `colourCode` læste `pre.textContent` og malede resultatet tilbage — så et klik på "Kopiér" skrev "Kopieret" ind i koden, og næste gemning lagde det i filen. Nu ét sted, der siger hvad en kodeblok indeholder, brugt af både farvelægningen og oversættelsen til Markdown: var de to uenige, ville forskellen ende i noten. Prøven klikker, læser udklipsholderen og læser filen bagefter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8aad7c13b0 |
perf(notes): listen sendte hele noter for at vise halvfems tegn af dem
Release / Build and publish image (push) Failing after 9m55s
Chrome frøs på notesiden, og der var to grunde. **Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i hukommelsen hver gang siden blev åbnet. Nu 763 KB. Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt, der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten kan se efter — og den gør det og henter hele noten, når den åbner en. Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der lige havde haft en titel. **Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme ud af trit med sig selv. **Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal fundne. Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3a02bc8304 |
fix(notes): søgningen svarede med det forkerte først, og et træk gjorde ingenting
Release / Build and publish image (push) Failing after 9m55s
**Søgningen sorterede efter dato.** Usynligt med ti noter og ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så `updated_at` siger ingenting, og loftet skar listen af ved halvtreds vilkårlige rækker. At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det fra brugerens plads er præcis dét. Nu bm25, med titlen vægtet ti gange kroppen: et ord i titlen er, hvad noten *handler om*, mens det samme ord én gang i en lang krop er en henvisning. `fold` vejer som kroppen, ellers ville et dansk match slå et præcist. Sammenføjet frem for `rowid IN (…)`, fordi rangeringen hører til matchet, og en underforespørgsel smider den væk. Fladen sorterer ikke om, når der er søgt — det ville kaste netop det væk, der gør en søgning i tolv hundrede noter brugbar. **Et træk af en note hen på et projekt gjorde ingenting.** `ondragover` tog imod både opgaver og noter, så rækken lyste op — og `ondrop` sendte kun opgaver videre, mens noten faldt ned i grenen, der omarrangerer projekter, hvor den ikke betyder noget. Den værst mulige form for i stykker: den så ud, som om den ville virke, helt indtil man slap. Det står i overleveringen som "virker i koden, men ikke fastholdt i prøver, fordi prøven blev upålidelig". Den er skrevet igen, og den ser på resultatet — ligger noten i projektet bagefter — frem for på om rækken blev fremhævet, hvilket var netop det, den gamle kunne se og som blev ved med at være sandt, mens funktionen var i stykker. **Og datoen i listen var altid `updated_at`.** 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. Rækken viser nu den dato, listen er sorteret efter, og har begge i sit værktøjstip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4aa8bd6a40 |
fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
Release / Build and publish image (push) Failing after 9m54s
**H2 — SSRF gennem AI-udbyderens adresse.** `base_url` blev gemt uden kontrol og hentet med en almindelig klient, og udbyderens svar blev givet tilbage ordret. Det var en fungerende skanner af det interne net for enhver bruger: peg den på en vært, læs de første tre hundrede bytes af det, der svarede. På en homelab er det det meste af, hvad der er værd at nå. Nu gennem `safedial`, og svarets krop går i loggen frem for i svaret. Operatøren har stadig brug for beskeden — for en lokal model er den som regel selve problemet — så den er der, bare kun for den, der kører instansen. **M2 — Web Push havde tre.** Endepunktet blev gemt ukontrolleret og POSTet til på en tidsplan, den, der ringer, selv vælger: sæt en påmindelse et minut ude, og serveren henter adressen, hvert minut. Upserten flyttede desuden ejerskabet med `user_id = excluded.user_id`, så enhver, der kendte en andens endepunkt, kunne overtage det — offerets telefon ville få angriberens beskeder og ingen af sine egne. Og `DeletePushSubscription` havde ingen ejer-kontrol. **H3 — tre veje uden loft.** FTS-udtrykket byggede omkring halvtreds bytes pr. ord uden grænse; en søgning på en kvart megabyte blev til to megabyte MATCH-udtryk og seksogtyve sekunder inde i SQLite, mens den holdt på én af kun fire til otte forbindelser. Nogle få af dem samtidig, og databasen er utilgængelig for alle på instansen, logins inklusive. `filterquery.Parse` er kvadratisk — halvtreds tusind led er syv sekunders CPU og seksogfyrre gigabyte allokering i én forespørgsel — og havde hverken loft på antal eller på dybde. Og noteimporten læste hver fil i arkivet uden grænse: 64 MB komprimeret kan blive til seksogtres gigabyte skrevet ned på den disk, databasen ligger på. **M1 — `comment_id` blev taget på tro.** Rettighedskontrollen så på opgaven i adressen, mens filen blev hængt på den kommentar, formularen bad om. En viewer på et delt projekt kan liste dets kommentarer, så hun kunne læse et id og lægge en fil under en andens kommentar i et projekt, hun kun må læse. **M4 — fire hemmeligheder lå i klartekst i hver sikkerhedskopi.** TOTP-koden og AI-nøglen forsegles nu; begge konverterer sig selv næste gang de skrives. Kalenderfeedets og mailadressens tokens gør ikke — de slås op på deres præcise værdi og vises i fladen mere end én gang, hvilket en hash ikke kan. Det står nu skrevet i `SECURITY.md`, som påstod det modsatte. At rette det betyder, at adressen kun kan vises én gang, og det er en beslutning om, hvordan funktionen virker. Og en prøve på, at politikken overhovedet bliver sendt. Det var den, der gjorde XSS-en i nat til indsat HTML frem for kørt script, og der var ingen prøve, der rørte den — en foranstaltning, ingen prøve rører, kan forsvinde i en oprydning, uden at nogen opdager det. Målt på script-src alene: `style-src 'unsafe-inline'` står der med vilje, og en indsat stil kan gøre en side grim, ikke køre kode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ce3fc466b2 |
fix(auth): andenfaktoren kunne gættes igennem, og et token kunne tilmelde sin egen
To huller fra sikkerhedsgennemgangen, og de er samme historie fortalt to gange: den ene kontrol, der findes til når adgangskoden allerede er tabt, kunne omgås af netop den, der havde adgangskoden. **H1 — spanden blev fyldt af adgangskoden alene.** `handleLogin` nulstillede ratebegrænseren ved hver korrekt adgangskode, *før* den så på, om der var en andenfaktor. Det gav én, der havde kodeordet, ubegrænsede portioner på ti gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på koden, log ind igen. Med tre gyldige koder i ethvert øjeblik og tyve forespørgsler i sekundet er det lige odds inden for en eftermiddag. Spanden ryddes nu først, når login er *færdigt*, og der er kommet en spand mere: koder tælles pr. konto og ikke kun pr. adresse. Adressespanden er slået af enhver med mere end én adresse, hvilket er det almindelige — en telefon og en bærbar er allerede to. Det, der gør en sekscifret kode sikker, er at gæt er en knap ressource, og knapheden skal tælles imod det, der bliver angrebet. Den ventende session får lov at leve. At afslutte den ved første forkerte ciffer blev overvejet og valgt fra: det ville koste en, der taster ét tal forkert, sin adgangskode igen, og det køber næsten ingenting oven i loftet pr. konto. **M3 — et lækket token kunne tilmelde sin egen andenfaktor.** `/auth/totp/setup` lå kun bag `requireAuth` og spurgte om ingenting, mens `disable` altid har krævet adgangskoden: døren åbnede med et token og lukkede kun med et kodeord. Et stjålet token kunne hente hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den eneste kopi af gendannelseskoderne. Ejerens næste login ville stoppe ved en kode, de ikke havde, og de kunne ikke slå den fra uden at logge ind først. Et tyveri bliver til et lejemål — den sætning står allerede i filen ved siden af om passkeys, og det er den samme regel. Nu bag `requireSession` sammen med passkeys og sessionerne, og `setup` spørger om adgangskoden ligesom `disable`. Symmetrien er pointen: at slå den til er mindst lige så indgribende som at slå den fra. Og en note kan oprettes inde fra et projekt. At ligge i et projekt *er* måden en note deles på, så det er én handling frem for to — man skulle før oprette noten under Noter og bagefter finde projektet frem i "Del i". Ruden findes nu, også før der er en eneste note, for ellers er der ingen knap dér, hvor man opdager at der skal skrives noget ned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bdde9cc6a5 |
merge: Google Kalender som menupunkt, med Verdandes egen kalender lagt over
Release / Build and publish image (push) Failing after 9m58s
Læs bredt, skriv snævert — decision 5 i planen, og kun den første halvdel: kalenderen læses, og der skrives ingenting tilbage. **OAuth-blokeringen er reel og blev ikke gået udenom.** En klient registreret som *Internal* tager kun konti i sit eget workspace — også til kalendere, ikke kun Gmail. Andreas' private konto bliver afvist med `org_internal`, og det er klienten, der virker efter hensigten, ikke en fejl. Fladen siger det nu med ord, man kan handle på, frem for at vise Googles egen skærm, der ikke gør. Vejen udenom, som ikke er bygget: kalenderens egen hemmelige ICS-adresse. Ingen OAuth, virker for enhver konto, og `internal/ics` findes allerede. Det er det næste stykke, og det eneste, der låser den private konto op. `internal/google` er flyttet ud af `internal/gmail`, fordi de to er det samme flow, der er uenige om én streng. Egen callback-adresse frem for at dele Gmails: én callback, der finder ud af hvilken token-butik den skal skrive i, går galt ved at overskrive en Gmail-forbindelse, der virkede. Egne tabeller frem for `mailboxes`. `trigger_kind`, `seen` og `last_uid` svarer alle på "hvad gør en besked til en opgave", og ingenting her bliver til en opgave; én konto har mange kalendere, og `mailboxes` har ikke plads til en en-til-mange. Afgørende i praksis: `mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en — en tredje værdi koster den tabelombygning, som migration 0021 skulle igennem. Begivenheder erstattes pr. kalender pr. vindue frem for at flettes med et sync-token: et vindue, der slettes og skrives forfra, kan ikke komme ud af trit, og et sync-token er endnu en ting, der skal være rigtig for altid, for at gitteret ikke stille viser noget forkert. `CalendarView` er udvidet, ikke kopieret. En begivenhed er et fyldt bånd, hvor en opgave er et kort med en farvet kant, så de to læses forskelligt før et ord er læst — og den kan ikke trækkes og er aldrig en knap: der er ikke noget at gøre færdigt ved en andens møde. To værn på det, der kommer fra Google: farven skal matche et hex-mønster, før den må stå i en style, og linket bliver kun til et href, hvis det begynder med https. Konflikten i jobs.go var to nye jobs skrevet på samme linje. Begge skal med. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3991ec9445 |
feat(notes): et billede kan indsættes og trækkes ind i en note
Rapporteret fra brug: man kunne ikke indsætte billeder eller trække filer ind i noter. Det passede — importen kunne tage billeder med, og intet andet kunne. Indsætningen tog kun `text/plain` og smed resten væk, og der var slet ingen håndtering af et træk. At indsætte et skærmbillede er det mest almindelige, der findes i et noteprogram, og det gjorde ingenting overhovedet. Der manglede også et sted at lægge filen. Vedhæftninger på en note fandtes i databasen, siden migration 0021, men kun importen kunne skrive dem; der var ingen rute. Nu er der en, med editor-rettigheder på noten og samme svar for "findes ikke" og "ikke din", så et id ikke kan afprøves. Skrevet ind som `` og ikke som en data-URL: en note er Markdown i en database, og et fotografi fra en telefon som base64 midt i teksten ville gøre noten til fire megabyte tekst, der skal læses hver gang listen tegnes. `execCommand` frem for at bygge noden selv, fordi det er dét, der lægger handlingen i browserens egen fortryd-historik — ⌘Z tager billedet ud igen, ligesom det tager et ord ud. `preventDefault` på både dragover og drop. Uden den første kommer der intet drop; uden den anden åbner browseren filen i stedet, og man mister det, man var i gang med at skrive, til en fane med et billede i. Kun filer: et træk inde fra appen selv bærer vores egen nyttelast og skal falde igennem til den, der lyttede. Prøven går begge veje ind, ser at billederne bliver hentet og tegnet, og læser filen bagefter for at sikre, at der står to bilagsadresser og ingen data-URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
241c5ed37d |
fix(mail): den samme mail blev en opgave igen og igen
Release / Build and publish image (push) Failing after 9m58s
Rapporteret fra drift med et skærmbillede af den samme mail seks gange i træk. **IMAP: flaget kommer af, når mailen er blevet en opgave.** Markøren var et uid — husk det højeste, begynd over det næste gang — og den holdt kun, hvis uids var, hvad serveren sagde. Den brød på et fetch-svar uden uid, på en UIDVALIDITY, der skiftede under en genopbygget postkasse, og på to postkasse-rækker, der pegede samme sted og hver især førte deres eget regnskab. Alle tre gav det samme symptom. Så regnskabet flytter derhen, hvor spørgsmålet bliver stillet. "Flaget" betyder "lav det her om til en opgave"; når den er en opgave, har flaget gjort sit arbejde og er ikke sandt længere. Så er der ingenting at huske, og dermed ingenting, der kan komme ud af trit — en anden synkronisering, en anden postkasse-række eller en database gendannet fra en kopi finder alle det samme svar, fordi svaret ligger i postkassen. Flaget tages af *efter* opgaven er skrevet. Et flag fjernet fra en mail, hvis opgave aldrig blev gemt, er en mail, ingen ser igen. **Gmail havde den samme fejl af en anden grund.** Listen over allerede behandlede beskeder blev bygget om ved hver kørsel af *kun de id'er, Gmail svarede med den gang* — for at den ikke skulle vokse uendeligt. Men forespørgslen er begrænset, så på en konto med mere stjernemarkeret post, end der er plads til i ét svar, glemte hver kørsel det, den forrige havde husket. De kom tilbage som ubehandlede og blev opgaver igen. Det var ikke en grænse, det var en måde at glemme på. Listen har nu et loft på antal i stedet, nyeste først. Gmail kan ikke få taget stjernen af: scopet er `gmail.readonly`, med vilje, fordi det er dét, der gør samtykkeskærmen til noget, et menneske kan sige ja til. Derfor skal regnskabet blive på vores side dér — men det skal så være et regnskab, der kan holde. Prøven kører hele vejen mod en rigtig IMAP-server: find de flagede, tag flaget af, og spørg så forfra med markøren sat til nul — altså det værst tænkelige tilfælde, hvor alt, vi selv havde husket, er væk. Der kommer ingenting tilbage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
987d4edfce |
feat(calendar): en læst Google-kalender, med sit eget endpoint og sin egen sweep
Samme registrering som Gmail, et andet scope og en anden redirect-URI. Google udsteder en refresh-token pr. godkendelse og holder op til hundrede i live pr. konto, hver med sine egne scopes — så en forbundet kalender rører ikke en Gmail, der virker, og at afbryde den ene tager ikke den anden med. Callbacken har sin egen sti. Én callback, der ud fra gemt tilstand afgjorde, hvilket af de to flows der kom tilbage, ville lægge valget af *hvilket token-lager der skrives* bag et opslag — og går det galt, går det galt ved at overskrive en virkende Gmail-forbindelse med kalender-tokens. Begivenhederne caches i et vindue på 90 dage tilbage og et år frem. Et månedsgitter bladrer, og et kald til Google pr. piletast er en kalender, der er langsommere end den kalender, den viser. Vinduet har en kant, og endpointet siger hvor den er: et tomt svar uden forklaring er en kalender, der lydløst påstår, at en dag er fri. |