This commit is contained in:
2025-03-13 17:25:16 +01:00
40 changed files with 1489 additions and 417 deletions

View File

@ -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
}

View File

@ -12,6 +12,7 @@ import (
) )
type ImageTags struct { type ImageTags struct {
ID uuid.UUID `sql:"primary_key"`
TagID uuid.UUID TagID uuid.UUID
ImageID uuid.UUID ImageID uuid.UUID
} }

View File

@ -12,8 +12,7 @@ import (
) )
type UserImages struct { type UserImages struct {
ID uuid.UUID `sql:"primary_key"` ID uuid.UUID `sql:"primary_key"`
ImageName string ImageID uuid.UUID
Image []byte UserID uuid.UUID
UserID uuid.UUID
} }

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -17,6 +17,7 @@ type imageTagsTable struct {
postgres.Table postgres.Table
// Columns // Columns
ID postgres.ColumnString
TagID postgres.ColumnString TagID postgres.ColumnString
ImageID postgres.ColumnString ImageID postgres.ColumnString
@ -59,9 +60,10 @@ func newImageTagsTable(schemaName, tableName, alias string) *ImageTagsTable {
func newImageTagsTableImpl(schemaName, tableName, alias string) imageTagsTable { func newImageTagsTableImpl(schemaName, tableName, alias string) imageTagsTable {
var ( var (
IDColumn = postgres.StringColumn("id")
TagIDColumn = postgres.StringColumn("tag_id") TagIDColumn = postgres.StringColumn("tag_id")
ImageIDColumn = postgres.StringColumn("image_id") ImageIDColumn = postgres.StringColumn("image_id")
allColumns = postgres.ColumnList{TagIDColumn, ImageIDColumn} allColumns = postgres.ColumnList{IDColumn, TagIDColumn, ImageIDColumn}
mutableColumns = postgres.ColumnList{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...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
ID: IDColumn,
TagID: TagIDColumn, TagID: TagIDColumn,
ImageID: ImageIDColumn, ImageID: ImageIDColumn,

View File

@ -10,10 +10,12 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke // 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. // this method only once at the beginning of the program.
func UseSchema(schema string) { func UseSchema(schema string) {
Image = Image.FromSchema(schema)
ImageLinks = ImageLinks.FromSchema(schema) ImageLinks = ImageLinks.FromSchema(schema)
ImageTags = ImageTags.FromSchema(schema) ImageTags = ImageTags.FromSchema(schema)
ImageText = ImageText.FromSchema(schema) ImageText = ImageText.FromSchema(schema)
UserImages = UserImages.FromSchema(schema) UserImages = UserImages.FromSchema(schema)
UserImagesToProcess = UserImagesToProcess.FromSchema(schema)
UserTags = UserTags.FromSchema(schema) UserTags = UserTags.FromSchema(schema)
Users = Users.FromSchema(schema) Users = Users.FromSchema(schema)
} }

View File

@ -17,10 +17,9 @@ type userImagesTable struct {
postgres.Table postgres.Table
// Columns // Columns
ID postgres.ColumnString ID postgres.ColumnString
ImageName postgres.ColumnString ImageID postgres.ColumnString
Image postgres.ColumnString UserID postgres.ColumnString
UserID postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@ -61,22 +60,20 @@ func newUserImagesTable(schemaName, tableName, alias string) *UserImagesTable {
func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable { func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable {
var ( var (
IDColumn = postgres.StringColumn("id") IDColumn = postgres.StringColumn("id")
ImageNameColumn = postgres.StringColumn("image_name") ImageIDColumn = postgres.StringColumn("image_id")
ImageColumn = postgres.StringColumn("image") UserIDColumn = postgres.StringColumn("user_id")
UserIDColumn = postgres.StringColumn("user_id") allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn}
allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, ImageColumn, UserIDColumn} mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn}
mutableColumns = postgres.ColumnList{ImageNameColumn, ImageColumn, UserIDColumn}
) )
return userImagesTable{ return userImagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
ID: IDColumn, ID: IDColumn,
ImageName: ImageNameColumn, ImageID: ImageIDColumn,
Image: ImageColumn, UserID: UserIDColumn,
UserID: UserIDColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,

View File

@ -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,
}
}

3
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
screenmark
.env.docker
.env

15
backend/Dockerfile Normal file
View File

@ -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"]

26
backend/README.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -4,6 +4,7 @@ go 1.24.0
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect 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/go-jet/jet/v2 v2.12.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1 // indirect

View File

@ -1,5 +1,7 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 h1:z2JfvBAZgsfxlQz6NXBYdZTXc7ep3jhbszTLtETv1JE=
github.com/go-jet/jet/v2 v2.12.0/go.mod h1:ufQVRQeI1mbcO5R8uCEVcVf3Foej9kReBdwDx7YMWUM= github.com/go-jet/jet/v2 v2.12.0/go.mod h1:ufQVRQeI1mbcO5R8uCEVcVf3Foej9kReBdwDx7YMWUM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@ -1,29 +1,62 @@
package main package main
import ( import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/lib/pq" "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() { func main() {
err := godotenv.Load() err := godotenv.Load()
if err != nil { if err != nil {
panic(err) panic(err)
} }
mode := os.Getenv("MODE")
log.Printf("Mode: %s\n", mode)
err = models.InitDatabase() err = models.InitDatabase()
if err != nil { if err != nil {
panic(err) 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 { if err != nil {
panic(err) panic(err)
} }
@ -36,42 +69,134 @@ func main() {
panic(err) panic(err)
} }
select { for {
case parameters := <-listener.Notify:
log.Println("received notification, new image available: " + parameters.Extra)
go func() { select {
openAiClient, err := CreateOpenAiClient() case parameters := <-listener.Notify:
if err != nil { imageId := parameters.Extra
panic(err)
}
image, err := models.GetImage(parameters.Extra) log.Println("received notification, new image available: " + imageId)
if err != nil {
log.Println(err)
return
}
imageInfo, err := openAiClient.GetImageInfo(image.ImageName, image.Image) go func() {
if err != nil { openAiClient, err := GetAiClient()
log.Println(err) if err != nil {
return 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-Origin", "*")
w.Header().Add("Access-Control-Allow-Credentials", "*") w.Header().Add("Access-Control-Allow-Credentials", "*")
w.Header().Add("Access-Control-Allow-Headers", "*") 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") imageName := r.PathValue("name")
userId := r.Header.Get("userId") userId := r.Header.Get("userId")
@ -86,24 +211,79 @@ func main() {
return 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 { if err != nil {
log.Println("First case")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Couldnt read the image from the request body") fmt.Fprintf(w, "Couldnt read the image from the request body")
return return
} }
err = models.SaveImage(userId, imageName, image) userImage, err := models.SaveImageToProcess(userId, imageName, image)
if err != nil { if err != nil {
log.Println("Second case")
log.Println(err) log.Println(err)
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Could not save image to DB") fmt.Fprintf(w, "Could not save image to DB")
return 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)
} }

View File

@ -2,16 +2,22 @@ package models
import ( import (
"database/sql" "database/sql"
"errors"
"os"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
const CONNECTION = "postgresql://localhost:5432/haystack?sslmode=disable"
var db *sql.DB var db *sql.DB
func InitDatabase() error { 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 db = database

View File

@ -3,31 +3,234 @@ package models
import ( import (
"errors" "errors"
"fmt" "fmt"
. "github.com/go-jet/jet/v2/postgres"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
. "screenmark/screenmark/.gen/haystack/haystack/table" . "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid" "github.com/google/uuid"
) )
func SaveImage(userId string, imageName string, imageData []byte) error { func SaveImageToProcess(userId string, imageName string, imageData []byte) (model.UserImagesToProcess, error) {
stmt := UserImages.INSERT(UserImages.UserID, UserImages.ImageName, UserImages.Image).VALUES(userId, imageName, imageData) 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) _, err := stmt.Exec(db)
return err return err
} }
func GetImage(imageId string) (model.UserImages, error) { func getUserId(imageId uuid.UUID) (uuid.UUID, error) {
id := uuid.MustParse(imageId) stmt := UserImages.SELECT(UserImages.UserID).WHERE(UserImages.ID.EQ(UUID(imageId)))
stmt := UserImages.SELECT(UserImages.ImageName, UserImages.Image).WHERE(UserImages.ID.EQ(UUID(id)))
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) err := stmt.Query(db, &images)
if len(images) != 1 { 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 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
}

116
backend/models/tags.go Normal file
View File

@ -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
}

View File

@ -73,9 +73,7 @@ func (content *OpenAiMessages) AddImage(imageName string, image []byte) error {
arrayMessage := OpenAiArrayMessage{Role: ROLE_USER, Content: make([]OpenAiContent, 1)} arrayMessage := OpenAiArrayMessage{Role: ROLE_USER, Content: make([]OpenAiContent, 1)}
arrayMessage.Content[0] = OpenAiImage{ arrayMessage.Content[0] = OpenAiImage{
ImageType: IMAGE_TYPE, ImageType: IMAGE_TYPE,
ImageUrl: ImageUrl{ ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
Url: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
},
} }
content.Messages = append(content.Messages, arrayMessage) content.Messages = append(content.Messages, arrayMessage)
@ -105,8 +103,8 @@ type ImageUrl struct {
} }
type OpenAiImage struct { type OpenAiImage struct {
ImageType string `json:"type"` ImageType string `json:"type"`
ImageUrl ImageUrl `json:"image_url"` ImageUrl string `json:"image_url"`
} }
func (imageMessage OpenAiImage) ToJson() ([]byte, error) { func (imageMessage OpenAiImage) ToJson() ([]byte, error) {
@ -114,6 +112,10 @@ func (imageMessage OpenAiImage) ToJson() ([]byte, error) {
return json.Marshal(imageMessage) return json.Marshal(imageMessage)
} }
type AiClient interface {
GetImageInfo(imageName string, imageData []byte) (ImageInfo, error)
}
type OpenAiClient struct { type OpenAiClient struct {
url string url string
apiKey string apiKey string
@ -137,8 +139,8 @@ const IMAGE_TYPE = "image_url"
const PROMPT = ` 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 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. that the image might contain. You will also try your best to assign some tags to this image, avoid too many tags.
Be sure to extract every link (URL) that you find.
This system is part of a bookmark manager, who's main goal is to allow the user to search through various screenshots. Use generic tags.
` `
const RESPONSE_FORMAT = ` const RESPONSE_FORMAT = `
@ -189,7 +191,7 @@ func CreateOpenAiClient() (OpenAiClient, error) {
return OpenAiClient{ return OpenAiClient{
apiKey: apiKey, apiKey: apiKey,
url: "https://api.openai.com/v1/chat/completions", url: "https://api.mistral.ai/v1/chat/completions",
systemPrompt: PROMPT, systemPrompt: PROMPT,
Do: func(req *http.Request) (*http.Response, error) { Do: func(req *http.Request) (*http.Response, error) {
client := &http.Client{} client := &http.Client{}
@ -210,10 +212,14 @@ func (client OpenAiClient) getRequest(body []byte) (*http.Request, error) {
return req, nil 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{ request := OpenAiRequestBody{
Model: model, Model: model,
Temperature: temperature, Temperature: temperature,
ResponseFormat: ResponseFormat{
Type: "json_schema",
JsonSchema: jsonSchema,
},
} }
// TODO: Add build pattern here that deals with errors in some internal state? // 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 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) { 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 { if err != nil {
return ImageInfo{}, err return ImageInfo{}, err
} }
@ -268,13 +315,5 @@ func (client OpenAiClient) GetImageInfo(imageName string, imageData []byte) (Ima
return ImageInfo{}, err return ImageInfo{}, err
} }
info := ImageInfo{} return parseOpenAiResponse(response)
err = json.Unmarshal(response, &info)
if err != nil {
return ImageInfo{}, err
}
log.Println(string(response))
return info, nil
} }

View File

@ -89,7 +89,7 @@ func TestMessageBuilderImage(t *testing.T) {
} }
func TestFullImageRequest(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 { if err != nil {
t.Log(request) t.Log(request)
t.FailNow() t.FailNow()
@ -101,7 +101,7 @@ func TestFullImageRequest(t *testing.T) {
t.FailNow() 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 { if string(jsonData) != expectedJson {
t.Logf("Expected:\n%s\n Got:\n%s\n", expectedJson, string(jsonData)) t.Logf("Expected:\n%s\n Got:\n%s\n", expectedJson, string(jsonData))
@ -149,3 +149,65 @@ func TestResponse(t *testing.T) {
t.FailNow() 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()
}
}

View File

@ -1,3 +1,6 @@
DROP DATABASE IF EXISTS haystack_db;
CREATE DATABASE haystack_db;
DROP SCHEMA IF EXISTS haystack CASCADE; DROP SCHEMA IF EXISTS haystack CASCADE;
CREATE SCHEMA haystack; CREATE SCHEMA haystack;
@ -8,21 +11,33 @@ CREATE TABLE haystack.users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid() 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(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
image_name TEXT NOT NULL, 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) user_id uuid NOT NULL REFERENCES haystack.users (id)
); );
CREATE TABLE haystack.user_tags ( CREATE TABLE haystack.user_tags (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 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) user_id uuid NOT NULL REFERENCES haystack.users (id)
); );
CREATE TABLE haystack.image_tags ( 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) 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) image_id UUID NOT NULL REFERENCES haystack.user_images (id)
); );
/* -----| Indexes |----- */
CREATE INDEX user_tags_index ON haystack.user_tags(tag);
/* -----| Stored Procedures |----- */ /* -----| Stored Procedures |----- */
CREATE OR REPLACE FUNCTION notify_new_image() CREATE OR REPLACE FUNCTION notify_new_image()
@ -51,6 +70,10 @@ $$ LANGUAGE plpgsql;
/* -----| Triggers |----- */ /* -----| Triggers |----- */
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
ON haystack.user_images ON haystack.user_images_to_process
FOR EACH ROW 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');

View File

@ -1,12 +1,12 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true
} }
} }
} }

Binary file not shown.

View File

@ -1,38 +1,41 @@
{ {
"name": "haystack", "name": "haystack",
"version": "0.1.0", "version": "0.1.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"lint": "bunx @biomejs/biome lint .", "lint": "bunx @biomejs/biome lint .",
"format": "bunx @biomejs/biome format . --write" "format": "bunx @biomejs/biome format . --write"
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@kobalte/core": "^0.13.9", "@kobalte/core": "^0.13.9",
"@kobalte/tailwindcss": "^0.9.0", "@kobalte/tailwindcss": "^0.9.0",
"@tabler/icons-solidjs": "^3.30.0", "@solidjs/router": "^0.15.3",
"@tauri-apps/api": "^2", "@tabler/icons-solidjs": "^3.30.0",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-dialog": "~2",
"clsx": "^2.1.1", "@tauri-apps/plugin-opener": "^2",
"solid-js": "^1.9.3", "clsx": "^2.1.1",
"tailwind-scrollbar-hide": "^2.0.0" "fuse.js": "^7.1.0",
}, "solid-js": "^1.9.3",
"devDependencies": { "tailwind-scrollbar-hide": "^2.0.0",
"@biomejs/biome": "^1.9.4", "valibot": "^1.0.0-rc.2"
"@tauri-apps/cli": "^2", },
"autoprefixer": "^10.4.20", "devDependencies": {
"postcss": "^8.5.3", "@biomejs/biome": "^1.9.4",
"postcss-cli": "^11.0.0", "@tauri-apps/cli": "^2",
"tailwindcss": "3.4.0", "autoprefixer": "^10.4.20",
"typescript": "~5.6.2", "postcss": "^8.5.3",
"vite": "^6.0.3", "postcss-cli": "^11.0.0",
"vite-plugin-solid": "^2.11.0" "tailwindcss": "3.4.0",
} "typescript": "~5.6.2",
"vite": "^6.0.3",
"vite-plugin-solid": "^2.11.0"
}
} }

View File

@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; };

View File

@ -1,12 +1,12 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"core:window:allow-start-dragging" "core:window:allow-start-dragging"
] ]
} }

View File

@ -116,7 +116,7 @@ pub fn run() {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.hidden_title(true) .hidden_title(true)
.inner_size(480.0, 360.0) .inner_size(480.0, 360.0)
.resizable(false); .resizable(true);
// set transparent title bar only when building for macOS // set transparent title bar only when building for macOS
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent); let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent);

View File

@ -1,30 +1,30 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "haystack", "productName": "haystack",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.haystack.app", "identifier": "com.haystack.app",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build", "beforeBuildCommand": "bun run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [], "windows": [],
"macOSPrivateApi": true, "macOSPrivateApi": true,
"security": { "security": {
"csp": null "csp": null
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ]
} }
} }

View File

@ -1,108 +1,132 @@
import { createSignal } from "solid-js"; import { createEffect, createResource, createSignal, For } from "solid-js";
import { Search } from "@kobalte/core/search"; import { Search } from "@kobalte/core/search";
import { IconSearch, IconRefresh } from "@tabler/icons-solidjs"; import { IconSearch, IconRefresh } from "@tabler/icons-solidjs";
import clsx from "clsx"; 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 = { type UserImages = Awaited<ReturnType<typeof getUserImages>>;
emoji: string;
name: string;
};
function App() { function App() {
const [options, setOptions] = createSignal<Emoji[]>([]); const [searchResults, setSearchResults] = createSignal<UserImages[number]['Text']>([]);
const [emoji, setEmoji] = createSignal<Emoji | null>(null);
const emojiData: Emoji[] = [ const [images] = createResource(getUserImages);
{ 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 queryEmojiData = (query: string) => { const nav = useNavigate();
return emojiData.filter((emoji) =>
emoji.name.toLowerCase().includes(query.toLowerCase())
);
};
return ( let fuze = new Fuse<NonNullable<UserImages[number]['Text']>[number]>([], { keys: ["Text.ImageText"] });
<main class="container pt-2">
<div class="px-4"> // TODO: there's probably a better way?
<Search createEffect(() => {
triggerMode="focus" const userImages = images();
options={options()} if (userImages == null) {
onInputChange={(query) => setOptions(queryEmojiData(query))} return;
onChange={(result) => setEmoji(result)} }
optionValue="name"
optionLabel="name" const imageText = userImages.flatMap(i => i.Text ?? []);
placeholder="Search for stuff..."
itemComponent={(props) => ( fuze = new Fuse(imageText, { keys: ["ImageText"], threshold: 0.3 });
<Search.Item });
item={props.item}
class={clsx( const onInputChange = (query: string) => {
"text-2xl leading-none text-gray-900 rounded-md p-2 select-none outline-none grid justify-items-center w-[calc(20%-5px)] box-border", // TODO: we can migrate this searching to Rust, so we don't abuse the main thread.
"hover:bg-gray-100 ui-highlighted:bg-gray-100 ui-highlighted:shadow-[inset_0_0_0_2px_rgb(2,132,199)] ui-disabled:text-gray-400 ui-disabled:opacity-50 ui-disabled:pointer-events-none" // But, it's not too bad as is.
)} setSearchResults(fuze.search(query).flatMap(s => s.item));
> }
<Search.ItemLabel class="mx-[-100px]">
{props.item.rawValue.emoji} return (
</Search.ItemLabel> <main class="container pt-2">
</Search.Item> <div class="px-4">
)} <Search
> triggerMode="focus"
<Search.Control options={searchResults() ?? []}
class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-gray-200 text-gray-900 transition-colors duration-250 ui-invalid:border-red-500 ui-invalid:text-red-500" onInputChange={onInputChange}
aria-label="Emoji" onChange={(item) => {
> if (item?.ImageID == null) {
<Search.Indicator console.error("ImageID was null");
class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900 text-base leading-none transition-colors duration-250" return;
loadingComponent={ }
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none">
<IconRefresh size={20} class="m-auto animate-spin" /> nav(`/image/${item.ImageID}`);
</Search.Icon> }}
} optionValue="ID"
> optionLabel="ImageText"
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none"> placeholder="Search for stuff..."
<IconSearch class="m-auto size-5 text-gray-600" /> itemComponent={(props) => (
</Search.Icon> <Search.Item
</Search.Indicator> item={props.item}
<Search.Input class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600" /> class={clsx(
</Search.Control> "text-2xl leading-none text-gray-900 rounded-md p-2 select-none outline-none grid justify-items-center w-full box-border",
<Search.Portal> "hover:bg-gray-100 ui-highlighted:bg-gray-100 ui-highlighted:shadow-[inset_0_0_0_2px_rgb(2,132,199)] ui-disabled:text-gray-400 ui-disabled:opacity-50 ui-disabled:pointer-events-none",
<Search.Content )}
class="bg-white rounded-md border border-gray-200 shadow-md origin-[var(--kb-search-content-transform-origin)] w-[var(--kb-popper-anchor-width)] data-[expanded]:animate-contentShow" >
onCloseAutoFocus={(e) => e.preventDefault()} <Search.ItemLabel class="mx-[-100px]">
> {props.item.rawValue.ImageText ?? ''}
<Search.Listbox class="overflow-y-auto max-h-[360px] p-2 flex flex-row justify-start flex-wrap gap-1.5 leading-none focus:outline-none" /> </Search.ItemLabel>
<Search.NoResult class="text-center p-2 pb-6 m-auto text-gray-600"> </Search.Item>
😬 No emoji found )}
</Search.NoResult> >
</Search.Content> <Search.Control
</Search.Portal> class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-gray-200 text-gray-900 transition-colors duration-250 ui-invalid:border-red-500 ui-invalid:text-red-500"
</Search> aria-label="Emoji"
</div> >
{/* <div class="mt-4 text-base leading-none"> <Search.Indicator
class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900 text-base leading-none transition-colors duration-250"
loadingComponent={
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none">
<IconRefresh size={20} class="m-auto animate-spin" />
</Search.Icon>
}
>
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none">
<IconSearch class="m-auto size-5 text-gray-600" />
</Search.Icon>
</Search.Indicator>
<Search.Input class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600" />
</Search.Control>
<Search.Portal>
<Search.Content
class="bg-white rounded-md border border-gray-200 shadow-md origin-[var(--kb-search-content-transform-origin)] w-[var(--kb-popper-anchor-width)] data-[expanded]:animate-contentShow"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<Search.Listbox class="overflow-y-auto max-h-[360px] p-2 flex flex-col justify-start gap-1.5 leading-none focus:outline-none" />
<Search.NoResult class="text-center p-2 pb-6 m-auto text-gray-600">
😬 No emoji found
</Search.NoResult>
</Search.Content>
</Search.Portal>
</Search>
</div>
{/* <div class="mt-4 text-base leading-none">
Emoji selected: {emoji()?.emoji} {emoji()?.name} Emoji selected: {emoji()?.emoji} {emoji()?.name}
</div> */} </div> */}
<div class="px-4 mt-4 bg-white rounded-t-2xl"> <ImageViewer />
<div class="h-[254px] overflow-scroll scrollbar-hide"> <div class="px-4 mt-4 bg-white rounded-t-2xl">
<div class="w-full grid grid-cols-9 grid-rows-9 gap-2 h-[480px] grid-flow-row-dense py-4"> <div class="h-[254px] overflow-scroll scrollbar-hide">
<div class="col-span-3 row-span-3 bg-red-200 rounded-xl" /> <div class="w-full grid grid-cols-9 grid-rows-9 gap-2 h-[480px] grid-flow-row-dense py-4">
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" /> <div class="col-span-3 row-span-3 bg-red-200 rounded-xl" />
<div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" /> <div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" /> <div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" />
<div class="col-span-3 row-span-3 bg-blue-200 rounded-xl" /> <div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" /> <div class="col-span-3 row-span-3 bg-blue-200 rounded-xl" />
<div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" /> <div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
</div> <div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" />
</div> <For each={images()}>
</div> {(image) => (
<div class="w-full border-t h-10 bg-white px-4 border-neutral-100"> <A href={`/image/${image.ID}`}><img src={`http://localhost:3040/image/${image.ID}`} class="col-span-3 row-span-3 rounded-xl" /></A>
footer )}
</div> </For>
</main> </div>
); </div>
</div>
<div class="w-full border-t h-10 bg-white px-4 border-neutral-100">
footer
</div>
</main>
);
} }
export default App; export default App;

View File

@ -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 (<Suspense fallback={<>Loading...</>}>
<A href="/">Back</A>
<h1 class="text-2xl font-bold">{image()?.Image.ImageName}</h1>
<img src={`http://localhost:3040/image/${image()?.ID}`} />
<div class="flex flex-col">
<For each={image()?.Tags ?? []}>
{(tag) => (
<div>{tag.Tag}</div>
)}
</For>
</div>
</Suspense>)
}

View File

@ -3,47 +3,47 @@ import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
export function FolderPicker() { export function FolderPicker() {
const [selectedPath, setSelectedPath] = createSignal<string>(""); const [selectedPath, setSelectedPath] = createSignal<string>("");
const [status, setStatus] = createSignal<string>(""); const [status, setStatus] = createSignal<string>("");
const handleFolderSelect = async () => { const handleFolderSelect = async () => {
try { try {
const selected = await open({ const selected = await open({
directory: true, directory: true,
multiple: false, multiple: false,
}); });
if (selected) { if (selected) {
setSelectedPath(selected as string); setSelectedPath(selected as string);
// Send the path to Rust // Send the path to Rust
const response = await invoke("handle_selected_folder", { const response = await invoke("handle_selected_folder", {
path: selected, path: selected,
}); });
setStatus(`Folder processed: ${response}`); setStatus(`Folder processed: ${response}`);
} }
} catch (error) { } catch (error) {
setStatus(`Error: ${error}`); setStatus(`Error: ${error}`);
} }
}; };
return ( return (
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<button <button
type="button" type="button"
onClick={handleFolderSelect} onClick={handleFolderSelect}
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
> >
Select Folder Select Folder
</button> </button>
{selectedPath() && ( {selectedPath() && (
<div class="text-left max-w-md"> <div class="text-left max-w-md">
<p class="font-semibold">Selected folder:</p> <p class="font-semibold">Selected folder:</p>
<p class="text-sm break-all">{selectedPath()}</p> <p class="text-sm break-all">{selectedPath()}</p>
</div> </div>
)} )}
{status() && <p class="text-sm text-gray-600">{status()}</p>} {status() && <p class="text-sm text-gray-600">{status()}</p>}
</div> </div>
); );
} }

View File

@ -1,37 +1,40 @@
import { createEffect, createSignal } from "solid-js"; import { createEffect, createSignal } from "solid-js";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { FolderPicker } from "./FolderPicker"; import { FolderPicker } from "./FolderPicker";
import { sendImage } from "../network";
export function ImageViewer() { export function ImageViewer() {
const [latestImage, setLatestImage] = createSignal<string | null>(null); const [latestImage, setLatestImage] = createSignal<string | null>(null);
createEffect(() => { createEffect(() => {
// Listen for PNG processing events // Listen for PNG processing events
const unlisten = listen("png-processed", (event) => { const unlisten = listen("png-processed", (event) => {
console.log("Received processed PNG"); console.log("Received processed PNG", event);
const base64Data = event.payload as string; const base64Data = event.payload as string;
setLatestImage(`data:image/png;base64,${base64Data}`);
setLatestImage(`data:image/png;base64,${base64Data}`);
sendImage("test-image.png", base64Data);
});
return () => {
unlisten.then((fn) => fn()); // Cleanup listener
};
}); });
return () => { return (
unlisten.then((fn) => fn()); // Cleanup listener <div>
}; <FolderPicker />
});
return ( {latestImage() && (
<div> <div class="mt-4">
<FolderPicker /> <h3>Latest Processed Image:</h3>
<img
{latestImage() && ( src={latestImage() || undefined}
<div class="mt-4"> alt="Latest processed"
<h3>Latest Processed Image:</h3> class="max-w-md"
<img />
src={latestImage() || undefined} </div>
alt="Latest processed" )}
class="max-w-md"
/>
</div> </div>
)} );
</div>
);
} }

View File

@ -3,21 +3,21 @@
@tailwind utilities; @tailwind utilities;
@font-face { @font-face {
font-family: "Manrope"; font-family: "Manrope";
src: url("./assets/fonts/Manrope-VariableFont_wght.ttf") format("truetype"); src: url("./assets/fonts/Manrope-VariableFont_wght.ttf") format("truetype");
font-weight: 100 900; font-weight: 100 900;
font-display: swap; font-display: swap;
} }
:root { :root {
@apply bg-neutral-100 text-black rounded-xl; @apply bg-neutral-100 text-black rounded-xl;
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 24px; line-height: 24px;
font-weight: 500; font-weight: 500;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
} }

View File

@ -2,5 +2,12 @@
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
import { Route, Router } from "@solidjs/router";
import { ImagePage } from "./ImagePage";
render(() => <App />, document.getElementById("root") as HTMLElement); render(() => (
<Router>
<Route path="/" component={App} />
<Route path="/image/:imageId" component={ImagePage} />
</Router>
), document.getElementById("root") as HTMLElement);

View File

@ -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<InferOutput<typeof sendImageResponseValidator>> => {
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<typeof getUserImagesResponseValidator>
> => {
const request = getBaseRequest({ path: "image" });
const res = await fetch(request).then((res) => res.json());
return parse(getUserImagesResponseValidator, res);
};

View File

@ -1,15 +1,15 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ["Manrope", "sans-serif"], sans: ["Manrope", "sans-serif"],
}, },
}, },
}, },
plugins: [ plugins: [
require("@kobalte/tailwindcss"), require("@kobalte/tailwindcss"),
require("tailwind-scrollbar-hide"), require("tailwind-scrollbar-hide"),
], ],
}; };

View File

@ -1,26 +1,26 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "preserve", "jsx": "preserve",
"jsxImportSource": "solid-js", "jsxImportSource": "solid-js",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@ -1,10 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@ -8,31 +8,31 @@ const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [solid()], plugins: [solid()],
css: { css: {
postcss: { postcss: {
plugins: [tailwindcss, autoprefixer], plugins: [tailwindcss, autoprefixer],
}, },
}, },
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
// 1. prevent vite from obscuring rust errors // 1. prevent vite from obscuring rust errors
clearScreen: false, clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available // 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",
host, host,
port: 1421, port: 1421,
} }
: undefined, : undefined,
watch: { watch: {
// 3. tell vite to ignore watching `src-tauri` // 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],
}, },
}, },
})); }));