219 Commits

Author SHA1 Message Date
0940134d9e chore(platforms): seperating permissions and inits on different platforms 2025-04-22 15:26:48 +01:00
af24a8f6d4 fix: backend err 2025-04-22 15:26:48 +01:00
446e8bb2ee feat: sending image to the backend 2025-04-22 15:26:48 +01:00
6211945e3d feat(images): share target working and receiving images! 2025-04-22 15:26:48 +01:00
9f215bb3d1 feat: working android dev environment 2025-04-22 15:26:48 +01:00
1a84e0d6c5 wip: working release sdk 2025-04-22 15:26:48 +01:00
bd9d8b955a wip: sorting out various versioning problems 2025-04-22 15:26:48 +01:00
58f60bf053 BIGWIP(android): trying to make an android release
fucking stupid shit why is it so hard
2025-04-22 15:26:48 +01:00
55cd552724 fix(linux-tauri): window building on linux 2025-04-19 15:42:14 +01:00
981bca86e9 wip(logs): displaying image
WIP because we need to bypass authorization here
2025-04-19 14:06:05 +01:00
ac0bcfdae0 feat(logs): route to view the logs for each image 2025-04-19 14:03:18 +01:00
af2aa2c1b6 chore(code): cleaning 2025-04-19 12:16:48 +01:00
9f98a21532 feat(logging): split logging to stdout & database to allow us to view it on webbrowser 2025-04-19 12:14:04 +01:00
130bce86a1 fix: enabling note agent 2025-04-19 10:30:49 +01:00
4da37d1704 feat(events): a better prompt with good integration with location agent 2025-04-19 10:30:29 +01:00
016834ee7d feat(contacts): not creating duplicates 2025-04-19 10:07:51 +01:00
bae6a7eda9 feat(location): prompt tweak + going back to faster model 2025-04-18 15:36:51 +01:00
e706b6a976 feat(location): correctly updating an image if it contains a duplicate locatino 2025-04-18 15:32:07 +01:00
7e7f01447e feat(agents): improving rationality by adding tool to allow the models to think through choices.
This works pretty nicely actually. I'm starting to understand how to
demistify the system prompt and have the tools the agent needs to do a
good job.
2025-04-18 15:06:20 +01:00
dc202189ca wip 2025-04-18 14:21:23 +01:00
1cff278263 feat(location-agent): using createLocation instead of updateLocation to simplify 2025-04-18 13:26:42 +01:00
1e40390952 feat(contact-agent): using createContact with an ID field to provide updates 2025-04-17 18:57:13 +01:00
150a43a5dc feat(event-agent): update events function 2025-04-17 18:19:54 +01:00
2b7206c29e feat(location-agent): seperating the tool to allow for replying
This means it makes less mistakes and doesnt get as confused.
2025-04-17 18:09:00 +01:00
7002b05aae fix(location-events): adding location id to the database from agent call 2025-04-17 15:32:50 +01:00
8e73ad6f4e feat(prompts): adding better prompts & restoring tool_stop
Mistral's models seem to do something really strange if you allow for
`tool_choice` to be anything but `any`. They start putting the tool call
inside the `content` instead of an actual tool call. This means that I
need this `stop` mechanism using a tool call instead because I cannot
trust the model to do it by itself.

I quite like this model though, it's cheap, it's fast and it's open
source. And all the answers are pretty good!
2025-04-17 15:24:21 +01:00
4b0ef8b17f feat(orchestrator): removing the end tool call
fix
2025-04-17 13:00:39 +01:00
57c760e7f0 chore: removing unnecessary logging 2025-04-17 13:00:24 +01:00
f5fdaff7c1 wip(orchestrator): improving orchestrator system prompt and tool description 2025-04-17 12:52:54 +01:00
8fff043849 feat(event-location): communicating using tool calls correctly 2025-04-17 11:15:02 +01:00
d1fd2aeaf1 fix(logger): nil pointer error + log debug level clean 2025-04-17 11:07:37 +01:00
1e5028177f refactor(agents): not returning an error on factory method 2025-04-17 11:02:11 +01:00
c4569e925b refactor(agents): encapsulating prompt and calls inside factory method 2025-04-17 10:58:19 +01:00
8fed2f9b9a fix: using correct eventAgent instead of orchestrator bug + better logging 2025-04-17 10:48:30 +01:00
1651926c4d refactor(agents): no need to wrap them in another struct 2025-04-17 10:36:11 +01:00
fa127c2331 feat: event agent calling location agent about location ID
This is pretty nice. We can now have agents spawn other agents and
actually get super cool functionality from it.

