feat: externalize apps/analysis to Gitea repos, add analysis table

- Migration 007: repo_url on apps table + analysis table with FTS5
- Analysis struct, parser, CRUD, validation, hash computation
- Selective purge: remote-only apps/analysis preserved across fn index
- CLI: fn app list/clone/pull, fn analysis list/clone/pull
- search/show/list now include analysis results
- Apps removed from git tracking (content lives in Gitea repos)
- .gitkeep for apps/ and analysis/ dirs
- Bash functions: jupyter analysis pipeline, shell utilities
- Browser domain: CDP functions moved from infra to browser

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:23:51 +02:00
parent 8f24157096
commit d7f2c00d7b
111 changed files with 2766 additions and 5043 deletions
+159 -4
View File
@@ -288,11 +288,12 @@ func (db *DB) InsertApp(a *App) error {
INSERT OR REPLACE INTO apps (
id, name, lang, domain, description, tags,
uses_functions, uses_types, framework, entry_point,
documentation, notes, dir_path, content_hash, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
documentation, notes, dir_path, content_hash, created_at, updated_at, repo_url
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags),
marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint,
a.Documentation, a.Notes, a.DirPath, a.ContentHash, a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339),
a.RepoURL,
)
return err
}
@@ -359,6 +360,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error)
&a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON,
&usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint,
&a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt, &a.ContentHash,
&a.RepoURL,
)
if err != nil {
return nil, fmt.Errorf("scanning app: %w", err)
@@ -375,7 +377,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error)
return result, nil
}
// Purge deletes all data from functions, types and apps. Used before re-indexing.
// Purge deletes all data from functions, types, apps and analysis. Used before re-indexing.
func (db *DB) Purge() error {
if _, err := db.conn.Exec("DELETE FROM functions"); err != nil {
return err
@@ -383,10 +385,163 @@ func (db *DB) Purge() error {
if _, err := db.conn.Exec("DELETE FROM types"); err != nil {
return err
}
_, err := db.conn.Exec("DELETE FROM apps")
if _, err := db.conn.Exec("DELETE FROM apps"); err != nil {
return err
}
_, err := db.conn.Exec("DELETE FROM analysis")
return err
}
// PurgeLocalOnly deletes functions, types, and only locally-present apps/analysis.
// Remote-only records (repo_url set, not in localAppIDs/localAnalysisIDs) are preserved.
func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs map[string]bool) error {
if _, err := db.conn.Exec("DELETE FROM functions"); err != nil {
return err
}
if _, err := db.conn.Exec("DELETE FROM types"); err != nil {
return err
}
// Delete local apps (those scanned from disk)
for id := range localAppIDs {
if _, err := db.conn.Exec("DELETE FROM apps WHERE id = ?", id); err != nil {
return err
}
}
// Delete apps without repo_url (legacy local-only apps not yet pushed)
if _, err := db.conn.Exec("DELETE FROM apps WHERE repo_url = '' OR repo_url IS NULL"); err != nil {
return err
}
// Same for analysis
for id := range localAnalysisIDs {
if _, err := db.conn.Exec("DELETE FROM analysis WHERE id = ?", id); err != nil {
return err
}
}
if _, err := db.conn.Exec("DELETE FROM analysis WHERE repo_url = '' OR repo_url IS NULL"); err != nil {
return err
}
return nil
}
// --- Analysis CRUD ---
// InsertAnalysis inserts or replaces an analysis entry.
func (db *DB) InsertAnalysis(a *Analysis) error {
now := time.Now().UTC()
if a.CreatedAt.IsZero() {
a.CreatedAt = now
}
if a.UpdatedAt.IsZero() {
a.UpdatedAt = now
}
if a.ID == "" {
a.ID = GenerateID(a.Name, a.Lang, a.Domain)
}
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO analysis (
id, name, lang, domain, description, tags,
uses_functions, uses_types, framework, entry_point,
documentation, notes, repo_url, dir_path, content_hash, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags),
marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint,
a.Documentation, a.Notes, a.RepoURL, a.DirPath, a.ContentHash,
a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339),
)
return err
}
// GetAnalysis returns a single analysis by ID.
func (db *DB) GetAnalysis(id string) (*Analysis, error) {
rows, err := db.conn.Query("SELECT * FROM analysis WHERE id = ?", id)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanAnalysis(rows)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("analysis %q not found", id)
}
return &items[0], nil
}
// SearchAnalysis performs FTS search on analysis with optional filters.
func (db *DB) SearchAnalysis(query string, lang, domain string) ([]Analysis, error) {
where := []string{}
args := []any{}
if query != "" {
where = append(where, "a.id IN (SELECT id FROM analysis_fts WHERE analysis_fts MATCH ?)")
args = append(args, query)
}
if lang != "" {
where = append(where, "a.lang = ?")
args = append(args, lang)
}
if domain != "" {
where = append(where, "a.domain = ?")
args = append(args, domain)
}
sql := "SELECT * FROM analysis a"
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += " ORDER BY a.name"
rows, err := db.conn.Query(sql, args...)
if err != nil {
return nil, fmt.Errorf("search analysis: %w", err)
}
defer rows.Close()
return scanAnalysis(rows)
}
// ListAllAnalysis returns all analysis entries.
func (db *DB) ListAllAnalysis() ([]Analysis, error) {
return db.SearchAnalysis("", "", "")
}
// ListAllApps returns all app entries.
func (db *DB) ListAllApps() ([]App, error) {
return db.SearchApps("", "", "")
}
func scanAnalysis(rows interface{ Next() bool; Scan(...any) error }) ([]Analysis, error) {
var result []Analysis
for rows.Next() {
var a Analysis
var tagsJSON, usesFnJSON, usesTypJSON string
var createdAt, updatedAt string
err := rows.Scan(
&a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON,
&usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint,
&a.Documentation, &a.Notes, &a.RepoURL, &a.DirPath, &a.ContentHash,
&createdAt, &updatedAt,
)
if err != nil {
return nil, fmt.Errorf("scanning analysis: %w", err)
}
a.Tags = unmarshalStrings(tagsJSON)
a.UsesFunctions = unmarshalStrings(usesFnJSON)
a.UsesTypes = unmarshalStrings(usesTypJSON)
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
a.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
result = append(result, a)
}
return result, nil
}
func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Function, error) {
var result []Function
for rows.Next() {