21 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
32 changed files with 649 additions and 677 deletions

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.StackModel
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,7 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
})
}
_, err = agent.listModel.Save(ctx, userID, createListArgs.Title, createListArgs.Description, model.Progress_Complete)
if err != nil {
return fmt.Errorf("creating list agent, saving list: %w", err)
}
err = agent.listModel.SaveItems(ctx, schemaItems)
err = agent.stackModel.SaveItems(ctx, schemaItems)
if err != nil {
return fmt.Errorf("creating list agent, saving items: %w", err)
}

View File

@ -176,7 +176,7 @@ type addToListArguments struct {
Schema []models.IDValue
}
func NewListAgent(log *log.Logger, stackModel models.StackModel, limitsMethods limits.LimitsManagerMethods) client.AgentClient {
func NewStackAgent(log *log.Logger, stackModel models.StackModel, limitsMethods limits.LimitsManagerMethods) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: listPrompt,
JsonTools: listTools,

View File

@ -87,6 +87,7 @@ func (h *AuthHandler) code(body codeBody, w http.ResponseWriter, r *http.Request
}
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)

View File

@ -142,6 +142,20 @@ func (m StackModel) SaveSchemaItems(ctx context.Context, imageID uuid.UUID, item
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
// ========================================

View File

@ -60,7 +60,7 @@ func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserI
).
FROM(
Image.
LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(ImageStacks.ID)),
LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(Image.ID)),
).
WHERE(Image.UserID.EQ(UUID(userId)))

View File

