commit fec3e8e2a5e28dd09442a51614166905caa71ea9 Author: @s.roertgen Date: Sat Mar 29 09:59:39 2025 +0100 Basic Eventstore for 30142 seems to work diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..160be26 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/sroertgen/eventstore + +go 1.24.1 + +require ( + github.com/fiatjaf/eventstore v0.16.2 + github.com/nbd-wtf/go-nostr v0.51.8 +) + +require ( + github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/bytedance/sonic v1.13.1 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/sys v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9d0ad4b --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g= +github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fiatjaf/eventstore v0.16.2 h1:h4rHwSwPcqAKqWUsAbYWUhDeSgm2Kp+PBkJc3FgBYu4= +github.com/fiatjaf/eventstore v0.16.2/go.mod h1:0gU8fzYO/bG+NQAVlHtJWOlt3JKKFefh5Xjj2d1dLIs= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nbd-wtf/go-nostr v0.51.8 h1:CIoS+YqChcm4e1L1rfMZ3/mIwTz4CwApM2qx7MHNzmE= +github.com/nbd-wtf/go-nostr v0.51.8/go.mod h1:d6+DfvMWYG5pA3dmNMBJd6WCHVDDhkXbHqvfljf0Gzg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= +golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/typesense30142/delete.go b/typesense30142/delete.go new file mode 100644 index 0000000..287fc32 --- /dev/null +++ b/typesense30142/delete.go @@ -0,0 +1,34 @@ +package typesense30142 + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/nbd-wtf/go-nostr" +) + +// Delete a nostr event from the index +func (ts *TSBackend) DeleteEvent(ctx context.Context, event *nostr.Event) error { + fmt.Println("deleting event") + d := event.Tags.GetD() + + url := fmt.Sprintf( + "%s/collections/%s/documents?filter_by=d:=%s&&eventPubKey:=%s", + ts.Host, ts.CollectionName, d, event.PubKey) + + resp, err := ts.makehttpRequest(url, http.MethodDelete, nil) + if err != nil { + return err + } + + // Any status code other than 200 is an error + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + diff --git a/typesense30142/lib.go b/typesense30142/lib.go new file mode 100644 index 0000000..e34860e --- /dev/null +++ b/typesense30142/lib.go @@ -0,0 +1,30 @@ +package typesense30142 + +import ( + "context" + "fmt" + + "github.com/fiatjaf/eventstore" + "github.com/nbd-wtf/go-nostr" +) + +var _ eventstore.Store = (*TSBackend)(nil) + +type TSBackend struct { + ApiKey string + Host string + CollectionName string +} + +func (ts *TSBackend) Init() error { + err := ts.CheckOrCreateCollection() + if err != nil { + return fmt.Errorf("Failed to check/create collection: %v", err) + } + + return nil +} + +func (ts *TSBackend) Close() {} + +func (ts *TSBackend) SaveEvent(ctx context.Context, event *nostr.Event) error {return nil} diff --git a/typesense30142/nostramb.go b/typesense30142/nostramb.go new file mode 100644 index 0000000..0dabdf9 --- /dev/null +++ b/typesense30142/nostramb.go @@ -0,0 +1,328 @@ +package typesense30142 + +import ( + "encoding/json" + + "github.com/nbd-wtf/go-nostr" +) + +// BaseEntity contains common fields used across many entity types +type BaseEntity struct { + Type string `json:"type,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +type ControlledVocabulary struct { + Type string `json:"type,omitempty"` + ID string `json:"id"` + PrefLabel string `json:"prefLabel"` + InLanguage string `json:"inLanguage,omitempty"` +} + +// LanguageEntity adds language support to entities +type LanguageEntity struct { + InLanguage string `json:"inLanguage,omitempty"` +} + +// LabeledEntity adds prefLabel to entities +type LabeledEntity struct { + PrefLabel string `json:"prefLabel,omitempty"` +} + +// About represents a topic or subject +type About struct { + ControlledVocabulary +} + +// Creator represents the creator of content +type Creator struct { + BaseEntity + Affiliation *Affiliation `json:"affiliation,omitempty"` + HonoricPrefix string `json:"honoricPrefix,omitempty"` +} + +// Contributor represents someone who contributed to the content +type Contributor struct { + BaseEntity + HonoricPrefix string `json:"honoricPrefix,omitempty"` +} + +// Publisher represents the publisher of the content +type Publisher struct { + BaseEntity +} + +// Funder represents an entity that funded the content +type Funder struct { + BaseEntity +} + +// Affiliation represents an organization affiliation +type Affiliation struct { + BaseEntity +} + +// ConditionsOfAccess represents access conditions +type ConditionsOfAccess struct { + ControlledVocabulary +} + +// LearningResourceType categorizes the learning resource +type LearningResourceType struct { + ControlledVocabulary +} + +// Audience represents the target audience +type Audience struct { + ControlledVocabulary +} + +// Teaches represents what the content teaches +type Teaches struct { + ID string `json:"id"` + LabeledEntity + LanguageEntity +} + +// Assesses represents what the content assesses +type Assesses struct { + ID string `json:"id"` + LabeledEntity + LanguageEntity +} + +// CompetencyRequired represents required competencies +type CompetencyRequired struct { + ID string `json:"id"` + LabeledEntity + LanguageEntity +} + +// EducationalLevel represents the educational level +type EducationalLevel struct { + ControlledVocabulary +} + +// InteractivityType represents the type of interactivity +type InteractivityType struct { + ControlledVocabulary +} + +// IsBasedOn represents a reference to source material +type IsBasedOn struct { + Type string `json:"type,omitempty"` + Name string `json:"name"` + Creator *Creator `json:"creator,omitempty"` + License *License `json:"license,omitempty"` +} + +// IsPartOf represents a parent relationship +type IsPartOf struct { + BaseEntity +} + +type HasPart struct { + BaseEntity +} + +// Trailer represents a media trailer +type Trailer struct { + Type string `json:"type"` + ContentUrl string `json:"contentUrl"` + EncodingFormat string `json:"encodingFormat"` + ContentSize string `json:"contentSize,omitempty"` + Sha256 string `json:"sha256,omitempty"` + EmbedUrl string `json:"embedUrl,omitempty"` + Bitrate string `json:"bitrate,omitempty"` +} + +// License represents the content license +type License struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +// NostrMetadata contains Nostr-specific metadata +type NostrMetadata struct { + EventID string `json:"eventID"` + EventKind int `json:"eventKind"` + EventPubKey string `json:"eventPubKey"` + EventSig string `json:"eventSignature"` + EventCreatedAt nostr.Timestamp `json:"eventCreatedAt"` + EventContent string `json:"eventContent"` + EventRaw string `json:"eventRaw"` +} + +// AMBMetadata represents the full metadata structure +type AMBMetadata struct { + // Event ID + ID string `json:"id"` + // Document ID + D string `json:"d"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + About []*About `json:"about,omitempty"` + Keywords []string `json:"keywords,omitempty"` + InLanguage []string `json:"inLanguage,omitempty"` + Image string `json:"image,omitempty"` + Trailer []*Trailer `json:"trailer,omitempty"` + + // Provenience + Creator []*Creator `json:"creator,omitempty"` + Contributor []*Contributor `json:"contributor,omitempty"` + DateCreated string `json:"dateCreated,omitempty"` + DatePublished string `json:"datePublished,omitempty"` + DateModified string `json:"dateModified,omitempty"` + Publisher []*Publisher `json:"publisher,omitempty"` + Funder []*Funder `json:"funder,omitempty"` + + // Costs and Rights + IsAccessibleForFree bool `json:"isAccessibleForFree,omitempty"` + License *License `json:"license,omitempty"` + ConditionsOfAccess *ConditionsOfAccess `json:"conditionsOfAccess,omitempty"` + + // Educational metadata + LearningResourceType []*LearningResourceType `json:"learningResourceType,omitempty"` + Audience []*Audience `json:"audience,omitempty"` + Teaches []*Teaches `json:"teaches,omitempty"` + Assesses []*Assesses `json:"assesses,omitempty"` + CompetencyRequired []*CompetencyRequired `json:"competencyRequired,omitempty"` + EducationalLevel []*EducationalLevel `json:"educationalLevel,omitempty"` + InteractivityType *InteractivityType `json:"interactivityType,omitempty"` + + // Relation + IsBasedOn []*IsBasedOn `json:"isBasedOn,omitempty"` + IsPartOf []*IsPartOf `json:"isPartOf,omitempty"` + HasPart []*HasPart `json:"hasPart,omitempty"` + + // Technical + Duration string `json:"duration,omitempty"` + // TODO Encoding `` + // TODO Caption + + // Nostr integration + NostrMetadata `json:",inline"` +} + +// converts a nostr event to stringified JSON +func eventToStringifiedJSON(event *nostr.Event) (string, error) { + jsonData, err := json.Marshal(event) + if err != nil { + return "", err + } + + jsonString := string(jsonData) + return jsonString, err +} + +// NostrToAMB converts a Nostr event of kind 30142 to AMB metadata +func NostrToAMB(event *nostr.Event) (*AMBMetadata, error) { + eventRaw, _ := eventToStringifiedJSON(event) + + amb := &AMBMetadata{ + Type: "LearningResource", + NostrMetadata: NostrMetadata{ + EventID: event.ID, + EventPubKey: event.PubKey, + EventContent: event.Content, + EventCreatedAt: event.CreatedAt, + EventKind: event.Kind, + EventSig: event.Sig, + EventRaw: eventRaw, + }, + } + + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + + // TODO alle Attribute durchgehen für das parsen + switch tag[0] { + case "d": + if len(tag) >= 2 { + amb.ID = event.ID + amb.D = tag[1] + } + case "name": + if len(tag) >= 2 { + amb.Name = tag[1] + } + case "description": + if len(tag) >= 2 { + amb.Description = tag[1] + } + case "creator": + if len(tag) >= 2 { + creator := &Creator{} + creator.Name = tag[1] + if len(tag) >= 3 { + creator.ID = tag[2] + } + if len(tag) >= 4 { + creator.Type = tag[3] + } + + amb.Creator = append(amb.Creator, creator) + } + case "image": + if len(tag) >= 2 { + amb.Image = tag[1] + } + case "about": + if len(tag) >= 3 { + subject := &About{} + subject.PrefLabel = tag[1] + subject.InLanguage = tag[2] + if len(tag) >= 4 { + subject.ID = tag[3] + } + amb.About = append(amb.About, subject) + } + case "learningResourceType": + if len(tag) >= 3 { + lrt := &LearningResourceType{} + lrt.PrefLabel = tag[1] + lrt.InLanguage = tag[2] + if len(tag) >= 4 { + lrt.ID = tag[3] + } + amb.LearningResourceType = append(amb.LearningResourceType, lrt) + } + case "inLanguage": + if len(tag) >= 2 { + amb.InLanguage = append(amb.InLanguage, tag[1]) + } + case "keywords": + if len(tag) >= 2 { + amb.Keywords = tag[1:] + } + case "license": + if len(tag) >= 3 { + amb.License = &License{} + amb.License.ID = tag[1] + amb.License.Name = tag[2] + + } + case "datePublished": + if len(tag) >= 2 { + amb.DatePublished = tag[1] + } + } + } + + return amb, nil +} + +// converts a stringified JSON event to a nostr.Event +func StringifiedJSONToNostrEvent(jsonString string) (nostr.Event, error) { + var event nostr.Event + err := json.Unmarshal([]byte(jsonString), &event) + if err != nil { + return nostr.Event{}, err + } + return event, nil +} + diff --git a/typesense30142/query.go b/typesense30142/query.go new file mode 100644 index 0000000..ce226df --- /dev/null +++ b/typesense30142/query.go @@ -0,0 +1,195 @@ +package typesense30142 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/nbd-wtf/go-nostr" +) + +func (ts *TSBackend) QueryEvents(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) { + ch := make(chan *nostr.Event) + + nostrs, err := ts.SearchResources(filter.Search) + if err != nil { + log.Printf("Search failed: %v", err) + return ch, err + } + + go func() { + for _, evt := range nostrs { + ch <- &evt + } + close(ch) + }() + return ch, nil +} + +// SearchQuery represents a parsed search query with raw terms and field filters +type SearchQuery struct { + RawTerms []string + FieldFilters map[string]string +} + +// ParseSearchQuery parses a search string with support for quoted terms and field:value pairs +func ParseSearchQuery(searchStr string) SearchQuery { + var query SearchQuery + query.RawTerms = []string{} + query.FieldFilters = make(map[string]string) + + // Regular expression to match quoted strings and field:value pairs + // This regex handles: + // 1. Quoted strings (preserving spaces and everything inside) + // 2. Field:value pairs + // 3. Regular words + re := regexp.MustCompile(`"([^"]+)"|(\S+\.\S+):(\S+)|(\S+)`) + matches := re.FindAllStringSubmatch(searchStr, -1) + + for _, match := range matches { + if match[1] != "" { + // This is a quoted string, add it to raw terms + query.RawTerms = append(query.RawTerms, match[1]) + } else if match[2] != "" && match[3] != "" { + // This is a field:value pair + fieldName := match[2] + fieldValue := match[3] + query.FieldFilters[fieldName] = fieldValue + } else if match[4] != "" { + // This is a regular word, check if it's a simple field:value + parts := strings.SplitN(match[4], ":", 2) + if len(parts) == 2 && !strings.Contains(parts[0], ".") { + // Simple field:value without dot notation + query.FieldFilters[parts[0]] = parts[1] + } else { + // Regular search term + query.RawTerms = append(query.RawTerms, match[4]) + } + } + } + + return query +} + +// BuildTypesenseQuery builds a Typesense search query from a parsed SearchQuery +func BuildTypesenseQuery(query SearchQuery) (string, map[string]string, error) { + // Join raw terms for the main query + mainQuery := strings.Join(query.RawTerms, " ") + + // Parameters for filter_by and other Typesense parameters + params := make(map[string]string) + + // Build filter expressions for field filters + var filterExpressions []string + + for field, value := range query.FieldFilters { + // Handle special fields with dot notation + if strings.Contains(field, ".") { + parts := strings.SplitN(field, ".", 2) + fieldName := parts[0] + subField := parts[1] + + filterExpressions = append(filterExpressions, fmt.Sprintf("%s.%s:%s", fieldName, subField, value)) + } else { + filterExpressions = append(filterExpressions, fmt.Sprintf("%s:%s", field, value)) + } + } + + // Combine all filter expressions + if len(filterExpressions) > 0 { + params["filter_by"] = strings.Join(filterExpressions, " && ") + } + + return mainQuery, params, nil +} + +// SearchResources searches for resources and returns both the AMB metadata and converted Nostr events +func (ts *TSBackend) SearchResources(searchStr string) ([]nostr.Event, error) { + parsedQuery := ParseSearchQuery(searchStr) + + mainQuery, params, err := BuildTypesenseQuery(parsedQuery) + if err != nil { + return nil, fmt.Errorf("error building Typesense query: %v", err) + } + + // URL encode the main query + encodedQuery := url.QueryEscape(mainQuery) + + // Default fields to search in + queryBy := "name,description" + + // Start building the search URL + searchURL := fmt.Sprintf("%s/collections/%s/documents/search?q=%s&query_by=%s", + ts.Host, ts.CollectionName, encodedQuery, queryBy) + + // Add additional parameters + for key, value := range params { + searchURL += fmt.Sprintf("&%s=%s", key, url.QueryEscape(value)) + } + + // Debug information + fmt.Printf("Search URL: %s\n", searchURL) + + resp, err := ts.makehttpRequest(searchURL, http.MethodGet, nil) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + // Check for errors + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("search failed with status code %d: %s", resp.StatusCode, string(body)) + } + + return parseSearchResponse(body) +} + +func parseSearchResponse(responseBody []byte) ([]nostr.Event, error) { + var searchResponse SearchResponse + if err := json.Unmarshal(responseBody, &searchResponse); err != nil { + return nil, fmt.Errorf("error parsing search response: %v", err) + } + + nostrResults := make([]nostr.Event, 0, len(searchResponse.Hits)) + + for _, hit := range searchResponse.Hits { + // Extract the document from the hit + docMap, ok := hit["document"].(map[string]AMBMetadata) + if !ok { + return nil, fmt.Errorf("invalid document format in search results") + } + + // Convert the map to AMB metadata + docJSON, err := json.Marshal(docMap) + if err != nil { + return nil, fmt.Errorf("error marshaling document: %v", err) + } + + var ambData AMBMetadata + if err := json.Unmarshal(docJSON, &ambData); err != nil { + return nil, fmt.Errorf("error unmarshaling to AMBMetadata: %v", err) + } + + // Convert the AMB metadata to a Nostr event + nostrEvent, err := StringifiedJSONToNostrEvent(ambData.EventRaw) + if err != nil { + fmt.Printf("Warning: failed to convert AMB to Nostr: %v\n", err) + continue + } + + nostrResults = append(nostrResults, nostrEvent) + } + + // Print the number of results for logging + fmt.Printf("Found %d results\n", + len(nostrResults)) + + return nostrResults, nil +} diff --git a/typesense30142/replace.go b/typesense30142/replace.go new file mode 100644 index 0000000..597fa29 --- /dev/null +++ b/typesense30142/replace.go @@ -0,0 +1,72 @@ +package typesense30142 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/nbd-wtf/go-nostr" +) + +// IndexNostrEvent converts a Nostr event to AMB metadata and indexes it in Typesense +func (ts *TSBackend) ReplaceEvent(ctx context.Context, event *nostr.Event) error { + ambData, err := NostrToAMB(event) + if err != nil { + return fmt.Errorf("error converting Nostr event to AMB metadata: %v", err) + } + + // check if event is already there, if so replace it, else index it + alreadyIndexed, err := ts.eventAlreadyIndexed(ambData) + return ts.indexDocument(ctx, ambData, alreadyIndexed) +} + +func (ts *TSBackend) eventAlreadyIndexed(doc *AMBMetadata) (*nostr.Event, error) { + url := fmt.Sprintf( + "%s/collections/%s/documents/search?filter_by=d:=%s&&eventPubKey:=%s&q=&query_by=d,eventPubKey", + ts.Host, ts.CollectionName, doc.D, doc.EventPubKey) + + resp, err := ts.makehttpRequest(url, http.MethodGet, nil) + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Search for event failed, status: %d, body: %s", resp.StatusCode, string(body)) + } + + events, err := parseSearchResponse(body) + if err != nil { + return nil, fmt.Errorf("Error while parsing search response: %v", err) + } + + // Check if we found any events + if len(events) == 0 { + return nil, nil + } + return &events[0], nil +} + +// Index a document in Typesense +func (ts *TSBackend) indexDocument(ctx context.Context, doc *AMBMetadata, alreadyIndexedEvent *nostr.Event) error { + if alreadyIndexedEvent != nil { + fmt.Println("deleting old event for new one") + ts.DeleteEvent(ctx, alreadyIndexedEvent) + } + + url := fmt.Sprintf("%s/collections/%s/documents", ts.Host, ts.CollectionName) + + jsonData, err := json.Marshal(doc) + if err != nil { + return err + } + resp, err := ts.makehttpRequest(url, http.MethodPost, jsonData) + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("failed to index document, status: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + + diff --git a/typesense30142/typesense.go b/typesense30142/typesense.go new file mode 100644 index 0000000..152017c --- /dev/null +++ b/typesense30142/typesense.go @@ -0,0 +1,182 @@ +package typesense30142 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/nbd-wtf/go-nostr" +) + +type CollectionSchema struct { + Name string `json:"name"` + Fields []Field `json:"fields"` + DefaultSortingField string `json:"default_sorting_field"` + EnableNestedFields bool `json:"enable_nested_fields"` +} + +type Field struct { + Name string `json:"name"` + Type string `json:"type"` + Facet bool `json:"facet,omitempty"` + Optional bool `json:"optional,omitempty"` +} + +type SearchResponse struct { + Found int `json:"found"` + Hits []map[string]any `json:"hits"` + Page int `json:"page"` + Request map[string]any `json:"request"` +} + +// CheckOrCreateCollection checks if a collection exists and creates it if it doesn't +func (ts *TSBackend) CheckOrCreateCollection() error { + exists, err := ts.collectionExists() + if err != nil { + log.Fatalf("Error checking collection: %v", err) + } + + if !exists { + log.Printf("Collection %s does not exist. Creating...\n", ts.CollectionName) + if err := ts.createCollection(ts.CollectionName); err != nil { + log.Fatalf("Error creating collection: %v", err) + } + log.Printf("Collection %s created successfully\n", ts.CollectionName) + } else { + log.Printf("Collection %s already exists\n", ts.CollectionName) + } + + return nil +} + +func (ts *TSBackend) collectionExists() (bool, error) { + url := fmt.Sprintf("%s/collections/%s", ts.Host, ts.CollectionName) + + resp, err := ts.makehttpRequest(url, http.MethodGet, nil) + if err != nil { + return false, err + } + // 404 means collection doesn't exist + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + + // Any status code other than 200 is an error + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + return true, nil +} + +// create a typesense collection +func (ts *TSBackend) createCollection(name string) error { + schema := CollectionSchema{ + Name: name, + Fields: []Field{ + // Base information + {Name: "id", Type: "string"}, + {Name: "d", Type: "string"}, + {Name: "type", Type: "string"}, + {Name: "name", Type: "string"}, + {Name: "description", Type: "string", Optional: true}, + {Name: "about", Type: "object[]", Optional: true}, + {Name: "keywords", Type: "string[]", Optional: true}, + {Name: "inLanguage", Type: "string[]", Optional: true}, + {Name: "image", Type: "string", Optional: true}, + {Name: "trailer", Type: "object[]", Optional: true}, + + // Provenience + {Name: "creator", Type: "object[]", Optional: true}, + {Name: "contributor", Type: "object[]", Optional: true}, + {Name: "dateCreated", Type: "string", Optional: true}, + {Name: "datePublished", Type: "string", Optional: true}, + {Name: "dateModified", Type: "string", Optional: true}, + {Name: "publisher", Type: "object[]", Optional: true}, + {Name: "funder", Type: "object[]", Optional: true}, + + // Costs and Rights + {Name: "isAccessibleForFree", Type: "bool", Optional: true}, + {Name: "license", Type: "object", Optional: true}, + {Name: "conditionsOfAccess", Type: "object", Optional: true}, + + // Educational Metadata + {Name: "learningResourceType", Type: "object[]", Optional: true}, + {Name: "audience", Type: "object[]", Optional: true}, + {Name: "teaches", Type: "object[]", Optional: true}, + {Name: "assesses", Type: "object[]", Optional: true}, + {Name: "competencyRequired", Type: "object[]", Optional: true}, + {Name: "educationalLevel", Type: "object[]", Optional: true}, + {Name: "interactivityType", Type: "object", Optional: true}, + + // Relation + {Name: "isBasedOn", Type: "object[]", Optional: true}, + {Name: "isPartOf", Type: "object[]", Optional: true}, + {Name: "hasPart", Type: "object[]", Optional: true}, + + // Technical + {Name: "duration", Type: "string", Optional: true}, + + // Nostr Event + {Name: "eventID", Type: "string"}, + {Name: "eventKind", Type: "int32"}, + {Name: "eventPubKey", Type: "string"}, + {Name: "eventSignature", Type: "string"}, + {Name: "eventCreatedAt", Type: "int64"}, + {Name: "eventContent", Type: "string"}, + {Name: "eventRaw", Type: "string"}, + }, + DefaultSortingField: "eventCreatedAt", + EnableNestedFields: true, + } + + url := fmt.Sprintf("%s/collections", ts.Host) + + jsonData, err := json.Marshal(schema) + if err != nil { + return err + } + + resp, err := ts.makehttpRequest(url, http.MethodPost, jsonData) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to create collection, status: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// Makes an http request to typesense +func (ts *TSBackend) makehttpRequest(url string, method string, reqBody []byte) (*http.Response, error) { + req, err := http.NewRequest(method, url, bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + + req.Header.Set("X-TYPESENSE-API-KEY", ts.ApiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return resp, nil +} + +// TODO Count events +func CountEvents(filter nostr.Filter) (int64, error) { + fmt.Println("filter", filter) + // search by author + // search by d-tag + return 0, nil +} + +