135 Commits

Author SHA1 Message Date
5ae6a3403f chore: removing old agent that was messy and too coupled
chore
2025-04-13 16:30:20 +01:00
3156cea904 feat(event): seperate event agent 2025-04-13 16:30:20 +01:00
d432d16752 feat(location): agent to create locations 2025-04-13 16:30:20 +01:00
98328be39d fix(email) 2025-04-13 16:28:40 +01:00
47c871523d feat(sse): very rough events. Not used in the client yet
feat(sse): very rough events. Not used in the client yet
2025-04-13 14:27:59 +01:00
cf7d5e0305 chore: removing unused files 2025-04-12 14:44:16 +01:00
9bb07c1b9b fix: tests 2025-04-12 14:43:01 +01:00
959b741fcb refactor(agent): main agent loop extracted away
Still not super sure how to represent these agents in code.
It doesn't make the most amount of sense to keep them in structs. A
curried function is more like it, with system prompt and tooling.

Maybe that's what I'll end up doing.
2025-04-12 14:39:16 +01:00
91cc54aaec fix(event) 2025-04-12 14:15:07 +01:00
d786ab15c9 fix(orchestrator): better describing the note taking agent 2025-04-12 07:53:43 +01:00
47e65e1609 fix(notes): improving note taking capabilities 2025-04-12 07:48:42 +01:00
91dd2f54ef fix(log): removing access token logging 2025-04-12 07:46:07 +01:00
42771ea958 feat(contact-agent): linking to existing instead of creating new ones 2025-04-12 07:29:29 +01:00
77a0901352 fix: removing extra log line 2025-04-12 07:22:35 +01:00
a43efa014f feat(log): pretty logging agent responses and tool calls 2025-04-12 07:16:30 +01:00
4990cf9c43 feat(contact-agent): working contact agent
Built this in under 20 minutes. Getting some really good agents
2025-04-11 21:12:06 +01:00
9660c99a14 feat: contacts working 2025-04-11 20:31:51 +01:00
f89de6db50 feat(authorization): e2e working authorization 2025-04-11 19:58:25 +01:00
6290c4b843 feat: checking user authorization on image retrieval 2025-04-11 19:41:36 +01:00
fba1618888 wip(token): verifying user when getting the image 2025-04-11 19:35:49 +01:00
5fee1f9ccc fix: not using cookies anymore
I think Tauri doesn't like it very much
2025-04-11 13:27:19 +01:00
3960203d26 feat(cookies): using HTTP setCookie instead of manually doing it 2025-04-11 11:34:32 +01:00
2302ba5eeb feat: minimal UI for login 2025-04-11 11:08:33 +01:00
c9a6c83649 feat(jwt): validating token 2025-04-10 15:49:53 +01:00
3294c1854c feat(jwt): adding access and refresh token generation 2025-04-10 15:35:35 +01:00
29a5adb40a feat(email): endpoint for sending auth code 2025-04-09 18:24:26 +01:00
51dc8daf35 wip(email-auth): auth and email modules 2025-04-09 18:13:49 +01:00
a22c56fd2c refactor: moving image listener to own function 2025-04-09 17:20:27 +01:00
11c5c8921b feat(orchestrator): async processing and ending the loop3 2025-04-09 15:23:51 +01:00
1a503c8320 fix(tool-calls): ToolLoop 2025-04-09 15:15:31 +01:00
f169fd2ba2 fix(tools): testing and processing
fix
2025-04-09 13:56:30 +01:00
d36dec8d60 fix(types): agent processing stuff 2025-04-09 12:12:09 +01:00
e065492dd4 feat(chat): more simplified chat messages and tool handling 2025-04-09 12:04:44 +01:00
26c6edb6ba fixup(chat): better way to organize agent messages and tool calls 2025-04-06 20:24:40 +01:00
5c5df168ad fix(tools): dont error if AI invested a tool 2025-04-05 15:04:09 +01:00
e101070851 refactor(tools): removing pointer map
This is not needed
2025-04-05 14:59:50 +01:00
5278727c51 feat(tools): return error to agent if any happened 2025-04-05 14:58:38 +01:00
9a354c38a5 test(tools): more robust multiple tool call handling 2025-04-05 14:52:31 +01:00
cd8375ce0f test(tools): starting test suite for tools 2025-04-05 14:35:54 +01:00
6549643340 feat(orchestrator): calling needed agents when it needs to 2025-04-05 11:01:43 +01:00
33fb206e2f fix(tool): raw text not scaling so well ey? 2025-04-04 22:50:19 +01:00
49f1990341 refactor(agents): working e2e now
I guess some repeated code doesnt hurt anyone, if it keeps things
simpler. Trying to be fancy with the interfaces didn't work so well.
2025-04-04 22:40:45 +01:00
40392e6da3 refactor(tool-calls): to be handled more generally 2025-04-04 22:17:58 +01:00
d3bc840555 refactor(ai-client): moving tool handling and client into seperate folders 2025-04-04 22:03:46 +01:00
ede5f16dc1 wip(orchestrator): basic scaffolding for the agent 2025-04-04 20:40:31 +01:00
75132503c0 feat: sample data 2025-04-02 17:32:52 +00:00
393eaea2f4 feat(notes): allowing frontend to save 2025-04-01 20:54:15 +00:00
a385ef21cf feat(notes): saving the notes for any images for easy text searching 2025-04-01 20:45:43 +00:00
a37818fc49 feat(events): search through organizer 2025-04-01 19:59:17 +00:00
0d3f86532e feat: adding rawData search 2025-04-01 19:48:06 +00:00
55e50d31ca fix(validators): allowing location in event fields 2025-04-01 19:33:31 +00:00
1b2a99a3c8 fix: adding location to general query 2025-04-01 19:32:55 +00:00
ae62d2bea5 Merge branch 'integrating-frontend' 2025-04-01 19:30:51 +00:00
0126125837 fix 2025-03-31 20:17:14 +00:00
c5278554cc feat(contacts): events can now have organizers 2025-03-31 18:40:36 +00:00
bb5f2bc2fe feat(schema): basic contact tables 2025-03-31 18:10:04 +00:00
b7ed4e2169 chore(imports): organisation 2025-03-31 18:03:02 +00:00
c609b45d99 feat(cards): adjusting for backend data types 2025-03-31 17:49:17 +00:00
c817654f3e feat(events): adding start and end times 2025-03-31 17:32:55 +00:00
3f53317c06 feat(schema): removing coordinates and adding start times to events
.
2025-03-31 16:44:42 +00:00
254edf3421 fix(backend): SQL statements without returns 2025-03-26 16:51:46 +00:00
0814e19a68 refactor(validators): frontend to new schema 2025-03-26 16:51:35 +00:00
382a1f53bd feat: attaching both to image 2025-03-26 16:22:23 +00:00
f90876f499 feat: creating events and attaching locations 2025-03-26 16:16:48 +00:00
caf168c7a1 feat(frontend): add new search card components and update styling
- Introduced new search card components for Contact, Event, Location, Note, Receipt, and Website.
- Updated the App component to utilize these new components for displaying search results.
- Changed the default font from Manrope to Switzer and updated related styles.
- Added a new dependency `solid-motionone` to package.json.
- Improved search functionality with a new sample data structure and enhanced search logic.
2025-03-23 21:56:09 +01:00
4c85f1de79 refactor(frontend): clean up App component and improve search functionality 2025-03-23 19:10:18 +01:00
410df01b4d feat(tool-calling) Big refactor on how tool calling is handled
these commits are too big
2025-03-22 20:46:26 +00:00
13e5ed9f9e feat(locations): allowing AI to attach it to the image 2025-03-22 17:47:02 +00:00
dfb4b34de3 feat(location): working e2e with tool calling 2025-03-22 12:22:31 +00:00
7b6c7090f8 feat(tool-calls): listLocation tool call handling 2025-03-22 11:14:00 +00:00
87869543f7 feat: using tools for event loocation agent 2025-03-22 10:12:51 +00:00
1cd4698969 refactor(naming): using Agent instead of openai 2025-03-21 17:07:00 +00:00
4ea817e81f fix: docker image 2025-03-21 14:49:51 +00:00
3541a4755c wip(frontend): adding more information 2025-03-21 14:36:03 +00:00
ea5802b61b fix: app to re-include images 2025-03-21 14:23:38 +00:00
cf703f3eee fix: re-adding location to event
Figured it out
2025-03-21 14:08:06 +00:00
84881c5c2d fix: simplifying query
I do have some learning to do about go-jet specifically.

I can't include the Location field on events, because the QRM will add
any location to the events
2025-03-21 14:01:21 +00:00
992a8ea282 feat: frontend validation 2025-03-21 13:44:42 +00:00
f7382b0d2b feat: returning events and locations from end point 2025-03-20 18:34:11 +00:00
47dd025ae3 feat: working e2e solution 2025-03-20 17:59:00 +00:00
f114ca06d8 merging 2025-03-19 09:46:52 +00:00
20213ff17b feat: adding events and locations to json schema 2025-03-19 09:46:42 +00:00
9932568986 feat(events): also working 2025-03-18 18:37:12 +00:00
3a0f93e406 feat(locations): now working 2025-03-18 18:18:01 +00:00
b09063f74a refactor(text,tags,links): to foreign key to image instead of user_image 2025-03-18 17:48:38 +00:00
b3b37d252d wip: add sample data and types 2025-03-17 22:20:26 +01:00
3c71fddbd2 fix: linter and format issues
format
2025-03-17 21:16:40 +01:00
a3e1db3d77 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-03-17 21:01:15 +01:00
5a766b8371 wip 2025-03-17 21:00:52 +01:00
fd804ae515 fix: frontend parsers 2025-03-16 18:41:26 +00:00
4b120982d0 fix: returning whole tag object 2025-03-16 18:29:15 +00:00
7582e4d8d9 fix: returns correct image ID 2025-03-16 18:20:29 +00:00
8acf25a2a7 refactor(models): using more organised structure 2025-03-16 18:13:30 +00:00
e505a1617e Merge branch 'main' of https://github.com/dimuuu/haystack-app 2025-03-13 17:25:16 +01:00
028e45bb7a wip 2025-03-13 17:24:53 +01:00
536a49fe1c feat(tags): correctly inserting new tags and adding them to images 2025-03-11 22:47:28 +00:00
40e854fb87 feat(tags): creating and getting user tags 2025-03-11 21:23:41 +00:00
5df6c67ee5 feat: new schema to support user tags better 2025-03-11 20:29:56 +00:00
05263d1089 fix: actually searching properly 2025-03-08 15:50:21 +00:00
1bc1b79042 feat: better result display 2025-03-08 15:42:16 +00:00
863716c096 feat: super basic image search 2025-03-08 15:37:10 +00:00
53ebbb6e8d feat: sending images and receiving them is now working 2025-03-08 13:13:05 +00:00
bf07c18fd7 feat: sending base64 image to backend
This is silly, but binary is apparently hard to do????
2025-03-08 12:30:16 +00:00
d212584486 wip: dialog to choose folder to watch 2025-03-08 11:58:25 +00:00
1424ec22f4 fix: using json response header 2025-03-07 14:14:40 +00:00
e595783d89 wip: Using mistral instead of OpenAi 2025-03-07 13:42:50 +00:00
2df18869e5 chore: running format 2025-02-26 21:27:43 +00:00
ee69d9c2fe feat: network file with validators for backend requests 2025-02-26 21:27:37 +00:00
7e7f3ff732 feat: making prompt be more generic with tags 2025-02-26 20:53:12 +00:00
3fe48464e4 feat: using different prompt 2025-02-26 20:49:55 +00:00
d1d6ee6762 fix: some spam from get images request 2025-02-26 20:48:52 +00:00
ad61b8e1fa fix: getting user images 2025-02-26 20:09:19 +00:00
d8095b0c67 refactor: tables for image and processing_image
This allows a single table to be used to process images, meaning if
anything happens to the system we can always return to polling the
database and process these images individually.

Because of this we also want an `image` table to contain the actual
binary data for the image, so we aren't selecting and writing it each
time, as it is potentially a bottleneck.
2025-02-26 20:01:56 +00:00
410270e217 refactor: using chi router + bug fixes 2025-02-26 18:04:30 +00:00
5bec6c9590 fix: actually saving to the correct db table 2025-02-26 15:54:27 +00:00
971f705288 fix: actually returning all the user images
fix
2025-02-24 21:19:59 +00:00
13ebd80ce9 chore: documentation 2025-02-24 21:04:25 +00:00
b99432c202 feat: saving AI information to database 2025-02-24 21:00:05 +00:00
ee0587a16b feat: method for getting images 2025-02-24 20:05:56 +00:00
64f6bde6a9 feat: methods to get image 2025-02-24 20:02:58 +00:00
f49589907a feat: instructions for docker compose 2025-02-24 20:02:54 +00:00
2115da85b5 feat: working docker image and compose file 2025-02-24 19:44:19 +00:00
43092fa4f5 feat: using parsing on response 2025-02-24 19:05:11 +00:00
46e4043994 chore: updating gitignore
fix
2025-02-24 19:01:18 +00:00
24ef31e00f feat: parsing response from open ai
bruh
2025-02-24 19:01:18 +00:00
993fbb30eb build very basic ui 2025-02-23 22:16:41 +01:00
8cc7e4002f some updates 2025-02-23 20:11:58 +01:00
1fc1079484 messing around with ui 2025-02-23 20:02:06 +01:00
df16298b1f added a bunch of frontend things 2025-02-23 19:30:11 +01:00
f4690b52a9 Merge branch 'main' of https://github.com/dimuuu/haystack-app 2025-02-23 14:59:04 +01:00
81590fe622 some window ui tweaks 2025-02-23 14:59:01 +01:00
97b1619b01 refactor: moving all files to backend 2025-02-22 23:30:59 +00:00
c0ce4892cd Merge branch 'main' of github.com:dimuuu/haystack-app 2025-02-22 23:29:49 +00:00
90431f824a add biome 2025-02-22 16:41:52 +01:00
fe60149769 v0 2025-02-22 16:30:41 +01:00
107 changed files with 1737 additions and 4665 deletions