@ -9,7 +9,7 @@ import (
const (
IMAGE_TYPE = "image"
LIST_TYPE = "list"
STACK_TYPE = "stack"
)
type ImageNotification struct {
@ -21,10 +21,10 @@ type ImageNotification struct {
Status string
}
type ListNotification struct {
type StackNotification struct {
Type string
ListID uuid.UUID
StackID uuid.UUID
Name string
Status string
@ -32,7 +32,7 @@ type ListNotification struct {
type Notification struct {
image *ImageNotification
list *ListNotification
stack *StackNotification
}
func GetImageNotification(image ImageNotification) Notification {
@ -41,9 +41,9 @@ func GetImageNotification(image ImageNotification) Notification {
}
}
func GetListNotification(list ListNotification) Notification {
func GetStackNotification(list StackNotification) Notification {
return Notification{
list: &list,
stack: &list,
}
}
@ -52,8 +52,8 @@ func (n Notification) MarshalJSON() ([]byte, error) {
return json.Marshal(n.image)
}
if n.list != nil {
return json.Marshal(n.list)
if n.stack != nil {
return json.Marshal(n.stack)
}
return nil, fmt.Errorf("no image or list present")

View File

@ -23,10 +23,7 @@ type ImageProcessor struct {
descriptionAgent agents.DescriptionAgent
stackAgent client.AgentClient
// TODO: add the notifier here
Processor *Processor[model.Image]
notifier *notifications.Notifier[notifications.Notification]
}
@ -43,6 +40,19 @@ func (p *ImageProcessor) setImageToProcess(ctx context.Context, image model.Imag
}
}
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)
@ -100,6 +110,8 @@ func (p *ImageProcessor) processImage(image model.Image) {
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{
@ -128,7 +140,7 @@ func NewImageProcessor(
}
descriptionAgent := agents.NewDescriptionAgent(logger, imageModel)
stackAgent := agents.NewListAgent(logger, listModel, limitsManager)
stackAgent := agents.NewStackAgent(logger, listModel, limitsManager)
imageProcessor := ImageProcessor{
imageModel: imageModel,

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

@ -41,9 +41,16 @@ func setupRouter(db *sql.DB, jwtManager *ourmiddleware.JwtManager) (chi.Router,
return nil, fmt.Errorf("processor: %w", err)
}
go imageProcessor.Processor.Work()
stackProcessorLog := createLogger("Stack Processor", os.Stdout)
stackProcessor, err := processor.NewStackProcessor(stackProcessorLog, stackModel, &notifier)
if err != nil {
return nil, fmt.Errorf("processor: %w", err)
}
stackHandler := stacks.CreateStackHandler(db, limitsManager, jwtManager)
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)

View File

@ -45,7 +45,9 @@ CREATE TABLE haystack.image_stacks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image_id UUID NOT NULL REFERENCES haystack.image (id) ON DELETE CASCADE,
stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE
stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE,
UNIQUE(image_id, stack_id)
);
CREATE TABLE haystack.schema_items (

View File

@ -8,6 +8,7 @@ import (
"screenmark/screenmark/limits"
"screenmark/screenmark/middleware"
"screenmark/screenmark/models"
"screenmark/screenmark/processor"
"github.com/charmbracelet/log"
"github.com/go-chi/chi/v5"
@ -23,6 +24,8 @@ type StackHandler struct {
limitsManager limits.LimitsManagerMethods
jwtManager *middleware.JwtManager
processor *processor.Processor[model.Stacks]
}
func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) {
@ -50,7 +53,7 @@ func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) {
return
}
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
if err != nil {
return
}
@ -83,7 +86,7 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
return
}
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
if err != nil {
return
}
@ -147,10 +150,10 @@ func (h *StackHandler) deleteImageFromStack(w http.ResponseWriter, r *http.Reque
func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stringImageID := chi.URLParam(r, "stackID")
stringStackID := chi.URLParam(r, "stackID")
stringSchemaItemID := chi.URLParam(r, "schemaItemID")
imageID, err := uuid.Parse(stringImageID)
stackID, err := uuid.Parse(stringStackID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
@ -169,8 +172,9 @@ func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http
return
}
stack, err := h.stackModel.Get(ctx, schemaItemID)
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
}
@ -185,7 +189,7 @@ func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http
// manipulations. So we could create a middleware.
// If you repeat this 3 times, then organise it :)
err = h.stackModel.DeleteSchemaItem(ctx, schemaItemID, imageID)
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)
@ -208,14 +212,16 @@ func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter,
}
// TODO: Add the stack processor here
_, err = h.stackModel.Save(ctx, userID, body.Title, body.Description, model.Progress_NotStarted)
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) {
@ -236,7 +242,12 @@ func (h *StackHandler) CreateRoutes(r chi.Router) {
})
}
func CreateStackHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods, jwtManager *middleware.JwtManager) StackHandler {
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")
@ -247,5 +258,6 @@ func CreateStackHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods, j
stackModel: stackModel,
limitsManager: limitsManager,
jwtManager: jwtManager,
processor: processor,
}
}

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

View File

