Merge branch 'main' of https://github.com/dimuuu/haystack-app
This commit is contained in:
18
backend/.gen/haystack/haystack/model/image.go
Normal file
18
backend/.gen/haystack/haystack/model/image.go
Normal 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
|
||||||
|
}
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
81
backend/.gen/haystack/haystack/table/image.go
Normal file
81
backend/.gen/haystack/haystack/table/image.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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
3
backend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
screenmark
|
||||||
|
.env.docker
|
||||||
|
.env
|
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal 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
26
backend/README.md
Normal 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.
|
27
backend/docker-compose.yml
Normal file
27
backend/docker-compose.yml
Normal 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
|
@ -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
|
||||||
|
@ -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=
|
||||||
|
236
backend/main.go
236
backend/main.go
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
116
backend/models/tags.go
Normal 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
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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');
|
||||||
|
@ -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.
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
36
frontend/src/ImagePage.tsx
Normal file
36
frontend/src/ImagePage.tsx
Normal 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>)
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
96
frontend/src/network/index.ts
Normal file
96
frontend/src/network/index.ts
Normal 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);
|
||||||
|
};
|
@ -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"),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -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" }]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
Reference in New Issue
Block a user