The pattern might be a little fragile.
2025-04-16 14:43:07 +01:00
7be669e49e wip(agents): allowing event agent to call location agent 2025-04-15 16:44:00 +01:00
7b6bdf2c7b feat: Adding text message to describe an action3 2025-04-15 16:43:27 +01:00
7b40959125 fix 2025-04-14 20:08:07 +01:00
63201280bb rollback: not using link functions as they are very problematic 2025-04-14 10:59:08 +01:00
885f877ef0 fix(network): restore conditional base URL for development environment
- Reintroduced conditional logic for the base URL to switch between local and production endpoints based on the environment.
2025-04-14 11:41:29 +02:00
f38d44f5f5 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 11:41:11 +02:00
64f8aa032e ffix 2025-04-14 10:40:02 +01:00
4ba41258e0 prompt 2025-04-14 10:38:25 +01:00
49f5cd0afb Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 11:37:34 +02:00
3ccb7ad7a1 refactor(network): simplify base URL and clean up validators
- Updated the base URL to a fixed production endpoint, removing the conditional logic for development.
- Commented out Location and Organizer validations in the eventValidator for future consideration.
- Added a console log in getUserImages to assist with backend response tracking.
2025-04-14 11:37:29 +02:00
2e092e5fe4 more prompt 2025-04-14 10:36:21 +01:00
60273c5782 fix more prompt 2025-04-14 10:33:56 +01:00
98799b01e6 stupid 2025-04-14 10:30:46 +01:00
1d0eb8ddaa horrible 2025-04-14 10:30:21 +01:00
8e7ee204ce fix: prompts 2025-04-14 10:28:31 +01:00
0ff541b7b6 debug 2025-04-14 10:22:54 +01:00
732d0cedd0 fix(network): update base URL for development and production environments
- Changed the base URL to use localhost for development and the production URL for other environments.
- Added Location validation back into the eventValidator and removed commented-out code for clarity.
- Cleaned up debugging logs in getUserImages function.
2025-04-14 10:56:32 +02:00
73dad6fd2d push 2025-04-14 09:55:50 +01:00
cb3b930c32 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 10:54:24 +02:00
cbf013aece feat(search): improve Search component with conditional rendering and debugging logs
- Added conditional rendering for the "No results found" message using the Show component.
- Introduced debugging logs in getUserImages and Search component to track data flow.
- Cleaned up the data mapping process in getUserImages for better readability.
2025-04-14 10:54:18 +02:00
edcba60c5a pushhh 2025-04-14 09:54:09 +01:00
ba940ae6fd debnug 2025-04-14 09:50:03 +01:00
efedb4e63c debug 2025-04-14 09:44:29 +01:00
9ac569359c debug 2025-04-14 09:44:19 +01:00
b13d3c1881 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 10:36:58 +02:00
e1857bd532 feat(login): implement logout functionality and redirect after login
- Added a logout function in the Settings component to clear user session data and redirect to the login page.
- Updated the Login component to redirect to the home page upon successful login.
- Adjusted styling in the Search component for better spacing in the "No results found" message.
2025-04-14 10:36:55 +02:00
b1b46ff7e5 fix 2025-04-14 09:31:27 +01:00
dbb98d1e48 feat: making all codes upper case + fetching fixes 2025-04-14 09:28:08 +01:00
d3fb92546f fix 2025-04-14 09:15:48 +01:00
313b764ec4 chore: removing SQL debug 2025-04-14 09:12:16 +01:00
43e63f739a feat(search): enhance Search component with shortcuts and item modal
- Added functionality to fetch and display global shortcuts in the Search component.
- Introduced ItemModal for displaying detailed information about selected items.
- Updated SearchCard components to improve layout and information presentation.
- Enhanced user experience with better styling and accessibility features.
2025-04-14 10:03:37 +02:00
ce9e27ec68 feat(image-viewer): integrate ImageViewer component and update FolderPicker layout
- Added ImageViewer component to the App for displaying processed images.
- Updated FolderPicker layout for improved user guidance and aesthetics.
- Refactored ShortcutItem and Shortcuts components for better structure and clarity.
- Introduced ItemModal component for future use.
2025-04-14 09:25:53 +02:00
76618d1124 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 08:55:42 +02:00
22a6ad9818 refactor(settings): reorganize FolderPicker component and update layout
- Moved FolderPicker to a new folder structure for better organization.
- Updated the Settings page layout to enhance visual hierarchy by increasing the title size.
- Removed the old FolderPicker component file after restructuring.
2025-04-14 08:55:36 +02:00
8af4f62492 feat(app): restructure routing and implement Search component
- Refactored the App component to streamline routing using the Router and Route components.
- Introduced a new Search component to handle search functionality, including input handling and result display.
- Removed inline search logic from the App component for better separation of concerns.
- Updated index.tsx to render the App component directly, simplifying the routing structure.
2025-04-14 08:47:57 +02:00
6e96eb53b4 feat: registering users if their email is not known 2025-04-13 22:29:25 +01:00
ca2e98e4b4 feat(app): refactor App component and add Settings page
- Refactored the App component to utilize a new SearchCard component for rendering search results.
- Introduced a Settings page with FolderPicker and Shortcuts components for user configuration.
- Removed the ImagePage component as it was no longer needed.
- Updated routing to include the new Settings page and adjusted imports accordingly.
- Added a settings button to the main interface for easy access to the new settings functionality.
2025-04-13 22:48:26 +02:00
43404aaf18 feat(search): add autofocus to search input and emit focus event on shortcut
- Added autofocus attribute to the search input field for improved user experience.
- Updated global shortcut handling to emit a "focus-search" event when the shortcut is triggered, enhancing the application's responsiveness to user actions.
- Updated dependencies in Cargo.toml to specific beta versions for better compatibility.
2025-04-13 21:57:36 +02:00
4196952178 feat(capabilities): add localhost URL to default permissions
- Updated the default capabilities configuration to allow access to http://localhost:3040 in addition to the existing https://haystack.johncosta.tech URL.
2025-04-13 21:28:14 +02:00
767ca20b4c chore: update dependencies and add new packages
- Updated several dependencies in Cargo.lock to their latest versions, including `anyhow`, `ashpd`, `bitflags`, `chrono`, and `http`.
- Added new dependencies such as `tauri-plugin-http`, `cookie_store`, and `h2`.
- Removed outdated dependencies related to `wayland` and updated the `windows` related packages for better compatibility.
2025-04-13 21:24:20 +02:00
bda733f8a5 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-13 21:21:43 +02:00
7d68f39bab refactor: using tauri http client 2025-04-13 19:34:02 +01:00
d687e86f86 fix 2025-04-13 19:18:07 +01:00
fca4a6445c chore: removing old agent that was messy and too coupled
chore
2025-04-13 16:30:20 +01:00
17cc12f0c9 feat(event): seperate event agent 2025-04-13 16:30:20 +01:00
b57968b938 feat(location): agent to create locations 2025-04-13 16:30:20 +01:00
f0477ed720 fix(email) 2025-04-13 16:28:40 +01:00
f09cc137a3 feat: add global shortcut functionality and update dependencies
- Introduced global shortcut management in the Tauri application, allowing users to set, change, and unregister shortcuts.
- Added new dependencies for global shortcut functionality in Cargo.toml and updated package.json.
- Enhanced the default capabilities to include global shortcut permissions.
- Refactored the main application logic to integrate the new shortcut features.
2025-04-13 16:40:04 +02:00
4e78d2e701 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-13 16:20:32 +02:00
0b97b2eed2 feat: update app description and enhance folder watching functionality
- Updated the app description in package.json and Cargo.toml to "Screenshots that organize themselves".
- Refactored the Tauri backend to introduce a new command for handling folder selection and watching for PNG file changes.
- Added utility functions for processing PNG files and managing the watcher state.
- Improved the frontend by integrating an ImageViewer component and setting up event listeners for search input focus.
2025-04-13 16:19:51 +02:00
0fcdd73a47 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
35072684de Revert "FIXUP wip: notifications on starting progress"
This reverts commit 391d0fdde2b69d067e30c2d3011046aab8b929c9.
2025-04-12 15:57:36 +01:00
391d0fdde2 FIXUP wip: notifications on starting progress 2025-04-12 15:55:58 +01:00
ab86bff35f chore: removing unused files 2025-04-12 14:44:16 +01:00
1560d1504e fix: tests 2025-04-12 14:43:01 +01:00
ae9ac1fe4f 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
ca8583575e fix(event) 2025-04-12 14:15:07 +01:00
02490c6c84 fix(orchestrator): better describing the note taking agent 2025-04-12 07:53:43 +01:00
36e776789e fix(notes): improving note taking capabilities 2025-04-12 07:48:42 +01:00
df028aaedb fix(log): removing access token logging 2025-04-12 07:46:07 +01:00
70d4411270 feat(contact-agent): linking to existing instead of creating new ones 2025-04-12 07:29:29 +01:00
6b181aac9f fix: removing extra log line 2025-04-12 07:22:35 +01:00
324aac438b feat(log): pretty logging agent responses and tool calls 2025-04-12 07:16:30 +01:00
b0f4a45c40 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
c02ccfd274 feat: contacts working 2025-04-11 20:31:51 +01:00
7264c6ed32 feat(authorization): e2e working authorization 2025-04-11 19:58:25 +01:00
283265c8c5 feat: checking user authorization on image retrieval 2025-04-11 19:41:36 +01:00
5b03d38392 wip(token): verifying user when getting the image 2025-04-11 19:35:49 +01:00
207f263853 fix: not using cookies anymore
I think Tauri doesn't like it very much
2025-04-11 13:27:19 +01:00
5b7fdd9f3e feat(cookies): using HTTP setCookie instead of manually doing it 2025-04-11 11:34:32 +01:00
4dbe1508c2 feat: minimal UI for login 2025-04-11 11:08:33 +01:00
36d60d7985 feat(jwt): validating token 2025-04-10 15:49:53 +01:00
a86addc8b2 feat(jwt): adding access and refresh token generation 2025-04-10 15:35:35 +01:00
06d2f1db6e feat(email): endpoint for sending auth code 2025-04-09 18:24:26 +01:00
b209de5c5d wip(email-auth): auth and email modules 2025-04-09 18:13:49 +01:00
680003b626 refactor: moving image listener to own function 2025-04-09 17:20:27 +01:00
1a9b707533 feat(orchestrator): async processing and ending the loop3 2025-04-09 15:23:51 +01:00
c35951063a fix(tool-calls): ToolLoop 2025-04-09 15:15:31 +01:00
f294f9cdc0 fix(tools): testing and processing
fix
2025-04-09 13:56:30 +01:00
88fda32125 fix(types): agent processing stuff 2025-04-09 12:12:09 +01:00
5502fc6b19 feat(chat): more simplified chat messages and tool handling 2025-04-09 12:04:44 +01:00
28ee32e2ff fixup(chat): better way to organize agent messages and tool calls 2025-04-06 20:24:40 +01:00
f5f4008034 fix(tools): dont error if AI invested a tool 2025-04-05 15:04:09 +01:00
d474b1700a refactor(tools): removing pointer map
This is not needed
2025-04-05 14:59:50 +01:00
d78f34a7aa feat(tools): return error to agent if any happened 2025-04-05 14:58:38 +01:00
1cafc31e0a test(tools): more robust multiple tool call handling 2025-04-05 14:52:31 +01:00
a1ce96d2e3 test(tools): starting test suite for tools 2025-04-05 14:35:54 +01:00
03e7803467 feat(orchestrator): calling needed agents when it needs to 2025-04-05 11:01:43 +01:00
286a9a8472 fix(tool): raw text not scaling so well ey? 2025-04-04 22:50:19 +01:00
aa153de185 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
cd27f1105a refactor(tool-calls): to be handled more generally 2025-04-04 22:17:58 +01:00
71d4581110 refactor(ai-client): moving tool handling and client into seperate folders 2025-04-04 22:03:46 +01:00
8a165c2042 wip(orchestrator): basic scaffolding for the agent 2025-04-04 20:40:31 +01:00
fe7c92b622 feat: sample data 2025-04-02 17:32:52 +00:00
745265773b feat(notes): allowing frontend to save 2025-04-01 20:54:15 +00:00
f72ee73020 feat(notes): saving the notes for any images for easy text searching 2025-04-01 20:45:43 +00:00
1cb6510465 feat(events): search through organizer 2025-04-01 19:59:17 +00:00
af485aec49 feat: adding rawData search 2025-04-01 19:48:06 +00:00
4320bd7fe9 fix(validators): allowing location in event fields 2025-04-01 19:33:31 +00:00
9652549b01 fix: adding location to general query 2025-04-01 19:32:55 +00:00
b56250c1f8 Merge branch 'integrating-frontend' 2025-04-01 19:30:51 +00:00
35f004752d fix 2025-03-31 20:17:14 +00:00
901f214f9d feat(contacts): events can now have organizers 2025-03-31 18:40:36 +00:00
0c78d741f0 feat(schema): basic contact tables 2025-03-31 18:10:04 +00:00
7c5d2f9433 chore(imports): organisation 2025-03-31 18:03:02 +00:00
e29f93bcd5 feat(cards): adjusting for backend data types 2025-03-31 17:49:17 +00:00
13e82334ca feat(events): adding start and end times 2025-03-31 17:32:55 +00:00
59737ca9ac feat(schema): removing coordinates and adding start times to events
.
2025-03-31 16:44:42 +00:00
b6969127eb fix(backend): SQL statements without returns 2025-03-26 16:51:46 +00:00
2645cbb1c2 refactor(validators): frontend to new schema 2025-03-26 16:51:35 +00:00
d716e66463 feat: attaching both to image 2025-03-26 16:22:23 +00:00
a576355e7c feat: creating events and attaching locations 2025-03-26 16:16:48 +00:00
2dcb59c19d 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
2c279bb68f refactor(frontend): clean up App component and improve search functionality 2025-03-23 19:10:18 +01:00
6f938a34e3 feat(tool-calling) Big refactor on how tool calling is handled
these commits are too big
2025-03-22 20:46:26 +00:00
7debe6bab2 feat(locations): allowing AI to attach it to the image 2025-03-22 17:47:02 +00:00
4c4bf7a9e4 feat(location): working e2e with tool calling 2025-03-22 12:22:31 +00:00
aad45fcf52 feat(tool-calls): listLocation tool call handling 2025-03-22 11:14:00 +00:00
7c473e054a feat: using tools for event loocation agent 2025-03-22 10:12:51 +00:00
7f96d2fc45 refactor(naming): using Agent instead of openai 2025-03-21 17:07:00 +00:00
df90eaa6ad fix: docker image 2025-03-21 14:49:51 +00:00
44d506bc69 wip(frontend): adding more information 2025-03-21 14:36:03 +00:00
45f3e11214 fix: app to re-include images 2025-03-21 14:23:38 +00:00
58f7afb521 fix: re-adding location to event
Figured it out
2025-03-21 14:08:06 +00:00
7b64563647 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
5b794b2e7f feat: frontend validation 2025-03-21 13:44:42 +00:00
72a3e58ef9 feat: returning events and locations from end point 2025-03-20 18:34:11 +00:00
f042c9dfcc feat: working e2e solution 2025-03-20 17:59:00 +00:00
d2dd43c6b2 merging 2025-03-19 09:46:52 +00:00
56d423d261 feat: adding events and locations to json schema 2025-03-19 09:46:42 +00:00
072eebc0bf feat(events): also working 2025-03-18 18:37:12 +00:00
28dd02a47d feat(locations): now working 2025-03-18 18:18:01 +00:00
2e1809aa27 refactor(text,tags,links): to foreign key to image instead of user_image 2025-03-18 17:48:38 +00:00
e76a4b901c wip: add sample data and types 2025-03-17 22:20:26 +01:00
b359e9d61d fix: linter and format issues
format
2025-03-17 21:16:40 +01:00
9b5113f428 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-03-17 21:01:15 +01:00
b29a013cde wip 2025-03-17 21:00:52 +01:00
32b5b08dcf fix: frontend parsers 2025-03-16 18:41:26 +00:00
439f729150 fix: returning whole tag object 2025-03-16 18:29:15 +00:00
bfd6d136dc fix: returns correct image ID 2025-03-16 18:20:29 +00:00
1d5d90c3b5 refactor(models): using more organised structure 2025-03-16 18:13:30 +00:00
b8ee0c2381 Merge branch 'main' of https://github.com/dimuuu/haystack-app 2025-03-13 17:25:16 +01:00
d9f972f674 wip 2025-03-13 17:24:53 +01:00
d4c6aa0310 feat(tags): correctly inserting new tags and adding them to images 2025-03-11 22:47:28 +00:00
6fcb1e9f26 feat(tags): creating and getting user tags 2025-03-11 21:23:41 +00:00
c215bf6909 feat: new schema to support user tags better 2025-03-11 20:29:56 +00:00
234988399d fix: actually searching properly 2025-03-08 15:50:21 +00:00
aee49c313b feat: better result display 2025-03-08 15:42:16 +00:00
9e3896a30f feat: super basic image search 2025-03-08 15:37:10 +00:00
e5ac7061f4 feat: sending images and receiving them is now working 2025-03-08 13:13:05 +00:00
03a4d49ee6 feat: sending base64 image to backend
This is silly, but binary is apparently hard to do????
2025-03-08 12:30:16 +00:00
c025eebea8 wip: dialog to choose folder to watch 2025-03-08 11:58:25 +00:00
53c4ec1869 fix: using json response header 2025-03-07 14:14:40 +00:00
5192aeb70f wip: Using mistral instead of OpenAi 2025-03-07 13:42:50 +00:00
3bd3b420c9 chore: running format 2025-02-26 21:27:43 +00:00
06df315ed0 feat: network file with validators for backend requests 2025-02-26 21:27:37 +00:00
a3dee09011 feat: making prompt be more generic with tags 2025-02-26 20:53:12 +00:00
a4136fe3f8 feat: using different prompt 2025-02-26 20:49:55 +00:00
0ec2ae65d2 fix: some spam from get images request 2025-02-26 20:48:52 +00:00
79ee5e23ca fix: getting user images 2025-02-26 20:09:19 +00:00
1f16cfb30b 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
2a37e37c4b refactor: using chi router + bug fixes 2025-02-26 18:04:30 +00:00
b29f5b1bb5 fix: actually saving to the correct db table 2025-02-26 15:54:27 +00:00
111c6ac74f fix: actually returning all the user images
fix
2025-02-24 21:19:59 +00:00
24fc2d7c84 chore: documentation 2025-02-24 21:04:25 +00:00
c36fb1c0d6 feat: saving AI information to database 2025-02-24 21:00:05 +00:00
e26835861d feat: method for getting images 2025-02-24 20:05:56 +00:00
e69d7b5c08 feat: methods to get image 2025-02-24 20:02:58 +00:00
c0fe7e1853 feat: instructions for docker compose 2025-02-24 20:02:54 +00:00
c9cd0df9ca feat: working docker image and compose file 2025-02-24 19:44:19 +00:00
18ecfecd54 feat: using parsing on response 2025-02-24 19:05:11 +00:00
51d14b0eba chore: updating gitignore
fix
2025-02-24 19:01:18 +00:00
4a32e99a5c feat: parsing response from open ai
bruh
2025-02-24 19:01:18 +00:00
098fd05dfd build very basic ui 2025-02-23 22:16:41 +01:00
fe786690ad some updates 2025-02-23 20:11:58 +01:00
ac8d3387c5 messing around with ui 2025-02-23 20:02:06 +01:00
ec13e70024 added a bunch of frontend things 2025-02-23 19:30:11 +01:00
40debf1da3 Merge branch 'main' of https://github.com/dimuuu/haystack-app 2025-02-23 14:59:04 +01:00
b773abd51a some window ui tweaks 2025-02-23 14:59:01 +01:00
050126116c refactor: moving all files to backend 2025-02-22 23:30:59 +00:00
49680c00b2 Merge branch 'main' of github.com:dimuuu/haystack-app 2025-02-22 23:29:49 +00:00
cf452653a7 add biome 2025-02-22 16:41:52 +01:00
531b126cf5 v0 2025-02-22 16:30:41 +01:00
108 changed files with 4821 additions and 1718 deletions