View File

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

View File

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

View File

@ -21,7 +21,6 @@ func UseSchema(schema string) {
ImageTags = ImageTags.FromSchema(schema)
ImageText = ImageText.FromSchema(schema)
Locations = Locations.FromSchema(schema)
Logs = Logs.FromSchema(schema)
Notes = Notes.FromSchema(schema)
UserContacts = UserContacts.FromSchema(schema)
UserEvents = UserEvents.FromSchema(schema)

View File

@ -66,7 +66,7 @@ func (m ChatUserMessage) MarshalJSON() ([]byte, error) {
case ArrayMessage:
return json.Marshal(&struct {
Role UserRole `json:"role"`
Content []MessageContentMessage `json:"content"`
Content []ImageMessageContent `json:"content"`
}{
Role: User,
Content: t.Content,
@ -121,35 +121,18 @@ func (m SingleMessage) IsSingleMessage() bool {
}
type ArrayMessage struct {
Content []MessageContentMessage `json:"content"`
Content []ImageMessageContent `json:"content"`
}
func (m ArrayMessage) IsSingleMessage() bool {
return false
}
type MessageContentMessage interface {
IsImageMessage() bool
}
type TextMessageContent struct {
TextType string `json:"type"`
Text string `json:"text"`
}
func (m TextMessageContent) IsImageMessage() bool {
return false
}
type ImageMessageContent struct {
ImageType string `json:"type"`
ImageUrl string `json:"image_url"`
}
func (m ImageMessageContent) IsImageMessage() bool {
return true
}
type ImageContentUrl struct {
Url string `json:"url"`
}
@ -182,7 +165,7 @@ func (chat *Chat) AddSystem(prompt string) {
})
}
func (chat *Chat) AddImage(imageName string, image []byte, query *string) error {
func (chat *Chat) AddImage(imageName string, image []byte) error {
extension := filepath.Ext(imageName)
if len(extension) == 0 {
// TODO: could also validate for image types we support.
@ -190,28 +173,14 @@ func (chat *Chat) AddImage(imageName string, image []byte, query *string) error
}
extension = extension[1:]
encodedString := base64.StdEncoding.EncodeToString(image)
contentLength := 1
if query != nil {
contentLength += 1
}
messageContent := ArrayMessage{
Content: make([]MessageContentMessage, contentLength),
Content: make([]ImageMessageContent, 1),
}
index := 0
if query != nil {
messageContent.Content[index] = TextMessageContent{
TextType: "text",
Text: *query,
}
index += 1
}
messageContent.Content[index] = ImageMessageContent{
messageContent.Content[0] = ImageMessageContent{
ImageType: "image_url",
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
}

View File

@ -73,28 +73,16 @@ type AgentClient struct {
Log *log.Logger
Reply string
Do func(req *http.Request) (*http.Response, error)
Options CreateAgentClientOptions
}
const OPENAI_API_KEY = "OPENAI_API_KEY"
type CreateAgentClientOptions struct {
Log *log.Logger
SystemPrompt string
JsonTools string
EndToolCall string
Query *string
}
func CreateAgentClient(options CreateAgentClientOptions) AgentClient {
func CreateAgentClient(log *log.Logger) (AgentClient, error) {
apiKey := os.Getenv(OPENAI_API_KEY)
if len(apiKey) == 0 {
panic("No api key")
return AgentClient{}, errors.New(OPENAI_API_KEY + " was not found.")
}
return AgentClient{
@ -105,14 +93,12 @@ func CreateAgentClient(options CreateAgentClientOptions) AgentClient {
return client.Do(req)
},
Log: options.Log,
Log: log,
ToolHandler: ToolsHandlers{
handlers: map[string]ToolHandler{},
},
Options: options,
}
}, nil
}
func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
@ -160,32 +146,39 @@ func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error)
return AgentResponse{}, errors.New("Unsupported. We currently only accept 1 choice from AI.")
}
client.Log.SetLevel(log.DebugLevel)
msg := agentResponse.Choices[0].Message
req.Chat.AddAiResponse(msg)
if len(msg.Content) > 0 {
client.Log.Debugf("Content: %s", msg.Content)
}
if msg.ToolCalls != nil && len(*msg.ToolCalls) > 0 {
client.Log.Debugf("Tool Call: %s", (*msg.ToolCalls)[0].Function.Name)
prettyJson, err := json.MarshalIndent((*msg.ToolCalls)[0].Function.Arguments, "", " ")
if err != nil {
return AgentResponse{}, err
}
client.Log.Debugf("Arguments: %s", string(prettyJson))
}
req.Chat.AddAiResponse(agentResponse.Choices[0].Message)
return agentResponse, nil
}
func (client *AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
for {
response, err := client.Request(req)
err := client.Process(info, req)
if err != nil {
return err
}
if response.Choices[0].FinishReason == "stop" {
client.Log.Debug("Agent is finished")
return nil
}
err = client.Process(info, req)
_, err = client.Request(req)
if err != nil {
if err == FinishedCall {
client.Log.Debug("Agent is finished")
}
return err
}
}
@ -193,7 +186,7 @@ func (client *AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody)
var FinishedCall = errors.New("Last tool tool was called")
func (client *AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error {
func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error {
var err error
message, err := req.Chat.GetLatest()
@ -218,11 +211,8 @@ func (client *AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody)
toolResponse := client.ToolHandler.Handle(info, toolCall)
if toolCall.Function.Name == "reply" {
client.Reply = toolCall.Function.Arguments
}
client.Log.Debug("Tool call", "name", toolCall.Function.Name, "arguments", toolCall.Function.Arguments, "response", toolResponse.Content)
client.Log.SetLevel(log.DebugLevel)
client.Log.Debugf("Response: %s", toolResponse.Content)
req.Chat.AddToolResponse(toolResponse)
}
@ -230,12 +220,9 @@ func (client *AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody)
return err
}
func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
func (client AgentClient) RunAgent(systemPrompt string, jsonTools string, endToolCall string, userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
var tools any
err := json.Unmarshal([]byte(client.Options.JsonTools), &tools)
if err != nil {
panic(err)
}
err := json.Unmarshal([]byte(jsonTools), &tools)
toolChoice := "any"
@ -244,7 +231,7 @@ func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageNa
ToolChoice: &toolChoice,
Model: "pixtral-12b-2409",
Temperature: 0.3,
EndToolCall: client.Options.EndToolCall,
EndToolCall: endToolCall,
ResponseFormat: ResponseFormat{
Type: "text",
},
@ -253,14 +240,17 @@ func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageNa
},
}
request.Chat.AddSystem(client.Options.SystemPrompt)
request.Chat.AddImage(imageName, imageData, client.Options.Query)
request.Chat.AddSystem(systemPrompt)
request.Chat.AddImage(imageName, imageData)
_, err = client.Request(&request)
if err != nil {
return err
}
toolHandlerInfo := ToolHandlerInfo{
ImageId: imageId,
ImageName: imageName,
UserId: userId,
Image: &imageData,
}
return client.ToolLoop(toolHandlerInfo, &request)

View File

@ -10,10 +10,6 @@ import (
type ToolHandlerInfo struct {
UserId uuid.UUID
ImageId uuid.UUID
ImageName string
// Pointer because we don't want to copy this around too much.
Image *[]byte
}
type ToolHandler struct {

View File

@ -3,58 +3,36 @@ package agents
import (
"context"
"encoding/json"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
const contactPrompt = `
**Role:** AI Contact Processor from Images.
You are an agent that performs actions on contacts and people you find on an image.
**Goal:** Extract contacts from an image, check against existing list using listContacts, add *only* new contacts using createContact, and call stopAgent when finished. Avoid duplicates.
You can use tools to achieve your task.
**Input:** Image potentially containing contact info (Name, Phone, Email, Address).
You should use listContacts to make sure that you don't create duplicate contacts.
**Workflow:**
1. **Scan Image:** Extract all contact details. If none, call stopAgent.
2. **Think:** Using the think tool, you must layout your thoughts about the contacts on the image. If they are duplicates or not, and what your next action should be,
3. **Check Duplicates:** If contacts found, *first* call listContacts. Compare extracted info to list. If all found contacts already exist, call stopAgent.
4. **Add New:** If you detect a new contact on the image, call createContact to create a new contact.
5. **Finish:** Call stopAgent once all new contacts are created OR if steps 1 or 2 determined no action/creation was needed.
Call createContact when you see there is a new contact on this image. Do not create duplicate contacts.
Or call linkContact when you think this image contains an existing contact.
**Tools:**
* listContacts: Check existing contacts (Use first if contacts found).
* createContact: Add a NEW contact (Name required).
* stopAgent: Signal task completion.
Call finish if you dont think theres anything else to do.
`
const contactTools = `
[
{
"type": "function",
"function": {
"name": "think",
"description": "Use this tool to think through the image, evaluating the contact and whether or not it exists in the users listContacts.",
"parameters": {
"type": "object",
"properties": {
"thought": {
"type": "string",
"description": "A singular thought about the image"
}
},
"required": ["thought"]
}
}
},
{
"type": "function",
"function": {
"name": "listContacts",
"description": "Retrieves the complete list of the user's currently saved contacts (e.g., names, phone numbers, emails if available in the stored data). This tool is essential and **must** be called *before* attempting to create a new contact if potential contact info is found in the image, to check if the person already exists and prevent duplicate entries.",
"description": "List the users existing contacts",
"parameters": {
"type": "object",
"properties": {},
@ -66,29 +44,23 @@ const contactTools = `
"type": "function",
"function": {
"name": "createContact",
"description": "Saves a new contact to the user's contact list. Only use this function **after** confirming the contact does not already exist by checking the output of listContacts. Provide all available extracted information for the new contact. Process one new contact per call.",
"description": "Creates a new contact",
"parameters": {
"type": "object",
"properties": {
"contactId": {
"type": "string",
"description": "The UUID of the contact. You should only provide this IF you believe the contact already exists, from listContacts."
},
"name": {
"type": "string",
"description": "The full name of the person being added as a contact. This field is mandatory."
"description": "the name of the person"
},
"phoneNumber": {
"type": "string",
"description": "The contact's primary phone number, including area or country code if available. Provide this if extracted from the image."
"type": "string"
},
"address": {
"type": "string",
"description": "The complete physical mailing address of the contact (e.g., street number, street name, city, state/province, postal code, country). Provide this if extracted from the image."
"description": "their physical address"
},
"email": {
"type": "string",
"description": "The contact's primary email address. Provide this if extracted from the image."
"type": "string"
}
},
"required": ["name"]
@ -98,8 +70,25 @@ const contactTools = `
{
"type": "function",
"function": {
"name": "stopAgent",
"description": "Use this tool to signal that the contact processing for the current image is complete. Call this *only* when: 1) No contact info was found initially, OR 2) All found contacts were confirmed to already exist after calling listContacts, OR 3) All necessary createContact calls for new individuals have been completed.",
"name": "linkContact",
"description": "Links an existing contact with this image",
"parameters": {
"type": "object",
"properties": {
"contactId": {
"type": "string",
"description": "The UUID of the existing contact"
}
},
"required": ["contactId"]
}
}
},
{
"type": "function",
"function": {
"name": "finish",
"description": "Call when you dont think theres anything to do",
"parameters": {
"type": "object",
"properties": {},
@ -110,29 +99,40 @@ const contactTools = `
]
`
type ContactAgent struct {
client client.AgentClient
contactModel models.ContactModel
}
type listContactsArguments struct{}
type createContactsArguments struct {
Name string `json:"name"`
ContactID *string `json:"contactId"`
PhoneNumber *string `json:"phoneNumber"`
Address *string `json:"address"`
Email *string `json:"email"`
}
type linkContactArguments struct {
ContactID string `json:"contactId"`
}
func NewContactAgent(log *log.Logger, contactModel models.ContactModel) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: contactPrompt,
JsonTools: contactTools,
Log: log,
EndToolCall: "stopAgent",
})
func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Contacts 👥",
}))
if err != nil {
return ContactAgent{}, err
}
agentClient.ToolHandler.AddTool("think", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "Thought", nil
})
agent := ContactAgent{
client: agentClient,
contactModel: contactModel,
}
agentClient.ToolHandler.AddTool("listContacts", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return contactModel.List(context.Background(), info.UserId)
return agent.contactModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -144,18 +144,7 @@ func NewContactAgent(log *log.Logger, contactModel models.ContactModel) client.A
ctx := context.Background()
contactId := uuid.Nil
if args.ContactID != nil {
contactUuid, err := uuid.Parse(*args.ContactID)
if err != nil {
return model.Contacts{}, err
}
contactId = contactUuid
}
contact, err := contactModel.Save(ctx, info.UserId, model.Contacts{
ID: contactId,
contact, err := agent.contactModel.Save(ctx, info.UserId, model.Contacts{
Name: args.Name,
PhoneNumber: args.PhoneNumber,
Email: args.Email,
@ -165,7 +154,7 @@ func NewContactAgent(log *log.Logger, contactModel models.ContactModel) client.A
return model.Contacts{}, err
}
_, err = contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
if err != nil {
return model.Contacts{}, err
}
@ -173,5 +162,27 @@ func NewContactAgent(log *log.Logger, contactModel models.ContactModel) client.A
return contact, nil
})
return agentClient
agentClient.ToolHandler.AddTool("linkContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := linkContactArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
}
ctx := context.Background()
contactUuid, err := uuid.Parse(args.ContactID)
if err != nil {
return "", err
}
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, contactUuid)
if err != nil {
return "", err
}
return "Saved", nil
})
return agent, nil
}

View File

@ -3,6 +3,7 @@ package agents
import (
"context"
"encoding/json"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
@ -13,49 +14,33 @@ import (
)
const eventPrompt = `
**You are an AI processing events from images using internal thought.**
You are an agent.
**Task:** Extract event details (Name, Date/Time, Location). Use think before deciding actions. Check duplicates with listEvents. Handle new events via getEventLocationId (if location exists) and createEvent. Use finish if no event or duplicate found.
1. **Analyze Image & Think:** Extract details. Use think to confirm if a valid event exists. If not -> stopAgent.
2. **Event Confirmed?** -> *Must* call listEvents, to check for existing events and prevent duplicates.
3. **Detect Duplicates** -> If the input contains an event that already exists from listEvents, then you should call stopAgent.
4. **New Events**
* If you think the input contains a location, then you can use getEventLocationId to retrieve the ID of the location. Only use this IF the input contains a location.
* Call createEvent.
5. **Multiple Events:** Process sequentially using this logic.
The user will send you images and you have to identify if they have any events or a place.
This could be a friend suggesting to meet, a conference, or anything that looks like an event.
**Tools:**
* think: Internal reasoning/planning step.
* listEvents: Check for duplicates (mandatory first step for found events).
* getEventLocationId: Get ID for location text.
* createEvent: Add new event (Name req.). Terminal action for new events.
* stopAgent: Signal completion (no event/duplicate found). Terminal action.
There are various tools you can use to perform this task.
listEvents
Lists the users already existing events, you should do this before using createEvents to avoid creating duplicates.
createEvent
Use this to create a new events.
linkEvent
Links an image to a events.
finish
Call when there is nothing else to do.
`
const eventTools = `
[
{
"type": "function",
"function": {
"name": "think",
"description": "Use this tool to think through the image, evaluating the event and whether or not it exists in the users listEvents.",
"parameters": {
"type": "object",
"properties": {
"thought": {
"type": "string",
"description": "A singular thought about the image"
}
},
"required": ["thought"]
}
}
},
{
"type": "function",
"function": {
"name": "listEvents",
"description": "Retrieves the list of the user's currently scheduled events. Essential for checking if an event identified in the image already exists to prevent duplicates. Must be called before potentially creating an event.",
"description": "List the events the user already has.",
"parameters": {
"type": "object",
"properties": {},
@ -67,25 +52,20 @@ const eventTools = `
"type": "function",
"function": {
"name": "createEvent",
"description": "Creates a new event in the user's calendar or list. Use only after listEvents confirms the event is new. Provide all extracted details.",
"description": "Use to create a new events",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name or title of the event. This field is mandatory."
"type": "string"
},
"startDateTime": {
"type": "string",
"description": "The event's start date and time in ISO 8601 format (e.g., '2025-04-18T10:00:00Z'). Include if available."
"description": "The start time as an ISO string"
},
"endDateTime": {
"type": "string",
"description": "The event's end date and time in ISO 8601 format. Optional, include if available and different from startDateTime."
},
"locationId": {
"type": "string",
"description": "The unique identifier (UUID or similar) for the event's location. Use this if available, do not invent it."
"description": "The end time as an ISO string"
}
},
"required": ["name"]
@ -95,43 +75,24 @@ const eventTools = `
{
"type": "function",
"function": {
"name": "updateEvent",
"description": "Updates an existing event record identified by its eventId. Use this tool when listEvents indicates a match for the event details found in the current input.",
"name": "linkEvent",
"description": "Use to link an already existing events to the image you were sent",
"parameters": {
"type": "object",
"properties": {
"eventId": {
"type": "string",
"description": "The UUID of the existing event"
"type": "string"
}
},
"required": ["eventId"]
}
}
},
{
"type": "function",
"function": {
"name": "getEventLocationId",
"description": "Retrieves a unique identifier for a location description associated with an event. Use this before createEvent if a new event specifies a location.",
"parameters": {
"type": "object",
"properties": {
"locationDescription": {
"type": "string",
"description": "The text describing the location extracted from the image (e.g., 'Conference Room B', '123 Main St, Anytown', 'Zoom Link details')."
}
},
"required": ["locationDescription"]
"required": ["eventsId"]
}
}
},
{
"type": "function",
"function": {
"name": "stopAgent",
"description": "Call this tool only when event processing for the current image is fully complete. This occurs if: 1) No event info was found, OR 2) The found event already exists, OR 3) A new event has been successfully created.",
"name": "finish",
"description": "Call this when there is nothing left to do.",
"parameters": {
"type": "object",
"properties": {},
@ -141,36 +102,41 @@ const eventTools = `
}
]`
type EventAgent struct {
client client.AgentClient
eventsModel models.EventModel
}
type listEventArguments struct{}
type createEventArguments struct {
Name string `json:"name"`
StartDateTime *string `json:"startDateTime"`
EndDateTime *string `json:"endDateTime"`
OrganizerName *string `json:"organizerName"`
LocationID *string `json:"locationId"`
}
type updateEventArguments struct {
type linkEventArguments struct {
EventID string `json:"eventId"`
}
func NewEventAgent(log *log.Logger, eventsModel models.EventModel, locationModel models.LocationModel) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: eventPrompt,
JsonTools: eventTools,
Log: log,
EndToolCall: "stopAgent",
})
func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Events 📍",
}))
locationAgent := NewLocationAgentWithComm(log.WithPrefix("Events 📅 > Locations 📍"), locationModel)
locationQuery := "Can you get me the ID of the location present in this image?"
locationAgent.Options.Query = &locationQuery
if err != nil {
return EventAgent{}, err
}
agentClient.ToolHandler.AddTool("think", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "Thought", nil
})
agent := EventAgent{
client: agentClient,
eventsModel: eventsModel,
}
agentClient.ToolHandler.AddTool("listEvents", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return eventsModel.List(context.Background(), info.UserId)
return agent.eventsModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -184,8 +150,6 @@ func NewEventAgent(log *log.Logger, eventsModel models.EventModel, locationModel
layout := "2006-01-02T15:04:05Z"
// TODO: check for nil pointers.
startTime, err := time.Parse(layout, *args.StartDateTime)
if err != nil {
return model.Events{}, err
@ -196,23 +160,17 @@ func NewEventAgent(log *log.Logger, eventsModel models.EventModel, locationModel
return model.Events{}, err
}
locationId, err := uuid.Parse(*args.LocationID)
if err != nil {
return model.Events{}, err
}
events, err := eventsModel.Save(ctx, info.UserId, model.Events{
events, err := agent.eventsModel.Save(ctx, info.UserId, model.Events{
Name: args.Name,
StartDateTime: &startTime,
EndDateTime: &endTime,
LocationID: &locationId,
})
if err != nil {
return model.Events{}, err
}
_, err = eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
_, err = agent.eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
if err != nil {
return model.Events{}, err
}
@ -220,8 +178,8 @@ func NewEventAgent(log *log.Logger, eventsModel models.EventModel, locationModel
return events, nil
})
agentClient.ToolHandler.AddTool("updateEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := updateEventArguments{}
agentClient.ToolHandler.AddTool("linkEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := linkEventArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
@ -234,17 +192,9 @@ func NewEventAgent(log *log.Logger, eventsModel models.EventModel, locationModel
return "", err
}
eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
agent.eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
return "Saved", nil
})
agentClient.ToolHandler.AddTool("getEventLocationId", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// TODO: reenable this when I'm creating the agent locally instead of getting it from above.
locationAgent.RunAgent(info.UserId, info.ImageId, info.ImageName, *info.Image)
log.Debugf("Reply from location %s\n", locationAgent.Reply)
return locationAgent.Reply, nil
})
return agentClient
return agent, nil
}

View File

@ -3,92 +3,43 @@ package agents
import (
"context"
"encoding/json"
"fmt"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
const locationPrompt = `
Role: Location AI Assistant
You are an agent.
Objective: Identify locations from images/text, manage a saved list, and answer user queries about saved locations using the provided tools.
The user does not want to have duplicate entries on their saved location list. So you should only create a new location if listLocation doesnt return
what would be a duplicate.
The user will send you images and you have to identify if they have any location or a place. This could a picture of a real place, an address, or it's name.
Core Logic:
There are various tools you can use to perform this task.
**Extract Location Details:** Attempt to extract location details (like InputName, InputAddress) from the user's input.
* If no details can be extracted, inform the user and use stopAgent.
listLocations
Lists the users already existing locations, you should do this before using createLocation to avoid creating duplicates.
**Check for Existing Location:** If details *were* extracted:
* Use listLocations with the extracted InputName and/or InputAddress to search for potentially matching locations already saved in the list.
createLocation
Use this to create a new location. Avoid making duplicates and only create a new location if listLocations doesnt contain the location on the image.
Action loop:
**Thinking**
* Use the think tool to analytise the image.
* You should think about whether listLocations already contains this location, or if it is a new location.
* You should always call this after listLocations.
* You must think about whether or not listLocations already has this location.
linkLocation
Links an image to a location.
**Decide Action based on Search Results:**
* If no existing location looks like the location on the input. You should use createLocation.
* Do not use this tool if this location already exists.
* If the input contains a location that already exists, you should use createExistingLocation.
* If there is a similar location in listLocation, you should use this tool. It doesnt have to be an exact match.
* Lastly, if the user asked a specific question about a location. You must do all the actions but also always use the reply tool to answer the user.
* This is the only way you can communicate with the user if they asked a query.
You should repeat the action loop until all locations on the image are done.
Once you are done, use stopAgent.
finish
Call when there is nothing else to do.
`
const replyTool = `
{
"type": "function",
"function": {
"name": "reply",
"description": "Signals intent to provide information about a specific known location in response to a user's query. Use only if the user asked a question and the location's ID was found via listLocations.",
"parameters": {
"type": "object",
"properties": {
"locationId": {
"type": "string",
"description": "The unique identifier of the saved location that the user is asking about."
}
},
"required": ["locationId"]
}
}
},`
const locationTools = `
[
{
"type": "function",
"function": {
"name": "think",
"description": "Use this tool to think through the image, evaluating the location and whether or not it exists in the users listLocations. You should also ask yourself if the user has asked a query, and if you've used the correct tool to reply to them.",
"parameters": {
"type": "object",
"properties": {
"thought": {
"type": "string",
"description": "A singular thought about the image"
}
},
"required": ["thought"]
}
}
},
{
"type": "function",
"function": {
"name": "listLocations",
"description": "Retrieves the list of the user's currently saved locations (names, addresses, IDs). Use this first to check if a location from an image already exists, or to find the ID of a location the user is asking about.",
"description": "List the locations the user already has.",
"parameters": {
"type": "object",
"properties": {},
@ -100,17 +51,15 @@ const locationTools = `
"type": "function",
"function": {
"name": "createLocation",
"description": "Creates a new location with as much information as you can extract. Be precise. You should only add the parameters you can actually see on the image.",
"description": "Use to create a new location",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The primary name of the location"
"type": "string"
},
"address": {
"type": "string",
"description": "The address of the location"
"type": "string"
}
},
"required": ["name"]
@ -120,26 +69,24 @@ const locationTools = `
{
"type": "function",
"function": {
"name": "createExistingLocation",
"description": "Called when a location already exists in the users list, from listLocations. Only call this to indicate this image contains a duplicate. And only after using the doesLocationExist tol",
"name": "linkLocation",
"description": "Use to link an already existing location to the image you were sent",
"parameters": {
"type": "object",
"properties": {
"locationId": {
"type": "string",
"description": "The UUID of the location, from listLocations"
"type": "string"
}
},
"required": ["locationId"]
}
}
},
%s
{
"type": "function",
"function": {
"name": "stopAgent",
"description": "Use this tool to signal that the contact processing for the current image is complete.",
"name": "finish",
"description": "Call this when there is nothing left to do.",
"parameters": {
"type": "object",
"properties": {},
@ -149,12 +96,10 @@ const locationTools = `
}
]`
func getLocationAgentTools(allowReply bool) string {
if allowReply {
return fmt.Sprintf(locationTools, replyTool)
} else {
return fmt.Sprintf(locationTools, "")
}
type LocationAgent struct {
client client.AgentClient
locationModel models.LocationModel
}
type listLocationArguments struct{}
@ -162,28 +107,28 @@ type createLocationArguments struct {
Name string `json:"name"`
Address *string `json:"address"`
}
type createExistingLocationArguments struct {
type linkLocationArguments struct {
LocationID string `json:"locationId"`
}
func NewLocationAgentWithComm(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
client := NewLocationAgent(log, locationModel)
func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error) {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Locations 📍",
}))
client.Options.JsonTools = getLocationAgentTools(true)
if err != nil {
return LocationAgent{}, err
}
return client
}
func NewLocationAgent(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: locationPrompt,
JsonTools: getLocationAgentTools(false),
Log: log,
EndToolCall: "stopAgent",
})
agent := LocationAgent{
client: agentClient,
locationModel: locationModel,
}
agentClient.ToolHandler.AddTool("listLocations", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return locationModel.List(context.Background(), info.UserId)
return agent.locationModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -195,9 +140,7 @@ func NewLocationAgent(log *log.Logger, locationModel models.LocationModel) clien
ctx := context.Background()
// TODO: this tool could be simplier, as the model could have a SaveToImage joined with the save.
location, err := locationModel.Save(ctx, info.UserId, model.Locations{
location, err := agent.locationModel.Save(ctx, info.UserId, model.Locations{
Name: args.Name,
Address: args.Address,
})
@ -206,7 +149,7 @@ func NewLocationAgent(log *log.Logger, locationModel models.LocationModel) clien
return model.Locations{}, err
}
_, err = locationModel.SaveToImage(ctx, info.ImageId, location.ID)
_, err = agent.locationModel.SaveToImage(ctx, info.ImageId, location.ID)
if err != nil {
return model.Locations{}, err
}
@ -214,8 +157,8 @@ func NewLocationAgent(log *log.Logger, locationModel models.LocationModel) clien
return location, nil
})
agentClient.ToolHandler.AddTool("createExistingLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := createExistingLocationArguments{}
agentClient.ToolHandler.AddTool("linkLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := linkLocationArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
@ -223,26 +166,14 @@ func NewLocationAgent(log *log.Logger, locationModel models.LocationModel) clien
ctx := context.Background()
locationId, err := uuid.Parse(args.LocationID)
contactUuid, err := uuid.Parse(args.LocationID)
if err != nil {
return "", err
}
_, err = locationModel.SaveToImage(ctx, info.ImageId, locationId)
if err != nil {
return "", err
}
return "", nil
agent.locationModel.SaveToImage(ctx, info.ImageId, contactUuid)
return "Saved", nil
})
agentClient.ToolHandler.AddTool("reply", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "ok", nil
})
agentClient.ToolHandler.AddTool("think", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "ok", nil
})
return agentClient
return agent, nil
}

View File

@ -2,9 +2,11 @@ package agents
import (
"context"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid"
@ -41,7 +43,7 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
}
request.Chat.AddSystem(noteAgentPrompt)
request.Chat.AddImage(imageName, imageData, nil)
request.Chat.AddImage(imageName, imageData)
resp, err := agent.client.Request(&request)
if err != nil {
@ -68,16 +70,20 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
return nil
}
func NewNoteAgent(log *log.Logger, noteModel models.NoteModel) NoteAgent {
client := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: noteAgentPrompt,
Log: log,
})
func NewNoteAgent(noteModel models.NoteModel) (NoteAgent, error) {
client, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Notes 📝",
}))
if err != nil {
return NoteAgent{}, err
}
agent := NoteAgent{
client: client,
noteModel: noteModel,
}
return agent
return agent, nil
}

View File

@ -1,63 +1,52 @@
package agents
import (
"errors"
"os"
"screenmark/screenmark/agents/client"
"time"
"github.com/charmbracelet/log"
)
const orchestratorPrompt = `
**Role:** You are an Orchestrator AI responsible for analyzing images provided by the user.
const OrchestratorPrompt = `
You are an Orchestrator for various AI agents.
**Primary Task:** Examine the input image and determine which specialized AI agent(s), available as tool calls, should be invoked to process the relevant information within the image, or if no specialized processing is needed. Your goal is to either extract and structure useful information for the user by selecting the most appropriate tool(s) or explicitly indicate that no specific action is required.
The user will send you images and you have to determine which agents you have to call, in order to best help the user.
**Analysis Process & Decision Logic:**
You might decide no agent needs to be called.
1. **Analyze Image Content:** Scrutinize the image for distinct types of information:
* General text/writing (including code, formulas)
* Information about a person or contact details
* Information about a place, location, or address
* Information about an event
* Content that doesn't fit any specific category or lacks actionable information.
The agents are available as tool calls.
2. **Thinking**
* You should use the think tool to allow you to think your way through the image.
* You should call this as many times as you need to in order to describe and analyse the image correctly.
Agents available:
3. **Agent Selection - Determine ALL that apply:**
* **contactAgent:** Is there information specifically related to a person or their contact details (e.g., business card, name/email/phone)?
* **locationAgent:** Is there information specifically identifying a place, location, city, or address (e.g., map, street sign, address text)?
* **eventAgent:** Is there information specifically related to an event (e.g., invitation, poster with date/time, schedule)?
* **noteAgent** Does the image contain *any* text/writing (including code, formulas)?
* **noAgent**: Call this when you are done working on this image.
noteAgent
Use when there is ANY text on the image.
* Call all applicable specialized agents (noteAgent, contactAgent, locationAgent, eventAgent) simultaneously (in parallel).
contactAgent
Use it when the image contains information relating a person.
locationAgent
Use it when the image contains some address or a place.
eventAgent
Use it when the image contains an event, this can be a date, a message suggesting an event.
noAction
When you think there is no more information to extract from the image.
Always call agents in parallel if you need to call more than 1.
Do not call the agent if you do not think it is relevant for the image.
`
const orchestratorTools = `
const OrchestratorTools = `
[
{
"type": "function",
"function": {
"name": "think",
"description": "Use to layout all your thoughts about the image, roughly describing it, and specially describing if the image contains anything relevant to your available agents",
"parameters": {
"type": "object",
"properties": {
"thought": {
"type": "string",
"description": "A singular thought about the image"
}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "noteAgent",
"description": "Extracts general textual content like handwritten notes, paragraphs in documents, presentation slides, code snippets, or mathematical formulas. Use this for significant text that isn't primarily contact details, an address, or specific event information.",
"description": "Use when there is any text on the image, this can be code/text/formulas any writing",
"parameters": {
"type": "object",
"properties": {},
@ -69,7 +58,7 @@ const orchestratorTools = `
"type": "function",
"function": {
"name": "contactAgent",
"description": "Extracts personal contact information. Use when the image clearly shows details like names, phone numbers, email addresses, job titles, or company names, especially from sources like business cards, email signatures, or contact lists.",
"description": "Use when then image contains some person or contact",
"parameters": {
"type": "object",
"properties": {},
@ -81,7 +70,7 @@ const orchestratorTools = `
"type": "function",
"function": {
"name": "locationAgent",
"description": "Use when the input has anything to do with a place. This could be a city, an address, a postcode, a virtual meeting location, or a geographical location.",
"description": "Use when then image contains some place, location or address",
"parameters": {
"type": "object",
"properties": {},
@ -93,7 +82,7 @@ const orchestratorTools = `
"type": "function",
"function": {
"name": "eventAgent",
"description": "Extracts details related to scheduled events, appointments, or specific occasions. Use when the image contains information like event titles, dates, times, venues, agendas, or descriptions, typically found on invitations, posters, calendar entries, or schedules.",
"description": "Use when then image contains some event",
"parameters": {
"type": "object",
"properties": {},
@ -104,8 +93,8 @@ const orchestratorTools = `
{
"type": "function",
"function": {
"name": "noAgent",
"description": "Extracts details related to scheduled events, appointments, or specific occasions. Use when the image contains information like event titles, dates, times, venues, agendas, or descriptions, typically found on invitations, posters, calendar entries, or schedules.",
"name": "noAction",
"description": "Use when you are sure nothing can be done about this image anymore",
"parameters": {
"type": "object",
"properties": {},
@ -113,8 +102,7 @@ const orchestratorTools = `
}
}
}
]
`
]`
type OrchestratorAgent struct {
Client client.AgentClient
@ -126,45 +114,58 @@ type Status struct {
Ok bool `json:"ok"`
}
func NewOrchestratorAgent(log *log.Logger, noteAgent NoteAgent, contactAgent client.AgentClient, locationAgent client.AgentClient, eventAgent client.AgentClient, imageName string, imageData []byte) client.AgentClient {
agent := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: orchestratorPrompt,
JsonTools: orchestratorTools,
Log: log,
EndToolCall: "noAgent",
})
func NewOrchestratorAgent(noteAgent NoteAgent, contactAgent ContactAgent, locationAgent LocationAgent, eventAgent EventAgent, imageName string, imageData []byte) (OrchestratorAgent, error) {
agent, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Orchestrator 🎼",
}))
agent.ToolHandler.AddTool("think", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "Thought", nil
})
if err != nil {
return OrchestratorAgent{}, err
}
agent.ToolHandler.AddTool("noteAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go noteAgent.GetNotes(info.UserId, info.ImageId, imageName, imageData)
return "noteAgent called successfully", nil
return Status{
Ok: true,
}, nil
})
agent.ToolHandler.AddTool("contactAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go contactAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
go contactAgent.client.RunAgent(contactPrompt, contactTools, "finish", info.UserId, info.ImageId, imageName, imageData)
return "contactAgent called successfully", nil
return Status{
Ok: true,
}, nil
})
agent.ToolHandler.AddTool("locationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go locationAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
go locationAgent.client.RunAgent(locationPrompt, locationTools, "finish", info.UserId, info.ImageId, imageName, imageData)
return "locationAgent called successfully", nil
return Status{
Ok: true,
}, nil
})
agent.ToolHandler.AddTool("eventAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go eventAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
go eventAgent.client.RunAgent(eventPrompt, eventTools, "finish", info.UserId, info.ImageId, imageName, imageData)
return "eventAgent called successfully", nil
return Status{
Ok: true,
}, nil
})
agent.ToolHandler.AddTool("noAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "ok", nil
agent.ToolHandler.AddTool("noAction", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// To nothing
return Status{
Ok: true,
}, errors.New("Finished! Kinda bad return type but...")
})
return agent
return OrchestratorAgent{
Client: agent,
}, nil
}

View File

@ -2,6 +2,7 @@ package main
import (
"errors"
"fmt"
"math/rand"
"time"
)
@ -17,7 +18,7 @@ type Auth struct {
mailer Mailer
}
var letterRunes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")
func randString(n int) string {
b := make([]rune, n)
@ -43,6 +44,7 @@ func (a *Auth) CreateCode(email string) error {
}
func (a *Auth) IsCodeValid(email string, code string) bool {
fmt.Println(a.codes)
existingCode, exists := a.codes[email]
if !exists {
return false
@ -53,6 +55,7 @@ func (a *Auth) IsCodeValid(email string, code string) bool {
func (a *Auth) UseCode(email string, code string) error {
if valid := a.IsCodeValid(email, code); !valid {
fmt.Println("returning error?")
return errors.New("This code is invalid.")
}

View File

@ -3,12 +3,13 @@ package main
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"screenmark/screenmark/agents"
"screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid"
"github.com/lib/pq"
)
@ -27,9 +28,6 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
imageModel := models.NewImageModel(db)
contactModel := models.NewContactModel(db)
databaseEventLog := createLogger("Database Events 🤖", os.Stdout)
databaseEventLog.SetLevel(log.DebugLevel)
err := listener.Listen("new_image")
if err != nil {
panic(err)
@ -41,38 +39,55 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
imageId := uuid.MustParse(parameters.Extra)
eventManager.listeners[parameters.Extra] = make(chan string)
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
ctx := context.Background()
go func() {
noteAgent, err := agents.NewNoteAgent(noteModel)
if err != nil {
panic(err)
}
contactAgent, err := agents.NewContactAgent(contactModel)
if err != nil {
panic(err)
}
locationAgent, err := agents.NewLocationAgent(locationModel)
if err != nil {
panic(err)
}
eventAgent, err := agents.NewEventAgent(eventModel)
if err != nil {
panic(err)
}
image, err := imageModel.GetToProcessWithData(ctx, imageId)
if err != nil {
databaseEventLog.Error("Failed to GetToProcessWithData", "error", err)
log.Println("Failed to GetToProcessWithData")
log.Println(err)
return
}
splitWriter := createDbStdoutWriter(db, image.ImageID)
noteAgent := agents.NewNoteAgent(createLogger("Notes 📝", splitWriter), noteModel)
contactAgent := agents.NewContactAgent(createLogger("Contacts 👥", splitWriter), contactModel)
locationAgent := agents.NewLocationAgent(createLogger("Locations 📍", splitWriter), locationModel)
eventAgent := agents.NewEventAgent(createLogger("Events 📅", splitWriter), eventModel, locationModel)
if err := imageModel.StartProcessing(ctx, image.ID); err != nil {
databaseEventLog.Error("Failed to FinishProcessing", "error", err)
log.Println("Failed to FinishProcessing")
log.Println(err)
return
}
orchestrator := agents.NewOrchestratorAgent(createLogger("Orchestrator 🎼", splitWriter), noteAgent, contactAgent, locationAgent, eventAgent, image.Image.ImageName, image.Image.Image)
orchestrator.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
_, err = imageModel.FinishProcessing(ctx, image.ID)
orchestrator, err := agents.NewOrchestratorAgent(noteAgent, contactAgent, locationAgent, eventAgent, image.Image.ImageName, image.Image.Image)
if err != nil {
databaseEventLog.Error("Failed to finish processing", "ImageID", imageId)
return
panic(err)
}
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
// Still need to find some way to hide this complexity away.
// I don't think wrapping agents in structs actually works too well.
err = orchestrator.Client.RunAgent(agents.OrchestratorPrompt, agents.OrchestratorTools, "noAction", image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
if err != nil {
log.Println(err)
}
imageModel.FinishProcessing(ctx, image.ID)
}()
}
}
@ -107,6 +122,9 @@ func ListenProcessingImageStatus(db *sql.DB, eventManager *EventManager) {
stringUuid := data.Extra[0:36]
status := data.Extra[36:]
fmt.Printf("UUID: %s\n", stringUuid)
fmt.Printf("Receiving :s\n", data.Extra)
imageListener, exists := eventManager.listeners[stringUuid]
if !exists {
continue

View File

@ -20,7 +20,6 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robert-nix/ansihtml v1.0.1 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/wneessen/go-mail v0.6.2 // indirect
golang.org/x/crypto v0.33.0 // indirect

View File

@ -6,7 +6,6 @@ github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpT
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
@ -34,10 +33,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
github.com/robert-nix/ansihtml v1.0.1/go.mod h1:CJwclxYaTPc2RfcxtanEACsYuTksh4yDXcNeHHKZINE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@ -114,6 +109,5 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

@ -91,36 +91,19 @@ func main() {
}
dataTypes := make([]DataType, 0)
// lord
// forgive me
idMap := make(map[uuid.UUID]bool)
for _, image := range images {
for _, location := range image.Locations {
_, exists := idMap[location.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{
Type: "location",
Data: location,
})
idMap[location.ID] = true
}
for _, event := range image.Events {
_, exists := idMap[event.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{
Type: "event",
Data: event,
})
idMap[event.ID] = true
}
for _, note := range image.Notes {
@ -128,20 +111,13 @@ func main() {
Type: "note",
Data: note,
})
idMap[note.ID] = true
}
for _, contact := range image.Contacts {
_, exists := idMap[contact.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{
Type: "contact",
Data: contact,
})
idMap[contact.ID] = true
}
}
@ -357,12 +333,6 @@ func main() {
return
}
if exists := userModel.DoesUserExist(r.Context(), codeBody.Email); !exists {
userModel.Save(r.Context(), model.Users{
Email: codeBody.Email,
})
}
uuid, err := userModel.GetUserIdFromEmail(r.Context(), codeBody.Email)
if err != nil {
log.Println(err)
@ -393,12 +363,6 @@ func main() {
fmt.Fprint(w, string(json))
})
logWriter := DatabaseWriter{
dbPool: db,
}
r.Route("/logs", createLogHandler(&logWriter))
log.Println("Listening and serving on port 3040.")
if err := http.ListenAndServe(":3040", r); err != nil {
log.Println(err)

View File

@ -29,56 +29,9 @@ func (m ContactModel) List(ctx context.Context, userId uuid.UUID) ([]model.Conta
return locations, err
}
func (m ContactModel) Get(ctx context.Context, contactId uuid.UUID) (model.Contacts, error) {
getContactStmt := Contacts.
SELECT(Contacts.AllColumns).
WHERE(Contacts.ID.EQ(UUID(contactId)))
contact := model.Contacts{}
err := getContactStmt.QueryContext(ctx, m.dbPool, &contact)
return contact, err
}
func (m ContactModel) Update(ctx context.Context, contact model.Contacts) (model.Contacts, error) {
existingContact, err := m.Get(ctx, contact.ID)
if err != nil {
return model.Contacts{}, err
}
existingContact.Name = contact.Name
if contact.Description != nil {
existingContact.Description = contact.Description
}
if contact.PhoneNumber != nil {
existingContact.PhoneNumber = contact.PhoneNumber
}
if contact.Email != nil {
existingContact.Email = contact.Email
}
updateContactStmt := Contacts.
UPDATE(Contacts.MutableColumns).
MODEL(existingContact).
WHERE(Contacts.ID.EQ(UUID(contact.ID))).
RETURNING(Contacts.AllColumns)
updatedContact := model.Contacts{}
err = updateContactStmt.QueryContext(ctx, m.dbPool, &updatedContact)
return updatedContact, err
}
func (m ContactModel) Save(ctx context.Context, userId uuid.UUID, contact model.Contacts) (model.Contacts, error) {
// TODO: make this a transaction
if contact.ID != uuid.Nil {
return m.Update(ctx, contact)
}
insertContactStmt := Contacts.
INSERT(Contacts.Name, Contacts.Description, Contacts.PhoneNumber, Contacts.Email).
VALUES(contact.Name, contact.Description, contact.PhoneNumber, contact.Email).

View File

@ -31,8 +31,8 @@ func (m EventModel) List(ctx context.Context, userId uuid.UUID) ([]model.Events,
func (m EventModel) Save(ctx context.Context, userId uuid.UUID, event model.Events) (model.Events, error) {
// TODO tx here
insertEventStmt := Events.
INSERT(Events.MutableColumns).
MODEL(event).
INSERT(Events.Name, Events.Description, Events.StartDateTime, Events.EndDateTime).
VALUES(event.Name, event.Description, event.StartDateTime, event.EndDateTime).
RETURNING(Events.AllColumns)
insertedEvent := model.Events{}

View File

@ -30,50 +30,7 @@ func (m LocationModel) List(ctx context.Context, userId uuid.UUID) ([]model.Loca
return locations, err
}
func (m LocationModel) Get(ctx context.Context, locationId uuid.UUID) (model.Locations, error) {
getLocationStmt := Locations.
SELECT(Locations.AllColumns).
WHERE(Locations.ID.EQ(UUID(locationId)))
location := model.Locations{}
err := getLocationStmt.QueryContext(ctx, m.dbPool, &location)
return location, err
}
func (m LocationModel) Update(ctx context.Context, location model.Locations) (model.Locations, error) {
existingLocation, err := m.Get(ctx, location.ID)
if err != nil {
return model.Locations{}, err
}
existingLocation.Name = location.Name
if location.Description != nil {
existingLocation.Description = location.Description
}
if location.Address != nil {
existingLocation.Address = location.Address
}
updateLocationStmt := Locations.
UPDATE(Locations.MutableColumns).
MODEL(existingLocation).
WHERE(Locations.ID.EQ(UUID(location.ID))).
RETURNING(Locations.AllColumns)
updatedLocation := model.Locations{}
err = updateLocationStmt.QueryContext(ctx, m.dbPool, &updatedLocation)
return updatedLocation, err
}
func (m LocationModel) Save(ctx context.Context, userId uuid.UUID, location model.Locations) (model.Locations, error) {
if location.ID != uuid.Nil {
return m.Update(ctx, location)
}
insertLocationStmt := Locations.
INSERT(Locations.Name, Locations.Address, Locations.Description).
VALUES(location.Name, location.Address, location.Description).

View File

@ -4,12 +4,12 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"screenmark/screenmark/.gen/haystack/haystack/model"
. "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
)
@ -30,8 +30,16 @@ type ImageWithProperties struct {
Text []model.ImageText
Locations []model.Locations
Events []model.Events
Events []struct {
model.Events
Location *model.Locations
Organizer *model.Contacts
}
Notes []model.Notes
Contacts []model.Contacts
}
@ -87,9 +95,11 @@ func (m UserModel) ListWithProperties(ctx context.Context, userId uuid.UUID) ([]
LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))).
WHERE(UserImages.UserID.EQ(UUID(userId)))
images := []ImageWithProperties{}
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
fmt.Println(listWithPropertiesStmt.DebugSql())
images := []ImageWithProperties{}
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
if err != nil {
return images, err
}
@ -105,24 +115,6 @@ func (m UserModel) GetUserIdFromEmail(ctx context.Context, email string) (uuid.U
return user.ID, err
}
func (m UserModel) DoesUserExist(ctx context.Context, email string) bool {
getUserIdStmt := Users.SELECT(Users.ID).WHERE(Users.Email.EQ(String(email)))
user := model.Users{}
err := getUserIdStmt.QueryContext(ctx, m.dbPool, &user)
return err != qrm.ErrNoRows
}
func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, error) {
insertUserStmt := Users.INSERT(Users.Email).VALUES(user.Email).RETURNING(Users.AllColumns)
insertedUser := model.Users{}
err := insertUserStmt.QueryContext(ctx, m.dbPool, &insertedUser)
return insertedUser, err
}
func NewUserModel(db *sql.DB) UserModel {
return UserModel{dbPool: db}
}

View File

@ -146,11 +146,6 @@ CREATE TABLE haystack.user_notes (
note_id UUID NOT NULL REFERENCES haystack.notes (id)
);
CREATE TABLE haystack.logs (
log TEXT NOT NULL,
image_id UUID NOT NULL REFERENCES haystack.image (id)
);
/* -----| Indexes |----- */
CREATE INDEX user_tags_index ON haystack.user_tags(tag);

3
frontend/.idea/.gitignore generated vendored
View File

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -1,607 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="labId" value="google" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OnePlus" />
<option name="codename" value="OP5552L1" />
<option name="id" value="OP5552L1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OnePlus" />
<option name="name" value="CPH2415" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2412" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OPPO" />
<option name="codename" value="OP573DL1" />
<option name="id" value="OP573DL1" />
<option name="labId" value="google" />
<option name="manufacturer" value="OPPO" />
<option name="name" value="CPH2557" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="labId" value="google" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Lenovo" />
<option name="codename" value="TB370FU" />
<option name="formFactor" value="Tablet" />
<option name="id" value="TB370FU" />
<option name="labId" value="google" />
<option name="manufacturer" value="Lenovo" />
<option name="name" value="Tab P12" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1840" />
<option name="screenY" value="2944" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="a15" />
<option name="id" value="a15" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="A15" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="a35x" />
<option name="id" value="a35x" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="A35" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="arcfox" />
<option name="id" value="arcfox" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="razr plus 2024" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1080" />
<option name="screenY" value="1272" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="motorola" />
<option name="codename" value="austin" />
<option name="id" value="austin" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g 5G (2022)" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="default" value="true" />
<option name="id" value="comet" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm2q" />
<option name="id" value="dm2q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="S23 Plus" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e1q" />
<option name="default" value="true" />
<option name="id" value="e1q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e3q" />
<option name="id" value="e3q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24 Ultra" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1440" />
<option name="screenY" value="3120" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="eos" />
<option name="id" value="eos" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Eos" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="fogona" />
<option name="id" value="fogona" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g play - 2024" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="g0q" />
<option name="id" value="g0q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-S906U1" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gta9pwifi" />
<option name="id" value="gta9pwifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-X210" />
<option name="screenDensity" value="240" />
<option name="screenX" value="1200" />
<option name="screenY" value="1920" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gts7xllite" />
<option name="id" value="gts7xllite" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-T738U" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="formFactor" value="Tablet" />
<option name="id" value="gts8uwifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8wifi" />
<option name="formFactor" value="Tablet" />
<option name="id" value="gts8wifi" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8" />
<option name="screenDensity" value="274" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="gts9fe" />
<option name="id" value="gts9fe" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S9 FE 5G" />
<option name="screenDensity" value="280" />
<option name="screenX" value="1440" />
<option name="screenY" value="2304" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="motorola" />
<option name="codename" value="maui" />
<option name="id" value="maui" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g play - 2023" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="o1q" />
<option name="id" value="o1q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S21" />
<option name="screenDensity" value="421" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q6q" />
<option name="id" value="q6q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1856" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="formFactor" value="Wear OS" />
<option name="id" value="r11" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="r11q" />
<option name="id" value="r11q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-S711U" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="t2q" />
<option name="id" value="t2q" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S21 Plus" />
<option name="screenDensity" value="394" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="formFactor" value="Tablet" />
<option name="id" value="tangorpro" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="default" value="true" />
<option name="id" value="tokay" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
</modules>
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

Binary file not shown.

View File

@ -1,7 +1,7 @@
{
"name": "haystack",
"version": "0.1.0",
"description": "Screenshots that organize themselves",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
@ -20,18 +20,13 @@
"@tabler/icons-solidjs": "^3.30.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-opener": "^2",
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",
"jwt-decode": "^4.0.0",
"solid-js": "^1.9.3",
"solid-markdown": "^2.0.14",
"solid-motionone": "^1.0.3",
"solidjs-markdown": "^0.2.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tauri-plugin-sharetarget-api": "^0.1.6",
"valibot": "^1.0.0-rc.2"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
[package]
name = "Haystack"
name = "haystack"
version = "0.1.0"
description = "Screenshots that organize themselves"
authors = ["Dmytro Kondakov", "John Costa"]
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -15,27 +15,17 @@ name = "haystack_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0.0-beta.12", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2.0.0-beta.12", features = ["macos-private-api"] }
tauri = { version = "2", features = ["macos-private-api"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2"
notify = "6.1.1"
base64 = "0.21.7"
tokio = { version = "1.36.0", features = ["full"] }
tauri-plugin-store = "2.0.0-beta.12"
tauri-plugin-http = "2.0.0-beta.12"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-opener = "2.2.6"
tauri-plugin-sharetarget = "0.1.6"
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.26"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-global-shortcut = "2.0.0-beta.12"
[target."cfg(target_os = \"android\")".dependencies]
tauri-plugin-sharetarget = "0.1.6"

View File

@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default",
"core:window:allow-start-dragging"
]
}

View File

@ -1,16 +0,0 @@
identifier = "Desktop"
description = "Capabilities for desktop platforms"
windows = ["main"]
platforms = ["linux", "macOS", "windows"]
permissions = [
"core:default",
"core:window:allow-start-dragging",
"fs:default",
"http:default",
{ identifier = "http:default", allow = [
{ url = "https://haystack.johncosta.tech" },
{ url = "http://localhost:3040" },
{ url = "http://192.168.1.199:3040" }
] },
]

View File

@ -1,16 +0,0 @@
identifier = "Mobile"
description = "Capabilities for mobile platforms"
windows = ["main"]
platforms = ["android", "iOS"]
permissions = [
"core:default",
"fs:default",
"http:default",
"sharetarget:default",
{ identifier = "http:default", allow = [
{ url = "https://haystack.johncosta.tech" },
{ url = "http://localhost:3040" },
{ url = "http://192.168.1.199:3040" }
] },
]

View File

@ -1,12 +0,0 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

View File

@ -1,19 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
/.tauri
/tauri.settings.gradle

View File

@ -1,6 +0,0 @@
/src/main/java/com/haystack/app/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties

View File

@ -1,83 +0,0 @@
import java.util.Properties
import java.io.FileInputStream
val keyPropertiesFile = rootProject.file("key.properties")
val keyProperties = Properties()
keyProperties.load(FileInputStream(keyPropertiesFile))
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 34
namespace = "com.haystack.app"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.haystack.app"
minSdk = 21
targetSdk = 34
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
signingConfigs {
create("release") {
keyAlias = keyProperties["keyAlias"] as String
keyPassword = keyProperties["keyPassword"] as String
storeFile = file(keyProperties["storeFile"] as String)
storePassword = keyProperties["storePassword"] as String
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
signingConfig = signingConfigs.getByName("release")
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.6.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.8.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")

View File

@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.haystack"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
<data android:mimeType="image/*" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -1,3 +0,0 @@
package com.haystack.app
class MainActivity : TauriActivity()

View File

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,6 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.haystack" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -1,4 +0,0 @@
<resources>
<string name="app_name">Haystack</string>
<string name="main_activity_title">Haystack</string>
</resources>

View File

@ -1,6 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.haystack" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@ -1,22 +0,0 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.5.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}

View File

@ -1,23 +0,0 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.5.1")
}

View File

@ -1,52 +0,0 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """bun""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
runTauriCli("$executable.cmd")
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}

View File

@ -1,85 +0,0 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}

View File

@ -1,25 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk

View File

@ -1,6 +0,0 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View File

@ -1,185 +0,0 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

View File

@ -1,89 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -1,3 +0,0 @@
include ':app'
apply from: 'tauri.settings.gradle'

View File

@ -1,71 +0,0 @@
use crate::state::SharedWatcherState;
use crate::utils::process_png_file;
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::mpsc::channel;
use tauri::AppHandle;
#[tauri::command]
pub async fn handle_selected_folder(
path: String,
state: tauri::State<'_, SharedWatcherState>,
app: AppHandle,
) -> Result<String, String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() || !path_buf.is_dir() {
return Err("Invalid directory path".to_string());
}
// Stop existing watcher if any
let mut state = state
.lock()
.map_err(|_| "Failed to lock state".to_string())?;
state.clear_watcher();
// Create a channel to receive file system events
let (tx, rx) = channel();
// Create a new watcher
let mut watcher = RecommendedWatcher::new(tx, Config::default())
.map_err(|e| format!("Failed to create watcher: {}", e))?;
// Start watching the directory
watcher
.watch(path_buf.as_ref(), RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?;
// Store the watcher in state
state.set_watcher(watcher);
let path_clone = path.clone();
let app_clone = app.clone();
tokio::spawn(async move {
println!("Starting to watch directory: {}", path_clone);
for res in rx {
match res {
Ok(event) => {
println!("Received event: {:?}", event);
match event.kind {
notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
for path in event.paths {
println!("Processing path: {}", path.display());
if let Some(extension) = path.extension() {
if extension.to_string_lossy().to_lowercase() == "png" {
if let Err(e) = process_png_file(&path, app_clone.clone()) {
eprintln!("Error processing PNG file: {}", e);
}
}
}
}
}
_ => {}
}
}
Err(e) => eprintln!("Watch error: {:?}", e),
}
}
});
Ok(format!("Now watching directory: {}", path))
}

View File

@ -1,44 +1,149 @@
mod commands;
mod state;
mod utils;
mod window;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::fs;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::sync::Arc;
use std::sync::Mutex;
use tauri::AppHandle;
use tauri::Emitter;
use tauri::{WebviewUrl, WebviewWindowBuilder};
use state::new_shared_watcher_state;
use window::setup_window;
struct WatcherState {
watcher: Option<RecommendedWatcher>,
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
pub fn desktop() {
let watcher_state = new_shared_watcher_state();
impl WatcherState {
fn new() -> Self {
Self { watcher: None }
}
}
// Handle PNG file processing
fn process_png_file(path: &PathBuf, app: AppHandle) -> Result<(), String> {
println!("Processing PNG file: {}", path.display());
// Read the file
let contents = fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
// Convert to base64
let base64_string = BASE64.encode(&contents);
println!("Generated base64 string of length: {}", base64_string.len());
// Emit the base64 to frontend
app.emit("png-processed", base64_string)
.map_err(|e| format!("Failed to emit event: {}", e))?;
println!("Successfully processed file: {}", path.display());
Ok(())
}
#[tauri::command]
async fn handle_selected_folder(
path: String,
state: tauri::State<'_, Arc<Mutex<WatcherState>>>,
app: AppHandle,
) -> Result<String, String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() || !path_buf.is_dir() {
return Err("Invalid directory path".to_string());
}
// Stop existing watcher if any
let mut state = state
.lock()
.map_err(|_| "Failed to lock state".to_string())?;
state.watcher = None;
// Create a channel to receive file system events
let (tx, rx) = channel();
// Create a new watcher
let mut watcher = RecommendedWatcher::new(tx, Config::default())
.map_err(|e| format!("Failed to create watcher: {}", e))?;
// Start watching the directory
watcher
.watch(path_buf.as_ref(), RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?;
// Store the watcher in state
state.watcher = Some(watcher);
let path_clone = path.clone();
let app_clone = app.clone();
tokio::spawn(async move {
println!("Starting to watch directory: {}", path_clone);
for res in rx {
match res {
Ok(event) => {
println!("Received event: {:?}", event);
match event.kind {
notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
for path in event.paths {
println!("Processing path: {}", path.display());
if let Some(extension) = path.extension() {
if extension.to_string_lossy().to_lowercase() == "png" {
if let Err(e) = process_png_file(&path, app_clone.clone()) {
eprintln!("Error processing PNG file: {}", e);
}
}
}
}
}
_ => {}
}
}
Err(e) => eprintln!("Watch error: {:?}", e),
}
}
});
Ok(format!("Now watching directory: {}", path))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let watcher_state = Arc::new(Mutex::new(WatcherState::new()));
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.manage(watcher_state)
.invoke_handler(tauri::generate_handler![commands::handle_selected_folder,])
.invoke_handler(tauri::generate_handler![handle_selected_folder])
.setup(|app| {
setup_window(app)?;
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.inner_size(480.0, 360.0)
// .hidden_title(true)
.resizable(true);
// set transparent title bar only when building for macOS
#[cfg(target_os = "macos")]
let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent);
let window = win_builder.build().unwrap();
// set background color only when building for macOS
#[cfg(target_os = "macos")]
{
use cocoa::appkit::{NSColor, NSWindow};
use cocoa::base::{id, nil};
let ns_window = window.ns_window().unwrap() as id;
unsafe {
let bg_color = NSColor::colorWithRed_green_blue_alpha_(
nil,
245.0 / 255.0,
245.0 / 255.0,
245.0 / 255.0,
1.0,
);
ns_window.setBackgroundColor_(bg_color);
}
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(any(target_os = "ios", target_os = "android"))]
pub fn android() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_sharetarget::init())
.setup(|app| {
setup_window(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running android tauri application");
}

View File

@ -2,9 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
haystack_lib::desktop();
#[cfg(any(target_os = "ios", target_os = "android"))]
haystack_lib::android();
haystack_lib::run()
}

View File

@ -1,27 +0,0 @@
use notify::RecommendedWatcher;
use std::sync::Arc;
use std::sync::Mutex;
pub struct WatcherState {
watcher: Option<RecommendedWatcher>,
}
impl WatcherState {
pub fn new() -> Self {
Self { watcher: None }
}
pub fn set_watcher(&mut self, watcher: RecommendedWatcher) {
self.watcher = Some(watcher);
}
pub fn clear_watcher(&mut self) {
self.watcher = None;
}
}
pub type SharedWatcherState = Arc<Mutex<WatcherState>>;
pub fn new_shared_watcher_state() -> SharedWatcherState {
Arc::new(Mutex::new(WatcherState::new()))
}

View File

@ -1,22 +0,0 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter};
pub fn process_png_file(path: &PathBuf, app: AppHandle) -> Result<(), String> {
println!("Processing PNG file: {}", path.display());
// Read the file
let contents = fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
// Convert to base64
let base64_string = BASE64.encode(&contents);
println!("Generated base64 string of length: {}", base64_string.len());
// Emit the base64 to frontend
app.emit("png-processed", base64_string)
.map_err(|e| format!("Failed to emit event: {}", e))?;
println!("Successfully processed file: {}", path.display());
Ok(())
}

View File

@ -1,39 +0,0 @@
use tauri::App;
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn setup_window(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
//.inner_size(480.0, 360.0)
//.title("Haystack")
//.resizable(false);
#[cfg(target_os = "macos")]
{
use cocoa::appkit::{NSColor, NSWindow};
use cocoa::base::{id, nil};
use tauri::TitleBarStyle;
let win_builder = win_builder
.hidden_title(true)
.title_bar_style(TitleBarStyle::Transparent);
let window = win_builder.build().unwrap();
let ns_window = window.ns_window().unwrap() as id;
unsafe {
let bg_color = NSColor::colorWithRed_green_blue_alpha_(
nil,
245.0 / 255.0,
245.0 / 255.0,
245.0 / 255.0,
1.0,
);
ns_window.setBackgroundColor_(bg_color);
}
}
{
win_builder.build().unwrap();
}
Ok(())
}

View File

@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Haystack",
"productName": "haystack",
"version": "0.1.0",
"identifier": "com.haystack.app",
"build": {

View File

@ -1,85 +1,176 @@
import { Route, Router } from "@solidjs/router";
import { listen } from "@tauri-apps/api/event";
import { createEffect, createSignal, For, onCleanup, Show } from "solid-js";
import { Login } from "./Login";
import { ProtectedRoute } from "./ProtectedRoute";
import { Search } from "./Search";
import { Settings } from "./Settings";
import { ImageViewer } from "./components/ImageViewer";
import type { PluginListener } from "@tauri-apps/api/core";
import {
listenForShareEvents,
type ShareEvent,
} from "tauri-plugin-sharetarget-api";
import { readFile } from "@tauri-apps/plugin-fs";
import { sendImage, sendImageFile } from "./network";
import { A } from "@solidjs/router";
import { IconSearch } from "@tabler/icons-solidjs";
import clsx from "clsx";
import Fuse from "fuse.js";
import { For, createEffect, createResource, createSignal } from "solid-js";
import { SearchCardEvent } from "./components/search-card/SearchCardEvent";
import { SearchCardLocation } from "./components/search-card/SearchCardLocation";
import { SearchCardNote } from "./components/search-card/SearchCardNote";
import { type UserImage, getUserImages } from "./network";
import { getCardSize } from "./utils/getCardSize";
import { SearchCardContact } from "./components/search-card/SearchCardContact";
export const App = () => {
const [logs, setLogs] = createSignal<string[]>([]);
const [file, setFile] = createSignal<File>();
const getCardComponent = (item: UserImage) => {
switch (item.type) {
case "location":
return <SearchCardLocation item={item} />;
case "event":
return <SearchCardEvent item={item} />;
case "note":
return <SearchCardNote item={item} />;
case "contact":
return <SearchCardContact item={item} />;
// case "Website":
// return <SearchCardWebsite item={item} />;
// case "Note":
// return <SearchCardNote item={item} />;
// case "Receipt":
// return <SearchCardReceipt item={item} />;
default:
return null;
}
};
createEffect(() => {
// TODO: Don't use window.location.href
const unlisten = listen("focus-search", () => {
window.location.href = "/";
});
onCleanup(() => {
unlisten.then((fn) => fn());
});
});
createEffect(() => {
let listener: PluginListener;
const setupListener = async () => {
listener = await listenForShareEvents(
async (intent: ShareEvent) => {
const contents = await readFile(intent.stream).catch(
(error: Error) => {
console.warn("fetching shared content failed:");
throw error;
},
);
setFile(
new File([contents], intent.name ?? "no-name", {
type: intent.content_type,
}),
);
setLogs((l) => [...l, intent.uri]);
},
);
};
setupListener();
return () => {
listener?.unregister();
};
});
createEffect(() => {
const f = file();
if (f == null) {
return;
// How wonderfully functional
const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => {
for (const _value of Object.values(next)) {
const value: unknown = _value;
switch (typeof value) {
case "object":
if (value != null) {
acc.push(...loop(acc, value));
}
break;
case "string":
case "number":
case "boolean":
acc.push(value.toString());
break;
default:
break;
}
}
sendImageFile(f.name, f);
return acc;
};
return loop([], object);
};
function App() {
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedItem, setSelectedItem] = createSignal<UserImage | null>(
null,
);
const [data] = createResource(() =>
getUserImages().then((data) =>
data.map((d) => ({
...d,
rawData: getAllValues(d),
})),
),
);
let fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "rawData", weight: 1 },
{ name: "title", weight: 1 },
],
threshold: 0.4,
});
createEffect(() => {
fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "data.Name", weight: 2 },
{ name: "rawData", weight: 1 },
],
threshold: 0.4,
});
});
const onInputChange = (event: InputEvent) => {
const query = (event.target as HTMLInputElement).value;
setSearchQuery(query);
setSearchResults(fuze.search(query).map((s) => s.item));
};
return (
<>
<ImageViewer />
<p>Hello</p>
<Show when={file()}>
{(f) => <img alt="my-image" src={URL.createObjectURL(f())} />}
</Show>
<For each={logs()}>{(log) => <p>{log}</p>}</For>
<Router>
<Route path="/login" component={Login} />
<main class="container pt-2">
<A href="login">login</A>
<div class="px-4">
<div class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-neutral-200 text-neutral-900">
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900">
<IconSearch
size={20}
class="m-auto size-5 text-neutral-600"
/>
</div>
<input
type="text"
value={searchQuery()}
onInput={onInputChange}
placeholder="Search for stuff..."
class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600"
/>
</div>
</div>
<Route path="/" component={ProtectedRoute}>
<Route path="/" component={Search} />
<Route path="/settings" component={Settings} />
</Route>
</Router>
<div class="px-4 mt-4 bg-white rounded-t-2xl">
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide">
{searchResults().length > 0 ? (
<div class="w-full grid grid-cols-9 gap-2 grid-flow-row-dense py-4">
<For each={searchResults()}>
{(item) => (
<div
onClick={() =>
setSelectedItem(item)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSelectedItem(item);
}
}}
class={clsx(
"h-[144px] border relative border-neutral-200 cursor-pointer overflow-hidden rounded-xl",
{
"col-span-3":
getCardSize(
item.type,
) === "1/1",
"col-span-6":
getCardSize(
item.type,
) === "2/1",
},
)}
>
<span class="sr-only">
{item.data.Name}
</span>
{getCardComponent(item)}
</div>
)}
</For>
</div>
) : searchQuery() !== "" ? (
<div class="text-center text-lg m-auto text-neutral-700">
No results found
</div>
) : null}
</div>
</div>
<div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100">
footer
</div>
</main>
</>
);
};
}
export default App;

View File

@ -0,0 +1,67 @@
import { A, useParams } from "@solidjs/router";
import { createEffect, createResource, For, Suspense } from "solid-js";
import { getUserImages } from "./network";
export function ImagePage() {
const { imageId } = useParams<{ imageId: string }>();
const [image] = createResource(async () => {
const userImages = await getUserImages();
const currentImage = userImages.find((image) => image.ID === imageId);
if (currentImage == null) {
// TODO: this error handling.
throw new Error("must be valid");
}
return currentImage;
});
createEffect(() => {
console.log(image());
});
return (
<Suspense fallback={<>Loading...</>}>
<A href="/">Back</A>
<h1 class="text-2xl font-bold">{image()?.Image.ImageName}</h1>
<img
src={`http://localhost:3040/image/${image()?.ID}`}
alt="link"
/>
<div class="flex flex-col">
<h2 class="text-xl font-bold">Tags</h2>
<For each={image()?.Tags ?? []}>
{(tag) => <div>{tag.Tag.Tag}</div>}
</For>
<h2 class="text-xl font-bold">Locations</h2>
<For each={image()?.Locations ?? []}>
{(location) => (
<ul>
<li>{location.Name}</li>
{location.Address && <li>{location.Address}</li>}
{location.Coordinates && (
<li>{location.Coordinates}</li>
)}
{location.Description && (
<li>{location.Description}</li>
)}
</ul>
)}
</For>
<h2 class="text-xl font-bold">Events</h2>
<For each={image()?.Events ?? []}>
{(event) => (
<ul>
<li>{event.Name}</li>
{event.Location && <li>{event.Location.Name}</li>}
{event.Description && <li>{event.Description}</li>}
</ul>
)}
</For>
</div>
</Suspense>
);
}

View File

@ -1,9 +1,9 @@
import { Button } from "@kobalte/core/button";
import { TextField } from "@kobalte/core/text-field";
import { Navigate } from "@solidjs/router";
import { type Component, Show, createSignal } from "solid-js";
import { createSignal, Show, type Component } from "solid-js";
import { postCode, postLogin } from "./network";
import { isTokenValid } from "./ProtectedRoute";
import { base, postCode, postLogin } from "./network";
import { Navigate } from "@solidjs/router";
export const Login: Component = () => {
let form: HTMLFormElement | undefined;
@ -34,16 +34,12 @@ export const Login: Component = () => {
localStorage.setItem("access", access);
localStorage.setItem("refresh", refresh);
window.location.href = "/";
}
};
const isAuthorized = isTokenValid();
return (
<>
{base}
<Show when={!isAuthorized} fallback={<Navigate href="/" />}>
<form ref={form} onSubmit={onSubmit}>
<TextField name="email">
@ -59,6 +55,5 @@ export const Login: Component = () => {
<Button type="submit">Submit</Button>
</form>
</Show>
</>
);
};

View File

@ -1,216 +0,0 @@
import { Button } from "@kobalte/core/button";
import { IconSearch, IconSettings } from "@tabler/icons-solidjs";
import { listen } from "@tauri-apps/api/event";
import Fuse from "fuse.js";
import {
For,
Show,
createEffect,
createResource,
createSignal,
onCleanup,
onMount,
} from "solid-js";
import { SearchCard } from "./components/search-card/SearchCard";
import { invoke } from "@tauri-apps/api/core";
import { ItemModal } from "./components/item-modal/ItemModal";
import type { Shortcut } from "./components/shortcuts/hooks/useShortcutEditor";
import { type UserImage, getUserImages } from "./network";
// How wonderfully functional
const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => {
for (const _value of Object.values(next)) {
const value: unknown = _value;
switch (typeof value) {
case "object":
if (value != null) {
acc.push(...loop(acc, value));
}
break;
case "string":
case "number":
case "boolean":
acc.push(value.toString());
break;
default:
break;
}
}
return acc;
};
return loop([], object);
};
export const Search = () => {
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedItem, setSelectedItem] = createSignal<UserImage | null>(
null,
);
const [data] = createResource(() =>
getUserImages().then((data) => {
console.log("DBG: ", data);
return data.map((d) => ({
...d,
rawData: getAllValues(d),
}));
}),
);
let fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "rawData", weight: 1 },
{ name: "title", weight: 1 },
],
threshold: 0.4,
});
createEffect(() => {
console.log("DBG: ", data());
setSearchResults(data() ?? []);
fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "data.Name", weight: 2 },
{ name: "rawData", weight: 1 },
],
threshold: 0.4,
});
});
const onInputChange = (event: InputEvent) => {
const query = (event.target as HTMLInputElement).value;
setSearchQuery(query);
setSearchResults(fuze.search(query).map((s) => s.item));
};
let searchInputRef: HTMLInputElement | undefined;
onMount(() => {
if (searchInputRef) {
searchInputRef.focus();
}
});
createEffect(() => {
// Listen for the focus-search event from Tauri
const unlisten = listen("focus-search", () => {
if (searchInputRef) {
searchInputRef.focus();
}
});
onCleanup(() => {
unlisten.then((fn) => fn());
});
});
const [shortcut, setShortcut] = createSignal<Shortcut>([]);
async function getCurrentShortcut() {
try {
const res: string = await invoke("get_current_shortcut");
console.log("DBG: ", res);
setShortcut(res?.split("+"));
} catch (err) {
console.error("Failed to fetch shortcut:", err);
}
}
onMount(() => {
getCurrentShortcut();
});
return (
<>
<main class="container pt-2">
<div class="px-4 flex items-center">
<div class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-neutral-200 text-neutral-900">
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-md px-2.5 text-gray-900">
<IconSearch
size={20}
class="m-auto size-5 text-neutral-600"
/>
</div>
<input
ref={searchInputRef}
type="text"
value={searchQuery()}
onInput={onInputChange}
placeholder="Search for stuff..."
autofocus
class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600"
/>
</div>
<Button
as="a"
href="/settings"
class="ml-2 p-2.5 bg-neutral-200 rounded-lg"
>
<IconSettings size={20} />
</Button>
</div>
<div class="px-4 mt-4 bg-white rounded-t-2xl">
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide">
<Show
when={searchResults().length > 0}
fallback={
<div class="text-center text-lg m-auto mt-6 text-neutral-700">
No results found
</div>
}
>
<div class="w-full grid grid-cols-9 gap-2 grid-flow-row-dense py-4">
<For each={searchResults()}>
{(item) => (
<div
onClick={() =>
setSelectedItem(item)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSelectedItem(item);
}
}}
class="h-[144px] border relative col-span-3 border-neutral-200 cursor-pointer overflow-hidden rounded-xl"
>
<span class="sr-only">
{item.data.Name}
</span>
<SearchCard item={item} />
</div>
)}
</For>
</div>
</Show>
</div>
</div>
<div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100">
<p class="text-sm text-neutral-700">
Use{" "}
{shortcut().length > 0
? shortcut().join("+")
: "shortcut"}{" "}
globally to toggle and reload this window
</p>
</div>
</main>
{selectedItem() && (
<ItemModal
item={selectedItem() as UserImage}
onClose={() => setSelectedItem(null)}
/>
)}
</>
);
};

View File

@ -1,33 +0,0 @@
import { Button } from "@kobalte/core/button";
import { FolderPicker } from "./components/folder-picker/FolderPicker";
import { Shortcuts } from "./components/shortcuts/Shortcuts";
export const Settings = () => {
const logout = () => {
localStorage.removeItem("access");
localStorage.removeItem("refresh");
window.location.href = "/login";
};
return (
<>
<main class="container pt-2">
<div class="flex flex-col px-4 gap-2">
<Button as="a" href="/">
Back to home
</Button>
<h1 class="text-3xl font-bold">Settings</h1>
<FolderPicker />
<Shortcuts />
<Button
class="p-2 bg-neutral-100 border mt-4 border-neutral-300"
onClick={logout}
>
Logout
</Button>
</div>
</main>
</>
);
};

View File

@ -0,0 +1,49 @@
import { createSignal } from "solid-js";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
export function FolderPicker() {
const [selectedPath, setSelectedPath] = createSignal<string>("");
const [status, setStatus] = createSignal<string>("");
const handleFolderSelect = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
});
if (selected) {
setSelectedPath(selected as string);
// Send the path to Rust
const response = await invoke("handle_selected_folder", {
path: selected,
});
setStatus(`Folder processed: ${response}`);
}
} catch (error) {
setStatus(`Error: ${error}`);
}
};
return (
<div class="flex flex-col items-center gap-4">
<button
type="button"
onClick={handleFolderSelect}
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Select Folder
</button>
{selectedPath() && (
<div class="text-left max-w-md">
<p class="font-semibold">Selected folder:</p>
<p class="text-sm break-all">{selectedPath()}</p>
</div>
)}
{status() && <p class="text-sm text-gray-600">{status()}</p>}
</div>
);
}

View File

@ -1,21 +1,19 @@
import { createEffect, createSignal } from "solid-js";
import { listen } from "@tauri-apps/api/event";
import { createEffect } from "solid-js";
import { FolderPicker } from "./FolderPicker";
import { sendImage } from "../network";
export function ImageViewer() {
// const [latestImage, setLatestImage] = createSignal<string | null>(null);
const [latestImage, setLatestImage] = createSignal<string | null>(null);
createEffect(async () => {
createEffect(() => {
// Listen for PNG processing events
const unlisten = listen("png-processed", async (event) => {
const unlisten = listen("png-processed", (event) => {
console.log("Received processed PNG", event);
const base64Data = event.payload as string;
// setLatestImage(`data:image/png;base64,${base64Data}`);
const result = await sendImage("test-image.png", base64Data);
window.location.reload();
console.log("DBG: ", result);
setLatestImage(`data:image/png;base64,${base64Data}`);
sendImage("test-image.png", base64Data);
});
return () => {
@ -23,22 +21,20 @@ export function ImageViewer() {
};
});
return null;
return (
<div>
<FolderPicker />
// return (
// <div>
// <FolderPicker />
// {latestImage() && (
// <div class="mt-4">
// <h3>Latest Processed Image:</h3>
// <img
// src={latestImage() || undefined}
// alt="Latest processed"
// class="max-w-md"
// />
// </div>
// )}
// </div>
// );
{latestImage() && (
<div class="mt-4">
<h3>Latest Processed Image:</h3>
<img
src={latestImage() || undefined}
alt="Latest processed"
class="max-w-md"
/>
</div>
)}
</div>
);
}

View File

@ -1,52 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { createSignal } from "solid-js";
export function FolderPicker() {
const [selectedPath, setSelectedPath] = createSignal<string>("");
const handleFolderSelect = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
});
if (selected) {
setSelectedPath(selected as string);
// Send the path to Rust
const response = await invoke("handle_selected_folder", {
path: selected,
});
console.log("DBG: ", response);
}
} catch (error) {
console.error("DBG: ", error);
}
};
return (
<div class="flex flex-col items-start gap-2">
<p class="text-sm text-neutral-700">
Select the folder where your screenshots are stored. We'll watch
this folder for any changes and process any new screenshots.
</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={handleFolderSelect}
class="bg-neutral-100 border border-neutral-300 rounded-md px-2 py-1"
>
Select folder
</button>
{selectedPath() && (
<div class="text-left max-w-md">
<p class="text-sm break-all">{selectedPath()}</p>
</div>
)}
</div>
</div>
);
}

View File

@ -1,25 +0,0 @@
import { IconX } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
type Props = {
item: UserImage;
onClose: () => void;
};
export const ItemModal = (props: Props) => {
return (
<div class="fixed inset-2 rounded-2xl p-4 bg-white border border-neutral-300">
<div class="flex justify-between">
<h1 class="text-2xl font-bold">{props.item.data.Name}</h1>
<button type="button" onClick={props.onClose}>
<IconX size={24} class="text-neutral-500" />
</button>
</div>
<div class="flex flex-col gap-2 mb-2">
<p class="text-sm text-neutral-500">
{JSON.stringify(props.item.data, null, 2)}
</p>
</div>
</div>
);
};

View File

@ -1,22 +0,0 @@
import type { UserImage } from "../../network";
import { SearchCardContact } from "./SearchCardContact";
import { SearchCardEvent } from "./SearchCardEvent";
import { SearchCardLocation } from "./SearchCardLocation";
import { SearchCardNote } from "./SearchCardNote";
export const SearchCard = (props: { item: UserImage }) => {
const { item } = props;
switch (item.type) {
case "location":
return <SearchCardLocation item={item} />;
case "event":
return <SearchCardEvent item={item} />;
case "note":
return <SearchCardNote item={item} />;
case "contact":
return <SearchCardContact item={item} />;
default:
return null;
}
};

View File

@ -12,15 +12,17 @@ export const SearchCardContact = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-orange-50">
<div class="flex mb-1 items-center gap-1">
<IconUser size={14} class="text-neutral-500" />
<p class="text-xs text-neutral-500">Contact</p>
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
<IconUser size={20} class="text-neutral-500 mt-1" />
</div>
<p class="text-sm text-neutral-900 font-bold mb-1">
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
<p class="text-xs text-neutral-500">{data.PhoneNumber}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500">{data.Email}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
</p>
<p class="text-xs text-neutral-700">Phone: {data.PhoneNumber}</p>
<p class="text-xs text-neutral-700">Mail: {data.Email}</p>
</div>
);
};

