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
This commit is contained in:
kristianwind
2026-09-07 22:28:58 +02:00
co-authored by Claude Opus 5
parent ff75302cf8
commit 916a0c0224
10 changed files with 749 additions and 38 deletions
+111 -15
View File
@@ -2875,13 +2875,13 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Prisliste for foraaret');
await page.keyboard.type('Kristians prisblad');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Aftale om levering\n');
await page.keyboard.type('[[Prisliste for foraaret]]');
await page.keyboard.type('Faelles aftale om afhentning\n');
await page.keyboard.type('[[Kristians prisblad]]');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Gæsten har en note af sin egen, som hun kan pege på.
@@ -2891,7 +2891,7 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await gæst.keyboard.type('Linneas egne maal');
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
await page.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
@@ -2901,19 +2901,19 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await expect(row).toBeVisible();
await row.locator('select').selectOption('editor');
// Hun skriver sit eget link ind i den delte note.
// Hun skriver sit eget link ind i den delte note — og et, der ikke rammer noget.
await gæst.goto(noterURL);
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const body = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(body).toContainText('Prisliste for foraaret');
await expect(body).toContainText('Kristians prisblad');
await body.click();
await gæst.keyboard.press('Control+End');
await gæst.keyboard.type('\n[[Linneas egne maal]]');
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
await body.blur();
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
await gæst.goto(noterURL);
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const delt = gæst.getByRole('textbox', { name: 'Notens tekst' });
// Hendes eget link fører derhen, det siger.
@@ -2922,22 +2922,118 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
'Linneas egne maal'
);
// Ejerens link peger på en note, hun ikke har fået. Den må ikke lande et
// tilfældigt sted — og den må sige hvorfor.
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
// Og det link, der ikke rammer nogen note, må ikke lande et tilfældigt sted. Det
// er dér, det gamle fald tilbage på "første søgeresultat" åbnede noten, man
// allerede stod i — hvilket ser ud som ingenting.
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const igen = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(igen).toContainText('Prisliste for foraaret');
await igen.locator('a.notelink', { hasText: 'Prisliste for foraaret' }).click();
await igen.locator('a.notelink', { hasText: 'Noget der aldrig blev skrevet' }).click();
await expect(gæst.locator('.toast')).toContainText('ikke delt med dig');
// Stadig i den delte note: linket flyttede hende ingen steder hen.
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Aftale om levering'
'Faelles aftale om afhentning'
);
await gæstCtx.close();
expect(trouble).toEqual([]);
});
/**
* Delingen tager de noter med, noten peger på.
*
* Den anden halvdel af den melding: linkene i en delt note skal ikke bare sige
* hvorfor de ikke kan følges — de skal kunne følges. Deler man en note, deler man
* det, den *siger*, og et link er en del af det.
*/
test('en delt note tager de noter med, den peger på', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('med@example.dk');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gæst = await ctx.newPage();
await gæst.goto(link);
await gæst.getByLabel('Navn', { exact: true }).fill('Mikkel');
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await gæst.getByRole('button', { name: 'Opret konto' }).click();
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
const noterURL = new URL('/noter', link).href;
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
// Tre noter: en kæde på to, og en der ikke hænger sammen med noget.
for (const text of [
'Leveringstider for pakker',
'Prisliste i kroner\nog [[Leveringstider for pakker]]',
'Bankoplysninger som ingen skal se',
'Aftale om levering\nse [[Prisliste i kroner]]'
]) {
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type(text);
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
}
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await share.locator('.addshare select').first().selectOption({ label: 'Mikkel' });
await share.getByRole('button', { name: 'Tilføj' }).click();
// Sagt, ikke stiltiende: begge noter fulgte med, og panelet navngiver dem.
await expect(page.locator('.toast')).toContainText('de 2 noter');
await expect(share.locator('.follows')).toContainText('Prisliste i kroner');
await expect(share.locator('.follows')).toContainText('Leveringstider for pakker');
// Og hos ham: hele kæden kan læses, og linkene fører derhen.
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await gæst
.getByRole('textbox', { name: 'Notens tekst' })
.locator('a.notelink', { hasText: 'Prisliste i kroner' })
.click();
const hos = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(hos).toContainText('Prisliste i kroner');
await hos.locator('a.notelink', { hasText: 'Leveringstider for pakker' }).click();
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Leveringstider for pakker'
);
// Men kun det, noten peger på. Resten er stadig ejerens alene.
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Bankoplysninger/ })
).toHaveCount(0);
// Den fulgte adgang står som det, den er, i panelet under den note, den ramte.
// `button.row`, ikke bare knappen ved navn: panelet står åbent og har sin egen
// knap med den samme titel på.
await page.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ }).click();
await page.locator('.sharewrap > button').click();
await expect(page.locator('.sharepanel .sharelist li').filter({ hasText: 'Mikkel' })).toContainText(
'følger med'
);
// Taget tilbage følger den med tilbage.
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
await page
.locator('.sharepanel .sharelist li')
.filter({ hasText: 'Mikkel' })
.getByRole('button', { name: 'Fjern adgang' })
.click();
await gæst.goto(noterURL);
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ })
).toHaveCount(0);
await ctx.close();
expect(trouble).toEqual([]);
});
/**
* `</>` er kode, ikke kilde.
*