53 Commits

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

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

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

View File

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

View File

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

View File

@ -1,19 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Logs struct {
Log string
ImageID uuid.UUID
CreatedAt *time.Time
}

View File

@ -1,22 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type ProcessingLists struct {
ID uuid.UUID `sql:"primary_key"`
UserID uuid.UUID
Title string
Fields string
Status Progress
CreatedAt *time.Time
}

View File

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

View File

@ -1,17 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
)
type Schemas struct {
ID uuid.UUID `sql:"primary_key"`
ListID uuid.UUID
}

View File

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

View File

@ -1,20 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type UserImages struct {
ID uuid.UUID `sql:"primary_key"`
ImageID uuid.UUID
UserID uuid.UUID
CreatedAt *time.Time
}

View File

@ -1,19 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
)
type UserImagesToProcess struct {
ID uuid.UUID `sql:"primary_key"`
Status Progress
ImageID uuid.UUID
UserID uuid.UUID
}

View File

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

View File

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

View File

@ -1,84 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var ImageLists = newImageListsTable("haystack", "image_lists", "")
type imageListsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ImageID postgres.ColumnString
ListID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ImageListsTable struct {
imageListsTable
EXCLUDED imageListsTable
}
// AS creates new ImageListsTable with assigned alias
func (a ImageListsTable) AS(alias string) *ImageListsTable {
return newImageListsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ImageListsTable with assigned schema name
func (a ImageListsTable) FromSchema(schemaName string) *ImageListsTable {
return newImageListsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ImageListsTable with assigned table prefix
func (a ImageListsTable) WithPrefix(prefix string) *ImageListsTable {
return newImageListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ImageListsTable with assigned table suffix
func (a ImageListsTable) WithSuffix(suffix string) *ImageListsTable {
return newImageListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newImageListsTable(schemaName, tableName, alias string) *ImageListsTable {
return &ImageListsTable{
imageListsTable: newImageListsTableImpl(schemaName, tableName, alias),
EXCLUDED: newImageListsTableImpl("", "excluded", ""),
}
}
func newImageListsTableImpl(schemaName, tableName, alias string) imageListsTable {
var (
IDColumn = postgres.StringColumn("id")
ImageIDColumn = postgres.StringColumn("image_id")
ListIDColumn = postgres.StringColumn("list_id")
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, ListIDColumn}
mutableColumns = postgres.ColumnList{ImageIDColumn, ListIDColumn}
defaultColumns = postgres.ColumnList{IDColumn}
)
return imageListsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ImageID: ImageIDColumn,
ListID: ListIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -24,7 +24,6 @@ type imageSchemaItemsTable struct {
AllColumns postgres.ColumnList
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,
}
}

View File

@ -0,0 +1,81 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var ImageStacks = newImageStacksTable("haystack", "image_stacks", "")
type imageStacksTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ImageID postgres.ColumnString
StackID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
}
type ImageStacksTable struct {
imageStacksTable
EXCLUDED imageStacksTable
}
// AS creates new ImageStacksTable with assigned alias
func (a ImageStacksTable) AS(alias string) *ImageStacksTable {
return newImageStacksTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ImageStacksTable with assigned schema name
func (a ImageStacksTable) FromSchema(schemaName string) *ImageStacksTable {
return newImageStacksTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ImageStacksTable with assigned table prefix
func (a ImageStacksTable) WithPrefix(prefix string) *ImageStacksTable {
return newImageStacksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ImageStacksTable with assigned table suffix
func (a ImageStacksTable) WithSuffix(suffix string) *ImageStacksTable {
return newImageStacksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newImageStacksTable(schemaName, tableName, alias string) *ImageStacksTable {
return &ImageStacksTable{
imageStacksTable: newImageStacksTableImpl(schemaName, tableName, alias),
EXCLUDED: newImageStacksTableImpl("", "excluded", ""),
}
}
func newImageStacksTableImpl(schemaName, tableName, alias string) imageStacksTable {
var (
IDColumn = postgres.StringColumn("id")
ImageIDColumn = postgres.StringColumn("image_id")
StackIDColumn = postgres.StringColumn("stack_id")
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, StackIDColumn}
mutableColumns = postgres.ColumnList{ImageIDColumn, StackIDColumn}
)
return imageStacksTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ImageID: ImageIDColumn,
StackID: StackIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
}
}

View File

@ -1,90 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Lists = newListsTable("haystack", "lists", "")
type listsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
UserID postgres.ColumnString
Name postgres.ColumnString
Description postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ListsTable struct {
listsTable
EXCLUDED listsTable
}
// AS creates new ListsTable with assigned alias
func (a ListsTable) AS(alias string) *ListsTable {
return newListsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ListsTable with assigned schema name
func (a ListsTable) FromSchema(schemaName string) *ListsTable {
return newListsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ListsTable with assigned table prefix
func (a ListsTable) WithPrefix(prefix string) *ListsTable {
return newListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ListsTable with assigned table suffix
func (a ListsTable) WithSuffix(suffix string) *ListsTable {
return newListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newListsTable(schemaName, tableName, alias string) *ListsTable {
return &ListsTable{
listsTable: newListsTableImpl(schemaName, tableName, alias),
EXCLUDED: newListsTableImpl("", "excluded", ""),
}
}
func newListsTableImpl(schemaName, tableName, alias string) listsTable {
var (
IDColumn = postgres.StringColumn("id")
UserIDColumn = postgres.StringColumn("user_id")
NameColumn = postgres.StringColumn("name")
DescriptionColumn = postgres.StringColumn("description")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn}
)
return listsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
UserID: UserIDColumn,
Name: NameColumn,
Description: DescriptionColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -1,84 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Logs = newLogsTable("haystack", "logs", "")
type logsTable struct {
postgres.Table
// Columns
Log postgres.ColumnString
ImageID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type LogsTable struct {
logsTable
EXCLUDED logsTable
}
// AS creates new LogsTable with assigned alias
func (a LogsTable) AS(alias string) *LogsTable {
return newLogsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new LogsTable with assigned schema name
func (a LogsTable) FromSchema(schemaName string) *LogsTable {
return newLogsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new LogsTable with assigned table prefix
func (a LogsTable) WithPrefix(prefix string) *LogsTable {
return newLogsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new LogsTable with assigned table suffix
func (a LogsTable) WithSuffix(suffix string) *LogsTable {
return newLogsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newLogsTable(schemaName, tableName, alias string) *LogsTable {
return &LogsTable{
logsTable: newLogsTableImpl(schemaName, tableName, alias),
EXCLUDED: newLogsTableImpl("", "excluded", ""),
}
}
func newLogsTableImpl(schemaName, tableName, alias string) logsTable {
var (
LogColumn = postgres.StringColumn("log")
ImageIDColumn = postgres.StringColumn("image_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return logsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
Log: LogColumn,
ImageID: ImageIDColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -1,93 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var ProcessingLists = newProcessingListsTable("haystack", "processing_lists", "")
type processingListsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
UserID postgres.ColumnString
Title postgres.ColumnString
Fields postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProcessingListsTable struct {
processingListsTable
EXCLUDED processingListsTable
}
// AS creates new ProcessingListsTable with assigned alias
func (a ProcessingListsTable) AS(alias string) *ProcessingListsTable {
return newProcessingListsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProcessingListsTable with assigned schema name
func (a ProcessingListsTable) FromSchema(schemaName string) *ProcessingListsTable {
return newProcessingListsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProcessingListsTable with assigned table prefix
func (a ProcessingListsTable) WithPrefix(prefix string) *ProcessingListsTable {
return newProcessingListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProcessingListsTable with assigned table suffix
func (a ProcessingListsTable) WithSuffix(suffix string) *ProcessingListsTable {
return newProcessingListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProcessingListsTable(schemaName, tableName, alias string) *ProcessingListsTable {
return &ProcessingListsTable{
processingListsTable: newProcessingListsTableImpl(schemaName, tableName, alias),
EXCLUDED: newProcessingListsTableImpl("", "excluded", ""),
}
}
func newProcessingListsTableImpl(schemaName, tableName, alias string) processingListsTable {
var (
IDColumn = postgres.StringColumn("id")
UserIDColumn = postgres.StringColumn("user_id")
TitleColumn = postgres.StringColumn("title")
FieldsColumn = postgres.StringColumn("fields")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn, StatusColumn, CreatedAtColumn}
)
return processingListsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
UserID: UserIDColumn,
Title: TitleColumn,
Fields: FieldsColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -21,11 +21,10 @@ type schemaItemsTable struct {
Item postgres.ColumnString
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,
}
}

View File

@ -1,81 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Schemas = newSchemasTable("haystack", "schemas", "")
type schemasTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ListID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type SchemasTable struct {
schemasTable
EXCLUDED schemasTable
}
// AS creates new SchemasTable with assigned alias
func (a SchemasTable) AS(alias string) *SchemasTable {
return newSchemasTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new SchemasTable with assigned schema name
func (a SchemasTable) FromSchema(schemaName string) *SchemasTable {
return newSchemasTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new SchemasTable with assigned table prefix
func (a SchemasTable) WithPrefix(prefix string) *SchemasTable {
return newSchemasTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new SchemasTable with assigned table suffix
func (a SchemasTable) WithSuffix(suffix string) *SchemasTable {
return newSchemasTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newSchemasTable(schemaName, tableName, alias string) *SchemasTable {
return &SchemasTable{
schemasTable: newSchemasTableImpl(schemaName, tableName, alias),
EXCLUDED: newSchemasTableImpl("", "excluded", ""),
}
}
func newSchemasTableImpl(schemaName, tableName, alias string) schemasTable {
var (
IDColumn = postgres.StringColumn("id")
ListIDColumn = postgres.StringColumn("list_id")
allColumns = postgres.ColumnList{IDColumn, ListIDColumn}
mutableColumns = postgres.ColumnList{ListIDColumn}
defaultColumns = postgres.ColumnList{IDColumn}
)
return schemasTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ListID: ListIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -0,0 +1,90 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Stacks = newStacksTable("haystack", "stacks", "")
type stacksTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
UserID postgres.ColumnString
Status postgres.ColumnString
Name postgres.ColumnString
Description postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
}
type StacksTable struct {
stacksTable
EXCLUDED stacksTable
}
// AS creates new StacksTable with assigned alias
func (a StacksTable) AS(alias string) *StacksTable {
return newStacksTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new StacksTable with assigned schema name
func (a StacksTable) FromSchema(schemaName string) *StacksTable {
return newStacksTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new StacksTable with assigned table prefix
func (a StacksTable) WithPrefix(prefix string) *StacksTable {
return newStacksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new StacksTable with assigned table suffix
func (a StacksTable) WithSuffix(suffix string) *StacksTable {
return newStacksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newStacksTable(schemaName, tableName, alias string) *StacksTable {
return &StacksTable{
stacksTable: newStacksTableImpl(schemaName, tableName, alias),
EXCLUDED: newStacksTableImpl("", "excluded", ""),
}
}
func newStacksTableImpl(schemaName, tableName, alias string) stacksTable {
var (
IDColumn = postgres.StringColumn("id")
UserIDColumn = postgres.StringColumn("user_id")
StatusColumn = postgres.StringColumn("status")
NameColumn = postgres.StringColumn("name")
DescriptionColumn = postgres.StringColumn("description")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, UserIDColumn, StatusColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{UserIDColumn, StatusColumn, NameColumn, DescriptionColumn, CreatedAtColumn}
)
return stacksTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
UserID: UserIDColumn,
Status: StatusColumn,
Name: NameColumn,
Description: DescriptionColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
}
}

View File

@ -11,14 +11,9 @@ package table
// this method only once at the beginning of the program.
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)
}

View File

@ -1,87 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var UserImages = newUserImagesTable("haystack", "user_images", "")
type userImagesTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
ImageID postgres.ColumnString
UserID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type UserImagesTable struct {
userImagesTable
EXCLUDED userImagesTable
}
// AS creates new UserImagesTable with assigned alias
func (a UserImagesTable) AS(alias string) *UserImagesTable {
return newUserImagesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new UserImagesTable with assigned schema name
func (a UserImagesTable) FromSchema(schemaName string) *UserImagesTable {
return newUserImagesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new UserImagesTable with assigned table prefix
func (a UserImagesTable) WithPrefix(prefix string) *UserImagesTable {
return newUserImagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new UserImagesTable with assigned table suffix
func (a UserImagesTable) WithSuffix(suffix string) *UserImagesTable {
return newUserImagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newUserImagesTable(schemaName, tableName, alias string) *UserImagesTable {
return &UserImagesTable{
userImagesTable: newUserImagesTableImpl(schemaName, tableName, alias),
EXCLUDED: newUserImagesTableImpl("", "excluded", ""),
}
}
func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable {
var (
IDColumn = postgres.StringColumn("id")
ImageIDColumn = postgres.StringColumn("image_id")
UserIDColumn = postgres.StringColumn("user_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn}
)
return userImagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
ImageID: ImageIDColumn,
UserID: UserIDColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

@ -1,87 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var UserImagesToProcess = newUserImagesToProcessTable("haystack", "user_images_to_process", "")
type userImagesToProcessTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
Status postgres.ColumnString
ImageID postgres.ColumnString
UserID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type UserImagesToProcessTable struct {
userImagesToProcessTable
EXCLUDED userImagesToProcessTable
}
// AS creates new UserImagesToProcessTable with assigned alias
func (a UserImagesToProcessTable) AS(alias string) *UserImagesToProcessTable {
return newUserImagesToProcessTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new UserImagesToProcessTable with assigned schema name
func (a UserImagesToProcessTable) FromSchema(schemaName string) *UserImagesToProcessTable {
return newUserImagesToProcessTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new UserImagesToProcessTable with assigned table prefix
func (a UserImagesToProcessTable) WithPrefix(prefix string) *UserImagesToProcessTable {
return newUserImagesToProcessTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new UserImagesToProcessTable with assigned table suffix
func (a UserImagesToProcessTable) WithSuffix(suffix string) *UserImagesToProcessTable {
return newUserImagesToProcessTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newUserImagesToProcessTable(schemaName, tableName, alias string) *UserImagesToProcessTable {
return &UserImagesToProcessTable{
userImagesToProcessTable: newUserImagesToProcessTableImpl(schemaName, tableName, alias),
EXCLUDED: newUserImagesToProcessTableImpl("", "excluded", ""),
}
}
func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userImagesToProcessTable {
var (
IDColumn = postgres.StringColumn("id")
StatusColumn = postgres.StringColumn("status")
ImageIDColumn = postgres.StringColumn("image_id")
UserIDColumn = postgres.StringColumn("user_id")
allColumns = postgres.ColumnList{IDColumn, StatusColumn, ImageIDColumn, UserIDColumn}
mutableColumns = postgres.ColumnList{StatusColumn, ImageIDColumn, UserIDColumn}
defaultColumns = postgres.ColumnList{IDColumn, StatusColumn}
)
return userImagesToProcessTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
Status: StatusColumn,
ImageID: ImageIDColumn,
UserID: UserIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "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,

View File

@ -26,7 +26,7 @@ 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: "policy/images",
Temperature: 0.3,
@ -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
}

View File

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

View File

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

View File

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

View File

@ -13,37 +13,58 @@ import (
"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
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 {
@ -68,22 +89,15 @@ 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
}
imagesReturn := ImagesReturn{
UserImages: images,
ProcessingImages: processingImages,
Lists: listsWithImages,
UserImages: images,
Stacks: stacksWithImages,
}
middleware.WriteJsonOrError(h.logger, imagesReturn, w)
@ -96,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
}
@ -129,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}", middleware.WithLimit(h.logger, h.limitsManager.HasReachedImageLimit, h.uploadImage))
r.Delete("/{image-id}", h.deleteImage)
})
}
func CreateImageHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods) 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")
@ -169,5 +226,7 @@ func CreateImageHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods) I
imageModel: imageModel,
userModel: userModel,
limitsManager: limitsManager,
jwtManager: jwtManager,
processor: processor,
}
}

View File

@ -57,11 +57,12 @@ type TestUser struct {
}
type TestContext struct {
db *sql.DB
router chi.Router
server *httptest.Server
users []TestUser
cleanup func()
db *sql.DB
router chi.Router
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,

View File

@ -12,7 +12,7 @@ import (
const (
LISTS_LIMIT = 10
IMAGE_LIMIT = 50
IMAGE_LIMIT = 10
)
type LimitsManager struct {
@ -29,9 +29,9 @@ type listCount struct {
}
func (m *LimitsManager) HasReachedStackLimit(userID uuid.UUID) (bool, error) {
getStacks := Lists.
SELECT(COUNT(Lists.UserID).AS("listCount.ListCount")).
WHERE(Lists.UserID.EQ(UUID(userID)))
getStacks := Stacks.
SELECT(COUNT(Stacks.UserID).AS("listCount.ListCount")).
WHERE(Stacks.UserID.EQ(UUID(userID)))
var count listCount
err := getStacks.Query(m.dbPool, &count)
@ -44,9 +44,9 @@ type imageCount struct {
}
func (m *LimitsManager) HasReachedImageLimit(userID uuid.UUID) (bool, error) {
getStacks := UserImages.
SELECT(COUNT(UserImages.UserID).AS("imageCount.ImageCount")).
WHERE(UserImages.UserID.EQ(UUID(userID)))
getStacks := Image.
SELECT(COUNT(Image.UserID).AS("imageCount.ImageCount")).
WHERE(Image.UserID.EQ(UUID(userID)))
var count imageCount
err := getStacks.Query(m.dbPool, &count)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -49,28 +49,20 @@ func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, err
}
type UserImageWithImage struct {
model.UserImages
Image struct {
model.Image
ImageLists []model.ImageLists
}
model.Image
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
model.Stacks
Schema struct {
model.Schemas
SchemaItems []model.SchemaItems
}
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)

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package main
package notifications
import (
"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),
}
}

View File

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

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

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

View File

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

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

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

View File

@ -2,12 +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"
@ -24,31 +27,33 @@ func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (cli
return client.ImageInfo, nil
}
func setupRouter(db *sql.DB) chi.Router {
imageModel := models.NewImageModel(db)
stackModel := models.NewListModel(db)
func setupRouter(db *sql.DB, jwtManager *ourmiddleware.JwtManager) (chi.Router, error) {
limitsManager := limits.CreateLimitsManager(db)
stackHandler := stacks.CreateStackHandler(db, limitsManager)
authHandler := auth.CreateAuthHandler(db)
imageHandler := images.CreateImageHandler(db, limitsManager)
imageModel := models.NewImageModel(db)
stackModel := models.NewStackModel(db)
notifier := NewNotifier[Notification](10)
notifier := notifications.NewNotifier[notifications.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, &notifier)
go ListenNewStackEvents(db)
go ListenProcessingStackStatus(db, stackModel, &notifier)
imageProcessorLogger := createLogger("Image Processor", os.Stdout)
imageProcessor, err := processor.NewImageProcessor(imageProcessorLogger, imageModel, stackModel, limitsManager, &notifier)
if err != nil {
return nil, fmt.Errorf("processor: %w", err)
}
stackProcessorLog := createLogger("Stack Processor", os.Stdout)
stackProcessor, err := processor.NewStackProcessor(stackProcessorLog, stackModel, &notifier)
if err != nil {
return nil, fmt.Errorf("processor: %w", err)
}
go imageProcessor.Processor.Work()
go stackProcessor.Processor.Work()
stackHandler := stacks.CreateStackHandler(db, limitsManager, jwtManager, stackProcessor.Processor)
authHandler := auth.CreateAuthHandler(db, jwtManager)
imageHandler := images.CreateImageHandler(db, limitsManager, jwtManager, imageProcessor.Processor)
r := chi.NewRouter()
r.Use(middleware.Logger)
@ -59,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(&notifier))
})
logWriter := DatabaseWriter{
dbPool: db,
}
r.Route("/logs", createLogHandler(&logWriter))
return r
return r, nil
}

View File

@ -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(),
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,
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),
image BYTEA NOT NULL,
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 (
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();

View File

@ -2,23 +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
logger *log.Logger
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) {
@ -29,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) {
@ -46,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)
@ -79,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)
@ -94,11 +101,107 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
type CreateStackBody struct {
Title string `json:"title"`
func (h *StackHandler) deleteImageFromStack(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// We want a regular string because AI will take care of creating these for us.
Fields string `json:"fields"`
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"`
Description string `json:"description"`
}
func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter, r *http.Request) {
@ -108,57 +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.WithLimit(h.logger, h.limitsManager.HasReachedStackLimit, middleware.WithValidatedPost(h.createStack)))
r.Patch("/{listID}", middleware.WithValidatedPost(h.editStack))
r.Delete("/{listID}", h.deleteStack)
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, limitsManager limits.LimitsManagerMethods) 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,
limitsManager,
logger: logger,
imageModel: imageModel,
stackModel: stackModel,
limitsManager: limitsManager,
jwtManager: jwtManager,
processor: processor,
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,280 +1,189 @@
//
// ShareViewController.swift
// 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
extractedImageName = provider.suggestedName ?? "image"
if let dotRange = extractedImageName.range(of: ".", options: .backwards) {
extractedImageName = String(extractedImageName[..<dotRange.lowerBound])
}
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
}
} 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
}
// Sanitize the base name slightly (remove problematic characters for a filename)
let anInvalidCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:")
self.baseImageName = self.baseImageName.components(separatedBy: anInvalidCharacters).joined(separator: "_")
if self.baseImageName.isEmpty { self.baseImageName = "SharedImage" } // Ensure not empty
} else {
print("Attachment is not an image.")
self.imageItemProvider = nil // Ensure it's nil so isContentValid fails
}
}
override func isContentValid() -> Bool {
// 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 {
print("Error: No image item provider found when posting.")
informUserAndCancel(message: "No image found to share.")
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
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)")
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
}
var imageData: Data?
var finalImageNameWithExtension: String
var mimeType: String = "application/octet-stream" // Default MIME type
// Determine base name (without extension)
var currentBaseName = self.baseImageName // Use the one prepared in viewDidLoad
if let suggested = provider.suggestedName, !suggested.isEmpty {
if let dotRange = suggested.range(of: ".", options: .backwards) {
currentBaseName = String(suggested[..<dotRange.lowerBound])
} else {
currentBaseName = suggested
}
let anInvalidCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:")
currentBaseName = currentBaseName.components(separatedBy: anInvalidCharacters).joined(separator: "_")
if currentBaseName.isEmpty { currentBaseName = "DefaultImageName" }
}
if let url = item as? URL {
print("Image provided as URL: \(url)")
finalImageNameWithExtension = url.lastPathComponent // Includes extension
// Ensure baseName is updated if URL provides a different one
if let dotRange = finalImageNameWithExtension.range(of:".", options: .backwards) {
currentBaseName = String(finalImageNameWithExtension[..<dotRange.lowerBound])
} else {
currentBaseName = finalImageNameWithExtension // No extension in name
}
do {
imageData = try Data(contentsOf: url)
// Determine MIME type from URL extension
let pathExtension = url.pathExtension.lowercased()
mimeType = self.mimeType(forPathExtension: pathExtension)
if !finalImageNameWithExtension.contains(".") && !pathExtension.isEmpty { // if original lastPathComponent had no ext
finalImageNameWithExtension = "\(currentBaseName).\(pathExtension)"
} else if !finalImageNameWithExtension.contains(".") && pathExtension.isEmpty { // no ext anywhere
finalImageNameWithExtension = "\(currentBaseName).jpg" // default to jpg
mimeType = "image/jpeg"
}
} catch {
print("Error creating Data from URL: \(error)")
self.informUserAndCancel(message: "Could not read image file.")
return
}
} else if let image = item as? UIImage {
print("Image provided as UIImage")
// Prefer PNG for screenshots/UIImage, fallback to JPEG
if let data = image.pngData() {
imageData = data
mimeType = "image/png"
finalImageNameWithExtension = "\(currentBaseName).png"
} else if let data = image.jpegData(compressionQuality: 0.9) { // Good quality JPEG
imageData = data
mimeType = "image/jpeg"
finalImageNameWithExtension = "\(currentBaseName).jpg"
} else {
print("Could not convert UIImage to Data.")
self.informUserAndCancel(message: "Could not process image.")
return
}
} else if let data = item as? Data {
print("Image provided as Data")
imageData = data
// We have raw data, try to use suggestedName's extension or default to png/jpg
var determinedExtension = "png" // Default
if let suggested = provider.suggestedName,
let dotRange = suggested.range(of: ".", options: .backwards) {
let ext = String(suggested[dotRange.upperBound...]).lowercased()
if ["png", "jpg", "jpeg", "gif"].contains(ext) {
determinedExtension = ext
}
}
mimeType = self.mimeType(forPathExtension: determinedExtension)
finalImageNameWithExtension = "\(currentBaseName).\(determinedExtension)"
} else {
print("Error: Could not get image data in a usable format. Item type: \(type(of: item)) Item: \(String(describing: item))")
self.informUserAndCancel(message: "Unsupported image format.")
guard let provider = self.imageItemProvider else {
print("Error: No image item provider found when posting.")
// Inform the user or log an error
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
return
}
guard let dataToUpload = imageData else {
print("Error: No image data to upload after processing.")
self.informUserAndCancel(message: "Image data is missing.")
return
}
// Ensure finalImageNameWithExtension is not just an extension like ".png"
if finalImageNameWithExtension.starts(with: ".") {
finalImageNameWithExtension = "\(self.baseImageName)\(finalImageNameWithExtension)"
}
if finalImageNameWithExtension.isEmpty || !finalImageNameWithExtension.contains(".") {
// Fallback if somehow the name is bad
finalImageNameWithExtension = "\(self.baseImageName).png"
mimeType = "image/png"
}
print("Uploading image: \(finalImageNameWithExtension), MIME: \(mimeType), Size: \(dataToUpload.count) bytes")
self.uploadRawData(dataToUpload, imageNameWithExtension: finalImageNameWithExtension, mimeType: mimeType, bearerToken: token)
}
}
func uploadRawData(_ rawData: Data, imageNameWithExtension: String, mimeType: String, bearerToken: String) {
// The imageNameWithExtension should already include the correct extension.
// The server URL seems to expect the filename as a path component.
let uploadURL = uploadURLBase.appendingPathComponent(imageNameWithExtension)
print("Final Upload URL: \(uploadURL.absoluteString)")
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue(mimeType, forHTTPHeaderField: "Content-Type") // Use determined MIME type
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.setValue("\(rawData.count)", forHTTPHeaderField: "Content-Length")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
DispatchQueue.main.async {
// Load the image data asynchronously
provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
print("Upload finished. Error: \(error?.localizedDescription ?? "None")")
if let httpResponse = response as? HTTPURLResponse {
print("HTTP Status: \(httpResponse.statusCode)")
if let responseData = data, let responseString = String(data: responseData, encoding: .utf8) {
print("Response Data: \(responseString)")
}
}
if let error = error {
print("Upload failed: \(error.localizedDescription)")
self.informUserAndCancel(message: "Upload failed: \(error.localizedDescription)")
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
let errorDescription = "Upload failed. Server returned status code \(httpResponse.statusCode)."
print(errorDescription)
self.informUserAndCancel(message: errorDescription)
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("Upload successful for \(imageNameWithExtension)")
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
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)
}
}
}
func uploadRawData(_ rawData: Data, imageName: String, bearerToken: String) {
// bearerToken is guaranteed to be non-nil here due to the guard in didSelectPost
let uploadURLwithName = uploadURL.appendingPathComponent(imageName)
var request = URLRequest(url: uploadURLwithName)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue("application/oclet-stream", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
// **IMPORTANT:** Complete the extension request on the main thread
DispatchQueue.main.async {
print("Upload finished. Error: \(error?.localizedDescription ?? "None"), Response: \(response?.description ?? "None")")
if let error = error {
// Handle upload error (e.g., show an alert to the user)
print("Upload failed: \(error.localizedDescription)")
self?.extensionContext!.cancelRequest(withError: error)
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
// Handle non-success HTTP status codes
let errorDescription = "Server returned status code \(httpResponse.statusCode)"
print(errorDescription)
self?.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorDescription]))
}
else {
// Upload was successful
print("Upload successful")
// Complete the request when the upload is done
self?.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
task.resume()
task.resume()
}
override func configurationItems() -> [Any]! {
// No configuration items needed for this simple image uploader.
return []
}
func refreshToken(completion: @escaping (String?) -> Void) {
guard let refreshToken = self.refreshToken else {
completion(nil)
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)
}
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")
// 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
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)
}
}
// 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
}
task.resume()
}
override func configurationItems() -> [Any]! {
// You can add items here if you want to allow the user to enter additional info
// e.g., a text field for a caption.
// This example only handles image upload, so no config items are needed.
return []
}
}

View File

@ -1,13 +1,13 @@
import { Navigate, Route, Router } from "@solidjs/router";
import { onAndroidMount } from "./mobile";
import {
FrontPage,
ImagePage,
Login,
Settings,
SearchPage,
AllImages,
List,
FrontPage,
ImagePage,
Login,
Settings,
SearchPage,
AllImages,
Stack,
} from "./pages";
import { SearchImageContextProvider } from "@contexts/SearchImageContext";
import { WithNotifications } from "@contexts/Notifications";
@ -15,32 +15,46 @@ 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();
onSendImage();
onAndroidMount();
onSendImage();
return (
<SearchImageContextProvider>
<Router>
<Route path="/" component={AppWrapper}>
<Route path="/login" component={Login} />
return (
<SearchImageContextProvider>
<Router>
<Route path="/" component={AppWrapper}>
<Route path="/login" component={Login} />
<Route path="/" component={ProtectedRoute}>
<Route path="/" component={WithNotifications}>
<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="/settings" component={Settings} />
</Route>
</Route>
</Route>
</Route>
<Route path="*" component={() => <Navigate href="/" />} />
</Router>
</SearchImageContextProvider>
);
<Route path="/" component={ProtectedRoute}>
<Route path="/" component={WithNotifications}>
<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="/stack/:stackID" component={Stack} />
<Route path="/settings" component={Settings} />
</Route>
</Route>
</Route>
</Route>
<Route path="*" component={() => <Navigate href="/" />} />
</Router>
<Portal>
<Toast.Region class="fixed w-72 top-4 right-4 z-50 flex flex-col space-y-2">
<Toast.List />
</Toast.Region>
</Portal>
</SearchImageContextProvider>
);
};

View File

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

View File

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

View File

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

View File

@ -1,46 +1,48 @@
import { Navigate } from "@solidjs/router";
import { 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");
const token = localStorage.getItem("access");
if (token == null) {
return false;
}
if (token == null) {
return false;
}
try {
jwtDecode(token);
return true;
} catch (err) {
return false;
}
try {
jwtDecode(token);
return true;
} catch (err) {
return false;
}
};
const accessTokenPropertiesValidator = object({
UserID: string(),
Type: literal('access'),
exp: pipe(number(), transform(i => new Date(i)))
});
export const getTokenProperties = (token: string): InferOutput<typeof accessTokenPropertiesValidator> => {
const decoded = jwtDecode(token);
return parse(accessTokenPropertiesValidator, decoded);
}
export const ProtectedRoute: Component<ParentProps> = (props) => {
const isValid = isTokenValid();
const isValid = isTokenValid();
if (isValid) {
const token = localStorage.getItem("access");
if (token == null) {
throw new Error("unreachable");
}
if (isValid) {
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 (
<Show when={isValid} fallback={<Navigate href="/login" />}>
{props.children}
</Show>
);
return (
<Show when={isValid} fallback={<Navigate href="/login" />}>
{props.children}
</Show>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,232 +1,358 @@
import { getTokenProperties } from "@components/protected-route";
import { fetch } from "@tauri-apps/plugin-http";
import { platform } from "@tauri-apps/plugin-os";
import { save_token } from "tauri-plugin-ios-shared-token-api";
import {
type InferOutput,
array,
literal,
null_,
nullable,
parse,
pipe,
strictObject,
string,
transform,
union,
uuid,
type InferOutput,
array,
null_,
literal,
nullable,
parse,
pipe,
safeParse,
strictObject,
string,
transform,
union,
uuid,
} from "valibot";
type BaseRequestParams = Partial<{
path: string;
body: RequestInit["body"];
method: "GET" | "POST";
path: string;
body: RequestInit["body"];
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}`, {
body,
method,
});
return new Request(`${base}/${path}`, {
body,
method,
});
};
const getBaseAuthorizedRequest = ({
path,
body,
method,
}: BaseRequestParams): Request => {
return new Request(`${base}/${path}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`,
},
body,
method,
});
};
const sendImageResponseValidator = strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
UserID: pipe(string(), uuid()),
Status: string(),
});
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): Promise<Request> => {
const accessToken = await getAccessToken();
return new Request(`${base}/${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
body,
method,
});
};
export const sendImageFile = async (
imageName: string,
file: File,
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
const request = getBaseAuthorizedRequest({
path: `images/${imageName}`,
body: file,
method: "POST",
});
imageName: string,
file: File,
): Promise<InferOutput<typeof imageValidator>> => {
const request = await getBaseAuthorizedRequest({
path: `images/${imageName}`,
body: file,
method: "POST",
});
request.headers.set("Content-Type", "application/oclet-stream");
request.headers.set("Content-Type", "application/oclet-stream");
const res = await fetch(request).then((res) => res.json());
const res = await fetch(request).then((res) => res.json());
const parsedRes = safeParse(imageValidator, res);
return parse(sendImageResponseValidator, res);
if (!parsedRes.success) {
console.log(parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
};
export const deleteImage = async (
imageID: string
): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `images/${imageID}`,
method: "DELETE",
});
await fetch(request);
}
export const deleteImageFromStack = async (listID: string, imageID: string): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${listID}/${imageID}`,
method: "DELETE",
});
await fetch(request);
}
export const deleteStackItem = async (
stackID: string,
schemaItemID: string,
): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${stackID}/${schemaItemID}`,
method: "DELETE",
});
await fetch(request);
}
export const deleteStack = async (listID: string): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${listID}`,
method: "DELETE",
});
await fetch(request);
}
export class ImageLimitReached extends Error {
constructor() {
super();
}
}
export const sendImage = async (
imageName: string,
base64Image: string,
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
const request = getBaseAuthorizedRequest({
path: `images/${imageName}`,
body: base64Image,
method: "POST",
});
imageName: string,
base64Image: string,
): Promise<InferOutput<typeof imageValidator>> => {
const request = await getBaseAuthorizedRequest({
path: `images/${imageName}`,
body: base64Image,
method: "POST",
});
request.headers.set("Content-Type", "application/base64");
request.headers.set("Content-Type", "application/base64");
const res = await fetch(request).then((res) => res.json());
const rawRes = await fetch(request);
if (!rawRes.ok && rawRes.status == 429) {
throw new ImageLimitReached()
}
return parse(sendImageResponseValidator, res);
const res = await rawRes.json();
const parsedRes = safeParse(imageValidator, res);
if (!parsedRes.success) {
console.log("Parsing issues: ", parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
};
const imageMetaValidator = strictObject({
ID: pipe(string(), uuid()),
ImageName: string(),
Description: string(),
Image: null_(),
});
const imageValidator = strictObject({
ID: pipe(string(), uuid()),
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(
strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()),
}),
),
}),
...imageValidator.entries,
ImageStacks: pipe(nullable(array(
strictObject({
ID: pipe(string(), uuid()),
ImageID: 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 stackItem = strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
SchemaItemID: pipe(string(), uuid()),
Value: string(),
})
const stackImage = strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
StackID: pipe(string(), uuid()),
Items: pipe(nullable(array(stackItem)), transform(l => l ?? [])),
});
const listValidator = strictObject({
ID: pipe(string(), uuid()),
UserID: pipe(string(), uuid()),
CreatedAt: pipe(string()),
Name: string(),
Description: nullable(string()),
const stackSchemaItem = strictObject({
ID: pipe(string(), uuid()),
StackID: pipe(string(), uuid()),
Images: pipe(
nullable(
array(
strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()),
Items: array(
strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
SchemaItemID: pipe(string(), uuid()),
Value: string(),
}),
),
}),
),
),
transform((n) => n ?? []),
),
Description: string(),
Item: string(),
Value: string(),
})
Schema: 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(),
}),
),
}),
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 List = InferOutput<typeof listValidator>;
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<
typeof userImageValidator
typeof userImageValidator
>[];
export const getUserImages = async (): Promise<
InferOutput<typeof imageRequestValidator>
InferOutput<typeof imageRequestValidator>
> => {
const request = getBaseAuthorizedRequest({ path: "images" });
const request = await getBaseAuthorizedRequest({ path: "images" });
const res = await fetch(request).then((res) => res.json());
const res = await fetch(request).then((res) => res.json());
return parse(imageRequestValidator, res);
console.log("Backend response: ", res);
const parsedRes = safeParse(imageRequestValidator, res);
if (!parsedRes.success) {
console.log("Schema error: ", parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
};
export const postLogin = async (email: string): Promise<void> => {
const request = getBaseRequest({
path: "auth/login",
body: JSON.stringify({ email }),
method: "POST",
});
const request = getBaseRequest({
path: "auth/login",
body: JSON.stringify({ email }),
method: "POST",
});
await fetch(request);
await fetch(request);
};
const codeValidator = strictObject({
access: string(),
refresh: string(),
access: string(),
refresh: string(),
});
export const postCode = async (
email: string,
code: string,
email: string,
code: string,
): Promise<InferOutput<typeof codeValidator>> => {
const request = getBaseRequest({
path: "auth/code",
body: JSON.stringify({ email, code }),
method: "POST",
});
const request = getBaseRequest({
path: "auth/code",
body: JSON.stringify({ email, code }),
method: "POST",
});
const res = await fetch(request).then((res) => res.json());
const res = await fetch(request).then((res) => res.json());
return parse(codeValidator, res);
const parsedRes = safeParse(codeValidator, res);
if (!parsedRes.success) {
console.log("Schema error: ", parsedRes.issues)
throw new Error(JSON.stringify(parsedRes.issues));
}
return parsedRes.output;
};
export const createList = async (
title: string,
description: string,
export class ReachedStackLimit extends Error {
constructor() {
super();
}
}
export const createStack = async (
title: string,
description: string,
): Promise<void> => {
const request = getBaseAuthorizedRequest({
path: "stacks",
method: "POST",
body: JSON.stringify({ title, description }),
});
const request = await getBaseAuthorizedRequest({
path: "stacks",
method: "POST",
body: JSON.stringify({ title, description }),
});
request.headers.set("Content-Type", "application/json");
request.headers.set("Content-Type", "application/json");
await fetch(request);
const res = await fetch(request);
if (!res.ok && res.status == 429) {
throw new ReachedStackLimit();
}
};

View File

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

View File

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

View File

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

View File

@ -5,24 +5,24 @@ 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()
.sort(
(a, b) =>
new Date(b.CreatedAt!).getTime() - new Date(a.CreatedAt!).getTime(),
)
.slice(0, NUMBER_OF_MAX_RECENT_IMAGES);
const latestImages = () =>
userImages()
.sort(
(a, b) =>
new Date(b.CreatedAt!).getTime() - new Date(a.CreatedAt!).getTime(),
)
.slice(0, NUMBER_OF_MAX_RECENT_IMAGES);
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">
<For each={latestImages()}>
{(image) => <ImageComponent ID={image.ImageID} />}
</For>
</div>
</div>
);
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-1 md:grid-cols-3 gap-4 place-items-center">
<For each={latestImages()}>
{(image) => <ImageComponent ID={image.ID} onDelete={onDeleteImage} />}
</For>
</div>
</div>
);
};

View File

@ -1,38 +1,42 @@
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 { 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} />
</div>
<div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4">
<h2 class="font-bold text-2xl">Description</h2>
<div class="grid grid-cols-3 gap-4">
<For each={image()?.Image.ImageLists}>
{(imageList) => (
<ListCard
list={lists().find((l) => l.ID === imageList.ListID)!}
/>
)}
</For>
</div>
</div>
<div class="w-full bg-white rounded-xl p-4">
<h2 class="font-bold text-2xl">Description</h2>
<SolidjsMarkdown>{image()?.Image.Description}</SolidjsMarkdown>
</div>
</main>
);
return (
<main class="flex flex-col items-center gap-4">
<div class="w-full bg-white rounded-xl p-4">
<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">Stacks</h2>
<div class="grid grid-cols-3 gap-4">
<For each={image()?.ImageStacks}>
{(imageList) => (
<StackCard
stack={stacks().find((l) => l.ID === imageList.StackID)!}
/>
)}
</For>
</div>
</div>
<div class="w-full bg-white rounded-xl p-4">
<h2 class="font-bold text-2xl">Description</h2>
<SolidjsMarkdown>{image()?.Description}</SolidjsMarkdown>
</div>
</main>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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