@ -7,7 +7,7 @@ import {
Settings,
SearchPage,
AllImages,
List,
Stack,
} from "./pages";
import { SearchImageContextProvider } from "@contexts/SearchImageContext";
import { WithNotifications } from "@contexts/Notifications";
@ -41,7 +41,7 @@ export const App = () => {
path="/image/:imageId"
component={ImagePage}
/>
<Route path="/list/:listId" component={List} />
<Route path="/stack/:stackID" component={Stack} />
<Route path="/settings" component={Settings} />
</Route>
</Route>

View File

@ -9,7 +9,7 @@ export const ProcessingImages: Component = () => {
const processingNumber = () =>
Object.keys(notifications.state.ProcessingImages).length +
Object.keys(notifications.state.ProcessingLists).length;
Object.keys(notifications.state.ProcessingStacks).length;
const [accessToken] = createResource(getAccessToken)
@ -60,13 +60,13 @@ export const ProcessingImages: Component = () => {
)}
</For>
<For each={Object.entries(notifications.state.ProcessingLists)}>
{([, _list]) => (
<Show when={_list}>
{(list) => (
<For each={Object.entries(notifications.state.ProcessingStacks)}>
{([, _stack]) => (
<Show when={_stack}>
{(stack) => (
<div class="flex gap-2 w-full justify-center">
<div class="flex flex-col gap-1">
<p class="text-slate-900">New Stack: {list().Name}</p>
<p class="text-slate-900">New Stack: {stack().Name}</p>
</div>
<LoadingCircle
status="loading"

View File

@ -1,8 +1,6 @@
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 => {
@ -36,19 +34,10 @@ export const ProtectedRoute: Component<ParentProps> = (props) => {
const isValid = isTokenValid();
if (isValid) {
const token = localStorage.getItem("access");
const token = localStorage.getItem("refresh");
if (token == null) {
throw new Error("unreachable");
}
if (platform() === "ios") {
// iOS share extension is a seperate process to the App.
// Therefore, we need to share our access token somewhere both the App & Share Extension can access
// This involves App Groups.
save_token(token)
.then(() => console.log("Saved token!!!"))
.catch((e) => console.error(e));
}
}
return (

View File

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

View File

@ -22,7 +22,7 @@ type NotificationState = {
string,
InferOutput<typeof processingImagesValidator> | undefined
>;
ProcessingLists: Record<
ProcessingStacks: Record<
string,
InferOutput<typeof processingListValidator> | undefined
>;
@ -31,7 +31,7 @@ type NotificationState = {
export const Notifications = (onCompleteImage: () => void) => {
const [state, setState] = createStore<NotificationState>({
ProcessingImages: {},
ProcessingLists: {},
ProcessingStacks: {},
});
const { userImages } = useSearchImageContext();
@ -39,8 +39,6 @@ export const Notifications = (onCompleteImage: () => void) => {
const [accessToken] = createResource(getAccessToken);
const dataEventListener = (e: MessageEvent<unknown>) => {
debugger;
if (typeof e.data !== "string") {
console.error("Error type is not string");
return;
@ -71,14 +69,14 @@ export const Notifications = (onCompleteImage: () => void) => {
} else {
setState("ProcessingImages", ImageID, notification.output);
}
} else if (notification.output.Type === "list") {
const { ListID, Status } = notification.output;
} else if (notification.output.Type === "stack") {
const { StackID, Status } = notification.output;
if (Status === "complete") {
setState("ProcessingLists", ListID, undefined);
setState("ProcessingStacks", StackID, undefined);
onCompleteImage();
} else {
setState("ProcessingLists", ListID, notification.output);
setState("ProcessingStacks", StackID, notification.output);
}
}
};
@ -100,7 +98,7 @@ export const Notifications = (onCompleteImage: () => void) => {
upsertImageProcessing(
Object.fromEntries(
images.filter(i => i.Status === 'complete').map((i) => [
images.filter(i => i.Status !== 'complete').map((i) => [
i.ID,
{
Type: "image",

View File

@ -7,7 +7,14 @@ import {
createResource,
useContext,
} from "solid-js";
import { deleteImage, deleteImageFromStack, getUserImages, JustTheImageWhatAreTheseNames } from "../network";
import {
deleteImage,
deleteImageFromStack,
deleteStack,
deleteStackItem,
getUserImages,
JustTheImageWhatAreTheseNames,
} from "../network";
export type SearchImageStore = {
imagesByDate: Accessor<
@ -19,16 +26,21 @@ export type SearchImageStore = {
userImages: Accessor<JustTheImageWhatAreTheseNames>;
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 sortedImages = createMemo<ReturnType<SearchImageStore["imagesByDate"]>>(
() => {
const sortedImages = createMemo<
ReturnType<SearchImageStore["imagesByDate"]>
>(() => {
const d = data();
if (d == null) {
return [];
@ -53,8 +65,7 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
return Object.entries(buckets)
.map(([date, images]) => ({ date: new Date(date), images }))
.sort((a, b) => b.date.getTime() - a.date.getTime());
},
);
});
return (
<SearchImageContext.Provider
@ -68,7 +79,13 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
},
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}

View File

@ -1,5 +1,7 @@
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,
@ -23,8 +25,8 @@ type BaseRequestParams = Partial<{
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}`, {
@ -42,25 +44,53 @@ export const getAccessToken = async (): Promise<string> => {
const refreshToken = localStorage.getItem("refresh")?.toString();
if (accessToken == null && refreshToken == null) {
throw new Error("your are not logged in")
throw new Error("you are not logged in")
}
const isValidAccessToken = accessToken != null && getTokenProperties(accessToken).exp.getTime() * 1000 > Date.now()
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({
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
accessToken = access;
}
return accessToken!
return accessToken!;
}
const getBaseAuthorizedRequest = async ({
@ -121,7 +151,19 @@ export const deleteImageFromStack = async (listID: string, imageID: string): Pro
await fetch(request);
}
export const deleteList = async (listID: string): Promise<void> => {
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",
@ -182,7 +224,7 @@ const userImageValidator = strictObject({
strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()),
StackID: pipe(string(), uuid()),
}),
)), transform(l => l ?? [])),
});
@ -226,7 +268,7 @@ const stackValidator = strictObject({
SchemaItems: array(stackSchemaItem),
});
export type List = InferOutput<typeof stackValidator>;
export type Stack = InferOutput<typeof stackValidator>;
const imageRequestValidator = strictObject({
UserImages: array(userImageValidator),
@ -291,13 +333,13 @@ export const postCode = async (
return parsedRes.output;
};
export class ReachedListLimit extends Error {
export class ReachedStackLimit extends Error {
constructor() {
super();
}
}
export const createList = async (
export const createStack = async (
title: string,
description: string,
): Promise<void> => {
@ -311,6 +353,6 @@ export const createList = async (
const res = await fetch(request);
if (!res.ok && res.status == 429) {
throw new ReachedListLimit();
throw new ReachedStackLimit();
}
};

View File

@ -1,10 +1,10 @@
import { literal, pipe, strictObject, string, union, uuid } from "valibot";
export const processingListValidator = strictObject({
Type: literal("list"),
Type: literal("stack"),
Name: string(),
ListID: pipe(string(), uuid()),
StackID: pipe(string(), uuid()),
Status: union([
literal("not-started"),

View File

@ -1,10 +1,10 @@
import { Component, For, createSignal } from "solid-js";
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { StackCard } from "@components/list-card";
import { Button } from "@kobalte/core/button";
import { Dialog } from "@kobalte/core/dialog";
import { createList, ReachedListLimit } from "../../network";
import { createStack, ReachedStackLimit } from "../../network";
import { createToast } from "../../utils/show-toast";
import { StackCard } from "@components/stack-card";
export const Categories: Component = () => {
const { stacks, onRefetchImages } = useSearchImageContext();
@ -15,20 +15,20 @@ 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 stacks
} catch (error) {
console.error("Failed to create list:", error);
if (error instanceof ReachedListLimit) {
console.error("Failed to create stack:", error);
if (error instanceof ReachedStackLimit) {
createToast("Reached limit!", "You've reached your limit for new stacks");
}
} finally {
@ -39,8 +39,8 @@ 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 stacks</h2>
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
<For each={stacks()}>{(list) => <StackCard list={list} />}</For>
<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">
@ -48,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>
@ -59,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()}
/>
@ -85,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()}
@ -107,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() ||
@ -116,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

@ -18,7 +18,7 @@ export const Recent: Component = () => {
return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Recent Screenshots</h2>
<div class="grid grid-cols-3 gap-4 place-items-center">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 place-items-center">
<For each={latestImages()}>
{(image) => <ImageComponent ID={image.ID} onDelete={onDeleteImage} />}
</For>

View File

@ -1,9 +1,9 @@
import { ImageComponentFullHeight } from "@components/image";
import { StackCard } from "@components/stack-card";
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useNavigate, useParams } from "@solidjs/router";
import { For, type Component } from "solid-js";
import SolidjsMarkdown from "solidjs-markdown";
import { StackCard } from "@components/list-card";
export const ImagePage: Component = () => {
const { imageId } = useParams<{ imageId: string }>();
@ -22,12 +22,12 @@ export const ImagePage: Component = () => {
}} />
</div>
<div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4">
<h2 class="font-bold text-2xl">Description</h2>
<h2 class="font-bold text-2xl">Stacks</h2>
<div class="grid grid-cols-3 gap-4">
<For each={image()?.ImageStacks}>
{(imageList) => (
<StackCard
list={stacks().find((l) => l.ID === imageList.ListID)!}
stack={stacks().find((l) => l.ID === imageList.StackID)!}
/>
)}
</For>

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

@ -7,6 +7,6 @@ export const useSearch = () => {
return () =>
new Fuse(userImages(), {
shouldSort: true,
keys: ["Image.Description"],
keys: ["Description"],
});
};

View File

@ -1,4 +1,3 @@
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useParams, useNavigate } from "@solidjs/router";
import {
Component,
@ -8,8 +7,9 @@ import {
createResource,
createSignal,
} from "solid-js";
import { base, deleteList, getAccessToken } from "../../network";
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);
@ -57,7 +57,7 @@ const DeleteButton: Component<{ onDelete: () => void }> = (props) => {
);
};
const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
const DeleteStackButton: Component<{ onDelete: () => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
return (
@ -67,7 +67,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
class="text-white bg-red-600 hover:bg-red-700 rounded px-3 py-2 text-sm font-medium"
onClick={() => setIsOpen(true)}
>
Delete List
Delete Stack
</button>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
@ -76,7 +76,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
<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 List
Confirm Delete Stack
</Dialog.Title>
<Dialog.Description class="mb-4">
Are you sure you want to delete this entire
@ -92,7 +92,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
onClick={props.onDelete}
>
Delete List
Delete Stack
</button>
</div>
</Dialog.Content>
@ -103,39 +103,88 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
);
};
export const List: Component = () => {
const { listId } = useParams();
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 } = useSearchImageContext();
const { stacks, onDeleteImageFromStack, onDeleteStack, onDeleteStackItem } =
useSearchImageContext();
const [accessToken] = createResource(getAccessToken);
const list = () => stacks().find((l) => l.ID === listId);
const stack = () => stacks().find((l) => l.ID === stackID);
const handleDeleteList = async () => {
await deleteList(listId)
const handleDeleteStack = async () => {
onDeleteStack(stackID);
nav("/");
};
return (
<Suspense>
<Show when={list()} fallback="List could not be found">
{(l) => (
<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">
{l().Name}
{s().Name}
</h1>
<Show when={l().Description}>
<Show when={s().Description}>
<p class="text-sm text-neutral-600 mt-1">
{l().Description}
{s().Description}
</p>
</Show>
</div>
<DeleteListButton onDelete={handleDeleteList} />
<DeleteStackButton
onDelete={handleDeleteStack}
/>
</div>
</div>
<div
@ -148,28 +197,39 @@ export const List: Component = () => {
<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().SchemaItems}>
<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() <
l().SchemaItems
.length -
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={l().Images}>
<For each={s().Images}>
{(image, rowIndex) => (
<tr
class={`hover:bg-neutral-50 transition-colors ${rowIndex() % 2 === 0
class={`hover:bg-neutral-50 transition-colors ${
rowIndex() % 2 === 0
? "bg-white"
: "bg-neutral-25"
}`}
@ -183,13 +243,13 @@ export const List: Component = () => {
<img
class="w-full h-full object-cover rounded-lg"
src={`${base}/images/${image.ImageID}?token=${accessToken()}`}
alt="List item"
alt="Stack item"
/>
</a>
<DeleteButton
onDelete={() =>
onDeleteImageFromStack(
l().ID,
s().ID,
image.ImageID,
)
}
@ -199,7 +259,8 @@ export const List: Component = () => {
<For each={image.Items}>
{(item, colIndex) => (
<td
class={`px-6 py-4 text-sm text-neutral-700 ${colIndex() <
class={`px-6 py-4 text-sm text-neutral-700 ${
colIndex() <
image.Items
.length -
1
@ -223,7 +284,7 @@ export const List: Component = () => {
</For>
</tbody>
</table>
<Show when={l().Images.length === 0}>
<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

View File

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