diff --git a/control/control.go b/control/control.go new file mode 100644 index 0000000..ec522f5 --- /dev/null +++ b/control/control.go @@ -0,0 +1,154 @@ +package control + +import ( + "os" + "strings" + "time" +) + +const SchemaVersion = "crawlkit.control.v1" + +type Manifest struct { + SchemaVersion string `json:"schema_version"` + ID string `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Binary Binary `json:"binary"` + Branding Branding `json:"branding"` + Paths Paths `json:"paths"` + Commands map[string]Command `json:"commands"` + Capabilities []string `json:"capabilities,omitempty"` + Privacy Privacy `json:"privacy"` +} + +type Binary struct { + Name string `json:"name"` +} + +type Branding struct { + SymbolName string `json:"symbol_name,omitempty"` + AccentColor string `json:"accent_color,omitempty"` + IconPath string `json:"icon_path,omitempty"` + BundleIdentifier string `json:"bundle_identifier,omitempty"` +} + +type Paths struct { + DefaultConfig string `json:"default_config,omitempty"` + ConfigEnv string `json:"config_env,omitempty"` + DefaultDatabase string `json:"default_database,omitempty"` + DefaultCache string `json:"default_cache,omitempty"` + DefaultLogs string `json:"default_logs,omitempty"` + DefaultShare string `json:"default_share,omitempty"` +} + +type Command struct { + Title string `json:"title,omitempty"` + Argv []string `json:"argv"` + JSON bool `json:"json,omitempty"` + Mutates bool `json:"mutates,omitempty"` + Legacy bool `json:"legacy,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` +} + +type Privacy struct { + ContainsPrivateMessages bool `json:"contains_private_messages"` + ExportsSecrets bool `json:"exports_secrets"` + LocalOnlyScopes []string `json:"local_only_scopes,omitempty"` +} + +type Status struct { + SchemaVersion string `json:"schema_version"` + AppID string `json:"app_id"` + GeneratedAt string `json:"generated_at"` + State string `json:"state"` + Summary string `json:"summary"` + ConfigPath string `json:"config_path,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + DatabaseBytes int64 `json:"database_bytes,omitempty"` + WALBytes int64 `json:"wal_bytes,omitempty"` + LastSyncAt string `json:"last_sync_at,omitempty"` + LastImportAt string `json:"last_import_at,omitempty"` + LastExportAt string `json:"last_export_at,omitempty"` + Counts []Count `json:"counts,omitempty"` + Freshness *Freshness `json:"freshness,omitempty"` + Share *Share `json:"share,omitempty"` + Databases []Database `json:"databases,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +type Count struct { + ID string `json:"id"` + Label string `json:"label"` + Value int64 `json:"value"` +} + +type Freshness struct { + Status string `json:"status"` + AgeSeconds int64 `json:"age_seconds,omitempty"` + StaleAfterSeconds int64 `json:"stale_after_seconds,omitempty"` +} + +type Share struct { + Enabled bool `json:"enabled"` + RepoPath string `json:"repo_path,omitempty"` + Remote string `json:"remote,omitempty"` + Branch string `json:"branch,omitempty"` + NeedsUpdate bool `json:"needs_update,omitempty"` +} + +type Database struct { + ID string `json:"id"` + Label string `json:"label"` + Kind string `json:"kind"` + Role string `json:"role"` + Path string `json:"path"` + IsPrimary bool `json:"is_primary"` + Bytes int64 `json:"bytes"` + ModifiedAt string `json:"modified_at,omitempty"` + Counts []Count `json:"counts,omitempty"` +} + +func NewManifest(id, displayName, binaryName string) Manifest { + return Manifest{ + SchemaVersion: SchemaVersion, + ID: strings.TrimSpace(id), + DisplayName: strings.TrimSpace(displayName), + Binary: Binary{Name: strings.TrimSpace(binaryName)}, + Commands: map[string]Command{}, + } +} + +func NewStatus(appID, summary string) Status { + return Status{ + SchemaVersion: SchemaVersion, + AppID: strings.TrimSpace(appID), + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + State: "unknown", + Summary: strings.TrimSpace(summary), + } +} + +func NewCount(id, label string, value int64) Count { + return Count{ID: strings.TrimSpace(id), Label: strings.TrimSpace(label), Value: value} +} + +func SQLiteDatabase(id, label, role, path string, primary bool, counts []Count) Database { + db := Database{ + ID: strings.TrimSpace(id), + Label: strings.TrimSpace(label), + Kind: "sqlite", + Role: strings.TrimSpace(role), + Path: strings.TrimSpace(path), + IsPrimary: primary, + Counts: append([]Count(nil), counts...), + } + if db.Role == "" { + db.Role = "archive" + } + if info, err := os.Stat(db.Path); err == nil { + db.Bytes = info.Size() + db.ModifiedAt = info.ModTime().UTC().Format(time.RFC3339) + } + return db +} diff --git a/control/control_test.go b/control/control_test.go new file mode 100644 index 0000000..35f10f8 --- /dev/null +++ b/control/control_test.go @@ -0,0 +1,38 @@ +package control + +import ( + "os" + "path/filepath" + "testing" +) + +func TestManifestDefaultsSchemaAndBinary(t *testing.T) { + manifest := NewManifest("slacrawl", "Slack Crawl", "slacrawl") + if manifest.SchemaVersion != SchemaVersion { + t.Fatalf("schema = %q", manifest.SchemaVersion) + } + if manifest.Binary.Name != "slacrawl" { + t.Fatalf("binary = %#v", manifest.Binary) + } + if manifest.Commands == nil { + t.Fatal("commands map should be initialized") + } +} + +func TestSQLiteDatabaseStatsPathReadOnly(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "archive.db") + if err := os.WriteFile(path, []byte("sqlite"), 0o600); err != nil { + t.Fatal(err) + } + db := SQLiteDatabase("primary", "Primary archive", "archive", path, true, []Count{NewCount("messages", "Messages", 7)}) + if db.Kind != "sqlite" || !db.IsPrimary || db.Bytes != 6 { + t.Fatalf("unexpected database: %#v", db) + } + if db.ModifiedAt == "" { + t.Fatal("modified_at should be set for existing paths") + } + if len(db.Counts) != 1 || db.Counts[0].Value != 7 { + t.Fatalf("counts = %#v", db.Counts) + } +} diff --git a/tui/tui.go b/tui/tui.go index f47b362..9d04647 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -21,13 +21,24 @@ type Item struct { Subtitle string `json:"subtitle,omitempty"` Detail string `json:"detail,omitempty"` Tags []string `json:"tags,omitempty"` + Depth int `json:"depth,omitempty"` } +type LayoutPreset string + +const ( + LayoutAuto LayoutPreset = "" + LayoutList LayoutPreset = "list" + LayoutChat LayoutPreset = "chat" + LayoutDocument LayoutPreset = "document" +) + type Row struct { Source string `json:"source,omitempty"` Kind string `json:"kind"` ID string `json:"id,omitempty"` ParentID string `json:"parent_id,omitempty"` + Depth int `json:"depth,omitempty"` Scope string `json:"scope,omitempty"` Container string `json:"container,omitempty"` Author string `json:"author,omitempty"` @@ -54,6 +65,7 @@ type BrowseOptions struct { EmptyMessage string Rows []Row JSON bool + Layout LayoutPreset Stdin io.Reader Stdout io.Writer } @@ -68,8 +80,12 @@ func Browse(ctx context.Context, opts BrowseOptions) error { return enc.Encode(opts.Rows) } items := make([]Item, 0, len(opts.Rows)) + layout := opts.Layout + if layout == LayoutAuto { + layout = inferLayout(opts.Rows) + } for _, row := range opts.Rows { - items = append(items, row.Item()) + items = append(items, row.ItemForLayout(layout)) } title := strings.TrimSpace(opts.Title) if title == "" { @@ -103,6 +119,13 @@ func Browse(ctx context.Context, opts BrowseOptions) error { } func (r Row) Item() Item { + return r.ItemForLayout(LayoutAuto) +} + +func (r Row) ItemForLayout(layout LayoutPreset) Item { + if layout == LayoutAuto { + layout = inferLayout([]Row{r}) + } title := firstNonEmpty(r.Title, r.Text, r.ID, "(untitled)") tags := append([]string(nil), r.Tags...) if r.Kind != "" { @@ -111,11 +134,16 @@ func (r Row) Item() Item { if r.Source != "" { tags = append([]string{r.Source}, tags...) } + depth := r.Depth + if depth == 0 && layout == LayoutChat && strings.TrimSpace(r.ParentID) != "" { + depth = 1 + } return Item{ Title: title, - Subtitle: r.subtitle(), - Detail: r.detail(), + Subtitle: r.subtitleForLayout(layout), + Detail: r.detailForLayout(layout), Tags: tags, + Depth: depth, } } @@ -154,8 +182,32 @@ func Run(ctx context.Context, opts Options) error { return err } -func (r Row) subtitle() string { +func inferLayout(rows []Row) LayoutPreset { + for _, row := range rows { + switch strings.ToLower(strings.TrimSpace(row.Kind)) { + case "message", "thread", "reply": + return LayoutChat + case "page", "database", "block", "collection": + return LayoutDocument + } + } + return LayoutList +} + +func (r Row) subtitleForLayout(layout LayoutPreset) string { + if layout == LayoutChat { + parts := []string{r.Container, r.Author, r.CreatedAt, r.UpdatedAt} + return joinNonEmpty(parts, " ") + } + if layout == LayoutDocument { + parts := []string{r.Kind, r.Scope, r.Container, r.UpdatedAt, r.CreatedAt} + return joinNonEmpty(parts, " ") + } parts := []string{r.Scope, r.Container, r.Author, r.CreatedAt, r.UpdatedAt} + return joinNonEmpty(parts, " ") +} + +func joinNonEmpty(parts []string, sep string) string { var out []string for _, part := range parts { part = strings.TrimSpace(part) @@ -163,26 +215,33 @@ func (r Row) subtitle() string { out = append(out, part) } } - return strings.Join(out, " ") + return strings.Join(out, sep) } -func (r Row) detail() string { +func (r Row) detailForLayout(layout LayoutPreset) string { var lines []string if text := strings.TrimSpace(r.Text); text != "" && text != strings.TrimSpace(r.Title) { lines = append(lines, text) } + if layout == LayoutDocument && strings.TrimSpace(r.URL) != "" { + lines = append(lines, fieldLine("url", r.URL)) + } for _, line := range []string{ fieldLine("id", r.ID), fieldLine("parent", r.ParentID), fieldLine("scope", r.Scope), fieldLine("container", r.Container), fieldLine("author", r.Author), - fieldLine("url", r.URL), } { if line != "" { lines = append(lines, line) } } + if layout != LayoutDocument { + if line := fieldLine("url", r.URL); line != "" { + lines = append(lines, line) + } + } if len(r.Fields) > 0 { keys := make([]string, 0, len(r.Fields)) for key := range r.Fields { @@ -351,6 +410,9 @@ func (m model) View() string { b.WriteString(" ") } line := item.Title + if item.Depth > 0 { + line = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + line + } if item.Subtitle != "" { line += " " + ansiDim + item.Subtitle + ansiReset if selected { diff --git a/tui/tui_test.go b/tui/tui_test.go index 445447b..facf615 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -45,7 +45,7 @@ func TestRowItemUsesSharedArchiveShape(t *testing.T) { Title: "panic locked database", Text: "full message text", Fields: map[string]string{"reply_to": "m0"}, - }.Item() + }.ItemForLayout(LayoutList) if item.Title != "panic locked database" { t.Fatalf("title = %q", item.Title) } @@ -60,6 +60,41 @@ func TestRowItemUsesSharedArchiveShape(t *testing.T) { } } +func TestChatLayoutIndentsReplyRows(t *testing.T) { + item := Row{ + Source: "discord", + Kind: "message", + ID: "m2", + ParentID: "m1", + Container: "general", + Author: "sam", + Title: "reply", + }.ItemForLayout(LayoutChat) + if item.Depth != 1 { + t.Fatalf("depth = %d", item.Depth) + } + if strings.Contains(item.Subtitle, "discord") { + t.Fatalf("chat subtitle should prioritize chat context, got %q", item.Subtitle) + } +} + +func TestDocumentLayoutPrioritizesURLDetail(t *testing.T) { + item := Row{ + Source: "notion", + Kind: "page", + ID: "page1", + Title: "Launch plan", + URL: "https://example.com/launch", + UpdatedAt: "2026-05-01T12:00:00Z", + }.ItemForLayout(LayoutDocument) + if !strings.HasPrefix(item.Detail, "url=https://example.com/launch") { + t.Fatalf("detail = %q", item.Detail) + } + if !strings.Contains(item.Subtitle, "page") || !strings.Contains(item.Subtitle, "2026-05-01") { + t.Fatalf("subtitle = %q", item.Subtitle) + } +} + func TestModelFilterAndRender(t *testing.T) { m := newModel(Options{ Title: "notcrawl",