Files
oikos/web/src/lib/components/knowledge/WikiCleanup.svelte
dtoro 89312a9ce4
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(web): redesign Knowledge as an editable wiki
Replaces the read-only stats dashboard with a three-pane wiki: a
navigator tree (group by folder/type/tag/entity), a reader/editor with
bare-slug auto-linking and revision history + diff, and a context rail
for backlinks and related notes. Adds a Cleanup mode for the drift
tools (duplicates, tag manager, orphans, trash) and a Cmd+K quick-open.

Also:
- Adds a real landing view (hero count, KPI row, Nomos-share meter,
  recently-updated, busiest tags) in place of the old "Select a note"
  empty state, and extends the design pass across the tree/reader/rail
  (kind icons instead of repeated text badges, accent-bar selection,
  constrained prose measure).
- Guards every note-selection path behind a confirm when there's an
  unsaved edit in progress, so switching notes can no longer silently
  discard a draft.
- Extracts the markdown-rendering CSS duplicated across ChatThread,
  EntityDetailContent, and the new WikiReader into a shared
  .markdown-body class in app.css, with ChatThread keeping only its
  decorative deltas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 22:50:54 +02:00

571 lines
22 KiB
Svelte

<script lang="ts">
// Maintenance view for the knowledge base's own drift — duplicates, tag
// casing splits, orphaned notes, and the trash. Surfaced as its own mode
// rather than folded into the main three-pane view because none of this
// is "browse a note," it's "audit the collection," and mixing the two
// would clutter the read/edit flow with tools most visits don't need.
//
// Every action here (merge, rename, restore) is deliberately one click
// away from a review step, never automatic — see fetchKnowledgeDuplicates'
// own doc comment on why title-similarity clustering can't be trusted as
// a verdict (the five "Lifecycle: <verb> a node" runbooks cluster despite
// being genuinely distinct documents).
import {
fetchKnowledgeDuplicates,
fetchKnowledgeTags,
fetchKnowledgeOrphans,
fetchKnowledgeTrash,
renameKnowledgeTag,
mergeKnowledge,
restoreKnowledge,
KnowledgeApiError,
type KnowledgeDuplicateCluster,
type KnowledgeTag,
type KnowledgeOrphan,
type KnowledgeTrashItem
} from '$lib/api'
import * as Tabs from '$lib/components/ui/tabs'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import Spinner from '$lib/components/Spinner.svelte'
import { toast } from 'svelte-sonner'
import { kindMeta } from './kinds'
import { relativeTime } from '$lib/utils'
import CopyIcon from '@lucide/svelte/icons/copy'
import TagIcon from '@lucide/svelte/icons/tag'
import GhostIcon from '@lucide/svelte/icons/ghost'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import CheckIcon from '@lucide/svelte/icons/check'
let {
onSelect,
onChanged
}: {
onSelect: (slug: string) => void
// Fired after any mutation (merge, tag rename, restore) so the parent's
// note list — which this view reads a filtered copy of, indirectly —
// stays in sync.
onChanged: () => void
} = $props()
let tab = $state<'duplicates' | 'tags' | 'orphans' | 'trash'>('duplicates')
// Shared across every loader/action below: the read helpers in api.ts now
// throw KnowledgeApiError on a failed request instead of quietly returning
// an empty list, so a real outage can't be mistaken for "nothing to
// clean up" — see api.ts's comment on listKnowledge for the same fix
// applied to the main note list.
function errMsg(e: unknown): string {
return e instanceof KnowledgeApiError ? e.message : 'Request failed.'
}
// ─── Duplicates ───────────────────────────────────────────────────────
let clusters = $state<KnowledgeDuplicateCluster[] | null>(null)
let duplicatesError = $state('')
// Per cluster (indexed by the cluster's first member slug — stable across
// a reload since clusters are keyed by content, not array position):
// which slug is the merge target and which sources are checked.
let mergeTarget = $state<Record<string, string>>({})
let mergeSources = $state<Record<string, Set<string>>>({})
let merging = $state<string | null>(null)
let mergeError = $state('')
async function loadDuplicates(): Promise<void> {
clusters = null
duplicatesError = ''
try {
const result = await fetchKnowledgeDuplicates()
clusters = result
const targets: Record<string, string> = {}
const sources: Record<string, Set<string>> = {}
for (const c of result) {
const key = c.members[0].slug
targets[key] = c.members[0].slug // newest first — see the Go handler's sort
sources[key] = new Set(c.members.slice(1).map((m) => m.slug))
}
mergeTarget = targets
mergeSources = sources
} catch (e) {
duplicatesError = errMsg(e)
clusters = [] // stop the spinner — the error message above explains the empty state
}
}
// A merge target switch leaves the PREVIOUS target unchecked (it's not in
// `sources` since it used to be excluded as "the target"), so recompute
// the whole source set relative to the new target rather than leaving it
// stale — otherwise the old target silently drops out of the merge
// instead of folding in like every other member.
function setMergeTarget(clusterKey: string, newTarget: string, allSlugs: string[]): void {
mergeTarget = { ...mergeTarget, [clusterKey]: newTarget }
mergeSources = {
...mergeSources,
[clusterKey]: new Set(allSlugs.filter((s) => s !== newTarget))
}
}
function toggleSource(clusterKey: string, slug: string): void {
const set = new Set(mergeSources[clusterKey])
if (set.has(slug)) set.delete(slug)
else set.add(slug)
mergeSources = { ...mergeSources, [clusterKey]: set }
}
async function doMerge(clusterKey: string): Promise<void> {
const target = mergeTarget[clusterKey]
const sources = [...(mergeSources[clusterKey] ?? [])]
if (!target || sources.length === 0) return
merging = clusterKey
mergeError = ''
try {
const result = await mergeKnowledge(target, sources)
toast.success(`Merged ${result.merged.length} note${result.merged.length === 1 ? '' : 's'}`)
onChanged()
await loadDuplicates()
} catch (e) {
mergeError = errMsg(e)
} finally {
merging = null
}
}
// ─── Tags ─────────────────────────────────────────────────────────────
let tags = $state<KnowledgeTag[] | null>(null)
let tagsError = $state('')
let renaming = $state<string | null>(null)
let renameDraft = $state('')
let renameBusy = $state(false)
async function loadTags(): Promise<void> {
tags = null
tagsError = ''
try {
tags = await fetchKnowledgeTags()
} catch (e) {
tagsError = errMsg(e)
tags = []
}
}
async function normalize(t: KnowledgeTag): Promise<void> {
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, t.tag)
toast.success(`Normalized "${t.tag}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
} catch (e) {
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
function startRename(t: KnowledgeTag): void {
renaming = t.tag
renameDraft = t.tag
}
async function confirmRename(t: KnowledgeTag): Promise<void> {
const to = renameDraft.trim().toLowerCase()
if (!to || to === t.tag) {
renaming = null
return
}
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, to)
toast.success(`Renamed "${t.tag}" to "${to}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
renaming = null
} catch (e) {
// Leave the rename input open on failure — the operator's typed value
// (and their reason for changing it) shouldn't vanish along with the
// error, forcing them to retype it to try again.
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
// ─── Orphans ──────────────────────────────────────────────────────────
let orphans = $state<KnowledgeOrphan[] | null>(null)
let orphanCounts = $state<Record<string, number>>({})
let orphansError = $state('')
async function loadOrphans(): Promise<void> {
orphans = null
orphansError = ''
try {
const result = await fetchKnowledgeOrphans()
orphans = result.items
orphanCounts = result.counts
} catch (e) {
orphansError = errMsg(e)
orphans = []
}
}
// ─── Trash ────────────────────────────────────────────────────────────
let trash = $state<KnowledgeTrashItem[] | null>(null)
let trashError = $state('')
let restoring = $state<string | null>(null)
async function loadTrash(): Promise<void> {
trash = null
trashError = ''
try {
trash = await fetchKnowledgeTrash()
} catch (e) {
trashError = errMsg(e)
trash = []
}
}
async function doRestore(slug: string): Promise<void> {
restoring = slug
try {
await restoreKnowledge(slug)
toast.success('Note restored')
onChanged()
await loadTrash()
} catch (e) {
toast.error(errMsg(e))
} finally {
restoring = null
}
}
function activate(t: typeof tab): void {
tab = t
if (t === 'duplicates' && clusters === null) loadDuplicates()
else if (t === 'tags' && tags === null) loadTags()
else if (t === 'orphans' && orphans === null) loadOrphans()
else if (t === 'trash' && trash === null) loadTrash()
}
// Initial tab's data.
loadDuplicates()
</script>
<div class="flex h-full flex-col gap-2">
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-8 w-fit">
<Tabs.Trigger value="duplicates" class="gap-1 text-xs" onclick={() => activate('duplicates')}>
<CopyIcon class="size-3.5" /> Duplicates
</Tabs.Trigger>
<Tabs.Trigger value="tags" class="gap-1 text-xs" onclick={() => activate('tags')}>
<TagIcon class="size-3.5" /> Tags
</Tabs.Trigger>
<Tabs.Trigger value="orphans" class="gap-1 text-xs" onclick={() => activate('orphans')}>
<GhostIcon class="size-3.5" /> Orphans
</Tabs.Trigger>
<Tabs.Trigger value="trash" class="gap-1 text-xs" onclick={() => activate('trash')}>
<Trash2Icon class="size-3.5" /> Trash
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="duplicates" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes with near-identical titles, grouped for review — not a verdict. Pick a target and the
sources to fold into it; sources are soft-deleted afterward and stay recoverable from Trash.
</p>
{#if mergeError}
<p
class="mb-2 rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{mergeError}
</p>
{/if}
{#if duplicatesError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{duplicatesError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadDuplicates}
>Retry</Button
>
</p>
{/if}
{#if clusters === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else if clusters.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
{:else}
<div class="flex flex-col gap-3">
{#each clusters as c (c.members[0].slug)}
{@const key = c.members[0].slug}
{@const sourceCount = mergeSources[key]?.size ?? 0}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<div class="flex min-w-0 items-center gap-2 text-xs">
<span class="font-medium">{c.members.length} similar notes</span>
<!-- Similarity as a meter rather than only a number: it's a
ratio, and the bar makes a 93% pileup visibly different
from a borderline 61% at a glance down a long list. -->
<span
class="hidden h-1 w-12 shrink-0 overflow-hidden rounded-full bg-primary/15 sm:block"
title="{(c.top_similarity * 100).toFixed(0)}% title similarity"
>
<span
class="block h-full rounded-full bg-primary"
style="width: {c.top_similarity * 100}%"
></span>
</span>
<span class="shrink-0 tabular-nums text-muted-foreground"
>{(c.top_similarity * 100).toFixed(0)}%</span
>
</div>
<Button
size="sm"
class="h-6 shrink-0 gap-1 text-xs"
disabled={merging === key || sourceCount === 0}
onclick={() => doMerge(key)}
>
{merging === key ? 'Merging…' : `Merge ${sourceCount} into target`}
</Button>
</div>
<!-- Two bare inputs per row read as "…what do these do?", so
name them once per cluster. An inline legend rather than
column headers: the controls are 14px wide and the words
are not, so headers sized to the columns just collide. -->
<p class="flex items-center gap-3 px-2.5 pt-2 text-[10px] text-muted-foreground">
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-full ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> keep as target
</span>
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-[2px] ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> fold into it
</span>
</p>
<div class="flex flex-col p-1">
{#each c.members as m (m.slug)}
{@const isTarget = mergeTarget[key] === m.slug}
{@const Icon = kindMeta(m.kind).icon}
<label
class="flex items-center gap-2 rounded px-1.5 py-1 text-xs {isTarget
? 'bg-primary/5'
: 'hover:bg-muted/40'}"
>
<input
type="radio"
class="size-3.5 shrink-0 accent-[var(--primary)]"
name="target-{key}"
aria-label="Keep &quot;{m.title}&quot; as the merge target"
checked={isTarget}
onchange={() =>
setMergeTarget(
key,
m.slug,
c.members.map((mm) => mm.slug)
)}
/>
<input
type="checkbox"
class="size-3.5 shrink-0 accent-[var(--primary)]"
aria-label="Fold &quot;{m.title}&quot; into the target"
disabled={isTarget}
checked={!isTarget && (mergeSources[key]?.has(m.slug) ?? false)}
onchange={() => toggleSource(key, m.slug)}
/>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<button
type="button"
class="min-w-0 flex-1 truncate text-left hover:underline {isTarget
? 'font-medium text-primary'
: ''}"
onclick={() => onSelect(m.slug)}
>
{m.title}
</button>
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(m.updated_at)}</span
>
{#if isTarget}
<Badge
variant="outline"
class="shrink-0 border-primary/40 text-[9px] text-primary">target</Badge
>
{/if}
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="tags" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if tagsError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{tagsError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTags}>Retry</Button>
</p>
{/if}
{#if tags === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else}
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each tags as t (t.tag)}
<tr class="border-b border-border/50">
<td class="py-1 pr-2">
{#if renaming === t.tag}
<div class="flex items-center gap-1">
<Input bind:value={renameDraft} class="h-6 w-32 text-xs" />
<Button
size="sm"
class="h-6 px-1.5"
disabled={renameBusy}
onclick={() => confirmRename(t)}
>
<CheckIcon class="size-3" />
</Button>
</div>
{:else}
<button
type="button"
class="font-mono hover:underline"
onclick={() => startRename(t)}
>
{t.tag}
</button>
{/if}
</td>
<!-- tabular-nums here (unlike the overview's standalone
figures): these are a column that has to line up. -->
<td class="w-8 py-1 pr-1 text-right tabular-nums">{t.uses}</td>
<td class="w-24 py-1 pr-3">
<!-- Magnitude, so: one hue, length-encoded, scaled to the
most-used tag. Recessive by design — it's a reading aid
down the column, not the subject of the table. -->
<span class="block h-1 overflow-hidden rounded-full bg-primary/10">
<span
class="block h-full rounded-full bg-primary/60"
style="width: {(t.uses / maxUses) * 100}%"
></span>
</span>
</td>
<td class="py-1 pr-2">
{#if t.split}
<span class="text-destructive">{t.variants.join(', ')}</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
<td class="py-1 text-right">
{#if t.split}
<Button
size="sm"
variant="outline"
class="h-6 text-xs"
disabled={renameBusy}
onclick={() => normalize(t)}
>
Normalize
</Button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Tabs.Content>
<Tabs.Content value="orphans" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes untagged, unlinked to any entity, or untouched for 90+ days — invisible to most
navigation paths and easy to lose track of.
{#if orphanCounts.untagged || orphanCounts.unlinked || orphanCounts.stale}
({orphanCounts.untagged ?? 0} untagged · {orphanCounts.unlinked ?? 0} unlinked · {orphanCounts.stale ??
0} stale)
{/if}
</p>
{#if orphansError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{orphansError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadOrphans}
>Retry</Button
>
</p>
{/if}
{#if orphans === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else if orphans.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each orphans as o (o.slug)}
<button
type="button"
class="flex items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted/40"
onclick={() => onSelect(o.slug)}
>
<span class="min-w-0 flex-1 truncate">{o.title}</span>
{#each o.reasons as r (r)}<Badge variant="outline" class="shrink-0 text-[9px]"
>{r}</Badge
>{/each}
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(o.updated_at)}</span
>
</button>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="trash" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if trashError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{trashError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTrash}>Retry</Button>
</p>
{/if}
{#if trash === null}
<div class="flex justify-center py-8"><Spinner /></div>
{:else if trash.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each trash as t (t.slug)}
<div class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40">
<span class="min-w-0 flex-1 truncate">{t.title}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">
deleted {relativeTime(t.deleted_at)} by {t.deleted_by || 'unknown'}
</span>
<Button
size="sm"
variant="outline"
class="h-6 shrink-0 gap-1 text-xs"
disabled={restoring === t.slug}
onclick={() => doRestore(t.slug)}
>
<RotateCcwIcon class="size-3" /> Restore
</Button>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>