View File

@ -0,0 +1,17 @@
//
// 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

@ -0,0 +1,78 @@
//
// 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,6 +21,7 @@ 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 []ImageMessageContent `json:"content"`
Content []MessageContentMessage `json:"content"`
}{
Role: User,
Content: t.Content,
@ -121,18 +121,35 @@ func (m SingleMessage) IsSingleMessage() bool {
}
type ArrayMessage struct {
Content []ImageMessageContent `json:"content"`
Content []MessageContentMessage `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"`
}
@ -165,7 +182,7 @@ func (chat *Chat) AddSystem(prompt string) {
})
}
func (chat *Chat) AddImage(imageName string, image []byte) error {
func (chat *Chat) AddImage(imageName string, image []byte, query *string) error {
extension := filepath.Ext(imageName)
if len(extension) == 0 {
// TODO: could also validate for image types we support.
@ -173,14 +190,28 @@ func (chat *Chat) AddImage(imageName string, image []byte) error {
}
extension = extension[1:]
encodedString := base64.StdEncoding.EncodeToString(image)
messageContent := ArrayMessage{
Content: make([]ImageMessageContent, 1),
contentLength := 1
if query != nil {
contentLength += 1
}
messageContent.Content[0] = ImageMessageContent{
messageContent := ArrayMessage{
Content: make([]MessageContentMessage, contentLength),
}
index := 0
if query != nil {
messageContent.Content[index] = TextMessageContent{
TextType: "text",
Text: *query,
}
index += 1
}
messageContent.Content[index] = ImageMessageContent{
ImageType: "image_url",
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
}

View File

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

View File

@ -10,6 +10,10 @@ 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,36 +3,58 @@ 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 = `
You are an agent that performs actions on contacts and people you find on an image.
**Role:** AI Contact Processor from Images.
You can use tools to achieve your task.
**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 should use listContacts to make sure that you don't create duplicate contacts.
**Input:** Image potentially containing contact info (Name, Phone, Email, Address).
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.
**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 finish if you dont think theres anything else to do.
**Tools:**
* listContacts: Check existing contacts (Use first if contacts found).
* createContact: Add a NEW contact (Name required).
* stopAgent: Signal task completion.
`
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": "List the users existing contacts",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -44,23 +66,29 @@ const contactTools = `
"type": "function",
"function": {
"name": "createContact",
"description": "Creates a new contact",
"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.",
"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 name of the person"
"description": "The full name of the person being added as a contact. This field is mandatory."
},
"phoneNumber": {
"type": "string"
"type": "string",
"description": "The contact's primary phone number, including area or country code if available. Provide this if extracted from the image."
},
"address": {
"type": "string",
"description": "their physical address"
"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."
},
"email": {
"type": "string"
"type": "string",
"description": "The contact's primary email address. Provide this if extracted from the image."
}
},
"required": ["name"]
@ -70,25 +98,8 @@ const contactTools = `
{
"type": "function",
"function": {
"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",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -99,40 +110,29 @@ 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(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
}
func NewContactAgent(log *log.Logger, contactModel models.ContactModel) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: contactPrompt,
JsonTools: contactTools,
Log: log,
EndToolCall: "stopAgent",
})
agent := ContactAgent{
client: agentClient,
contactModel: contactModel,
}
agentClient.ToolHandler.AddTool("think", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "Thought", nil
})
agentClient.ToolHandler.AddTool("listContacts", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return agent.contactModel.List(context.Background(), info.UserId)
return contactModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -144,7 +144,18 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
ctx := context.Background()
contact, err := agent.contactModel.Save(ctx, info.UserId, model.Contacts{
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,
Name: args.Name,
PhoneNumber: args.PhoneNumber,
Email: args.Email,
@ -154,7 +165,7 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
return model.Contacts{}, err
}
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
_, err = contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
if err != nil {
return model.Contacts{}, err
}
@ -162,27 +173,5 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
return contact, nil
})
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
return agentClient
}

View File

@ -3,7 +3,6 @@ package agents
import (
"context"
"encoding/json"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
@ -14,33 +13,49 @@ import (
)
const eventPrompt = `
You are an agent.
**You are an AI processing events from images using internal thought.**
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.
**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.
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.
**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.
`
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": "List the events the user already has.",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -52,20 +67,25 @@ const eventTools = `
"type": "function",
"function": {
"name": "createEvent",
"description": "Use to create a new events",
"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.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string"
"type": "string",
"description": "The name or title of the event. This field is mandatory."
},
"startDateTime": {
"type": "string",
"description": "The start time as an ISO string"
"description": "The event's start date and time in ISO 8601 format (e.g., '2025-04-18T10:00:00Z'). Include if available."
},
"endDateTime": {
"type": "string",
"description": "The end time as an ISO 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."
}
},
"required": ["name"]
@ -75,24 +95,43 @@ const eventTools = `
{
"type": "function",
"function": {
"name": "linkEvent",
"description": "Use to link an already existing events to the image you were sent",
"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.",
"parameters": {
"type": "object",
"properties": {
"eventId": {
"type": "string"
"type": "string",
"description": "The UUID of the existing event"
}
},
"required": ["eventsId"]
"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"]
}
}
},
{
"type": "function",
"function": {
"name": "finish",
"description": "Call this when there is nothing left to do.",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -102,41 +141,36 @@ 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 linkEventArguments struct {
type updateEventArguments struct {
EventID string `json:"eventId"`
}
func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Events 📍",
}))
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",
})
if err != nil {
return EventAgent{}, err
}
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
agent := EventAgent{
client: agentClient,
eventsModel: eventsModel,
}
agentClient.ToolHandler.AddTool("think", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "Thought", nil
})
agentClient.ToolHandler.AddTool("listEvents", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return agent.eventsModel.List(context.Background(), info.UserId)
return eventsModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -150,6 +184,8 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
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
@ -160,17 +196,23 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
return model.Events{}, err
}
events, err := agent.eventsModel.Save(ctx, info.UserId, model.Events{
locationId, err := uuid.Parse(*args.LocationID)
if err != nil {
return model.Events{}, err
}
events, err := eventsModel.Save(ctx, info.UserId, model.Events{
Name: args.Name,
StartDateTime: &startTime,
EndDateTime: &endTime,
LocationID: &locationId,
})
if err != nil {
return model.Events{}, err
}
_, err = agent.eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
_, err = eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
if err != nil {
return model.Events{}, err
}
@ -178,8 +220,8 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
return events, nil
})
agentClient.ToolHandler.AddTool("linkEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := linkEventArguments{}
agentClient.ToolHandler.AddTool("updateEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := updateEventArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
@ -192,9 +234,17 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
return "", err
}
agent.eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
return "Saved", nil
})
return agent, 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
}

View File

@ -3,43 +3,92 @@ package agents
import (
"context"
"encoding/json"
"os"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
const locationPrompt = `
You are an agent.
Role: Location AI Assistant
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.
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.
There are various tools you can use to perform this task.
Core Logic:
listLocations
Lists the users already existing locations, you should do this before using createLocation to avoid creating duplicates.
**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.
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.
**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.
linkLocation
Links an image to a location.
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.
finish
Call when there is nothing else to do.
**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.
`
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": "List the locations the user already has.",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -51,15 +100,17 @@ const locationTools = `
"type": "function",
"function": {
"name": "createLocation",
"description": "Use to create a new location",
"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.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string"
"type": "string",
"description": "The primary name of the location"
},
"address": {
"type": "string"
"type": "string",
"description": "The address of the location"
}
},
"required": ["name"]
@ -69,24 +120,26 @@ const locationTools = `
{
"type": "function",
"function": {
"name": "linkLocation",
"description": "Use to link an already existing location to the image you were sent",
"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",
"parameters": {
"type": "object",
"properties": {
"locationId": {
"type": "string"
"type": "string",
"description": "The UUID of the location, from listLocations"
}
},
"required": ["locationId"]
}
}
},
%s
{
"type": "function",
"function": {
"name": "finish",
"description": "Call this when there is nothing left to do.",
"name": "stopAgent",
"description": "Use this tool to signal that the contact processing for the current image is complete.",
"parameters": {
"type": "object",
"properties": {},
@ -96,10 +149,12 @@ const locationTools = `
}
]`
type LocationAgent struct {
client client.AgentClient
locationModel models.LocationModel
func getLocationAgentTools(allowReply bool) string {
if allowReply {
return fmt.Sprintf(locationTools, replyTool)
} else {
return fmt.Sprintf(locationTools, "")
}
}
type listLocationArguments struct{}
@ -107,28 +162,28 @@ type createLocationArguments struct {
Name string `json:"name"`
Address *string `json:"address"`
}
type linkLocationArguments struct {
type createExistingLocationArguments struct {
LocationID string `json:"locationId"`
}
func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error) {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Locations 📍",
}))
func NewLocationAgentWithComm(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
client := NewLocationAgent(log, locationModel)
if err != nil {
return LocationAgent{}, err
}
client.Options.JsonTools = getLocationAgentTools(true)
agent := LocationAgent{
client: agentClient,
locationModel: locationModel,
}
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",
})
agentClient.ToolHandler.AddTool("listLocations", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return agent.locationModel.List(context.Background(), info.UserId)
return locationModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -140,7 +195,9 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
ctx := context.Background()
location, err := agent.locationModel.Save(ctx, info.UserId, model.Locations{
// 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{
Name: args.Name,
Address: args.Address,
})
@ -149,7 +206,7 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
return model.Locations{}, err
}
_, err = agent.locationModel.SaveToImage(ctx, info.ImageId, location.ID)
_, err = locationModel.SaveToImage(ctx, info.ImageId, location.ID)
if err != nil {
return model.Locations{}, err
}
@ -157,8 +214,8 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
return location, nil
})
agentClient.ToolHandler.AddTool("linkLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := linkLocationArguments{}
agentClient.ToolHandler.AddTool("createExistingLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := createExistingLocationArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
@ -166,14 +223,26 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
ctx := context.Background()
contactUuid, err := uuid.Parse(args.LocationID)
locationId, err := uuid.Parse(args.LocationID)
if err != nil {
return "", err
}
agent.locationModel.SaveToImage(ctx, info.ImageId, contactUuid)
return "Saved", nil
_, err = locationModel.SaveToImage(ctx, info.ImageId, locationId)
if err != nil {
return "", err
}
return "", nil
})
return agent, 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
}

View File

@ -2,11 +2,9 @@ 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"
@ -43,7 +41,7 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
}
request.Chat.AddSystem(noteAgentPrompt)
request.Chat.AddImage(imageName, imageData)
request.Chat.AddImage(imageName, imageData, nil)
resp, err := agent.client.Request(&request)
if err != nil {
@ -70,20 +68,16 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
return nil
}
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
}
func NewNoteAgent(log *log.Logger, noteModel models.NoteModel) NoteAgent {
client := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: noteAgentPrompt,
Log: log,
})
agent := NoteAgent{
client: client,
noteModel: noteModel,
}
return agent, nil
return agent
}

View File

@ -1,52 +1,63 @@
package agents
import (
"errors"
"os"
"screenmark/screenmark/agents/client"
"time"
"github.com/charmbracelet/log"
)
const OrchestratorPrompt = `
You are an Orchestrator for various AI agents.
const orchestratorPrompt = `
**Role:** You are an Orchestrator AI responsible for analyzing images provided by the user.
The user will send you images and you have to determine which agents you have to call, in order to best help the user.
**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.
You might decide no agent needs to be called.
**Analysis Process & Decision Logic:**
The agents are available as tool calls.
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.
Agents available:
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.
noteAgent
Use when there is ANY text on the image.
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.
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.
* Call all applicable specialized agents (noteAgent, contactAgent, locationAgent, eventAgent) simultaneously (in parallel).
`
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": "Use when there is any text on the image, this can be code/text/formulas any writing",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -58,7 +69,7 @@ const OrchestratorTools = `
"type": "function",
"function": {
"name": "contactAgent",
"description": "Use when then image contains some person or contact",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -70,7 +81,7 @@ const OrchestratorTools = `
"type": "function",
"function": {
"name": "locationAgent",
"description": "Use when then image contains some place, location or address",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -82,7 +93,7 @@ const OrchestratorTools = `
"type": "function",
"function": {
"name": "eventAgent",
"description": "Use when then image contains some event",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -93,8 +104,8 @@ const OrchestratorTools = `
{
"type": "function",
"function": {
"name": "noAction",
"description": "Use when you are sure nothing can be done about this image anymore",
"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.",
"parameters": {
"type": "object",
"properties": {},
@ -102,7 +113,8 @@ const OrchestratorTools = `
}
}
}
]`
]
`
type OrchestratorAgent struct {
Client client.AgentClient
@ -114,58 +126,45 @@ type Status struct {
Ok bool `json:"ok"`
}
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 🎼",
}))
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",
})
if err != nil {
return OrchestratorAgent{}, err
}
agent.ToolHandler.AddTool("think", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "Thought", nil
})
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 Status{
Ok: true,
}, nil
return "noteAgent called successfully", nil
})
agent.ToolHandler.AddTool("contactAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go contactAgent.client.RunAgent(contactPrompt, contactTools, "finish", info.UserId, info.ImageId, imageName, imageData)
go contactAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return Status{
Ok: true,
}, nil
return "contactAgent called successfully", nil
})
agent.ToolHandler.AddTool("locationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go locationAgent.client.RunAgent(locationPrompt, locationTools, "finish", info.UserId, info.ImageId, imageName, imageData)
go locationAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return Status{
Ok: true,
}, nil
return "locationAgent called successfully", nil
})
agent.ToolHandler.AddTool("eventAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go eventAgent.client.RunAgent(eventPrompt, eventTools, "finish", info.UserId, info.ImageId, imageName, imageData)
go eventAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return Status{
Ok: true,
}, nil
return "eventAgent called successfully", 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...")
agent.ToolHandler.AddTool("noAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "ok", nil
})
return OrchestratorAgent{
Client: agent,
}, nil
return agent
}

