feat(control): add app metadata and status contracts
This commit is contained in:
parent
0396564386
commit
1f001962a0
154
control/control.go
Normal file
154
control/control.go
Normal file
@ -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
|
||||
}
|
||||
38
control/control_test.go
Normal file
38
control/control_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
76
tui/tui.go
76
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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user