release: v0.2 – collaboration docs, richer demo data, new screenshots
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s

- Turn the collaboration plan into module docs (Doku/modules/collaboration.md)
  describing the implemented state; remove Doku/plans
- Header version comes from the build: git tag via APP_VERSION (CI passes
  it for tag builds), otherwise "<package version>-dev"; package 0.2.0
- Sample data (setup option and demo mode): six inbox thoughts, two task
  lists with Kanban states, subtasks and due dates, shopping and packing
  lists, three notes in folders incl. a long nested outline; demo mode
  shares the note and two lists with user/user
- e2e/screenshots.sh renews the README screenshots from demo data
- README: live collaboration, editor sharing, image tags, version badge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 15:32:31 +01:00
1 parent f26f286fb8
commit 4e39b367bd
25 files changed
+590 -373

No files matched your search

+44
View File
@@ -362,3 +362,47 @@ func TestDeletedAtFollowsDeleteAndRestore(t *testing.T) {
t.Fatal("restoring must clear deleted_at")
}
}
func TestDemoAccountsGetSampleDataAndShares(t *testing.T) {
s, _ := newSyncTestStore(t)
if err := s.EnsureDemoAccounts(); err != nil {
t.Fatal(err)
}
admin, _ := s.GetUserByLogin("admin")
member, _ := s.GetUserByLogin("user")
var docID string
if err := s.db.QueryRow(`SELECT id FROM documents WHERE user_id = ? AND title LIKE 'Homelab 2.0%'`, admin.ID).Scan(&docID); err != nil {
t.Fatalf("sample note missing: %v", err)
}
doc, err := s.GetDocumentByID(admin.ID, docID)
if err != nil || len(doc.Blocks) < 20 || doc.FolderPath != "Projekte" {
t.Fatalf("sample note too small or misplaced: %d blocks, folder %q, %v", len(doc.Blocks), doc.FolderPath, err)
}
maxLevel := 0
for _, b := range doc.Blocks {
if b.Level > maxLevel {
maxLevel = b.Level
}
}
if maxLevel < 2 {
t.Fatalf("sample note must have nested bullets, max level %d", maxLevel)
}
if role := s.DocRole(member.ID, docID); role != DocRoleEditor {
t.Fatalf("demo user must be editor of the sample note, got %q", role)
}
var tasks, inbox int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND converted_type = 'task'`, admin.ID).Scan(&tasks)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, admin.ID).Scan(&inbox)
if tasks < 8 || inbox < 5 {
t.Fatalf("expected tasks and inbox thoughts, got %d tasks, %d inbox", tasks, inbox)
}
// Running it again (container restart) must not seed twice
if err := s.EnsureDemoAccounts(); err != nil {
t.Fatal(err)
}
var docs int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ?`, admin.ID).Scan(&docs)
if docs != 3 {
t.Fatalf("restart seeded again: %d notes", docs)
}
}
+210
View File
@@ -0,0 +1,210 @@
package storage
import (
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// seedBlock describes one outliner bullet of the sample notes: nesting level, block type and text.
type seedBlock struct {
level int
typ string
text string
checked bool
lang string
}
func seedBlocks(spec []seedBlock) []domain.DocumentBlock {
out := make([]domain.DocumentBlock, len(spec))
for i, b := range spec {
typ := b.typ
if typ == "" {
typ = "bullet"
}
out[i] = domain.DocumentBlock{ID: domain.NewID("blk"), Type: typ, Content: b.text, Checked: b.checked, Lang: b.lang, Level: b.level}
}
return out
}
type seedTask struct {
content string
priority string
due int // days from today; 0 = no due date unless dueToday
dueToday bool
status string // kanban column
notes string
subtasks []string
doneSubs int
}
// SeedStarterContent fills a new account with realistic sample data (optional at setup, always in demo mode):
// inbox thoughts, two task lists with Kanban states, shopping and packing lists and a few linked notes.
func (s *Store) SeedStarterContent(user *domain.User) error {
day := func(offset int) string { return time.Now().AddDate(0, 0, offset).Format("2006-01-02") }
inbox := []struct{ content, priority, source string }{
{"Zündkerzen und Luftfilter für den Rasenmäher bestellen #garage #wartung", "normal", "manual"},
{"Urlaubsidee: Wanderhütte in Südtirol für Oktober buchen #urlaub #familie", "high", "voice"},
{"Kellerfeuchtigkeit über 68 % gestiegen – Entfeuchter prüfen #smarthome #alarm", "urgent", "webhook"},
{"Buchtipp von Jonas: „Die Kunst des klaren Denkens“ #lesen", "low", "manual"},
{"Geburtstagsgeschenk für Oma – vielleicht ein Fotobuch vom Sommer? #familie", "normal", "manual"},
{"Idee: Backup-Report jeden Montag per Discord schicken #homelab", "normal", "voice"},
}
for _, t := range inbox {
_, _ = s.CreateThought(user.ID, t.content, t.priority, t.source, "")
}
addTasks := func(listID string, tasks []seedTask) {
for _, t := range tasks {
due := ""
if t.due != 0 || t.dueToday {
due = day(t.due)
}
th, err := s.CreateThoughtWithDetails(user.ID, t.content, t.priority, "manual", due, listID, t.status, "")
if err != nil {
continue
}
if _, err := s.UpdateThoughtTriage(user.ID, th.ID, "keep", "task", "", due, listID); err != nil {
continue
}
subs := make([]domain.SubTask, len(t.subtasks))
for i, title := range t.subtasks {
subs[i] = domain.SubTask{ID: domain.NewID("sub"), Title: title, IsCompleted: i < t.doneSubs, CreatedAt: domain.NowISO()}
}
done := t.status == "done"
status, notes := t.status, t.notes
_, _ = s.UpdateThoughtDetails(user.ID, th.ID, nil, &done, nil, nil, nil, &status, &notes, &subs, nil)
}
}
homelab, err := s.CreateTaskList(user.ID, "", "", "Homelab", "🖥️", "#2563eb")
if err == nil {
addTasks(homelab.ID, []seedTask{
{content: "MindSift-Container auf den neuen Server umziehen #docker", priority: "high", due: 2, status: "in_progress",
notes: "Volume `./data` vorher mit `sqlite3 .backup` sichern.",
subtasks: []string{"Backup der Datenbank ziehen", "docker-compose.yml anpassen", "DNS auf neue IP umstellen", "Alten Container stoppen"}, doneSubs: 2},
{content: "Reverse-Proxy: Nginx-Timeouts für Long-Polling erhöhen #nginx", priority: "normal", due: 5, status: "todo"},
{content: "USV-Batterie tauschen lassen #hardware", priority: "urgent", due: -1, status: "todo",
notes: "Händler hat das Ersatzteil seit Montag da."},
{content: "Wartet auf Lieferung: zweite SSD für das RAID #hardware", priority: "normal", due: 7, status: "waiting"},
{content: "Gitea-Runner auf Version 0.2 aktualisieren #devops", priority: "low", status: "done"},
{content: "Uptime-Monitoring für alle Dienste einrichten #monitoring", priority: "normal", due: 10, status: "todo",
subtasks: []string{"Uptime Kuma installieren", "Benachrichtigung per Discord", "Statusseite veröffentlichen"}, doneSubs: 1},
})
}
house, err := s.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#16a34a")
if err == nil {
addTasks(house.ID, []seedTask{
{content: "Hecke schneiden, bevor die Vögel brüten #garten", priority: "normal", due: 3, status: "todo"},
{content: "Heizungswartung beim Installateur anfragen #wartung", priority: "high", dueToday: true, status: "in_progress"},
{content: "Steuerunterlagen für 2026 sortieren #finanzen", priority: "normal", due: 14, status: "todo",
subtasks: []string{"Handwerkerrechnungen", "Spendenquittungen", "Fahrtkosten"}},
{content: "Fahrradschloss nachkaufen", priority: "low", status: "done"},
})
}
shop, err := s.CreateList(user.ID, "Wocheneinkauf", "🛒", "shopping")
if err == nil {
items := []struct{ content, category, qty string }{
{"Hafermilch (Barista Edition)", "Kühlregal", "3x"},
{"Griechischer Joghurt", "Kühlregal", "500 g"},
{"Bergkäse", "Kühlregal", "200 g"},
{"Äpfel (Elstar)", "Obst & Gemüse", "1 kg"},
{"Cherrytomaten", "Obst & Gemüse", "2 Schalen"},
{"Avocados", "Obst & Gemüse", "3x"},
{"Bauernbrot", "Backwaren", "1x"},
{"Espressobohnen (100 % Arabica)", "Kaffee & Tee", "1 kg"},
{"Pfefferminztee", "Kaffee & Tee", "1x"},
{"Spülmaschinentabs", "Drogerie", "1 Pack"},
}
for i, it := range items {
item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i)
if err == nil && (i == 1 || i == 6 || i == 8) {
_, _ = s.ToggleListItem(item.ID, true, user.ID, user.DisplayName)
}
}
}
packing, err := s.CreateList(user.ID, "Packliste Südtirol", "🎒", "shopping")
if err == nil {
for i, it := range []struct{ content, category string }{
{"Wanderschuhe", "Kleidung"}, {"Regenjacke", "Kleidung"}, {"Fleecepullover", "Kleidung"},
{"Erste-Hilfe-Set", "Ausrüstung"}, {"Stirnlampe", "Ausrüstung"}, {"Trinkflaschen", "Ausrüstung"},
{"Personalausweise", "Dokumente"}, {"Hüttenschlafsack", "Ausrüstung"},
} {
_, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i)
}
}
_, _ = s.CreateFolder(user.ID, "Projekte")
_, _ = s.CreateFolder(user.ID, "Privat")
_, _ = s.CreateDocument(user.ID, "", "Homelab 2.0 – Umzug auf den neuen Server", "🚀", "Projekte", seedBlocks([]seedBlock{
{typ: "callout", text: "Ziel: Bis Ende des Monats laufen alle Dienste auf dem neuen Mini-PC – leiser, sparsamer und mit echtem Backup. Aufgaben dazu stehen in der Liste #homelab."},
{typ: "h2", text: "Warum überhaupt?"},
{level: 1, text: "Der alte Server zieht 65 W im Leerlauf, der neue nur 9 W"},
{level: 2, text: "Ersparnis ca. 120 € im Jahr bei aktuellem Strompreis"},
{level: 1, text: "Backups liefen bisher nur manuell – das soll sich ändern #backup"},
{level: 1, text: "Endlich Platz für die Dienste, die seit Monaten auf der Wunschliste stehen"},
{typ: "h2", text: "Checkliste Umzug"},
{level: 1, typ: "todo", text: "Vorbereitung"},
{level: 2, typ: "todo", text: "Proxmox installieren und Updates einspielen", checked: true},
{level: 2, typ: "todo", text: "Netzwerk: feste IP und VLAN für Dienste", checked: true},
{level: 2, typ: "todo", text: "ZFS-Pool mit zwei SSDs anlegen"},
{level: 1, typ: "todo", text: "Dienste umziehen"},
{level: 2, typ: "todo", text: "MindSift (Daten liegen komplett in einer SQLite-Datei)", checked: true},
{level: 2, typ: "todo", text: "Gitea mit Runner und Container-Registry"},
{level: 2, typ: "todo", text: "Home Assistant inklusive Zigbee-Stick"},
{level: 1, typ: "todo", text: "Alten Server sicher löschen und verkaufen"},
{typ: "h2", text: "Dienste & Ports"},
{level: 1, text: "**MindSift** – Notizen, Aufgaben und Listen für die ganze Familie #mindsift"},
{level: 2, text: "läuft hinter Nginx, WebSocket-Upgrade und Long-Polling sind freigegeben"},
{level: 2, text: "Demo-Konten nur im Testsystem, nie öffentlich"},
{level: 1, text: "**Gitea** – Code, CI und Images, z. B. `git.example.com` #devops"},
{level: 1, text: "**Home Assistant** – Sensoren melden Alarme per Webhook direkt in die MindSift-Inbox #smarthome"},
{level: 2, text: "Beispiel: Kellerfeuchtigkeit über 65 % → Gedanke mit Priorität „dringend“"},
{typ: "h3", text: "Compose-Ausschnitt"},
{level: 1, typ: "code", lang: "yaml", text: "services:\n mindsift:\n image: git.example.com/sandrodach/mindsift:v0.2\n restart: unless-stopped\n volumes: [\"./data:/data\"]\n environment:\n - JWT_SECRET=${JWT_SECRET}"},
{typ: "h2", text: "Offene Fragen"},
{level: 1, text: "Reicht eine USV für Router und Server zusammen?"},
{level: 1, text: "Offsite-Backup: Hetzner Storage Box oder Festplatte bei den Eltern? Siehe [[Ideen-Sammlung]]"},
}), []string{"homelab", "projekt"}, true)
_, _ = s.CreateDocument(user.ID, "", "Urlaub Südtirol", "🏔️", "Privat", seedBlocks([]seedBlock{
{typ: "h2", text: "Reiseplan Oktober"},
{level: 1, text: "Anreise Samstag, früh los wegen Brenner-Stau"},
{level: 1, text: "Hütte bei Villnöß – Bestätigung kommt per Mail"},
{level: 2, text: "Halbpension, Hunde erlaubt"},
{typ: "h2", text: "Touren-Ideen"},
{level: 1, typ: "todo", text: "Adolf-Munkel-Weg unter den Geislerspitzen", checked: true},
{level: 1, typ: "todo", text: "Seceda mit der Seilbahn, oben Höhenweg"},
{level: 1, typ: "todo", text: "Regentag: Ötzi-Museum in Bozen #familie"},
{typ: "callout", text: "Packliste liegt unter Listen → Packliste Südtirol."},
}), []string{"urlaub"}, false)
_, _ = s.CreateDocument(user.ID, "", "Ideen-Sammlung", "💡", "", seedBlocks([]seedBlock{
{text: "Offsite-Backup verschlüsselt mit restic #backup"},
{level: 1, text: "Vorteil: Snapshots, Deduplizierung, läuft auf jedem Ziel"},
{text: "Familien-Wiki statt WhatsApp-Gruppe für Rezepte und Termine #familie"},
{text: "Wochenrückblick jeden Sonntag: Inbox leeren, Aufgaben für die Woche planen #routine"},
}), []string{"ideen"}, false)
return nil
}
// seedDemoShares shares part of the admin's sample data with the demo user, so both demo accounts can try
// out editing a note, a task list and a shopping list together.
func (s *Store) seedDemoShares(admin, member *domain.User) {
var docID, taskListID, listID string
_ = s.db.QueryRow(`SELECT id FROM documents WHERE user_id = ? AND title LIKE 'Homelab 2.0%'`, admin.ID).Scan(&docID)
_ = s.db.QueryRow(`SELECT id FROM task_lists WHERE user_id = ? AND title = 'Homelab'`, admin.ID).Scan(&taskListID)
_ = s.db.QueryRow(`SELECT id FROM lists WHERE user_id = ? AND title = 'Wocheneinkauf'`, admin.ID).Scan(&listID)
if docID != "" {
_, _ = s.CreateDocShare(admin.ID, docID, member.ID, "write")
}
if taskListID != "" {
_, _ = s.AddTaskListShare(taskListID, "user", member.ID, "editor", admin.ID)
}
if listID != "" {
_, _ = s.AddListShare(admin.ID, listID, "user", member.ID, "editor")
}
}
+9 -54
View File
@@ -474,58 +474,6 @@ func (s *Store) migrate() error {
return nil
}
// SeedStarterContent populates realistic initial content for a newly created user if requested
func (s *Store) SeedStarterContent(user *domain.User) error {
now := domain.NowISO()
starterThoughts := []struct {
content string
priority string
source string
dueDate string
}{
{
content: "Zündkerzen und Luftfilter für den Rasenmäher bestellen #garage #wartung",
priority: "normal",
source: "manual",
},
{
content: "Urlaubsidee: Wanderhütte in Südtirol für Oktober buchen #urlaub #familie",
priority: "high",
source: "voice",
},
{
content: "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm",
priority: "urgent",
source: "webhook",
},
}
for _, st := range starterThoughts {
_, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate)
}
keptTask, err := s.CreateThought(user.ID, "Gitea CI/CD Pipeline für MindSift Container testen #devops #docker", "high", "manual", now[:10])
if err == nil && keptTask != nil {
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "")
}
shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
if err == nil && shopList != nil {
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
}
starterBlocks := []domain.DocumentBlock{
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."},
{ID: "blk_2", Type: "h2", Content: "Projekt-Checkliste"},
{ID: "blk_3", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true},
}
_, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Notizen", "🚀", "", starterBlocks, []string{"mindsift", "workflow"}, true)
return nil
}
// EnsureDefaultUser is retained strictly for unit test convenience
func (s *Store) EnsureDefaultUser() (*domain.User, error) {
s.initMu.Lock()
@@ -679,13 +627,14 @@ func (s *Store) EnsureDemoAccounts() error {
}
// 1. Admin user (admin / admin)
adminCreated := false
adminUser, err := s.GetUserByLogin("admin")
if err != nil || adminUser == nil {
adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin")
if err != nil {
return fmt.Errorf("failed to create demo admin: %w", err)
}
_ = s.SeedStarterContent(adminUser)
adminCreated = true
} else {
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
@@ -694,7 +643,7 @@ func (s *Store) EnsureDemoAccounts() error {
// 2. Regular user (user / user)
normalUser, err := s.GetUserByLogin("user")
if err != nil || normalUser == nil {
_, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
if err != nil {
return fmt.Errorf("failed to create demo user: %w", err)
}
@@ -703,6 +652,12 @@ func (s *Store) EnsureDemoAccounts() error {
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
}
// 3. Sample data for the admin, partly shared with the demo user (only on first start)
if adminCreated {
_ = s.SeedStarterContent(adminUser)
s.seedDemoShares(adminUser, normalUser)
}
return nil
}