Merge dev into main (v0.47.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s

This commit is contained in:
sandrodach committed 2026-11-01 17:26:55 +01:00
commit 9f0f25c6a7
12 files changed
+303 -40

No files matched your search

+8 -1
View File
@@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.47.0] - 2026-11-01
### Added
- Verlinken-Fenster in Notizen: Ab 3 Buchstaben stehen unter den passenden Notizen auch passende **Punkte aus allen Notizen** (bis zu 8, Suchbegriff markiert, mit Notiztitel). Ein Klick verlinkt direkt diesen Punkt.
- Beispieldaten (Demo und auf Wunsch bei der Einrichtung): neuer Ordner „MindSift-Hilfe“ mit kurzen Anleitungen (Erste Schritte, Notizen, Aufgaben, Listen, Tastenkürzel, Spracheingabe, Teilen, API), eine Notiz „Zoom ausprobieren“ mit zehn Ebenen, eine tiefere Küchenplanung in „Hausrenovierung 2027“ und Beispiele für verknüpfte To-dos: „Backup-Konzept“ (Häkchen als Aufgabe mit Unteraufgaben) und die als Notiz vertiefte Aufgabe „Uptime-Monitoring“.
## [0.46.0] - 2026-11-01 ## [0.46.0] - 2026-11-01
### Added ### Added
@@ -744,7 +750,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.46.0...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.47.0...dev
[0.47.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.46.0...v0.47.0
[0.46.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.2...v0.46.0 [0.46.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.2...v0.46.0
[0.45.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.1...v0.45.2 [0.45.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.1...v0.45.2
[0.45.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.0...v0.45.1 [0.45.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.0...v0.45.1
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.46.0-16a34a?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/Version-v0.47.0-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.46.0", "version": "0.47.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.46.0", "version": "0.47.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.46.0", "version": "0.47.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -26
View File
@@ -10,6 +10,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { SearchHit } from '../lib/types'; import { SearchHit } from '../lib/types';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { SnippetRenderer } from './SnippetRenderer';
interface CommandPaletteProps { interface CommandPaletteProps {
isOpen: boolean; isOpen: boolean;
@@ -19,32 +20,6 @@ interface CommandPaletteProps {
onOpenList: (listId: string) => void; onOpenList: (listId: string) => void;
} }
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
const parts = useMemo(() => {
if (!snippet) return [];
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
}, [snippet]);
return (
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
{parts.map((part, i) => {
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
const text = part.slice(6, -7);
return (
<mark
key={i}
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
>
{text}
</mark>
);
}
return <span key={i}>{part}</span>;
})}
</span>
);
};
export const CommandPalette: React.FC<CommandPaletteProps> = ({ export const CommandPalette: React.FC<CommandPaletteProps> = ({
isOpen, isOpen,
@@ -0,0 +1,28 @@
import React, { useMemo } from 'react';
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
export const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
const parts = useMemo(() => {
if (!snippet) return [];
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
}, [snippet]);
return (
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
{parts.map((part, i) => {
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
const text = part.slice(6, -7);
return (
<mark
key={i}
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
>
{text}
</mark>
);
}
return <span key={i}>{part}</span>;
})}
</span>
);
};
@@ -14,6 +14,7 @@ import {
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { Document, DocumentBlock } from '../../lib/types'; import { Document, DocumentBlock } from '../../lib/types';
import { buildBlockLink } from '../../lib/wikiLink'; import { buildBlockLink } from '../../lib/wikiLink';
import { SnippetRenderer } from '../../components/SnippetRenderer';
export interface LinkModalProps { export interface LinkModalProps {
isOpen: boolean; isOpen: boolean;
@@ -134,6 +135,50 @@ export const LinkModal: React.FC<LinkModalProps> = ({
); );
}, [pickBlocks, blockSearch]); }, [pickBlocks, blockSearch]);
// Bullets of all notes matching the search (full-text index, like the global search), from 3 letters
type BlockHit = { docId: string; blockId: string; title: string; icon?: string; snippet: string; content: string };
const [blockHits, setBlockHits] = useState<BlockHit[]>([]);
const [blockHitsLoading, setBlockHitsLoading] = useState(false);
useEffect(() => {
const q = search.trim();
if (!isOpen || tab !== 'note' || pickDoc || q.length < 3) {
setBlockHits([]);
setBlockHitsLoading(false);
return;
}
let cancelled = false;
setBlockHitsLoading(true);
const timer = window.setTimeout(() => {
api
.searchAll(q)
.then((res) => {
if (cancelled) return;
const docsById = new Map((res.documents || []).map((d) => [d.id, d]));
const hits = (res.hits || [])
.filter((h) => h.type === 'document' && h.blockId && h.docId)
.slice(0, 8)
.map((h) => {
const block = docsById.get(h.docId!)?.blocks?.find((b) => b.id === h.blockId);
return {
docId: h.docId!,
blockId: h.blockId!,
title: h.title,
icon: h.icon,
snippet: h.snippet,
content: block?.content ?? h.snippet.replace(/<\/?mark>/g, ''),
};
});
setBlockHits(hits);
})
.catch(() => !cancelled && setBlockHits([]))
.finally(() => !cancelled && setBlockHitsLoading(false));
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [search, isOpen, tab, pickDoc]);
const handleSelectNote = (doc: { title: string }) => { const handleSelectNote = (doc: { title: string }) => {
const noteTitle = doc.title.trim() || 'Notiz'; const noteTitle = doc.title.trim() || 'Notiz';
onInsertLink(`[[${noteTitle}]]`); onInsertLink(`[[${noteTitle}]]`);
@@ -399,6 +444,33 @@ export const LinkModal: React.FC<LinkModalProps> = ({
)) ))
)} )}
</div> </div>
{/* Matching bullets of all notes: a click links that bullet */}
{search.trim().length >= 3 && (blockHitsLoading || blockHits.length > 0) && (
<>
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5 pt-1">
<span>Punkte</span>
{blockHitsLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
{blockHits.map((h) => (
<button
key={`${h.docId}:${h.blockId}`}
type="button"
onClick={() => onInsertLink(buildBlockLink(h.title || 'Notiz', h.blockId, h.content))}
className="w-full text-left p-2.5 min-h-11 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors cursor-pointer space-y-0.5"
title="Diesen Punkt verlinken"
>
<SnippetRenderer snippet={h.snippet} />
<span className="block text-[10px] text-faint truncate">
{h.icon ? `${h.icon} ` : ''}
{h.title || 'Unbenannte Notiz'}
</span>
</button>
))}
</div>
</>
)}
</div> </div>
)} )}
</div> </div>
+2 -2
View File
@@ -38,9 +38,9 @@ func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
var req struct { var req struct {
Title string `json:"title"` Title string `json:"title"`
Icon *string `json:"icon"` // "" = no icon; missing = the default Icon *string `json:"icon"` // "" = no icon; missing = the default
ListType string `json:"listType"` ListType string `json:"listType"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
+4 -4
View File
@@ -25,11 +25,11 @@ func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
var req struct { var req struct {
SpaceID string `json:"spaceId"` SpaceID string `json:"spaceId"`
TeamID string `json:"teamId"` TeamID string `json:"teamId"`
Title string `json:"title"` Title string `json:"title"`
Icon *string `json:"icon"` // "" = no icon; missing = the default Icon *string `json:"icon"` // "" = no icon; missing = the default
Color string `json:"color"` Color string `json:"color"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
+13 -3
View File
@@ -3,6 +3,7 @@ package storage
import ( import (
"errors" "errors"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -398,13 +399,22 @@ func TestDemoAccountsGetSampleDataAndShares(t *testing.T) {
t.Fatalf("expected tasks and inbox thoughts, got %d tasks, %d inbox", tasks, inbox) t.Fatalf("expected tasks and inbox thoughts, got %d tasks, %d inbox", tasks, inbox)
} }
// Running it again (container restart) must not seed twice // Running it again (container restart) must not seed twice
var before, docs int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ?`, admin.ID).Scan(&before)
if err := s.EnsureDemoAccounts(); err != nil { if err := s.EnsureDemoAccounts(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
var docs int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ?`, admin.ID).Scan(&docs) _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ?`, admin.ID).Scan(&docs)
if docs != 4 { if docs != before || docs < 10 {
t.Fatalf("restart seeded again: %d notes", docs) t.Fatalf("restart seeded again or sample notes missing: %d notes before, %d after", before, docs)
}
// The linked example: a task tied to a note checkbox, with the checkboxes below as subtasks
var subtasks string
if err := s.db.QueryRow(`SELECT subtasks FROM thoughts WHERE user_id = ? AND converted_type = 'document_block' AND content LIKE 'Backup-Konzept%'`, admin.ID).Scan(&subtasks); err != nil {
t.Fatalf("linked sample task missing: %v", err)
}
if !strings.Contains(subtasks, "systemd-Timer") || !strings.Contains(subtasks, "blockId") {
t.Fatalf("checkboxes below should be linked subtasks: %s", subtasks)
} }
} }
+12
View File
@@ -194,7 +194,17 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
{level: 4, text: "Varianten"}, {level: 4, text: "Varianten"},
{level: 5, text: "A: Zeile mit Insel – 14.800 €"}, {level: 5, text: "A: Zeile mit Insel – 14.800 €"},
{level: 6, text: "Insel braucht eigenen Stromkreis, Elektriker fragen"}, {level: 6, text: "Insel braucht eigenen Stromkreis, Elektriker fragen"},
{level: 7, text: "Elektriker Huber, Termin im März"},
{level: 8, text: "Was er wissen muss"},
{level: 9, text: "Kochfeld 7,4 kW → Starkstrom (400 V) an der Insel"},
{level: 9, text: "Steckdosen auf beiden Seiten der Insel, eine davon mit USB"},
{level: 9, typ: "todo", text: "Unterverteilung fotografieren und vorab schicken"},
{level: 8, text: "Kosten"},
{level: 9, text: "Schätzung 900–1.200 €, abhängig vom Bodenaufbau"},
{level: 6, text: "Kochfeld mit integriertem Abzug, kein Hängeschrank nötig"}, {level: 6, text: "Kochfeld mit integriertem Abzug, kein Hängeschrank nötig"},
{level: 7, text: "Umluft oder Abluft?"},
{level: 8, text: "Abluft braucht einen Mauerdurchbruch in der Außenwand"},
{level: 8, text: "Umluft: Filter alle 12 Monate tauschen #wartung"},
{level: 5, text: "B: L-Form ohne Insel – 11.200 €"}, {level: 5, text: "B: L-Form ohne Insel – 11.200 €"},
{level: 6, text: "mehr Stauraum, aber der Essplatz wird kleiner"}, {level: 6, text: "mehr Stauraum, aber der Essplatz wird kleiner"},
{level: 4, typ: "todo", text: "Gegenangebot vom zweiten Studio einholen #renovierung"}, {level: 4, typ: "todo", text: "Gegenangebot vom zweiten Studio einholen #renovierung"},
@@ -227,6 +237,8 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
{text: "Wochenrückblick jeden Sonntag: Inbox leeren, Aufgaben für die Woche planen #routine"}, {text: "Wochenrückblick jeden Sonntag: Inbox leeren, Aufgaben für die Woche planen #routine"},
}), []string{"ideen"}, false) }), []string{"ideen"}, false)
s.seedLinkedTodos(user, homelab)
s.seedHelpNotes(user)
return nil return nil
} }
+159
View File
@@ -0,0 +1,159 @@
package storage
import "github.com/sandrodach/mindsift/internal/domain"
// seedLinkedTodos shows the link between notes and tasks both ways: a checklist in a note whose main
// checkbox became a task (the checkboxes below are its subtasks), and a task deepened into a note.
func (s *Store) seedLinkedTodos(user *domain.User, homelab *domain.TaskList) {
listID := ""
if homelab != nil {
listID = homelab.ID
}
blocks := seedBlocks([]seedBlock{
{typ: "callout", text: "Das Häkchen „Backup-Konzept umsetzen“ ist mit einer Aufgabe in der Liste Homelab verknüpft (Badge „Erweitert“). Die Häkchen direkt darunter sind ihre Unteraufgaben: Abhaken hier oder in der Aufgabe – beide Seiten bleiben gleich."},
{typ: "todo", text: "Backup-Konzept umsetzen #backup"},
{level: 1, typ: "todo", text: "restic-Repository auf der Storage Box anlegen", checked: true},
{level: 1, typ: "todo", text: "Nächtlicher Lauf per systemd-Timer"},
{level: 1, typ: "todo", text: "Wiederherstellung einmal komplett testen"},
{level: 1, text: "Normale Punkte gehören nicht zur Aufgabe – hier ist Platz für Gedanken zum Ablauf"},
{level: 2, text: "Aufbewahrung: 7 tägliche, 4 wöchentliche, 6 monatliche Stände"},
{typ: "h3", text: "So geht's"},
{level: 1, text: "Ein Häkchen über „Erweitert“ (Menü am Häkchen) zur Aufgabe machen"},
{level: 1, text: "Neue Häkchen direkt eine Ebene darunter werden automatisch Unteraufgaben"},
{level: 1, text: "Umgekehrt: In den Aufgaben „Als Notiz vertiefen“ – siehe die Notiz zur Aufgabe „Uptime-Monitoring“"},
})
doc, err := s.CreateDocument(user.ID, "", "Backup-Konzept", "💾", "Projekte", blocks, []string{"homelab"}, false)
if err != nil || doc == nil {
return
}
task, err := s.CreateTaskFromDocBlock(user.ID, doc.ID, blocks[1].ID, blocks[1].Content, false)
if err != nil || task == nil {
return
}
s.ReconcileDocTaskLinks(doc.ID)
high, status := "high", "in_progress"
_, _ = s.UpdateThoughtDetails(user.ID, task.ID, nil, nil, nil, &high, &listID, &status, nil, nil, nil)
// A task of the list deepened into a note: the note starts with its checkbox and subtasks
var monitoringID string
_ = s.db.QueryRow(`SELECT id FROM thoughts WHERE user_id = ? AND content LIKE 'Uptime-Monitoring%'`, user.ID).Scan(&monitoringID)
if monitoringID != "" {
_, _ = s.UpdateThoughtTriage(user.ID, monitoringID, "keep", "document", "", "", "")
}
}
// seedHelpNotes: a short guide to MindSift as notes, so the demo explains itself while showing the block
// types (headings, callouts, numbered lists, code, links to notes).
func (s *Store) seedHelpNotes(user *domain.User) {
_, _ = s.CreateFolder(user.ID, "MindSift-Hilfe")
help := func(title, icon string, spec []seedBlock) {
_, _ = s.CreateDocument(user.ID, "", title, icon, "MindSift-Hilfe", seedBlocks(spec), []string{"hilfe"}, false)
}
help("Erste Schritte", "👋", []seedBlock{
{typ: "callout", text: "Willkommen! Diese Notizen erklären MindSift. Alles hier ist Beispielinhalt – ändern, verschieben und löschen ist ausdrücklich erlaubt. #hilfe"},
{typ: "h2", text: "Die Idee in drei Schritten"},
{typ: "numbered", text: "**Erfassen:** Alles, was dir einfällt, sofort in die Leiste oben (Quick Dump) – Taste `C` oder `/`, am Handy das +."},
{typ: "numbered", text: "**Sortieren:** In der Triage gehst du die Gedanken später durch: verwerfen, Aufgabe, Notiz oder Listeneintrag."},
{typ: "numbered", text: "**Vertiefen:** Aufgaben bekommen Unteraufgaben und Termine, Notizen werden zu Gliederungen, Listen teilst du mit der Familie."},
{typ: "h2", text: "Weiterlesen"},
{level: 1, text: "[[Notizen & Outliner]] – Punkte, Ebenen, Zoom und Verlinken"},
{level: 1, text: "[[Zoom ausprobieren]] – eine Gliederung zehn Ebenen tief"},
{level: 1, text: "[[Aufgaben & Kanban]] – Listen, Spalten, Papierkorb"},
{level: 1, text: "[[Listen & Einkauf]] – gemeinsam einkaufen und packen"},
{level: 1, text: "[[Tastenkürzel]] – schneller arbeiten"},
{level: 1, text: "[[Spracheingabe]] – diktieren statt tippen"},
{level: 1, text: "[[Teilen & gemeinsam bearbeiten]]"},
{level: 1, text: "[[API & Smart Home]] – Home Assistant und KI-Agenten anbinden"},
})
help("Notizen & Outliner", "📝", []seedBlock{
{typ: "h2", text: "Punkte und Ebenen"},
{level: 1, text: "`Enter` legt einen neuen Punkt an, `Tab` rückt ein, `Shift+Tab` rückt aus"},
{level: 1, text: "Punkte mit Unterpunkten lassen sich auf- und zuklappen (Pfeil links beim Drüberfahren)"},
{level: 1, text: "Am Punkt (•) ziehen verschiebt den ganzen Ast"},
{level: 1, text: "Klick auf den Punkt (•) **zoomt** in den Ast – probier es in [[Zoom ausprobieren]]"},
{typ: "h2", text: "Formatieren"},
{level: 1, text: "Am Zeilenanfang: `- ` Punkt, `1. ` Nummer, `[] ` Häkchen, `# ` Überschrift"},
{level: 1, text: "`/` öffnet das Menü: /todo, /h1, /code, /callout, /image, /audio, /diktat, /extract …"},
{level: 1, text: "**fett** mit `Strg+B`, *kursiv* mit `Strg+I`, Link mit `Strg+K`"},
{typ: "h2", text: "Verlinken"},
{level: 1, text: "`[[Notiztitel]]` verlinkt eine Notiz, z. B. [[Erste Schritte]]"},
{level: 1, text: "Auch einzelne Punkte lassen sich verlinken: im Verlinken-Fenster nach Text suchen, gefundene Punkte stehen unter den Notizen"},
{level: 1, text: "`#Hashtags` sammeln Fundstellen über alle Notizen, z. B. #hilfe"},
{typ: "h2", text: "Markieren und löschen"},
{level: 1, text: "Mit der Maus über mehrere Punkte ziehen markiert Text über Punktgrenzen; Entf löscht wie in einem Texteditor"},
{level: 1, text: "`Strg+Z` nimmt jede eigene Änderung zurück"},
{typ: "callout", text: "Gelöschte Notizen landen im Papierkorb unten in der Ordnerleiste. Wie lange sie dort bleiben, steht unter Kontoeinstellungen → Aufbewahrung."},
})
help("Zoom ausprobieren", "🔍", []seedBlock{
{typ: "callout", text: "Klick auf einen Punkt (•) zeigt nur diesen Ast. Oben erscheint der Pfad – ein Klick darauf geht Ebene für Ebene zurück."},
{text: "Ebene 1: Projekt „Garten neu anlegen“"},
{level: 1, text: "Ebene 2: Beete"},
{level: 2, text: "Ebene 3: Hochbeet am Zaun"},
{level: 3, text: "Ebene 4: Bepflanzung"},
{level: 4, text: "Ebene 5: Frühjahr"},
{level: 5, text: "Ebene 6: Salat und Radieschen"},
{level: 6, text: "Ebene 7: Sorten"},
{level: 7, text: "Ebene 8: Pflücksalat „Lollo Rosso“"},
{level: 8, text: "Ebene 9: Aussaat ab Mitte März"},
{level: 9, text: "Ebene 10: alle zwei Wochen nachsäen – so gibt es den ganzen Sommer Salat 🥗"},
{level: 7, text: "Ebene 8: Radieschen „Eiszapfen“"},
{level: 5, text: "Ebene 6: Kräuter"},
{level: 6, text: "Ebene 7: Schnittlauch, Petersilie, Basilikum"},
{level: 4, text: "Ebene 5: Sommer"},
{level: 5, text: "Ebene 6: Tomaten und Zucchini"},
{level: 2, text: "Ebene 3: Kräuterspirale"},
{level: 1, text: "Ebene 2: Rasen"},
{level: 2, typ: "todo", text: "Ebene 3: Vertikutieren im April"},
})
help("Aufgaben & Kanban", "✅", []seedBlock{
{typ: "h2", text: "Ansichten"},
{level: 1, text: "**Liste** mit den Reitern Offen, Erledigt und Papierkorb; Gruppe und Sortierung stehen rechts darüber"},
{level: 1, text: "**Kanban** mit eigenen Spalten je Aufgabenliste (Spalten anpassen: Name, Farbe, Erledigt-Spalte)"},
{level: 1, text: "„Alle Aufgaben“ zeigt die Spalten aller Listen zusammengeführt"},
{typ: "h2", text: "Arbeiten mit Aufgaben"},
{level: 1, text: "Klick öffnet die Details: Priorität, Fälligkeit mit Uhrzeit, Wiederholung, Unteraufgaben, Notizen (mit Mikrofon)"},
{level: 1, text: "Ziehen sortiert um oder wechselt die Spalte; `Strg+Z` nimmt Löschen, Verschieben und Abhaken zurück"},
{level: 1, text: "Aufgaben und Notizen lassen sich verknüpfen – Beispiel: [[Backup-Konzept]]"},
{typ: "h2", text: "Aufräumen"},
{level: 1, text: "Erledigte wandern nach einer Frist in den Papierkorb, der sich später selbst leert"},
{level: 1, text: "Die Fristen stellst du unter Kontoeinstellungen → Aufbewahrung ein"},
})
help("Listen & Einkauf", "🛒", []seedBlock{
{text: "Kategorien entstehen aus Hashtags: `Hafermilch #Kühlregal` landet unter „Kühlregal“"},
{text: "Mengen ins eigene Feld, z. B. `500 g` oder `3x`"},
{text: "Geteilte Listen zeigen Abhaken bei allen sofort – ideal für den Wocheneinkauf"},
{text: "Abgehakte Einträge verschwinden nach einer Woche von selbst (einstellbar unter Aufbewahrung)"},
})
help("Tastenkürzel", "⌨️", []seedBlock{
{typ: "h2", text: "Überall"},
{level: 1, text: "`C` oder `/` – Quick Dump"},
{level: 1, text: "`Strg+K` – globale Suche über Notizen, Aufgaben und Listen"},
{typ: "h2", text: "Triage"},
{level: 1, text: "`H` / `←` verwerfen, `L` / `→` Aufgabe, `T` mit Termin, `D` Notiz, Leertaste später"},
{typ: "h2", text: "Notizen"},
{level: 1, text: "`Tab` / `Shift+Tab` ein- und ausrücken, `Strg+Z` / `Strg+Shift+Z` rückgängig und wiederholen"},
{level: 1, text: "`Strg+Shift+F` Fokus-Modus – nur die Notiz, in Lesebreite"},
{typ: "h2", text: "Aufgaben"},
{level: 1, text: "`Strg+Z` nimmt Löschen, Verschieben und Abhaken zurück"},
})
help("Spracheingabe", "🎙️", []seedBlock{
{text: "Mikrofone gibt es im Quick Dump, beim Anlegen von Aufgaben und Listeneinträgen, in den Detailnotizen einer Aufgabe und als `/diktat` in Notizen"},
{text: "Am Handy: das + gedrückt halten startet sofort eine Aufnahme"},
{text: "Mit eigenem Whisper-Server bleibt die Aufnahme auf deinem Server; unter Kontoeinstellungen → Spracheingabe wählst du Whisper oder die Erkennung des Browsers"},
{typ: "callout", text: "In dieser Demo nutzt MindSift die Spracherkennung des Browsers."},
})
help("Teilen & gemeinsam bearbeiten", "🤝", []seedBlock{
{text: "Notizen, Ordner, Aufgabenlisten und Listen lassen sich mit Personen oder Gruppen teilen – zum Bearbeiten oder nur zum Lesen"},
{text: "In einer geteilten Notiz schreiben mehrere gleichzeitig; wer in einem Punkt tippt, hält ihn kurz für sich"},
{level: 1, text: "Probier es aus: „Homelab 2.0“ ist mit dem Demo-Konto **user** geteilt – in einem zweiten Browser als user/user anmelden"},
{text: "Aufgaben in geteilten Listen lassen sich Personen zuweisen und beobachten"},
})
help("API & Smart Home", "🔌", []seedBlock{
{text: "Unter Kontoeinstellungen → API legst du Schlüssel mit genau den Rechten an, die ein Gerät braucht"},
{typ: "h3", text: "Heute fällige Aufgaben abfragen"},
{typ: "code", lang: "bash", text: "curl -H \"X-API-Key: mindsift_sec_…\" \\\n \"https://mindsift.example.com/api/v1/tasks?due=today&status=open\""},
{typ: "h3", text: "KI-Agenten (MCP)"},
{text: "Claude Code, Codex und andere Agenten lesen und pflegen Aufgaben und Listen über `/api/v1/mcp`"},
{text: "Was ein Schlüssel anlegt oder ändert, trägt eine kleine Badge mit seinem Namen"},
})
}