View File

@ -1,3 +1,5 @@
import { Separator } from "@kobalte/core/separator";
import { IconCalendar } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -10,22 +12,21 @@ export const SearchCardEvent = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-purple-50">
<div class="flex mb-1 items-center gap-1">
<IconCalendar size={14} class="text-neutral-500" />
<p class="text-xs text-neutral-500">Event</p>
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
<IconCalendar size={20} class="text-neutral-500 mt-1" />
</div>
<p class="text-sm text-neutral-900 font-bold mb-1">
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
</p>
<p class="text-xs text-neutral-700">
<p class="text-xs text-neutral-500">
Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
{data.StartDateTime
? new Date(data.StartDateTime).toLocaleDateString("en-US", {
{new Date(data.StartDateTime).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "unknown date"}
})}
</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
</p>
</div>
);

View File

@ -1,3 +1,5 @@
import { Separator } from "@kobalte/core/separator";
import { IconMapPin } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -10,14 +12,15 @@ export const SearchCardLocation = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-red-50">
<div class="flex mb-1 items-center gap-1">
<IconMapPin size={14} class="text-neutral-500" />
<p class="text-xs text-neutral-500">Location</p>
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
<IconMapPin size={20} class="text-neutral-500 mt-1" />
</div>
<p class="text-sm text-neutral-900 font-bold mb-1">
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
<p class="text-xs text-neutral-500">{data.Address}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
</p>
<p class="text-xs text-neutral-700">Address: {data.Address}</p>
</div>
);
};

