Compare commits
55 Commits
de96f12b55
...
main
Author | SHA1 | Date | |
---|---|---|---|
106d3b1fa1 | |||
b9f6b77286 | |||
3c8fd843e6 | |||
e61af3007f | |||
3594baceb5 | |||
d534779fad | |||
a776c88cab | |||
72de7c7648 | |||
a8b150857c | |||
dd4f508346 | |||
f21ee57632 | |||
0e42c9002b | |||
9e60a41f0a | |||
eaff553dc9 | |||
6880811236 | |||
38bda46dcf | |||
bd86ad499b | |||
838ab37fc1 | |||
9948d2521b | |||
64abf79f9c | |||
0d41a65435 | |||
ecd1529130 | |||
015a7cb5cd | |||
980b42aa44 | |||
649cfe0b02 | |||
1fb9616aa7 | |||
013447fa90 | |||
221afb599b | |||
f8619d3ef7 | |||
f6393c9a59 | |||
561064a194 | |||
3015d7bac2 | |||
a3345afbfa | |||
f078ac7d0b | |||
e28d9e5d16 | |||
29c56bee1c | |||
3ebc0810e7 | |||
0c595f76a3 | |||
176d2b0bd4 | |||
115d08a245 | |||
b4b600bd7c | |||
ce2cd977ac | |||
8b6b9453a8 | |||
2dd9f33303 | |||
94ee8bdb7e | |||
5d1c758451 | |||
00359e2e8d | |||
95330c163b | |||
84a0996be9 | |||
48579267b5 | |||
8b54d502f2 | |||
e45688d57e | |||
f7c9c97f0a | |||
76924a0332 | |||
d97593d487 |
@ -9,11 +9,15 @@ package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
UserID uuid.UUID
|
||||
ImageName string
|
||||
Description string
|
||||
Status Progress
|
||||
Image []byte
|
||||
CreatedAt *time.Time
|
||||
}
|
||||
|
@ -11,8 +11,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ImageLists struct {
|
||||
type ImageStacks struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
ImageID uuid.UUID
|
||||
ListID uuid.UUID
|
||||
StackID uuid.UUID
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -16,5 +16,5 @@ type SchemaItems struct {
|
||||
Item string
|
||||
Value string
|
||||
Description string
|
||||
SchemaID uuid.UUID
|
||||
StackID uuid.UUID
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -12,9 +12,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Lists struct {
|
||||
type Stacks struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
UserID uuid.UUID
|
||||
Status Progress
|
||||
Name string
|
||||
Description string
|
||||
CreatedAt *time.Time
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -9,9 +9,11 @@ package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Users struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
Email string
|
||||
CreatedAt *time.Time
|
||||
}
|
||||
|
@ -18,13 +18,15 @@ type imageTable struct {
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
ImageName postgres.ColumnString
|
||||
Description postgres.ColumnString
|
||||
Image postgres.ColumnBytea
|
||||
Status postgres.ColumnString
|
||||
Image postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ImageTable struct {
|
||||
@ -63,12 +65,14 @@ func newImageTable(schemaName, tableName, alias string) *ImageTable {
|
||||
func newImageTableImpl(schemaName, tableName, alias string) imageTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
ImageNameColumn = postgres.StringColumn("image_name")
|
||||
DescriptionColumn = postgres.StringColumn("description")
|
||||
ImageColumn = postgres.ByteaColumn("image")
|
||||
allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, DescriptionColumn, ImageColumn}
|
||||
mutableColumns = postgres.ColumnList{ImageNameColumn, DescriptionColumn, ImageColumn}
|
||||
defaultColumns = postgres.ColumnList{IDColumn}
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
ImageColumn = postgres.StringColumn("image")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, ImageNameColumn, DescriptionColumn, StatusColumn, ImageColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{UserIDColumn, ImageNameColumn, DescriptionColumn, StatusColumn, ImageColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return imageTable{
|
||||
@ -76,12 +80,14 @@ func newImageTableImpl(schemaName, tableName, alias string) imageTable {
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
UserID: UserIDColumn,
|
||||
ImageName: ImageNameColumn,
|
||||
Description: DescriptionColumn,
|
||||
Status: StatusColumn,
|
||||
Image: ImageColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
@ -24,7 +24,6 @@ type imageSchemaItemsTable struct {
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ImageSchemaItemsTable struct {
|
||||
@ -68,7 +67,6 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche
|
||||
ImageIDColumn = postgres.StringColumn("image_id")
|
||||
allColumns = postgres.ColumnList{IDColumn, ValueColumn, SchemaItemIDColumn, ImageIDColumn}
|
||||
mutableColumns = postgres.ColumnList{ValueColumn, SchemaItemIDColumn, ImageIDColumn}
|
||||
defaultColumns = postgres.ColumnList{IDColumn}
|
||||
)
|
||||
|
||||
return imageSchemaItemsTable{
|
||||
@ -82,6 +80,5 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
|
81
backend/.gen/haystack/haystack/table/image_stacks.go
Normal file
81
backend/.gen/haystack/haystack/table/image_stacks.go
Normal file
@ -0,0 +1,81 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var 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,
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -21,11 +21,10 @@ type schemaItemsTable struct {
|
||||
Item postgres.ColumnString
|
||||
Value postgres.ColumnString
|
||||
Description postgres.ColumnString
|
||||
SchemaID postgres.ColumnString
|
||||
StackID postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type SchemaItemsTable struct {
|
||||
@ -67,10 +66,9 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab
|
||||
ItemColumn = postgres.StringColumn("item")
|
||||
ValueColumn = postgres.StringColumn("value")
|
||||
DescriptionColumn = postgres.StringColumn("description")
|
||||
SchemaIDColumn = postgres.StringColumn("schema_id")
|
||||
allColumns = postgres.ColumnList{IDColumn, ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn}
|
||||
mutableColumns = postgres.ColumnList{ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn}
|
||||
defaultColumns = postgres.ColumnList{IDColumn}
|
||||
StackIDColumn = postgres.StringColumn("stack_id")
|
||||
allColumns = postgres.ColumnList{IDColumn, ItemColumn, ValueColumn, DescriptionColumn, StackIDColumn}
|
||||
mutableColumns = postgres.ColumnList{ItemColumn, ValueColumn, DescriptionColumn, StackIDColumn}
|
||||
)
|
||||
|
||||
return schemaItemsTable{
|
||||
@ -81,10 +79,9 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab
|
||||
Item: ItemColumn,
|
||||
Value: ValueColumn,
|
||||
Description: DescriptionColumn,
|
||||
SchemaID: SchemaIDColumn,
|
||||
StackID: StackIDColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
90
backend/.gen/haystack/haystack/table/stacks.go
Normal file
90
backend/.gen/haystack/haystack/table/stacks.go
Normal 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,
|
||||
}
|
||||
}
|
@ -11,14 +11,9 @@ package table
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
Image = Image.FromSchema(schema)
|
||||
ImageLists = ImageLists.FromSchema(schema)
|
||||
ImageSchemaItems = ImageSchemaItems.FromSchema(schema)
|
||||
Lists = Lists.FromSchema(schema)
|
||||
Logs = Logs.FromSchema(schema)
|
||||
ProcessingLists = ProcessingLists.FromSchema(schema)
|
||||
ImageStacks = ImageStacks.FromSchema(schema)
|
||||
SchemaItems = SchemaItems.FromSchema(schema)
|
||||
Schemas = Schemas.FromSchema(schema)
|
||||
UserImages = UserImages.FromSchema(schema)
|
||||
UserImagesToProcess = UserImagesToProcess.FromSchema(schema)
|
||||
Stacks = Stacks.FromSchema(schema)
|
||||
Users = Users.FromSchema(schema)
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -19,10 +19,10 @@ type usersTable struct {
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
Email postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type UsersTable struct {
|
||||
@ -62,9 +62,9 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
EmailColumn = postgres.StringColumn("email")
|
||||
allColumns = postgres.ColumnList{IDColumn, EmailColumn}
|
||||
mutableColumns = postgres.ColumnList{EmailColumn}
|
||||
defaultColumns = postgres.ColumnList{IDColumn}
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{IDColumn, EmailColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{EmailColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return usersTable{
|
||||
@ -73,9 +73,9 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable {
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
Email: EmailColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
|
@ -246,7 +246,7 @@ func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageNa
|
||||
request := AgentRequestBody{
|
||||
Tools: &tools,
|
||||
ToolChoice: &toolChoice,
|
||||
Model: "google/gemini-2.5-flash",
|
||||
Model: "policy/images",
|
||||
RandomSeed: &seed,
|
||||
Temperature: 0.3,
|
||||
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)
|
||||
|
||||
toolHandlerInfo := ToolHandlerInfo{
|
||||
ImageId: imageId,
|
||||
ImageID: imageId,
|
||||
ImageName: imageName,
|
||||
UserId: userId,
|
||||
Image: &imageData,
|
||||
@ -284,7 +284,7 @@ func (client *AgentClient) RunAgentAlone(userID uuid.UUID, userReq string) error
|
||||
request := AgentRequestBody{
|
||||
Tools: &tools,
|
||||
ToolChoice: &toolChoice,
|
||||
Model: "google/gemini-2.5-flash",
|
||||
Model: "policy/images",
|
||||
RandomSeed: &seed,
|
||||
Temperature: 0.3,
|
||||
EndToolCall: client.Options.EndToolCall,
|
||||
|
@ -9,7 +9,7 @@ import (
|
||||
|
||||
type ToolHandlerInfo struct {
|
||||
UserId uuid.UUID
|
||||
ImageId uuid.UUID
|
||||
ImageID uuid.UUID
|
||||
ImageName string
|
||||
|
||||
// Pointer because we don't want to copy this around too much.
|
||||
|
@ -40,7 +40,7 @@ func (suite *ToolTestSuite) TestSingleToolCall() {
|
||||
response := suite.handler.Handle(
|
||||
ToolHandlerInfo{
|
||||
UserId: uuid.Nil,
|
||||
ImageId: uuid.Nil,
|
||||
ImageID: uuid.Nil,
|
||||
},
|
||||
ToolCall{
|
||||
Index: 0,
|
||||
@ -91,7 +91,7 @@ func (suite *ToolTestSuite) TestMultipleToolCalls() {
|
||||
err := suite.client.Process(
|
||||
ToolHandlerInfo{
|
||||
UserId: uuid.Nil,
|
||||
ImageId: uuid.Nil,
|
||||
ImageID: uuid.Nil,
|
||||
},
|
||||
&AgentRequestBody{
|
||||
Chat: &chat,
|
||||
@ -154,7 +154,7 @@ func (suite *ToolTestSuite) TestMultipleToolCallsWithErrors() {
|
||||
err := suite.client.Process(
|
||||
ToolHandlerInfo{
|
||||
UserId: uuid.Nil,
|
||||
ImageId: uuid.Nil,
|
||||
ImageID: uuid.Nil,
|
||||
},
|
||||
&AgentRequestBody{
|
||||
Chat: &chat,
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/models"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"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 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 = `
|
||||
@ -76,15 +79,15 @@ type createNewListArguments struct {
|
||||
type CreateListAgent struct {
|
||||
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{
|
||||
Model: "google/gemini-2.5-flash",
|
||||
Model: "policy/images",
|
||||
Temperature: 0.3,
|
||||
ResponseFormat: client.ResponseFormat{
|
||||
Type: "json_object",
|
||||
Type: "json_schema",
|
||||
JsonSchema: listJsonSchema,
|
||||
},
|
||||
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.AddUser(userReq)
|
||||
|
||||
req := fmt.Sprintf("List title: %s | Users list description: %s", title, userReq)
|
||||
|
||||
request.Chat.AddUser(req)
|
||||
|
||||
resp, err := agent.client.Request(&request)
|
||||
if err != nil {
|
||||
@ -102,10 +108,16 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
|
||||
|
||||
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
|
||||
err = json.Unmarshal([]byte(structuredOutput), &createListArgs)
|
||||
err = json.Unmarshal([]byte(content), &createListArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -113,6 +125,8 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
|
||||
schemaItems := make([]model.SchemaItems, 0)
|
||||
for _, field := range createListArgs.Fields {
|
||||
schemaItems = append(schemaItems, model.SchemaItems{
|
||||
StackID: stackID,
|
||||
|
||||
Item: field.Name,
|
||||
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
|
||||
}
|
||||
|
||||
func NewCreateListAgent(log *log.Logger, listModel models.ListModel) CreateListAgent {
|
||||
func NewCreateListAgent(log *log.Logger, listModel models.StackModel) CreateListAgent {
|
||||
client := client.CreateAgentClient(client.CreateAgentClientOptions{
|
||||
SystemPrompt: createListAgentPrompt,
|
||||
Log: log,
|
||||
|
@ -26,9 +26,9 @@ type DescriptionAgent struct {
|
||||
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{
|
||||
Model: "google/gemini-2.5-flash-lite-preview-06-17",
|
||||
Model: "policy/images",
|
||||
Temperature: 0.3,
|
||||
ResponseFormat: client.ResponseFormat{
|
||||
Type: "text",
|
||||
@ -49,9 +49,9 @@ func (agent DescriptionAgent) Describe(log *log.Logger, imageId uuid.UUID, image
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
@ -3,8 +3,10 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/limits"
|
||||
"screenmark/screenmark/models"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
@ -174,7 +176,7 @@ type addToListArguments struct {
|
||||
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{
|
||||
SystemPrompt: listPrompt,
|
||||
JsonTools: listTools,
|
||||
@ -193,21 +195,38 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
savedList, err := listModel.Save(ctx, info.UserId, args.Name, args.Desription, args.Schema)
|
||||
|
||||
hasReachedLimit, err := limitsMethods.HasReachedStackLimit(info.UserId)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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) {
|
||||
@ -219,12 +238,17 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
listUuid, err := uuid.Parse(args.ListID)
|
||||
listUUID, err := uuid.Parse(args.ListID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,8 @@ type AuthHandler struct {
|
||||
user models.UserModel
|
||||
|
||||
auth Auth
|
||||
|
||||
jwtManager *middleware.JwtManager
|
||||
}
|
||||
|
||||
type loginBody struct {
|
||||
@ -34,6 +36,14 @@ type codeReturn struct {
|
||||
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) {
|
||||
err := h.auth.CreateCode(body.Email)
|
||||
if err != nil {
|
||||
@ -65,8 +75,8 @@ func (h *AuthHandler) code(body codeBody, w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
refresh := middleware.CreateRefreshToken(uuid)
|
||||
access := middleware.CreateAccessToken(uuid)
|
||||
refresh := h.jwtManager.CreateRefreshToken(uuid)
|
||||
access := h.jwtManager.CreateAccessToken(uuid)
|
||||
|
||||
codeReturn := codeReturn{
|
||||
Access: access,
|
||||
@ -76,6 +86,23 @@ func (h *AuthHandler) code(body codeBody, w http.ResponseWriter, r *http.Request
|
||||
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) {
|
||||
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("/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)
|
||||
logger := log.New(os.Stdout).WithPrefix("Auth")
|
||||
|
||||
@ -99,8 +127,9 @@ func CreateAuthHandler(db *sql.DB) AuthHandler {
|
||||
auth := CreateAuth(mailer)
|
||||
|
||||
return AuthHandler{
|
||||
logger,
|
||||
userModel,
|
||||
auth,
|
||||
logger: logger,
|
||||
user: userModel,
|
||||
auth: auth,
|
||||
jwtManager: jwtManager,
|
||||
}
|
||||
}
|
||||
|
@ -1,307 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"screenmark/screenmark/agents"
|
||||
"screenmark/screenmark/middleware"
|
||||
"screenmark/screenmark/models"
|
||||
"screenmark/screenmark/notifications"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"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.
|
||||
* We never close these channels.
|
||||
*
|
||||
* 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
|
||||
|
||||
userSplitters := make(map[string]*ChannelSplitter[Notification])
|
||||
userSplitters := make(map[string]*notifications.ChannelSplitter[notifications.Notification])
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
_userId := r.Context().Value(middleware.USER_ID).(uuid.UUID)
|
||||
@ -324,7 +43,7 @@ func CreateEventsHandler(notifier *Notifier[Notification]) http.HandlerFunc {
|
||||
userNotifications := notifier.Listeners[userId]
|
||||
|
||||
if _, exists := userSplitters[userId]; !exists {
|
||||
splitter := NewChannelSplitter(userNotifications)
|
||||
splitter := notifications.NewChannelSplitter(userNotifications)
|
||||
|
||||
userSplitters[userId] = &splitter
|
||||
splitter.Listen()
|
||||
|
@ -10,38 +10,61 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/limits"
|
||||
"screenmark/screenmark/middleware"
|
||||
"screenmark/screenmark/models"
|
||||
"screenmark/screenmark/processor"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ImageHandler struct {
|
||||
logger *log.Logger
|
||||
|
||||
imageModel models.ImageModel
|
||||
userModel models.UserModel
|
||||
|
||||
limitsManager limits.LimitsManagerMethods
|
||||
|
||||
jwtManager *middleware.JwtManager
|
||||
|
||||
processor *processor.Processor[model.Image]
|
||||
}
|
||||
|
||||
type ImagesReturn struct {
|
||||
UserImages []models.UserImageWithImage `json:"userImages"`
|
||||
ProcessingImages []models.UserProcessingImage `json:"processingImages"`
|
||||
Lists []models.ListsWithImages `json:"lists"`
|
||||
UserImages []models.UserImageWithImage
|
||||
Stacks []models.ListsWithImages
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "Could not get image")
|
||||
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
|
||||
extension := filepath.Ext(image.ImageName)
|
||||
if len(extension) == 0 {
|
||||
@ -66,13 +89,7 @@ func (h *ImageHandler) listImages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
processingImages, err := h.imageModel.GetProcessing(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)
|
||||
stacksWithImages, err := h.userModel.ListWithImages(r.Context(), userId)
|
||||
if err != nil {
|
||||
middleware.WriteErrorInternal(h.logger, "could not get lists with images", w)
|
||||
return
|
||||
@ -80,8 +97,7 @@ func (h *ImageHandler) listImages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
imagesReturn := ImagesReturn{
|
||||
UserImages: images,
|
||||
ProcessingImages: processingImages,
|
||||
Lists: listsWithImages,
|
||||
Stacks: stacksWithImages,
|
||||
}
|
||||
|
||||
middleware.WriteJsonOrError(h.logger, imagesReturn, w)
|
||||
@ -94,7 +110,7 @@ func (h *ImageHandler) uploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := middleware.GetUserID(r.Context(), h.logger, w)
|
||||
userID, err := middleware.GetUserID(r.Context(), h.logger, w)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -127,37 +143,80 @@ func (h *ImageHandler) uploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.WriteErrorBadRequest(h.logger, "unsupported content type, need octet-stream or base64", w)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
userImage, err := h.imageModel.Process(r.Context(), userId, model.Image{
|
||||
Image: image,
|
||||
ImageName: imageName,
|
||||
})
|
||||
|
||||
newImage, err := h.imageModel.Save(ctx, imageName, image, userID)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
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.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.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)
|
||||
userModel := models.NewUserModel(db)
|
||||
logger := log.New(os.Stdout).WithPrefix("Images")
|
||||
@ -166,5 +225,8 @@ func CreateImageHandler(db *sql.DB) ImageHandler {
|
||||
logger: logger,
|
||||
imageModel: imageModel,
|
||||
userModel: userModel,
|
||||
limitsManager: limitsManager,
|
||||
jwtManager: jwtManager,
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
@ -62,6 +62,7 @@ type TestContext struct {
|
||||
server *httptest.Server
|
||||
users []TestUser
|
||||
cleanup func()
|
||||
jwtManager *middleware.JwtManager
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
router := setupRouter(db)
|
||||
jwtManager := middleware.NewJwtManager([]byte("test-jwt-secret"))
|
||||
router, err := setupRouter(db, jwtManager)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(router)
|
||||
|
||||
tc.db = db
|
||||
tc.router = router
|
||||
tc.server = server
|
||||
tc.jwtManager = jwtManager
|
||||
tc.cleanup = func() {
|
||||
server.Close()
|
||||
cleanup()
|
||||
@ -202,7 +209,7 @@ func (tc *TestContext) createTestUser(email string) TestUser {
|
||||
}
|
||||
|
||||
// Create access token for the user
|
||||
accessToken := middleware.CreateAccessToken(userID)
|
||||
accessToken := tc.jwtManager.CreateAccessToken(userID)
|
||||
|
||||
user := TestUser{
|
||||
ID: userID,
|
||||
|
61
backend/limits/limits.go
Normal file
61
backend/limits/limits.go
Normal 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,
|
||||
}
|
||||
}
|
@ -1,21 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"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/google/uuid"
|
||||
"github.com/muesli/termenv"
|
||||
@ -31,12 +21,6 @@ func (w *DatabaseWriter) Write(p []byte) (n int, err error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
insertLogStmt := Logs.
|
||||
INSERT(Logs.Log, Logs.ImageID).
|
||||
VALUES(string(p), w.imageId)
|
||||
|
||||
_, err = insertLogStmt.Exec(w.dbPool)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
} 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 {
|
||||
return &DatabaseWriter{
|
||||
dbPool: dbPool,
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"screenmark/screenmark/middleware"
|
||||
"screenmark/screenmark/models"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@ -15,12 +16,22 @@ func main() {
|
||||
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()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
router := setupRouter(db)
|
||||
router, err := setupRouter(db, jwtManager)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
port, exists := os.LookupEnv("PORT")
|
||||
if !exists {
|
||||
|
@ -18,29 +18,33 @@ const (
|
||||
type JwtClaims struct {
|
||||
UserID string
|
||||
Type JwtType
|
||||
Expire time.Time
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
// obviously this is very not secure. TODO: extract to env
|
||||
var JWT_SECRET = []byte("very secret")
|
||||
type JwtManager struct {
|
||||
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{
|
||||
"UserID": claims.UserID,
|
||||
"Type": claims.Type,
|
||||
"Expire": claims.Expire,
|
||||
"exp": claims.Expiry.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func CreateRefreshToken(userId uuid.UUID) string {
|
||||
token := createToken(JwtClaims{
|
||||
func (jm *JwtManager) CreateRefreshToken(userId uuid.UUID) string {
|
||||
token := jm.createToken(JwtClaims{
|
||||
UserID: userId.String(),
|
||||
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(JWT_SECRET)
|
||||
tokenString, err := token.SignedString(jm.secret)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -48,15 +52,14 @@ func CreateRefreshToken(userId uuid.UUID) string {
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func CreateAccessToken(userId uuid.UUID) string {
|
||||
token := createToken(JwtClaims{
|
||||
func (jm *JwtManager) CreateAccessToken(userId uuid.UUID) string {
|
||||
token := jm.createToken(JwtClaims{
|
||||
UserID: userId.String(),
|
||||
Type: Access,
|
||||
Expire: time.Now().Add(time.Hour),
|
||||
Expiry: time.Now().Add(time.Minute),
|
||||
})
|
||||
|
||||
// TODO: bruh what is this
|
||||
tokenString, err := token.SignedString(JWT_SECRET)
|
||||
tokenString, err := token.SignedString(jm.secret)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -66,18 +69,20 @@ func CreateAccessToken(userId uuid.UUID) string {
|
||||
|
||||
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) {
|
||||
return JWT_SECRET, nil
|
||||
return jm.secret, nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
|
||||
if err != nil {
|
||||
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 {
|
||||
tokenType, ok := claims["Type"]
|
||||
if !ok || tokenType.(string) != "access" {
|
||||
@ -94,3 +99,38 @@ func GetUserIdFromAccess(accessToken string) (uuid.UUID, error) {
|
||||
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)
|
||||
}
|
||||
|
36
backend/middleware/limits.go
Normal file
36
backend/middleware/limits.go
Normal 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)
|
||||
}
|
||||
}
|
@ -50,15 +50,12 @@ func GetUserID(ctx context.Context, logger *log.Logger, w http.ResponseWriter) (
|
||||
return userIdUuid, nil
|
||||
}
|
||||
|
||||
func ProtectedRoute(next http.Handler) http.Handler {
|
||||
func ProtectedRouteURL(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.Header.Get("Authorization")
|
||||
if len(token) < len("Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token := r.URL.Query().Get("token")
|
||||
|
||||
userId, err := GetUserIdFromAccess(token[len("Bearer "):])
|
||||
userId, err := GetUserIdFromAccess(jm, token)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
@ -70,8 +67,34 @@ func ProtectedRoute(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, newR)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserIdFromUrl(next http.Handler) http.Handler {
|
||||
func ProtectedRoute(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.Header.Get("Authorization")
|
||||
|
||||
if len(token) < len("Bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := GetUserIdFromAccess(jm, token[len("Bearer "):])
|
||||
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 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")
|
||||
|
||||
@ -80,7 +103,7 @@ func GetUserIdFromUrl(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
userId, err := GetUserIdFromAccess(token)
|
||||
userId, err := GetUserIdFromAccess(jm, token)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
@ -92,6 +115,7 @@ func GetUserIdFromUrl(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, newR)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetPathParamID(logger *log.Logger, param string, w http.ResponseWriter, r *http.Request) (uuid.UUID, error) {
|
||||
pathParam := r.PathValue(param)
|
||||
|
@ -35,6 +35,7 @@ func writeError(logger *log.Logger, error string, w http.ResponseWriter, code in
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("writing error", "error", error)
|
||||
w.Write(jsonObject)
|
||||
w.WriteHeader(code)
|
||||
}
|
||||
|
@ -4,11 +4,11 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/enum"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
||||
|
||||
. "github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@ -17,204 +17,72 @@ type ImageModel struct {
|
||||
dbPool *sql.DB
|
||||
}
|
||||
|
||||
type ImageData struct {
|
||||
model.UserImages
|
||||
func (m ImageModel) Save(ctx context.Context, name string, image []byte, userID uuid.UUID) (model.Image, error) {
|
||||
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 {
|
||||
model.UserImagesToProcess
|
||||
|
||||
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)))
|
||||
func (m ImageModel) Get(ctx context.Context, imageID uuid.UUID) (model.Image, bool, error) {
|
||||
getImageStmt := Image.SELECT(Image.AllColumns).WHERE(Image.ID.EQ(UUID(imageID)))
|
||||
|
||||
image := model.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) {
|
||||
getProcessingStmt := SELECT(UserImagesToProcess.AllColumns, Image.ID, Image.ImageName).
|
||||
FROM(
|
||||
UserImagesToProcess.INNER_JOIN(
|
||||
Image, Image.ID.EQ(UserImagesToProcess.ImageID),
|
||||
),
|
||||
).WHERE(
|
||||
UserImagesToProcess.UserID.EQ(UUID(userId)).
|
||||
AND(UserImagesToProcess.Status.NOT_EQ(enum.Progress.Complete)),
|
||||
)
|
||||
func (m ImageModel) UpdateDescription(ctx context.Context, imageID uuid.UUID, description string) error {
|
||||
updateImageDescriptionStmt := Image.UPDATE(Image.Description).
|
||||
SET(Image.Description.SET(String(description))).
|
||||
WHERE(Image.ID.EQ(UUID(imageID)))
|
||||
|
||||
images := []UserProcessingImage{}
|
||||
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)
|
||||
_, err := updateImageDescriptionStmt.ExecContext(ctx, m.dbPool)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (m ImageModel) IsUserAuthorized(ctx context.Context, imageId uuid.UUID, userId uuid.UUID) bool {
|
||||
getImageUserId := UserImages.SELECT(UserImages.UserID).WHERE(UserImages.ImageID.EQ(UUID(imageId)))
|
||||
func (m ImageModel) UpdateProcess(ctx context.Context, imageID uuid.UUID, process model.Progress) error {
|
||||
updateImageDescriptionStmt := Image.UPDATE(Image.Status).
|
||||
SET(process).
|
||||
WHERE(Image.ID.EQ(UUID(imageID)))
|
||||
|
||||
userImage := model.UserImages{}
|
||||
err := getImageUserId.QueryContext(ctx, m.dbPool, &userImage)
|
||||
_, err := updateImageDescriptionStmt.ExecContext(ctx, m.dbPool)
|
||||
|
||||
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 {
|
||||
|
@ -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
200
backend/models/stacks.go
Normal 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}
|
||||
}
|
@ -49,28 +49,20 @@ func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, err
|
||||
}
|
||||
|
||||
type UserImageWithImage struct {
|
||||
model.UserImages
|
||||
|
||||
Image struct {
|
||||
model.Image
|
||||
ImageLists []model.ImageLists
|
||||
}
|
||||
ImageStacks []model.ImageStacks
|
||||
}
|
||||
|
||||
func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserImageWithImage, error) {
|
||||
getUserImagesStmt := SELECT(
|
||||
UserImages.AllColumns,
|
||||
Image.ID,
|
||||
Image.ImageName,
|
||||
Image.Description,
|
||||
ImageLists.AllColumns,
|
||||
Image.AllColumns.Except(Image.Image),
|
||||
ImageStacks.AllColumns,
|
||||
).
|
||||
FROM(
|
||||
UserImages.
|
||||
INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID)).
|
||||
INNER_JOIN(ImageLists, ImageLists.ImageID.EQ(UserImages.ImageID)),
|
||||
Image.
|
||||
LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(Image.ID)),
|
||||
).
|
||||
WHERE(UserImages.UserID.EQ(UUID(userId)))
|
||||
WHERE(Image.UserID.EQ(UUID(userId)))
|
||||
|
||||
userImages := []UserImageWithImage{}
|
||||
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 {
|
||||
model.Lists
|
||||
|
||||
Schema struct {
|
||||
model.Schemas
|
||||
model.Stacks
|
||||
|
||||
SchemaItems []model.SchemaItems
|
||||
}
|
||||
|
||||
Images []struct {
|
||||
model.ImageLists
|
||||
model.ImageStacks
|
||||
|
||||
Items []model.ImageSchemaItems
|
||||
}
|
||||
@ -96,20 +84,18 @@ type ListsWithImages struct {
|
||||
|
||||
func (m UserModel) ListWithImages(ctx context.Context, userId uuid.UUID) ([]ListsWithImages, error) {
|
||||
stmt := SELECT(
|
||||
Lists.AllColumns,
|
||||
ImageLists.AllColumns,
|
||||
Schemas.AllColumns,
|
||||
Stacks.AllColumns,
|
||||
ImageStacks.AllColumns,
|
||||
SchemaItems.AllColumns,
|
||||
ImageSchemaItems.AllColumns,
|
||||
).
|
||||
FROM(
|
||||
Lists.
|
||||
INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)).
|
||||
INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)).
|
||||
LEFT_JOIN(ImageLists, ImageLists.ListID.EQ(Lists.ID)).
|
||||
LEFT_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)),
|
||||
Stacks.
|
||||
INNER_JOIN(SchemaItems, SchemaItems.StackID.EQ(Stacks.ID)).
|
||||
LEFT_JOIN(ImageStacks, ImageStacks.StackID.EQ(Stacks.ID)).
|
||||
LEFT_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageStacks.ID)),
|
||||
).
|
||||
WHERE(Lists.UserID.EQ(UUID(userId)))
|
||||
WHERE(Stacks.UserID.EQ(UUID(userId)))
|
||||
|
||||
lists := []ListsWithImages{}
|
||||
err := stmt.QueryContext(ctx, m.dbPool, &lists)
|
||||
|
38
backend/notifications/channel_splitter.go
Normal file
38
backend/notifications/channel_splitter.go
Normal 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),
|
||||
}
|
||||
}
|
64
backend/notifications/entities_notification.go
Normal file
64
backend/notifications/entities_notification.go
Normal 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")
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@ -56,42 +56,3 @@ func NewNotifier[TNotification any](bufferSize int) Notifier[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),
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package main
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"testing"
|
157
backend/processor/image.go
Normal file
157
backend/processor/image.go
Normal 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
|
||||
}
|
23
backend/processor/processor.go
Normal file
23
backend/processor/processor.go
Normal 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
142
backend/processor/stack.go
Normal 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
|
||||
}
|
@ -2,11 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/auth"
|
||||
"screenmark/screenmark/images"
|
||||
"screenmark/screenmark/limits"
|
||||
"screenmark/screenmark/models"
|
||||
"screenmark/screenmark/notifications"
|
||||
"screenmark/screenmark/processor"
|
||||
"screenmark/screenmark/stacks"
|
||||
|
||||
ourmiddleware "screenmark/screenmark/middleware"
|
||||
@ -23,29 +27,33 @@ func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (cli
|
||||
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)
|
||||
stackModel := models.NewListModel(db)
|
||||
stackModel := models.NewStackModel(db)
|
||||
|
||||
stackHandler := stacks.CreateStackHandler(db)
|
||||
authHandler := auth.CreateAuthHandler(db)
|
||||
imageHandler := images.CreateImageHandler(db)
|
||||
notifier := notifications.NewNotifier[notifications.Notification](10)
|
||||
|
||||
notifier := NewNotifier[Notification](10)
|
||||
|
||||
// Only start event listeners if not in test environment
|
||||
if os.Getenv("GO_TEST_ENVIRONMENT") != "true" {
|
||||
|
||||
// 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, ¬ifier)
|
||||
go ListenNewStackEvents(db)
|
||||
go ListenProcessingStackStatus(db, stackModel, ¬ifier)
|
||||
imageProcessorLogger := createLogger("Image Processor", os.Stdout)
|
||||
imageProcessor, err := processor.NewImageProcessor(imageProcessorLogger, imageModel, stackModel, limitsManager, ¬ifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processor: %w", err)
|
||||
}
|
||||
|
||||
stackProcessorLog := createLogger("Stack Processor", os.Stdout)
|
||||
stackProcessor, err := processor.NewStackProcessor(stackProcessorLog, stackModel, ¬ifier)
|
||||
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.Use(middleware.Logger)
|
||||
@ -56,16 +64,10 @@ func setupRouter(db *sql.DB) chi.Router {
|
||||
r.Route("/images", imageHandler.CreateRoutes)
|
||||
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Use(ourmiddleware.GetUserIdFromUrl)
|
||||
r.Use(ourmiddleware.GetUserIdFromUrl(jwtManager))
|
||||
|
||||
r.Get("/", CreateEventsHandler(¬ifier))
|
||||
})
|
||||
|
||||
logWriter := DatabaseWriter{
|
||||
dbPool: db,
|
||||
}
|
||||
|
||||
r.Route("/logs", createLogHandler(&logWriter))
|
||||
|
||||
return r
|
||||
return r, nil
|
||||
}
|
||||
|
@ -9,72 +9,45 @@ CREATE TYPE haystack.progress AS ENUM('not-started','in-progress', 'complete');
|
||||
/* -----| Schema tables |----- */
|
||||
|
||||
CREATE TABLE haystack.users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.image (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
image_name 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',
|
||||
image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id),
|
||||
user_id uuid NOT NULL REFERENCES haystack.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.user_images (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id),
|
||||
user_id uuid NOT NULL REFERENCES haystack.users (id),
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.logs (
|
||||
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(),
|
||||
user_id UUID NOT NULL REFERENCES haystack.users (id),
|
||||
|
||||
image_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
|
||||
status haystack.progress NOT NULL DEFAULT 'not-started',
|
||||
|
||||
image BYTEA NOT NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.stacks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES haystack.users (id),
|
||||
|
||||
status haystack.progress NOT NULL DEFAULT 'not-started',
|
||||
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.processing_lists (
|
||||
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 (
|
||||
CREATE TABLE haystack.image_stacks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
image_id UUID NOT NULL REFERENCES haystack.image (id),
|
||||
list_id UUID NOT NULL REFERENCES haystack.lists (id)
|
||||
);
|
||||
image_id UUID NOT NULL REFERENCES haystack.image (id) ON DELETE CASCADE,
|
||||
stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE,
|
||||
|
||||
CREATE TABLE haystack.schemas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
list_id UUID NOT NULL REFERENCES haystack.lists (id)
|
||||
UNIQUE(image_id, stack_id)
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.schema_items (
|
||||
@ -84,7 +57,7 @@ CREATE TABLE haystack.schema_items (
|
||||
value 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 (
|
||||
@ -92,66 +65,6 @@ CREATE TABLE haystack.image_schema_items (
|
||||
|
||||
value TEXT,
|
||||
|
||||
schema_item_id UUID NOT NULL REFERENCES haystack.schema_items (id),
|
||||
image_id UUID NOT NULL REFERENCES haystack.image (id)
|
||||
schema_item_id UUID NOT NULL REFERENCES haystack.schema_items (id) ON DELETE CASCADE,
|
||||
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();
|
||||
|
@ -2,21 +2,30 @@ package stacks
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
. "screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/limits"
|
||||
"screenmark/screenmark/middleware"
|
||||
"screenmark/screenmark/models"
|
||||
"strings"
|
||||
"screenmark/screenmark/processor"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type StackHandler struct {
|
||||
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) {
|
||||
@ -27,14 +36,14 @@ func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
lists, err := h.stackModel.List(ctx, userID)
|
||||
stacks, err := h.stackModel.List(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Warn("could not get stacks", "err", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
middleware.WriteJsonOrError(h.logger, lists, w)
|
||||
middleware.WriteJsonOrError(h.logger, stacks, w)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
listID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
|
||||
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: must check for permission here.
|
||||
|
||||
lists, err := h.stackModel.ListItems(ctx, listID)
|
||||
lists, err := h.stackModel.ListItems(ctx, stackID)
|
||||
if err != nil {
|
||||
h.logger.Warn("could not get list items", "err", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@ -77,12 +86,12 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
listID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
|
||||
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = h.stackModel.Delete(ctx, listID, userID)
|
||||
err = h.stackModel.Delete(ctx, stackID, userID)
|
||||
if err != nil {
|
||||
h.logger.Warn("could not delete stack", "err", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
@ -92,11 +101,107 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *StackHandler) deleteImageFromStack(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
stringListID := chi.URLParam(r, "stackID")
|
||||
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"`
|
||||
|
||||
// We want a regular string because AI will take care of creating these for us.
|
||||
Fields string `json:"fields"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Convert fields string to basic schema items
|
||||
// For now, create a simple schema item for each field
|
||||
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)
|
||||
// TODO: Add the stack processor here
|
||||
stack, err := h.stackModel.Save(ctx, userID, body.Title, body.Description, model.Progress_NotStarted)
|
||||
if err != nil {
|
||||
h.logger.Warn("could not save stack", "err", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
h.processor.Add(stack)
|
||||
|
||||
middleware.WriteJsonOrError(h.logger, stack, w)
|
||||
}
|
||||
|
||||
func (h *StackHandler) CreateRoutes(r chi.Router) {
|
||||
h.logger.Info("Mounting stack router")
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.ProtectedRoute)
|
||||
r.Use(middleware.ProtectedRoute(h.jwtManager))
|
||||
r.Use(middleware.SetJson)
|
||||
|
||||
r.Get("/", h.getAllStacks)
|
||||
r.Get("/{listID}", h.getStackItems)
|
||||
r.Get("/{stackID}", h.getStackItems)
|
||||
|
||||
r.Post("/", middleware.WithValidatedPost(h.createStack))
|
||||
r.Patch("/{listID}", middleware.WithValidatedPost(h.editStack))
|
||||
r.Delete("/{listID}", h.deleteStack)
|
||||
r.Post("/", middleware.WithLimit(h.logger, h.limitsManager.HasReachedStackLimit, middleware.WithValidatedPost(h.createStack)))
|
||||
r.Patch("/{stackID}", middleware.WithValidatedPost(h.editStack))
|
||||
r.Delete("/{stackID}", h.deleteStack)
|
||||
r.Delete("/{stackID}/{imageID}", h.deleteImageFromStack)
|
||||
r.Delete("/{stackID}/{schemaItemID}", h.deleteImageStackSchemaItem)
|
||||
})
|
||||
}
|
||||
|
||||
func CreateStackHandler(db *sql.DB) StackHandler {
|
||||
stackModel := models.NewListModel(db)
|
||||
func CreateStackHandler(
|
||||
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")
|
||||
|
||||
return StackHandler{
|
||||
logger,
|
||||
stackModel,
|
||||
logger: logger,
|
||||
imageModel: imageModel,
|
||||
stackModel: stackModel,
|
||||
limitsManager: limitsManager,
|
||||
jwtManager: jwtManager,
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
@ -4,22 +4,22 @@
|
||||
"": {
|
||||
"name": "haystack",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@kobalte/tailwindcss": "^0.9.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tabler/icons-solidjs": "^3.34.0",
|
||||
"@tabler/icons-solidjs": "^3.35.0",
|
||||
"@tanstack/solid-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.6.0",
|
||||
"@tauri-apps/plugin-dialog": "~2.3.0",
|
||||
"@tauri-apps/plugin-fs": "~2.4.0",
|
||||
"@tauri-apps/plugin-http": "2.4.3",
|
||||
"@tauri-apps/plugin-log": "~2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"@tauri-apps/plugin-os": "2.2.1",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "~2.3.3",
|
||||
"@tauri-apps/plugin-fs": "~2.4.2",
|
||||
"@tauri-apps/plugin-http": "^2.4.3",
|
||||
"@tauri-apps/plugin-log": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-js": "^1.9.9",
|
||||
"solid-markdown": "^2.0.14",
|
||||
"solid-motionone": "^1.0.4",
|
||||
"solidjs-markdown": "^0.2.0",
|
||||
@ -30,15 +30,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tauri-apps/cli": "^2.6.2",
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"tailwindcss": "3.4.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"vite": "^6.3.6",
|
||||
"vite-plugin-solid": "^2.11.8",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@ -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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
@ -672,7 +672,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@ -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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
@ -14,22 +14,22 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@kobalte/tailwindcss": "^0.9.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tabler/icons-solidjs": "^3.34.0",
|
||||
"@tabler/icons-solidjs": "^3.35.0",
|
||||
"@tanstack/solid-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.6.0",
|
||||
"@tauri-apps/plugin-dialog": "~2.3.0",
|
||||
"@tauri-apps/plugin-fs": "~2.4.0",
|
||||
"@tauri-apps/plugin-http": "2.4.3",
|
||||
"@tauri-apps/plugin-log": "~2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"@tauri-apps/plugin-os": "2.2.1",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "~2.3.3",
|
||||
"@tauri-apps/plugin-fs": "~2.4.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.2",
|
||||
"@tauri-apps/plugin-log": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-js": "^1.9.9",
|
||||
"solid-markdown": "^2.0.14",
|
||||
"solid-motionone": "^1.0.4",
|
||||
"solidjs-markdown": "^0.2.0",
|
||||
@ -40,15 +40,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tauri-apps/cli": "^2.6.2",
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"tailwindcss": "3.4.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"vite": "^6.3.6",
|
||||
"vite-plugin-solid": "^2.11.8",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
1675
frontend/src-tauri/Cargo.lock
generated
1675
frontend/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -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>
|
@ -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>
|
@ -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>
|
@ -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 []
|
||||
}
|
||||
}
|
@ -1,280 +1,189 @@
|
||||
//
|
||||
// ShareViewController.swift
|
||||
// Haystack
|
||||
// ShareViewController.swift
|
||||
// Haystack
|
||||
//
|
||||
// Created by Rio Keefe on 03/05/2025.
|
||||
// Created by Rio Keefe on 03/05/2025.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Social
|
||||
import MobileCoreServices // For kUTTypeImage
|
||||
import MobileCoreServices
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
|
||||
let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier
|
||||
let tokenKey = "sharedAuthToken"
|
||||
let uploadURLBase = URL(string: "https://haystack.johncosta.tech/image/")! // Base URL
|
||||
let tokenKey = "sharedAuthToken" // This key holds the refresh token.
|
||||
let uploadURL = URL(string: "https://haystack.johncosta.tech/images/")!
|
||||
|
||||
var bearerToken: String?
|
||||
var refreshToken: String?
|
||||
private var imageItemProvider: NSItemProvider?
|
||||
// Store a base name, extension will be determined during item loading
|
||||
private var baseImageName: String = "SharedImage" // A more descriptive default
|
||||
private var extractedImageName: String = "image" // Default name
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
if let sharedDefaults = UserDefaults(suiteName: appGroupName) {
|
||||
bearerToken = sharedDefaults.string(forKey: tokenKey)
|
||||
print("Retrieved bearer token: \(bearerToken ?? "nil")")
|
||||
refreshToken = sharedDefaults.string(forKey: tokenKey)
|
||||
print("Retrieved refresh token: \(refreshToken ?? "nil")")
|
||||
} else {
|
||||
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,
|
||||
let provider = extensionItem.attachments?.first else {
|
||||
print("No attachments found.")
|
||||
// Invalidate content if no provider
|
||||
self.imageItemProvider = nil // Ensure it's nil so isContentValid fails
|
||||
return
|
||||
}
|
||||
|
||||
// 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, and clean it.
|
||||
// This will be our default base name if the item itself doesn't provide a better one.
|
||||
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
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
// 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
|
||||
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
|
||||
let isValid = imageItemProvider != nil && bearerToken != nil
|
||||
if imageItemProvider == nil {
|
||||
print("isContentValid: imageItemProvider is nil")
|
||||
}
|
||||
if bearerToken == nil {
|
||||
print("isContentValid: bearerToken is nil")
|
||||
}
|
||||
return isValid
|
||||
// Content is valid only if we have an item provider for an image AND a refresh token
|
||||
return imageItemProvider != nil && refreshToken != nil
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
guard let provider = imageItemProvider else {
|
||||
refreshToken { accessToken in
|
||||
guard let token = accessToken else {
|
||||
// Inform the user about the authentication failure
|
||||
let error = NSError(domain: "ShareExtension", code: -1, userInfo: [NSLocalizedDescriptionKey: "Authentication failed. Please log in again."])
|
||||
self.extensionContext!.cancelRequest(withError: error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let provider = self.imageItemProvider else {
|
||||
print("Error: No image item provider found when posting.")
|
||||
informUserAndCancel(message: "No image found to share.")
|
||||
// Inform the user or log an error
|
||||
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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)")
|
||||
self.informUserAndCancel(message: "Could not load image: \(error.localizedDescription)")
|
||||
// Inform the user about the failure
|
||||
self.extensionContext!.cancelRequest(withError: error)
|
||||
return
|
||||
}
|
||||
|
||||
var imageData: Data?
|
||||
var finalImageNameWithExtension: String
|
||||
var mimeType: String = "application/octet-stream" // Default MIME type
|
||||
var rawImageData: Data?
|
||||
var finalImageName = self.extractedImageName // Use the name extracted earlier
|
||||
|
||||
// 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, 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])
|
||||
}
|
||||
|
||||
|
||||
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)"
|
||||
|
||||
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. Item type: \(type(of: item)) Item: \(String(describing: item))")
|
||||
self.informUserAndCancel(message: "Unsupported image format.")
|
||||
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 = imageData else {
|
||||
print("Error: No image data to upload after processing.")
|
||||
self.informUserAndCancel(message: "Image data is missing.")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Ensure finalImageNameWithExtension is not just an extension like ".png"
|
||||
if finalImageNameWithExtension.starts(with: ".") {
|
||||
finalImageNameWithExtension = "\(self.baseImageName)\(finalImageNameWithExtension)"
|
||||
// Now perform the upload asynchronously
|
||||
self.uploadRawData(dataToUpload, imageName: finalImageName, bearerToken: token)
|
||||
}
|
||||
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)")
|
||||
func uploadRawData(_ rawData: Data, imageName: String, bearerToken: String) {
|
||||
// bearerToken is guaranteed to be non-nil here due to the guard in didSelectPost
|
||||
|
||||
var request = URLRequest(url: uploadURL)
|
||||
let uploadURLwithName = uploadURL.appendingPathComponent(imageName)
|
||||
|
||||
var request = URLRequest(url: uploadURLwithName)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = rawData
|
||||
request.setValue(mimeType, forHTTPHeaderField: "Content-Type") // Use determined MIME type
|
||||
request.setValue("application/oclet-stream", forHTTPHeaderField: "Content-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
|
||||
// **IMPORTANT:** Complete the extension request on the main thread
|
||||
DispatchQueue.main.async {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
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.informUserAndCancel(message: "Upload failed: \(error.localizedDescription)")
|
||||
self?.extensionContext!.cancelRequest(withError: error)
|
||||
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
|
||||
let errorDescription = "Upload failed. Server returned status code \(httpResponse.statusCode)."
|
||||
// Handle non-success HTTP status codes
|
||||
let errorDescription = "Server returned status code \(httpResponse.statusCode)"
|
||||
print(errorDescription)
|
||||
self.informUserAndCancel(message: errorDescription)
|
||||
} else {
|
||||
print("Upload successful for \(imageNameWithExtension)")
|
||||
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
|
||||
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()
|
||||
}
|
||||
|
||||
func refreshToken(completion: @escaping (String?) -> Void) {
|
||||
guard let refreshToken = self.refreshToken else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let url = URL(string: "https://haystack.johncosta.tech/auth/refresh")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = ["refresh": refreshToken]
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
if let data = data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let accessToken = json["access"] as? String {
|
||||
completion(accessToken)
|
||||
} else {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
// No configuration items needed for this simple image uploader.
|
||||
// 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 []
|
||||
}
|
||||
|
||||
// Helper to inform user and cancel request
|
||||
private func informUserAndCancel(message: String) {
|
||||
let error = NSError(domain: "com.haystack.ShareExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
|
||||
print("Informing user: \(message)")
|
||||
// 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
|
||||
private func mimeType(forPathExtension pathExtension: String) -> String {
|
||||
let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)?.takeRetainedValue()
|
||||
if let uti = uti {
|
||||
let mimeType = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue()
|
||||
if let mimeType = mimeType {
|
||||
return mimeType as String
|
||||
}
|
||||
}
|
||||
// Fallback for common types if UTType fails or for robustness
|
||||
switch pathExtension.lowercased() {
|
||||
// case "jpg", "jpeg": return "image/jpeg"
|
||||
// case "png": return "image/png"
|
||||
// case "gif": return "image/gif"
|
||||
// case "bmp": return "image/bmp"
|
||||
// case "tiff", "tif": return "image/tiff"
|
||||
default: return "application/octet-stream" // Generic fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
Settings,
|
||||
SearchPage,
|
||||
AllImages,
|
||||
List,
|
||||
Stack,
|
||||
} from "./pages";
|
||||
import { SearchImageContextProvider } from "@contexts/SearchImageContext";
|
||||
import { WithNotifications } from "@contexts/Notifications";
|
||||
@ -15,6 +15,8 @@ import { ProtectedRoute } from "@components/protected-route";
|
||||
import { AppWrapper } from "@components/app-wrapper";
|
||||
import { WithTopbarAndDock } from "@components/app-wrapper/with-topbar-and-dock";
|
||||
import { onSendImage } from "@contexts/send-image";
|
||||
import { Toast } from "@kobalte/core/toast";
|
||||
import { Portal } from "solid-js/web";
|
||||
|
||||
export const App = () => {
|
||||
onAndroidMount();
|
||||
@ -31,9 +33,15 @@ export const App = () => {
|
||||
<Route path="/" component={WithTopbarAndDock}>
|
||||
<Route path="/" component={FrontPage} />
|
||||
<Route path="/search" component={SearchPage} />
|
||||
<Route path="/all-images" component={AllImages} />
|
||||
<Route path="/image/:imageId" component={ImagePage} />
|
||||
<Route path="/list/:listId" component={List} />
|
||||
<Route
|
||||
path="/all-images"
|
||||
component={AllImages}
|
||||
/>
|
||||
<Route
|
||||
path="/image/:imageId"
|
||||
component={ImagePage}
|
||||
/>
|
||||
<Route path="/stack/:stackID" component={Stack} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
</Route>
|
||||
</Route>
|
||||
@ -41,6 +49,12 @@ export const App = () => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -1,25 +1,112 @@
|
||||
import { Component } from "solid-js";
|
||||
import { base } from "../../network";
|
||||
import { Component, createResource, createSignal, Suspense } from "solid-js";
|
||||
import { base, getAccessToken } from "../../network";
|
||||
import { A } from "@solidjs/router";
|
||||
import { Dialog } from "@kobalte/core";
|
||||
|
||||
type ImageComponentProps = {
|
||||
ID: string;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ImageComponent: Component<ImageComponentProps> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
const [accessToken] = createResource(getAccessToken);
|
||||
|
||||
export const ImageComponent: Component<{ ID: string }> = (props) => {
|
||||
return (
|
||||
<A href={`/image/${props.ID}`} class="w-full flex justify-center h-[300px]">
|
||||
<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}`}
|
||||
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
|
||||
// merge the fuckers
|
||||
|
||||
export const ImageComponentFullHeight: Component<ImageComponentProps> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
const [accessToken] = createResource(getAccessToken);
|
||||
|
||||
return (
|
||||
<A href={`/image/${props.ID}`} class="w-full flex justify-center">
|
||||
<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}`}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
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 { base } from "@network/index";
|
||||
import { base, getAccessToken } from "@network/index";
|
||||
import { useNotifications } from "@contexts/Notifications";
|
||||
|
||||
export const ProcessingImages: Component = () => {
|
||||
@ -9,9 +9,12 @@ export const ProcessingImages: Component = () => {
|
||||
|
||||
const processingNumber = () =>
|
||||
Object.keys(notifications.state.ProcessingImages).length +
|
||||
Object.keys(notifications.state.ProcessingLists).length;
|
||||
Object.keys(notifications.state.ProcessingStacks).length;
|
||||
|
||||
const [accessToken] = createResource(getAccessToken)
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Popover sameWidth gutter={4}>
|
||||
<Popover.Trigger class="w-full flex justify-between gap-4 rounded-xl px-4 py-2">
|
||||
<Show when={processingNumber() > 0}>
|
||||
@ -42,7 +45,7 @@ export const ProcessingImages: Component = () => {
|
||||
<img
|
||||
class="w-16 h-16 aspect-square rounded"
|
||||
alt="processing"
|
||||
src={`${base}/images/${id}`}
|
||||
src={`${base}/images/${id}?token=${accessToken()}`}
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-slate-100">{image().ImageName}</p>
|
||||
@ -57,13 +60,13 @@ export const ProcessingImages: Component = () => {
|
||||
)}
|
||||
</For>
|
||||
|
||||
<For each={Object.entries(notifications.state.ProcessingLists)}>
|
||||
{([, _list]) => (
|
||||
<Show when={_list}>
|
||||
{(list) => (
|
||||
<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: {list().Name}</p>
|
||||
<p class="text-slate-900">New Stack: {stack().Name}</p>
|
||||
</div>
|
||||
<LoadingCircle
|
||||
status="loading"
|
||||
@ -78,5 +81,6 @@ export const ProcessingImages: Component = () => {
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { Navigate } from "@solidjs/router";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
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 => {
|
||||
const token = localStorage.getItem("access");
|
||||
@ -19,23 +18,26 @@ export const isTokenValid = (): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
const isValid = isTokenValid();
|
||||
|
||||
if (isValid) {
|
||||
const token = localStorage.getItem("access");
|
||||
const token = localStorage.getItem("refresh");
|
||||
if (token == null) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
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(token)
|
||||
.then(() => console.log("Saved token!!!"))
|
||||
.catch((e) => console.error(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
35
frontend/src/components/stack-card/index.tsx
Normal file
35
frontend/src/components/stack-card/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -5,11 +5,12 @@ import {
|
||||
Component,
|
||||
createContext,
|
||||
createEffect,
|
||||
createResource,
|
||||
onCleanup,
|
||||
ParentProps,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { base } from "@network/index";
|
||||
import { base, getAccessToken } from "@network/index";
|
||||
import {
|
||||
notificationValidator,
|
||||
processingImagesValidator,
|
||||
@ -21,7 +22,7 @@ type NotificationState = {
|
||||
string,
|
||||
InferOutput<typeof processingImagesValidator> | undefined
|
||||
>;
|
||||
ProcessingLists: Record<
|
||||
ProcessingStacks: Record<
|
||||
string,
|
||||
InferOutput<typeof processingListValidator> | undefined
|
||||
>;
|
||||
@ -30,15 +31,12 @@ type NotificationState = {
|
||||
export const Notifications = (onCompleteImage: () => void) => {
|
||||
const [state, setState] = createStore<NotificationState>({
|
||||
ProcessingImages: {},
|
||||
ProcessingLists: {},
|
||||
ProcessingStacks: {},
|
||||
});
|
||||
|
||||
const { processingImages } = useSearchImageContext();
|
||||
const { userImages } = useSearchImageContext();
|
||||
|
||||
const access = localStorage.getItem("access");
|
||||
if (access == null) {
|
||||
throw new Error("Access token not defined");
|
||||
}
|
||||
const [accessToken] = createResource(getAccessToken);
|
||||
|
||||
const dataEventListener = (e: MessageEvent<unknown>) => {
|
||||
if (typeof e.data !== "string") {
|
||||
@ -71,14 +69,14 @@ export const Notifications = (onCompleteImage: () => void) => {
|
||||
} else {
|
||||
setState("ProcessingImages", ImageID, notification.output);
|
||||
}
|
||||
} else if (notification.output.Type === "list") {
|
||||
const { ListID, Status } = notification.output;
|
||||
} else if (notification.output.Type === "stack") {
|
||||
const { StackID, Status } = notification.output;
|
||||
|
||||
if (Status === "complete") {
|
||||
setState("ProcessingLists", ListID, undefined);
|
||||
setState("ProcessingStacks", StackID, undefined);
|
||||
onCompleteImage();
|
||||
} else {
|
||||
setState("ProcessingLists", ListID, notification.output);
|
||||
setState("ProcessingStacks", StackID, notification.output);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -93,19 +91,19 @@ export const Notifications = (onCompleteImage: () => void) => {
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const images = processingImages();
|
||||
const images = userImages();
|
||||
if (images == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
upsertImageProcessing(
|
||||
Object.fromEntries(
|
||||
images.map((i) => [
|
||||
i.ImageID,
|
||||
images.filter(i => i.Status !== 'complete').map((i) => [
|
||||
i.ID,
|
||||
{
|
||||
Type: "image",
|
||||
ImageID: i.ImageID,
|
||||
ImageName: i.Image.ImageName,
|
||||
ImageID: i.ID,
|
||||
ImageName: i.ImageName,
|
||||
Status: i.Status,
|
||||
},
|
||||
]),
|
||||
@ -113,17 +111,24 @@ export const Notifications = (onCompleteImage: () => void) => {
|
||||
);
|
||||
});
|
||||
|
||||
const events = new EventSource(`${base}/notifications?token=${access}`);
|
||||
let events: EventSource | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
const token = accessToken();
|
||||
if (token) {
|
||||
events = new EventSource(`${base}/notifications?token=${token}`);
|
||||
events.addEventListener("data", dataEventListener);
|
||||
|
||||
events.onerror = (e) => {
|
||||
console.error(e);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (events) {
|
||||
events.removeEventListener("data", dataEventListener);
|
||||
events.close();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -3,39 +3,44 @@ import {
|
||||
type Component,
|
||||
type ParentProps,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { getUserImages, JustTheImageWhatAreTheseNames } from "../network";
|
||||
import {
|
||||
deleteImage,
|
||||
deleteImageFromStack,
|
||||
deleteStack,
|
||||
deleteStackItem,
|
||||
getUserImages,
|
||||
JustTheImageWhatAreTheseNames,
|
||||
} from "../network";
|
||||
|
||||
export type SearchImageStore = {
|
||||
imagesByDate: Accessor<
|
||||
Array<{ date: Date; images: JustTheImageWhatAreTheseNames }>
|
||||
>;
|
||||
|
||||
lists: Accessor<Awaited<ReturnType<typeof getUserImages>>["lists"]>;
|
||||
stacks: Accessor<Awaited<ReturnType<typeof getUserImages>>["Stacks"]>;
|
||||
|
||||
userImages: Accessor<JustTheImageWhatAreTheseNames>;
|
||||
|
||||
processingImages: Accessor<
|
||||
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>();
|
||||
export const SearchImageContextProvider: Component<ParentProps> = (props) => {
|
||||
const [data, { refetch }] = createResource(getUserImages);
|
||||
|
||||
createEffect(() => {
|
||||
console.log(data());
|
||||
});
|
||||
|
||||
const sortedImages = createMemo<ReturnType<SearchImageStore["imagesByDate"]>>(
|
||||
() => {
|
||||
const sortedImages = createMemo<
|
||||
ReturnType<SearchImageStore["imagesByDate"]>
|
||||
>(() => {
|
||||
const d = data();
|
||||
if (d == null) {
|
||||
return [];
|
||||
@ -44,7 +49,7 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
|
||||
// Sorted by day. But we could potentially add more in the future.
|
||||
const buckets: Record<string, JustTheImageWhatAreTheseNames> = {};
|
||||
|
||||
for (const image of d.userImages) {
|
||||
for (const image of d.UserImages) {
|
||||
if (image.CreatedAt == null) {
|
||||
continue;
|
||||
}
|
||||
@ -60,19 +65,27 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
|
||||
return Object.entries(buckets)
|
||||
.map(([date, images]) => ({ date: new Date(date), images }))
|
||||
.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
},
|
||||
);
|
||||
|
||||
const processingImages = () => data()?.processingImages ?? [];
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchImageContext.Provider
|
||||
value={{
|
||||
imagesByDate: sortedImages,
|
||||
lists: () => data()?.lists ?? [],
|
||||
userImages: () => data()?.userImages ?? [],
|
||||
processingImages,
|
||||
stacks: () => data()?.Stacks ?? [],
|
||||
userImages: () => data()?.UserImages ?? [],
|
||||
onRefetchImages: refetch,
|
||||
onDeleteImage: (imageID: string) => {
|
||||
deleteImage(imageID).then(refetch);
|
||||
},
|
||||
onDeleteImageFromStack: (stackID: string, imageID: string) => {
|
||||
deleteImageFromStack(stackID, imageID).then(refetch);
|
||||
},
|
||||
onDeleteStack: (stackID: string) => {
|
||||
deleteStack(stackID).then(refetch)
|
||||
},
|
||||
onDeleteStackItem: (stackID: string, schemaItemID: string) => {
|
||||
deleteStackItem(stackID, schemaItemID).then(refetch);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { createEffect } from "solid-js";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
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 = () => {
|
||||
let sentImage = "";
|
||||
@ -22,7 +23,16 @@ export const onSendImage = () => {
|
||||
appWindow.show();
|
||||
appWindow.setFocus();
|
||||
|
||||
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 () => {
|
||||
|
@ -1,13 +1,17 @@
|
||||
import { getTokenProperties } from "@components/protected-route";
|
||||
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 {
|
||||
type InferOutput,
|
||||
array,
|
||||
literal,
|
||||
null_,
|
||||
literal,
|
||||
nullable,
|
||||
parse,
|
||||
pipe,
|
||||
safeParse,
|
||||
strictObject,
|
||||
string,
|
||||
transform,
|
||||
@ -18,11 +22,11 @@ import {
|
||||
type BaseRequestParams = Partial<{
|
||||
path: string;
|
||||
body: RequestInit["body"];
|
||||
method: "GET" | "POST";
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
}>;
|
||||
|
||||
// export const base = "https://haystack.johncosta.tech";
|
||||
export const base = "http://localhost:3040";
|
||||
export const base = "https://haystack.johncosta.tech";
|
||||
// export const base = "http://192.168.1.199:3040";
|
||||
|
||||
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
||||
return new Request(`${base}/${path}`, {
|
||||
@ -31,31 +35,84 @@ const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
||||
});
|
||||
};
|
||||
|
||||
const getBaseAuthorizedRequest = ({
|
||||
const refreshTokenValidator = strictObject({
|
||||
access: 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): Request => {
|
||||
}: BaseRequestParams): Promise<Request> => {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
return new Request(`${base}/${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body,
|
||||
method,
|
||||
});
|
||||
};
|
||||
const sendImageResponseValidator = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
UserID: pipe(string(), uuid()),
|
||||
Status: string(),
|
||||
});
|
||||
|
||||
export const sendImageFile = async (
|
||||
imageName: string,
|
||||
file: File,
|
||||
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
|
||||
const request = getBaseAuthorizedRequest({
|
||||
): Promise<InferOutput<typeof imageValidator>> => {
|
||||
const request = await getBaseAuthorizedRequest({
|
||||
path: `images/${imageName}`,
|
||||
body: file,
|
||||
method: "POST",
|
||||
@ -64,15 +121,68 @@ export const sendImageFile = async (
|
||||
request.headers.set("Content-Type", "application/oclet-stream");
|
||||
|
||||
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 (
|
||||
imageName: string,
|
||||
base64Image: string,
|
||||
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
|
||||
const request = getBaseAuthorizedRequest({
|
||||
): Promise<InferOutput<typeof imageValidator>> => {
|
||||
const request = await getBaseAuthorizedRequest({
|
||||
path: `images/${imageName}`,
|
||||
body: base64Image,
|
||||
method: "POST",
|
||||
@ -80,96 +190,89 @@ export const sendImage = async (
|
||||
|
||||
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()),
|
||||
ImageName: string(),
|
||||
CreatedAt: string(),
|
||||
UserID: pipe(string(), uuid()),
|
||||
Description: string(),
|
||||
|
||||
Image: null_(),
|
||||
});
|
||||
ImageName: string(),
|
||||
|
||||
Status: union([literal('not-started'), literal('in-progress'), literal('complete')]),
|
||||
})
|
||||
|
||||
const userImageValidator = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
CreatedAt: pipe(string()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
UserID: pipe(string(), uuid()),
|
||||
Image: strictObject({
|
||||
...imageMetaValidator.entries,
|
||||
ImageLists: array(
|
||||
...imageValidator.entries,
|
||||
ImageStacks: pipe(nullable(array(
|
||||
strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
ListID: pipe(string(), uuid()),
|
||||
}),
|
||||
),
|
||||
StackID: pipe(string(), uuid()),
|
||||
}),
|
||||
)), transform(l => l ?? [])),
|
||||
});
|
||||
|
||||
const userProcessingImageValidator = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
UserID: pipe(string(), uuid()),
|
||||
Image: imageMetaValidator,
|
||||
Status: union([
|
||||
literal("not-started"),
|
||||
literal("in-progress"),
|
||||
literal("complete"),
|
||||
]),
|
||||
});
|
||||
|
||||
const listValidator = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
UserID: pipe(string(), uuid()),
|
||||
CreatedAt: pipe(string()),
|
||||
Name: string(),
|
||||
Description: nullable(string()),
|
||||
|
||||
Images: pipe(
|
||||
nullable(
|
||||
array(
|
||||
strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
ListID: pipe(string(), uuid()),
|
||||
Items: array(
|
||||
strictObject({
|
||||
const stackItem = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
SchemaItemID: pipe(string(), uuid()),
|
||||
Value: string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
transform((n) => n ?? []),
|
||||
),
|
||||
|
||||
Schema: strictObject({
|
||||
Value: string(),
|
||||
})
|
||||
|
||||
const stackImage = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
ListID: pipe(string(), uuid()),
|
||||
SchemaItems: array(
|
||||
strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
SchemaID: pipe(string(), uuid()),
|
||||
Item: string(),
|
||||
Value: nullable(string()),
|
||||
Description: string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
StackID: pipe(string(), uuid()),
|
||||
|
||||
Items: pipe(nullable(array(stackItem)), transform(l => l ?? [])),
|
||||
});
|
||||
|
||||
export type List = InferOutput<typeof listValidator>;
|
||||
const stackSchemaItem = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
StackID: pipe(string(), uuid()),
|
||||
|
||||
Description: string(),
|
||||
Item: string(),
|
||||
Value: string(),
|
||||
})
|
||||
|
||||
const stackValidator = strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
CreatedAt: string(),
|
||||
UserID: pipe(string(), uuid()),
|
||||
Description: string(),
|
||||
|
||||
Status: union([literal('not-started'), literal('in-progress'), literal('complete')]),
|
||||
|
||||
Name: string(),
|
||||
|
||||
Images: pipe(nullable(array(stackImage)), transform(l => l ?? [])),
|
||||
SchemaItems: array(stackSchemaItem),
|
||||
});
|
||||
|
||||
export type Stack = InferOutput<typeof stackValidator>;
|
||||
|
||||
const imageRequestValidator = strictObject({
|
||||
userImages: array(userImageValidator),
|
||||
processingImages: array(userProcessingImageValidator),
|
||||
lists: array(listValidator),
|
||||
UserImages: array(userImageValidator),
|
||||
Stacks: array(stackValidator),
|
||||
});
|
||||
|
||||
export type JustTheImageWhatAreTheseNames = InferOutput<
|
||||
@ -179,11 +282,19 @@ export type JustTheImageWhatAreTheseNames = InferOutput<
|
||||
export const getUserImages = async (): Promise<
|
||||
InferOutput<typeof imageRequestValidator>
|
||||
> => {
|
||||
const request = getBaseAuthorizedRequest({ path: "images" });
|
||||
const request = await getBaseAuthorizedRequest({ path: "images" });
|
||||
|
||||
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> => {
|
||||
@ -213,14 +324,26 @@ export const postCode = async (
|
||||
|
||||
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 {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export const createStack = async (
|
||||
title: string,
|
||||
description: string,
|
||||
): Promise<void> => {
|
||||
const request = getBaseAuthorizedRequest({
|
||||
const request = await getBaseAuthorizedRequest({
|
||||
path: "stacks",
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, description }),
|
||||
@ -228,5 +351,8 @@ export const createList = async (
|
||||
|
||||
request.headers.set("Content-Type", "application/json");
|
||||
|
||||
await fetch(request);
|
||||
const res = await fetch(request);
|
||||
if (!res.ok && res.status == 429) {
|
||||
throw new ReachedStackLimit();
|
||||
}
|
||||
};
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { literal, pipe, strictObject, string, union, uuid } from "valibot";
|
||||
|
||||
export const processingListValidator = strictObject({
|
||||
Type: literal("list"),
|
||||
Type: literal("stack"),
|
||||
|
||||
Name: string(),
|
||||
ListID: pipe(string(), uuid()),
|
||||
StackID: pipe(string(), uuid()),
|
||||
|
||||
Status: union([
|
||||
literal("not-started"),
|
||||
|
@ -11,7 +11,7 @@ type ImageOrDate =
|
||||
export const AllImages: Component = () => {
|
||||
let scrollRef: HTMLDivElement | undefined;
|
||||
|
||||
const { imagesByDate } = useSearchImageContext();
|
||||
const { imagesByDate, onDeleteImage } = useSearchImageContext();
|
||||
|
||||
const items = () => {
|
||||
const items: Array<ImageOrDate> = [];
|
||||
@ -20,7 +20,7 @@ export const AllImages: Component = () => {
|
||||
items.push({ type: "date", date });
|
||||
const chunkedRows = chunkRows(3, images);
|
||||
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) });
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export const AllImages: Component = () => {
|
||||
const item = items()[i.index];
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<For each={item.ID}>{(id) => <ImageComponent ID={id} />}</For>
|
||||
<For each={item.ID}>{(id) => <ImageComponent ID={id} onDelete={onDeleteImage} />}</For>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { Component, For, createSignal } from "solid-js";
|
||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||
import { ListCard } from "@components/list-card";
|
||||
import { Button } from "@kobalte/core/button";
|
||||
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 = () => {
|
||||
const { lists, onRefetchImages } = useSearchImageContext();
|
||||
const { stacks, onRefetchImages } = useSearchImageContext();
|
||||
|
||||
const [title, setTitle] = createSignal("");
|
||||
const [description, setDescription] = createSignal("");
|
||||
@ -14,19 +15,22 @@ export const Categories: Component = () => {
|
||||
const [isCreating, setIsCreating] = createSignal(false);
|
||||
const [showForm, setShowForm] = createSignal(false);
|
||||
|
||||
const handleCreateList = async () => {
|
||||
const handleCreatestack = async () => {
|
||||
if (description().trim().length === 0 || title().trim().length === 0)
|
||||
return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createList(title().trim(), description().trim());
|
||||
await createStack(title().trim(), description().trim());
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setShowForm(false);
|
||||
onRefetchImages(); // Refresh the lists
|
||||
onRefetchImages(); // Refresh the stacks
|
||||
} 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 {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@ -34,9 +38,9 @@ export const Categories: Component = () => {
|
||||
|
||||
return (
|
||||
<div class="rounded-xl bg-white p-4 flex flex-col gap-2">
|
||||
<h2 class="text-xl font-bold">Generated Lists</h2>
|
||||
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
|
||||
<For each={lists()}>{(list) => <ListCard list={list} />}</For>
|
||||
<h2 class="text-xl font-bold">Generated stacks</h2>
|
||||
<div class="w-full grid grid-cols-1 md:grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
|
||||
<For each={stacks()}>{(stack) => <StackCard stack={stack} />}</For>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
+ Create List
|
||||
+ Create stack
|
||||
</Button>
|
||||
</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">
|
||||
<div class="p-6">
|
||||
<Dialog.Title class="text-xl font-bold text-neutral-900 mb-4">
|
||||
Create New List
|
||||
Create New stack
|
||||
</Dialog.Title>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="list-title"
|
||||
for="stack-title"
|
||||
class="block text-sm font-medium text-neutral-700 mb-2"
|
||||
>
|
||||
List Title
|
||||
stack Title
|
||||
</label>
|
||||
<input
|
||||
id="list-title"
|
||||
id="stack-title"
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(e) =>
|
||||
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"
|
||||
disabled={isCreating()}
|
||||
/>
|
||||
@ -81,18 +85,18 @@ export const Categories: Component = () => {
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="list-description"
|
||||
for="stack-description"
|
||||
class="block text-sm font-medium text-neutral-700 mb-2"
|
||||
>
|
||||
List Description
|
||||
stack Description
|
||||
</label>
|
||||
<textarea
|
||||
id="list-description"
|
||||
id="stack-description"
|
||||
value={description()}
|
||||
onInput={(e) =>
|
||||
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"
|
||||
rows="4"
|
||||
disabled={isCreating()}
|
||||
@ -103,7 +107,7 @@ export const Categories: Component = () => {
|
||||
<div class="flex gap-3 mt-6">
|
||||
<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"
|
||||
onClick={handleCreateList}
|
||||
onClick={handleCreatestack}
|
||||
disabled={
|
||||
isCreating() ||
|
||||
!title().trim() ||
|
||||
@ -112,7 +116,7 @@ export const Categories: Component = () => {
|
||||
>
|
||||
{isCreating()
|
||||
? "Creating..."
|
||||
: "Create List"}
|
||||
: "Create stack"}
|
||||
</Button>
|
||||
<Button
|
||||
class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium"
|
||||
|
@ -5,7 +5,7 @@ import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||
const NUMBER_OF_MAX_RECENT_IMAGES = 10;
|
||||
|
||||
export const Recent: Component = () => {
|
||||
const { userImages } = useSearchImageContext();
|
||||
const { userImages, onDeleteImage } = useSearchImageContext();
|
||||
|
||||
const latestImages = () =>
|
||||
userImages()
|
||||
@ -18,9 +18,9 @@ export const Recent: Component = () => {
|
||||
return (
|
||||
<div class="rounded-xl bg-white p-4 flex flex-col gap-2">
|
||||
<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()}>
|
||||
{(image) => <ImageComponent ID={image.ImageID} />}
|
||||
{(image) => <ImageComponent ID={image.ID} onDelete={onDeleteImage} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,29 +1,33 @@
|
||||
import { ImageComponentFullHeight } from "@components/image";
|
||||
import { StackCard } from "@components/stack-card";
|
||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { useNavigate, useParams } from "@solidjs/router";
|
||||
import { For, type Component } from "solid-js";
|
||||
import SolidjsMarkdown from "solidjs-markdown";
|
||||
import { ListCard } from "@components/list-card";
|
||||
|
||||
export const ImagePage: Component = () => {
|
||||
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 (
|
||||
<main class="flex flex-col items-center gap-4">
|
||||
<div class="w-full bg-white rounded-xl p-4">
|
||||
<ImageComponentFullHeight ID={imageId} />
|
||||
<ImageComponentFullHeight ID={imageId} onDelete={(id) => {
|
||||
onDeleteImage(id);
|
||||
nav("/");
|
||||
}} />
|
||||
</div>
|
||||
<div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4">
|
||||
<h2 class="font-bold text-2xl">Description</h2>
|
||||
<h2 class="font-bold text-2xl">Stacks</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<For each={image()?.Image.ImageLists}>
|
||||
<For each={image()?.ImageStacks}>
|
||||
{(imageList) => (
|
||||
<ListCard
|
||||
list={lists().find((l) => l.ID === imageList.ListID)!}
|
||||
<StackCard
|
||||
stack={stacks().find((l) => l.ID === imageList.StackID)!}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@ -31,7 +35,7 @@ export const ImagePage: Component = () => {
|
||||
</div>
|
||||
<div class="w-full bg-white rounded-xl p-4">
|
||||
<h2 class="font-bold text-2xl">Description</h2>
|
||||
<SolidjsMarkdown>{image()?.Image.Description}</SolidjsMarkdown>
|
||||
<SolidjsMarkdown>{image()?.Description}</SolidjsMarkdown>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
@ -4,4 +4,4 @@ export * from "./settings";
|
||||
export * from "./login";
|
||||
export * from "./search";
|
||||
export * from "./all-images";
|
||||
export * from "./list";
|
||||
export * from "./stack";
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -4,10 +4,13 @@ import { IconSearch } from "@tabler/icons-solidjs";
|
||||
import { useSearch } from "./search";
|
||||
import { JustTheImageWhatAreTheseNames } from "@network/index";
|
||||
import { ImageComponent } from "@components/image";
|
||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||
|
||||
export const SearchPage: Component = () => {
|
||||
const fuse = useSearch();
|
||||
|
||||
const { onDeleteImage } = useSearchImageContext();
|
||||
|
||||
const [searchItems, setSearchItems] =
|
||||
createSignal<JustTheImageWhatAreTheseNames>([]);
|
||||
|
||||
@ -38,7 +41,7 @@ export const SearchPage: Component = () => {
|
||||
<Search.Content class="container relative w-full rounded-xl bg-white p-4 grid grid-cols-3 gap-4">
|
||||
<Search.Arrow />
|
||||
<For each={searchItems()}>
|
||||
{(item) => <ImageComponent ID={item.ImageID} />}
|
||||
{(item) => <ImageComponent ID={item.ID} onDelete={onDeleteImage} />}
|
||||
</For>
|
||||
<Search.NoResult>No result found</Search.NoResult>
|
||||
</Search.Content>
|
||||
|
@ -7,6 +7,6 @@ export const useSearch = () => {
|
||||
return () =>
|
||||
new Fuse(userImages(), {
|
||||
shouldSort: true,
|
||||
keys: ["Image.Description"],
|
||||
keys: ["Description"],
|
||||
});
|
||||
};
|
||||
|
304
frontend/src/pages/stack/index.tsx
Normal file
304
frontend/src/pages/stack/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
37
frontend/src/utils/show-toast.tsx
Normal file
37
frontend/src/utils/show-toast.tsx
Normal 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>
|
||||
));
|
||||
};
|
@ -24,6 +24,7 @@ class SharedToken: Plugin {
|
||||
}
|
||||
|
||||
sharedDefaults.set(token, forKey: sharedTokenKey)
|
||||
sharedDefaults.synchronize()
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user