55 Commits

Author SHA1 Message Date
106d3b1fa1 fix? 2025-10-05 21:25:44 +01:00
b9f6b77286 another fix 2025-10-05 21:13:53 +01:00
3c8fd843e6 debug: info 2025-10-05 21:07:24 +01:00
e61af3007f fix! 2025-10-05 20:55:26 +01:00
3594baceb5 lets pretend it is working 2025-10-05 20:47:28 +01:00
d534779fad fix: swift stuff 2025-10-05 20:35:49 +01:00
a776c88cab fix: more swift stuff 2025-10-05 20:34:23 +01:00
72de7c7648 fix: swift 2025-10-05 20:31:59 +01:00
a8b150857c fix 2025-10-05 19:56:48 +01:00
dd4f508346 fix 2025-10-05 19:05:17 +01:00
f21ee57632 fix: search 2025-10-05 16:27:38 +01:00
0e42c9002b fix: minor bugs 2025-10-05 16:25:00 +01:00
9e60a41f0a fix: processing images indicator 2025-10-05 15:59:14 +01:00
eaff553dc9 fix: image stacks on image page 2025-10-05 15:55:50 +01:00
6880811236 fix: delete schema column button 2025-10-05 15:54:46 +01:00
38bda46dcf hack: to get response format to work properly
I think this might be a bug with requesty
2025-10-05 15:35:09 +01:00
bd86ad499b fix: setting entities to done 2025-10-05 15:20:35 +01:00
838ab37fc1 refactor: list -> stack 2025-10-05 15:12:37 +01:00
9948d2521b feat: creating list processor 2025-10-05 15:08:25 +01:00
64abf79f9c feat: stack model processor 2025-10-05 14:54:24 +01:00
0d41a65435 feat: deleting column from frontend 2025-10-05 14:53:04 +01:00
ecd1529130 feat: implementing delete schema column function 2025-10-05 13:59:54 +01:00
015a7cb5cd fix: saving image schema items 2025-10-05 13:44:50 +01:00
980b42aa44 fix: notification system 2025-10-05 12:10:06 +01:00
649cfe0b02 fix: frontend with new backend schema 2025-10-05 10:44:57 +01:00
1fb9616aa7 WIP: image processing is back and working 2025-09-21 22:07:56 +01:00
013447fa90 wip: processing images 2025-09-21 21:48:22 +01:00
221afb599b BIG MASSIVE REFACTOR OMG
Ripped out literally everything to simplify the backend as much as
possible.

Some of the code was so horrifically complicated it's insaneeee
2025-09-21 21:31:44 +01:00
f8619d3ef7 fix: stuff 2025-09-21 16:48:17 +01:00
f6393c9a59 suspending 2025-09-21 15:54:24 +01:00
561064a194 using resources to always have a valid access token 2025-09-21 15:51:33 +01:00
3015d7bac2 fully working refresh tokens. No more expiring :) 2025-09-21 15:43:14 +01:00
a3345afbfa AI: checking the actual expire 2025-09-21 14:51:02 +01:00
f078ac7d0b AI: refactoring JWT tokens to jwt token manager 2025-09-21 14:42:06 +01:00
e28d9e5d16 using access token in header! 2025-09-15 21:50:15 +01:00
29c56bee1c protecting backend images 2025-09-14 19:09:50 +01:00
3ebc0810e7 Revert "frontend method to refresh image"
This reverts commit ce2cd977ac18c498e0601b3a5f631587f7cd5272.
2025-09-14 18:04:00 +01:00
0c595f76a3 disabling my mistakes 2025-09-14 18:03:52 +01:00
176d2b0bd4 reprocessing images now works 2025-09-14 18:02:40 +01:00
115d08a245 going back to how it was 2025-09-14 17:45:30 +01:00
b4b600bd7c Revert "refactor: creating image process to handle processing of images"
This reverts commit 8b6b9453a8f86d705ec2319c51f429faf687cb52.
2025-09-14 17:44:33 +01:00
ce2cd977ac frontend method to refresh image 2025-09-14 17:42:16 +01:00
8b6b9453a8 refactor: creating image process to handle processing of images
Decoupling this from the DB, it's a good step.

Not yet perfect however.
2025-09-14 17:42:16 +01:00
2dd9f33303 feat: allowing users to delete images from lists 2025-08-30 21:38:01 +01:00
94ee8bdb7e fix: cascade deleting of image properties 2025-08-30 21:03:15 +01:00
5d1c758451 feat: adding button to delete image 2025-08-30 20:46:05 +01:00
00359e2e8d feat: handling image delete 2025-08-30 20:34:57 +01:00
95330c163b fix: only firing update status when image is not 'not-started' 2025-08-30 20:32:05 +01:00
84a0996be9 feat: checking users limits when AI scans their images 2025-08-30 20:32:05 +01:00
48579267b5 feat: showing limits for list creation 2025-08-30 11:17:24 +01:00
8b54d502f2 feat: showing limits error on frontend 2025-08-30 11:15:17 +01:00
e45688d57e fix: allowing nullable user lists 2025-08-30 10:53:10 +01:00
f7c9c97f0a fix: allowing images without lists to survive 2025-08-30 10:49:28 +01:00
76924a0332 chore: using requesty policy instead of direct models 2025-08-30 10:46:37 +01:00
d97593d487 feat: adding limits 2025-08-30 10:45:53 +01:00
86 changed files with 4038 additions and 4093 deletions

View File

@ -9,11 +9,15 @@ package model
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"time"
) )
type Image struct { type Image struct {
ID uuid.UUID `sql:"primary_key"` ID uuid.UUID `sql:"primary_key"`
UserID uuid.UUID
ImageName string ImageName string
Description string Description string
Status Progress
Image []byte Image []byte
CreatedAt *time.Time
} }

View File

@ -11,8 +11,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type ImageLists struct { type ImageStacks struct {
ID uuid.UUID `sql:"primary_key"` ID uuid.UUID `sql:"primary_key"`
ImageID uuid.UUID ImageID uuid.UUID
ListID uuid.UUID StackID uuid.UUID
} }

View File

@ -1,19 +0,0 @@
//
// 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"
"time"
)
type Logs struct {
Log string
ImageID uuid.UUID
CreatedAt *time.Time
}

View File

@ -1,22 +0,0 @@
//
// 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"
"time"
)
type ProcessingLists struct {
ID uuid.UUID `sql:"primary_key"`
UserID uuid.UUID
Title string
Fields string
Status Progress
CreatedAt *time.Time
}

View File

@ -16,5 +16,5 @@ type SchemaItems struct {
Item string Item string
Value string Value string
Description string Description string
SchemaID uuid.UUID StackID uuid.UUID
} }

View File

@ -1,17 +0,0 @@
//
// 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 Schemas struct {
ID uuid.UUID `sql:"primary_key"`
ListID uuid.UUID
}

View File

@ -12,9 +12,10 @@ import (
"time" "time"
) )
type Lists struct { type Stacks struct {
ID uuid.UUID `sql:"primary_key"` ID uuid.UUID `sql:"primary_key"`
UserID uuid.UUID UserID uuid.UUID
Status Progress
Name string Name string
Description string Description string
CreatedAt *time.Time CreatedAt *time.Time

View File

@ -1,20 +0,0 @@
//
// 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"
"time"
)
type UserImages struct {
ID uuid.UUID `sql:"primary_key"`
ImageID uuid.UUID
UserID uuid.UUID
CreatedAt *time.Time
}

View File

@ -1,19 +0,0 @@
//
// 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"`
Status Progress
ImageID uuid.UUID
UserID uuid.UUID
}

View File

@ -9,9 +9,11 @@ package model
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"time"
) )
type Users struct { type Users struct {
ID uuid.UUID `sql:"primary_key"` ID uuid.UUID `sql:"primary_key"`
Email string Email string
CreatedAt *time.Time
} }

View File