View File

@ -1,5 +1,4 @@
import { Separator } from "@kobalte/core/separator";
import SolidjsMarkdown from "solidjs-markdown";
import { IconNote } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -13,15 +12,14 @@ export const SearchCardNote = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-green-50">
<div class="flex mb-1 items-center gap-1">
<IconNote size={14} class="text-neutral-500" />
<p class="text-xs text-neutral-500">Note</p>
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
<IconNote size={20} class="text-neutral-500 mt-1" />
</div>
<p class="text-sm text-neutral-900 font-bold mb-1">
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
</p>
<p class="text-xs text-neutral-700">
<SolidjsMarkdown>{data.Content}</SolidjsMarkdown>
<p class="text-xs text-neutral-500">Keywords TODO</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Content}
</p>
</div>
);

View File

@ -1,78 +0,0 @@
import { IconX } from "@tabler/icons-solidjs";
import { type Component, For } from "solid-js";
import { formatKey } from "./utils/formatKey";
import { sortKeys } from "./utils/sortKeys";
interface ShortcutItemProps {
shortcut: string[];
isEditing: boolean;
currentKeys: string[];
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
}
export const ShortcutItem: Component<ShortcutItemProps> = (props) => {
const renderKeys = (keys: string[]) => {
const sortedKeys = sortKeys(keys);
return (
<For each={sortedKeys}>
{(key) => (
<kbd class="px-2 py-1 text-sm font-semibold rounded bg-neutral-100 border border-neutral-300 text-neutral-900 ">
{formatKey(key)}
</kbd>
)}
</For>
);
};
return (
<div class="flex">
<div class="flex items-center gap-4">
{props.isEditing ? (
<>
<div class="flex gap-1 min-w-[144px]">
{props.currentKeys.length > 0 ? (
renderKeys(props.currentKeys)
) : (
<span class="text-neutral-500">
Press keys...
</span>
)}
</div>
<div class="flex gap-2">
<button
type="button"
onClick={props.onSave}
disabled={props.currentKeys.length < 2}
class="px-3 py-1 text-sm rounded bg-neutral-900 text-white"
>
Save
</button>
<button
type="button"
onClick={props.onCancel}
class="p-1 rounded text-neutral-500"
>
<IconX class="w-4 h-4" />
</button>
</div>
</>
) : (
<>
<div class="flex gap-1">
{renderKeys(props.shortcut)}
</div>
<button
type="button"
onClick={props.onEdit}
class="px-3 py-1 text-sm rounded bg-neutral-200 text-neutral-700 "
>
Edit
</button>
</>
)}
</div>
</div>
);
};