View File

@ -2,7 +2,6 @@ package main
import (
"errors"
"fmt"
"math/rand"
"time"
)
@ -18,7 +17,7 @@ type Auth struct {
mailer Mailer
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")
var letterRunes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randString(n int) string {
b := make([]rune, n)
@ -44,7 +43,6 @@ 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
@ -55,7 +53,6 @@ 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,13 +3,12 @@ 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"
)
@ -28,6 +27,9 @@ 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)
@ -39,55 +41,38 @@ 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 {
log.Println("Failed to GetToProcessWithData")
log.Println(err)
databaseEventLog.Error("Failed to GetToProcessWithData", "error", 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 {
log.Println("Failed to FinishProcessing")
log.Println(err)
databaseEventLog.Error("Failed to FinishProcessing", "error", err)
return
}
orchestrator, err := agents.NewOrchestratorAgent(noteAgent, contactAgent, locationAgent, eventAgent, image.Image.ImageName, image.Image.Image)
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)
if err != nil {
panic(err)
databaseEventLog.Error("Failed to finish processing", "ImageID", imageId)
return
}
// 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)
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
}()
}
}
@ -122,9 +107,6 @@ 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,6 +20,7 @@ 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,6 +6,7 @@ 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=
@ -33,6 +34,10 @@ 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=
@ -109,5 +114,6 @@ 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=