@ -18,13 +18,15 @@ type imageTable struct {
// Columns // Columns
ID postgres.ColumnString ID postgres.ColumnString
UserID postgres.ColumnString
ImageName postgres.ColumnString ImageName postgres.ColumnString
Description postgres.ColumnString Description postgres.ColumnString
Image postgres.ColumnBytea Status postgres.ColumnString
Image postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
} }
type ImageTable struct { type ImageTable struct {
@ -63,12 +65,14 @@ func newImageTable(schemaName, tableName, alias string) *ImageTable {
func newImageTableImpl(schemaName, tableName, alias string) imageTable { func newImageTableImpl(schemaName, tableName, alias string) imageTable {
var ( var (
IDColumn = postgres.StringColumn("id") IDColumn = postgres.StringColumn("id")
UserIDColumn = postgres.StringColumn("user_id")
ImageNameColumn = postgres.StringColumn("image_name") ImageNameColumn = postgres.StringColumn("image_name")
DescriptionColumn = postgres.StringColumn("description") DescriptionColumn = postgres.StringColumn("description")
ImageColumn = postgres.ByteaColumn("image") StatusColumn = postgres.StringColumn("status")
allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, DescriptionColumn, ImageColumn} ImageColumn = postgres.StringColumn("image")
mutableColumns = postgres.ColumnList{ImageNameColumn, DescriptionColumn, ImageColumn} CreatedAtColumn = postgres.TimestampzColumn("created_at")
defaultColumns = postgres.ColumnList{IDColumn} allColumns = postgres.ColumnList{IDColumn, UserIDColumn, ImageNameColumn, DescriptionColumn, StatusColumn, ImageColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{UserIDColumn, ImageNameColumn, DescriptionColumn, StatusColumn, ImageColumn, CreatedAtColumn}
) )
return imageTable{ return imageTable{
@ -76,12 +80,14 @@ func newImageTableImpl(schemaName, tableName, alias string) imageTable {
//Columns //Columns
ID: IDColumn, ID: IDColumn,
UserID: UserIDColumn,
ImageName: ImageNameColumn, ImageName: ImageNameColumn,
Description: DescriptionColumn, Description: DescriptionColumn,
Status: StatusColumn,
Image: ImageColumn, Image: ImageColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
} }
} }

View File

@ -1,84 +0,0 @@
//
// 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 ImageLists = newImageListsTable("haystack", "image_lists", "")
type imageListsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ImageID postgres.ColumnString
ListID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ImageListsTable struct {
imageListsTable
EXCLUDED imageListsTable
}
// AS creates new ImageListsTable with assigned alias
func (a ImageListsTable) AS(alias string) *ImageListsTable {
return newImageListsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ImageListsTable with assigned schema name
func (a ImageListsTable) FromSchema(schemaName string) *ImageListsTable {
return newImageListsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ImageListsTable with assigned table prefix
func (a ImageListsTable) WithPrefix(prefix string) *ImageListsTable {
return newImageListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ImageListsTable with assigned table suffix
func (a ImageListsTable) WithSuffix(suffix string) *ImageListsTable {
return newImageListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newImageListsTable(schemaName, tableName, alias string) *ImageListsTable {
return &ImageListsTable{
imageListsTable: newImageListsTableImpl(schemaName, tableName, alias),
EXCLUDED: newImageListsTableImpl("", "excluded", ""),
}
}
func newImageListsTableImpl(schemaName, tableName, alias string) imageListsTable {
var (
IDColumn = postgres.StringColumn("id")
ImageIDColumn = postgres.StringColumn("image_id")
ListIDColumn = postgres.StringColumn("list_id")
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, ListIDColumn}
mutableColumns = postgres.ColumnList{ImageIDColumn, ListIDColumn}
defaultColumns = postgres.ColumnList{IDColumn}
)
return imageListsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ImageID: ImageIDColumn,
ListID: ListIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -24,7 +24,6 @@ type imageSchemaItemsTable struct {
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
} }
type ImageSchemaItemsTable struct { type ImageSchemaItemsTable struct {
@ -68,7 +67,6 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche
ImageIDColumn = postgres.StringColumn("image_id") ImageIDColumn = postgres.StringColumn("image_id")
allColumns = postgres.ColumnList{IDColumn, ValueColumn, SchemaItemIDColumn, ImageIDColumn} allColumns = postgres.ColumnList{IDColumn, ValueColumn, SchemaItemIDColumn, ImageIDColumn}
mutableColumns = postgres.ColumnList{ValueColumn, SchemaItemIDColumn, ImageIDColumn} mutableColumns = postgres.ColumnList{ValueColumn, SchemaItemIDColumn, ImageIDColumn}
defaultColumns = postgres.ColumnList{IDColumn}
) )
return imageSchemaItemsTable{ return imageSchemaItemsTable{
@ -82,6 +80,5 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
} }
} }

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 ImageStacks = newImageStacksTable("haystack", "image_stacks", "")
type imageStacksTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ImageID postgres.ColumnString
StackID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
}
type ImageStacksTable struct {
imageStacksTable
EXCLUDED imageStacksTable
}
// AS creates new ImageStacksTable with assigned alias
func (a ImageStacksTable) AS(alias string) *ImageStacksTable {
return newImageStacksTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ImageStacksTable with assigned schema name
func (a ImageStacksTable) FromSchema(schemaName string) *ImageStacksTable {
return newImageStacksTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ImageStacksTable with assigned table prefix
func (a ImageStacksTable) WithPrefix(prefix string) *ImageStacksTable {
return newImageStacksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ImageStacksTable with assigned table suffix
func (a ImageStacksTable) WithSuffix(suffix string) *ImageStacksTable {
return newImageStacksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newImageStacksTable(schemaName, tableName, alias string) *ImageStacksTable {
return &ImageStacksTable{
imageStacksTable: newImageStacksTableImpl(schemaName, tableName, alias),
EXCLUDED: newImageStacksTableImpl("", "excluded", ""),
}
}
func newImageStacksTableImpl(schemaName, tableName, alias string) imageStacksTable {
var (
IDColumn = postgres.StringColumn("id")
ImageIDColumn = postgres.StringColumn("image_id")
StackIDColumn = postgres.StringColumn("stack_id")
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, StackIDColumn}
mutableColumns = postgres.ColumnList{ImageIDColumn, StackIDColumn}
)
return imageStacksTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ImageID: ImageIDColumn,
StackID: StackIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
}
}

View File

@ -1,90 +0,0 @@
//
// 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 Lists = newListsTable("haystack", "lists", "")
type listsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
UserID postgres.ColumnString
Name postgres.ColumnString
Description postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ListsTable struct {
listsTable
EXCLUDED listsTable
}
// AS creates new ListsTable with assigned alias
func (a ListsTable) AS(alias string) *ListsTable {
return newListsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ListsTable with assigned schema name
func (a ListsTable) FromSchema(schemaName string) *ListsTable {
return newListsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ListsTable with assigned table prefix
func (a ListsTable) WithPrefix(prefix string) *ListsTable {
return newListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ListsTable with assigned table suffix
func (a ListsTable) WithSuffix(suffix string) *ListsTable {
return newListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newListsTable(schemaName, tableName, alias string) *ListsTable {
return &ListsTable{
listsTable: newListsTableImpl(schemaName, tableName, alias),
EXCLUDED: newListsTableImpl("", "excluded", ""),
}
}
func newListsTableImpl(schemaName, tableName, alias string) listsTable {
var (
IDColumn = postgres.StringColumn("id")
UserIDColumn = postgres.StringColumn("user_id")
NameColumn = postgres.StringColumn("name")
DescriptionColumn = postgres.StringColumn("description")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn}
)
return listsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
UserID: UserIDColumn,
Name: NameColumn,
Description: DescriptionColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -1,84 +0,0 @@
//
// 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 Logs = newLogsTable("haystack", "logs", "")
type logsTable struct {
postgres.Table
// Columns
Log postgres.ColumnString
ImageID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type LogsTable struct {
logsTable
EXCLUDED logsTable
}
// AS creates new LogsTable with assigned alias
func (a LogsTable) AS(alias string) *LogsTable {
return newLogsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new LogsTable with assigned schema name
func (a LogsTable) FromSchema(schemaName string) *LogsTable {
return newLogsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new LogsTable with assigned table prefix
func (a LogsTable) WithPrefix(prefix string) *LogsTable {
return newLogsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new LogsTable with assigned table suffix
func (a LogsTable) WithSuffix(suffix string) *LogsTable {
return newLogsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newLogsTable(schemaName, tableName, alias string) *LogsTable {
return &LogsTable{
logsTable: newLogsTableImpl(schemaName, tableName, alias),
EXCLUDED: newLogsTableImpl("", "excluded", ""),
}
}
func newLogsTableImpl(schemaName, tableName, alias string) logsTable {
var (
LogColumn = postgres.StringColumn("log")
ImageIDColumn = postgres.StringColumn("image_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return logsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
Log: LogColumn,
ImageID: ImageIDColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -1,93 +0,0 @@
//
// 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 ProcessingLists = newProcessingListsTable("haystack", "processing_lists", "")
type processingListsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
UserID postgres.ColumnString
Title postgres.ColumnString
Fields postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProcessingListsTable struct {
processingListsTable
EXCLUDED processingListsTable
}
// AS creates new ProcessingListsTable with assigned alias
func (a ProcessingListsTable) AS(alias string) *ProcessingListsTable {
return newProcessingListsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProcessingListsTable with assigned schema name
func (a ProcessingListsTable) FromSchema(schemaName string) *ProcessingListsTable {
return newProcessingListsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProcessingListsTable with assigned table prefix
func (a ProcessingListsTable) WithPrefix(prefix string) *ProcessingListsTable {
return newProcessingListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProcessingListsTable with assigned table suffix
func (a ProcessingListsTable) WithSuffix(suffix string) *ProcessingListsTable {
return newProcessingListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProcessingListsTable(schemaName, tableName, alias string) *ProcessingListsTable {
return &ProcessingListsTable{
processingListsTable: newProcessingListsTableImpl(schemaName, tableName, alias),
EXCLUDED: newProcessingListsTableImpl("", "excluded", ""),
}
}
func newProcessingListsTableImpl(schemaName, tableName, alias string) processingListsTable {
var (
IDColumn = postgres.StringColumn("id")
UserIDColumn = postgres.StringColumn("user_id")
TitleColumn = postgres.StringColumn("title")
FieldsColumn = postgres.StringColumn("fields")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn, StatusColumn, CreatedAtColumn}
)
return processingListsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
UserID: UserIDColumn,
Title: TitleColumn,
Fields: FieldsColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -21,11 +21,10 @@ type schemaItemsTable struct {
Item postgres.ColumnString Item postgres.ColumnString
Value postgres.ColumnString Value postgres.ColumnString
Description postgres.ColumnString Description postgres.ColumnString
SchemaID postgres.ColumnString StackID postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
} }
type SchemaItemsTable struct { type SchemaItemsTable struct {
@ -67,10 +66,9 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab
ItemColumn = postgres.StringColumn("item") ItemColumn = postgres.StringColumn("item")
ValueColumn = postgres.StringColumn("value") ValueColumn = postgres.StringColumn("value")
DescriptionColumn = postgres.StringColumn("description") DescriptionColumn = postgres.StringColumn("description")
SchemaIDColumn = postgres.StringColumn("schema_id") StackIDColumn = postgres.StringColumn("stack_id")
allColumns = postgres.ColumnList{IDColumn, ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn} allColumns = postgres.ColumnList{IDColumn, ItemColumn, ValueColumn, DescriptionColumn, StackIDColumn}
mutableColumns = postgres.ColumnList{ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn} mutableColumns = postgres.ColumnList{ItemColumn, ValueColumn, DescriptionColumn, StackIDColumn}
defaultColumns = postgres.ColumnList{IDColumn}
) )
return schemaItemsTable{ return schemaItemsTable{
@ -81,10 +79,9 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab
Item: ItemColumn, Item: ItemColumn,
Value: ValueColumn, Value: ValueColumn,
Description: DescriptionColumn, Description: DescriptionColumn,
SchemaID: SchemaIDColumn, StackID: StackIDColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
} }
} }

View File

@ -1,81 +0,0 @@
//
// 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 Schemas = newSchemasTable("haystack", "schemas", "")
type schemasTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ListID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type SchemasTable struct {
schemasTable
EXCLUDED schemasTable
}
// AS creates new SchemasTable with assigned alias
func (a SchemasTable) AS(alias string) *SchemasTable {
return newSchemasTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new SchemasTable with assigned schema name
func (a SchemasTable) FromSchema(schemaName string) *SchemasTable {
return newSchemasTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new SchemasTable with assigned table prefix
func (a SchemasTable) WithPrefix(prefix string) *SchemasTable {
return newSchemasTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new SchemasTable with assigned table suffix
func (a SchemasTable) WithSuffix(suffix string) *SchemasTable {
return newSchemasTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newSchemasTable(schemaName, tableName, alias string) *SchemasTable {
return &SchemasTable{
schemasTable: newSchemasTableImpl(schemaName, tableName, alias),
EXCLUDED: newSchemasTableImpl("", "excluded", ""),
}
}
func newSchemasTableImpl(schemaName, tableName, alias string) schemasTable {
var (
IDColumn = postgres.StringColumn("id")
ListIDColumn = postgres.StringColumn("list_id")
allColumns = postgres.ColumnList{IDColumn, ListIDColumn}
mutableColumns = postgres.ColumnList{ListIDColumn}
defaultColumns = postgres.ColumnList{IDColumn}
)
return schemasTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ListID: ListIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -0,0 +1,90 @@
//
// 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 Stacks = newStacksTable("haystack", "stacks", "")
type stacksTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
UserID postgres.ColumnString
Status postgres.ColumnString
Name postgres.ColumnString
Description postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
}
type StacksTable struct {
stacksTable
EXCLUDED stacksTable
}
// AS creates new StacksTable with assigned alias
func (a StacksTable) AS(alias string) *StacksTable {
return newStacksTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new StacksTable with assigned schema name
func (a StacksTable) FromSchema(schemaName string) *StacksTable {
return newStacksTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new StacksTable with assigned table prefix
func (a StacksTable) WithPrefix(prefix string) *StacksTable {
return newStacksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new StacksTable with assigned table suffix
func (a StacksTable) WithSuffix(suffix string) *StacksTable {
return newStacksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newStacksTable(schemaName, tableName, alias string) *StacksTable {
return &StacksTable{
stacksTable: newStacksTableImpl(schemaName, tableName, alias),
EXCLUDED: newStacksTableImpl("", "excluded", ""),
}
}
func newStacksTableImpl(schemaName, tableName, alias string) stacksTable {
var (
IDColumn = postgres.StringColumn("id")
UserIDColumn = postgres.StringColumn("user_id")
StatusColumn = postgres.StringColumn("status")
NameColumn = postgres.StringColumn("name")
DescriptionColumn = postgres.StringColumn("description")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, StatusColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{UserIDColumn, StatusColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
)
return stacksTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
UserID: UserIDColumn,
Status: StatusColumn,
Name: NameColumn,
Description: DescriptionColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
}
}

View File

@ -11,14 +11,9 @@ package table
// 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) Image = Image.FromSchema(schema)
ImageLists = ImageLists.FromSchema(schema)
ImageSchemaItems = ImageSchemaItems.FromSchema(schema) ImageSchemaItems = ImageSchemaItems.FromSchema(schema)
Lists = Lists.FromSchema(schema) ImageStacks = ImageStacks.FromSchema(schema)
Logs = Logs.FromSchema(schema)
ProcessingLists = ProcessingLists.FromSchema(schema)
SchemaItems = SchemaItems.FromSchema(schema) SchemaItems = SchemaItems.FromSchema(schema)
Schemas = Schemas.FromSchema(schema) Stacks = Stacks.FromSchema(schema)
UserImages = UserImages.FromSchema(schema)
UserImagesToProcess = UserImagesToProcess.FromSchema(schema)
Users = Users.FromSchema(schema) Users = Users.FromSchema(schema)
} }

View File

@ -1,87 +0,0 @@
//
// 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 UserImages = newUserImagesTable("haystack", "user_images", "")
type userImagesTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ImageID postgres.ColumnString
UserID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type UserImagesTable struct {
userImagesTable
EXCLUDED userImagesTable
}
// AS creates new UserImagesTable with assigned alias
func (a UserImagesTable) AS(alias string) *UserImagesTable {
return newUserImagesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new UserImagesTable with assigned schema name
func (a UserImagesTable) FromSchema(schemaName string) *UserImagesTable {
return newUserImagesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new UserImagesTable with assigned table prefix
func (a UserImagesTable) WithPrefix(prefix string) *UserImagesTable {
return newUserImagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new UserImagesTable with assigned table suffix
func (a UserImagesTable) WithSuffix(suffix string) *UserImagesTable {
return newUserImagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newUserImagesTable(schemaName, tableName, alias string) *UserImagesTable {
return &UserImagesTable{
userImagesTable: newUserImagesTableImpl(schemaName, tableName, alias),
EXCLUDED: newUserImagesTableImpl("", "excluded", ""),
}
}
func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable {
var (
IDColumn = postgres.StringColumn("id")
ImageIDColumn = postgres.StringColumn("image_id")
UserIDColumn = postgres.StringColumn("user_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn}
)
return userImagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ImageID: ImageIDColumn,
UserID: UserIDColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -1,87 +0,0 @@
//
// 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
Status postgres.ColumnString
ImageID postgres.ColumnString
UserID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns 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")
StatusColumn = postgres.StringColumn("status")
ImageIDColumn = postgres.StringColumn("image_id")
UserIDColumn = postgres.StringColumn("user_id")
allColumns = postgres.ColumnList{IDColumn, StatusColumn, ImageIDColumn, UserIDColumn}
mutableColumns = postgres.ColumnList{StatusColumn, ImageIDColumn, UserIDColumn}
defaultColumns = postgres.ColumnList{IDColumn, StatusColumn}
)
return userImagesToProcessTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
Status: StatusColumn,
ImageID: ImageIDColumn,
UserID: UserIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -17,12 +17,12 @@ type usersTable struct {
postgres.Table postgres.Table
// Columns // Columns
ID postgres.ColumnString ID postgres.ColumnString
Email postgres.ColumnString Email postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
} }
type UsersTable struct { type UsersTable struct {
@ -60,22 +60,22 @@ func newUsersTable(schemaName, tableName, alias string) *UsersTable {
func newUsersTableImpl(schemaName, tableName, alias string) usersTable { func newUsersTableImpl(schemaName, tableName, alias string) usersTable {
var ( var (
IDColumn = postgres.StringColumn("id") IDColumn = postgres.StringColumn("id")
EmailColumn = postgres.StringColumn("email") EmailColumn = postgres.StringColumn("email")
allColumns = postgres.ColumnList{IDColumn, EmailColumn} CreatedAtColumn = postgres.TimestampzColumn("created_at")
mutableColumns = postgres.ColumnList{EmailColumn} allColumns = postgres.ColumnList{IDColumn, EmailColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn} mutableColumns = postgres.ColumnList{EmailColumn, CreatedAtColumn}
) )
return usersTable{ return usersTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
ID: IDColumn, ID: IDColumn,
Email: EmailColumn, Email: EmailColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
} }
} }

View File

@ -246,7 +246,7 @@ func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageNa
request := AgentRequestBody{ request := AgentRequestBody{
Tools: &tools, Tools: &tools,
ToolChoice: &toolChoice, ToolChoice: &toolChoice,
Model: "google/gemini-2.5-flash", Model: "policy/images",
RandomSeed: &seed, RandomSeed: &seed,
Temperature: 0.3, Temperature: 0.3,
EndToolCall: client.Options.EndToolCall, EndToolCall: client.Options.EndToolCall,
@ -262,7 +262,7 @@ func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageNa
request.Chat.AddImage(imageName, imageData, client.Options.Query) request.Chat.AddImage(imageName, imageData, client.Options.Query)
toolHandlerInfo := ToolHandlerInfo{ toolHandlerInfo := ToolHandlerInfo{
ImageId: imageId, ImageID: imageId,
ImageName: imageName, ImageName: imageName,
UserId: userId, UserId: userId,
Image: &imageData, Image: &imageData,
@ -284,7 +284,7 @@ func (client *AgentClient) RunAgentAlone(userID uuid.UUID, userReq string) error
request := AgentRequestBody{ request := AgentRequestBody{
Tools: &tools, Tools: &tools,
ToolChoice: &toolChoice, ToolChoice: &toolChoice,
Model: "google/gemini-2.5-flash", Model: "policy/images",
RandomSeed: &seed, RandomSeed: &seed,
Temperature: 0.3, Temperature: 0.3,
EndToolCall: client.Options.EndToolCall, EndToolCall: client.Options.EndToolCall,

View File

@ -9,7 +9,7 @@ import (
type ToolHandlerInfo struct { type ToolHandlerInfo struct {
UserId uuid.UUID UserId uuid.UUID
ImageId uuid.UUID ImageID uuid.UUID
ImageName string ImageName string
// Pointer because we don't want to copy this around too much. // Pointer because we don't want to copy this around too much.

View File

@ -40,7 +40,7 @@ func (suite *ToolTestSuite) TestSingleToolCall() {
response := suite.handler.Handle( response := suite.handler.Handle(
ToolHandlerInfo{ ToolHandlerInfo{
UserId: uuid.Nil, UserId: uuid.Nil,
ImageId: uuid.Nil, ImageID: uuid.Nil,
}, },
ToolCall{ ToolCall{
Index: 0, Index: 0,
@ -91,7 +91,7 @@ func (suite *ToolTestSuite) TestMultipleToolCalls() {
err := suite.client.Process( err := suite.client.Process(
ToolHandlerInfo{ ToolHandlerInfo{
UserId: uuid.Nil, UserId: uuid.Nil,
ImageId: uuid.Nil, ImageID: uuid.Nil,
}, },
&AgentRequestBody{ &AgentRequestBody{
Chat: &chat, Chat: &chat,
@ -154,7 +154,7 @@ func (suite *ToolTestSuite) TestMultipleToolCallsWithErrors() {
err := suite.client.Process( err := suite.client.Process(
ToolHandlerInfo{ ToolHandlerInfo{
UserId: uuid.Nil, UserId: uuid.Nil,
ImageId: uuid.Nil, ImageID: uuid.Nil,
}, },
&AgentRequestBody{ &AgentRequestBody{
Chat: &chat, Chat: &chat,

View File

@ -7,6 +7,7 @@ import (
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"strings"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
@ -21,6 +22,8 @@ and add a good description for each one.
You can add fields if you think they make a lot of sense. You can add fields if you think they make a lot of sense.
You can remove fields if they are not correct, but be sure before you do this. You can remove fields if they are not correct, but be sure before you do this.
You must respond in json format, do not add backticks to the json. ONLY valid json.
` `
const listJsonSchema = ` const listJsonSchema = `
@ -76,15 +79,15 @@ type createNewListArguments struct {
type CreateListAgent struct { type CreateListAgent struct {
client client.AgentClient client client.AgentClient
listModel models.ListModel stackModel models.StackModel
} }
func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, userReq string) error { func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, stackID uuid.UUID, title string, userReq string) error {
request := client.AgentRequestBody{ request := client.AgentRequestBody{
Model: "google/gemini-2.5-flash", Model: "policy/images",
Temperature: 0.3, Temperature: 0.3,
ResponseFormat: client.ResponseFormat{ ResponseFormat: client.ResponseFormat{
Type: "json_object", Type: "json_schema",
JsonSchema: listJsonSchema, JsonSchema: listJsonSchema,
}, },
Chat: &client.Chat{ Chat: &client.Chat{
@ -93,7 +96,10 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
} }
request.Chat.AddSystem(agent.client.Options.SystemPrompt) request.Chat.AddSystem(agent.client.Options.SystemPrompt)
request.Chat.AddUser(userReq)
req := fmt.Sprintf("List title: %s | Users list description: %s", title, userReq)
request.Chat.AddUser(req)
resp, err := agent.client.Request(&request) resp, err := agent.client.Request(&request)
if err != nil { if err != nil {
@ -102,10 +108,16 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
ctx := context.Background() ctx := context.Background()
structuredOutput := resp.Choices[0].Message.Content content := resp.Choices[0].Message.Content
if strings.HasPrefix(content, "```json") {
content = content[len("```json") : len(content)-3]
}
log.Info("", "res", content)
var createListArgs createNewListArguments var createListArgs createNewListArguments
err = json.Unmarshal([]byte(structuredOutput), &createListArgs) err = json.Unmarshal([]byte(content), &createListArgs)
if err != nil { if err != nil {
return err return err
} }
@ -113,6 +125,8 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
schemaItems := make([]model.SchemaItems, 0) schemaItems := make([]model.SchemaItems, 0)
for _, field := range createListArgs.Fields { for _, field := range createListArgs.Fields {
schemaItems = append(schemaItems, model.SchemaItems{ schemaItems = append(schemaItems, model.SchemaItems{
StackID: stackID,
Item: field.Name, Item: field.Name,
Description: field.Description, Description: field.Description,
@ -120,12 +134,15 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
}) })
} }
agent.listModel.Save(ctx, userID, createListArgs.Title, createListArgs.Description, schemaItems) err = agent.stackModel.SaveItems(ctx, schemaItems)
if err != nil {
return fmt.Errorf("creating list agent, saving items: %w", err)
}
return nil return nil
} }
func NewCreateListAgent(log *log.Logger, listModel models.ListModel) CreateListAgent { func NewCreateListAgent(log *log.Logger, listModel models.StackModel) CreateListAgent {
client := client.CreateAgentClient(client.CreateAgentClientOptions{ client := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: createListAgentPrompt, SystemPrompt: createListAgentPrompt,
Log: log, Log: log,

View File

@ -26,9 +26,9 @@ type DescriptionAgent struct {
imageModel models.ImageModel imageModel models.ImageModel
} }
func (agent DescriptionAgent) Describe(log *log.Logger, imageId uuid.UUID, imageName string, imageData []byte) error { func (agent DescriptionAgent) Describe(log *log.Logger, imageID uuid.UUID, imageName string, imageData []byte) error {
request := client.AgentRequestBody{ request := client.AgentRequestBody{
Model: "google/gemini-2.5-flash-lite-preview-06-17", Model: "policy/images",
Temperature: 0.3, Temperature: 0.3,
ResponseFormat: client.ResponseFormat{ ResponseFormat: client.ResponseFormat{
Type: "text", Type: "text",
@ -49,9 +49,9 @@ func (agent DescriptionAgent) Describe(log *log.Logger, imageId uuid.UUID, image
ctx := context.Background() ctx := context.Background()
markdown := resp.Choices[0].Message.Content description := resp.Choices[0].Message.Content
err = agent.imageModel.AddDescription(ctx, imageId, markdown) err = agent.imageModel.UpdateDescription(ctx, imageID, description)
if err != nil { if err != nil {
return err return err
} }

View File

@ -3,8 +3,10 @@ package agents
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/limits"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
@ -174,7 +176,7 @@ type addToListArguments struct {
Schema []models.IDValue Schema []models.IDValue
} }
func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClient { func NewStackAgent(log *log.Logger, stackModel models.StackModel, limitsMethods limits.LimitsManagerMethods) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{ agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: listPrompt, SystemPrompt: listPrompt,
JsonTools: listTools, JsonTools: listTools,
@ -193,21 +195,38 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien
return "", err return "", err
} }
ctx := context.Background() hasReachedLimit, err := limitsMethods.HasReachedStackLimit(info.UserId)
savedList, err := listModel.Save(ctx, info.UserId, args.Name, args.Desription, args.Schema)
if err != nil { if err != nil {
log.Error(err) return "", fmt.Errorf("error checking stack limits: %w", err)
}
if hasReachedLimit {
log.Warn("User has reached limits", "userID", info.UserId)
return "", fmt.Errorf("reached stack limits")
}
ctx := context.Background()
savedList, err := stackModel.Save(ctx, info.UserId, args.Name, args.Desription, model.Progress_Complete)
if err != nil {
log.Error("saving list", "err", err)
return "", err return "", err
} }
log.Debug(savedList) for i := range args.Schema {
args.Schema[i].StackID = savedList.ID
}
err = stackModel.SaveItems(ctx, args.Schema)
if err != nil {
log.Error("saving items", "err", err)
return "", err
}
return savedList, nil return savedList, nil
}) })
agentClient.ToolHandler.AddTool("listLists", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("listLists", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return listModel.List(context.Background(), info.UserId) return stackModel.List(context.Background(), info.UserId)
}) })
agentClient.ToolHandler.AddTool("addToList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("addToList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -219,12 +238,17 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien
ctx := context.Background() ctx := context.Background()
listUuid, err := uuid.Parse(args.ListID) listUUID, err := uuid.Parse(args.ListID)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := listModel.SaveInto(ctx, listUuid, info.ImageId, args.Schema); err != nil { imageStack, err := stackModel.SaveImage(ctx, info.ImageID, listUUID)
if err != nil {
return "", err
}
if err := stackModel.SaveSchemaItems(ctx, imageStack.ID, args.Schema); err != nil {
return "", err return "", err
} }

View File

@ -18,6 +18,8 @@ type AuthHandler struct {
user models.UserModel user models.UserModel
auth Auth auth Auth
jwtManager *middleware.JwtManager
} }
type loginBody struct { type loginBody struct {
@ -34,6 +36,14 @@ type codeReturn struct {
Refresh string `json:"refresh"` Refresh string `json:"refresh"`
} }
type refreshBody struct {
Refresh string `json:"refresh"`
}
type refreshReturn struct {
Access string `json:"access"`
}
func (h *AuthHandler) login(body loginBody, w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) login(body loginBody, w http.ResponseWriter, r *http.Request) {
err := h.auth.CreateCode(body.Email) err := h.auth.CreateCode(body.Email)
if err != nil { if err != nil {
@ -65,8 +75,8 @@ func (h *AuthHandler) code(body codeBody, w http.ResponseWriter, r *http.Request
return return
} }
refresh := middleware.CreateRefreshToken(uuid) refresh := h.jwtManager.CreateRefreshToken(uuid)
access := middleware.CreateAccessToken(uuid) access := h.jwtManager.CreateAccessToken(uuid)
codeReturn := codeReturn{ codeReturn := codeReturn{
Access: access, Access: access,
@ -76,6 +86,23 @@ func (h *AuthHandler) code(body codeBody, w http.ResponseWriter, r *http.Request
middleware.WriteJsonOrError(h.logger, codeReturn, w) middleware.WriteJsonOrError(h.logger, codeReturn, w)
} }
func (h *AuthHandler) refresh(body refreshBody, w http.ResponseWriter, r *http.Request) {
h.logger.Info("token", "refresh", body.Refresh)
userId, err := h.jwtManager.GetUserIdFromRefresh(body.Refresh)
if err != nil {
middleware.WriteErrorBadRequest(h.logger, "invalid refresh token: "+err.Error(), w)
return
}
access := h.jwtManager.CreateAccessToken(userId)
refreshReturn := refreshReturn{
Access: access,
}
middleware.WriteJsonOrError(h.logger, refreshReturn, w)
}
func (h *AuthHandler) CreateRoutes(r chi.Router) { func (h *AuthHandler) CreateRoutes(r chi.Router) {
h.logger.Info("Mounting auth router") h.logger.Info("Mounting auth router")
@ -84,10 +111,11 @@ func (h *AuthHandler) CreateRoutes(r chi.Router) {
r.Post("/login", middleware.WithValidatedPost(h.login)) r.Post("/login", middleware.WithValidatedPost(h.login))
r.Post("/code", middleware.WithValidatedPost(h.code)) r.Post("/code", middleware.WithValidatedPost(h.code))
r.Post("/refresh", middleware.WithValidatedPost(h.refresh))
}) })
} }
func CreateAuthHandler(db *sql.DB) AuthHandler { func CreateAuthHandler(db *sql.DB, jwtManager *middleware.JwtManager) AuthHandler {
userModel := models.NewUserModel(db) userModel := models.NewUserModel(db)
logger := log.New(os.Stdout).WithPrefix("Auth") logger := log.New(os.Stdout).WithPrefix("Auth")
@ -99,8 +127,9 @@ func CreateAuthHandler(db *sql.DB) AuthHandler {
auth := CreateAuth(mailer) auth := CreateAuth(mailer)
return AuthHandler{ return AuthHandler{
logger, logger: logger,
userModel, user: userModel,
auth, auth: auth,
jwtManager: jwtManager,
} }
} }

View File

@ -1,307 +1,26 @@
package main package main
import ( import (
"context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"screenmark/screenmark/agents"
"screenmark/screenmark/middleware" "screenmark/screenmark/middleware"
"screenmark/screenmark/models" "screenmark/screenmark/notifications"
"strconv" "strconv"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
) )
const (
IMAGE_TYPE = "image"
LIST_TYPE = "list"
)
type imageNotification struct {
Type string
ImageID uuid.UUID
ImageName string
Status string
}
type listNotification struct {
Type string
ListID uuid.UUID
Name string
Status string
}
type Notification struct {
image *imageNotification
list *listNotification
}
func getImageNotification(image imageNotification) Notification {
return Notification{
image: &image,
}
}
func getListNotification(list listNotification) Notification {
return Notification{
list: &list,
}
}
func (n Notification) MarshalJSON() ([]byte, error) {
if n.image != nil {
return json.Marshal(n.image)
}
if n.list != nil {
return json.Marshal(n.list)
}
return nil, fmt.Errorf("no image or list present")
}
func (n *Notification) UnmarshalJSON(data []byte) error {
return fmt.Errorf("unimplemented")
}
func ListenNewImageEvents(db *sql.DB) {
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
if err != nil {
panic(err)
}
})
defer listener.Close()
imageModel := models.NewImageModel(db)
listModel := models.NewListModel(db)
databaseEventLog := createLogger("Database Events 🤖", os.Stdout)
databaseEventLog.SetLevel(log.DebugLevel)
err := listener.Listen("new_image")
if err != nil {
panic(err)
}
for parameters := range listener.Notify {
imageId := uuid.MustParse(parameters.Extra)
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
ctx := context.Background()
go func() {
image, err := imageModel.GetToProcessWithData(ctx, imageId)
if err != nil {
databaseEventLog.Error("Failed to GetToProcessWithData", "error", err)
return
}
splitWriter := createDbStdoutWriter(db, image.ImageID)
if err := imageModel.StartProcessing(ctx, image.ID); err != nil {
databaseEventLog.Error("Failed to FinishProcessing", "error", err)
return
}
descriptionAgent := agents.NewDescriptionAgent(createLogger("Description 📝", splitWriter), imageModel)
listAgent := agents.NewListAgent(createLogger("Lists 🖋️", splitWriter), listModel)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
descriptionAgent.Describe(createLogger("Description 📓", splitWriter), image.Image.ID, image.Image.ImageName, image.Image.Image)
}()
go func() {
defer wg.Done()
listAgent.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
}()
wg.Wait()
_, err = imageModel.FinishProcessing(ctx, image.ID)
if err != nil {
databaseEventLog.Error("Failed to finish processing", "ImageID", imageId, "error", err)
return
}
databaseEventLog.Debug("Finished processing image", "ImageID", imageId)
}()
}
}
func ListenProcessingImageStatus(db *sql.DB, images models.ImageModel, notifier *Notifier[Notification]) {
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
if err != nil {
panic(err)
}
})
defer listener.Close()
logger := createLogger("Image Status 📊", os.Stdout)
if err := listener.Listen("new_processing_image_status"); err != nil {
panic(err)
}
for data := range listener.Notify {
imageStringUuid := data.Extra[0:36]
status := data.Extra[36:]
imageUuid, err := uuid.Parse(imageStringUuid)
if err != nil {
logger.Error(err)
continue
}
processingImage, err := images.GetToProcess(context.Background(), imageUuid)
if err != nil {
logger.Error("GetToProcess failed", "err", err)
continue
}
logger.Info("Update", "id", imageStringUuid, "status", status)
notification := getImageNotification(imageNotification{
Type: IMAGE_TYPE,
ImageID: processingImage.ImageID,
ImageName: processingImage.Image.ImageName,
Status: status,
})
if err := notifier.SendAndCreate(processingImage.UserID.String(), notification); err != nil {
logger.Error(err)
}
}
}
func ListenNewStackEvents(db *sql.DB) {
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
if err != nil {
panic(err)
}
})
defer listener.Close()
stackModel := models.NewListModel(db)
newStacksLogger := createLogger("New Stacks 🤖", os.Stdout)
newStacksLogger.SetLevel(log.DebugLevel)
err := listener.Listen("new_stack")
if err != nil {
panic(err)
}
for parameters := range listener.Notify {
stackID := uuid.MustParse(parameters.Extra)
newStacksLogger.Debug("Starting processing stack", "StackID", stackID)
ctx := context.Background()
go func() {
stack, err := stackModel.GetProcessing(ctx, stackID)
if err != nil {
newStacksLogger.Error("failed to get processing", "error", err)
return
}
if err := stackModel.StartProcessing(ctx, stackID); err != nil {
newStacksLogger.Error("failed to start processing", "error", err)
return
}
listAgent := agents.NewCreateListAgent(newStacksLogger, stackModel)
userListRequest := fmt.Sprintf("title=%s,fields=%s", stack.Title, stack.Fields)
err = listAgent.CreateList(newStacksLogger, stack.UserID, userListRequest)
if err != nil {
newStacksLogger.Error("running agent", "err", err)
return
}
if err := stackModel.EndProcessing(ctx, stackID); err != nil {
newStacksLogger.Error("failed to finish processing", "error", err)
return
}
newStacksLogger.Debug("Finished processing stack", "StackID", stackID)
}()
}
}
func ListenProcessingStackStatus(db *sql.DB, stacks models.ListModel, notifier *Notifier[Notification]) {
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
if err != nil {
panic(err)
}
})
defer listener.Close()
logger := createLogger("Stack Status 📊", os.Stdout)
if err := listener.Listen("new_processing_stack_status"); err != nil {
panic(err)
}
for data := range listener.Notify {
stackStringUUID := data.Extra[0:36]
status := data.Extra[36:]
stackUUID, err := uuid.Parse(stackStringUUID)
if err != nil {
logger.Error(err)
continue
}
processingStack, err := stacks.GetToProcess(context.Background(), stackUUID)
if err != nil {
logger.Error("GetToProcess failed", "err", err)
continue
}
logger.Info("Update", "id", stackStringUUID, "status", status)
notification := getListNotification(listNotification{
Type: LIST_TYPE,
Name: processingStack.Title,
ListID: stackUUID,
Status: status,
})
if err := notifier.SendAndCreate(processingStack.UserID.String(), notification); err != nil {
logger.Error(err)
}
}
}
/* /*
* TODO: We have channels open every a user sends an image. * TODO: We have channels open every a user sends an image.
* We never close these channels. * We never close these channels.
* *
* What is a reasonable default? Close the channel after 1 minute of inactivity? * What is a reasonable default? Close the channel after 1 minute of inactivity?
*/ */
func CreateEventsHandler(notifier *Notifier[Notification]) http.HandlerFunc { func CreateEventsHandler(notifier *notifications.Notifier[notifications.Notification]) http.HandlerFunc {
counter := 0 counter := 0
userSplitters := make(map[string]*ChannelSplitter[Notification]) userSplitters := make(map[string]*notifications.ChannelSplitter[notifications.Notification])
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
_userId := r.Context().Value(middleware.USER_ID).(uuid.UUID) _userId := r.Context().Value(middleware.USER_ID).(uuid.UUID)
@ -324,7 +43,7 @@ func CreateEventsHandler(notifier *Notifier[Notification]) http.HandlerFunc {
userNotifications := notifier.Listeners[userId] userNotifications := notifier.Listeners[userId]
if _, exists := userSplitters[userId]; !exists { if _, exists := userSplitters[userId]; !exists {
splitter := NewChannelSplitter(userNotifications) splitter := notifications.NewChannelSplitter(userNotifications)
userSplitters[userId] = &splitter userSplitters[userId] = &splitter
splitter.Listen() splitter.Listen()

View File

@ -10,38 +10,61 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/limits"
"screenmark/screenmark/middleware" "screenmark/screenmark/middleware"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"screenmark/screenmark/processor"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid"
) )
type ImageHandler struct { type ImageHandler struct {
logger *log.Logger logger *log.Logger
imageModel models.ImageModel imageModel models.ImageModel
userModel models.UserModel userModel models.UserModel
limitsManager limits.LimitsManagerMethods
jwtManager *middleware.JwtManager
processor *processor.Processor[model.Image]
} }
type ImagesReturn struct { type ImagesReturn struct {
UserImages []models.UserImageWithImage `json:"userImages"` UserImages []models.UserImageWithImage
ProcessingImages []models.UserProcessingImage `json:"processingImages"` Stacks []models.ListsWithImages
Lists []models.ListsWithImages `json:"lists"`
} }
func (h *ImageHandler) serveImage(w http.ResponseWriter, r *http.Request) { func (h *ImageHandler) serveImage(w http.ResponseWriter, r *http.Request) {
imageId, err := middleware.GetPathParamID(h.logger, "id", w, r) imageID, err := middleware.GetPathParamID(h.logger, "id", w, r)
if err != nil { if err != nil {
return return
} }
image, err := h.imageModel.Get(r.Context(), imageId) ctx := r.Context()
userID, err := middleware.GetUserID(ctx, h.logger, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
image, exists, err := h.imageModel.Get(r.Context(), imageID)
if err != nil { if err != nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Could not get image") fmt.Fprintf(w, "Could not get image")
return return
} }
// Do not leak that this ID exists.
if !exists || image.UserID != userID {
w.WriteHeader(http.StatusNotFound)
return
}
// TODO: this could be part of the db table // TODO: this could be part of the db table
extension := filepath.Ext(image.ImageName) extension := filepath.Ext(image.ImageName)
if len(extension) == 0 { if len(extension) == 0 {
@ -66,22 +89,15 @@ func (h *ImageHandler) listImages(w http.ResponseWriter, r *http.Request) {
return return
} }
processingImages, err := h.imageModel.GetProcessing(r.Context(), userId) stacksWithImages, err := h.userModel.ListWithImages(r.Context(), userId)
if err != nil {
middleware.WriteErrorInternal(h.logger, "could not get processing images", w)
return
}
listsWithImages, err := h.userModel.ListWithImages(r.Context(), userId)
if err != nil { if err != nil {
middleware.WriteErrorInternal(h.logger, "could not get lists with images", w) middleware.WriteErrorInternal(h.logger, "could not get lists with images", w)
return return
} }
imagesReturn := ImagesReturn{ imagesReturn := ImagesReturn{
UserImages: images, UserImages: images,
ProcessingImages: processingImages, Stacks: stacksWithImages,
Lists: listsWithImages,
} }
middleware.WriteJsonOrError(h.logger, imagesReturn, w) middleware.WriteJsonOrError(h.logger, imagesReturn, w)
@ -94,7 +110,7 @@ func (h *ImageHandler) uploadImage(w http.ResponseWriter, r *http.Request) {
return return
} }
userId, err := middleware.GetUserID(r.Context(), h.logger, w) userID, err := middleware.GetUserID(r.Context(), h.logger, w)
if err != nil { if err != nil {
return return
} }
@ -127,44 +143,90 @@ func (h *ImageHandler) uploadImage(w http.ResponseWriter, r *http.Request) {
middleware.WriteErrorBadRequest(h.logger, "unsupported content type, need octet-stream or base64", w) middleware.WriteErrorBadRequest(h.logger, "unsupported content type, need octet-stream or base64", w)
return return
} }
ctx := r.Context()
userImage, err := h.imageModel.Process(r.Context(), userId, model.Image{ newImage, err := h.imageModel.Save(ctx, imageName, image, userID)
Image: image,
ImageName: imageName,
})
if err != nil { if err != nil {
middleware.WriteErrorInternal(h.logger, "could not save image to DB", w) middleware.WriteErrorInternal(h.logger, "could not save image to DB: "+err.Error(), w)
return return
} }
middleware.WriteJsonOrError(h.logger, userImage, w) h.logger.Info("About to add image")
h.processor.Add(newImage)
// We nullify the image's data, so we're not transferring all that
// data back to the frontend.
newImage.Image = nil
middleware.WriteJsonOrError(h.logger, newImage, w)
}
func (h *ImageHandler) deleteImage(w http.ResponseWriter, r *http.Request) {
stringImageID := chi.URLParam(r, "image-id")
imageID, err := uuid.Parse(stringImageID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
ctx := r.Context()
userID, err := middleware.GetUserID(ctx, h.logger, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
exists, err := h.imageModel.Delete(ctx, imageID, userID)
if err != nil {
h.logger.Warn("cannot delete image", "error", err)
w.WriteHeader(http.StatusBadRequest)
return
}
// Don't leak if the image exists or not
if !exists {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
} }
func (h *ImageHandler) CreateRoutes(r chi.Router) { func (h *ImageHandler) CreateRoutes(r chi.Router) {
h.logger.Info("Mounting image router") h.logger.Info("Mounting image router")
// Public route for serving images (not protected)
r.Get("/{id}", h.serveImage)
// Protected routes
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.ProtectedRoute) r.Use(middleware.ProtectedRouteURL(h.jwtManager))
r.Get("/{id}", h.serveImage)
})
r.Group(func(r chi.Router) {
r.Use(middleware.ProtectedRoute(h.jwtManager))
r.Use(middleware.SetJson) r.Use(middleware.SetJson)
r.Get("/", h.listImages) r.Get("/", h.listImages)
r.Post("/{name}", h.uploadImage) r.Post("/{name}", middleware.WithLimit(h.logger, h.limitsManager.HasReachedImageLimit, h.uploadImage))
r.Delete("/{image-id}", h.deleteImage)
}) })
} }
func CreateImageHandler(db *sql.DB) ImageHandler { func CreateImageHandler(
db *sql.DB,
limitsManager limits.LimitsManagerMethods,
jwtManager *middleware.JwtManager,
processor *processor.Processor[model.Image],
) ImageHandler {
imageModel := models.NewImageModel(db) imageModel := models.NewImageModel(db)
userModel := models.NewUserModel(db) userModel := models.NewUserModel(db)
logger := log.New(os.Stdout).WithPrefix("Images") logger := log.New(os.Stdout).WithPrefix("Images")
return ImageHandler{ return ImageHandler{
logger: logger, logger: logger,
imageModel: imageModel, imageModel: imageModel,
userModel: userModel, userModel: userModel,
limitsManager: limitsManager,
jwtManager: jwtManager,
processor: processor,
} }
} }

View File

@ -57,11 +57,12 @@ type TestUser struct {
} }
type TestContext struct { type TestContext struct {
db *sql.DB db *sql.DB
router chi.Router router chi.Router
server *httptest.Server server *httptest.Server
users []TestUser users []TestUser
cleanup func() cleanup func()
jwtManager *middleware.JwtManager
} }
func setupTestDatabase() (*sql.DB, func(), error) { func setupTestDatabase() (*sql.DB, func(), error) {
@ -179,12 +180,18 @@ func setupTestContext(t *testing.T) *TestContext {
t.Fatalf("Failed to setup test database: %v", err) t.Fatalf("Failed to setup test database: %v", err)
} }
router := setupRouter(db) jwtManager := middleware.NewJwtManager([]byte("test-jwt-secret"))
router, err := setupRouter(db, jwtManager)
if err != nil {
panic(err)
}
server := httptest.NewServer(router) server := httptest.NewServer(router)
tc.db = db tc.db = db
tc.router = router tc.router = router
tc.server = server tc.server = server
tc.jwtManager = jwtManager
tc.cleanup = func() { tc.cleanup = func() {
server.Close() server.Close()
cleanup() cleanup()
@ -202,7 +209,7 @@ func (tc *TestContext) createTestUser(email string) TestUser {
} }
// Create access token for the user // Create access token for the user
accessToken := middleware.CreateAccessToken(userID) accessToken := tc.jwtManager.CreateAccessToken(userID)
user := TestUser{ user := TestUser{
ID: userID, ID: userID,

61
backend/limits/limits.go Normal file
View File

@ -0,0 +1,61 @@
package limits
import (
"database/sql"
. "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
)
const (
LISTS_LIMIT = 10
IMAGE_LIMIT = 10
)
type LimitsManager struct {
dbPool *sql.DB
}
type LimitsManagerMethods interface {
HasReachedStackLimit(userID uuid.UUID) (bool, error)
HasReachedImageLimit(userID uuid.UUID) (bool, error)
}
type listCount struct {
ListCount int `alias:"list_count"`
}
func (m *LimitsManager) HasReachedStackLimit(userID uuid.UUID) (bool, error) {
getStacks := Stacks.
SELECT(COUNT(Stacks.UserID).AS("listCount.ListCount")).
WHERE(Stacks.UserID.EQ(UUID(userID)))
var count listCount
err := getStacks.Query(m.dbPool, &count)
return count.ListCount >= LISTS_LIMIT, err
}
type imageCount struct {
ImageCount int `alias:"image_count"`
}
func (m *LimitsManager) HasReachedImageLimit(userID uuid.UUID) (bool, error) {
getStacks := Image.
SELECT(COUNT(Image.UserID).AS("imageCount.ImageCount")).
WHERE(Image.UserID.EQ(UUID(userID)))
var count imageCount
err := getStacks.Query(m.dbPool, &count)
return count.ImageCount >= IMAGE_LIMIT, err
}
func CreateLimitsManager(db *sql.DB) *LimitsManager {
return &LimitsManager{
db,
}
}

View File

@ -1,21 +1,11 @@
package main package main
import ( import (
"context"
"database/sql" "database/sql"
"fmt"
"io" "io"
"net/http"
"os" "os"
"time" "time"
"screenmark/screenmark/.gen/haystack/haystack/model"
. "screenmark/screenmark/.gen/haystack/haystack/table"
"github.com/go-chi/chi/v5"
. "github.com/go-jet/jet/v2/postgres"
"github.com/robert-nix/ansihtml"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/muesli/termenv" "github.com/muesli/termenv"
@ -31,12 +21,6 @@ func (w *DatabaseWriter) Write(p []byte) (n int, err error) {
return 0, nil return 0, nil
} }
insertLogStmt := Logs.
INSERT(Logs.Log, Logs.ImageID).
VALUES(string(p), w.imageId)
_, err = insertLogStmt.Exec(w.dbPool)
if err != nil { if err != nil {
return 0, err return 0, err
} else { } else {
@ -44,85 +28,6 @@ func (w *DatabaseWriter) Write(p []byte) (n int, err error) {
} }
} }
func (w *DatabaseWriter) GetImageLogs(ctx context.Context, imageId uuid.UUID) ([]string, error) {
getImageLogsStmt := Logs.
SELECT(Logs.Log).
WHERE(Logs.ImageID.EQ(UUID(imageId)))
logs := []model.Logs{}
err := getImageLogsStmt.QueryContext(ctx, w.dbPool, &logs)
if err != nil {
return []string{}, err
}
stringLogs := make([]string, len(logs))
for i, log := range logs {
stringLogs[i] = log.Log
}
return stringLogs, nil
}
func createLogHandler(logWriter *DatabaseWriter) func(r chi.Router) {
return func(r chi.Router) {
r.Get("/{imageId}", func(w http.ResponseWriter, r *http.Request) {
stringImageId := r.PathValue("imageId")
imageId, err := uuid.Parse(stringImageId)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
return
}
logs, err := logWriter.GetImageLogs(r.Context(), imageId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
html := ""
imageTag := fmt.Sprintf(`<image src="https://haystack.johncosta.tech/image/%s">`, stringImageId)
for _, log := range logs {
html += fmt.Sprintf("<div>%s</div>", string(ansihtml.ConvertToHTML([]byte(log)))+"\n")
}
css := `
<style>
body {
background-color: #1e1e1e;
color: #f0f0f0;
font-family: sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
}
/* Basic styling for code blocks often used for logs */
pre {
background-color: #2a2a2a;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
border: 1px solid #444;
}
code {
font-family: monospace;
}
</style>
`
fullHtml := fmt.Sprintf("<html><head><title>Logs</title>%s</head><body>%s%s</body></html>", css, imageTag, html)
w.Header().Add("Content-Type", "text/html")
w.Write([]byte(fullHtml))
w.WriteHeader(http.StatusOK)
})
}
}
func newDatabaseWriter(dbPool *sql.DB, imageId uuid.UUID) *DatabaseWriter { func newDatabaseWriter(dbPool *sql.DB, imageId uuid.UUID) *DatabaseWriter {
return &DatabaseWriter{ return &DatabaseWriter{
dbPool: dbPool, dbPool: dbPool,

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"screenmark/screenmark/middleware"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@ -15,12 +16,22 @@ func main() {
panic(err) panic(err)
} }
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
panic("JWT_SECRET environment variable not set")
}
jwtManager := middleware.NewJwtManager([]byte(jwtSecret))
db, err := models.InitDatabase() db, err := models.InitDatabase()
if err != nil { if err != nil {
panic(err) panic(err)
} }
router := setupRouter(db) router, err := setupRouter(db, jwtManager)
if err != nil {
panic(err)
}
port, exists := os.LookupEnv("PORT") port, exists := os.LookupEnv("PORT")
if !exists { if !exists {

View File

@ -18,29 +18,33 @@ const (
type JwtClaims struct { type JwtClaims struct {
UserID string UserID string
Type JwtType Type JwtType
Expire time.Time Expiry time.Time
} }
// obviously this is very not secure. TODO: extract to env type JwtManager struct {
var JWT_SECRET = []byte("very secret") secret []byte
}
func createToken(claims JwtClaims) *jwt.Token { func NewJwtManager(secret []byte) *JwtManager {
return &JwtManager{secret: secret}
}
func (jm *JwtManager) createToken(claims JwtClaims) *jwt.Token {
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"UserID": claims.UserID, "UserID": claims.UserID,
"Type": claims.Type, "Type": claims.Type,
"Expire": claims.Expire, "exp": claims.Expiry.Unix(),
}) })
} }
func CreateRefreshToken(userId uuid.UUID) string { func (jm *JwtManager) CreateRefreshToken(userId uuid.UUID) string {
token := createToken(JwtClaims{ token := jm.createToken(JwtClaims{
UserID: userId.String(), UserID: userId.String(),
Type: Refresh, Type: Refresh,
Expire: time.Now().Add(time.Hour * 24 * 7), Expiry: time.Now().Add(time.Hour * 24 * 30),
}) })
// TODO: bruh what is this tokenString, err := token.SignedString(jm.secret)
tokenString, err := token.SignedString(JWT_SECRET)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -48,15 +52,14 @@ func CreateRefreshToken(userId uuid.UUID) string {
return tokenString return tokenString
} }
func CreateAccessToken(userId uuid.UUID) string { func (jm *JwtManager) CreateAccessToken(userId uuid.UUID) string {
token := createToken(JwtClaims{ token := jm.createToken(JwtClaims{
UserID: userId.String(), UserID: userId.String(),
Type: Access, Type: Access,
Expire: time.Now().Add(time.Hour), Expiry: time.Now().Add(time.Minute),
}) })
// TODO: bruh what is this tokenString, err := token.SignedString(jm.secret)
tokenString, err := token.SignedString(JWT_SECRET)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -66,18 +69,20 @@ func CreateAccessToken(userId uuid.UUID) string {
var NotValidToken = errors.New("Not a valid token") var NotValidToken = errors.New("Not a valid token")
func GetUserIdFromAccess(accessToken string) (uuid.UUID, error) { func (jm *JwtManager) GetUserIdFromAccess(accessToken string) (uuid.UUID, error) {
token, err := jwt.Parse(accessToken, func(token *jwt.Token) (any, error) { token, err := jwt.Parse(accessToken, func(token *jwt.Token) (any, error) {
return JWT_SECRET, nil return jm.secret, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil { if err != nil {
return uuid.Nil, err return uuid.Nil, err
} }
// Blah blah, check expiry and stuff // Check if token is valid (JWT library validates exp claim automatically)
if !token.Valid {
return uuid.Nil, NotValidToken
}
// this function is stupid
if claims, ok := token.Claims.(jwt.MapClaims); ok { if claims, ok := token.Claims.(jwt.MapClaims); ok {
tokenType, ok := claims["Type"] tokenType, ok := claims["Type"]
if !ok || tokenType.(string) != "access" { if !ok || tokenType.(string) != "access" {
@ -94,3 +99,38 @@ func GetUserIdFromAccess(accessToken string) (uuid.UUID, error) {
return uuid.Nil, NotValidToken return uuid.Nil, NotValidToken
} }
} }
func (jm *JwtManager) GetUserIdFromRefresh(refreshToken string) (uuid.UUID, error) {
token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (any, error) {
return jm.secret, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
return uuid.Nil, err
}
// Check if token is valid (JWT library validates exp claim automatically)
if !token.Valid {
return uuid.Nil, NotValidToken
}
if claims, ok := token.Claims.(jwt.MapClaims); ok {
tokenType, ok := claims["Type"]
if !ok || tokenType.(string) != "refresh" {
return uuid.Nil, NotValidToken
}
userId, err := uuid.Parse(claims["UserID"].(string))
if err != nil {
return uuid.Nil, NotValidToken
}
return userId, nil
} else {
return uuid.Nil, NotValidToken
}
}
func GetUserIdFromAccess(jm *JwtManager, accessToken string) (uuid.UUID, error) {
return jm.GetUserIdFromAccess(accessToken)
}

View File

@ -0,0 +1,36 @@
package middleware
import (
"net/http"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
func WithLimit(logger *log.Logger, getLimit func(userID uuid.UUID) (bool, error), next func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, err := GetUserID(ctx, logger, w)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
hasReachedLimit, err := getLimit(userID)
if err != nil {
logger.Error("failed to image limit", "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
logger.Info("Limits", "hasReachedLimit", hasReachedLimit)
if hasReachedLimit {
w.WriteHeader(http.StatusTooManyRequests)
return
}
next(w, r)
}
}

View File

@ -50,47 +50,71 @@ func GetUserID(ctx context.Context, logger *log.Logger, w http.ResponseWriter) (
return userIdUuid, nil return userIdUuid, nil
} }
func ProtectedRoute(next http.Handler) http.Handler { func ProtectedRouteURL(jm *JwtManager) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(next http.Handler) http.Handler {
token := r.Header.Get("Authorization") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(token) < len("Bearer ") { token := r.URL.Query().Get("token")
w.WriteHeader(http.StatusUnauthorized)
return
}
userId, err := GetUserIdFromAccess(token[len("Bearer "):]) userId, err := GetUserIdFromAccess(jm, token)
if err != nil { if err != nil {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
contextWithUserId := context.WithValue(r.Context(), USER_ID, userId) contextWithUserId := context.WithValue(r.Context(), USER_ID, userId)
newR := r.WithContext(contextWithUserId) newR := r.WithContext(contextWithUserId)
next.ServeHTTP(w, newR) next.ServeHTTP(w, newR)
}) })
}
} }
func GetUserIdFromUrl(next http.Handler) http.Handler { func ProtectedRoute(jm *JwtManager) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(next http.Handler) http.Handler {
token := r.URL.Query().Get("token") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if len(token) == 0 { if len(token) < len("Bearer ") {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
userId, err := GetUserIdFromAccess(token) userId, err := GetUserIdFromAccess(jm, token[len("Bearer "):])
if err != nil { if err != nil {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
contextWithUserId := context.WithValue(r.Context(), USER_ID, userId) contextWithUserId := context.WithValue(r.Context(), USER_ID, userId)
newR := r.WithContext(contextWithUserId) newR := r.WithContext(contextWithUserId)
next.ServeHTTP(w, newR) next.ServeHTTP(w, newR)
}) })
}
}
func GetUserIdFromUrl(jm *JwtManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if len(token) == 0 {
w.WriteHeader(http.StatusUnauthorized)
return
}
userId, err := GetUserIdFromAccess(jm, token)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
contextWithUserId := context.WithValue(r.Context(), USER_ID, userId)
newR := r.WithContext(contextWithUserId)
next.ServeHTTP(w, newR)
})
}
} }
func GetPathParamID(logger *log.Logger, param string, w http.ResponseWriter, r *http.Request) (uuid.UUID, error) { func GetPathParamID(logger *log.Logger, param string, w http.ResponseWriter, r *http.Request) (uuid.UUID, error) {

View File

@ -35,6 +35,7 @@ func writeError(logger *log.Logger, error string, w http.ResponseWriter, code in
return return
} }
logger.Error("writing error", "error", error)
w.Write(jsonObject) w.Write(jsonObject)
w.WriteHeader(code) w.WriteHeader(code)
} }

View File

@ -4,11 +4,11 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"screenmark/screenmark/.gen/haystack/haystack/enum"
"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/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -17,204 +17,72 @@ type ImageModel struct {
dbPool *sql.DB dbPool *sql.DB
} }
type ImageData struct { func (m ImageModel) Save(ctx context.Context, name string, image []byte, userID uuid.UUID) (model.Image, error) {
model.UserImages saveImageStmt := Image.INSERT(Image.ImageName, Image.Image, Image.Description, Image.UserID).
VALUES(name, image, "", userID).
RETURNING(Image.AllColumns)
Image model.Image newImage := model.Image{}
err := saveImageStmt.QueryContext(ctx, m.dbPool, &newImage)
return newImage, err
} }
type ProcessingImageData struct { func (m ImageModel) Get(ctx context.Context, imageID uuid.UUID) (model.Image, bool, error) {
model.UserImagesToProcess getImageStmt := Image.SELECT(Image.AllColumns).WHERE(Image.ID.EQ(UUID(imageID)))
Image model.Image
}
type UserProcessingImage struct {
model.UserImagesToProcess
Image model.Image
}
func (m ImageModel) Process(ctx context.Context, userId uuid.UUID, image model.Image) (model.UserImagesToProcess, error) {
tx, err := m.dbPool.BeginTx(ctx, nil)
if err != nil {
return model.UserImagesToProcess{}, fmt.Errorf("Failed to begin transaction: %w", err)
}
insertImageStmt := Image.
INSERT(Image.ImageName, Image.Image, Image.Description).
VALUES(image.ImageName, image.Image, image.Description).
RETURNING(Image.ID)
insertedImage := model.Image{}
err = insertImageStmt.QueryContext(ctx, tx, &insertedImage)
if err != nil {
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert/query new image. SQL %s: %w", insertImageStmt.DebugSql(), err)
}
stmt := UserImagesToProcess.
INSERT(UserImagesToProcess.UserID, UserImagesToProcess.ImageID).
VALUES(userId, insertedImage.ID).
RETURNING(UserImagesToProcess.AllColumns)
userImage := model.UserImagesToProcess{}
err = stmt.QueryContext(ctx, tx, &userImage)
if err != nil {
return model.UserImagesToProcess{}, fmt.Errorf("Could not insert user_image: %w", err)
}
err = tx.Commit()
return userImage, err
}
func (m ImageModel) GetToProcess(ctx context.Context, imageId uuid.UUID) (UserProcessingImage, error) {
getToProcessStmt := SELECT(UserImagesToProcess.AllColumns, Image.ID, Image.ImageName).
FROM(
UserImagesToProcess.INNER_JOIN(
Image, Image.ID.EQ(UserImagesToProcess.ImageID),
),
).
WHERE(UserImagesToProcess.ID.EQ(UUID(imageId)))
images := []UserProcessingImage{}
err := getToProcessStmt.QueryContext(ctx, m.dbPool, &images)
if len(images) != 1 {
return UserProcessingImage{}, fmt.Errorf("Expected 1, got %d\n", len(images))
}
return images[0], err
}
func (m ImageModel) GetToProcessWithData(ctx context.Context, imageId uuid.UUID) (ProcessingImageData, error) {
stmt := SELECT(UserImagesToProcess.AllColumns, Image.AllColumns).
FROM(
UserImagesToProcess.INNER_JOIN(
Image, Image.ID.EQ(UserImagesToProcess.ImageID),
),
).WHERE(UserImagesToProcess.ID.EQ(UUID(imageId)))
images := []ProcessingImageData{}
err := stmt.QueryContext(ctx, m.dbPool, &images)
if len(images) != 1 {
return ProcessingImageData{}, fmt.Errorf("Expected 1, got %d\n", len(images))
}
return images[0], err
}
func (m ImageModel) FinishProcessing(ctx context.Context, imageId uuid.UUID) (model.UserImages, error) {
imageToProcess, err := m.GetToProcess(ctx, imageId)
if err != nil {
return model.UserImages{}, err
}
tx, err := m.dbPool.Begin()
if err != nil {
return model.UserImages{}, err
}
insertImageStmt := UserImages.
INSERT(UserImages.UserID, UserImages.ImageID).
VALUES(imageToProcess.UserID, imageToProcess.ImageID).
RETURNING(UserImages.ID, UserImages.UserID, UserImages.ImageID)
userImage := model.UserImages{}
err = insertImageStmt.QueryContext(ctx, tx, &userImage)
if err != nil {
return model.UserImages{}, err
}
// Hacky. Update the status before removing so we can get our regular triggers
// to work.
updateStatusStmt := UserImagesToProcess.
UPDATE(UserImagesToProcess.Status).
SET(model.Progress_Complete).
WHERE(UserImagesToProcess.ID.EQ(UUID(imageToProcess.ID)))
_, err = updateStatusStmt.ExecContext(ctx, tx)
if err != nil {
return model.UserImages{}, err
}
// TODO:
// We cannot delete the image to process because our events rely on it.
// This indicates our DB structure with the two tables might need some adjusting.
// Or re-doing all together perhaps.
// (switching to a one table (user_images) could work)
// But for now, we can just not delete the images to process and set them to complete
// removeProcessingStmt := UserImagesToProcess.
// DELETE().
// WHERE(UserImagesToProcess.ID.EQ(UUID(imageToProcess.ID)))
//
// _, err = removeProcessingStmt.ExecContext(ctx, tx)
// if err != nil {
// return model.UserImages{}, err
// }
err = tx.Commit()
return userImage, err
}
func (m ImageModel) StartProcessing(ctx context.Context, processingImageId uuid.UUID) error {
startProcessingStmt := UserImagesToProcess.
UPDATE(UserImagesToProcess.Status).
SET(model.Progress_InProgress).
WHERE(UserImagesToProcess.ID.EQ(UUID(processingImageId)))
_, err := startProcessingStmt.ExecContext(ctx, m.dbPool)
return err
}
func (m ImageModel) Get(ctx context.Context, imageId uuid.UUID) (model.Image, error) {
getImageStmt := Image.SELECT(Image.AllColumns).
WHERE(Image.ID.EQ(UUID(imageId)))
image := model.Image{} image := model.Image{}
err := getImageStmt.QueryContext(ctx, m.dbPool, &image) err := getImageStmt.QueryContext(ctx, m.dbPool, &image)
return image, err return image, err != qrm.ErrNoRows, err
} }
func (m ImageModel) GetProcessing(ctx context.Context, userId uuid.UUID) ([]UserProcessingImage, error) { func (m ImageModel) UpdateDescription(ctx context.Context, imageID uuid.UUID, description string) error {
getProcessingStmt := SELECT(UserImagesToProcess.AllColumns, Image.ID, Image.ImageName). updateImageDescriptionStmt := Image.UPDATE(Image.Description).
FROM( SET(Image.Description.SET(String(description))).
UserImagesToProcess.INNER_JOIN( WHERE(Image.ID.EQ(UUID(imageID)))
Image, Image.ID.EQ(UserImagesToProcess.ImageID),
),
).WHERE(
UserImagesToProcess.UserID.EQ(UUID(userId)).
AND(UserImagesToProcess.Status.NOT_EQ(enum.Progress.Complete)),
)
images := []UserProcessingImage{} _, err := updateImageDescriptionStmt.ExecContext(ctx, m.dbPool)
err := getProcessingStmt.QueryContext(ctx, m.dbPool, &images)
return images, err
}
func (m ImageModel) AddDescription(ctx context.Context, imageId uuid.UUID, description string) error {
updateImageStmt := Image.UPDATE(Image.Description).
SET(description).
WHERE(Image.ID.EQ(UUID(imageId)))
_, err := updateImageStmt.ExecContext(ctx, m.dbPool)
return err return err
} }
func (m ImageModel) IsUserAuthorized(ctx context.Context, imageId uuid.UUID, userId uuid.UUID) bool { func (m ImageModel) UpdateProcess(ctx context.Context, imageID uuid.UUID, process model.Progress) error {
getImageUserId := UserImages.SELECT(UserImages.UserID).WHERE(UserImages.ImageID.EQ(UUID(imageId))) updateImageDescriptionStmt := Image.UPDATE(Image.Status).
SET(process).
WHERE(Image.ID.EQ(UUID(imageID)))
userImage := model.UserImages{} _, err := updateImageDescriptionStmt.ExecContext(ctx, m.dbPool)
err := getImageUserId.QueryContext(ctx, m.dbPool, &userImage)
return err != nil && userImage.UserID.String() == userId.String() return err
}
func (m ImageModel) Update(ctx context.Context, image model.Image) (model.Image, error) {
updateImageStmt := Image.UPDATE(Image.MutableColumns.Except(Image.Image)).
MODEL(image).
WHERE(Image.ID.EQ(UUID(image.ID))).
RETURNING(Image.AllColumns.Except(Image.Image))
updatedImage := model.Image{}
err := updateImageStmt.QueryContext(ctx, m.dbPool, &updatedImage)
return updatedImage, err
}
func (m ImageModel) Delete(ctx context.Context, imageID, userID uuid.UUID) (bool, error) {
deleteImageStmt := Image.DELETE().WHERE(Image.ID.EQ(UUID(imageID)).AND(Image.UserID.EQ(UUID(userID))))
r, err := deleteImageStmt.ExecContext(ctx, m.dbPool)
if err != nil {
return false, fmt.Errorf("deleting image: %w", err)
}
rowsAffected, err := r.RowsAffected()
if err != nil {
return false, fmt.Errorf("unreachable: %w", err)
}
return rowsAffected > 0, nil
} }
func NewImageModel(db *sql.DB) ImageModel { func NewImageModel(db *sql.DB) ImageModel {

View File

@ -1,310 +0,0 @@
package models
import (
"context"
"database/sql"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model"
. "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
)
type ListModel struct {
dbPool *sql.DB
}
type ListWithItems struct {
model.Lists
Schema struct {
model.Schemas
SchemaItems []model.SchemaItems
}
}
type ImageWithSchema struct {
model.ImageLists
Items []model.ImageSchemaItems
}
type IDValue struct {
ID string `json:"id"`
Value string `json:"value"`
}
// ========================================
// SELECT for lists
// ========================================
func (m ListModel) List(ctx context.Context, userId uuid.UUID) ([]ListWithItems, error) {
getListsWithItems := SELECT(
Lists.AllColumns,
Schemas.AllColumns,
SchemaItems.AllColumns,
).
FROM(
Lists.
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)).
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)),
).
WHERE(Lists.UserID.EQ(UUID(userId)))
lists := []ListWithItems{}
err := getListsWithItems.QueryContext(ctx, m.dbPool, &lists)
return lists, err
}
func (m ListModel) ListItems(ctx context.Context, listID uuid.UUID) ([]ImageWithSchema, error) {
getListItems := SELECT(
ImageLists.AllColumns,
ImageSchemaItems.AllColumns,
).
FROM(
ImageLists.
INNER_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)),
).
WHERE(ImageLists.ListID.EQ(UUID(listID)))
listItems := make([]ImageWithSchema, 0)
err := getListItems.QueryContext(ctx, m.dbPool, &listItems)
return listItems, err
}
// ========================================
// SELECT for specific items
// ========================================
func (m ListModel) GetProcessing(ctx context.Context, processingListID uuid.UUID) (model.ProcessingLists, error) {
getProcessingListStmt := ProcessingLists.
SELECT(ProcessingLists.AllColumns).
WHERE(ProcessingLists.ID.EQ(UUID(processingListID)))
list := model.ProcessingLists{}
err := getProcessingListStmt.QueryContext(ctx, m.dbPool, &list)
return list, err
}
func (m ListModel) GetToProcess(ctx context.Context, listID uuid.UUID) (model.ProcessingLists, error) {
getToProcessStmt := ProcessingLists.
SELECT(ProcessingLists.AllColumns).
WHERE(ProcessingLists.ID.EQ(UUID(listID)))
stack := []model.ProcessingLists{}
err := getToProcessStmt.QueryContext(ctx, m.dbPool, &stack)
if len(stack) != 1 {
return model.ProcessingLists{}, fmt.Errorf("Expected 1, got %d\n", len(stack))
}
return stack[0], err
}
// ========================================
// UPDATE
// ========================================
func (m ListModel) StartProcessing(ctx context.Context, processingListID uuid.UUID) error {
startProcessingStmt := ProcessingLists.
UPDATE(ProcessingLists.Status).
SET(model.Progress_InProgress).
WHERE(ProcessingLists.ID.EQ(UUID(processingListID)))
_, err := startProcessingStmt.ExecContext(ctx, m.dbPool)
return err
}
func (m ListModel) EndProcessing(ctx context.Context, processingListID uuid.UUID) error {
startProcessingStmt := ProcessingLists.
UPDATE(ProcessingLists.Status).
SET(model.Progress_Complete).
WHERE(ProcessingLists.ID.EQ(UUID(processingListID)))
_, err := startProcessingStmt.ExecContext(ctx, m.dbPool)
return err
}
// ========================================
// INSERT methods
// ========================================
func (m ListModel) Save(ctx context.Context, userId uuid.UUID, name string, description string, schemaItems []model.SchemaItems) (ListWithItems, error) {
tx, err := m.dbPool.BeginTx(ctx, nil)
stmt := Lists.INSERT(Lists.UserID, Lists.Name, Lists.Description).
VALUES(userId, name, description).
RETURNING(Lists.ID, Lists.Name, Lists.Description)
newList := model.Lists{}
err = stmt.QueryContext(ctx, tx, &newList)
if err != nil {
tx.Rollback()
return ListWithItems{}, fmt.Errorf("Could not save new list. %s", err)
}
insertSchemaStmt := Schemas.INSERT(Schemas.ListID).
VALUES(newList.ID).
RETURNING(Schemas.ID)
newSchema := model.Schemas{}
err = insertSchemaStmt.QueryContext(ctx, tx, &newSchema)
if err != nil {
tx.Rollback()
return ListWithItems{}, fmt.Errorf("Could not save new schema. %s", err)
}
// This is very interesting...
for i := range schemaItems {
schemaItems[i].SchemaID = newSchema.ID
}
insertSchemaItemsStmt := SchemaItems.INSERT(SchemaItems.Item, SchemaItems.Value, SchemaItems.Description, SchemaItems.SchemaID).
MODELS(schemaItems)
_, err = insertSchemaItemsStmt.ExecContext(ctx, tx)
if err != nil {
tx.Rollback()
return ListWithItems{}, fmt.Errorf("Could not save schema items. %s", err)
}
err = tx.Commit()
if err != nil {
return ListWithItems{}, fmt.Errorf("Could not commit transaction. %s", err)
}
getListAndItems := SELECT(Lists.AllColumns, Schemas.AllColumns, SchemaItems.AllColumns).
FROM(
Lists.
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)).
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)),
).
WHERE(Lists.ID.EQ(UUID(newList.ID)))
listWithItems := ListWithItems{}
err = getListAndItems.QueryContext(ctx, m.dbPool, &listWithItems)
return listWithItems, err
}
func (m ListModel) SaveInto(ctx context.Context, listId uuid.UUID, imageId uuid.UUID, schemaValues []IDValue) error {
imageSchemaItems := make([]model.ImageSchemaItems, len(schemaValues))
for i, v := range schemaValues {
parsedId, err := uuid.Parse(v.ID)
if err != nil {
return err
}
imageSchemaItems[i].SchemaItemID = parsedId
imageSchemaItems[i].ImageID = imageId
imageSchemaItems[i].Value = &v.Value
}
tx, err := m.dbPool.BeginTx(ctx, nil)
if err != nil {
return err
}
stmt := ImageLists.INSERT(ImageLists.ListID, ImageLists.ImageID).
VALUES(listId, imageId)
_, err = stmt.ExecContext(ctx, tx)
if err != nil {
tx.Rollback()
return fmt.Errorf("Could not insert new list. %s", err)
}
insertSchemaItemsStmt := ImageSchemaItems.
INSERT(ImageSchemaItems.Value, ImageSchemaItems.SchemaItemID, ImageSchemaItems.ImageID).
MODELS(imageSchemaItems)
_, err = insertSchemaItemsStmt.ExecContext(ctx, tx)
if err != nil {
tx.Rollback()
return fmt.Errorf("Could not insert schema items. %s", err)
}
err = tx.Commit()
return err
}
func (m ListModel) SaveProcessing(ctx context.Context, userID uuid.UUID, title string, fields string) error {
insertListToProcess := ProcessingLists.
INSERT(ProcessingLists.UserID, ProcessingLists.Title, ProcessingLists.Fields).
VALUES(userID, title, fields)
_, err := insertListToProcess.ExecContext(ctx, m.dbPool)
return err
}
// ========================================
// DELETE methods
// ========================================
func (m ListModel) Delete(ctx context.Context, listID uuid.UUID, userID uuid.UUID) error {
// First verify the list belongs to the user
checkOwnershipStmt := Lists.
SELECT(Lists.ID).
WHERE(Lists.ID.EQ(UUID(listID)).AND(Lists.UserID.EQ(UUID(userID))))
var existingList model.Lists
err := checkOwnershipStmt.QueryContext(ctx, m.dbPool, &existingList)
if err != nil {
return fmt.Errorf("could not verify list ownership: %w", err)
}
// Start a transaction to ensure all deletions happen atomically
tx, err := m.dbPool.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("could not start transaction: %w", err)
}
defer tx.Rollback()
// Delete in reverse order of dependencies:
// 1. Delete schema items first
deleteSchemaItemsStmt := SchemaItems.DELETE().
WHERE(SchemaItems.SchemaID.IN(
Schemas.SELECT(Schemas.ID).
WHERE(Schemas.ListID.EQ(UUID(listID))),
))
_, err = deleteSchemaItemsStmt.ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("could not delete schema items: %w", err)
}
// 2. Delete schemas
deleteSchemasStmt := Schemas.DELETE().WHERE(Schemas.ListID.EQ(UUID(listID)))
_, err = deleteSchemasStmt.ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("could not delete schemas: %w", err)
}
// 3. Delete the list itself
deleteListStmt := Lists.DELETE().WHERE(Lists.ID.EQ(UUID(listID)))
_, err = deleteListStmt.ExecContext(ctx, tx)
if err != nil {
return fmt.Errorf("could not delete list: %w", err)
}
// Commit the transaction
err = tx.Commit()
if err != nil {
return fmt.Errorf("could not commit transaction: %w", err)
}
return nil
}
func NewListModel(db *sql.DB) ListModel {
return ListModel{dbPool: db}
}

200
backend/models/stacks.go Normal file
View File

@ -0,0 +1,200 @@
package models
import (
"context"
"database/sql"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model"
. "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
)
type StackModel struct {
dbPool *sql.DB
}
type StackWithItems struct {
model.Stacks
SchemaItems []model.SchemaItems
}
type ImageWithSchema struct {
model.ImageStacks
Items []model.ImageSchemaItems
}
type IDValue struct {
ID string `json:"id"`
Value string `json:"value"`
}
// ========================================
// SELECT for lists
// ========================================
func (m StackModel) List(ctx context.Context, userId uuid.UUID) ([]StackWithItems, error) {
getStacksWithItems := SELECT(
Stacks.AllColumns,
SchemaItems.AllColumns,
).
FROM(
Stacks.
INNER_JOIN(SchemaItems, SchemaItems.StackID.EQ(Stacks.ID)),
).
WHERE(Stacks.UserID.EQ(UUID(userId)))
lists := []StackWithItems{}
err := getStacksWithItems.QueryContext(ctx, m.dbPool, &lists)
return lists, err
}
func (m StackModel) ListItems(ctx context.Context, stackID uuid.UUID) ([]ImageWithSchema, error) {
getListItems := SELECT(
ImageStacks.AllColumns,
ImageSchemaItems.AllColumns,
).
FROM(
ImageStacks.
INNER_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageStacks.ImageID)),
).
WHERE(ImageStacks.StackID.EQ(UUID(stackID)))
listItems := make([]ImageWithSchema, 0)
err := getListItems.QueryContext(ctx, m.dbPool, &listItems)
return listItems, err
}
func (m StackModel) Get(ctx context.Context, stackID uuid.UUID) (model.Stacks, error) {
getStackStmt := Stacks.SELECT(Stacks.AllColumns).WHERE(Stacks.ID.EQ(UUID(stackID)))
stack := model.Stacks{}
err := getStackStmt.QueryContext(ctx, m.dbPool, &stack)
return stack, err
}
// ========================================
// INSERT methods
// ========================================
func (m StackModel) Save(ctx context.Context, userID uuid.UUID, name string, description string, status model.Progress) (model.Stacks, error) {
saveListStmt := Stacks.
INSERT(Stacks.UserID, Stacks.Name, Stacks.Description, Stacks.Status).
VALUES(userID, name, description, status).
RETURNING(Stacks.ID, Stacks.UserID, Stacks.Name, Stacks.Description, Stacks.Status, Stacks.CreatedAt)
list := model.Stacks{}
err := saveListStmt.QueryContext(ctx, m.dbPool, &list)
return list, err
}
func (m StackModel) SaveItems(ctx context.Context, items []model.SchemaItems) error {
saveItemsStmt := SchemaItems.INSERT(SchemaItems.MutableColumns).MODELS(items)
_, err := saveItemsStmt.ExecContext(ctx, m.dbPool)
return err
}
func (m StackModel) SaveImage(ctx context.Context, imageID uuid.UUID, stackID uuid.UUID) (model.ImageStacks, error) {
saveImageStmt := ImageStacks.
INSERT(ImageStacks.ImageID, ImageStacks.StackID).
VALUES(imageID, stackID).
RETURNING(ImageStacks.AllColumns)
imageStack := model.ImageStacks{}
err := saveImageStmt.QueryContext(ctx, m.dbPool, &imageStack)
return imageStack, err
}
func (m StackModel) SaveSchemaItems(ctx context.Context, imageID uuid.UUID, items []IDValue) error {
if len(items) == 0 {
return fmt.Errorf("items cannot be empty")
}
saveSchemaItemStmt := ImageSchemaItems.
INSERT(
ImageSchemaItems.ImageID,
ImageSchemaItems.SchemaItemID,
ImageSchemaItems.Value,
)
for _, item := range items {
saveSchemaItemStmt = saveSchemaItemStmt.VALUES(
imageID,
item.ID,
item.Value,
)
}
_, err := saveSchemaItemStmt.ExecContext(ctx, m.dbPool)
return err
}
// ========================================
// UPDATE methods
// ========================================
func (m StackModel) UpdateProcess(ctx context.Context, stackID uuid.UUID, process model.Progress) error {
updateStackProgressStmt := Stacks.UPDATE(Stacks.Status).
SET(process).
WHERE(Stacks.ID.EQ(UUID(stackID)))
_, err := updateStackProgressStmt.ExecContext(ctx, m.dbPool)
return err
}
// ========================================
// DELETE methods
// ========================================
func (m StackModel) DeleteSchemaItem(ctx context.Context, stackID uuid.UUID, schemaItemID uuid.UUID) error {
deleteImageListStmt := SchemaItems.DELETE().
WHERE(
SchemaItems.ID.EQ(UUID(schemaItemID)).
// The StackID check is a sanity check.
// We don't technically need it, but it adds extra protection
// in case we make a mistake later on
AND(SchemaItems.StackID.EQ(UUID(stackID))),
)
_, err := deleteImageListStmt.ExecContext(ctx, m.dbPool)
return err
}
func (m StackModel) DeleteImage(ctx context.Context, stackID uuid.UUID, imageID uuid.UUID) error {
deleteImageListStmt := ImageStacks.DELETE().
WHERE(
ImageStacks.StackID.EQ(UUID(stackID)).
AND(ImageStacks.ImageID.EQ(UUID(imageID))),
)
_, err := deleteImageListStmt.ExecContext(ctx, m.dbPool)
return err
}
func (m StackModel) Delete(ctx context.Context, stackID uuid.UUID, userID uuid.UUID) error {
deleteStackStmt := Stacks.DELETE().WHERE(Stacks.ID.EQ(UUID(stackID)).AND(Stacks.UserID.EQ(UUID(userID))))
_, err := deleteStackStmt.ExecContext(ctx, m.dbPool)
return err
}
func NewStackModel(db *sql.DB) StackModel {
return StackModel{dbPool: db}
}

View File

@ -49,28 +49,20 @@ func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, err
} }
type UserImageWithImage struct { type UserImageWithImage struct {
model.UserImages model.Image
ImageStacks []model.ImageStacks
Image struct {
model.Image
ImageLists []model.ImageLists
}
} }
func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserImageWithImage, error) { func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserImageWithImage, error) {
getUserImagesStmt := SELECT( getUserImagesStmt := SELECT(
UserImages.AllColumns, Image.AllColumns.Except(Image.Image),
Image.ID, ImageStacks.AllColumns,
Image.ImageName,
Image.Description,
ImageLists.AllColumns,
). ).
FROM( FROM(
UserImages. Image.
INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID)). LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(Image.ID)),
INNER_JOIN(ImageLists, ImageLists.ImageID.EQ(UserImages.ImageID)),
). ).
WHERE(UserImages.UserID.EQ(UUID(userId))) WHERE(Image.UserID.EQ(UUID(userId)))
userImages := []UserImageWithImage{} userImages := []UserImageWithImage{}
err := getUserImagesStmt.QueryContext(ctx, m.dbPool, &userImages) err := getUserImagesStmt.QueryContext(ctx, m.dbPool, &userImages)
@ -79,16 +71,12 @@ func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserI
} }
type ListsWithImages struct { type ListsWithImages struct {
model.Lists model.Stacks
Schema struct { SchemaItems []model.SchemaItems
model.Schemas
SchemaItems []model.SchemaItems
}
Images []struct { Images []struct {
model.ImageLists model.ImageStacks
Items []model.ImageSchemaItems Items []model.ImageSchemaItems
} }
@ -96,20 +84,18 @@ type ListsWithImages struct {
func (m UserModel) ListWithImages(ctx context.Context, userId uuid.UUID) ([]ListsWithImages, error) { func (m UserModel) ListWithImages(ctx context.Context, userId uuid.UUID) ([]ListsWithImages, error) {
stmt := SELECT( stmt := SELECT(
Lists.AllColumns, Stacks.AllColumns,
ImageLists.AllColumns, ImageStacks.AllColumns,
Schemas.AllColumns,
SchemaItems.AllColumns, SchemaItems.AllColumns,
ImageSchemaItems.AllColumns, ImageSchemaItems.AllColumns,
). ).
FROM( FROM(
Lists. Stacks.
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)). INNER_JOIN(SchemaItems, SchemaItems.StackID.EQ(Stacks.ID)).
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)). LEFT_JOIN(ImageStacks, ImageStacks.StackID.EQ(Stacks.ID)).
LEFT_JOIN(ImageLists, ImageLists.ListID.EQ(Lists.ID)). LEFT_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageStacks.ID)),
LEFT_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)),
). ).
WHERE(Lists.UserID.EQ(UUID(userId))) WHERE(Stacks.UserID.EQ(UUID(userId)))
lists := []ListsWithImages{} lists := []ListsWithImages{}
err := stmt.QueryContext(ctx, m.dbPool, &lists) err := stmt.QueryContext(ctx, m.dbPool, &lists)

View File

@ -0,0 +1,38 @@
package notifications
type ChannelSplitter[TNotification any] struct {
ch chan TNotification
Listeners map[string]chan TNotification
}
func (s *ChannelSplitter[TNotification]) Listen() {
go func() {
for {
select {
case msg := <-s.ch:
for _, v := range s.Listeners {
v <- msg
}
}
}
}()
}
func (s *ChannelSplitter[TNotification]) Add(id string) chan TNotification {
ch := make(chan TNotification)
s.Listeners[id] = ch
return ch
}
func (s *ChannelSplitter[TNotification]) Remove(id string) {
delete(s.Listeners, id)
}
func NewChannelSplitter[TNotification any](ch chan TNotification) ChannelSplitter[TNotification] {
return ChannelSplitter[TNotification]{
ch: ch,
Listeners: make(map[string]chan TNotification),
}
}

View File

@ -0,0 +1,64 @@
package notifications
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
)
const (
IMAGE_TYPE = "image"
STACK_TYPE = "stack"
)
type ImageNotification struct {
Type string
ImageID uuid.UUID
ImageName string
Status string
}
type StackNotification struct {
Type string
StackID uuid.UUID
Name string
Status string
}
type Notification struct {
image *ImageNotification
stack *StackNotification
}
func GetImageNotification(image ImageNotification) Notification {
return Notification{
image: &image,
}
}
func GetStackNotification(list StackNotification) Notification {
return Notification{
stack: &list,
}
}
func (n Notification) MarshalJSON() ([]byte, error) {
if n.image != nil {
return json.Marshal(n.image)
}
if n.stack != nil {
return json.Marshal(n.stack)
}
return nil, fmt.Errorf("no image or list present")
}
func (n *Notification) UnmarshalJSON(data []byte) error {
return fmt.Errorf("unimplemented")
}

View File

@ -1,4 +1,4 @@
package main package notifications
import ( import (
"errors" "errors"
@ -56,42 +56,3 @@ func NewNotifier[TNotification any](bufferSize int) Notifier[TNotification] {
Listeners: make(map[string]chan TNotification), Listeners: make(map[string]chan TNotification),
} }
} }
// ----------------------------------
type ChannelSplitter[TNotification any] struct {
ch chan TNotification
Listeners map[string]chan TNotification
}
func (s *ChannelSplitter[TNotification]) Listen() {
go func() {
for {
select {
case msg := <-s.ch:
for _, v := range s.Listeners {
v <- msg
}
}
}
}()
}
func (s *ChannelSplitter[TNotification]) Add(id string) chan TNotification {
ch := make(chan TNotification)
s.Listeners[id] = ch
return ch
}
func (s *ChannelSplitter[TNotification]) Remove(id string) {
delete(s.Listeners, id)
}
func NewChannelSplitter[TNotification any](ch chan TNotification) ChannelSplitter[TNotification] {
return ChannelSplitter[TNotification]{
ch: ch,
Listeners: make(map[string]chan TNotification),
}
}

View File

@ -1,4 +1,4 @@
package main package notifications
import ( import (
"testing" "testing"

157
backend/processor/image.go Normal file
View File

@ -0,0 +1,157 @@
package processor
import (
"context"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/limits"
"screenmark/screenmark/models"
"screenmark/screenmark/notifications"
"sync"
"github.com/charmbracelet/log"
)
const IMAGE_PROCESS_AT_A_TIME = 10
type ImageProcessor struct {
imageModel models.ImageModel
logger *log.Logger
descriptionAgent agents.DescriptionAgent
stackAgent client.AgentClient
Processor *Processor[model.Image]
notifier *notifications.Notifier[notifications.Notification]
}
func (p *ImageProcessor) setImageToProcess(ctx context.Context, image model.Image) {
err := p.imageModel.UpdateProcess(ctx, image.ID, model.Progress_InProgress)
if err != nil {
// TODO: what can we actually do here for the errors?
// We can't stop the work for the others
p.logger.Error("failed to update image", "err", err)
// TODO: we can use context here to actually pass some information through
return
}
}
func (p *ImageProcessor) setImageToDone(ctx context.Context, image model.Image) {
err := p.imageModel.UpdateProcess(ctx, image.ID, model.Progress_Complete)
if err != nil {
// TODO: what can we actually do here for the errors?
// We can't stop the work for the others
p.logger.Error("failed to update image", "err", err)
// TODO: we can use context here to actually pass some information through
return
}
}
func (p *ImageProcessor) describe(ctx context.Context, image model.Image) {
descriptionSubLogger := p.logger.With("describe image", image.ID)
err := p.descriptionAgent.Describe(descriptionSubLogger, image.ID, image.ImageName, image.Image)
if err != nil {
// Again, wtf do we do?
// Although i think the agent actually returns an error when it's finished
p.logger.Error("failed to describe image", "err", err)
return
}
}
func (p *ImageProcessor) extractInfo(ctx context.Context, image model.Image) {
err := p.stackAgent.RunAgent(image.UserID, image.ID, image.ImageName, image.Image)
if err != nil {
// Again, wtf do we do?
// Although i think the agent actually returns an error when it's finished
p.logger.Error("failed to process image", "err", err)
return
}
}
func (p *ImageProcessor) processImage(image model.Image) {
p.logger.Info("Processing image", "ID", image.ID)
ctx := context.Background()
p.setImageToProcess(ctx, image)
var wg sync.WaitGroup
wg.Add(2)
imageNotification := notifications.GetImageNotification(notifications.ImageNotification{
Type: notifications.IMAGE_TYPE,
ImageID: image.ID,
ImageName: image.ImageName,
Status: string(model.Progress_InProgress),
})
err := p.notifier.SendAndCreate(image.UserID.String(), imageNotification)
if err != nil {
p.logger.Error("sending in progress notification", "err", err)
return
}
go func() {
p.describe(ctx, image)
wg.Done()
}()
go func() {
p.extractInfo(ctx, image)
wg.Done()
}()
wg.Wait()
p.setImageToDone(ctx, image)
// TODO: there is some repeated code here. The ergonomicts of the notifications,
// isn't the best.
imageNotification = notifications.GetImageNotification(notifications.ImageNotification{
Type: notifications.IMAGE_TYPE,
ImageID: image.ID,
ImageName: image.ImageName,
Status: string(model.Progress_Complete),
})
err = p.notifier.SendAndCreate(image.UserID.String(), imageNotification)
if err != nil {
p.logger.Error("sending done notification", "err", err)
return
}
}
func NewImageProcessor(
logger *log.Logger,
imageModel models.ImageModel,
listModel models.StackModel,
limitsManager limits.LimitsManagerMethods,
notifier *notifications.Notifier[notifications.Notification],
) (ImageProcessor, error) {
if notifier == nil {
return ImageProcessor{}, fmt.Errorf("notifier is nil")
}
descriptionAgent := agents.NewDescriptionAgent(logger, imageModel)
stackAgent := agents.NewStackAgent(logger, listModel, limitsManager)
imageProcessor := ImageProcessor{
imageModel: imageModel,
logger: logger,
descriptionAgent: descriptionAgent,
stackAgent: stackAgent,
notifier: notifier,
}
imageProcessor.Processor = NewProcessor(int(IMAGE_PROCESS_AT_A_TIME), imageProcessor.processImage)
return imageProcessor, nil
}

View File

@ -0,0 +1,23 @@
package processor
type Processor[TMessage any] struct {
queue chan TMessage
process func(message TMessage)
}
func (p *Processor[TMessage]) Work() {
for msg := range p.queue {
p.process(msg)
}
}
func (p *Processor[TMessage]) Add(msg TMessage) {
p.queue <- msg
}
func NewProcessor[TMessage any](bufferSize int, process func(message TMessage)) *Processor[TMessage] {
return &Processor[TMessage]{
queue: make(chan TMessage, bufferSize),
process: process,
}
}

142
backend/processor/stack.go Normal file
View File

@ -0,0 +1,142 @@
package processor
import (
"context"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents"
"screenmark/screenmark/models"
"screenmark/screenmark/notifications"
"sync"
"github.com/charmbracelet/log"
)
const STACK_PROCESS_AT_A_TIME = 10
// TODO:
// This processor contains a lot of shared stuff.
// If we ever want to do more generic stuff with "in-progress" and stuff
// we can extract that into a common thing
//
// However, this will require a pretty big DB shuffle.
type StackProcessor struct {
stackModel models.StackModel
logger *log.Logger
stackAgent agents.CreateListAgent
Processor *Processor[model.Stacks]
notifier *notifications.Notifier[notifications.Notification]
}
func (p *StackProcessor) setStackToProcess(ctx context.Context, stack model.Stacks) {
err := p.stackModel.UpdateProcess(ctx, stack.ID, model.Progress_InProgress)
if err != nil {
// TODO: what can we actually do here for the errors?
// We can't stop the work for the others
p.logger.Error("failed to update stack", "err", err)
// TODO: we can use context here to actually pass some information through
return
}
}
func (p *StackProcessor) setStackToDone(ctx context.Context, stack model.Stacks) {
err := p.stackModel.UpdateProcess(ctx, stack.ID, model.Progress_Complete)
if err != nil {
// TODO: what can we actually do here for the errors?
// We can't stop the work for the others
p.logger.Error("failed to update stack", "err", err)
// TODO: we can use context here to actually pass some information through
return
}
}
func (p *StackProcessor) extractInfo(ctx context.Context, stack model.Stacks) {
err := p.stackAgent.CreateList(p.logger, stack.UserID, stack.ID, stack.Name, stack.Description)
if err != nil {
// Again, wtf do we do?
// Although i think the agent actually returns an error when it's finished
p.logger.Error("failed to process image", "err", err)
return
}
}
func (p *StackProcessor) processImage(stack model.Stacks) {
p.logger.Info("Processing image", "ID", stack.ID)
ctx := context.Background()
p.setStackToProcess(ctx, stack)
var wg sync.WaitGroup
// Future proofing!
wg.Add(1)
stackNotification := notifications.GetStackNotification(notifications.StackNotification{
Type: notifications.STACK_TYPE,
Status: string(model.Progress_InProgress),
StackID: stack.ID,
Name: stack.Name,
})
err := p.notifier.SendAndCreate(stack.UserID.String(), stackNotification)
if err != nil {
p.logger.Error("sending in progress notification", "err", err)
return
}
go func() {
p.extractInfo(ctx, stack)
wg.Done()
}()
wg.Wait()
p.setStackToDone(ctx, stack)
// TODO: there is some repeated code here. The ergonomicts of the notifications,
// isn't the best.
stackNotification = notifications.GetStackNotification(notifications.StackNotification{
Type: notifications.STACK_TYPE,
Status: string(model.Progress_Complete),
StackID: stack.ID,
Name: stack.Name,
})
err = p.notifier.SendAndCreate(stack.UserID.String(), stackNotification)
if err != nil {
p.logger.Error("sending done notification", "err", err)
return
}
}
func NewStackProcessor(
logger *log.Logger,
stackModel models.StackModel,
notifier *notifications.Notifier[notifications.Notification],
) (StackProcessor, error) {
if notifier == nil {
return StackProcessor{}, fmt.Errorf("notifier is nil")
}
stackAgent := agents.NewCreateListAgent(logger, stackModel)
imageProcessor := StackProcessor{
logger: logger,
stackModel: stackModel,
stackAgent: stackAgent,
notifier: notifier,
}
imageProcessor.Processor = NewProcessor(int(IMAGE_PROCESS_AT_A_TIME), imageProcessor.processImage)
return imageProcessor, nil
}

View File

@ -2,11 +2,15 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt"
"os" "os"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/auth" "screenmark/screenmark/auth"
"screenmark/screenmark/images" "screenmark/screenmark/images"
"screenmark/screenmark/limits"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"screenmark/screenmark/notifications"
"screenmark/screenmark/processor"
"screenmark/screenmark/stacks" "screenmark/screenmark/stacks"
ourmiddleware "screenmark/screenmark/middleware" ourmiddleware "screenmark/screenmark/middleware"
@ -23,29 +27,33 @@ func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (cli
return client.ImageInfo, nil return client.ImageInfo, nil
} }
func setupRouter(db *sql.DB) chi.Router { func setupRouter(db *sql.DB, jwtManager *ourmiddleware.JwtManager) (chi.Router, error) {
limitsManager := limits.CreateLimitsManager(db)
imageModel := models.NewImageModel(db) imageModel := models.NewImageModel(db)
stackModel := models.NewListModel(db) stackModel := models.NewStackModel(db)
stackHandler := stacks.CreateStackHandler(db) notifier := notifications.NewNotifier[notifications.Notification](10)
authHandler := auth.CreateAuthHandler(db)
imageHandler := images.CreateImageHandler(db)
notifier := NewNotifier[Notification](10) imageProcessorLogger := createLogger("Image Processor", os.Stdout)
imageProcessor, err := processor.NewImageProcessor(imageProcessorLogger, imageModel, stackModel, limitsManager, &notifier)
// Only start event listeners if not in test environment if err != nil {
if os.Getenv("GO_TEST_ENVIRONMENT") != "true" { return nil, fmt.Errorf("processor: %w", err)
// TODO: should extract these into a notification manager
// And actually make them the same code.
// The events are basically the same.
go ListenNewImageEvents(db)
go ListenProcessingImageStatus(db, imageModel, &notifier)
go ListenNewStackEvents(db)
go ListenProcessingStackStatus(db, stackModel, &notifier)
} }
stackProcessorLog := createLogger("Stack Processor", os.Stdout)
stackProcessor, err := processor.NewStackProcessor(stackProcessorLog, stackModel, &notifier)
if err != nil {
return nil, fmt.Errorf("processor: %w", err)
}
go imageProcessor.Processor.Work()
go stackProcessor.Processor.Work()
stackHandler := stacks.CreateStackHandler(db, limitsManager, jwtManager, stackProcessor.Processor)
authHandler := auth.CreateAuthHandler(db, jwtManager)
imageHandler := images.CreateImageHandler(db, limitsManager, jwtManager, imageProcessor.Processor)
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger)
@ -56,16 +64,10 @@ func setupRouter(db *sql.DB) chi.Router {
r.Route("/images", imageHandler.CreateRoutes) r.Route("/images", imageHandler.CreateRoutes)
r.Route("/notifications", func(r chi.Router) { r.Route("/notifications", func(r chi.Router) {
r.Use(ourmiddleware.GetUserIdFromUrl) r.Use(ourmiddleware.GetUserIdFromUrl(jwtManager))
r.Get("/", CreateEventsHandler(&notifier)) r.Get("/", CreateEventsHandler(&notifier))
}) })
logWriter := DatabaseWriter{ return r, nil
dbPool: db,
}
r.Route("/logs", createLogHandler(&logWriter))
return r
} }

View File

@ -9,72 +9,45 @@ CREATE TYPE haystack.progress AS ENUM('not-started','in-progress', 'complete');
/* -----| Schema tables |----- */ /* -----| Schema tables |----- */
CREATE TABLE haystack.users ( CREATE TABLE haystack.users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL email TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
); );
CREATE TABLE haystack.image ( CREATE TABLE haystack.image (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES haystack.users (id),
image_name TEXT NOT NULL, image_name TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
image BYTEA NOT NULL
);
CREATE TABLE haystack.user_images_to_process (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
status haystack.progress NOT NULL DEFAULT 'not-started', status haystack.progress NOT NULL DEFAULT 'not-started',
image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id),
user_id uuid NOT NULL REFERENCES haystack.users (id)
);
CREATE TABLE haystack.user_images ( image BYTEA NOT NULL,
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),
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
); );
CREATE TABLE haystack.logs ( CREATE TABLE haystack.stacks (
log TEXT NOT NULL,
image_id UUID NOT NULL REFERENCES haystack.image (id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
CREATE TABLE haystack.lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES haystack.users (id), user_id UUID NOT NULL REFERENCES haystack.users (id),
status haystack.progress NOT NULL DEFAULT 'not-started',
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
); );
CREATE TABLE haystack.processing_lists ( CREATE TABLE haystack.image_stacks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES haystack.users (id),
title TEXT NOT NULL,
fields TEXT NOT NULL,
status haystack.progress NOT NULL DEFAULT 'not-started',
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
CREATE TABLE haystack.image_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image_id UUID NOT NULL REFERENCES haystack.image (id), image_id UUID NOT NULL REFERENCES haystack.image (id) ON DELETE CASCADE,
list_id UUID NOT NULL REFERENCES haystack.lists (id) stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE,
);
CREATE TABLE haystack.schemas ( UNIQUE(image_id, stack_id)
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES haystack.lists (id)
); );
CREATE TABLE haystack.schema_items ( CREATE TABLE haystack.schema_items (
@ -84,7 +57,7 @@ CREATE TABLE haystack.schema_items (
value TEXT NOT NULL, value TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
schema_id UUID NOT NULL REFERENCES haystack.schemas (id) stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE
); );
CREATE TABLE haystack.image_schema_items ( CREATE TABLE haystack.image_schema_items (
@ -92,66 +65,6 @@ CREATE TABLE haystack.image_schema_items (
value TEXT, value TEXT,
schema_item_id UUID NOT NULL REFERENCES haystack.schema_items (id), schema_item_id UUID NOT NULL REFERENCES haystack.schema_items (id) ON DELETE CASCADE,
image_id UUID NOT NULL REFERENCES haystack.image (id) image_id UUID NOT NULL REFERENCES haystack.image_stacks (id) ON DELETE CASCADE
); );
/* -----| Indexes |----- */
/* -----| Stored Procedures |----- */
CREATE OR REPLACE FUNCTION notify_new_image()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('new_image', NEW.id::texT);
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_new_processing_image_status()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('new_processing_image_status', NEW.id::text || NEW.status::text);
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_new_stacks()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('new_stack', NEW.id::text);
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_new_processing_stack_status()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('new_processing_stack_status', NEW.id::text || NEW.status::text);
RETURN NEW;
END
$$ LANGUAGE plpgsql;
/* -----| Triggers |----- */
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
ON haystack.user_images_to_process
FOR EACH ROW
EXECUTE PROCEDURE notify_new_image();
CREATE OR REPLACE TRIGGER on_update_image_progress
AFTER UPDATE OF status
ON haystack.user_images_to_process
FOR EACH ROW
EXECUTE PROCEDURE notify_new_processing_image_status();
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
ON haystack.processing_lists
FOR EACH ROW
EXECUTE PROCEDURE notify_new_stacks();
CREATE OR REPLACE TRIGGER on_update_stack_progress
AFTER UPDATE OF status
ON haystack.processing_lists
FOR EACH ROW
EXECUTE PROCEDURE notify_new_processing_stack_status();

View File

@ -2,21 +2,30 @@ package stacks
import ( import (
"database/sql" "database/sql"
"fmt"
"net/http" "net/http"
"os" "os"
. "screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/limits"
"screenmark/screenmark/middleware" "screenmark/screenmark/middleware"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"strings" "screenmark/screenmark/processor"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid"
) )
type StackHandler struct { type StackHandler struct {
logger *log.Logger logger *log.Logger
stackModel models.ListModel
imageModel models.ImageModel
stackModel models.StackModel
limitsManager limits.LimitsManagerMethods
jwtManager *middleware.JwtManager
processor *processor.Processor[model.Stacks]
} }
func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) { func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) {
@ -27,14 +36,14 @@ func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) {
return return
} }
lists, err := h.stackModel.List(ctx, userID) stacks, err := h.stackModel.List(ctx, userID)
if err != nil { if err != nil {
h.logger.Warn("could not get stacks", "err", err) h.logger.Warn("could not get stacks", "err", err)
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
return return
} }
middleware.WriteJsonOrError(h.logger, lists, w) middleware.WriteJsonOrError(h.logger, stacks, w)
} }
func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) { func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) {
@ -44,14 +53,14 @@ func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) {
return return
} }
listID, err := middleware.GetPathParamID(h.logger, "listID", w, r) stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
if err != nil { if err != nil {
return return
} }
// TODO: must check for permission here. // TODO: must check for permission here.
lists, err := h.stackModel.ListItems(ctx, listID) lists, err := h.stackModel.ListItems(ctx, stackID)
if err != nil { if err != nil {
h.logger.Warn("could not get list items", "err", err) h.logger.Warn("could not get list items", "err", err)
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@ -77,12 +86,12 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
return return
} }
listID, err := middleware.GetPathParamID(h.logger, "listID", w, r) stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
if err != nil { if err != nil {
return return
} }
err = h.stackModel.Delete(ctx, listID, userID) err = h.stackModel.Delete(ctx, stackID, userID)
if err != nil { if err != nil {
h.logger.Warn("could not delete stack", "err", err) h.logger.Warn("could not delete stack", "err", err)
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@ -92,11 +101,107 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
type CreateStackBody struct { func (h *StackHandler) deleteImageFromStack(w http.ResponseWriter, r *http.Request) {
Title string `json:"title"` ctx := r.Context()
// We want a regular string because AI will take care of creating these for us. stringListID := chi.URLParam(r, "stackID")
Fields string `json:"fields"` stringImageID := chi.URLParam(r, "imageID")
imageID, err := uuid.Parse(stringImageID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
stackID, err := uuid.Parse(stringListID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// TODO: this should be extracted into a middleware of sorts
userID, err := middleware.GetUserID(ctx, h.logger, w)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
stack, err := h.stackModel.Get(ctx, stackID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if stack.UserID != userID {
w.WriteHeader(http.StatusUnauthorized)
return
}
err = h.stackModel.DeleteImage(ctx, stackID, imageID)
if err != nil {
h.logger.Warn("failed to delete image from list", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stringStackID := chi.URLParam(r, "stackID")
stringSchemaItemID := chi.URLParam(r, "schemaItemID")
stackID, err := uuid.Parse(stringStackID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
schemaItemID, err := uuid.Parse(stringSchemaItemID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// TODO: this should be extracted into a middleware of sorts
userID, err := middleware.GetUserID(ctx, h.logger, w)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
stack, err := h.stackModel.Get(ctx, stackID)
if err != nil {
h.logger.Error("could not get stack model", "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if stack.UserID != userID {
w.WriteHeader(http.StatusUnauthorized)
return
}
// TODO:
// The code above is repeated, because it contains stack & image
// manipulations. So we could create a middleware.
// If you repeat this 3 times, then organise it :)
err = h.stackModel.DeleteSchemaItem(ctx, stackID, schemaItemID)
if err != nil {
h.logger.Warn("failed to delete image from list", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
type CreateStackBody struct {
Title string `json:"title"`
Description string `json:"description"`
} }
func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter, r *http.Request) { func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter, r *http.Request) {
@ -106,56 +211,53 @@ func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter,
return return
} }
// Convert fields string to basic schema items // TODO: Add the stack processor here
// For now, create a simple schema item for each field stack, err := h.stackModel.Save(ctx, userID, body.Title, body.Description, model.Progress_NotStarted)
var schemaItems []SchemaItems
if body.Fields != "" {
fields := strings.Split(body.Fields, ",")
for i, field := range fields {
field = strings.TrimSpace(field)
if field != "" {
schemaItems = append(schemaItems, SchemaItems{
Item: field,
Value: "",
Description: fmt.Sprintf("Field %d: %s", i+1, field),
})
}
}
}
// Use empty description for now since the API doesn't provide one
_, err = h.stackModel.Save(ctx, userID, body.Title, "", schemaItems)
if err != nil { if err != nil {
h.logger.Warn("could not save stack", "err", err) h.logger.Warn("could not save stack", "err", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
w.WriteHeader(http.StatusOK) h.processor.Add(stack)
middleware.WriteJsonOrError(h.logger, stack, w)
} }
func (h *StackHandler) CreateRoutes(r chi.Router) { func (h *StackHandler) CreateRoutes(r chi.Router) {
h.logger.Info("Mounting stack router") h.logger.Info("Mounting stack router")
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.ProtectedRoute) r.Use(middleware.ProtectedRoute(h.jwtManager))
r.Use(middleware.SetJson) r.Use(middleware.SetJson)
r.Get("/", h.getAllStacks) r.Get("/", h.getAllStacks)
r.Get("/{listID}", h.getStackItems) r.Get("/{stackID}", h.getStackItems)
r.Post("/", middleware.WithValidatedPost(h.createStack)) r.Post("/", middleware.WithLimit(h.logger, h.limitsManager.HasReachedStackLimit, middleware.WithValidatedPost(h.createStack)))
r.Patch("/{listID}", middleware.WithValidatedPost(h.editStack)) r.Patch("/{stackID}", middleware.WithValidatedPost(h.editStack))
r.Delete("/{listID}", h.deleteStack) r.Delete("/{stackID}", h.deleteStack)
r.Delete("/{stackID}/{imageID}", h.deleteImageFromStack)
r.Delete("/{stackID}/{schemaItemID}", h.deleteImageStackSchemaItem)
}) })
} }
func CreateStackHandler(db *sql.DB) StackHandler { func CreateStackHandler(
stackModel := models.NewListModel(db) db *sql.DB,
limitsManager limits.LimitsManagerMethods,
jwtManager *middleware.JwtManager,
processor *processor.Processor[model.Stacks],
) StackHandler {
stackModel := models.NewStackModel(db)
imageModel := models.NewImageModel(db)
logger := log.New(os.Stdout).WithPrefix("Stacks") logger := log.New(os.Stdout).WithPrefix("Stacks")
return StackHandler{ return StackHandler{
logger, logger: logger,
stackModel, imageModel: imageModel,
stackModel: stackModel,
limitsManager: limitsManager,
jwtManager: jwtManager,
processor: processor,
} }
} }

View File

@ -4,22 +4,22 @@
"": { "": {
"name": "haystack", "name": "haystack",
"dependencies": { "dependencies": {
"@kobalte/core": "^0.13.10", "@kobalte/core": "^0.13.11",
"@kobalte/tailwindcss": "^0.9.0", "@kobalte/tailwindcss": "^0.9.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@tabler/icons-solidjs": "^3.34.0", "@tabler/icons-solidjs": "^3.35.0",
"@tanstack/solid-virtual": "^3.13.12", "@tanstack/solid-virtual": "^3.13.12",
"@tauri-apps/api": "^2.6.0", "@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "~2.3.0", "@tauri-apps/plugin-dialog": "~2.3.3",
"@tauri-apps/plugin-fs": "~2.4.0", "@tauri-apps/plugin-fs": "~2.4.2",
"@tauri-apps/plugin-http": "2.4.3", "@tauri-apps/plugin-http": "^2.4.3",
"@tauri-apps/plugin-log": "~2.6.0", "@tauri-apps/plugin-log": "^2.6.0",
"@tauri-apps/plugin-opener": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-os": "2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"solid-js": "^1.9.7", "solid-js": "^1.9.9",
"solid-markdown": "^2.0.14", "solid-markdown": "^2.0.14",
"solid-motionone": "^1.0.4", "solid-motionone": "^1.0.4",
"solidjs-markdown": "^0.2.0", "solidjs-markdown": "^0.2.0",
@ -30,15 +30,15 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@tauri-apps/cli": "^2.6.2", "@tauri-apps/cli": "^2.8.4",
"@types/resolve": "^1.20.6", "@types/resolve": "^1.20.6",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"tailwindcss": "3.4.0", "tailwindcss": "3.4.0",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.3.5", "vite": "^6.3.6",
"vite-plugin-solid": "^2.11.7", "vite-plugin-solid": "^2.11.8",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
}, },
}, },
@ -174,7 +174,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@kobalte/core": ["@kobalte/core@0.13.10", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-lzP64ThxZqZB6O6MnMq6w7DxK38o2ClbW3Ob6afUI6p86cUMz5Hb4rdysvYI6m1TKYlOAlFODKkoRznqybQohw=="], "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="],
"@kobalte/tailwindcss": ["@kobalte/tailwindcss@0.9.0", "", { "peerDependencies": { "tailwindcss": "^3.3.3" } }, "sha512-WbueJTVRiO4yrmfHIBwp07y3M5iibJ/gauEAQ7mOyg1tZulvpO7SM/UdgzX95a9a0KDt1mQFxwO7RmpOUXWOWA=="], "@kobalte/tailwindcss": ["@kobalte/tailwindcss@0.9.0", "", { "peerDependencies": { "tailwindcss": "^3.3.3" } }, "sha512-WbueJTVRiO4yrmfHIBwp07y3M5iibJ/gauEAQ7mOyg1tZulvpO7SM/UdgzX95a9a0KDt1mQFxwO7RmpOUXWOWA=="],
@ -272,51 +272,51 @@
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tabler/icons": ["@tabler/icons@3.34.0", "", {}, "sha512-jtVqv0JC1WU2TTEBN32D9+R6mc1iEBuPwLnBsWaR02SIEciu9aq5806AWkCHuObhQ4ERhhXErLEK7Fs+tEZxiA=="], "@tabler/icons": ["@tabler/icons@3.35.0", "", {}, "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ=="],
"@tabler/icons-solidjs": ["@tabler/icons-solidjs@3.34.0", "", { "dependencies": { "@tabler/icons": "3.34.0" }, "peerDependencies": { "solid-js": "^1.4.7" } }, "sha512-O6RI1dz4o2MhsyMUk4tELySY25deyB+cHsREwQdYynB+8K9CncVgi9vlpG7lE14lmJ64edduDpCkMxqKdev5jQ=="], "@tabler/icons-solidjs": ["@tabler/icons-solidjs@3.35.0", "", { "dependencies": { "@tabler/icons": "3.35.0" }, "peerDependencies": { "solid-js": "^1.4.7" } }, "sha512-9kJxO7ITryM30xgmXJgYkebGXRjXIKIwue5g8AQfk+z0eNLFZqWz5w1833KPSNy/2k/86Pe0IOZJ4Gav3Th5xw=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-0dS8GkBTmbuM9cUR6Jni0a45eJbd32CAEbZj8HrZMWIj3lu974NpGz5ywcomOGJ9GdeHuDaRzlwtonBbKV1ihQ=="], "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-0dS8GkBTmbuM9cUR6Jni0a45eJbd32CAEbZj8HrZMWIj3lu974NpGz5ywcomOGJ9GdeHuDaRzlwtonBbKV1ihQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],
"@tauri-apps/api": ["@tauri-apps/api@2.6.0", "", {}, "sha512-hRNcdercfgpzgFrMXWwNDBN0B7vNzOzRepy6ZAmhxi5mDLVPNrTpo9MGg2tN/F7JRugj4d2aF7E1rtPXAHaetg=="], "@tauri-apps/api": ["@tauri-apps/api@2.8.0", "", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.6.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.6.2", "@tauri-apps/cli-darwin-x64": "2.6.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.6.2", "@tauri-apps/cli-linux-arm64-gnu": "2.6.2", "@tauri-apps/cli-linux-arm64-musl": "2.6.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.6.2", "@tauri-apps/cli-linux-x64-gnu": "2.6.2", "@tauri-apps/cli-linux-x64-musl": "2.6.2", "@tauri-apps/cli-win32-arm64-msvc": "2.6.2", "@tauri-apps/cli-win32-ia32-msvc": "2.6.2", "@tauri-apps/cli-win32-x64-msvc": "2.6.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-s1/eyBHxk0wG1blLeOY2IDjgZcxVrkxU5HFL8rNDwjYGr0o7yr3RAtwmuUPhz13NO+xGAL1bJZaLFBdp+5joKg=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.8.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.8.4", "@tauri-apps/cli-darwin-x64": "2.8.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.8.4", "@tauri-apps/cli-linux-arm64-gnu": "2.8.4", "@tauri-apps/cli-linux-arm64-musl": "2.8.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-gnu": "2.8.4", "@tauri-apps/cli-linux-x64-musl": "2.8.4", "@tauri-apps/cli-win32-arm64-msvc": "2.8.4", "@tauri-apps/cli-win32-ia32-msvc": "2.8.4", "@tauri-apps/cli-win32-x64-msvc": "2.8.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlvT+Yb7u2HplyN2Cf/nBplCQARC/I4uedlYHlgtxg6rV7xbo9BvG1jLOo29IFhqA2rOp5w1LtgvVGwsOf2kxw=="], "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.8.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-21gdPWfv1bP8rkTdCL44in70QcYcPaDM70L+y78N8TkBuC+/+wqnHcwwjzb+mUyck6UoEw2DORagSI/oKKUGJw=="], "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.8.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-MW8Y6HqHS5yzQkwGoLk/ZyE1tWpnz/seDoY4INsbvUZdknuUf80yn3H+s6eGKtT/0Bfqon/W9sY7pEkgHRPQgA=="], "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-9PdINTUtnyrnQt9hvC4y1m0NoxKSw/wUB9OTBAQabPj8WLAdvySWiUpEiqJjwLhlu4T6ltXZRpNTEzous3/RXg=="], "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrcJTRr7FrtQlTDkYaRXIGo/8YU/xkWmBPC646WwKNZ/S6yqCiDcOMoPe7Cx4ZvcG6sK6LUCLQMfaSNEL7PT0A=="], "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.6.2", "", { "os": "linux", "cpu": "none" }, "sha512-GnTshO/BaZ9KGIazz2EiFfXGWgLur5/pjqklRA/ck42PGdUQJhV/Ao7A7TdXPjqAzpFxNo6M/Hx0GCH2iMS7IA=="], "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.8.4", "", { "os": "linux", "cpu": "none" }, "sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QDG3WeJD6UJekmrtVPCJRzlKgn9sGzhvD58oAw5gIU+DRovgmmG2U1jH9fS361oYGjWWO7d/KM9t0kugZzi4lQ=="], "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-TNVTDDtnWzuVqWBFdZ4+8ZTg17tc21v+CT5XBQ+KYCoYtCrIaHpW04fS5Tmudi+vYdBwoPDfwpKEB6LhCeFraQ=="], "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-z77C1oa/hMLO/jM1JF39tK3M3v9nou7RsBnQoOY54z5WPcpVAbS0XdFhXB7sSN72BOiO3moDky9lQANQz6L3CA=="], "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.8.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-TmD8BbzbjluBw8+QEIWUVmFa9aAluSkT1N937n1mpYLXcPbTpbunqRFiIznTwupoJNJIdtpF/t7BdZDRh5rrcg=="], "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.8.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ItB8RCKk+nCmqOxOvbNtltz6x1A4QX6cSM21kj3NkpcnjT9rHSMcfyf8WVI2fkoMUJR80iqCblUX6ARxC3lj6w=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.8.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-ylSBvYYShpGlKKh732ZuaHyJ5Ie1JR71QCXewCtsRLqGdc8Is4xWdz6t43rzXyvkItM9syNPMvFVcvjgEy+/GA=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-cWXB9QJDbLIA0v7I5QY183awazBEQNPhp19iPvrMZoJRX8SbFkhWFx1/q7zy7xGpXXzxz29qtq6z21Ho7W5Iew=="],
"@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-Sp8AdDcbyXyk6LD6Pmdx44SH3LPeNAvxR2TFfq/8CwqzfO1yOyV+RzT8fov0NNN7d9nvW7O7MtMAptJ42YXA5g=="], "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig=="],
"@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.4.3", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-Us8X+FikzpaZRNr4kH4HLwyXascHbM42p6LxAqRTQnHPrrqp1usaH4vxWAZalPvTbHJ3gBEMJPHusFJgtjGJjA=="], "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-x1mQKHSLDk4mS2S938OTeyk8L7QyLpCrKZCZcjkljGsvTvRMojCvI9SeJ1kaxc7t8xSilkC7WdId8xER9TIGLg=="],
"@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-gVp3l31akA1Jk2bZsTA0hMFD5/gLe49Nw1btu5lViau0QqgC2XyT79LSwvy7a44ewtQbSexchqIg7oTJKMIbXQ=="], "@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-81XQ2f93x4vmIB5OY0XlYAxy60cHdYLs0Ki8Qp38tNATRiuBit+Orh3frpY3qfYQnqEvYVyRub7YRJWlmW2RRA=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-43VyN8JJtvKWJY72WI/KNZszTpDpzHULFxQs0CJBIYUdCRowQ6Q1feWTDb979N7nldqSuDOaBupZ6wz2nvuWwQ=="], "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.2.1", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A=="], "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@ -672,7 +672,7 @@
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"solid-js": ["solid-js@1.9.7", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw=="], "solid-js": ["solid-js@1.9.9", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA=="],
"solid-jsx": ["solid-jsx@0.9.1", "", { "peerDependencies": { "solid-js": "^1.4.0" } }, "sha512-HHTx58rx3tqg5LMGuQnaE1vqZjpl+RMP0jYQnBkTY0xKIASVNSLZJCZoPFrpKH8wWWYyTLHdepgzs8u/e6yz5Q=="], "solid-jsx": ["solid-jsx@0.9.1", "", { "peerDependencies": { "solid-js": "^1.4.0" } }, "sha512-HHTx58rx3tqg5LMGuQnaE1vqZjpl+RMP0jYQnBkTY0xKIASVNSLZJCZoPFrpKH8wWWYyTLHdepgzs8u/e6yz5Q=="],
@ -766,9 +766,9 @@
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], "vite": ["vite@6.3.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.7", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-5TgK1RnE449g0Ryxb9BXqem89RSy7fE8XGVCo+Gw84IHgPuPVP7nYNP6WBVAaY/0xw+OqfdQee+kusL0y3XYNg=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.8", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-hFrCxBfv3B1BmFqnJF4JOCYpjrmi/zwyeKjcomQ0khh8HFyQ8SbuBWQ7zGojfrz6HUOBFrJBNySDi/JgAHytWg=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="],
@ -796,10 +796,6 @@
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@tauri-apps/plugin-http/@tauri-apps/api": ["@tauri-apps/api@2.5.0", "", {}, "sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA=="],
"@tauri-apps/plugin-os/@tauri-apps/api": ["@tauri-apps/api@2.5.0", "", {}, "sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],

View File

@ -14,22 +14,22 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@kobalte/core": "^0.13.10", "@kobalte/core": "^0.13.11",
"@kobalte/tailwindcss": "^0.9.0", "@kobalte/tailwindcss": "^0.9.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@tabler/icons-solidjs": "^3.34.0", "@tabler/icons-solidjs": "^3.35.0",
"@tanstack/solid-virtual": "^3.13.12", "@tanstack/solid-virtual": "^3.13.12",
"@tauri-apps/api": "^2.6.0", "@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "~2.3.0", "@tauri-apps/plugin-dialog": "~2.3.3",
"@tauri-apps/plugin-fs": "~2.4.0", "@tauri-apps/plugin-fs": "~2.4.2",
"@tauri-apps/plugin-http": "2.4.3", "@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-log": "~2.6.0", "@tauri-apps/plugin-log": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-os": "2.2.1", "@tauri-apps/plugin-os": "^2.3.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"solid-js": "^1.9.7", "solid-js": "^1.9.9",
"solid-markdown": "^2.0.14", "solid-markdown": "^2.0.14",
"solid-motionone": "^1.0.4", "solid-motionone": "^1.0.4",
"solidjs-markdown": "^0.2.0", "solidjs-markdown": "^0.2.0",
@ -40,15 +40,15 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@tauri-apps/cli": "^2.6.2", "@tauri-apps/cli": "^2.8.4",
"@types/resolve": "^1.20.6", "@types/resolve": "^1.20.6",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"tailwindcss": "3.4.0", "tailwindcss": "3.4.0",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.3.5", "vite": "^6.3.6",
"vite-plugin-solid": "^2.11.7", "vite-plugin-solid": "^2.11.8",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.haystack.app</string>
</array>
</dict>
</plist>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Haystack</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImage</key>
<true/>
<key>NSExtensionActivationSupportsMovie</key>
<false/>
<key>NSExtensionActivationSupportsText</key>
<false/>
<key>NSExtensionActivationSupportsURL</key>
<false/>
<key>NSExtensionActivationSupportsWebPageWithText</key>
<false/>
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
<integer>0</integer>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
<key>NSExtensionPrincipalClass</key>
<string>Haystack.ShareViewController</string>
</dict>
</dict>
</plist>

View File

@ -1,171 +0,0 @@
//
//  ShareViewController.swift
//  Haystack
//
//  Created by Rio Keefe on 03/05/2025.
//
import UIKit
import Social
import MobileCoreServices
class ShareViewController: SLComposeServiceViewController {
let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier
let tokenKey = "sharedAuthToken"
let uploadURL = URL(string: "https://haystack.johncosta.tech/image/")!
var bearerToken: String?
// Store the item provider to access it later in didSelectPost
private var imageItemProvider: NSItemProvider?
private var extractedImageName: String = "image" // Default name
override func viewDidLoad() {
super.viewDidLoad()
// Load the bearer token from the App Group in viewDidLoad
// This is okay as reading from UserDefaults is fast
if let sharedDefaults = UserDefaults(suiteName: appGroupName) {
bearerToken = sharedDefaults.string(forKey: tokenKey)
print("Retrieved bearer token: \(bearerToken ?? "nil")")
} else {
print("Error accessing App Group UserDefaults.")
// Optionally inform the user or disable posting if token is crucial
// self.isContentValid() could check if bearerToken is nil
}
// Store the item provider, but don't load the data synchronously yet
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first as? NSItemProvider {
if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
self.imageItemProvider = provider
// Attempt to get a suggested name early if available
extractedImageName = provider.suggestedName ?? "image"
if let dotRange = extractedImageName.range(of: ".", options: .backwards) {
extractedImageName = String(extractedImageName[..<dotRange.lowerBound])
}
} else {
print("No image found.")
// If no image is found, the content is not valid for this extension
// You might want to adjust isContentValid() based on this
}
}
}
override func isContentValid() -> Bool {
// Content is valid only if we have an item provider for an image AND a bearer token
return imageItemProvider != nil && bearerToken != nil
}
override func didSelectPost() {
// This method is called when the user taps the "Post" button.
// Start the asynchronous operation here.
guard let provider = imageItemProvider else {
print("Error: No image item provider found when posting.")
// Inform the user or log an error
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
return
}
guard let token = bearerToken else {
print("Error: Bearer token is missing when posting.")
// Inform the user or log an error
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
return
}
// Load the image data asynchronously
provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
if let error = error {
print("Error loading image data for upload: \(error.localizedDescription)")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: error)
return
}
var rawImageData: Data?
var finalImageName = self.extractedImageName // Use the name extracted earlier
if let url = item as? URL, let data = try? Data(contentsOf: url) {
rawImageData = data
// Refine the name extraction here if necessary, though doing it in viewDidLoad is also an option
finalImageName = url.lastPathComponent
if let dotRange = finalImageName.range(of: ".", options: .backwards) {
finalImageName = String(finalImageName[..<dotRange.lowerBound])
}
} else if let data = item as? Data {
rawImageData = data
// Use the suggested name if available, fallback to default
finalImageName = provider.suggestedName ?? "image"
} else {
print("Error: Could not get image data in a usable format.")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -4, userInfo: [NSLocalizedDescriptionKey: "Could not process image data."]))
return
}
guard let dataToUpload = rawImageData else {
print("Error: No image data to upload.")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -5, userInfo: [NSLocalizedDescriptionKey: "Image data is missing."]))
return
}
// Now perform the upload asynchronously
self.uploadRawData(dataToUpload, imageName: finalImageName, bearerToken: token)
}
// Do not complete the request here.
// The request will be completed in the uploadRawData completion handler.
}
func uploadRawData(_ rawData: Data, imageName: String, bearerToken: String) {
// bearerToken is guaranteed to be non-nil here due to the guard in didSelectPost
let uploadURLwithName = uploadURL.appendingPathComponent(imageName)
var request = URLRequest(url: uploadURLwithName)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue("application/oclet-stream", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
// **IMPORTANT:** Complete the extension request on the main thread
DispatchQueue.main.async {
print("Upload finished. Error: \(error?.localizedDescription ?? "None"), Response: \(response?.description ?? "None")")
if let error = error {
// Handle upload error (e.g., show an alert to the user)
print("Upload failed: \(error.localizedDescription)")
self?.extensionContext!.cancelRequest(withError: error)
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
// Handle non-success HTTP status codes
let errorDescription = "Server returned status code \(httpResponse.statusCode)"
print(errorDescription)
self?.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorDescription]))
}
else {
// Upload was successful
print("Upload successful")
// Complete the request when the upload is done
self?.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
task.resume()
}
override func configurationItems() -> [Any]! {
// You can add items here if you want to allow the user to enter additional info
// e.g., a text field for a caption.
// This example only handles image upload, so no config items are needed.
return []
}
}

View File

@ -1,280 +1,189 @@
// //
// ShareViewController.swift //  ShareViewController.swift
// Haystack //  Haystack
// //
// Created by Rio Keefe on 03/05/2025. //  Created by Rio Keefe on 03/05/2025.
// //
import UIKit import UIKit
import Social import Social
import MobileCoreServices // For kUTTypeImage import MobileCoreServices
class ShareViewController: SLComposeServiceViewController { class ShareViewController: SLComposeServiceViewController {
let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier
let tokenKey = "sharedAuthToken" let tokenKey = "sharedAuthToken" // This key holds the refresh token.
let uploadURLBase = URL(string: "https://haystack.johncosta.tech/image/")! // Base URL let uploadURL = URL(string: "https://haystack.johncosta.tech/images/")!
var bearerToken: String? var refreshToken: String?
private var imageItemProvider: NSItemProvider? private var imageItemProvider: NSItemProvider?
// Store a base name, extension will be determined during item loading private var extractedImageName: String = "image" // Default name
private var baseImageName: String = "SharedImage" // A more descriptive default
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
if let sharedDefaults = UserDefaults(suiteName: appGroupName) { if let sharedDefaults = UserDefaults(suiteName: appGroupName) {
bearerToken = sharedDefaults.string(forKey: tokenKey) refreshToken = sharedDefaults.string(forKey: tokenKey)
print("Retrieved bearer token: \(bearerToken ?? "nil")") print("Retrieved refresh token: \(refreshToken ?? "nil")")
} else { } else {
print("Error accessing App Group UserDefaults.") print("Error accessing App Group UserDefaults.")
// Invalidate content if token is crucial and missing
// This will be caught by isContentValid()
} }
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, // Store the item provider, but don't load the data synchronously yet
let provider = extensionItem.attachments?.first else { if let item = extensionContext?.inputItems.first as? NSExtensionItem,
print("No attachments found.") let provider = item.attachments?.first as? NSItemProvider {
// Invalidate content if no provider if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
self.imageItemProvider = nil // Ensure it's nil so isContentValid fails self.imageItemProvider = provider
return // Attempt to get a suggested name early if available
} extractedImageName = provider.suggestedName ?? "image"
if let dotRange = extractedImageName.range(of: ".", options: .backwards) {
extractedImageName = String(extractedImageName[..<dotRange.lowerBound])
}
if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) { } else {
self.imageItemProvider = provider print("No image found.")
// Attempt to get a suggested name early if available, and clean it. // If no image is found, the content is not valid for this extension
// This will be our default base name if the item itself doesn't provide a better one. // You might want to adjust isContentValid() based on this
if let suggested = provider.suggestedName, !suggested.isEmpty {
if let dotRange = suggested.range(of: ".", options: .backwards) {
self.baseImageName = String(suggested[..<dotRange.lowerBound])
} else {
self.baseImageName = suggested
}
} }
// Sanitize the base name slightly (remove problematic characters for a filename)
let anInvalidCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:")
self.baseImageName = self.baseImageName.components(separatedBy: anInvalidCharacters).joined(separator: "_")
if self.baseImageName.isEmpty { self.baseImageName = "SharedImage" } // Ensure not empty
} else {
print("Attachment is not an image.")
self.imageItemProvider = nil // Ensure it's nil so isContentValid fails
} }
} }
override func isContentValid() -> Bool { override func isContentValid() -> Bool {
// Content is valid only if we have an item provider for an image AND a bearer token // Content is valid only if we have an item provider for an image AND a refresh token
let isValid = imageItemProvider != nil && bearerToken != nil return imageItemProvider != nil && refreshToken != nil
if imageItemProvider == nil {
print("isContentValid: imageItemProvider is nil")
}
if bearerToken == nil {
print("isContentValid: bearerToken is nil")
}
return isValid
} }
override func didSelectPost() { override func didSelectPost() {
guard let provider = imageItemProvider else { refreshToken { accessToken in
print("Error: No image item provider found when posting.") guard let token = accessToken else {
informUserAndCancel(message: "No image found to share.") // Inform the user about the authentication failure
return let error = NSError(domain: "ShareExtension", code: -1, userInfo: [NSLocalizedDescriptionKey: "Authentication failed. Please log in again."])
} self.extensionContext!.cancelRequest(withError: error)
guard let token = bearerToken else {
print("Error: Bearer token is missing when posting.")
informUserAndCancel(message: "Authentication error. Please try again.")
return
}
// Start activity indicator or similar UI feedback
// For SLComposeServiceViewController, the system provides some UI
provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
if let error = error {
print("Error loading image data for upload: \(error.localizedDescription)")
self.informUserAndCancel(message: "Could not load image: \(error.localizedDescription)")
return return
} }
var imageData: Data? guard let provider = self.imageItemProvider else {
var finalImageNameWithExtension: String print("Error: No image item provider found when posting.")
var mimeType: String = "application/octet-stream" // Default MIME type // Inform the user or log an error
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
// Determine base name (without extension)
var currentBaseName = self.baseImageName // Use the one prepared in viewDidLoad
if let suggested = provider.suggestedName, !suggested.isEmpty {
if let dotRange = suggested.range(of: ".", options: .backwards) {
currentBaseName = String(suggested[..<dotRange.lowerBound])
} else {
currentBaseName = suggested
}
let anInvalidCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:")
currentBaseName = currentBaseName.components(separatedBy: anInvalidCharacters).joined(separator: "_")
if currentBaseName.isEmpty { currentBaseName = "DefaultImageName" }
}
if let url = item as? URL {
print("Image provided as URL: \(url)")
finalImageNameWithExtension = url.lastPathComponent // Includes extension
// Ensure baseName is updated if URL provides a different one
if let dotRange = finalImageNameWithExtension.range(of:".", options: .backwards) {
currentBaseName = String(finalImageNameWithExtension[..<dotRange.lowerBound])
} else {
currentBaseName = finalImageNameWithExtension // No extension in name
}
do {
imageData = try Data(contentsOf: url)
// Determine MIME type from URL extension
let pathExtension = url.pathExtension.lowercased()
mimeType = self.mimeType(forPathExtension: pathExtension)
if !finalImageNameWithExtension.contains(".") && !pathExtension.isEmpty { // if original lastPathComponent had no ext
finalImageNameWithExtension = "\(currentBaseName).\(pathExtension)"
} else if !finalImageNameWithExtension.contains(".") && pathExtension.isEmpty { // no ext anywhere
finalImageNameWithExtension = "\(currentBaseName).jpg" // default to jpg
mimeType = "image/jpeg"
}
} catch {
print("Error creating Data from URL: \(error)")
self.informUserAndCancel(message: "Could not read image file.")
return
}
} else if let image = item as? UIImage {
print("Image provided as UIImage")
// Prefer PNG for screenshots/UIImage, fallback to JPEG
if let data = image.pngData() {
imageData = data
mimeType = "image/png"
finalImageNameWithExtension = "\(currentBaseName).png"
} else if let data = image.jpegData(compressionQuality: 0.9) { // Good quality JPEG
imageData = data
mimeType = "image/jpeg"
finalImageNameWithExtension = "\(currentBaseName).jpg"
} else {
print("Could not convert UIImage to Data.")
self.informUserAndCancel(message: "Could not process image.")
return
}
} else if let data = item as? Data {
print("Image provided as Data")
imageData = data
// We have raw data, try to use suggestedName's extension or default to png/jpg
var determinedExtension = "png" // Default
if let suggested = provider.suggestedName,
let dotRange = suggested.range(of: ".", options: .backwards) {
let ext = String(suggested[dotRange.upperBound...]).lowercased()
if ["png", "jpg", "jpeg", "gif"].contains(ext) {
determinedExtension = ext
}
}
mimeType = self.mimeType(forPathExtension: determinedExtension)
finalImageNameWithExtension = "\(currentBaseName).\(determinedExtension)"
} else {
print("Error: Could not get image data in a usable format. Item type: \(type(of: item)) Item: \(String(describing: item))")
self.informUserAndCancel(message: "Unsupported image format.")
return return
} }
guard let dataToUpload = imageData else { // Load the image data asynchronously
print("Error: No image data to upload after processing.") provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
self.informUserAndCancel(message: "Image data is missing.")
return
}
// Ensure finalImageNameWithExtension is not just an extension like ".png"
if finalImageNameWithExtension.starts(with: ".") {
finalImageNameWithExtension = "\(self.baseImageName)\(finalImageNameWithExtension)"
}
if finalImageNameWithExtension.isEmpty || !finalImageNameWithExtension.contains(".") {
// Fallback if somehow the name is bad
finalImageNameWithExtension = "\(self.baseImageName).png"
mimeType = "image/png"
}
print("Uploading image: \(finalImageNameWithExtension), MIME: \(mimeType), Size: \(dataToUpload.count) bytes")
self.uploadRawData(dataToUpload, imageNameWithExtension: finalImageNameWithExtension, mimeType: mimeType, bearerToken: token)
}
}
func uploadRawData(_ rawData: Data, imageNameWithExtension: String, mimeType: String, bearerToken: String) {
// The imageNameWithExtension should already include the correct extension.
// The server URL seems to expect the filename as a path component.
let uploadURL = uploadURLBase.appendingPathComponent(imageNameWithExtension)
print("Final Upload URL: \(uploadURL.absoluteString)")
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue(mimeType, forHTTPHeaderField: "Content-Type") // Use determined MIME type
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.setValue("\(rawData.count)", forHTTPHeaderField: "Content-Length")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
DispatchQueue.main.async {
guard let self = self else { return } guard let self = self else { return }
print("Upload finished. Error: \(error?.localizedDescription ?? "None")")
if let httpResponse = response as? HTTPURLResponse {
print("HTTP Status: \(httpResponse.statusCode)")
if let responseData = data, let responseString = String(data: responseData, encoding: .utf8) {
print("Response Data: \(responseString)")
}
}
if let error = error { if let error = error {
print("Upload failed: \(error.localizedDescription)") print("Error loading image data for upload: \(error.localizedDescription)")
self.informUserAndCancel(message: "Upload failed: \(error.localizedDescription)") // Inform the user about the failure
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { self.extensionContext!.cancelRequest(withError: error)
let errorDescription = "Upload failed. Server returned status code \(httpResponse.statusCode)." return
print(errorDescription) }
self.informUserAndCancel(message: errorDescription)
var rawImageData: Data?
var finalImageName = self.extractedImageName // Use the name extracted earlier
if let url = item as? URL, let data = try? Data(contentsOf: url) {
rawImageData = data
// Refine the name extraction here if necessary, though doing it in viewDidLoad is also an option
finalImageName = url.lastPathComponent
if let dotRange = finalImageName.range(of: ".", options: .backwards) {
finalImageName = String(finalImageName[..<dotRange.lowerBound])
}
} else if let data = item as? Data {
rawImageData = data
// Use the suggested name if available, fallback to default
finalImageName = provider.suggestedName ?? "image"
} else { } else {
print("Upload successful for \(imageNameWithExtension)") print("Error: Could not get image data in a usable format.")
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil) // Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -4, userInfo: [NSLocalizedDescriptionKey: "Could not process image data."]))
return
}
guard let dataToUpload = rawImageData else {
print("Error: No image data to upload.")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -5, userInfo: [NSLocalizedDescriptionKey: "Image data is missing."]))
return
}
// Now perform the upload asynchronously
self.uploadRawData(dataToUpload, imageName: finalImageName, bearerToken: token)
}
}
}
func uploadRawData(_ rawData: Data, imageName: String, bearerToken: String) {
// bearerToken is guaranteed to be non-nil here due to the guard in didSelectPost
let uploadURLwithName = uploadURL.appendingPathComponent(imageName)
var request = URLRequest(url: uploadURLwithName)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue("application/oclet-stream", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
// **IMPORTANT:** Complete the extension request on the main thread
DispatchQueue.main.async {
print("Upload finished. Error: \(error?.localizedDescription ?? "None"), Response: \(response?.description ?? "None")")
if let error = error {
// Handle upload error (e.g., show an alert to the user)
print("Upload failed: \(error.localizedDescription)")
self?.extensionContext!.cancelRequest(withError: error)
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
// Handle non-success HTTP status codes
let errorDescription = "Server returned status code \(httpResponse.statusCode)"
print(errorDescription)
self?.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorDescription]))
}
else {
// Upload was successful
print("Upload successful")
// Complete the request when the upload is done
self?.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
} }
} }
} }
task.resume() task.resume()
} }
override func configurationItems() -> [Any]! { func refreshToken(completion: @escaping (String?) -> Void) {
// No configuration items needed for this simple image uploader. guard let refreshToken = self.refreshToken else {
return [] completion(nil)
} return
}
// Helper to inform user and cancel request let url = URL(string: "https://haystack.johncosta.tech/auth/refresh")!
private func informUserAndCancel(message: String) { var request = URLRequest(url: url)
let error = NSError(domain: "com.haystack.ShareExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) request.httpMethod = "POST"
print("Informing user: \(message)") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// You could present an alert here if SLComposeServiceViewController allows easy alert presentation.
// For now, just cancel the request. The system might show a generic error.
self.extensionContext!.cancelRequest(withError: error)
}
// Helper to get MIME type from path extension let body = ["refresh": refreshToken]
private func mimeType(forPathExtension pathExtension: String) -> String { request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)?.takeRetainedValue()
if let uti = uti { let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
let mimeType = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue() if let data = data,
if let mimeType = mimeType { let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
return mimeType as String let accessToken = json["access"] as? String {
completion(accessToken)
} else {
completion(nil)
} }
} }
// Fallback for common types if UTType fails or for robustness
switch pathExtension.lowercased() { task.resume()
// case "jpg", "jpeg": return "image/jpeg" }
// case "png": return "image/png"
// case "gif": return "image/gif" override func configurationItems() -> [Any]! {
// case "bmp": return "image/bmp" // You can add items here if you want to allow the user to enter additional info
// case "tiff", "tif": return "image/tiff" // e.g., a text field for a caption.
default: return "application/octet-stream" // Generic fallback // This example only handles image upload, so no config items are needed.
} return []
} }
} }

View File

@ -1,13 +1,13 @@
import { Navigate, Route, Router } from "@solidjs/router"; import { Navigate, Route, Router } from "@solidjs/router";
import { onAndroidMount } from "./mobile"; import { onAndroidMount } from "./mobile";
import { import {
FrontPage, FrontPage,
ImagePage, ImagePage,
Login, Login,
Settings, Settings,
SearchPage, SearchPage,
AllImages, AllImages,
List, Stack,
} from "./pages"; } from "./pages";
import { SearchImageContextProvider } from "@contexts/SearchImageContext"; import { SearchImageContextProvider } from "@contexts/SearchImageContext";
import { WithNotifications } from "@contexts/Notifications"; import { WithNotifications } from "@contexts/Notifications";
@ -15,32 +15,46 @@ import { ProtectedRoute } from "@components/protected-route";
import { AppWrapper } from "@components/app-wrapper"; import { AppWrapper } from "@components/app-wrapper";
import { WithTopbarAndDock } from "@components/app-wrapper/with-topbar-and-dock"; import { WithTopbarAndDock } from "@components/app-wrapper/with-topbar-and-dock";
import { onSendImage } from "@contexts/send-image"; import { onSendImage } from "@contexts/send-image";
import { Toast } from "@kobalte/core/toast";
import { Portal } from "solid-js/web";
export const App = () => { export const App = () => {
onAndroidMount(); onAndroidMount();
onSendImage(); onSendImage();
return ( return (
<SearchImageContextProvider> <SearchImageContextProvider>
<Router> <Router>
<Route path="/" component={AppWrapper}> <Route path="/" component={AppWrapper}>
<Route path="/login" component={Login} /> <Route path="/login" component={Login} />
<Route path="/" component={ProtectedRoute}> <Route path="/" component={ProtectedRoute}>
<Route path="/" component={WithNotifications}> <Route path="/" component={WithNotifications}>
<Route path="/" component={WithTopbarAndDock}> <Route path="/" component={WithTopbarAndDock}>
<Route path="/" component={FrontPage} /> <Route path="/" component={FrontPage} />
<Route path="/search" component={SearchPage} /> <Route path="/search" component={SearchPage} />
<Route path="/all-images" component={AllImages} /> <Route
<Route path="/image/:imageId" component={ImagePage} /> path="/all-images"
<Route path="/list/:listId" component={List} /> component={AllImages}
<Route path="/settings" component={Settings} /> />
</Route> <Route
</Route> path="/image/:imageId"
</Route> component={ImagePage}
</Route> />
<Route path="*" component={() => <Navigate href="/" />} /> <Route path="/stack/:stackID" component={Stack} />
</Router> <Route path="/settings" component={Settings} />
</SearchImageContextProvider> </Route>
); </Route>
</Route>
</Route>
<Route path="*" component={() => <Navigate href="/" />} />
</Router>
<Portal>
<Toast.Region class="fixed w-72 top-4 right-4 z-50 flex flex-col space-y-2">
<Toast.List />
</Toast.Region>
</Portal>
</SearchImageContextProvider>
);
}; };

View File

@ -1,25 +1,112 @@
import { Component } from "solid-js"; import { Component, createResource, createSignal, Suspense } from "solid-js";
import { base } from "../../network"; import { base, getAccessToken } from "../../network";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { Dialog } from "@kobalte/core";
export const ImageComponent: Component<{ ID: string }> = (props) => { type ImageComponentProps = {
return ( ID: string;
<A href={`/image/${props.ID}`} class="w-full flex justify-center h-[300px]"> onDelete: (id: string) => void;
<img }
class="flex w-full object-cover rounded-xl"
src={`${base}/images/${props.ID}`} export const ImageComponent: Component<ImageComponentProps> = (props) => {
/> const [isOpen, setIsOpen] = createSignal(false);
</A>
); const [accessToken] = createResource(getAccessToken);
return (
<Suspense fallback={<></>}>
<div class="relative w-full flex justify-center h-[300px]">
<A href={`/image/${props.ID}`} class="flex w-full">
<img
class="flex w-full object-cover rounded-xl"
src={`${base}/images/${props.ID}?token=${accessToken()}`}
/>
</A>
<button
aria-label="Delete image"
class="absolute top-2 right-2 bg-gray-800 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
onClick={() => setIsOpen(true)}
>
×
</button>
</div>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black bg-opacity-50" />
<Dialog.Content class="fixed top-1/2 left-1/2 max-w-md w-full p-6 bg-white rounded shadow-lg transform -translate-x-1/2 -translate-y-1/2">
<Dialog.Title class="text-lg font-bold mb-2">
Confirm Delete
</Dialog.Title>
<Dialog.Description class="mb-4">
Are you sure you want to delete this image?
</Dialog.Description>
<div class="flex justify-end gap-2">
<Dialog.CloseButton>
<button class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">
Cancel
</button>
</Dialog.CloseButton>
<button class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" onClick={() => props.onDelete(props.ID)}>
Confirm
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Suspense>
);
}; };
export const ImageComponentFullHeight: Component<{ ID: string }> = (props) => { // TODO: these two components are basically identical
return ( // merge the fuckers
<A href={`/image/${props.ID}`} class="w-full flex justify-center">
<img export const ImageComponentFullHeight: Component<ImageComponentProps> = (props) => {
class="flex w-full object-cover rounded-xl" const [isOpen, setIsOpen] = createSignal(false);
src={`${base}/images/${props.ID}`}
/> const [accessToken] = createResource(getAccessToken);
</A>
); return (
<Suspense>
<div class="relative w-full flex justify-center">
<A href={`/image/${props.ID}`} class="flex w-full">
<img
class="flex w-full object-cover rounded-xl"
src={`${base}/images/${props.ID}?token=${accessToken()}`}
/>
</A>
<button
aria-label="Delete image"
class="absolute top-2 right-2 bg-gray-800 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
onClick={() => setIsOpen(true)}
>
×
</button>
</div>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black bg-opacity-50" />
<Dialog.Content class="fixed top-1/2 left-1/2 max-w-md w-full p-6 bg-white rounded shadow-lg transform -translate-x-1/2 -translate-y-1/2">
<Dialog.Title class="text-lg font-bold mb-2">
Confirm Delete
</Dialog.Title>
<Dialog.Description class="mb-4">
Are you sure you want to delete this image?
</Dialog.Description>
<div class="flex justify-end gap-2">
<Dialog.CloseButton>
<button class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">
Cancel
</button>
</Dialog.CloseButton>
<button class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" onClick={() => props.onDelete(props.ID)}>
Confirm
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Suspense>
);
}; };

View File

@ -1,35 +0,0 @@
import { List } from "@network/index";
import { Component } from "solid-js";
import fastHashCode from "../../utils/hash";
import { A } from "@solidjs/router";
const colors = [
"bg-emerald-50",
"bg-lime-50",
"bg-indigo-50",
"bg-sky-50",
"bg-amber-50",
"bg-teal-50",
"bg-fuchsia-50",
"bg-pink-50",
];
export const ListCard: Component<{ list: List }> = (props) => {
return (
<A
href={`/list/${props.list.ID}`}
class={
"flex flex-col p-4 border border-neutral-200 rounded-lg " +
colors[
fastHashCode(props.list.Name, { forcePositive: true }) % colors.length
]
}
>
<p class="text-xl font-bold">{props.list.Name}</p>
<p class="text-lg">{props.list.Images.length}</p>
</A>
);
};

View File

@ -1,82 +1,86 @@
import { Popover } from "@kobalte/core/popover"; import { Popover } from "@kobalte/core/popover";
import { Component, For, Show } from "solid-js"; import { Component, createResource, For, Show, Suspense } from "solid-js";
import { LoadingCircle } from "./LoadingCircle"; import { LoadingCircle } from "./LoadingCircle";
import { base } from "@network/index"; import { base, getAccessToken } from "@network/index";
import { useNotifications } from "@contexts/Notifications"; import { useNotifications } from "@contexts/Notifications";
export const ProcessingImages: Component = () => { export const ProcessingImages: Component = () => {
const notifications = useNotifications(); const notifications = useNotifications();
const processingNumber = () => const processingNumber = () =>
Object.keys(notifications.state.ProcessingImages).length + Object.keys(notifications.state.ProcessingImages).length +
Object.keys(notifications.state.ProcessingLists).length; Object.keys(notifications.state.ProcessingStacks).length;
return ( const [accessToken] = createResource(getAccessToken)
<Popover sameWidth gutter={4}>
<Popover.Trigger class="w-full flex justify-between gap-4 rounded-xl px-4 py-2">
<Show when={processingNumber() > 0}>
<p class="text-md">
Processing {processingNumber()}{" "}
{processingNumber() === 1 ? "item" : "items"}
...
</p>
</Show>
<Show
when={processingNumber() === 0}
fallback={<LoadingCircle status="loading" />}
>
<LoadingCircle status="complete" />
</Show>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content class="shadow-2xl flex flex-col gap-2 bg-white rounded-xl p-2">
<Show
when={processingNumber() > 0}
fallback={<p>No items to process</p>}
>
<For each={Object.entries(notifications.state.ProcessingImages)}>
{([id, _image]) => (
<Show when={_image}>
{(image) => (
<div class="flex gap-2 w-full justify-center">
<img
class="w-16 h-16 aspect-square rounded"
alt="processing"
src={`${base}/images/${id}`}
/>
<div class="flex flex-col gap-1">
<p class="text-slate-100">{image().ImageName}</p>
</div>
<LoadingCircle
status="loading"
class="ml-auto self-center"
/>
</div>
)}
</Show>
)}
</For>
<For each={Object.entries(notifications.state.ProcessingLists)}> return (
{([, _list]) => ( <Suspense>
<Show when={_list}> <Popover sameWidth gutter={4}>
{(list) => ( <Popover.Trigger class="w-full flex justify-between gap-4 rounded-xl px-4 py-2">
<div class="flex gap-2 w-full justify-center"> <Show when={processingNumber() > 0}>
<div class="flex flex-col gap-1"> <p class="text-md">
<p class="text-slate-900">New Stack: {list().Name}</p> Processing {processingNumber()}{" "}
</div> {processingNumber() === 1 ? "item" : "items"}
<LoadingCircle ...
status="loading" </p>
class="ml-auto self-center" </Show>
/> <Show
</div> when={processingNumber() === 0}
)} fallback={<LoadingCircle status="loading" />}
</Show> >
)} <LoadingCircle status="complete" />
</For> </Show>
</Show> </Popover.Trigger>
</Popover.Content> <Popover.Portal>
</Popover.Portal> <Popover.Content class="shadow-2xl flex flex-col gap-2 bg-white rounded-xl p-2">
</Popover> <Show
); when={processingNumber() > 0}
fallback={<p>No items to process</p>}
>
<For each={Object.entries(notifications.state.ProcessingImages)}>
{([id, _image]) => (
<Show when={_image}>
{(image) => (
<div class="flex gap-2 w-full justify-center">
<img
class="w-16 h-16 aspect-square rounded"
alt="processing"
src={`${base}/images/${id}?token=${accessToken()}`}
/>
<div class="flex flex-col gap-1">
<p class="text-slate-100">{image().ImageName}</p>
</div>
<LoadingCircle
status="loading"
class="ml-auto self-center"
/>
</div>
)}
</Show>
)}
</For>
<For each={Object.entries(notifications.state.ProcessingStacks)}>
{([, _stack]) => (
<Show when={_stack}>
{(stack) => (
<div class="flex gap-2 w-full justify-center">
<div class="flex flex-col gap-1">
<p class="text-slate-900">New Stack: {stack().Name}</p>
</div>
<LoadingCircle
status="loading"
class="ml-auto self-center"
/>
</div>
)}
</Show>
)}
</For>
</Show>
</Popover.Content>
</Popover.Portal>
</Popover>
</Suspense>
);
}; };

View File

@ -1,46 +1,48 @@
import { Navigate } from "@solidjs/router"; import { Navigate } from "@solidjs/router";
import { platform } from "@tauri-apps/plugin-os";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { Component, ParentProps, Show } from "solid-js"; import { Component, ParentProps, Show } from "solid-js";
import { save_token } from "tauri-plugin-ios-shared-token-api"; import { InferOutput, literal, number, object, parse, pipe, string, transform } from "valibot";
export const isTokenValid = (): boolean => { export const isTokenValid = (): boolean => {
const token = localStorage.getItem("access"); const token = localStorage.getItem("access");
if (token == null) { if (token == null) {
return false; return false;
} }
try { try {
jwtDecode(token); jwtDecode(token);
return true; return true;
} catch (err) { } catch (err) {
return false; return false;
} }
}; };
const accessTokenPropertiesValidator = object({
UserID: string(),
Type: literal('access'),
exp: pipe(number(), transform(i => new Date(i)))
});
export const getTokenProperties = (token: string): InferOutput<typeof accessTokenPropertiesValidator> => {
const decoded = jwtDecode(token);
return parse(accessTokenPropertiesValidator, decoded);
}
export const ProtectedRoute: Component<ParentProps> = (props) => { export const ProtectedRoute: Component<ParentProps> = (props) => {
const isValid = isTokenValid(); const isValid = isTokenValid();
if (isValid) { if (isValid) {
const token = localStorage.getItem("access"); const token = localStorage.getItem("refresh");
if (token == null) { if (token == null) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
}
if (platform() === "ios") { return (
// iOS share extension is a seperate process to the App. <Show when={isValid} fallback={<Navigate href="/login" />}>
// Therefore, we need to share our access token somewhere both the App & Share Extension can access {props.children}
// This involves App Groups. </Show>
save_token(token) );
.then(() => console.log("Saved token!!!"))
.catch((e) => console.error(e));
}
}
return (
<Show when={isValid} fallback={<Navigate href="/login" />}>
{props.children}
</Show>
);
}; };

View File

@ -0,0 +1,35 @@
import { Stack } from "@network/index";
import { Component } from "solid-js";
import fastHashCode from "../../utils/hash";
import { A } from "@solidjs/router";
const colors = [
"bg-emerald-50",
"bg-lime-50",
"bg-indigo-50",
"bg-sky-50",
"bg-amber-50",
"bg-teal-50",
"bg-fuchsia-50",
"bg-pink-50",
];
export const StackCard: Component<{ stack: Stack }> = (props) => {
return (
<A
href={`/stack/${props.stack.ID}`}
class={
"flex flex-col p-4 border border-neutral-200 rounded-lg " +
colors[
fastHashCode(props.stack.Name, { forcePositive: true }) % colors.length
]
}
>
<p class="text-xl font-bold">{props.stack.Name}</p>
<p class="text-lg">{props.stack.Images.length}</p>
</A>
);
};

View File

@ -2,154 +2,159 @@ import { InferOutput, safeParse } from "valibot";
import { useSearchImageContext } from "./SearchImageContext"; import { useSearchImageContext } from "./SearchImageContext";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { import {
Component, Component,
createContext, createContext,
createEffect, createEffect,
onCleanup, createResource,
ParentProps, onCleanup,
useContext, ParentProps,
useContext,
} from "solid-js"; } from "solid-js";
import { base } from "@network/index"; import { base, getAccessToken } from "@network/index";
import { import {
notificationValidator, notificationValidator,
processingImagesValidator, processingImagesValidator,
processingListValidator, processingListValidator,
} from "@network/notifications"; } from "@network/notifications";
type NotificationState = { type NotificationState = {
ProcessingImages: Record< ProcessingImages: Record<
string, string,
InferOutput<typeof processingImagesValidator> | undefined InferOutput<typeof processingImagesValidator> | undefined
>; >;
ProcessingLists: Record< ProcessingStacks: Record<
string, string,
InferOutput<typeof processingListValidator> | undefined InferOutput<typeof processingListValidator> | undefined
>; >;
}; };
export const Notifications = (onCompleteImage: () => void) => { export const Notifications = (onCompleteImage: () => void) => {
const [state, setState] = createStore<NotificationState>({ const [state, setState] = createStore<NotificationState>({
ProcessingImages: {}, ProcessingImages: {},
ProcessingLists: {}, ProcessingStacks: {},
}); });
const { processingImages } = useSearchImageContext(); const { userImages } = useSearchImageContext();
const access = localStorage.getItem("access"); const [accessToken] = createResource(getAccessToken);
if (access == null) {
throw new Error("Access token not defined");
}
const dataEventListener = (e: MessageEvent<unknown>) => { const dataEventListener = (e: MessageEvent<unknown>) => {
if (typeof e.data !== "string") { if (typeof e.data !== "string") {
console.error("Error type is not string"); console.error("Error type is not string");
return; return;
} }
let jsonData: object = {}; let jsonData: object = {};
try { try {
jsonData = JSON.parse(e.data); jsonData = JSON.parse(e.data);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return; return;
} }
const notification = safeParse(notificationValidator, jsonData); const notification = safeParse(notificationValidator, jsonData);
if (!notification.success) { if (!notification.success) {
console.error("Processing image could not be parsed.", e.data); console.error("Processing image could not be parsed.", e.data);
return; return;
} }
console.log("SSE: ", notification); console.log("SSE: ", notification);
if (notification.output.Type === "image") { if (notification.output.Type === "image") {
const { ImageID, Status } = notification.output; const { ImageID, Status } = notification.output;
if (Status === "complete") { if (Status === "complete") {
setState("ProcessingImages", ImageID, undefined); setState("ProcessingImages", ImageID, undefined);
onCompleteImage(); onCompleteImage();
} else { } else {
setState("ProcessingImages", ImageID, notification.output); setState("ProcessingImages", ImageID, notification.output);
} }
} else if (notification.output.Type === "list") { } else if (notification.output.Type === "stack") {
const { ListID, Status } = notification.output; const { StackID, Status } = notification.output;
if (Status === "complete") { if (Status === "complete") {
setState("ProcessingLists", ListID, undefined); setState("ProcessingStacks", StackID, undefined);
onCompleteImage(); onCompleteImage();
} else { } else {
setState("ProcessingLists", ListID, notification.output); setState("ProcessingStacks", StackID, notification.output);
} }
} }
}; };
const upsertImageProcessing = ( const upsertImageProcessing = (
images: NotificationState["ProcessingImages"], images: NotificationState["ProcessingImages"],
) => { ) => {
setState("ProcessingImages", (currentImages) => ({ setState("ProcessingImages", (currentImages) => ({
...currentImages, ...currentImages,
...images, ...images,
})); }));
}; };
createEffect(() => { createEffect(() => {
const images = processingImages(); const images = userImages();
if (images == null) { if (images == null) {
return; return;
} }
upsertImageProcessing( upsertImageProcessing(
Object.fromEntries( Object.fromEntries(
images.map((i) => [ images.filter(i => i.Status !== 'complete').map((i) => [
i.ImageID, i.ID,
{ {
Type: "image", Type: "image",
ImageID: i.ImageID, ImageID: i.ID,
ImageName: i.Image.ImageName, ImageName: i.ImageName,
Status: i.Status, Status: i.Status,
}, },
]), ]),
), ),
); );
}); });
const events = new EventSource(`${base}/notifications?token=${access}`); let events: EventSource | undefined;
events.addEventListener("data", dataEventListener); createEffect(() => {
const token = accessToken();
if (token) {
events = new EventSource(`${base}/notifications?token=${token}`);
events.addEventListener("data", dataEventListener);
events.onerror = (e) => {
console.error(e);
};
}
});
events.onerror = (e) => { onCleanup(() => {
console.error(e); if (events) {
}; events.removeEventListener("data", dataEventListener);
events.close();
}
});
onCleanup(() => { return {
events.removeEventListener("data", dataEventListener); state,
events.close(); };
});
return {
state,
};
}; };
export const NotificationsContext = export const NotificationsContext =
createContext<ReturnType<typeof Notifications>>(); createContext<ReturnType<typeof Notifications>>();
export const useNotifications = () => { export const useNotifications = () => {
const notifications = useContext(NotificationsContext); const notifications = useContext(NotificationsContext);
if (notifications == null) { if (notifications == null) {
throw new Error("Cannot use this hook with an unmounted notifications"); throw new Error("Cannot use this hook with an unmounted notifications");
} }
return notifications; return notifications;
}; };
export const WithNotifications: Component<ParentProps> = (props) => { export const WithNotifications: Component<ParentProps> = (props) => {
const { onRefetchImages } = useSearchImageContext(); const { onRefetchImages } = useSearchImageContext();
const notifications = Notifications(onRefetchImages); const notifications = Notifications(onRefetchImages);
return ( return (
<NotificationsContext.Provider value={notifications}> <NotificationsContext.Provider value={notifications}>
{props.children} {props.children}
</NotificationsContext.Provider> </NotificationsContext.Provider>
); );
}; };

View File

@ -1,92 +1,105 @@
import { import {
type Accessor, type Accessor,
type Component, type Component,
type ParentProps, type ParentProps,
createContext, createContext,
createEffect, createMemo,
createMemo, createResource,
createResource, useContext,
useContext,
} from "solid-js"; } from "solid-js";
import { getUserImages, JustTheImageWhatAreTheseNames } from "../network"; import {
deleteImage,
deleteImageFromStack,
deleteStack,
deleteStackItem,
getUserImages,
JustTheImageWhatAreTheseNames,
} from "../network";
export type SearchImageStore = { export type SearchImageStore = {
imagesByDate: Accessor< imagesByDate: Accessor<
Array<{ date: Date; images: JustTheImageWhatAreTheseNames }> Array<{ date: Date; images: JustTheImageWhatAreTheseNames }>
>; >;
lists: Accessor<Awaited<ReturnType<typeof getUserImages>>["lists"]>; stacks: Accessor<Awaited<ReturnType<typeof getUserImages>>["Stacks"]>;
userImages: Accessor<JustTheImageWhatAreTheseNames>; userImages: Accessor<JustTheImageWhatAreTheseNames>;
processingImages: Accessor< onRefetchImages: () => void;
Awaited<ReturnType<typeof getUserImages>>["processingImages"] | undefined
>;
onRefetchImages: () => void; onDeleteImage: (imageID: string) => void;
onDeleteImageFromStack: (stackID: string, imageID: string) => void;
onDeleteStack: (stackID: string) => void;
onDeleteStackItem: (stackID: string, schemaItemID: string) => void;
}; };
const SearchImageContext = createContext<SearchImageStore>(); const SearchImageContext = createContext<SearchImageStore>();
export const SearchImageContextProvider: Component<ParentProps> = (props) => { export const SearchImageContextProvider: Component<ParentProps> = (props) => {
const [data, { refetch }] = createResource(getUserImages); const [data, { refetch }] = createResource(getUserImages);
createEffect(() => { const sortedImages = createMemo<
console.log(data()); ReturnType<SearchImageStore["imagesByDate"]>
}); >(() => {
const d = data();
if (d == null) {
return [];
}
const sortedImages = createMemo<ReturnType<SearchImageStore["imagesByDate"]>>( // Sorted by day. But we could potentially add more in the future.
() => { const buckets: Record<string, JustTheImageWhatAreTheseNames> = {};
const d = data();
if (d == null) {
return [];
}
// Sorted by day. But we could potentially add more in the future. for (const image of d.UserImages) {
const buckets: Record<string, JustTheImageWhatAreTheseNames> = {}; if (image.CreatedAt == null) {
continue;
}
for (const image of d.userImages) { const date = new Date(image.CreatedAt).toDateString();
if (image.CreatedAt == null) { if (!(date in buckets)) {
continue; buckets[date] = [];
} }
const date = new Date(image.CreatedAt).toDateString(); buckets[date].push(image);
if (!(date in buckets)) { }
buckets[date] = [];
}
buckets[date].push(image); return Object.entries(buckets)
} .map(([date, images]) => ({ date: new Date(date), images }))
.sort((a, b) => b.date.getTime() - a.date.getTime());
});
return Object.entries(buckets) return (
.map(([date, images]) => ({ date: new Date(date), images })) <SearchImageContext.Provider
.sort((a, b) => b.date.getTime() - a.date.getTime()); value={{
}, imagesByDate: sortedImages,
); stacks: () => data()?.Stacks ?? [],
userImages: () => data()?.UserImages ?? [],
const processingImages = () => data()?.processingImages ?? []; onRefetchImages: refetch,
onDeleteImage: (imageID: string) => {
return ( deleteImage(imageID).then(refetch);
<SearchImageContext.Provider },
value={{ onDeleteImageFromStack: (stackID: string, imageID: string) => {
imagesByDate: sortedImages, deleteImageFromStack(stackID, imageID).then(refetch);
lists: () => data()?.lists ?? [], },
userImages: () => data()?.userImages ?? [], onDeleteStack: (stackID: string) => {
processingImages, deleteStack(stackID).then(refetch)
onRefetchImages: refetch, },
}} onDeleteStackItem: (stackID: string, schemaItemID: string) => {
> deleteStackItem(stackID, schemaItemID).then(refetch);
{props.children} },
</SearchImageContext.Provider> }}
); >
{props.children}
</SearchImageContext.Provider>
);
}; };
export const useSearchImageContext = () => { export const useSearchImageContext = () => {
const context = useContext(SearchImageContext); const context = useContext(SearchImageContext);
if (context == null) { if (context == null) {
throw new Error( throw new Error(
"Unreachable: We should always have a mounted context and no undefined values", "Unreachable: We should always have a mounted context and no undefined values",
); );
} }
return context; return context;
}; };

View File

@ -1,32 +1,42 @@
import { createEffect } from "solid-js"; import { createEffect } from "solid-js";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { sendImage } from "@network/index"; import { ImageLimitReached, sendImage } from "@network/index";
import { createToast } from "../utils/show-toast";
export const onSendImage = () => { export const onSendImage = () => {
let sentImage = ""; let sentImage = "";
createEffect(async () => { createEffect(async () => {
// Listen for PNG processing events // Listen for PNG processing events
const unlisten = listen("png-processed", async (event) => { const unlisten = listen("png-processed", async (event) => {
const base64Data = event.payload as string; const base64Data = event.payload as string;
if (base64Data === sentImage) { if (base64Data === sentImage) {
return; return;
} }
sentImage = base64Data; sentImage = base64Data;
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
appWindow.show(); appWindow.show();
appWindow.setFocus(); appWindow.setFocus();
await sendImage("test-image.png", base64Data); try {
}); await sendImage("test-image.png", base64Data);
} catch (e) {
if (e instanceof ImageLimitReached) {
createToast("Limits reached!", "You've reached your image limit")
console.log("Reached image limits!");
} else {
throw e
}
}
});
return () => { return () => {
unlisten.then((fn) => fn()); // Cleanup listener unlisten.then((fn) => fn()); // Cleanup listener
}; };
}); });
}; };

View File

@ -1,232 +1,358 @@
import { getTokenProperties } from "@components/protected-route";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import { platform } from "@tauri-apps/plugin-os";
import { save_token } from "tauri-plugin-ios-shared-token-api";
import { import {
type InferOutput, type InferOutput,
array, array,
literal, null_,
null_, literal,
nullable, nullable,
parse, parse,
pipe, pipe,
strictObject, safeParse,
string, strictObject,
transform, string,
union, transform,
uuid, union,
uuid,
} from "valibot"; } from "valibot";
type BaseRequestParams = Partial<{ type BaseRequestParams = Partial<{
path: string; path: string;
body: RequestInit["body"]; body: RequestInit["body"];
method: "GET" | "POST"; method: "GET" | "POST" | "DELETE";
}>; }>;
// export const base = "https://haystack.johncosta.tech"; export const base = "https://haystack.johncosta.tech";
export const base = "http://localhost:3040"; // export const base = "http://192.168.1.199:3040";
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => { const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
return new Request(`${base}/${path}`, { return new Request(`${base}/${path}`, {
body, body,
method, method,
}); });
}; };
const getBaseAuthorizedRequest = ({ const refreshTokenValidator = strictObject({
path, access: string(),
body, })
method,
}: BaseRequestParams): Request => {
return new Request(`${base}/${path}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`,
},
body,
method,
});
};
const sendImageResponseValidator = strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
UserID: pipe(string(), uuid()),
Status: string(),
});
export const getAccessToken = async (): Promise<string> => {
let accessToken = localStorage.getItem("access")?.toString();
const refreshToken = localStorage.getItem("refresh")?.toString();
if (accessToken == null && refreshToken == null) {
throw new Error("you are not logged in")
}
if (platform() === "ios") {
// iOS share extension is a seperate process to the App.
// Therefore, we need to share our access token somewhere both the App & Share Extension can access
// This involves App Groups.
save_token(refreshToken!)
.then(() => console.log("Saved token!!!"))
.catch(console.error);
}
// FIX: Check what getTokenProperties returns
const tokenProps = getTokenProperties(accessToken!);
// If tokenProps.exp is a number (seconds), convert to milliseconds:
const expiryTime = typeof tokenProps.exp === 'number'
? tokenProps.exp * 1000 // Convert seconds to milliseconds
: tokenProps.exp.getTime(); // Already a Date object
const isValidAccessToken = accessToken != null && expiryTime > Date.now();
console.log('Token check:', {
expiryTime: new Date(expiryTime),
now: new Date(),
isValid: isValidAccessToken,
timeLeft: (expiryTime - Date.now()) / 1000 + 's'
});
if (!isValidAccessToken) {
console.log('Refreshing token...');
const newAccessToken = await fetch(getBaseRequest({
path: 'auth/refresh',
method: "POST",
body: JSON.stringify({
refresh: refreshToken,
})
})).then(r => r.json());
const { access } = parse(refreshTokenValidator, newAccessToken);
localStorage.setItem("access", access);
accessToken = access;
}
return accessToken!;
}
const getBaseAuthorizedRequest = async ({
path,
body,
method,
}: BaseRequestParams): Promise<Request> => {
const accessToken = await getAccessToken();
return new Request(`${base}/${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
body,
method,
});
};
export const sendImageFile = async ( export const sendImageFile = async (
imageName: string, imageName: string,
file: File, file: File,
): Promise<InferOutput<typeof sendImageResponseValidator>> => { ): Promise<InferOutput<typeof imageValidator>> => {
const request = getBaseAuthorizedRequest({ const request = await getBaseAuthorizedRequest({
path: `images/${imageName}`, path: `images/${imageName}`,
body: file, body: file,
method: "POST", method: "POST",
}); });
request.headers.set("Content-Type", "application/oclet-stream"); request.headers.set("Content-Type", "application/oclet-stream");
const res = await fetch(request).then((res) => res.json()); const res = await fetch(request).then((res) => res.json());
const parsedRes = safeParse(imageValidator, res);
return parse(sendImageResponseValidator, res); if (!parsedRes.success) {
console.log(parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
}; };
export const deleteImage = async (
imageID: string
): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `images/${imageID}`,
method: "DELETE",
});
await fetch(request);
}
export const deleteImageFromStack = async (listID: string, imageID: string): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${listID}/${imageID}`,
method: "DELETE",
});
await fetch(request);
}
export const deleteStackItem = async (
stackID: string,
schemaItemID: string,
): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${stackID}/${schemaItemID}`,
method: "DELETE",
});
await fetch(request);
}
export const deleteStack = async (listID: string): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${listID}`,
method: "DELETE",
});
await fetch(request);
}
export class ImageLimitReached extends Error {
constructor() {
super();
}
}
export const sendImage = async ( export const sendImage = async (
imageName: string, imageName: string,
base64Image: string, base64Image: string,
): Promise<InferOutput<typeof sendImageResponseValidator>> => { ): Promise<InferOutput<typeof imageValidator>> => {
const request = getBaseAuthorizedRequest({ const request = await getBaseAuthorizedRequest({
path: `images/${imageName}`, path: `images/${imageName}`,
body: base64Image, body: base64Image,
method: "POST", method: "POST",
}); });
request.headers.set("Content-Type", "application/base64"); request.headers.set("Content-Type", "application/base64");
const res = await fetch(request).then((res) => res.json()); const rawRes = await fetch(request);
if (!rawRes.ok && rawRes.status == 429) {
throw new ImageLimitReached()
}
return parse(sendImageResponseValidator, res); const res = await rawRes.json();
const parsedRes = safeParse(imageValidator, res);
if (!parsedRes.success) {
console.log("Parsing issues: ", parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
}; };
const imageMetaValidator = strictObject({ const imageValidator = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
ImageName: string(), CreatedAt: string(),
Description: string(), UserID: pipe(string(), uuid()),
Image: null_(), Description: string(),
});
Image: null_(),
ImageName: string(),
Status: union([literal('not-started'), literal('in-progress'), literal('complete')]),
})
const userImageValidator = strictObject({ const userImageValidator = strictObject({
ID: pipe(string(), uuid()), ...imageValidator.entries,
CreatedAt: pipe(string()), ImageStacks: pipe(nullable(array(
ImageID: pipe(string(), uuid()), strictObject({
UserID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
Image: strictObject({ ImageID: pipe(string(), uuid()),
...imageMetaValidator.entries, StackID: pipe(string(), uuid()),
ImageLists: array( }),
strictObject({ )), transform(l => l ?? [])),
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()),
}),
),
}),
}); });
const userProcessingImageValidator = strictObject({ const stackItem = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()), ImageID: pipe(string(), uuid()),
UserID: pipe(string(), uuid()), SchemaItemID: pipe(string(), uuid()),
Image: imageMetaValidator,
Status: union([ Value: string(),
literal("not-started"), })
literal("in-progress"),
literal("complete"), const stackImage = strictObject({
]), ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
StackID: pipe(string(), uuid()),
Items: pipe(nullable(array(stackItem)), transform(l => l ?? [])),
}); });
const listValidator = strictObject({ const stackSchemaItem = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
UserID: pipe(string(), uuid()), StackID: pipe(string(), uuid()),
CreatedAt: pipe(string()),
Name: string(),
Description: nullable(string()),
Images: pipe( Description: string(),
nullable( Item: string(),
array( Value: string(),
strictObject({ })
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()),
Items: array(
strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
SchemaItemID: pipe(string(), uuid()),
Value: string(),
}),
),
}),
),
),
transform((n) => n ?? []),
),
Schema: strictObject({ const stackValidator = strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()), CreatedAt: string(),
SchemaItems: array( UserID: pipe(string(), uuid()),
strictObject({ Description: string(),
ID: pipe(string(), uuid()),
SchemaID: pipe(string(), uuid()), Status: union([literal('not-started'), literal('in-progress'), literal('complete')]),
Item: string(),
Value: nullable(string()), Name: string(),
Description: string(),
}), Images: pipe(nullable(array(stackImage)), transform(l => l ?? [])),
), SchemaItems: array(stackSchemaItem),
}),
}); });
export type List = InferOutput<typeof listValidator>; export type Stack = InferOutput<typeof stackValidator>;
const imageRequestValidator = strictObject({ const imageRequestValidator = strictObject({
userImages: array(userImageValidator), UserImages: array(userImageValidator),
processingImages: array(userProcessingImageValidator), Stacks: array(stackValidator),
lists: array(listValidator),
}); });
export type JustTheImageWhatAreTheseNames = InferOutput< export type JustTheImageWhatAreTheseNames = InferOutput<
typeof userImageValidator typeof userImageValidator
>[]; >[];
export const getUserImages = async (): Promise< export const getUserImages = async (): Promise<
InferOutput<typeof imageRequestValidator> InferOutput<typeof imageRequestValidator>
> => { > => {
const request = getBaseAuthorizedRequest({ path: "images" }); const request = await getBaseAuthorizedRequest({ path: "images" });
const res = await fetch(request).then((res) => res.json()); const res = await fetch(request).then((res) => res.json());
return parse(imageRequestValidator, res); console.log("Backend response: ", res);
const parsedRes = safeParse(imageRequestValidator, res);
if (!parsedRes.success) {
console.log("Schema error: ", parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
}; };
export const postLogin = async (email: string): Promise<void> => { export const postLogin = async (email: string): Promise<void> => {
const request = getBaseRequest({ const request = getBaseRequest({
path: "auth/login", path: "auth/login",
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
method: "POST", method: "POST",
}); });
await fetch(request); await fetch(request);
}; };
const codeValidator = strictObject({ const codeValidator = strictObject({
access: string(), access: string(),
refresh: string(), refresh: string(),
}); });
export const postCode = async ( export const postCode = async (
email: string, email: string,
code: string, code: string,
): Promise<InferOutput<typeof codeValidator>> => { ): Promise<InferOutput<typeof codeValidator>> => {
const request = getBaseRequest({ const request = getBaseRequest({
path: "auth/code", path: "auth/code",
body: JSON.stringify({ email, code }), body: JSON.stringify({ email, code }),
method: "POST", method: "POST",
}); });
const res = await fetch(request).then((res) => res.json()); const res = await fetch(request).then((res) => res.json());
return parse(codeValidator, res); const parsedRes = safeParse(codeValidator, res);
if (!parsedRes.success) {
console.log("Schema error: ", parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
}; };
export const createList = async ( export class ReachedStackLimit extends Error {
title: string, constructor() {
description: string, super();
}
}
export const createStack = async (
title: string,
description: string,
): Promise<void> => { ): Promise<void> => {
const request = getBaseAuthorizedRequest({ const request = await getBaseAuthorizedRequest({
path: "stacks", path: "stacks",
method: "POST", method: "POST",
body: JSON.stringify({ title, description }), body: JSON.stringify({ title, description }),
}); });
request.headers.set("Content-Type", "application/json"); request.headers.set("Content-Type", "application/json");
await fetch(request); const res = await fetch(request);
if (!res.ok && res.status == 429) {
throw new ReachedStackLimit();
}
}; };

View File

@ -1,31 +1,31 @@
import { literal, pipe, strictObject, string, union, uuid } from "valibot"; import { literal, pipe, strictObject, string, union, uuid } from "valibot";
export const processingListValidator = strictObject({ export const processingListValidator = strictObject({
Type: literal("list"), Type: literal("stack"),
Name: string(), Name: string(),
ListID: pipe(string(), uuid()), StackID: pipe(string(), uuid()),
Status: union([ Status: union([
literal("not-started"), literal("not-started"),
literal("in-progress"), literal("in-progress"),
literal("complete"), literal("complete"),
]), ]),
}); });
export const processingImagesValidator = strictObject({ export const processingImagesValidator = strictObject({
Type: literal("image"), Type: literal("image"),
ImageID: pipe(string(), uuid()), ImageID: pipe(string(), uuid()),
ImageName: string(), ImageName: string(),
Status: union([ Status: union([
literal("not-started"), literal("not-started"),
literal("in-progress"), literal("in-progress"),
literal("complete"), literal("complete"),
]), ]),
}); });
export const notificationValidator = union([ export const notificationValidator = union([
processingListValidator, processingListValidator,
processingImagesValidator, processingImagesValidator,
]); ]);

View File

@ -5,60 +5,60 @@ import { ImageComponent } from "@components/image";
import { chunkRows } from "./chunk"; import { chunkRows } from "./chunk";
type ImageOrDate = type ImageOrDate =
| { type: "image"; ID: string[] } | { type: "image"; ID: string[] }
| { type: "date"; date: Date }; | { type: "date"; date: Date };
export const AllImages: Component = () => { export const AllImages: Component = () => {
let scrollRef: HTMLDivElement | undefined; let scrollRef: HTMLDivElement | undefined;
const { imagesByDate } = useSearchImageContext(); const { imagesByDate, onDeleteImage } = useSearchImageContext();
const items = () => { const items = () => {
const items: Array<ImageOrDate> = []; const items: Array<ImageOrDate> = [];
for (const { date, images } of imagesByDate()) { for (const { date, images } of imagesByDate()) {
items.push({ type: "date", date }); items.push({ type: "date", date });
const chunkedRows = chunkRows(3, images); const chunkedRows = chunkRows(3, images);
for (const chunk of chunkedRows) { for (const chunk of chunkedRows) {
items.push({ type: "image", ID: chunk.map((c) => c.ImageID) }); items.push({ type: "image", ID: chunk.map((c) => c.ID) });
} }
} }
return items; return items;
}; };
const rowVirtualizer = createVirtualizer({ const rowVirtualizer = createVirtualizer({
count: items().length, count: items().length,
estimateSize: () => 400, estimateSize: () => 400,
getScrollElement: () => scrollRef!, getScrollElement: () => scrollRef!,
overscan: 3, overscan: 3,
}); });
return ( return (
<div <div
ref={scrollRef} ref={scrollRef}
class="flex flex-col gap-4 h-[calc(100% - 12px)] overflow-y-auto" class="flex flex-col gap-4 h-[calc(100% - 12px)] overflow-y-auto"
> >
<div <div
class={`h-[${rowVirtualizer.getTotalSize()}px] grid grid-cols-3 gap-4 rounded-xl border border-neutral-200 bg-white p-4`} class={`h-[${rowVirtualizer.getTotalSize()}px] grid grid-cols-3 gap-4 rounded-xl border border-neutral-200 bg-white p-4`}
> >
<For each={rowVirtualizer.getVirtualItems()}> <For each={rowVirtualizer.getVirtualItems()}>
{(i) => { {(i) => {
const item = items()[i.index]; const item = items()[i.index];
if (item.type === "image") { if (item.type === "image") {
return ( return (
<For each={item.ID}>{(id) => <ImageComponent ID={id} />}</For> <For each={item.ID}>{(id) => <ImageComponent ID={id} onDelete={onDeleteImage} />}</For>
); );
} else { } else {
return ( return (
<h3 class="col-span-3 font-bold text-2xl"> <h3 class="col-span-3 font-bold text-2xl">
{item.date.toDateString()} {item.date.toDateString()}
</h3> </h3>
); );
} }
}} }}
</For> </For>
</div> </div>
</div> </div>
); );
}; };

View File

@ -1,12 +1,13 @@
import { Component, For, createSignal } from "solid-js"; import { Component, For, createSignal } from "solid-js";
import { useSearchImageContext } from "@contexts/SearchImageContext"; import { useSearchImageContext } from "@contexts/SearchImageContext";
import { ListCard } from "@components/list-card";
import { Button } from "@kobalte/core/button"; import { Button } from "@kobalte/core/button";
import { Dialog } from "@kobalte/core/dialog"; import { Dialog } from "@kobalte/core/dialog";
import { createList } from "../../network"; import { createStack, ReachedStackLimit } from "../../network";
import { createToast } from "../../utils/show-toast";
import { StackCard } from "@components/stack-card";
export const Categories: Component = () => { export const Categories: Component = () => {
const { lists, onRefetchImages } = useSearchImageContext(); const { stacks, onRefetchImages } = useSearchImageContext();
const [title, setTitle] = createSignal(""); const [title, setTitle] = createSignal("");
const [description, setDescription] = createSignal(""); const [description, setDescription] = createSignal("");
@ -14,19 +15,22 @@ export const Categories: Component = () => {
const [isCreating, setIsCreating] = createSignal(false); const [isCreating, setIsCreating] = createSignal(false);
const [showForm, setShowForm] = createSignal(false); const [showForm, setShowForm] = createSignal(false);
const handleCreateList = async () => { const handleCreatestack = async () => {
if (description().trim().length === 0 || title().trim().length === 0) if (description().trim().length === 0 || title().trim().length === 0)
return; return;
setIsCreating(true); setIsCreating(true);
try { try {
await createList(title().trim(), description().trim()); await createStack(title().trim(), description().trim());
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setShowForm(false); setShowForm(false);
onRefetchImages(); // Refresh the lists onRefetchImages(); // Refresh the stacks
} catch (error) { } catch (error) {
console.error("Failed to create list:", error); console.error("Failed to create stack:", error);
if (error instanceof ReachedStackLimit) {
createToast("Reached limit!", "You've reached your limit for new stacks");
}
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@ -34,9 +38,9 @@ export const Categories: Component = () => {
return ( return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2"> <div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Generated Lists</h2> <h2 class="text-xl font-bold">Generated stacks</h2>
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4"> <div class="w-full grid grid-cols-1 md:grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
<For each={lists()}>{(list) => <ListCard list={list} />}</For> <For each={stacks()}>{(stack) => <StackCard stack={stack} />}</For>
</div> </div>
<div class="mt-4"> <div class="mt-4">
@ -44,7 +48,7 @@ export const Categories: Component = () => {
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium shadow-sm hover:shadow-md" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium shadow-sm hover:shadow-md"
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
> >
+ Create List + Create stack
</Button> </Button>
</div> </div>
@ -55,25 +59,25 @@ export const Categories: Component = () => {
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"> <Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="p-6"> <div class="p-6">
<Dialog.Title class="text-xl font-bold text-neutral-900 mb-4"> <Dialog.Title class="text-xl font-bold text-neutral-900 mb-4">
Create New List Create New stack
</Dialog.Title> </Dialog.Title>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label <label
for="list-title" for="stack-title"
class="block text-sm font-medium text-neutral-700 mb-2" class="block text-sm font-medium text-neutral-700 mb-2"
> >
List Title stack Title
</label> </label>
<input <input
id="list-title" id="stack-title"
type="text" type="text"
value={title()} value={title()}
onInput={(e) => onInput={(e) =>
setTitle(e.target.value) setTitle(e.target.value)
} }
placeholder="Enter a title for your list" placeholder="Enter a title for your stack"
class="w-full p-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors" class="w-full p-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
disabled={isCreating()} disabled={isCreating()}
/> />
@ -81,18 +85,18 @@ export const Categories: Component = () => {
<div> <div>
<label <label
for="list-description" for="stack-description"
class="block text-sm font-medium text-neutral-700 mb-2" class="block text-sm font-medium text-neutral-700 mb-2"
> >
List Description stack Description
</label> </label>
<textarea <textarea
id="list-description" id="stack-description"
value={description()} value={description()}
onInput={(e) => onInput={(e) =>
setDescription(e.target.value) setDescription(e.target.value)
} }
placeholder="Describe what kind of list you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')" placeholder="Describe what kind of stack you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')"
class="w-full p-3 border border-neutral-300 rounded-lg resize-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors" class="w-full p-3 border border-neutral-300 rounded-lg resize-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
rows="4" rows="4"
disabled={isCreating()} disabled={isCreating()}
@ -103,7 +107,7 @@ export const Categories: Component = () => {
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<Button <Button
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 font-medium shadow-sm hover:shadow-md" class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 font-medium shadow-sm hover:shadow-md"
onClick={handleCreateList} onClick={handleCreatestack}
disabled={ disabled={
isCreating() || isCreating() ||
!title().trim() || !title().trim() ||
@ -112,7 +116,7 @@ export const Categories: Component = () => {
> >
{isCreating() {isCreating()
? "Creating..." ? "Creating..."
: "Create List"} : "Create stack"}
</Button> </Button>
<Button <Button
class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium" class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium"

View File

@ -5,24 +5,24 @@ import { useSearchImageContext } from "@contexts/SearchImageContext";
const NUMBER_OF_MAX_RECENT_IMAGES = 10; const NUMBER_OF_MAX_RECENT_IMAGES = 10;
export const Recent: Component = () => { export const Recent: Component = () => {
const { userImages } = useSearchImageContext(); const { userImages, onDeleteImage } = useSearchImageContext();
const latestImages = () => const latestImages = () =>
userImages() userImages()
.sort( .sort(
(a, b) => (a, b) =>
new Date(b.CreatedAt!).getTime() - new Date(a.CreatedAt!).getTime(), new Date(b.CreatedAt!).getTime() - new Date(a.CreatedAt!).getTime(),
) )
.slice(0, NUMBER_OF_MAX_RECENT_IMAGES); .slice(0, NUMBER_OF_MAX_RECENT_IMAGES);
return ( return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2"> <div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Recent Screenshots</h2> <h2 class="text-xl font-bold">Recent Screenshots</h2>
<div class="grid grid-cols-3 gap-4 place-items-center"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 place-items-center">
<For each={latestImages()}> <For each={latestImages()}>
{(image) => <ImageComponent ID={image.ImageID} />} {(image) => <ImageComponent ID={image.ID} onDelete={onDeleteImage} />}
</For> </For>
</div> </div>
</div> </div>
); );
}; };

View File

@ -1,38 +1,42 @@
import { ImageComponentFullHeight } from "@components/image"; import { ImageComponentFullHeight } from "@components/image";
import { StackCard } from "@components/stack-card";
import { useSearchImageContext } from "@contexts/SearchImageContext"; import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useParams } from "@solidjs/router"; import { useNavigate, useParams } from "@solidjs/router";
import { For, type Component } from "solid-js"; import { For, type Component } from "solid-js";
import SolidjsMarkdown from "solidjs-markdown"; import SolidjsMarkdown from "solidjs-markdown";
import { ListCard } from "@components/list-card";
export const ImagePage: Component = () => { export const ImagePage: Component = () => {
const { imageId } = useParams<{ imageId: string }>(); const { imageId } = useParams<{ imageId: string }>();
const nav = useNavigate();
const { userImages, lists } = useSearchImageContext(); const { userImages, stacks, onDeleteImage } = useSearchImageContext();
const image = () => userImages().find((i) => i.ImageID === imageId); const image = () => userImages().find((i) => i.ID === imageId);
return ( return (
<main class="flex flex-col items-center gap-4"> <main class="flex flex-col items-center gap-4">
<div class="w-full bg-white rounded-xl p-4"> <div class="w-full bg-white rounded-xl p-4">
<ImageComponentFullHeight ID={imageId} /> <ImageComponentFullHeight ID={imageId} onDelete={(id) => {
</div> onDeleteImage(id);
<div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4"> nav("/");
<h2 class="font-bold text-2xl">Description</h2> }} />
<div class="grid grid-cols-3 gap-4"> </div>
<For each={image()?.Image.ImageLists}> <div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4">
{(imageList) => ( <h2 class="font-bold text-2xl">Stacks</h2>
<ListCard <div class="grid grid-cols-3 gap-4">
list={lists().find((l) => l.ID === imageList.ListID)!} <For each={image()?.ImageStacks}>
/> {(imageList) => (
)} <StackCard
</For> stack={stacks().find((l) => l.ID === imageList.StackID)!}
</div> />
</div> )}
<div class="w-full bg-white rounded-xl p-4"> </For>
<h2 class="font-bold text-2xl">Description</h2> </div>
<SolidjsMarkdown>{image()?.Image.Description}</SolidjsMarkdown> </div>
</div> <div class="w-full bg-white rounded-xl p-4">
</main> <h2 class="font-bold text-2xl">Description</h2>
); <SolidjsMarkdown>{image()?.Description}</SolidjsMarkdown>
</div>
</main>
);
}; };

View File

@ -4,4 +4,4 @@ export * from "./settings";
export * from "./login"; export * from "./login";
export * from "./search"; export * from "./search";
export * from "./all-images"; export * from "./all-images";
export * from "./list"; export * from "./stack";

View File

@ -1,107 +0,0 @@
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useParams } from "@solidjs/router";
import { Component, For, Show } from "solid-js";
import { base } from "../../network";
export const List: Component = () => {
const { listId } = useParams();
const { lists } = useSearchImageContext();
const list = () => lists().find((l) => l.ID === listId);
return (
<Show when={list()} fallback="List could not be found">
{(l) => (
<div class="w-full h-full bg-white rounded-lg shadow-sm border border-neutral-200 overflow-hidden">
<div class="overflow-x-auto overflow-y-auto h-full">
<table class="w-full min-w-full">
<thead class="bg-neutral-50 border-b border-neutral-200 sticky top-0 z-10">
<tr>
<th class="px-6 py-4 text-left text-sm font-semibold text-neutral-900 border-r border-neutral-200 min-w-40">
Image
</th>
<For each={l().Schema.SchemaItems}>
{(item, index) => (
<th
class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${
index() <
l().Schema.SchemaItems
.length -
1
? "border-r border-neutral-200"
: ""
}`}
>
{item.Item}
</th>
)}
</For>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200">
<For each={l().Images}>
{(image, rowIndex) => (
<tr
class={`hover:bg-neutral-50 transition-colors ${
rowIndex() % 2 === 0
? "bg-white"
: "bg-neutral-25"
}`}
>
<td class="px-6 py-4 border-r border-neutral-200">
<div class="w-32 h-24 overflow-hidden rounded-lg">
<a
href={`/image/${image.ImageID}`}
class="w-full h-full flex justify-center"
>
<img
class="w-full h-full object-cover rounded-lg"
src={`${base}/images/${image.ImageID}`}
alt="List item"
/>
</a>
</div>
</td>
<For each={image.Items}>
{(item, colIndex) => (
<td
class={`px-6 py-4 text-sm text-neutral-700 ${
colIndex() <
image.Items.length -
1
? "border-r border-neutral-200"
: ""
}`}
>
<div
class="max-w-xs truncate"
title={item.Value}
>
{item.Value}
</div>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={l().Images.length === 0}>
<div class="px-6 py-12 text-center text-neutral-500">
<p class="text-lg">
No images in this list yet
</p>
<p class="text-sm mt-1">
Images will appear here once added to the
list
</p>
</div>
</Show>
</div>
</div>
)}
</Show>
);
};

View File

@ -4,45 +4,48 @@ import { IconSearch } from "@tabler/icons-solidjs";
import { useSearch } from "./search"; import { useSearch } from "./search";
import { JustTheImageWhatAreTheseNames } from "@network/index"; import { JustTheImageWhatAreTheseNames } from "@network/index";
import { ImageComponent } from "@components/image"; import { ImageComponent } from "@components/image";
import { useSearchImageContext } from "@contexts/SearchImageContext";
export const SearchPage: Component = () => { export const SearchPage: Component = () => {
const fuse = useSearch(); const fuse = useSearch();
const [searchItems, setSearchItems] = const { onDeleteImage } = useSearchImageContext();
createSignal<JustTheImageWhatAreTheseNames>([]);
return ( const [searchItems, setSearchItems] =
<Search createSignal<JustTheImageWhatAreTheseNames>([]);
options={searchItems()}
onInputChange={(e) => { return (
setSearchItems( <Search
fuse() options={searchItems()}
.search(e) onInputChange={(e) => {
.map((i) => i.item), setSearchItems(
); fuse()
}} .search(e)
> .map((i) => i.item),
<Search.Label /> );
<Search.Control class="flex"> }}
<Search.Indicator class="bg-neutral-200 p-4 rounded-l-xl"> >
<Search.Icon> <Search.Label />
<IconSearch /> <Search.Control class="flex">
</Search.Icon> <Search.Indicator class="bg-neutral-200 p-4 rounded-l-xl">
</Search.Indicator> <Search.Icon>
<Search.Input <IconSearch />
class="w-full p-4 font-bold text-xl rounded-r-xl" </Search.Icon>
placeholder="Woking Station..." </Search.Indicator>
/> <Search.Input
</Search.Control> class="w-full p-4 font-bold text-xl rounded-r-xl"
<Search.Portal> placeholder="Woking Station..."
<Search.Content class="container relative w-full rounded-xl bg-white p-4 grid grid-cols-3 gap-4"> />
<Search.Arrow /> </Search.Control>
<For each={searchItems()}> <Search.Portal>
{(item) => <ImageComponent ID={item.ImageID} />} <Search.Content class="container relative w-full rounded-xl bg-white p-4 grid grid-cols-3 gap-4">
</For> <Search.Arrow />
<Search.NoResult>No result found</Search.NoResult> <For each={searchItems()}>
</Search.Content> {(item) => <ImageComponent ID={item.ID} onDelete={onDeleteImage} />}
</Search.Portal> </For>
</Search> <Search.NoResult>No result found</Search.NoResult>
); </Search.Content>
</Search.Portal>
</Search>
);
}; };

View File

@ -2,11 +2,11 @@ import { useSearchImageContext } from "@contexts/SearchImageContext";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
export const useSearch = () => { export const useSearch = () => {
const { userImages } = useSearchImageContext(); const { userImages } = useSearchImageContext();
return () => return () =>
new Fuse(userImages(), { new Fuse(userImages(), {
shouldSort: true, shouldSort: true,
keys: ["Image.Description"], keys: ["Description"],
}); });
}; };

View File

@ -0,0 +1,304 @@
import { useParams, useNavigate } from "@solidjs/router";
import {
Component,
For,
Show,
Suspense,
createResource,
createSignal,
} from "solid-js";
import { base, getAccessToken } from "../../network";
import { Dialog } from "@kobalte/core";
import { useSearchImageContext } from "@contexts/SearchImageContext";
const DeleteButton: Component<{ onDelete: () => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
return (
<>
<button
aria-label="Delete image from list"
class="text-white bg-red-600 hover:bg-red-700 rounded px-2 py-1 text-sm"
onClick={() => setIsOpen(true)}
>
×
</button>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black bg-opacity-50" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<Dialog.Title class="text-lg font-bold mb-2">
Confirm Delete
</Dialog.Title>
<Dialog.Description class="mb-4">
Are you sure you want to delete this image from
this list?
</Dialog.Description>
<div class="flex justify-end gap-2">
<Dialog.CloseButton>
<button class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">
Cancel
</button>
</Dialog.CloseButton>
<button
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
onClick={props.onDelete}
>
Confirm
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
</>
);
};
const DeleteStackButton: Component<{ onDelete: () => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
return (
<>
<button
aria-label="Delete list"
class="text-white bg-red-600 hover:bg-red-700 rounded px-3 py-2 text-sm font-medium"
onClick={() => setIsOpen(true)}
>
Delete Stack
</button>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black bg-opacity-50" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<Dialog.Title class="text-lg font-bold mb-2">
Confirm Delete Stack
</Dialog.Title>
<Dialog.Description class="mb-4">
Are you sure you want to delete this entire
list? This action cannot be undone.
</Dialog.Description>
<div class="flex justify-end gap-2">
<Dialog.CloseButton>
<button class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">
Cancel
</button>
</Dialog.CloseButton>
<button
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
onClick={props.onDelete}
>
Delete Stack
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
</>
);
};
const DeleteStackItemButton: Component<{ onDelete: () => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
return (
<>
<button
aria-label="Delete schema item"
class="text-gray-500 hover:text-red-700 text-sm"
onClick={() => setIsOpen(true)}
>
×
</button>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black bg-opacity-50" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<Dialog.Title class="text-lg font-bold mb-2">
Confirm Delete
</Dialog.Title>
<Dialog.Description class="mb-4">
Are you sure you want to delete this column from
this list?
</Dialog.Description>
<div class="flex justify-end gap-2">
<Dialog.CloseButton>
<button class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">
Cancel
</button>
</Dialog.CloseButton>
<button
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
onClick={props.onDelete}
>
Confirm
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
</>
);
};
export const Stack: Component = () => {
const { stackID } = useParams();
const nav = useNavigate();
const { stacks, onDeleteImageFromStack, onDeleteStack, onDeleteStackItem } =
useSearchImageContext();
const [accessToken] = createResource(getAccessToken);
const stack = () => stacks().find((l) => l.ID === stackID);
const handleDeleteStack = async () => {
onDeleteStack(stackID);
nav("/");
};
return (
<Suspense>
<Show when={stack()} fallback="Stack could not be found">
{(s) => (
<div class="w-full h-full bg-white rounded-lg shadow-sm border border-neutral-200 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-neutral-900">
{s().Name}
</h1>
<Show when={s().Description}>
<p class="text-sm text-neutral-600 mt-1">
{s().Description}
</p>
</Show>
</div>
<DeleteStackButton
onDelete={handleDeleteStack}
/>
</div>
</div>
<div
class="overflow-x-auto overflow-y-auto"
style="height: calc(100% - 80px);"
>
<table class="w-full min-w-full">
<thead class="bg-neutral-50 border-b border-neutral-200 sticky top-0 z-10">
<tr>
<th class="px-6 py-4 text-left text-sm font-semibold text-neutral-900 border-r border-neutral-200 min-w-40">
Image
</th>
<For each={s().SchemaItems}>
{(item, index) => (
<th
class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${
index() <
s().SchemaItems.length -
1
? "border-r border-neutral-200"
: ""
}`}
>
<div class="flex items-center gap-2">
{item.Item}
<DeleteStackItemButton
onDelete={() =>
onDeleteStackItem(
s().ID,
item.ID,
)
}
/>
</div>
</th>
)}
</For>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200">
<For each={s().Images}>
{(image, rowIndex) => (
<tr
class={`hover:bg-neutral-50 transition-colors ${
rowIndex() % 2 === 0
? "bg-white"
: "bg-neutral-25"
}`}
>
<td class="px-6 py-4 border-r border-neutral-200">
<div class="flex items-center gap-2">
<a
href={`/image/${image.ImageID}`}
class="w-32 h-24 flex justify-center rounded-lg overflow-hidden"
>
<img
class="w-full h-full object-cover rounded-lg"
src={`${base}/images/${image.ImageID}?token=${accessToken()}`}
alt="Stack item"
/>
</a>
<DeleteButton
onDelete={() =>
onDeleteImageFromStack(
s().ID,
image.ImageID,
)
}
/>
</div>
</td>
<For each={image.Items}>
{(item, colIndex) => (
<td
class={`px-6 py-4 text-sm text-neutral-700 ${
colIndex() <
image.Items
.length -
1
? "border-r border-neutral-200"
: ""
}`}
>
<div
class="max-w-xs truncate"
title={
item.Value
}
>
{item.Value}
</div>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={s().Images.length === 0}>
<div class="px-6 py-12 text-center text-neutral-500">
<p class="text-lg">
No images in this list yet
</p>
<p class="text-sm mt-1">
Images will appear here once added to
the list
</p>
</div>
</Show>
</div>
</div>
)}
</Show>
</Suspense>
);
};

View File

@ -0,0 +1,37 @@
import { Toast, toaster } from "@kobalte/core/toast";
import { IconCircleDashedX } from "@tabler/icons-solidjs";
export const createToast = (title: string, text: string) => {
console.log("creating toast")
toaster.show((props) => (
<Toast
toastId={props.toastId}
class="max-w-lg w-full bg-white shadow-lg rounded-lg pointer-events-auto flex ring-1 ring-black ring-opacity-5"
>
<div class="flex-1 w-0 p-4">
<div class="flex items-start">
<div class="flex-shrink-0 pt-0.5">
<IconCircleDashedX class="h-6 w-6 text-red-600" />
</div>
<div class="ml-3 flex-1">
<Toast.Title class="text-sm font-medium text-gray-900">
{title}
</Toast.Title>
<Toast.Description class="mt-1 text-sm text-gray-500">
{text}
</Toast.Description>
</div>
</div>
</div>
<div class="flex border-l border-gray-200">
<Toast.CloseButton class="w-full border border-transparent rounded-none rounded-r-lg p-4 flex items-center justify-center text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<span class="sr-only">Close</span>
<IconCircleDashedX class="h-5 w-5" aria-hidden="true" />
</Toast.CloseButton>
</div>
<Toast.ProgressTrack class="absolute bottom-0 left-0 right-0 h-1 bg-gray-200 rounded-b-lg overflow-hidden">
<Toast.ProgressFill class="h-full bg-indigo-600 transition-all duration-300" />
</Toast.ProgressTrack>
</Toast>
));
};

View File

@ -24,6 +24,7 @@ class SharedToken: Plugin {
} }
sharedDefaults.set(token, forKey: sharedTokenKey) sharedDefaults.set(token, forKey: sharedTokenKey)
sharedDefaults.synchronize()
invoke.resolve() invoke.resolve()
} }
} }