View File

@ -1,77 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { createSignal, onMount } from "solid-js";
import { ShortcutItem } from "./ShortcutItem";
import { type Shortcut, useShortcutEditor } from "./hooks/useShortcutEditor";
export const Shortcuts = () => {
const [shortcut, setShortcut] = createSignal<Shortcut>([]);
async function getCurrentShortcut() {
try {
const res: string = await invoke("get_current_shortcut");
console.log("DBG: ", res);
setShortcut(res?.split("+"));
} catch (err) {
console.error("Failed to fetch shortcut:", err);
}
}
onMount(() => {
getCurrentShortcut();
});
const changeShortcut = (key: Shortcut) => {
setShortcut(key);
if (key.length === 0) return;
invoke("change_shortcut", { key: key?.join("+") }).catch((err) => {
console.error("Failed to save hotkey:", err);
});
};
const {
isEditing,
currentKeys,
startEditing,
saveShortcut,
cancelEditing,
} = useShortcutEditor(shortcut(), changeShortcut);
const onEditShortcut = async () => {
startEditing();
invoke("unregister_shortcut").catch((err) => {
console.error("Failed to save hotkey:", err);
});
};
const onCancelShortcut = async () => {
cancelEditing();
invoke("change_shortcut", { key: shortcut()?.join("+") }).catch(
(err) => {
console.error("Failed to save hotkey:", err);
},
);
};
const onSaveShortcut = async () => {
saveShortcut();
};
return (
<div class="flex flex-col gap-2 mt-4">
<p class="text-sm text-neutral-700">
Set up a to quickly open Haystack search. This shortcut also
reloads items when updates happen (we should definetely fix
that)
</p>
<ShortcutItem
shortcut={shortcut()}
isEditing={isEditing()}
currentKeys={currentKeys()}
onEdit={onEditShortcut}
onSave={onSaveShortcut}
onCancel={onCancelShortcut}
/>
</div>
);
};

