diff --git a/backend/.gen/haystack/haystack/model/image.go b/backend/.gen/haystack/haystack/model/image.go new file mode 100644 index 0000000..e80c52a --- /dev/null +++ b/backend/.gen/haystack/haystack/model/image.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" +) + +type Image struct { + ID uuid.UUID `sql:"primary_key"` + ImageName string + Image []byte +} diff --git a/backend/.gen/haystack/haystack/model/image_tags.go b/backend/.gen/haystack/haystack/model/image_tags.go index a3c666c..8b20f01 100644 --- a/backend/.gen/haystack/haystack/model/image_tags.go +++ b/backend/.gen/haystack/haystack/model/image_tags.go @@ -12,6 +12,7 @@ import ( ) type ImageTags struct { + ID uuid.UUID `sql:"primary_key"` TagID uuid.UUID ImageID uuid.UUID } diff --git a/backend/.gen/haystack/haystack/model/user_images.go b/backend/.gen/haystack/haystack/model/user_images.go index 18c33ca..d6b4998 100644 --- a/backend/.gen/haystack/haystack/model/user_images.go +++ b/backend/.gen/haystack/haystack/model/user_images.go @@ -12,8 +12,7 @@ import ( ) type UserImages struct { - ID uuid.UUID `sql:"primary_key"` - ImageName string - Image []byte - UserID uuid.UUID + ID uuid.UUID `sql:"primary_key"` + ImageID uuid.UUID + UserID uuid.UUID } diff --git a/backend/.gen/haystack/haystack/model/user_images_to_process.go b/backend/.gen/haystack/haystack/model/user_images_to_process.go new file mode 100644 index 0000000..8b8ee4c --- /dev/null +++ b/backend/.gen/haystack/haystack/model/user_images_to_process.go @@ -0,0 +1,18 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" +) + +type UserImagesToProcess struct { + ID uuid.UUID `sql:"primary_key"` + ImageID uuid.UUID + UserID uuid.UUID +} diff --git a/backend/.gen/haystack/haystack/table/image.go b/backend/.gen/haystack/haystack/table/image.go new file mode 100644 index 0000000..9185561 --- /dev/null +++ b/backend/.gen/haystack/haystack/table/image.go @@ -0,0 +1,81 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var Image = newImageTable("haystack", "image", "") + +type imageTable struct { + postgres.Table + + // Columns + ID postgres.ColumnString + ImageName postgres.ColumnString + Image postgres.ColumnString + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList +} + +type ImageTable struct { + imageTable + + EXCLUDED imageTable +} + +// AS creates new ImageTable with assigned alias +func (a ImageTable) AS(alias string) *ImageTable { + return newImageTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new ImageTable with assigned schema name +func (a ImageTable) FromSchema(schemaName string) *ImageTable { + return newImageTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new ImageTable with assigned table prefix +func (a ImageTable) WithPrefix(prefix string) *ImageTable { + return newImageTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new ImageTable with assigned table suffix +func (a ImageTable) WithSuffix(suffix string) *ImageTable { + return newImageTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newImageTable(schemaName, tableName, alias string) *ImageTable { + return &ImageTable{ + imageTable: newImageTableImpl(schemaName, tableName, alias), + EXCLUDED: newImageTableImpl("", "excluded", ""), + } +} + +func newImageTableImpl(schemaName, tableName, alias string) imageTable { + var ( + IDColumn = postgres.StringColumn("id") + ImageNameColumn = postgres.StringColumn("image_name") + ImageColumn = postgres.StringColumn("image") + allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, ImageColumn} + mutableColumns = postgres.ColumnList{ImageNameColumn, ImageColumn} + ) + + return imageTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + ImageName: ImageNameColumn, + Image: ImageColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + } +} diff --git a/backend/.gen/haystack/haystack/table/image_tags.go b/backend/.gen/haystack/haystack/table/image_tags.go index 9aaa4f8..c3a30dd 100644 --- a/backend/.gen/haystack/haystack/table/image_tags.go +++ b/backend/.gen/haystack/haystack/table/image_tags.go @@ -17,6 +17,7 @@ type imageTagsTable struct { postgres.Table // Columns + ID postgres.ColumnString TagID postgres.ColumnString ImageID postgres.ColumnString @@ -59,9 +60,10 @@ func newImageTagsTable(schemaName, tableName, alias string) *ImageTagsTable { func newImageTagsTableImpl(schemaName, tableName, alias string) imageTagsTable { var ( + IDColumn = postgres.StringColumn("id") TagIDColumn = postgres.StringColumn("tag_id") ImageIDColumn = postgres.StringColumn("image_id") - allColumns = postgres.ColumnList{TagIDColumn, ImageIDColumn} + allColumns = postgres.ColumnList{IDColumn, TagIDColumn, ImageIDColumn} mutableColumns = postgres.ColumnList{TagIDColumn, ImageIDColumn} ) @@ -69,6 +71,7 @@ func newImageTagsTableImpl(schemaName, tableName, alias string) imageTagsTable { Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns + ID: IDColumn, TagID: TagIDColumn, ImageID: ImageIDColumn, diff --git a/backend/.gen/haystack/haystack/table/table_use_schema.go b/backend/.gen/haystack/haystack/table/table_use_schema.go index f7f65cd..913cd2a 100644 --- a/backend/.gen/haystack/haystack/table/table_use_schema.go +++ b/backend/.gen/haystack/haystack/table/table_use_schema.go @@ -10,10 +10,12 @@ package table // UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke // this method only once at the beginning of the program. func UseSchema(schema string) { + Image = Image.FromSchema(schema) ImageLinks = ImageLinks.FromSchema(schema) ImageTags = ImageTags.FromSchema(schema) ImageText = ImageText.FromSchema(schema) UserImages = UserImages.FromSchema(schema) + UserImagesToProcess = UserImagesToProcess.FromSchema(schema) UserTags = UserTags.FromSchema(schema) Users = Users.FromSchema(schema) } diff --git a/backend/.gen/haystack/haystack/table/user_images.go b/backend/.gen/haystack/haystack/table/user_images.go index 98bbd64..5e82a2b 100644 --- a/backend/.gen/haystack/haystack/table/user_images.go +++ b/backend/.gen/haystack/haystack/table/user_images.go @@ -17,10 +17,9 @@ type userImagesTable struct { postgres.Table // Columns - ID postgres.ColumnString - ImageName postgres.ColumnString - Image postgres.ColumnString - UserID postgres.ColumnString + ID postgres.ColumnString + ImageID postgres.ColumnString + UserID postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -61,22 +60,20 @@ func newUserImagesTable(schemaName, tableName, alias string) *UserImagesTable { func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable { var ( - IDColumn = postgres.StringColumn("id") - ImageNameColumn = postgres.StringColumn("image_name") - ImageColumn = postgres.StringColumn("image") - UserIDColumn = postgres.StringColumn("user_id") - allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, ImageColumn, UserIDColumn} - mutableColumns = postgres.ColumnList{ImageNameColumn, ImageColumn, UserIDColumn} + IDColumn = postgres.StringColumn("id") + ImageIDColumn = postgres.StringColumn("image_id") + UserIDColumn = postgres.StringColumn("user_id") + allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn} + mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn} ) return userImagesTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - ID: IDColumn, - ImageName: ImageNameColumn, - Image: ImageColumn, - UserID: UserIDColumn, + ID: IDColumn, + ImageID: ImageIDColumn, + UserID: UserIDColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/.gen/haystack/haystack/table/user_images_to_process.go b/backend/.gen/haystack/haystack/table/user_images_to_process.go new file mode 100644 index 0000000..b3df555 --- /dev/null +++ b/backend/.gen/haystack/haystack/table/user_images_to_process.go @@ -0,0 +1,81 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var UserImagesToProcess = newUserImagesToProcessTable("haystack", "user_images_to_process", "") + +type userImagesToProcessTable struct { + postgres.Table + + // Columns + ID postgres.ColumnString + ImageID postgres.ColumnString + UserID postgres.ColumnString + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList +} + +type UserImagesToProcessTable struct { + userImagesToProcessTable + + EXCLUDED userImagesToProcessTable +} + +// AS creates new UserImagesToProcessTable with assigned alias +func (a UserImagesToProcessTable) AS(alias string) *UserImagesToProcessTable { + return newUserImagesToProcessTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new UserImagesToProcessTable with assigned schema name +func (a UserImagesToProcessTable) FromSchema(schemaName string) *UserImagesToProcessTable { + return newUserImagesToProcessTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new UserImagesToProcessTable with assigned table prefix +func (a UserImagesToProcessTable) WithPrefix(prefix string) *UserImagesToProcessTable { + return newUserImagesToProcessTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new UserImagesToProcessTable with assigned table suffix +func (a UserImagesToProcessTable) WithSuffix(suffix string) *UserImagesToProcessTable { + return newUserImagesToProcessTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newUserImagesToProcessTable(schemaName, tableName, alias string) *UserImagesToProcessTable { + return &UserImagesToProcessTable{ + userImagesToProcessTable: newUserImagesToProcessTableImpl(schemaName, tableName, alias), + EXCLUDED: newUserImagesToProcessTableImpl("", "excluded", ""), + } +} + +func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userImagesToProcessTable { + var ( + IDColumn = postgres.StringColumn("id") + ImageIDColumn = postgres.StringColumn("image_id") + UserIDColumn = postgres.StringColumn("user_id") + allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn} + mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn} + ) + + return userImagesToProcessTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + ImageID: ImageIDColumn, + UserID: UserIDColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + } +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fcbf23e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +screenmark +.env.docker +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1ab2a73 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM golang + +WORKDIR /app + +# Dependency management +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o /app/haystack + +EXPOSE 3040 + +CMD ["/app/haystack"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8619de6 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,26 @@ +# Running the backend + +1. Create a `.env.docker` file, which must contain the following. + +OPENAI_API_KEY=openai_key +DB_CONNECTION=postgresql://postgres:password@database:5432/haystack_db?sslmode=disable + +2. Use `docker-compose up` to spin up the containers. + +You should be able to access the backend through port `3040` + +# Methods + +For now, we cheat and add a `userId` header which if os type `UUID`. Use the auto generated test one (fcc22dbb-7792-4595-be8e-d0439e13990a). + +- `GET /image` | Returns all of the users image, including tags, links and text any image contains. +- `GET /image/{imageId}` | Returns the actual image, use this to display images in the UI. +- `POST /image/{imageNameWithExtension}` | Sends an image to the backend, saves it and sents it to open ai to later process. + +# Architecture + +1. The user posts an image, which gets saved on our database (all data, including images are saved on DB). +2. We listen for table event creation, and we can process this image by sending it to OpenAI. +3. After OpenAI responds, we write to the database. + +This means that for now, we don't have a notification system to tell the user when their image is done processing. But will do in the future. diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..d8cd091 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,27 @@ +services: + database: + image: postgres + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + volumes: + - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql + ports: + - 4321:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d haystack_db"] + interval: 10s + retries: 5 + start_period: 5s + timeout: 5s + backend: + build: . + restart: always + env_file: .env.docker + ports: + - 3040:3040 + depends_on: + database: + condition: service_healthy + restart: true diff --git a/backend/go.mod b/backend/go.mod index c5dca0b..fcdc38a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-chi/chi/v5 v5.2.1 // indirect github.com/go-jet/jet/v2 v2.12.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7ccdb24..ecd9db0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,7 @@ 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/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-jet/jet/v2 v2.12.0 h1:z2JfvBAZgsfxlQz6NXBYdZTXc7ep3jhbszTLtETv1JE= github.com/go-jet/jet/v2 v2.12.0/go.mod h1:ufQVRQeI1mbcO5R8uCEVcVf3Foej9kReBdwDx7YMWUM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/backend/main.go b/backend/main.go index 6d472b1..5f28569 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,29 +1,62 @@ package main import ( + "bytes" + "encoding/base64" + "encoding/json" "fmt" "io" "log" "net/http" + "os" + "path/filepath" "screenmark/screenmark/models" "time" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/joho/godotenv" "github.com/lib/pq" ) +type TestAiClient struct { + ImageInfo ImageInfo +} + +func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (ImageInfo, error) { + return client.ImageInfo, nil +} + +func GetAiClient() (AiClient, error) { + mode := os.Getenv("MODE") + if mode == "TESTING" { + return TestAiClient{ + ImageInfo: ImageInfo{ + Tags: []string{"tag"}, + Links: []string{"links"}, + Text: []string{"text"}, + }, + }, nil + } + + return CreateOpenAiClient() +} + func main() { err := godotenv.Load() if err != nil { panic(err) } + mode := os.Getenv("MODE") + log.Printf("Mode: %s\n", mode) + err = models.InitDatabase() if err != nil { panic(err) } - listener := pq.NewListener(models.CONNECTION, time.Second, time.Second, func(event pq.ListenerEventType, err error) { + listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) { if err != nil { panic(err) } @@ -36,42 +69,134 @@ func main() { panic(err) } - select { - case parameters := <-listener.Notify: - log.Println("received notification, new image available: " + parameters.Extra) + for { - go func() { - openAiClient, err := CreateOpenAiClient() - if err != nil { - panic(err) - } + select { + case parameters := <-listener.Notify: + imageId := parameters.Extra - image, err := models.GetImage(parameters.Extra) - if err != nil { - log.Println(err) - return - } + log.Println("received notification, new image available: " + imageId) - imageInfo, err := openAiClient.GetImageInfo(image.ImageName, image.Image) - if err != nil { - log.Println(err) - return - } + go func() { + openAiClient, err := GetAiClient() + if err != nil { + panic(err) + } - log.Printf("Info: %+v\n", imageInfo) - }() + image, err := models.GetImageToProcessWithData(imageId) + if err != nil { + log.Println("1") + log.Println(err) + return + } + + imageInfo, err := openAiClient.GetImageInfo(image.Image.ImageName, image.Image.Image) + if err != nil { + log.Println("2") + log.Println(err) + return + } + + savedImage, err := models.SaveImage(image.ID) + if err != nil { + log.Println("3") + log.Println(err) + return + } + + log.Println("Finished processing image " + imageId) + log.Printf("Image attributes: %+v\n", imageInfo) + + _, err = models.SaveImageTags(savedImage.ID.String(), imageInfo.Tags) + if err != nil { + log.Println("1") + log.Println(err) + } + + _, err = models.SaveImageLinks(savedImage.ID.String(), imageInfo.Links) + if err != nil { + log.Println("2") + log.Println(err) + } + + _, err = models.SaveImageTexts(savedImage.ID.String(), imageInfo.Text) + if err != nil { + log.Println("3") + log.Println(err) + } + }() + } } }() - mux := http.NewServeMux() + r := chi.NewRouter() - mux.HandleFunc("OPTIONS /image/{name}", func(w http.ResponseWriter, r *http.Request) { + r.Use(middleware.Logger) + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + + next.ServeHTTP(w, r) + }) + }) + + r.Options("/*", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Access-Control-Allow-Origin", "*") w.Header().Add("Access-Control-Allow-Credentials", "*") w.Header().Add("Access-Control-Allow-Headers", "*") }) - mux.HandleFunc("POST /image/{name}", func(w http.ResponseWriter, r *http.Request) { + r.Get("/image", func(w http.ResponseWriter, r *http.Request) { + userId := r.Header.Get("userId") + + w.Header().Add("Access-Control-Allow-Origin", "*") + w.Header().Add("Access-Control-Allow-Credentials", "*") + w.Header().Add("Access-Control-Allow-Headers", "*") + + images, err := models.GetUserImages(userId) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Something went wrong") + return + } + + jsonImages, err := json.Marshal(images) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Could not create JSON response for this image") + return + } + + w.Write(jsonImages) + }) + + r.Get("/image/{id}", func(w http.ResponseWriter, r *http.Request) { + imageId := r.PathValue("id") + + w.Header().Add("Access-Control-Allow-Origin", "*") + w.Header().Add("Access-Control-Allow-Credentials", "*") + w.Header().Add("Access-Control-Allow-Headers", "*") + + // TODO: really need authorization here! + image, err := models.GetImage(imageId) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Could not get image") + return + } + + // TODO: this could be part of the db table + extension := filepath.Ext(image.Image.ImageName) + extension = extension[1:] + + w.Header().Add("Content-Type", "image/"+extension) + w.Write(image.Image.Image) + }) + + r.Post("/image/{name}", func(w http.ResponseWriter, r *http.Request) { imageName := r.PathValue("name") userId := r.Header.Get("userId") @@ -86,24 +211,79 @@ func main() { return } - image, err := io.ReadAll(r.Body) + contentType := r.Header.Get("Content-Type") + + log.Println(contentType) + + // TODO: length checks on body + // TODO: extract this shit out + image := make([]byte, 0) + if contentType == "application/base64" { + decoder := base64.NewDecoder(base64.StdEncoding, r.Body) + buf := &bytes.Buffer{} + + decodedIamge, err := io.Copy(buf, decoder) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "bruh, base64 aint decoding") + return + } + + fmt.Println(string(image)) + fmt.Println(decodedIamge) + + image = buf.Bytes() + } else if contentType == "application/oclet-stream" { + bodyData, err := io.ReadAll(r.Body) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "bruh, binary aint binaring") + return + } + // TODO: check headers + + image = bodyData + } else { + log.Println("bad stuff?") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Bruh, you need oclet stream or base64") + return + } if err != nil { + log.Println("First case") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "Couldnt read the image from the request body") return } - err = models.SaveImage(userId, imageName, image) + userImage, err := models.SaveImageToProcess(userId, imageName, image) if err != nil { + log.Println("Second case") log.Println(err) w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "Could not save image to DB") return } + + jsonUserImage, err := json.Marshal(userImage) + if err != nil { + log.Println("Third case") + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Could not create JSON response for this image") + return + } + + w.WriteHeader(http.StatusCreated) + + fmt.Fprint(w, string(jsonUserImage)) + w.Header().Add("Content-Type", "application/json") }) - log.Println("Listening and serving.") + log.Println("Listening and serving on port 3040.") - http.ListenAndServe(":3040", mux) + http.ListenAndServe(":3040", r) } diff --git a/backend/models/database.go b/backend/models/database.go index 87edbb8..fb6f08b 100644 --- a/backend/models/database.go +++ b/backend/models/database.go @@ -2,16 +2,22 @@ package models import ( "database/sql" + "errors" + "os" _ "github.com/lib/pq" ) -const CONNECTION = "postgresql://localhost:5432/haystack?sslmode=disable" - var db *sql.DB func InitDatabase() error { - database, err := sql.Open("postgres", CONNECTION) + connection := os.Getenv("DB_CONNECTION") + + if len(connection) == 0 { + return errors.New("DB_CONNECTION env was not found.") + } + + database, err := sql.Open("postgres", connection) db = database diff --git a/backend/models/image.go b/backend/models/image.go index 39d91e9..d0d5f61 100644 --- a/backend/models/image.go +++ b/backend/models/image.go @@ -3,31 +3,234 @@ package models import ( "errors" "fmt" - - . "github.com/go-jet/jet/v2/postgres" "screenmark/screenmark/.gen/haystack/haystack/model" . "screenmark/screenmark/.gen/haystack/haystack/table" + . "github.com/go-jet/jet/v2/postgres" + "github.com/google/uuid" ) -func SaveImage(userId string, imageName string, imageData []byte) error { - stmt := UserImages.INSERT(UserImages.UserID, UserImages.ImageName, UserImages.Image).VALUES(userId, imageName, imageData) +func SaveImageToProcess(userId string, imageName string, imageData []byte) (model.UserImagesToProcess, error) { + insertImageStmt := Image.INSERT(Image.ImageName, Image.Image).VALUES(imageName, imageData).RETURNING(Image.ID) + + // TODO: should be a transaction + + image := model.Image{} + err := insertImageStmt.Query(db, &image) + if err != nil { + return model.UserImagesToProcess{}, err + } + + stmt := UserImagesToProcess.INSERT(UserImagesToProcess.UserID, UserImagesToProcess.ImageID).VALUES(userId, image.ID).RETURNING(UserImagesToProcess.AllColumns) + + fmt.Println(stmt.DebugSql()) + + userImage := model.UserImagesToProcess{} + err = stmt.Query(db, &userImage) + + return userImage, err +} + +func removeImageToProcess(imageId string) error { + id := uuid.MustParse(imageId) + + stmt := UserImagesToProcess.DELETE().WHERE(UserImagesToProcess.ID.EQ(UUID(id))) + _, err := stmt.Exec(db) return err } -func GetImage(imageId string) (model.UserImages, error) { - id := uuid.MustParse(imageId) - stmt := UserImages.SELECT(UserImages.ImageName, UserImages.Image).WHERE(UserImages.ID.EQ(UUID(id))) +func getUserId(imageId uuid.UUID) (uuid.UUID, error) { + stmt := UserImages.SELECT(UserImages.UserID).WHERE(UserImages.ID.EQ(UUID(imageId))) - images := []model.UserImages{} + fmt.Println(stmt.DebugSql()) + + userIds := make([]string, 0) + + err := stmt.Query(db, &userIds) + if err != nil { + return uuid.Nil, err + } + + if len(userIds) != 1 { + return uuid.Nil, errors.New("expect only one user id per image id") + } + + return uuid.Parse(userIds[0]) +} + +func SaveImage(imageId uuid.UUID) (model.UserImages, error) { + imageToProcess, err := GetImageToProcess(imageId.String()) + if err != nil { + return model.UserImages{}, err + } + + stmt := UserImages.INSERT(UserImages.UserID, UserImages.ImageID).VALUES(imageToProcess.UserID, imageToProcess.ImageID).RETURNING(UserImages.ID, UserImages.UserID, UserImages.ImageID) + + userImage := model.UserImages{} + err = stmt.Query(db, &userImage) + if err != nil { + return model.UserImages{}, err + } + + err = removeImageToProcess(imageId.String()) + if err != nil { + return model.UserImages{}, err + } + + return userImage, err +} + +type ImageData struct { + model.UserImages + + Image model.Image +} + +func GetImage(imageId string) (ImageData, error) { + id := uuid.MustParse(imageId) + stmt := SELECT(UserImages.AllColumns, Image.AllColumns).FROM(UserImages.INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID))).WHERE(UserImages.ID.EQ(UUID(id))) + + images := []ImageData{} err := stmt.Query(db, &images) if len(images) != 1 { - return model.UserImages{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images))) + return ImageData{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images))) } return images[0], err } + +type ImageToProcessData struct { + model.UserImagesToProcess + + Image model.Image +} + +func GetImageToProcessWithData(imageId string) (ImageToProcessData, error) { + id := uuid.MustParse(imageId) + // stmt := UserImagesToProcess.SELECT(UserImages.AllColumns).WHERE(UserImages.ID.EQ(UUID(id))) + + // TODO: Image should be `Images` + stmt := SELECT(UserImagesToProcess.AllColumns, Image.AllColumns).FROM(UserImagesToProcess.INNER_JOIN(Image, Image.ID.EQ(UserImagesToProcess.ImageID))).WHERE(UserImagesToProcess.ID.EQ(UUID(id))) + + images := []ImageToProcessData{} + err := stmt.Query(db, &images) + + if len(images) != 1 { + return ImageToProcessData{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images))) + } + + return images[0], err +} + +func GetImageToProcess(imageId string) (model.UserImagesToProcess, error) { + id := uuid.MustParse(imageId) + stmt := UserImagesToProcess.SELECT(UserImagesToProcess.AllColumns).WHERE(UserImagesToProcess.ID.EQ(UUID(id))) + + images := []model.UserImagesToProcess{} + err := stmt.Query(db, &images) + + if len(images) != 1 { + return model.UserImagesToProcess{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images))) + } + + return images[0], err +} + +type UserImagesWithInfo struct { + ID uuid.UUID + + // TODO: this shit + Image model.Image + + Tags []model.ImageTags + Links []model.ImageLinks + Text []model.ImageText +} + +func GetUserImages(userId string) ([]UserImagesWithInfo, error) { + id := uuid.MustParse(userId) + stmt := SELECT(UserImages.ID.AS("UserImagesWithInfo.ID"), Image.ID, Image.ImageName, ImageTags.AllColumns, ImageText.AllColumns, ImageLinks.AllColumns).FROM(UserImages.INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID)).LEFT_JOIN(ImageTags, ImageTags.ImageID.EQ(UserImages.ID)).LEFT_JOIN(ImageText, ImageText.ImageID.EQ(UserImages.ID)).LEFT_JOIN(ImageLinks, ImageLinks.ImageID.EQ(UserImages.ID))).WHERE(UserImages.UserID.EQ(UUID(id))) + + images := []UserImagesWithInfo{} + err := stmt.Query(db, &images) + + return images, err +} + +func SaveImageTags(imageId string, tags []string) ([]model.ImageTags, error) { + id := uuid.MustParse(imageId) + + userId, err := getUserId(id) + if err != nil { + return []model.ImageTags{}, err + } + + err = CreateTags(userId, tags) + if err != nil { + return []model.ImageTags{}, err + } + + userTagsExpression := make([]Expression, 0) + for _, tag := range tags { + userTagsExpression = append(userTagsExpression, String(tag)) + } + + userTags := make([]model.UserTags, 0) + + getTagsStmt := UserTags.SELECT(UserTags.ID, UserTags.Tag).WHERE(UserTags.Tag.IN(userTagsExpression...)) + err = getTagsStmt.Query(db, &userTags) + if err != nil { + return []model.ImageTags{}, err + } + + stmt := ImageTags.INSERT(ImageTags.ImageID, ImageTags.TagID) + + for _, t := range userTags { + stmt = stmt.VALUES(id, t.ID) + } + + stmt.RETURNING(ImageTags.AllColumns) + + imageTags := make([]model.ImageTags, 0) + err = stmt.Query(db, &imageTags) + + return imageTags, err +} + +func SaveImageLinks(imageId string, links []string) ([]model.ImageLinks, error) { + id := uuid.MustParse(imageId) + + stmt := ImageLinks.INSERT(ImageLinks.ImageID, ImageLinks.Link) + + for _, t := range links { + stmt = stmt.VALUES(id, t) + } + + stmt.RETURNING(ImageLinks.AllColumns) + + imageLinks := []model.ImageLinks{} + err := stmt.Query(db, &imageLinks) + + return imageLinks, err +} + +func SaveImageTexts(imageId string, texts []string) ([]model.ImageText, error) { + id := uuid.MustParse(imageId) + + stmt := ImageText.INSERT(ImageText.ImageID, ImageText.ImageText) + + for _, t := range texts { + stmt = stmt.VALUES(id, t) + } + + stmt.RETURNING(ImageText.AllColumns) + + imageTags := []model.ImageText{} + err := stmt.Query(db, &imageTags) + + return imageTags, err +} diff --git a/backend/models/tags.go b/backend/models/tags.go new file mode 100644 index 0000000..e61b977 --- /dev/null +++ b/backend/models/tags.go @@ -0,0 +1,116 @@ +package models + +import ( + "fmt" + "screenmark/screenmark/.gen/haystack/haystack/model" + . "screenmark/screenmark/.gen/haystack/haystack/table" + + . "github.com/go-jet/jet/v2/postgres" + "github.com/google/uuid" +) + +// Raw dogging SQL is kinda based though? +// +// | nO, usE OrM!! +// +// | RAW - RAW +// | SQL | \ SQL +// | GOOD | \ GOOD +// | - - +// | -- -- +// | -- -- +// | ---- IQ ---- +func getNonExistantTags(userId uuid.UUID, tags []string) ([]string, error) { + values := "" + counter := 1 + // big big SQL injection problem here? + for counter = 1; counter <= len(tags); counter++ { + values += fmt.Sprintf("($%d),", counter) + } + values = values[0 : len(values)-1] + + /* + WITH given_tags + AS (SELECT given_tags.tag FROM (VALUES ('c')) AS given_tags (tag)), + this_user_tags as ( + SELECT id, tag + FROM haystack.user_tags + where user_tags.user_id = 'fcc22dbb-7792-4595-be8e-d0439e13990a' + ) + select given_tags.tag from given_tags + LEFT OUTER JOIN this_user_tags ON this_user_tags.tag = given_tags.tag + where this_user_tags.tag is null; + */ + + withStuff := fmt.Sprintf(`WITH given_tags + AS (SELECT given_tags.tag FROM (VALUES `+values+`) AS given_tags (tag)), + this_user_tags AS + (SELECT id, tag FROM haystack.user_tags WHERE user_tags.user_id = $%d) + SELECT given_tags.tag + FROM given_tags + LEFT OUTER JOIN haystack.user_tags ON haystack.user_tags.tag = given_tags.tag + where user_tags.tag is null`, counter) + + stmt, err := db.Prepare(withStuff) + fmt.Println(withStuff) + + if err != nil { + fmt.Println("failing to prepare stmt") + return []string{}, err + } + + defer stmt.Close() + + args := make([]any, counter) + for i, v := range tags { + args[i] = v + } + args[counter-1] = userId.String() + + rows, err := stmt.Query(args...) + if err != nil { + return []string{}, err + } + + nonExistantTags := make([]string, 0) + + for rows.Next() { + var tag string + rows.Scan(&tag) + + nonExistantTags = append(nonExistantTags, tag) + } + + return nonExistantTags, nil +} + +func CreateTags(userId uuid.UUID, tags []string) error { + tagsToInsert, err := getNonExistantTags(userId, tags) + if err != nil { + return err + } + + if len(tagsToInsert) == 0 { + return nil + } + + stmt := UserTags.INSERT(UserTags.UserID, UserTags.Tag) + + for _, tag := range tagsToInsert { + stmt = stmt.VALUES(UUID(userId), tag) + } + + _, err = stmt.Exec(db) + + return err +} + +func GetTags(userId uuid.UUID) ([]model.UserTags, error) { + stmt := UserTags.SELECT(UserTags.AllColumns).WHERE(UserTags.UserID.EQ(UUID(userId))) + + userTags := []model.UserTags{} + + err := stmt.Query(db, &userTags) + + return userTags, err +} diff --git a/backend/openai.go b/backend/openai.go index 1bcd3aa..4a90a34 100644 --- a/backend/openai.go +++ b/backend/openai.go @@ -73,9 +73,7 @@ func (content *OpenAiMessages) AddImage(imageName string, image []byte) error { arrayMessage := OpenAiArrayMessage{Role: ROLE_USER, Content: make([]OpenAiContent, 1)} arrayMessage.Content[0] = OpenAiImage{ ImageType: IMAGE_TYPE, - ImageUrl: ImageUrl{ - Url: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString), - }, + ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString), } content.Messages = append(content.Messages, arrayMessage) @@ -105,8 +103,8 @@ type ImageUrl struct { } type OpenAiImage struct { - ImageType string `json:"type"` - ImageUrl ImageUrl `json:"image_url"` + ImageType string `json:"type"` + ImageUrl string `json:"image_url"` } func (imageMessage OpenAiImage) ToJson() ([]byte, error) { @@ -114,6 +112,10 @@ func (imageMessage OpenAiImage) ToJson() ([]byte, error) { return json.Marshal(imageMessage) } +type AiClient interface { + GetImageInfo(imageName string, imageData []byte) (ImageInfo, error) +} + type OpenAiClient struct { url string apiKey string @@ -137,8 +139,8 @@ const IMAGE_TYPE = "image_url" const PROMPT = ` You are an image information extractor. The user will provide you with screenshots and your job is to extract any relevant links and text that the image might contain. You will also try your best to assign some tags to this image, avoid too many tags. - -This system is part of a bookmark manager, who's main goal is to allow the user to search through various screenshots. +Be sure to extract every link (URL) that you find. +Use generic tags. ` const RESPONSE_FORMAT = ` @@ -189,7 +191,7 @@ func CreateOpenAiClient() (OpenAiClient, error) { return OpenAiClient{ apiKey: apiKey, - url: "https://api.openai.com/v1/chat/completions", + url: "https://api.mistral.ai/v1/chat/completions", systemPrompt: PROMPT, Do: func(req *http.Request) (*http.Response, error) { client := &http.Client{} @@ -210,10 +212,14 @@ func (client OpenAiClient) getRequest(body []byte) (*http.Request, error) { return req, nil } -func getCompletionsForImage(model string, temperature float64, prompt, imageName string, imageData []byte) (OpenAiRequestBody, error) { +func getCompletionsForImage(model string, temperature float64, prompt string, imageName string, jsonSchema string, imageData []byte) (OpenAiRequestBody, error) { request := OpenAiRequestBody{ Model: model, Temperature: temperature, + ResponseFormat: ResponseFormat{ + Type: "json_schema", + JsonSchema: jsonSchema, + }, } // TODO: Add build pattern here that deals with errors in some internal state? @@ -231,8 +237,49 @@ func getCompletionsForImage(model string, temperature float64, prompt, imageName return request, nil } +type ResponseChoiceMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ResponseChoice struct { + Index int `json:"index"` + Message ResponseChoiceMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type OpenAiResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Choices []ResponseChoice `json:"choices"` + Created int `json:"created"` +} + +// TODO: add usage parsing +func parseOpenAiResponse(jsonResponse []byte) (ImageInfo, error) { + response := OpenAiResponse{} + + err := json.Unmarshal(jsonResponse, &response) + if err != nil { + return ImageInfo{}, err + } + + if len(response.Choices) != 1 { + log.Println(string(jsonResponse)) + return ImageInfo{}, errors.New("Expected exactly one choice.") + } + + imageInfo := ImageInfo{} + err = json.Unmarshal([]byte(response.Choices[0].Message.Content), &imageInfo) + if err != nil { + return ImageInfo{}, errors.New("Could not parse content into image type.") + } + + return imageInfo, nil +} + func (client OpenAiClient) GetImageInfo(imageName string, imageData []byte) (ImageInfo, error) { - aiRequest, err := getCompletionsForImage("gpt-4o-mini", 1.0, client.systemPrompt, imageName, imageData) + aiRequest, err := getCompletionsForImage("pixtral-12b-2409", 1.0, client.systemPrompt, imageName, RESPONSE_FORMAT, imageData) if err != nil { return ImageInfo{}, err } @@ -268,13 +315,5 @@ func (client OpenAiClient) GetImageInfo(imageName string, imageData []byte) (Ima return ImageInfo{}, err } - info := ImageInfo{} - err = json.Unmarshal(response, &info) - if err != nil { - return ImageInfo{}, err - } - - log.Println(string(response)) - - return info, nil + return parseOpenAiResponse(response) } diff --git a/backend/openai_test.go b/backend/openai_test.go index 3a2f663..945f507 100644 --- a/backend/openai_test.go +++ b/backend/openai_test.go @@ -89,7 +89,7 @@ func TestMessageBuilderImage(t *testing.T) { } func TestFullImageRequest(t *testing.T) { - request, err := getCompletionsForImage("model", 0.1, "You are an assistant", "image.png", []byte("some data")) + request, err := getCompletionsForImage("model", 0.1, "You are an assistant", "image.png", "", []byte("some data")) if err != nil { t.Log(request) t.FailNow() @@ -101,7 +101,7 @@ func TestFullImageRequest(t *testing.T) { t.FailNow() } - expectedJson := `{"model":"model","temperature":0.1,"response_format":{"type":"","json_schema":""},"messages":[{"role":"system","content":"You are an assistant"},{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,c29tZSBkYXRh"}}]}]}` + expectedJson := `{"model":"model","temperature":0.1,"response_format":{"type":"json_schema","json_schema":""},"messages":[{"role":"system","content":"You are an assistant"},{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,c29tZSBkYXRh"}}]}]}` if string(jsonData) != expectedJson { t.Logf("Expected:\n%s\n Got:\n%s\n", expectedJson, string(jsonData)) @@ -149,3 +149,65 @@ func TestResponse(t *testing.T) { t.FailNow() } } + +func TestResponseParsing(t *testing.T) { + response := `{ + "id": "chatcmpl-B4XgiHcd7A2nyK7eyARdggSvfFuWQ", + "object": "chat.completion", + "created": 1740422508, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"links\":[\"link\"],\"tags\":[\"tag\"],\"text\":[\"text\"]}", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 775, + "completion_tokens": 33, + "total_tokens": 808, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_7fcd609668" +}` + + imageParsed, err := parseOpenAiResponse([]byte(response)) + if err != nil { + t.Log(err) + t.FailNow() + } + + if len(imageParsed.Links) != 1 || imageParsed.Links[0] != "link" { + t.Log(imageParsed) + t.Log("Should have one link called 'link'.") + t.FailNow() + } + + if len(imageParsed.Tags) != 1 || imageParsed.Tags[0] != "tag" { + t.Log(imageParsed) + t.Log("Should have one tag called 'tag'.") + t.FailNow() + } + + if len(imageParsed.Text) != 1 || imageParsed.Text[0] != "text" { + t.Log(imageParsed) + t.Log("Should have one text called 'text'.") + t.FailNow() + } +} diff --git a/backend/schema.sql b/backend/schema.sql index edbd83d..af3d517 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -1,3 +1,6 @@ +DROP DATABASE IF EXISTS haystack_db; +CREATE DATABASE haystack_db; + DROP SCHEMA IF EXISTS haystack CASCADE; CREATE SCHEMA haystack; @@ -8,21 +11,33 @@ CREATE TABLE haystack.users ( id uuid PRIMARY KEY DEFAULT gen_random_uuid() ); -CREATE TABLE haystack.user_images ( +CREATE TABLE haystack.image ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), image_name TEXT NOT NULL, - image BYTEA NOT NULL, + image BYTEA NOT NULL +); + +CREATE TABLE haystack.user_images_to_process ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id), + user_id uuid NOT NULL REFERENCES haystack.users (id) +); + +CREATE TABLE haystack.user_images ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id), user_id uuid NOT NULL REFERENCES haystack.users (id) ); CREATE TABLE haystack.user_tags ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - tag TEXT NOT NULL, + tag VARCHAR(32) UNIQUE NOT NULL, user_id uuid NOT NULL REFERENCES haystack.users (id) ); CREATE TABLE haystack.image_tags ( - tag_id UUID NOT NULL REFERENCES haystack.user_tags (id), + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tag_id UUID NOT NULL REFERENCES haystack.user_tags (id), image_id UUID NOT NULL REFERENCES haystack.user_images (id) ); @@ -38,6 +53,10 @@ CREATE TABLE haystack.image_links ( image_id UUID NOT NULL REFERENCES haystack.user_images (id) ); +/* -----| Indexes |----- */ + +CREATE INDEX user_tags_index ON haystack.user_tags(tag); + /* -----| Stored Procedures |----- */ CREATE OR REPLACE FUNCTION notify_new_image() @@ -51,6 +70,10 @@ $$ LANGUAGE plpgsql; /* -----| Triggers |----- */ CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT -ON haystack.user_images +ON haystack.user_images_to_process FOR EACH ROW -EXECUTE PROCEDURE notify_new_image() +EXECUTE PROCEDURE notify_new_image(); + +/* -----| Test Data |----- */ + +INSERT INTO haystack.users VALUES ('fcc22dbb-7792-4595-be8e-d0439e13990a'); diff --git a/frontend/biome.json b/frontend/biome.json index decb4ed..f8702e4 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,12 +1,12 @@ { - "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - } + "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } } diff --git a/frontend/bun.lockb b/frontend/bun.lockb index d69237e..e34de90 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 68a9eed..6214062 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,38 +1,41 @@ { - "name": "haystack", - "version": "0.1.0", - "description": "", - "type": "module", - "scripts": { - "start": "vite", - "dev": "vite", - "build": "vite build", - "serve": "vite preview", - "tauri": "tauri", - "lint": "bunx @biomejs/biome lint .", - "format": "bunx @biomejs/biome format . --write" - }, - "license": "MIT", - "dependencies": { - "@kobalte/core": "^0.13.9", - "@kobalte/tailwindcss": "^0.9.0", - "@tabler/icons-solidjs": "^3.30.0", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "~2", - "@tauri-apps/plugin-opener": "^2", - "clsx": "^2.1.1", - "solid-js": "^1.9.3", - "tailwind-scrollbar-hide": "^2.0.0" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@tauri-apps/cli": "^2", - "autoprefixer": "^10.4.20", - "postcss": "^8.5.3", - "postcss-cli": "^11.0.0", - "tailwindcss": "3.4.0", - "typescript": "~5.6.2", - "vite": "^6.0.3", - "vite-plugin-solid": "^2.11.0" - } + "name": "haystack", + "version": "0.1.0", + "description": "", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "tauri": "tauri", + "lint": "bunx @biomejs/biome lint .", + "format": "bunx @biomejs/biome format . --write" + }, + "license": "MIT", + "dependencies": { + "@kobalte/core": "^0.13.9", + "@kobalte/tailwindcss": "^0.9.0", + "@solidjs/router": "^0.15.3", + "@tabler/icons-solidjs": "^3.30.0", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "~2", + "@tauri-apps/plugin-opener": "^2", + "clsx": "^2.1.1", + "fuse.js": "^7.1.0", + "solid-js": "^1.9.3", + "tailwind-scrollbar-hide": "^2.0.0", + "valibot": "^1.0.0-rc.2" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@tauri-apps/cli": "^2", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.3", + "postcss-cli": "^11.0.0", + "tailwindcss": "3.4.0", + "typescript": "~5.6.2", + "vite": "^6.0.3", + "vite-plugin-solid": "^2.11.0" + } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2aa7205..7b75c83 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, }; diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index e8480e0..d8e24c2 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -1,12 +1,12 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], - "permissions": [ - "core:default", - "opener:default", - "dialog:default", - "core:window:allow-start-dragging" - ] + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "dialog:default", + "core:window:allow-start-dragging" + ] } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 04928dd..c1fcc24 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -116,7 +116,7 @@ pub fn run() { let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .hidden_title(true) .inner_size(480.0, 360.0) - .resizable(false); + .resizable(true); // set transparent title bar only when building for macOS #[cfg(target_os = "macos")] let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent); diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 2aaa7b5..d056e73 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,30 +1,30 @@ { - "$schema": "https://schema.tauri.app/config/2", - "productName": "haystack", - "version": "0.1.0", - "identifier": "com.haystack.app", - "build": { - "beforeDevCommand": "bun run dev", - "devUrl": "http://localhost:1420", - "beforeBuildCommand": "bun run build", - "frontendDist": "../dist" - }, - "app": { - "windows": [], - "macOSPrivateApi": true, - "security": { - "csp": null - } - }, - "bundle": { - "active": true, - "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] - } + "$schema": "https://schema.tauri.app/config/2", + "productName": "haystack", + "version": "0.1.0", + "identifier": "com.haystack.app", + "build": { + "beforeDevCommand": "bun run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "bun run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [], + "macOSPrivateApi": true, + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2129247..07eecb8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,108 +1,132 @@ -import { createSignal } from "solid-js"; +import { createEffect, createResource, createSignal, For } from "solid-js"; import { Search } from "@kobalte/core/search"; import { IconSearch, IconRefresh } from "@tabler/icons-solidjs"; import clsx from "clsx"; +import { ImageViewer } from "./components/ImageViewer"; +import { getUserImages } from "./network"; +import { image } from "@tauri-apps/api"; +import { A, useNavigate } from "@solidjs/router"; +import Fuse from "fuse.js"; -type Emoji = { - emoji: string; - name: string; -}; +type UserImages = Awaited>; function App() { - const [options, setOptions] = createSignal([]); - const [emoji, setEmoji] = createSignal(null); + const [searchResults, setSearchResults] = createSignal([]); - const emojiData: Emoji[] = [ - { emoji: "😀", name: "Grinning Face" }, - { emoji: "😃", name: "Grinning Face with Big Eyes" }, - { emoji: "😄", name: "Grinning Face with Smiling Eyes" }, - { emoji: "😁", name: "Beaming Face with Smiling Eyes" }, - { emoji: "😆", name: "Grinning Squinting Face" }, - ]; + const [images] = createResource(getUserImages); - const queryEmojiData = (query: string) => { - return emojiData.filter((emoji) => - emoji.name.toLowerCase().includes(query.toLowerCase()) - ); - }; + const nav = useNavigate(); - return ( -
-
- setOptions(queryEmojiData(query))} - onChange={(result) => setEmoji(result)} - optionValue="name" - optionLabel="name" - placeholder="Search for stuff..." - itemComponent={(props) => ( - - - {props.item.rawValue.emoji} - - - )} - > - - - - - } - > - - - - - - - - e.preventDefault()} - > - - - 😬 No emoji found - - - - -
- {/*
+ let fuze = new Fuse[number]>([], { keys: ["Text.ImageText"] }); + + // TODO: there's probably a better way? + createEffect(() => { + const userImages = images(); + if (userImages == null) { + return; + } + + const imageText = userImages.flatMap(i => i.Text ?? []); + + fuze = new Fuse(imageText, { keys: ["ImageText"], threshold: 0.3 }); + }); + + const onInputChange = (query: string) => { + // TODO: we can migrate this searching to Rust, so we don't abuse the main thread. + // But, it's not too bad as is. + setSearchResults(fuze.search(query).flatMap(s => s.item)); + } + + return ( +
+
+ { + if (item?.ImageID == null) { + console.error("ImageID was null"); + return; + } + + nav(`/image/${item.ImageID}`); + }} + optionValue="ID" + optionLabel="ImageText" + placeholder="Search for stuff..." + itemComponent={(props) => ( + + + {props.item.rawValue.ImageText ?? ''} + + + )} + > + + + + + } + > + + + + + + + + e.preventDefault()} + > + + + 😬 No emoji found + + + + +
+ {/*
Emoji selected: {emoji()?.emoji} {emoji()?.name}
*/} -
-
-
-
-
-
-
-
-
-
-
-
-
-
- footer -
-
- ); + +
+
+
+
+
+
+
+
+
+
+ + {(image) => ( + + )} + +
+
+
+
+ footer +
+
+ ); } export default App; diff --git a/frontend/src/ImagePage.tsx b/frontend/src/ImagePage.tsx new file mode 100644 index 0000000..2173ef7 --- /dev/null +++ b/frontend/src/ImagePage.tsx @@ -0,0 +1,36 @@ +import { A, useParams } from "@solidjs/router" +import { createEffect, createResource, For, Suspense } from "solid-js" +import { getUserImages } from "./network" + +export function ImagePage() { + const { imageId } = useParams<{ imageId: string }>() + + const [image] = createResource(async () => { + const userImages = await getUserImages(); + + const currentImage = userImages.find(image => image.ID === imageId); + if (currentImage == null) { + // TODO: this error handling. + throw new Error("must be valid"); + } + + return currentImage; + }); + + createEffect(() => { + console.log(image()); + }) + + return (Loading...}> + Back +

{image()?.Image.ImageName}

+ +
+ + {(tag) => ( +
{tag.Tag}
+ )} +
+
+
) +} diff --git a/frontend/src/components/FolderPicker.tsx b/frontend/src/components/FolderPicker.tsx index 585fbbe..285a2bb 100644 --- a/frontend/src/components/FolderPicker.tsx +++ b/frontend/src/components/FolderPicker.tsx @@ -3,47 +3,47 @@ import { open } from "@tauri-apps/plugin-dialog"; import { invoke } from "@tauri-apps/api/core"; export function FolderPicker() { - const [selectedPath, setSelectedPath] = createSignal(""); - const [status, setStatus] = createSignal(""); + const [selectedPath, setSelectedPath] = createSignal(""); + const [status, setStatus] = createSignal(""); - const handleFolderSelect = async () => { - try { - const selected = await open({ - directory: true, - multiple: false, - }); + const handleFolderSelect = async () => { + try { + const selected = await open({ + directory: true, + multiple: false, + }); - if (selected) { - setSelectedPath(selected as string); - // Send the path to Rust - const response = await invoke("handle_selected_folder", { - path: selected, - }); - setStatus(`Folder processed: ${response}`); - } - } catch (error) { - setStatus(`Error: ${error}`); - } - }; + if (selected) { + setSelectedPath(selected as string); + // Send the path to Rust + const response = await invoke("handle_selected_folder", { + path: selected, + }); + setStatus(`Folder processed: ${response}`); + } + } catch (error) { + setStatus(`Error: ${error}`); + } + }; - return ( -
- + return ( +
+ - {selectedPath() && ( -
-

Selected folder:

-

{selectedPath()}

-
- )} + {selectedPath() && ( +
+

Selected folder:

+

{selectedPath()}

+
+ )} - {status() &&

{status()}

} -
- ); + {status() &&

{status()}

} +
+ ); } diff --git a/frontend/src/components/ImageViewer.tsx b/frontend/src/components/ImageViewer.tsx index 85fc08a..50c3563 100644 --- a/frontend/src/components/ImageViewer.tsx +++ b/frontend/src/components/ImageViewer.tsx @@ -1,37 +1,40 @@ import { createEffect, createSignal } from "solid-js"; import { listen } from "@tauri-apps/api/event"; import { FolderPicker } from "./FolderPicker"; +import { sendImage } from "../network"; export function ImageViewer() { - const [latestImage, setLatestImage] = createSignal(null); + const [latestImage, setLatestImage] = createSignal(null); - createEffect(() => { - // Listen for PNG processing events - const unlisten = listen("png-processed", (event) => { - console.log("Received processed PNG"); - const base64Data = event.payload as string; - setLatestImage(`data:image/png;base64,${base64Data}`); + createEffect(() => { + // Listen for PNG processing events + const unlisten = listen("png-processed", (event) => { + console.log("Received processed PNG", event); + const base64Data = event.payload as string; + + setLatestImage(`data:image/png;base64,${base64Data}`); + sendImage("test-image.png", base64Data); + }); + + return () => { + unlisten.then((fn) => fn()); // Cleanup listener + }; }); - return () => { - unlisten.then((fn) => fn()); // Cleanup listener - }; - }); + return ( +
+ - return ( -
- - - {latestImage() && ( -
-

Latest Processed Image:

- Latest processed + {latestImage() && ( +
+

Latest Processed Image:

+ Latest processed +
+ )}
- )} -
- ); + ); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 71bc21a..d61995c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,21 +3,21 @@ @tailwind utilities; @font-face { - font-family: "Manrope"; - src: url("./assets/fonts/Manrope-VariableFont_wght.ttf") format("truetype"); - font-weight: 100 900; - font-display: swap; + font-family: "Manrope"; + src: url("./assets/fonts/Manrope-VariableFont_wght.ttf") format("truetype"); + font-weight: 100 900; + font-display: swap; } :root { - @apply bg-neutral-100 text-black rounded-xl; - font-family: Manrope, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 500; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + @apply bg-neutral-100 text-black rounded-xl; + font-family: Manrope, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 500; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; } diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index e02db44..13e8ce0 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -2,5 +2,12 @@ import { render } from "solid-js/web"; import App from "./App"; import "./index.css"; +import { Route, Router } from "@solidjs/router"; +import { ImagePage } from "./ImagePage"; -render(() => , document.getElementById("root") as HTMLElement); +render(() => ( + + + + +), document.getElementById("root") as HTMLElement); diff --git a/frontend/src/network/index.ts b/frontend/src/network/index.ts new file mode 100644 index 0000000..8b22a4b --- /dev/null +++ b/frontend/src/network/index.ts @@ -0,0 +1,96 @@ +import { + array, + InferOutput, + null as Null, + nullable, + object, + parse, + pipe, + string, + uuid, +} from "valibot"; + +type BaseRequestParams = Partial<{ + path: string; + body: RequestInit["body"]; + method: "GET" | "POST"; +}>; + +const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => { + return new Request(`http://localhost:3040/${path}`, { + headers: { userId: "fcc22dbb-7792-4595-be8e-d0439e13990a" }, + body, + method, + }); +}; + +const sendImageResponseValidator = object({ + ID: pipe(string(), uuid()), + ImageID: pipe(string(), uuid()), + UserID: pipe(string(), uuid()), +}); + +export const sendImage = async ( + imageName: string, + base64Image: string, +): Promise> => { + const request = getBaseRequest({ + path: `image/${imageName}`, + body: base64Image, + method: "POST", + }); + + request.headers.set("Content-Type", "application/base64"); + + const res = await fetch(request).then((res) => res.json()); + + return parse(sendImageResponseValidator, res); +}; + +const getUserImagesResponseValidator = array( + object({ + ID: pipe(string(), uuid()), + Image: object({ + ID: pipe(string(), uuid()), + ImageName: string(), + Image: Null(), + }), + Tags: nullable( + array( + object({ + ID: pipe(string(), uuid()), + Tag: string(), + ImageID: pipe(string(), uuid()), + }), + ), + ), + Links: nullable( + array( + object({ + ID: pipe(string(), uuid()), + Links: string(), + ImageID: pipe(string(), uuid()), + }), + ), + ), + Text: nullable( + array( + object({ + ID: pipe(string(), uuid()), + ImageText: string(), + ImageID: pipe(string(), uuid()), + }), + ), + ), + }), +); + +export const getUserImages = async (): Promise< + InferOutput +> => { + const request = getBaseRequest({ path: "image" }); + + const res = await fetch(request).then((res) => res.json()); + + return parse(getUserImagesResponseValidator, res); +}; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 97f179f..f749829 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,15 +1,15 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], - theme: { - extend: { - fontFamily: { - sans: ["Manrope", "sans-serif"], - }, - }, - }, - plugins: [ - require("@kobalte/tailwindcss"), - require("tailwind-scrollbar-hide"), - ], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + fontFamily: { + sans: ["Manrope", "sans-serif"], + }, + }, + }, + plugins: [ + require("@kobalte/tailwindcss"), + require("tailwind-scrollbar-hide"), + ], }; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 3999958..f7f13c7 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,26 +1,26 @@ { - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "skipLibCheck": true, + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "preserve", - "jsxImportSource": "solid-js", + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 42872c5..eca6668 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -1,10 +1,10 @@ { - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index af994b4..815ec0f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,31 +8,31 @@ const host = process.env.TAURI_DEV_HOST; // https://vitejs.dev/config/ export default defineConfig(async () => ({ - plugins: [solid()], - css: { - postcss: { - plugins: [tailwindcss, autoprefixer], - }, - }, - // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` - // - // 1. prevent vite from obscuring rust errors - clearScreen: false, - // 2. tauri expects a fixed port, fail if that port is not available - server: { - port: 1420, - strictPort: true, - host: host || false, - hmr: host - ? { - protocol: "ws", - host, - port: 1421, - } - : undefined, - watch: { - // 3. tell vite to ignore watching `src-tauri` - ignored: ["**/src-tauri/**"], - }, - }, + plugins: [solid()], + css: { + postcss: { + plugins: [tailwindcss, autoprefixer], + }, + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + }, }));