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
+3
View File
@@ -34,6 +34,9 @@ export const da = {
'notes.shareRemove': 'Fjern adgang',
'notes.shareNobody': 'Ikke delt med nogen endnu.',
'notes.sharedToast': 'Delt med {name}.',
'notes.sharedWithFollowers': 'Delt med {name} — og de {count} noter, den peger på.',
'notes.shareFollows': 'Følger med:',
'notes.shareVia': 'følger med “{note}”',
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
'notes.save': 'Gem',
+3
View File
@@ -30,6 +30,9 @@ export const en = {
'notes.shareRemove': 'Remove access',
'notes.shareNobody': 'Not shared with anyone yet.',
'notes.sharedToast': 'Shared with {name}.',
'notes.sharedWithFollowers': 'Shared with {name} — along with the {count} notes it points at.',
'notes.shareFollows': 'Comes along:',
'notes.shareVia': 'came along with “{note}”',
'notes.unsharedPerson': '{name} can no longer see the note.',
'notes.shareOwnerOnly': 'Only the owner can share the note.',
'notes.save': 'Save',
+87 -15
View File
@@ -192,6 +192,10 @@
// hentes kun for en note, man selv har lavet.
let shares = $state([]);
let shareCandidates = $state([]);
// De noter, denne note tager med sig, når den deles. Ejeren skal kunne se dem:
// en deling, der stille tager tre noter mere med, er rigtig og alligevel ikke
// til at overskue.
let shareFollows = $state([]);
let sharePick = $state('');
let shareRole = $state('viewer');
let shareBusy = $state(false);
@@ -223,6 +227,7 @@
// under den nye.
shares = [];
shareCandidates = [];
shareFollows = [];
sharePick = '';
if (!id || selected.created_by !== app.user?.id) return;
let alive = true;
@@ -232,6 +237,7 @@
if (!alive || selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
})
.catch(() => {});
return () => {
@@ -246,10 +252,20 @@
const person = shareCandidates.find((p) => p.id === sharePick);
shareBusy = true;
try {
await api.shareNote(selected.id, sharePick, shareRole);
const r = await api.shareNote(selected.id, sharePick, shareRole);
shares = [...shares, { user: person, role: shareRole }].sort((a, b) => byName(a.user, b.user));
shareCandidates = shareCandidates.filter((p) => p.id !== sharePick);
app.toast(t('notes.sharedToast', { name: person?.name ?? '' }));
shareFollows = r?.follows ?? shareFollows;
// Sagt frem for stiltiende. Delingen tog flere noter med, og den, der
// delte, skal vide hvor mange, uden at skulle læse panelet efter.
app.toast(
shareFollows.length
? t('notes.sharedWithFollowers', {
name: person?.name ?? '',
count: String(shareFollows.length)
})
: t('notes.sharedToast', { name: person?.name ?? '' })
);
sharePick = '';
} catch (e) {
app.toast(humanMessage(e));
@@ -1024,25 +1040,49 @@
></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
<!-- En adgang, der er fulgt med et link, er ikke valgt her og
kan ikke tages tilbage her: den følger den note, der peger
på denne. Rollen og krydset ville begge love noget, det
næste opslag rullede tilbage — så de står ikke. -->
{#if sh.linked}
<span class="viafollow"
>{t('notes.shareVia', { note: sh.via ?? '' })}</span
>
{:else}
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareFollows.length}
<!-- Hvad delingen tager med sig. Et link i en delt note skal føre
samme sted hen for begge, og det kræver, at det, den peger på,
også er delt — men så skal det stå her, hvor den, der deler,
kan se det. -->
<p class="follows">
{t('notes.shareFollows')}
{#each shareFollows as f, i (f.note_id)}<button
class="followlink"
onclick={() => openLink({ kind: 'note', target_id: f.title })}
>{f.title}</button
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
</p>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
@@ -1910,6 +1950,38 @@
background: var(--surface-raised);
}
/* Adgang, der er fulgt med et link. Sat i stedet for rollen og krydset, altså
yderst til højre, hvor øjet allerede leder efter "hvad kan denne række". */
.sharelist .viafollow {
margin-left: auto;
color: var(--ink-faint);
font-size: var(--text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hvad delingen tager med sig. Under listen af folk, fordi det gælder dem alle
— ikke én linje pr. person, som ville sige det samme tre gange. */
.follows {
margin: var(--s1) 0 0;
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: 1.5;
}
.followlink {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.followlink:hover {
text-decoration: underline;
}
.addshare {
display: flex;
align-items: center;