View File

@ -1,134 +0,0 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
import { isModifierKey } from "../utils/isModifierKey";
import { normalizeKey } from "../utils/normalizeKey";
import { sortKeys } from "../utils/sortKeys";
export type Shortcut = string[];
const RESERVED_SHORTCUTS = [
["Command", "C"],
["Command", "V"],
["Command", "X"],
["Command", "A"],
["Command", "Z"],
["Command", "Q"],
// Windows/Linux
["Control", "C"],
["Control", "V"],
["Control", "X"],
["Control", "A"],
["Control", "Z"],
];
export function useShortcutEditor(
shortcut: Shortcut,
onChange: (shortcut: Shortcut) => void,
) {
console.log("DBG: ", shortcut);
const [isEditing, setIsEditing] = createSignal(false);
const [currentKeys, setCurrentKeys] = createSignal<string[]>([]);
const pressedKeys = new Set<string>();
const startEditing = () => {
setIsEditing(true);
setCurrentKeys([]);
};
const saveShortcut = async () => {
if (!isEditing() || currentKeys().length < 2) return;
const hasModifier = currentKeys().some(isModifierKey);
const hasNonModifier = currentKeys().some((key) => !isModifierKey(key));
if (!hasModifier || !hasNonModifier) return;
const isReserved = RESERVED_SHORTCUTS.some(
(reserved) =>
reserved.length === currentKeys().length &&
reserved.every(
(key, index) =>
key.toLowerCase() ===
currentKeys()[index].toLowerCase(),
),
);
if (isReserved) {
console.error("This is a system reserved shortcut");
return;
}
// Sort keys to ensure consistent order (modifiers first)
const sortedKeys = sortKeys(currentKeys());
onChange(sortedKeys);
setIsEditing(false);
setCurrentKeys([]);
};
const cancelEditing = () => {
setIsEditing(false);
setCurrentKeys([]);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isEditing()) return;
e.preventDefault();
e.stopPropagation();
const key = normalizeKey(e.code);
// Update pressed keys
pressedKeys.add(key);
setCurrentKeys(() => {
const keys = Array.from(pressedKeys);
let modifiers = keys.filter(isModifierKey);
let nonModifiers = keys.filter((k) => !isModifierKey(k));
if (modifiers.length > 2) {
modifiers = modifiers.slice(0, 2);
}
if (nonModifiers.length > 2) {
nonModifiers = nonModifiers.slice(0, 2);
}
// Combine modifiers and non-modifiers
return [...modifiers, ...nonModifiers];
});
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isEditing()) return;
const key = normalizeKey(e.code);
pressedKeys.delete(key);
};
// Use createEffect to handle event listeners based on editing state
createEffect(() => {
if (isEditing()) {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
} else {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
pressedKeys.clear();
}
});
// Clean up event listeners on unmount
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
});
return {
isEditing,
currentKeys,
startEditing,
saveShortcut,
cancelEditing,
};
}

Some files were not shown because too many files have changed in this diff Show More