149
backend/logs.go Normal file
View File

@ -0,0 +1,149 @@
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,19 +91,36 @@ 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 {
@ -111,13 +128,20 @@ 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
}
}
@ -333,6 +357,12 @@ 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)
@ -363,6 +393,12 @@ 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,9 +29,56 @@ 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.Name, Events.Description, Events.StartDateTime, Events.EndDateTime).
VALUES(event.Name, event.Description, event.StartDateTime, event.EndDateTime).
INSERT(Events.MutableColumns).
MODEL(event).
RETURNING(Events.AllColumns)
insertedEvent := model.Events{}

View File

@ -30,7 +30,50 @@ 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,16 +30,8 @@ type ImageWithProperties struct {
Text []model.ImageText
Locations []model.Locations
Events []struct {
model.Events
Location *model.Locations
Organizer *model.Contacts
}
Events []model.Events
Notes []model.Notes
Contacts []model.Contacts
}
@ -95,11 +87,9 @@ func (m UserModel) ListWithProperties(ctx context.Context, userId uuid.UUID) ([]
LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))).
WHERE(UserImages.UserID.EQ(UUID(userId)))
fmt.Println(listWithPropertiesStmt.DebugSql())
images := []ImageWithProperties{}
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
if err != nil {
return images, err
}
@ -115,6 +105,24 @@ 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,6 +146,11 @@ 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 Normal file
View File

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

