15 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
19 changed files with 282 additions and 532 deletions

View File

@ -7,6 +7,7 @@ import (
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"strings"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
@ -109,12 +110,14 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, stac
content := resp.Choices[0].Message.Content content := resp.Choices[0].Message.Content
structuredOutput := content[len("```json") : len(content)-3] if strings.HasPrefix(content, "```json") {
content = content[len("```json") : len(content)-3]
}
log.Info("", "res", structuredOutput) log.Info("", "res", content)
var createListArgs createNewListArguments var createListArgs createNewListArguments
err = json.Unmarshal([]byte(structuredOutput), &createListArgs) err = json.Unmarshal([]byte(content), &createListArgs)
if err != nil { if err != nil {
return err return err
} }

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) { 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) userId, err := h.jwtManager.GetUserIdFromRefresh(body.Refresh)
if err != nil { if err != nil {
middleware.WriteErrorBadRequest(h.logger, "invalid refresh token: "+err.Error(), w) middleware.WriteErrorBadRequest(h.logger, "invalid refresh token: "+err.Error(), w)

View File

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

View File

@ -45,7 +45,9 @@ CREATE TABLE haystack.image_stacks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image_id UUID NOT NULL REFERENCES haystack.image (id) ON DELETE CASCADE, 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 ( CREATE TABLE haystack.schema_items (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,6 @@
import { Navigate } from "@solidjs/router"; import { Navigate } from "@solidjs/router";
import { platform } from "@tauri-apps/plugin-os";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { Component, ParentProps, Show } from "solid-js"; import { Component, ParentProps, Show } from "solid-js";
import { save_token } from "tauri-plugin-ios-shared-token-api";
import { InferOutput, literal, number, object, parse, pipe, string, transform } from "valibot"; import { InferOutput, literal, number, object, parse, pipe, string, transform } from "valibot";
export const isTokenValid = (): boolean => { export const isTokenValid = (): boolean => {
@ -36,19 +34,10 @@ export const ProtectedRoute: Component<ParentProps> = (props) => {
const isValid = isTokenValid(); const isValid = isTokenValid();
if (isValid) { if (isValid) {
const token = localStorage.getItem("access"); const token = localStorage.getItem("refresh");
if (token == null) { if (token == null) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
if (platform() === "ios") {
// 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 ( return (

View File

@ -39,8 +39,6 @@ export const Notifications = (onCompleteImage: () => void) => {
const [accessToken] = createResource(getAccessToken); const [accessToken] = createResource(getAccessToken);
const dataEventListener = (e: MessageEvent<unknown>) => { const dataEventListener = (e: MessageEvent<unknown>) => {
debugger;
if (typeof e.data !== "string") { if (typeof e.data !== "string") {
console.error("Error type is not string"); console.error("Error type is not string");
return; return;
@ -100,7 +98,7 @@ export const Notifications = (onCompleteImage: () => void) => {
upsertImageProcessing( upsertImageProcessing(
Object.fromEntries( Object.fromEntries(
images.filter(i => i.Status === 'complete').map((i) => [ images.filter(i => i.Status !== 'complete').map((i) => [
i.ID, i.ID,
{ {
Type: "image", Type: "image",

View File

@ -1,5 +1,7 @@
import { getTokenProperties } from "@components/protected-route"; import { getTokenProperties } from "@components/protected-route";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import { platform } from "@tauri-apps/plugin-os";
import { save_token } from "tauri-plugin-ios-shared-token-api";
import { import {
type InferOutput, type InferOutput,
@ -23,8 +25,8 @@ type BaseRequestParams = Partial<{
method: "GET" | "POST" | "DELETE"; method: "GET" | "POST" | "DELETE";
}>; }>;
// export const base = "https://haystack.johncosta.tech"; export const base = "https://haystack.johncosta.tech";
export const base = "http://localhost:3040"; // export const base = "http://192.168.1.199:3040";
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => { const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
return new Request(`${base}/${path}`, { return new Request(`${base}/${path}`, {
@ -42,25 +44,53 @@ export const getAccessToken = async (): Promise<string> => {
const refreshToken = localStorage.getItem("refresh")?.toString(); const refreshToken = localStorage.getItem("refresh")?.toString();
if (accessToken == null && refreshToken == null) { 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) { if (!isValidAccessToken) {
console.log('Refreshing token...');
const newAccessToken = await fetch(getBaseRequest({ const newAccessToken = await fetch(getBaseRequest({
path: 'auth/refresh', method: "POST", body: JSON.stringify({ path: 'auth/refresh',
method: "POST",
body: JSON.stringify({
refresh: refreshToken, refresh: refreshToken,
}) })
})).then(r => r.json()); })).then(r => r.json());
const { access } = parse(refreshTokenValidator, newAccessToken); const { access } = parse(refreshTokenValidator, newAccessToken);
localStorage.setItem("access", access); localStorage.setItem("access", access);
accessToken = access accessToken = access;
} }
return accessToken! return accessToken!;
} }
const getBaseAuthorizedRequest = async ({ const getBaseAuthorizedRequest = async ({
@ -194,7 +224,7 @@ const userImageValidator = strictObject({
strictObject({ strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()), ImageID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()), StackID: pipe(string(), uuid()),
}), }),
)), transform(l => l ?? [])), )), transform(l => l ?? [])),
}); });

View File

@ -39,7 +39,7 @@ export const Categories: Component = () => {
return ( return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2"> <div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Generated stacks</h2> <h2 class="text-xl font-bold">Generated stacks</h2>
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4"> <div class="w-full grid grid-cols-1 md:grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
<For each={stacks()}>{(stack) => <StackCard stack={stack} />}</For> <For each={stacks()}>{(stack) => <StackCard stack={stack} />}</For>
</div> </div>

View File

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

View File

@ -22,12 +22,12 @@ export const ImagePage: Component = () => {
}} /> }} />
</div> </div>
<div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4"> <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"> <div class="grid grid-cols-3 gap-4">
<For each={image()?.ImageStacks}> <For each={image()?.ImageStacks}>
{(imageList) => ( {(imageList) => (
<StackCard <StackCard
stack={stacks().find((l) => l.ID === imageList.ListID)!} stack={stacks().find((l) => l.ID === imageList.StackID)!}
/> />
)} )}
</For> </For>

View File

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

View File

@ -1,4 +1,3 @@
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useParams, useNavigate } from "@solidjs/router"; import { useParams, useNavigate } from "@solidjs/router";
import { import {
Component, Component,
@ -10,6 +9,7 @@ import {
} from "solid-js"; } from "solid-js";
import { base, getAccessToken } from "../../network"; import { base, getAccessToken } from "../../network";
import { Dialog } from "@kobalte/core"; import { Dialog } from "@kobalte/core";
import { useSearchImageContext } from "@contexts/SearchImageContext";
const DeleteButton: Component<{ onDelete: () => void }> = (props) => { const DeleteButton: Component<{ onDelete: () => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
@ -103,11 +103,58 @@ const DeleteStackButton: Component<{ onDelete: () => void }> = (props) => {
); );
}; };
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 = () => { export const Stack: Component = () => {
const { stackID } = useParams(); const { stackID } = useParams();
const nav = useNavigate(); const nav = useNavigate();
const { stacks, onDeleteImageFromStack, onDeleteStack } = useSearchImageContext(); const { stacks, onDeleteImageFromStack, onDeleteStack, onDeleteStackItem } =
useSearchImageContext();
const [accessToken] = createResource(getAccessToken); const [accessToken] = createResource(getAccessToken);
@ -135,7 +182,9 @@ export const Stack: Component = () => {
</p> </p>
</Show> </Show>
</div> </div>
<DeleteStackButton onDelete={handleDeleteStack} /> <DeleteStackButton
onDelete={handleDeleteStack}
/>
</div> </div>
</div> </div>
<div <div
@ -151,15 +200,25 @@ export const Stack: Component = () => {
<For each={s().SchemaItems}> <For each={s().SchemaItems}>
{(item, index) => ( {(item, index) => (
<th <th
class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${index() < class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${
s().SchemaItems index() <
.length - s().SchemaItems.length -
1 1
? "border-r border-neutral-200" ? "border-r border-neutral-200"
: "" : ""
}`} }`}
> >
{item.Item} <div class="flex items-center gap-2">
{item.Item}
<DeleteStackItemButton
onDelete={() =>
onDeleteStackItem(
s().ID,
item.ID,
)
}
/>
</div>
</th> </th>
)} )}
</For> </For>
@ -169,10 +228,11 @@ export const Stack: Component = () => {
<For each={s().Images}> <For each={s().Images}>
{(image, rowIndex) => ( {(image, rowIndex) => (
<tr <tr
class={`hover:bg-neutral-50 transition-colors ${rowIndex() % 2 === 0 class={`hover:bg-neutral-50 transition-colors ${
? "bg-white" rowIndex() % 2 === 0
: "bg-neutral-25" ? "bg-white"
}`} : "bg-neutral-25"
}`}
> >
<td class="px-6 py-4 border-r border-neutral-200"> <td class="px-6 py-4 border-r border-neutral-200">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -199,13 +259,14 @@ export const Stack: Component = () => {
<For each={image.Items}> <For each={image.Items}>
{(item, colIndex) => ( {(item, colIndex) => (
<td <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 image.Items
.length - .length -
1 1
? "border-r border-neutral-200" ? "border-r border-neutral-200"
: "" : ""
}`} }`}
> >
<div <div
class="max-w-xs truncate" class="max-w-xs truncate"

View File

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