607
frontend/.idea/caches/deviceStreaming.xml generated Normal file
View File

@ -0,0 +1,607 @@
<?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>

9
frontend/.idea/frontend.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?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>

6
frontend/.idea/misc.xml generated Normal file
View File

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

8
frontend/.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?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>

6
frontend/.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?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": "",
"description": "Screenshots that organize themselves",
"type": "module",
"scripts": {
"start": "vite",
@ -20,13 +20,18 @@
"@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 = "A Tauri App"
authors = ["you"]
description = "Screenshots that organize themselves"
authors = ["Dmytro Kondakov", "John Costa"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -15,17 +15,27 @@ name = "haystack_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-build = { version = "2.0.0-beta.12", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api"] }
tauri-plugin-opener = "2"
tauri = { version = "2.0.0-beta.12", features = ["macos-private-api"] }
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

@ -1,12 +0,0 @@
{
"$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

@ -0,0 +1,16 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,12 @@
# 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

@ -0,0 +1,19 @@
*.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

@ -0,0 +1,6 @@
/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

@ -0,0 +1,83 @@
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

@ -0,0 +1,21 @@
# 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

@ -0,0 +1,41 @@
<?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

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

View File

@ -0,0 +1,30 @@
<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

@ -0,0 +1,170 @@
<?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

@ -0,0 +1,18 @@
<?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.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,6 @@
<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

@ -0,0 +1,10 @@
<?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

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

View File

@ -0,0 +1,6 @@
<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

@ -0,0 +1,5 @@
<?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>

Binary file not shown.

View File

@ -0,0 +1,22 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,52 @@
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

@ -0,0 +1,85 @@
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

@ -0,0 +1,25 @@
# 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

Binary file not shown.

View File

@ -0,0 +1,6 @@
#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

185
frontend/src-tauri/gen/android/gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/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

@ -0,0 +1,89 @@
@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

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

View File

@ -0,0 +1,71 @@
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,149 +1,44 @@
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};
mod commands;
mod state;
mod utils;
mod window;
struct WatcherState {
watcher: Option<RecommendedWatcher>,
}
use state::new_shared_watcher_state;
use window::setup_window;
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()));
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
pub fn desktop() {
let watcher_state = new_shared_watcher_state();
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![handle_selected_folder])
.invoke_handler(tauri::generate_handler![commands::handle_selected_folder,])
.setup(|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);
}
}
setup_window(app)?;
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,5 +2,9 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
haystack_lib::run()
#[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();
}

View File

@ -0,0 +1,175 @@
use tauri::App;
use tauri::AppHandle;
use tauri::Emitter;
use tauri::Manager;
use tauri::Runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::JsonValue;
use tauri_plugin_store::StoreExt;
/// Name of the Tauri storage
const HAYSTACK_TAURI_STORE: &str = "haystack_tauri_store";
/// Key for storing global shortcuts
const HAYSTACK_GLOBAL_SHORTCUT: &str = "haystack_global_shortcut";
/// Default shortcut for macOS
#[cfg(target_os = "macos")]
const DEFAULT_SHORTCUT: &str = "command+shift+k";
/// Default shortcut for Windows and Linux
#[cfg(any(target_os = "windows", target_os = "linux"))]
const DEFAULT_SHORTCUT: &str = "ctrl+shift+k";
/// Set shortcut during application startup
pub fn enable_shortcut(app: &App) {
let store = app
.store(HAYSTACK_TAURI_STORE)
.expect("Creating the store should not fail");
// Use stored shortcut if it exists
if let Some(stored_shortcut) = store.get(HAYSTACK_GLOBAL_SHORTCUT) {
let stored_shortcut_str = match stored_shortcut {
JsonValue::String(str) => str,
unexpected_type => panic!(
"Haystack shortcuts should be stored as strings, found type: {} ",
unexpected_type
),
};
let stored_shortcut = stored_shortcut_str
.parse::<Shortcut>()
.expect("Stored shortcut string should be valid");
_register_shortcut_upon_start(app, stored_shortcut); // Register stored shortcut
} else {
// Use default shortcut if none is stored
store.set(
HAYSTACK_GLOBAL_SHORTCUT,
JsonValue::String(DEFAULT_SHORTCUT.to_string()),
);
let default_shortcut = DEFAULT_SHORTCUT
.parse::<Shortcut>()
.expect("Default shortcut should be valid");
_register_shortcut_upon_start(app, default_shortcut); // Register default shortcut
}
}
/// Get the current stored shortcut as a string
#[tauri::command]
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
let shortcut = _get_shortcut(&app);
Ok(shortcut)
}
/// Unregister the current shortcut in Tauri
#[tauri::command]
pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
let shortcut_str = _get_shortcut(&app);
let shortcut = shortcut_str
.parse::<Shortcut>()
.expect("Stored shortcut string should be valid");
// Unregister the shortcut
app.global_shortcut()
.unregister(shortcut)
.expect("Failed to unregister shortcut")
}
/// Change the global shortcut
#[tauri::command]
pub fn change_shortcut<R: Runtime>(
app: AppHandle<R>,
_window: tauri::Window<R>,
key: String,
) -> Result<(), String> {
println!("Key: {}", key);
let shortcut = match key.parse::<Shortcut>() {
Ok(shortcut) => shortcut,
Err(_) => return Err(format!("Invalid shortcut {}", key)),
};
// Store the new shortcut
let store = app
.get_store(HAYSTACK_TAURI_STORE)
.expect("Store should already be loaded or created");
store.set(HAYSTACK_GLOBAL_SHORTCUT, JsonValue::String(key));
// Register the new shortcut
_register_shortcut(&app, shortcut);
Ok(())
}
/// Helper function to register a shortcut, primarily for updating shortcuts
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
let main_window = app.get_webview_window("main").unwrap();
// Register global shortcut and define its behavior
app.global_shortcut()
.on_shortcut(shortcut, move |app, scut, event| {
if scut == &shortcut {
if let ShortcutState::Pressed = event.state() {
// Toggle window visibility
if main_window.is_visible().unwrap() {
main_window.hide().unwrap(); // Hide window
} else {
main_window.show().unwrap(); // Show window
main_window.set_focus().unwrap(); // Focus window
// Emit focus-search event
app.emit("focus-search", ()).unwrap();
}
}
}
})
.map_err(|err| format!("Failed to register new shortcut '{}'", err))
.unwrap();
}
/// Helper function to register shortcuts during application startup
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
let window = app
.get_webview_window("main")
.expect("webview to be defined");
// Initialize global shortcut and set its handler
app.handle()
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, scut, event| {
if scut == &shortcut {
if let ShortcutState::Pressed = event.state() {
// Toggle window visibility
if window.is_visible().unwrap() {
window.hide().unwrap(); // Hide window
} else {
window.show().unwrap(); // Show window
window.set_focus().unwrap(); // Focus window
// Emit focus-search event
app.emit("focus-search", ()).unwrap();
}
}
}
})
.build(),
)
.unwrap();
app.global_shortcut().register(shortcut).unwrap(); // Register global shortcut
}
/// Retrieve the stored global shortcut as a string
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
let store = app
.get_store(HAYSTACK_TAURI_STORE)
.expect("Store should already be loaded or created");
match store
.get(HAYSTACK_GLOBAL_SHORTCUT)
.expect("Shortcut should already be stored")
{
JsonValue::String(str) => str,
unexpected_type => panic!(
"Haystack shortcuts should be stored as strings, found type: {} ",
unexpected_type
),
}
}

View File

@ -0,0 +1,27 @@
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

@ -0,0 +1,22 @@
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

@ -0,0 +1,39 @@
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,176 +1,85 @@
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";
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";
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;
}
};
export const App = () => {
const [logs, setLogs] = createSignal<string[]>([]);
const [file, setFile] = createSignal<File>();
// 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;
}
}
createEffect(() => {
// TODO: Don't use window.location.href
const unlisten = listen("focus-search", () => {
window.location.href = "/";
});
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,
onCleanup(() => {
unlisten.then((fn) => fn());
});
});
createEffect(() => {
fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "data.Name", weight: 2 },
{ name: "rawData", weight: 1 },
],
threshold: 0.4,
});
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();
};
});
const onInputChange = (event: InputEvent) => {
const query = (event.target as HTMLInputElement).value;
setSearchQuery(query);
setSearchResults(fuze.search(query).map((s) => s.item));
};
createEffect(() => {
const f = file();
if (f == null) {
return;
}
sendImageFile(f.name, f);
});
return (
<>
<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>
<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} />
<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>
<Route path="/" component={ProtectedRoute}>
<Route path="/" component={Search} />
<Route path="/settings" component={Settings} />
</Route>
</Router>
</>
);
}
export default App;
};

View File

@ -1,67 +0,0 @@
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 { createSignal, Show, type Component } from "solid-js";
import { postCode, postLogin } from "./network";
import { isTokenValid } from "./ProtectedRoute";
import { Navigate } from "@solidjs/router";
import { type Component, Show, createSignal } from "solid-js";
import { isTokenValid } from "./ProtectedRoute";
import { base, postCode, postLogin } from "./network";
export const Login: Component = () => {
let form: HTMLFormElement | undefined;
@ -34,12 +34,16 @@ 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">
@ -55,5 +59,6 @@ export const Login: Component = () => {
<Button type="submit">Submit</Button>
</form>
</Show>
</>
);
};

216
frontend/src/Search.tsx Normal file
View File

@ -0,0 +1,216 @@
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)}
/>
)}
</>
);
};

33
frontend/src/Settings.tsx Normal file
View File

@ -0,0 +1,33 @@
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

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

View File

@ -0,0 +1,52 @@
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

@ -0,0 +1,25 @@
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

@ -0,0 +1,22 @@
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,17 +12,15 @@ export const SearchCardContact = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-orange-50">
<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 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>
<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 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">Phone: {data.PhoneNumber}</p>
<p class="text-xs text-neutral-700">Mail: {data.Email}</p>
</div>
);
};

View File

@ -1,5 +1,3 @@
import { Separator } from "@kobalte/core/separator";
import { IconCalendar } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -12,21 +10,22 @@ export const SearchCardEvent = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-purple-50">
<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 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>
<p class="text-xs text-neutral-500">
<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">
Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
{new Date(data.StartDateTime).toLocaleDateString("en-US", {
{data.StartDateTime
? new Date(data.StartDateTime).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
})
: "unknown date"}
</p>
</div>
);

View File

@ -1,5 +1,3 @@
import { Separator } from "@kobalte/core/separator";
import { IconMapPin } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -12,15 +10,14 @@ export const SearchCardLocation = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-red-50">
<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 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>
<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 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">Address: {data.Address}</p>
</div>
);
};

View File

@ -1,4 +1,5 @@
import { Separator } from "@kobalte/core/separator";
import SolidjsMarkdown from "solidjs-markdown";
import { IconNote } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -12,14 +13,15 @@ export const SearchCardNote = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-green-50">
<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 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>
<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 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>
</div>
);

View File

@ -0,0 +1,78 @@
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

@ -0,0 +1,77 @@
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>
);
};

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