Compare commits

..

348 Commits

Author SHA1 Message Date
Savanni D'Gerinel 81d452694d Reverse the blinker pins 2024-09-15 23:57:06 -04:00
Savanni D'Gerinel 88cf32047b Enable the brake light 2024-09-08 12:53:35 -04:00
Savanni D'Gerinel 6cae7dbb0e Set up a basic server with a device listing endpoint 2024-08-26 10:41:17 -04:00
Savanni D'Gerinel 80776c65d8 Write a program that enumerates audio sinks on the device 2024-08-21 09:40:58 -04:00
Savanni D'Gerinel 1c54e0832b Make a design system page. Build up CSS. 2024-08-20 17:01:36 +00:00
Savanni D'Gerinel aee4528fb3 Rename the Dashboard 2024-08-20 17:01:36 +00:00
Savanni D'Gerinel 0535b6da5a Rename Launcher components 2024-08-20 17:01:36 +00:00
Savanni D'Gerinel b55324feab Add Activator groups 2024-08-20 17:01:36 +00:00
Savanni D'Gerinel 50d8a9670e Start creating some UI components 2024-08-20 17:01:36 +00:00
Savanni D'Gerinel 9cda35e766 UI placeholder 2024-08-20 17:01:36 +00:00
Savanni D'Gerinel d0f461a5eb Create the dashboard placeholder 2024-08-20 17:01:36 +00:00
Savanni D'Gerinel 70c013218a Update pins for the realities of the board layout 2024-07-30 14:50:14 -04:00
Savanni D'Gerinel 37c7e04820 Turn on the built-in LED when software starts up 2024-07-20 11:21:16 -04:00
Savanni D'Gerinel 291663d4a3 Re-add the armv6 toolchain 2024-07-08 09:35:44 -04:00
Savanni D'Gerinel 2b0fc7639e Debounce buttons, fix colors, and add a new water pattern 2024-07-08 09:29:34 -04:00
Savanni D'Gerinel 80d8dedbaf Adjust colors and the blinker patterns 2024-07-08 09:29:34 -04:00
Savanni D'Gerinel d7a70119c8 Send out the full set of lights 2024-07-08 09:29:34 -04:00
Savanni D'Gerinel 54c4b99ab6 Improve the blinker animations and state transitions when switching blinkers 2024-07-08 09:29:34 -04:00
Savanni D'Gerinel ef5415303b Start monitoring events 2024-07-08 09:29:34 -04:00
Savanni D'Gerinel 8d183d6d8c Build some of the framework for the bike application
This now sends a set of lights to the dashboard from a pico. I had to
adjust some of the colors as they do not look nearly as good in lights
as they do in the screen. There is no real application loop yet, no the
ability to get feedback from external controls.
2024-07-08 09:29:32 -04:00
Savanni D'Gerinel 0b949111d2 Switch to a fixed point arithmatic library 2024-07-08 09:28:40 -04:00
Savanni D'Gerinel 6164cb3b39 Refactor the bike library until it compiles with no_std
Theoretically, this is the first step to getting to running on the pico
2024-07-08 09:28:40 -04:00
Savanni D'Gerinel 22f0f9061c Rotate the right side
The actual bike is going to be a long loop which folds from the end of
the left side to the back end of the right side. This requires that the
colors get moved around for proper mirroring.
2024-07-08 09:28:40 -04:00
Savanni D'Gerinel 0bb5e62f96 Set up a bunch of animations and some state transitions! 2024-07-08 09:28:40 -04:00
Savanni D'Gerinel 06aedc34bb Now I'm able to send messages from the UI to the core 2024-07-08 09:28:40 -04:00
Savanni D'Gerinel 84b077e20c Build the core of the application. 2024-07-08 09:28:40 -04:00
Savanni D'Gerinel fc2e88add2 Set up a GTK simulator for the bike lights engine 2024-07-08 09:28:38 -04:00
Savanni D'Gerinel 15c4ae9bad Update the review tree when navigating 2024-05-07 08:49:49 -04:00
Savanni D'Gerinel 7dd531b493 It is now possible to move backwards and forwards on the mainline of a tree 2024-05-07 07:53:15 -04:00
Savanni D'Gerinel cbfb3f2e37 Clean up tests 2024-05-01 09:36:48 -04:00
Savanni D'Gerinel 9540a2c5bb Highlight the current node and make all nodes a bit larger 2024-04-30 23:34:16 -04:00
Savanni D'Gerinel 6165d65977 Make the review tree scrollable 2024-04-30 23:28:12 -04:00
Savanni D'Gerinel 4f8a1636c1 Set up a view model for the game review and highlight current node 2024-04-30 23:27:05 -04:00
Savanni D'Gerinel 20b02fbd90 Update the Nix derivation 2024-04-30 22:26:12 -04:00
Savanni D'Gerinel 278ec27b4e Linting 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel 8b7add37c1 Switch from slab_tree to nary_tree 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel 5441a3c441 Adapt the app to the new slab tree 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel b1374229f3 Calculate out the depth and width of each node 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel bc5042c004 Start propogating the slab_tree up to OTG 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel 0534143d6b Finish implementing GameTree.Clone and PartialEq 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel d7f5269e15 Begin implementing traits for a game record 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel c913e9da37 Convert the recursive parse tree to a slab GameTree 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel c50bd652f1 Switch from my custom Tree setup to a slab tree in GameRecord 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel 093e1f7f8a Start writing functions for finding a node within the tree 2024-04-30 22:25:01 -04:00
Savanni D'Gerinel 3c94f906a6 Update Cargo.nix 2024-04-19 12:27:17 -04:00
Savanni D'Gerinel 0aecaee760 Use pre-loaded images 2024-04-05 10:47:16 -04:00
Savanni D'Gerinel baeb458126 Create a resource manager that preloads the images 2024-04-05 10:06:52 -04:00
Savanni D'Gerinel da144a58ec Revise the graphics 2024-04-05 09:15:20 -04:00
Savanni D'Gerinel f09af67193 Add images for fancier stones 2024-04-01 00:14:15 -04:00
Savanni D'Gerinel 32391a46e7 Render the board in the background 2024-03-31 19:59:44 -04:00
Savanni D'Gerinel 0a62c96b0f Clippy 2024-03-31 19:36:44 -04:00
Savanni D'Gerinel 78863ee709 Cleanups 2024-03-31 18:16:41 -04:00
Savanni D'Gerinel 5cdcf0499c Improve ident calculation to help with tree drawing 2024-03-31 16:19:09 -04:00
Savanni D'Gerinel b982f2c1cc Start doing a bare basic rendering of nodes in a game tree 2024-03-31 14:09:48 -04:00
Savanni D'Gerinel 46b25cc6c5 Set up a BFS iterator over the node tree 2024-03-31 13:35:23 -04:00
Savanni D'Gerinel 9fbc630500 Game tree becomes a tree of UUIDs, not GameNodes
Doing this avoids reference lifetime hell
2024-03-30 11:00:54 -04:00
Savanni D'Gerinel b481d5d058 Adjust the coordinates calculated to be zero-based 2024-03-29 09:29:32 -04:00
Savanni D'Gerinel 7a06b8cf39 Work out how to calculate the horizontal position of each node 2024-03-29 09:10:38 -04:00
Savanni D'Gerinel 3192c0a142 Apply clippy to otg-gtk 2024-03-26 09:17:26 -04:00
Savanni D'Gerinel acf7ca0c9a Resolve clippy warnings on otg-core 2024-03-26 09:17:26 -04:00
Savanni D'Gerinel 64138b9e90 Resolve clippy warnings on SGF 2024-03-26 09:17:26 -04:00
Savanni D'Gerinel e587d269e9 Start on the foundations of a tree-drawing algorithm
I don't actually know what I'm doing. I've done some reading and from
that I'm doing experiments until I can better understand what I've read.
2024-03-26 09:17:26 -04:00
Savanni D'Gerinel 57aadd7597 Create the drawing area for the review tree 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel b70d927eac Render the board with the completed game state. 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 3a7f204883 Add more test data and ensure the mainline is returned even with branches 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 642351f248 Return the mainline of a game that has no branches in it. 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel d9bb9d92e5 Apply moves to the abstract board
To get here, I had to also build some conversion functions and make a
lot of things within the game record public
2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 30e7bdb817 Render the grid of the goban 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 556f91b70b Set the size of the drawing area 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 894575b0fb Start on the new Goban component 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel d964ab0d2f Minimal linting 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 74c8eb6861 Document the Goban representation in Core 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 3aac3b8393 Rename Game to GameRecord for disambiguation. 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel 295f0a0411 Create the game review page and work on navigating to it with a navigation stack 2024-03-26 09:17:25 -04:00
Savanni D'Gerinel e694ba74ca Update to Rust 1.77 2024-03-26 09:14:28 -04:00
Savanni D'Gerinel 5f9cd2622a Fix Cargo.nix 2024-03-26 09:06:08 -04:00
Savanni D'Gerinel 48271389ad Rename Kifu to On The Grid 2024-03-22 08:19:14 -04:00
Savanni D'Gerinel 49571b0f82 Show real information in the library view 2024-03-21 23:30:24 -04:00
Savanni D'Gerinel 89a289a1ae Make games serializable through Serde 2024-03-21 23:23:47 -04:00
Savanni D'Gerinel fe082773e3 Set up the library view again 2024-03-21 23:16:10 -04:00
Savanni D'Gerinel db9efbaedd Move the settings view model back into the core 2024-03-21 23:14:25 -04:00
Savanni D'Gerinel 1d959117aa Write a little app to demonstrate reading an an SGF file 2024-03-21 23:12:30 -04:00
Savanni D'Gerinel a5990a2a30 Ensure that the territory property is accepted 2024-03-21 22:49:24 -04:00
Savanni D'Gerinel bd6d5b62e3 Reduce the recursion amount of parser Node to GameNode 2024-03-21 22:49:24 -04:00
Savanni D'Gerinel 82c1765513 Write the more semantic Game interpreter 2024-03-21 22:49:24 -04:00
Savanni D'Gerinel de54ec676f Make sure the settings get saved 2024-03-21 17:01:40 -04:00
Savanni D'Gerinel 5612c89a61 Thread signals through all of the settings dialog 2024-03-21 08:43:08 -04:00
Savanni D'Gerinel c3c144e035 Set up the preferences UI with the path to the games library 2024-03-19 10:08:43 -04:00
Savanni D'Gerinel 05a6dcf3af Set up the initial settings screen 2024-03-15 14:07:55 -04:00
Savanni D'Gerinel b98e0bdcea Strip kifu down even further and revamp the app_window structure 2024-03-15 10:49:25 -04:00
Savanni D'Gerinel 3d4a298dc1 Write some documentation for the LocalObserver 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 2d7fbb9a4b Add the placeholders for game review and settings 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 3a5cb17e09 Extract notification receiving into an observer 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel f6c82cbcb0 Ensure the game_view_model handler aborts when the view_model gets dropped 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 74b00d94b1 Set up the notifications pattern for view models 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel ddf83b3018 GameViewModel now subscribes directly to the Core 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel a1f41a440f Remove typeshare from kifu 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel e838f601ca Set up a placeholder for the GameViewModel 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 1b0a90a332 Create the placeholder for the database view model 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 4dc6e3151b Remove everything related to the kifu typescript code 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 79cec6e21d Switch to async channels and disable most of the existing runtime api 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel 32cc74edfa Set up gio settings 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel d3d3260091 Create a flake that can run the Kifu app 2024-02-28 04:42:57 +00:00
Savanni D'Gerinel a1441f7bb1 Bump versions to 0.6.0 2024-02-19 18:47:48 -05:00
Savanni D'Gerinel 9e7350b087 Add an about page that calls out gtk-rs and the GUI development book 2024-02-19 18:41:38 -05:00
Savanni D'Gerinel 0032f16422 Resolve the duplicate widget insertion warning 2024-02-19 18:04:45 -05:00
Savanni D'Gerinel a5d51dab70 Hugely improve the historical view formatting 2024-02-19 17:56:48 -05:00
Savanni D'Gerinel c24a5f515f Set up CSS styles around the date range picker 2024-02-19 17:27:02 -05:00
Savanni D'Gerinel d70ca08db2 Correctly set the date range picker when initializing the historical view 2024-02-19 16:47:10 -05:00
Savanni D'Gerinel 55b6327d42 Open the welcome screen if the database is not available 2024-02-19 16:27:11 -05:00
Savanni D'Gerinel 1c2f40c868 Apply clippy suggestions 2024-02-19 16:14:08 -05:00
Savanni D'Gerinel 1527942f9c Add support for DurationWorkout and SetRep, finishing the legacy importer 2024-02-19 14:24:52 -05:00
Savanni D'Gerinel 7ba758b325 Build an application that can read the legacy record data structure 2024-02-19 13:27:19 -05:00
Savanni D'Gerinel aed4735209 Add some documentation around the quit action 2024-02-19 12:05:21 -05:00
Savanni D'Gerinel c14b20b79e Set up an application quit action group
This ties together a menu item, closing the main window, and Ctrl-Q into
the same action, which is to quit the application.
2024-02-19 11:58:01 -05:00
Savanni D'Gerinel 56ff5527ba Quit the application when the user clicks the header bar X 2024-02-19 09:49:20 -05:00
Savanni D'Gerinel f7f55d74fd Bump fitnesstrax to v0.5.0 2024-02-18 17:58:52 -05:00
Savanni D'Gerinel 843924afef Wrap the list view within a scrolled window 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 86d7ca0b01 Change TextEntry to a builder pattern 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel d137ee2481 Apply clippy 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 74dbb58ed9 Extract the date range picker and enable the quick picker 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 9a18496c95 Apply the value of a date field to the historical view
Date fields can now be updated and their values retrieved. So now I have added a button that retrieves the range of the date field and updates the historical view in response to them.
2024-02-18 22:19:39 +00:00
Savanni D'Gerinel 01776a534e Start setting up date range widgets 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel b33d17c256 Start building an internally editable date field 2024-02-18 22:19:39 +00:00
Savanni D'Gerinel ab59eedef5 Fitnesstrax v0.4.1 2024-02-12 10:14:55 -05:00
Savanni D'Gerinel 4dd6afeae7 Add the desktop shortcut and the gsettings schema to the fitnesstrax installation 2024-02-12 09:15:47 -05:00
Savanni D'Gerinel cb2bec4287 First pass at an installer override 2024-02-10 12:28:12 -05:00
Savanni D'Gerinel 5a93c4fdcd Bump the fitnesstrax version to 0.4.0 2024-02-09 22:52:34 -05:00
Savanni D'Gerinel 6e5cbc0930 Ensure there is a button for every workout type 2024-02-09 22:40:29 -05:00
Savanni D'Gerinel 73a5ab89a3 Rename BikeRide to Biking
This just generally feels more comfortable, especially when put in conjuction with the other activity types
2024-02-09 08:24:01 -05:00
Savanni D'Gerinel 291dc32fe5 Show summaries of all time-distance workouts 2024-02-09 08:22:12 -05:00
Savanni D'Gerinel b5c42e3ac3 Add the activity type selector to the time-distance widget 2024-02-09 08:17:10 -05:00
Savanni D'Gerinel 6394d89331 Create new records with the date of the view model 2024-02-08 22:11:46 -05:00
Savanni D'Gerinel 38b1e62b60 Add the ability to edit the time on a time_distance record 2024-02-08 21:58:22 -05:00
Savanni D'Gerinel 4acf034b8d Clean up warnings and remove printlns 2024-02-08 19:01:56 -05:00
Savanni D'Gerinel 1aff203afc Reload data when the user saves on the DayEdit panel
This required some big overhauls. The view model no longer takes records. It only takes the date that it is responsible for, and it will ask the database for records pertaining to that date. This means that once the view model has saved all of its records, it can simply reload those records from the database. This has the effect that as soon as the user moves from DayEdit back to DayDetail, all of the interesting information has been repopulated.
2024-02-08 19:01:56 -05:00
Savanni D'Gerinel 9fc9d2b758 Create Duration and Distance structures to handle rendering
These structures handle parsing and rendering of a Duration and a Distance, allowing that knowledge to be centralized and reused. Then I'm using those structures in a variety of places in order to ensure that the information gets rendered consistently.
2024-02-08 10:30:14 -05:00
Savanni D'Gerinel 76f4b31466 Show existing time/distance workout rows in day detail and editor 2024-02-08 10:23:47 -05:00
Savanni D'Gerinel 73052a0694 Save new time/distance records
This sets up a bunch of callbacks. We're starting to get into Callback Hell, where there are things that need knowledge that I really don't want them to have.

However, edit fields for TimeDistanceEdit now propogate data back into the view model, which is then able to save the results.
2024-02-08 10:20:57 -05:00
Savanni D'Gerinel 2c42c35dfe Build the facilities to add a new time/distance workout
This adds the code to show the new records in the UI, plus it adds them to the view model. Some of the representation changed in order to facilitate linking UI elements to particular records. There are now some buttons to create workouts of various types, clicking on a button adds a new row to the UI, and it also adds a new record to the view model. Saving the view model writes the records to the database.
2024-02-08 09:44:58 -05:00
Savanni D'Gerinel afe693fe10 Make emseries::Record copyable 2024-02-08 09:18:55 -05:00
Savanni D'Gerinel af1422d523 Build some convenienc functions for measurement entry fields
Move the weight field into text_entry
2024-02-08 09:18:55 -05:00
Savanni D'Gerinel 792e20d44b Add buttons with icons to represent workouts 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 8016188b29 Add a test program for gnome icons 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 74df2880bb Implement the Edit Cancel button 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 96c4201680 Render time distance details in the day detail view 2024-02-08 09:02:09 -05:00
Savanni D'Gerinel ecdd38ebbc Show a summary of the day's biking stats when there is one 2024-02-08 08:57:17 -05:00
Savanni D'Gerinel 2fb8728856 Invert the TraxRecord
This simplifies, though not as much as I was hoping, the patterns for accessing data along strict type patterns. I may see better results once I'm getting the Time/Distance views working.
2024-02-08 00:20:40 -05:00
Savanni D'Gerinel a7d43ef184 Update Cargo.nix 2024-02-08 00:16:27 -05:00
Savanni D'Gerinel 9727d35116 Resolve clippy warnings
Warnings were mounting up. It was time to resolve them before attempting a massive rebase.
2024-02-07 23:36:03 -05:00
Savanni D'Gerinel 1d6155d9e5 Finish the update and delete view model functions 2024-02-07 09:29:08 -05:00
Savanni D'Gerinel a8bf540517 Remove test that steps and weights are honored correctly
Step and weight records may be presented in any order. Any test that
tries to enforce that one gets presented before teh other can't cannot
succeed. So, I've removed that test and instead put in a warning that
will appear when the view model gets loaded.
2024-02-07 08:28:38 -05:00
Savanni D'Gerinel 3db870d790 Set up time distance operations and tests 2024-02-03 15:28:33 -05:00
Savanni D'Gerinel 24276d172b Introduce the RecordProvider interface
DayDetailViewModel needs testing. I've worked out an improved API, and a set of tests to go along with it, and those can be made more easily with a mockable RecordProvider. So, in addition to stubbing out a bunch of tests, I've also created RecordProvider, mocked it, and implemented it for App.
2024-02-01 10:12:35 -05:00
Savanni D'Gerinel 96317f5692 Finish removing the previous record grouping
Now that the DayDetailViewModel knows to retrieve its own records, the grouping functions, and passing groups of records around, no longer make sens.
2024-02-01 10:08:18 -05:00
Savanni D'Gerinel c1e797f3ae DayDetailViewModel now ignores records and directly retrieves data from App
This is preparatory work. Having the view model directly retrieve data both adds a degree of symmetry (it both gets data from and sends data to the app) and makes it possible for the view model to refresh itself when needing to revert data or after saving data.
2024-02-01 09:27:40 -05:00
Savanni D'Gerinel 304008c674 The view model can no longer be initialized without an app 2024-01-31 09:51:17 -05:00
Savanni D'Gerinel 772188b470 Set up text entry fields for all of the common metrics 2024-01-31 08:56:54 -05:00
Savanni D'Gerinel bc31522c95 Add the on_update callback to TextEntry, and test the component 2024-01-31 08:44:46 -05:00
Savanni D'Gerinel 6c68564a77 Create a function which safely initializes GTK once
This is only available in test code, and it allows GUI component tests to run without having to worry about double-initializing GTK
2024-01-31 08:40:55 -05:00
Savanni D'Gerinel 55c1a6372f Rename the formatters 2024-01-31 08:38:17 -05:00
Savanni D'Gerinel 2cbd539bf4 Add a type for managing Time of Day values 2024-01-31 08:27:42 -05:00
Savanni D'Gerinel 7d14308def Create automated testing for weight, duration, and distance formatters 2024-01-31 08:10:46 -05:00
Savanni D'Gerinel dcd6301bb9 Introduce structures for formatting and parsing Duration and Distance values
These aren't in use yet.
2024-01-30 10:11:30 -05:00
Savanni D'Gerinel 69567db486 Introduce a structure for formatting and parsing Weight values 2024-01-30 10:08:10 -05:00
Savanni D'Gerinel f8d66bbb69 Update build tools for dashboard and fitnesstrax 2024-01-25 22:55:45 -05:00
Savanni D'Gerinel dce11dde2b Set up flake-based builds 2024-01-25 22:48:41 -05:00
Savanni D'Gerinel 3f9a7072eb Fitnesstrax, version 0.3.0 2024-01-21 10:14:50 -05:00
Savanni D'Gerinel 7ec48ded5d Make the day summary use the view model 2024-01-20 17:04:20 -05:00
Savanni D'Gerinel 9461c387fe Simplify the weight editor 2024-01-20 16:05:33 -05:00
Savanni D'Gerinel d4c48c4443 Add a step count editor field 2024-01-20 15:59:03 -05:00
Savanni D'Gerinel 9bedb7a76c Tons of linting and get tests running again 2024-01-20 15:04:46 -05:00
Savanni D'Gerinel 1fe318068b Set up a view model for the day detail view 2024-01-20 11:16:31 -05:00
Savanni D'Gerinel 18e7e4fe2f Start setting up the day detail view model
I've created the view model and added a getter function for the weight.
I'm passing the view model now to the DayDetailView, DayDetail, and
DayEdit.

I'm starting to set up the Save function for the view model, draining
all of the updated records and saving them.

None of the components yet save any updates to the view model, so
updated_records is always going to be empty until I figure that out.
2024-01-18 09:00:08 -05:00
Savanni D'Gerinel 1c2c4982a1 Update the record in the detail view on save 2024-01-18 07:43:18 -05:00
Savanni D'Gerinel c075b7ed6e Just barely get the data saveable again
Starting to see some pretty serious limitations already.
2024-01-17 22:59:20 -05:00
Savanni D'Gerinel 56d0a53666 Fix how DayEdit deals with the weight field 2024-01-17 22:35:13 -05:00
Savanni D'Gerinel b00acc64a3 Set up the ActionGroup component 2024-01-17 22:13:55 -05:00
Savanni D'Gerinel 104760c754 Be able to switch into edit mode 2024-01-15 23:27:55 -05:00
Savanni D'Gerinel 1e6555ef61 Create a day detail view
DayDetail, the component, I used to use as a view. Now I'm swapping
things out so that DayDetailView handles the view itself. The DayDetail
component will still show the details of the day, but I'll create a
DayEditComponent which is dedicated to showing the edit interface for
everything in a day.

The swapping will now happen in DayDetailView, not in DayDetail or an
even deeper component.
2024-01-15 15:53:01 -05:00
Savanni D'Gerinel 2e2ff6b47e Create a Singleton component and use it to simplify the weight view 2024-01-15 13:20:23 -05:00
Savanni D'Gerinel 2d22397382 Bump the dashboard version 2024-01-09 08:10:58 -05:00
Savanni D'Gerinel 2c7666304a Fix the year in the current date view 2024-01-09 08:10:02 -05:00
Savanni D'Gerinel 0007522b26 Extract the Weight and Time Distance widgets 2024-01-01 23:58:55 -05:00
Savanni D'Gerinel b7b9b1b29f Create an EditView "component"
This is a super tiny data structure that covers an edit mode, a view
mode, and an unconfigured mode. It's mostly a container so that views
don't have to preserve everything directly.
2024-01-01 23:49:31 -05:00
Savanni D'Gerinel 2e3d5fc5a4 Clean up the parameters to TextEntry and populate the field 2024-01-01 22:57:59 -05:00
Savanni D'Gerinel a25b76d230 Create a validated text entry widget
I move the weight edit view into the validated text entry widget, and I
work on some of the unfortunate logic in the weight blur function. I've
left behind a lot of breadcrumbs for things that still need to be done.
2023-12-31 11:18:15 -05:00
Savanni D'Gerinel 9970161c30 Set up a lot of the files necessary for a desktop app 2023-12-29 13:13:22 -05:00
Savanni D'Gerinel 7bd4885b09 Save new information to the day detail view and to the historical view 2023-12-29 09:24:37 -05:00
Savanni D'Gerinel b5dcee3737 Update the historical view when a change happens in the db 2023-12-28 22:47:47 -05:00
Savanni D'Gerinel 0c3ae062c8 Save real data to the database. Load data on app start. 2023-12-28 22:46:44 -05:00
Savanni D'Gerinel f422e233a1 Record data to the database
This isn't recording real data. It's basically discarding all
information from the weight edit field. But it is creating a record.
2023-12-28 22:43:56 -05:00
Savanni D'Gerinel 7a6e902fdd Create placeholders in the historical view for days that are unpopulated. 2023-12-28 22:36:44 -05:00
Savanni D'Gerinel 6d9e2ea382 Switch to the updated emseries record type 2023-12-28 22:36:40 -05:00
Savanni D'Gerinel 04a48574d3 Develop a pattern to detect clicking outside of a focused child
Be able to respond to blur events and potentially be able to record weight.
2023-12-28 22:34:09 -05:00
Savanni D'Gerinel e13e7cf4c3 Create a widget that can show the weight view and edit modes 2023-12-28 22:31:11 -05:00
Savanni D'Gerinel 383f809191 Open and style the day detail view and add it to the navigation stack 2023-12-28 22:31:07 -05:00
Savanni D'Gerinel d269924827 Refactorings and dead code removal 2023-12-28 22:20:30 -05:00
Savanni D'Gerinel 8049859816 Clean up showing the welcome and historical screens
Swapping is now done in dedicated functions instead of a big pattern
match.

After selecting a database, the app window will apply the configuration
by opening the database, saving the path to configuration, and switching
to the historical view.
2023-12-28 21:45:55 -05:00
Savanni D'Gerinel ac343a2af6 Switch from channel-based communication to async calls into the core 2023-12-28 19:09:12 -05:00
Savanni D'Gerinel 5cd0e822c6 Update to adwaita 1.4, and add a navigation page stack 2023-12-28 13:21:42 -05:00
Savanni D'Gerinel fe5e4ed044 Save the views as their original widgets
This allows me to directly reference functions that occur on those
widgets without losing them behind a gtk::Widget upcast or needing to
later downcast them.
2023-12-28 12:59:29 -05:00
Savanni D'Gerinel e30668ca8e Drop DateTimeTz from fitnesstrax 2023-12-28 12:51:50 -05:00
Savanni D'Gerinel 149587f0bd Bind the ID to the record instead of keeping them separate 2023-12-27 16:13:47 -05:00
Savanni D'Gerinel d2f4ec97c0 Rename UniqueId to RecordId 2023-12-26 15:39:17 -05:00
Savanni D'Gerinel c94b7db484 Stop using DateTimeTz 2023-12-26 15:37:53 -05:00
Savanni D'Gerinel 85e2494c3b Add a test application that demonstrates chrono and timezone processing 2023-12-26 13:33:18 -05:00
Savanni D'Gerinel af8f9b0244 Generate some random data and feed it into hte historical view 2023-12-24 19:13:49 -05:00
Savanni D'Gerinel 1b3ca7439d Add styling to the day summary 2023-12-24 12:00:12 -05:00
Savanni D'Gerinel 3dc8be0d26 Render a weight record 2023-12-22 18:53:29 -05:00
Savanni D'Gerinel 43cd408e2c Start elaborating upon the HistoricalView
I've created the DaySummary structure and set up a list view to go into
the historical view. One hard-coded date is visible as a placeholder to
start filling things into the day summary.
2023-12-22 17:32:45 -05:00
Savanni D'Gerinel 3a728a51b4 Extract the application loop from the main file 2023-12-22 15:17:22 -05:00
Savanni D'Gerinel f19090311b Extract all of the UI components into dedicated files 2023-12-22 15:17:22 -05:00
Savanni D'Gerinel dedcc76df0 Mild cleanups 2023-12-22 15:16:03 -05:00
Savanni D'Gerinel 6678ab9852 Documentation 2023-12-22 14:28:23 -05:00
Savanni D'Gerinel 9c200f555c Set up app invocation and response handling 2023-12-22 14:08:16 -05:00
Savanni D'Gerinel 3ca8bf64cc Set up message passing between app window and an app thread 2023-12-19 18:05:22 -05:00
Savanni D'Gerinel 87994012fa Save the database path to settings and attempt to open the database on start 2023-12-19 10:59:33 -05:00
Savanni D'Gerinel 50268ffadc Actually be able to open the database 2023-12-19 10:46:53 -05:00
Savanni D'Gerinel beedeba8dc Style the welcome screen 2023-12-19 10:10:02 -05:00
Savanni D'Gerinel db188ea75a Allow the user to create a new file 2023-12-19 00:37:51 -05:00
Savanni D'Gerinel 104ffc5782 Set up callbacks to make the save button sensitive to the file selection 2023-12-19 00:31:36 -05:00
Savanni D'Gerinel 38db3d6780 Elaborate upon and format the welcome dialog 2023-12-18 21:14:08 -05:00
Savanni D'Gerinel 0dd0a5f7cc Set up some of the content of the welcome view 2023-12-18 20:04:55 -05:00
Savanni D'Gerinel acdf9ec150 Add the window header bar 2023-12-18 19:08:32 -05:00
Savanni D'Gerinel 0ebdcd7c2a Add some commentary 2023-12-18 18:36:22 -05:00
Savanni D'Gerinel baf652173c Set up the main views for the window, as well as the redraw policy
Whenever we change views, we need to call the redraw function. That
function will handle dropping the old view and populating the new one.
2023-12-18 18:30:41 -05:00
Savanni D'Gerinel c4befcc6de Add the CSS style context to the main window 2023-12-18 11:59:56 -05:00
Savanni D'Gerinel a7d6d82ec2 Set up an environment variable to toggle between dev and production schemas 2023-12-07 09:56:10 -05:00
Savanni D'Gerinel f3a453d151 Set up a development gsettings schema 2023-12-07 09:45:56 -05:00
Savanni D'Gerinel b9aa434278 Remove types that are not implemented yet
I've gone *years* without these types, mostly because I wasn't doing
these workouts. I can go longer.
2023-12-06 23:55:12 -05:00
Savanni D'Gerinel 83a4839b1d Implement the timestamp function 2023-12-06 23:52:46 -05:00
Savanni D'Gerinel 0e0d67a9ac Split Fitnesstrax into two crates 2023-12-06 23:52:33 -05:00
Savanni D'Gerinel e5fb605816 Create a test that verifies that a series can be made for a TraxRecord 2023-12-06 23:52:33 -05:00
Savanni D'Gerinel f9db002464 Make the series open function accept anything that can be a path reference 2023-12-06 23:52:28 -05:00
Savanni D'Gerinel 0ac9bb74a6 Set up the bare minimum of a GUI app, opening only a single window 2023-12-06 23:52:28 -05:00
Savanni D'Gerinel f034dfcb8b Set up the basic data structures of a new fitnesstrax app. 2023-12-06 23:52:28 -05:00
Savanni D'Gerinel 7abb33c4fe Work out how the session filter and the handlers can function 2023-11-21 09:57:35 -05:00
Savanni D'Gerinel 581979fc54 Make some test endpoints and prototype an authentication filter 2023-11-20 23:30:10 -05:00
Savanni D'Gerinel bf93625225 Create a placeholder for the Visions server 2023-11-20 00:03:16 -05:00
Savanni D'Gerinel 778da0b651 Start working out designs and build tools for the visions vtt 2023-11-20 00:03:16 -05:00
Savanni D'Gerinel 8b53114d0d Have the file-service depend on the new authdb library 2023-11-19 23:55:43 -05:00
Savanni D'Gerinel 42e931d780 Move the cli app into authdb 2023-11-19 23:54:02 -05:00
Savanni D'Gerinel 532210db03 Extract the authentication DB from the file service 2023-11-19 23:43:33 -05:00
Savanni D'Gerinel 37f6334c9f Update the gtk dependencies for all packages
This breaks the hex-grid application. set_source_pixbuf got removed and
I have not figured out a replacement for drawing a pixbuf to a context.
2023-11-14 10:05:56 -05:00
Savanni D'Gerinel 3310c460ba Cleanups 2023-11-14 08:27:13 -05:00
Savanni D'Gerinel 6d14cdbe2a Build a color test pattern. 2023-11-14 08:04:31 -05:00
Savanni D'Gerinel c46ab1b389 Tweak the bit-banging code to get the protocol right 2023-11-13 18:21:07 -05:00
Savanni D'Gerinel 168ba6eb40 Try controlling dotstars through SPI and through bit-banging 2023-11-09 22:24:37 -05:00
Savanni D'Gerinel 7e3ee9a5b7 Set up a blink application for the raspberry pi pico 2023-11-05 15:54:33 -05:00
Savanni D'Gerinel 86a6d386d2 Set up raspberry pi cross-compile tools 2023-11-05 15:53:43 -05:00
Savanni D'Gerinel e461cb9908 Import the new level-one parser
This is the parser that does a raw parse of the SGF file, interpreting components but not enforcing node types.
2023-10-30 01:57:00 +00:00
Savanni D'Gerinel 942e91009e Disable sgf::go and provide a shim for a game 2023-10-30 01:57:00 +00:00
Savanni D'Gerinel 48113d6ccb Bump version to 0.2.0 2023-10-26 00:26:52 -04:00
Savanni D'Gerinel d878f4e82c Resolve more linting issues 2023-10-26 00:19:13 -04:00
Savanni D'Gerinel 7949033857 Add the handler to delete a file 2023-10-26 00:14:10 -04:00
Savanni D'Gerinel ce874e1d30 Fix the form to string conversion and set up the Delete form 2023-10-26 00:12:45 -04:00
Savanni D'Gerinel 07b8bb7bfe Style the authentication page for mobile 2023-10-26 00:03:49 -04:00
Savanni D'Gerinel a403c1b1b3 Hugely refactor the HTML 2023-10-26 00:03:39 -04:00
Savanni D'Gerinel 9a014af75a Remove my custom Image struct 2023-10-25 23:24:41 -04:00
Savanni D'Gerinel 448231739b Remove my custom Unordered List 2023-10-25 23:08:02 -04:00
Savanni D'Gerinel b0027032a4 Rename the password field to be compatible with 1Password 2023-10-25 23:05:06 -04:00
Savanni D'Gerinel 41bbfa14f3 Bump file-service tag to 0.1.2 2023-10-25 10:38:11 -04:00
Savanni D'Gerinel 66876e41c0 Clean up broken tests and clippy warnings 2023-10-25 10:35:24 -04:00
Savanni D'Gerinel ee348c29cb Render the name and the uploaded date for each file in the gallery 2023-10-25 10:20:14 -04:00
Savanni D'Gerinel e96b8087e2 Add filenames to FileInfo and then set those filenames when creating the file 2023-10-25 10:17:17 -04:00
Savanni D'Gerinel 12df1f4b9b Create an UnorderedList HTML container 2023-10-25 09:47:27 -04:00
Savanni D'Gerinel c2e34db79c Map on the data within the node instead of the node itself 2023-10-24 23:05:02 -04:00
Savanni D'Gerinel 0fbfb4f1ad Add a tree map operation 2023-10-20 23:43:47 -04:00
Savanni D'Gerinel c2e78d7c54 Clean up some unnecessary references 2023-10-20 20:28:36 -04:00
Savanni D'Gerinel 2ceccbf38d Remove the Clone constraint from T 2023-10-20 20:17:33 -04:00
Savanni D'Gerinel fbf6a9e76e Move the refcell to inside of the Node 2023-10-20 19:49:31 -04:00
Savanni D'Gerinel 52f814e663 Build a basic tree and experiment with traversals 2023-10-20 18:32:43 -04:00
Savanni D'Gerinel 4114874156 Fix some linter errors 2023-10-18 23:12:58 -04:00
Savanni D'Gerinel b756e8ca81 Reverse the order of Error and FatalError parameters in the Result
In other usage, I discovered that it's rather confusing to have the parameters in the order that they were in. It feels better to have the fatal error after the regular error.
2023-10-18 22:13:11 -04:00
Savanni D'Gerinel 3cb742d863 Rename flow to result-extended
The original name has always felt awful. I understand Rust well enough now to be able to use the name Result and override the built-in Result.
2023-10-18 22:03:43 -04:00
Savanni D'Gerinel 27e1691854 Set up a stylesheet for the OnePlus 8 2023-10-07 15:24:12 -04:00
Savanni D'Gerinel 2d2e82f41a Work out some basic styling for a phone screen 2023-10-06 23:59:02 -04:00
Savanni D'Gerinel 78c017ede7 Style the upload form 2023-10-06 23:51:41 -04:00
Savanni D'Gerinel cfdceff055 Refactor out the common card styling 2023-10-06 21:04:27 -04:00
Savanni D'Gerinel 07b4cb31ce Add reasonable desktop styling for the gallery 2023-10-06 20:36:27 -04:00
Savanni D'Gerinel b3f88a49aa Clean up the authentication page CSS
Center the authentication field in the authentication page. Provide some padding within the card, and arrange the form itself.
2023-10-06 19:05:24 -04:00
Savanni D'Gerinel 3f1316b3dd Serve the CSS file 2023-10-06 19:04:15 -04:00
Savanni D'Gerinel ef057eca66 run tests on release builds 2023-10-05 13:01:24 -04:00
Savanni D'Gerinel c70e1d943d Disable the komi test
The SGF parser doesn't currently parse komi
2023-10-05 13:01:24 -04:00
Savanni D'Gerinel 7711f68993 Resolve tests which call the GameState constructor
I changed the constructor from new() to default(), but didn't catch all of the tests.
2023-10-05 13:01:24 -04:00
Savanni D'Gerinel f13b3effd6 Run release build before building running the dist scripts 2023-10-05 13:01:24 -04:00
Savanni D'Gerinel 4cdd2b6b0f Make sure the distribution scripts compress files and include version numbers 2023-10-05 13:01:24 -04:00
Savanni D'Gerinel 6c831567eb Remove orizentic from the environment
This needs a total overhaul and so isn't worth fixing right now.
2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 0afe0c1b88 Resolve warnings in the kifu app 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel e0f3cdb50a Resolve warnings in the SGF library 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel efac7e43eb Resolve warnings in the hex-grid app 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 37c60e4346 Resolve warnings in gm-control-panel 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 2084061526 Resolve linter warnings in emseries 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 79422b5c7a Resolve warnings in memorycache and dashboard 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 3f2feee4dd Resolve linting problems with flow 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 5443015868 Resolving linting problems in geo-types 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 49b1865818 Resolve warnings in the IFC library 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 10849687e3 Resolve warnings in fluent-ergonomics 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel d441e19479 Resolve warnings in cyberpunk-splash 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 5496e9ce10 Resolve warnings in coordinates 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel 7b6b7ec011 Resolve warnings in changeset 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel e657320b28 Thoroughly lint the file-service 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel bdcd7ee18e Set up strict clippy linting in the build scripts 2023-10-05 12:57:35 -04:00
Savanni D'Gerinel f9974e79a7 Set a maximum upload to 15MB 2023-10-05 00:08:27 -04:00
Savanni D'Gerinel 4200432e1f Make sure to compress the tar files when bundling for distribution 2023-10-05 00:07:55 -04:00
Savanni D'Gerinel 525b5389a1 add .tar.gz to .gitignore 2023-10-04 15:22:43 -04:00
Savanni D'Gerinel d4a5e0f55d Serve the original file with the main path instead of the thumbnail 2023-10-04 15:22:43 -04:00
Savanni D'Gerinel 1d89254413 Set up the file service packaging script
Improve the dist script
2023-10-04 15:22:43 -04:00
Savanni D'Gerinel 2f6be84a43 Remove dead comments 2023-10-03 19:48:44 -04:00
Savanni D'Gerinel f7403b43a3 Remove a legacy file 2023-10-03 19:48:44 -04:00
Savanni D'Gerinel 2e7e159325 Remove an excess comment 2023-10-03 19:48:44 -04:00
Savanni D'Gerinel 1e11069282 Remove old placeholder directories 2023-10-03 19:48:44 -04:00
Savanni D'Gerinel c38d680e57 Handle file uploads with a validated session 2023-10-03 19:48:44 -04:00
Savanni D'Gerinel 9bb32a378c Validate the session token with file uploads
File uploads now check the session token before continuing.

Resolves: https://www.pivotaltracker.com/story/show/186174680
2023-10-03 19:48:44 -04:00
Savanni D'Gerinel b3bfa84691 Validate the session token
A previous commit added authentication token checks. Auth tokens are replaced with session tokens, which can (and should) expire. This commit validates sessions, which now allows access to gated operations.
2023-10-03 19:48:44 -04:00
Savanni D'Gerinel f53c7200e6 Add a CLI application for user management 2023-10-03 19:48:44 -04:00
Savanni D'Gerinel 491c80b42b Split out a support library 2023-10-03 19:48:44 -04:00
Savanni D'Gerinel 5e4db0032b Add session checks 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 4a7d741224 Add the ability to create and list users 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 6aedff8cda Create the initial database migration 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 535ea6cd9d Finish the auth handler and create app auth stubs 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel da8281636a Set up authentication routes 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel b448ab7656 Complete upload 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 75a90bbdff Set up temperory working directories 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 94aa67a156 Correctly set up file ids from list_files 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel ee5f4646df Refactor PathResolver so it cannot fail 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 561ec70a65 Remove old test files 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 14f0a74af8 Lots more refactoring :( 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 68b62464f0 Clean up the filehandle logic 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel da6bf3bfea Add cool_asserts 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 3e87e13526 Provide a unified interface for the File and Thumbnail 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 88938e44c8 Load file by ID 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 89a1aa7ee5 Get thumbnail creation working again 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 22e25256a5 Add some tests to verify that a file can be added to the system
Still gutting a lot of the old code, but this MR focuses more on ensuring that a file can be added and that the metadata gets saved.
2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 9787ed3e67 Add some testing for the PathResolver 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 396f6e3bcf Start ripping out lots of infrastructure
Much of the infrastructure is old and seems to be based on some assumptions about how Iron handled multipart posts. I don't understand how much of this works, so I'm slowly ripping parts out and rebuilding how the separation of concerns works.
2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 8521db333b Set up the delete route
Sets up the delete route, including post-delete redirect back to the root.
Also adds logging.

Delete does not actually delete things yet.
2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 4a7b23544e Refactor file and thumbnail serving to common code 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel a06c9fae25 Attempt to add etag caching 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel f05e0a15f1 Render thumbnails 2023-10-03 19:48:43 -04:00
Savanni D'Gerinel 634c404ae9 Swap from iron to warp and start rebuilding the app 2023-10-03 19:48:25 -04:00
Savanni D'Gerinel e36657591b Add orizentic and file-service to the build 2023-10-03 19:32:57 -04:00
Savanni D'Gerinel 7077724e15 Import a questionably refactored version of file-service 2023-10-03 17:59:55 -04:00
Savanni D'Gerinel 4816c9f4cf Import orizentic 2023-10-03 17:59:55 -04:00
Savanni D'Gerinel 207d099607 nom parsing practice 2023-09-25 22:54:54 +00:00
Savanni D'Gerinel 59061c02ce dashboard: 0.1.0 --> 0.1.1 2023-09-21 09:44:22 -04:00
Savanni D'Gerinel 3d460e5840 Sleep for only one second if the gtk sender can't be found
This probably means that the main app hasn't started yet. Just sleep for one second before retrying.
2023-09-21 09:37:56 -04:00
319 changed files with 59993 additions and 9828 deletions

5
.gitignore vendored
View File

@ -4,3 +4,8 @@ node_modules
dist
result
*.tgz
*.tar.gz
*.sqlite
*.sqlite-shm
*.sqlite-wal
file-service/var

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"rust-analyzer.showUnlinkedFileNotification": false
}

3996
Cargo.lock generated

File diff suppressed because it is too large Load Diff

17402
Cargo.nix Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
[workspace]
resolver = "2"
members = [
"authdb",
"bike-lights/bike",
"bike-lights/core",
"bike-lights/simulator",
"changeset",
"config",
"config-derive",
@ -7,15 +12,23 @@ members = [
"cyberpunk-splash",
"dashboard",
"emseries",
"flow",
"file-service",
"fitnesstrax/core",
"fitnesstrax/app",
"fluent-ergonomics",
"geo-types",
"gm-control-panel",
"hex-grid",
"icon-test",
"ifc",
"kifu/core",
"kifu/gtk",
"memorycache",
"nom-training",
"otg/core",
"otg/gtk",
"result-extended",
"screenplay",
"sgf",
"timezone-testing",
"tree",
"visions/server", "gm-dash/server",
]

View File

@ -1,30 +0,0 @@
all: test bin
test: kifu-core/test-oneshot sgf/test-oneshot
bin: kifu-gtk
kifu-core/dev:
cd kifu/core && make test
kifu-core/test:
cd kifu/core && make test
kifu-core/test-oneshot:
cd kifu/core && make test-oneshot
kifu-gtk:
cd kifu/gtk && make release
kifu-gtk/dev:
cd kifu/gtk && make dev
kifu-pwa:
cd kifu/pwa && make release
kifu-pwa/dev:
pushd kifu/pwa && make dev
kifu-pwa/server:
pushd kifu/pwa && make server

27
authdb/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "authdb"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "authdb"
path = "src/lib.rs"
[[bin]]
name = "auth-cli"
path = "src/bin/cli.rs"
[dependencies]
base64ct = { version = "1", features = [ "alloc" ] }
clap = { version = "4", features = [ "derive" ] }
serde = { version = "1.0", features = ["derive"] }
sha2 = { version = "0.10" }
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
thiserror = { version = "1" }
tokio = { version = "1", features = [ "full" ] }
uuid = { version = "0.4", features = [ "serde", "v4" ] }
[dev-dependencies]
cool_asserts = "*"

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY NOT NULL,
username TEXT NOT NULL,
token TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT NOT NULL,
user_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
);

40
authdb/src/bin/cli.rs Normal file
View File

@ -0,0 +1,40 @@
use authdb::{AuthDB, Username};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Subcommand, Debug)]
enum Commands {
AddUser { username: String },
DeleteUser { username: String },
ListUsers,
}
#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
command: Commands,
}
#[tokio::main]
pub async fn main() {
let args = Args::parse();
let authdb = AuthDB::new(PathBuf::from(&std::env::var("AUTHDB").unwrap()))
.await
.expect("to be able to open the database");
match args.command {
Commands::AddUser { username } => {
match authdb.add_user(Username::from(username.clone())).await {
Ok(token) => {
println!("User {} created. Auth token: {}", username, *token);
}
Err(err) => {
println!("Could not create user {}", username);
println!("\tError: {:?}", err);
}
}
}
Commands::DeleteUser { .. } => {}
Commands::ListUsers => {}
}
}

302
authdb/src/lib.rs Normal file
View File

@ -0,0 +1,302 @@
use base64ct::{Base64, Encoding};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::ops::Deref;
use std::path::PathBuf;
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("authentication token is duplicated")]
DuplicateAuthToken,
#[error("session token is duplicated")]
DuplicateSessionToken,
#[error("database failed")]
SqlError(sqlx::Error),
}
impl From<sqlx::Error> for AuthError {
fn from(err: sqlx::Error) -> AuthError {
AuthError::SqlError(err)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct Username(String);
impl From<String> for Username {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for Username {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<Username> for String {
fn from(s: Username) -> Self {
Self::from(&s)
}
}
impl From<&Username> for String {
fn from(s: &Username) -> Self {
let Username(s) = s;
Self::from(s)
}
}
impl Deref for Username {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl sqlx::FromRow<'_, SqliteRow> for Username {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let name: String = row.try_get("username")?;
Ok(Username::from(name))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct AuthToken(String);
impl From<String> for AuthToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for AuthToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<AuthToken> for PathBuf {
fn from(s: AuthToken) -> Self {
Self::from(&s)
}
}
impl From<&AuthToken> for PathBuf {
fn from(s: &AuthToken) -> Self {
let AuthToken(s) = s;
Self::from(s)
}
}
impl Deref for AuthToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct SessionToken(String);
impl From<String> for SessionToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<SessionToken> for PathBuf {
fn from(s: SessionToken) -> Self {
Self::from(&s)
}
}
impl From<&SessionToken> for PathBuf {
fn from(s: &SessionToken) -> Self {
let SessionToken(s) = s;
Self::from(s)
}
}
impl Deref for SessionToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone)]
pub struct AuthDB {
pool: SqlitePool,
}
impl AuthDB {
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
let migrator = sqlx::migrate!("./migrations");
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
migrator.run(&pool).await?;
Ok(Self { pool })
}
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(username.to_string());
let auth_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
.bind(username.to_string())
.bind(auth_token.clone())
.execute(&self.pool)
.await?;
Ok(AuthToken::from(auth_token))
}
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
.fetch_all(&self.pool)
.await?;
Ok(usernames)
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if results.len() > 1 {
return Err(AuthError::DuplicateAuthToken);
}
if results.is_empty() {
return Ok(None);
}
let user_id: i64 = results[0].try_get("id")?;
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(token.to_string());
let session_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
.bind(session_token.clone())
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(Some(SessionToken::from(session_token)))
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
let rows = sqlx::query(
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
)
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if rows.len() > 1 {
return Err(AuthError::DuplicateSessionToken);
}
if rows.is_empty() {
return Ok(None);
}
let username: String = rows[0].try_get("username")?;
Ok(Some(Username::from(username)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use cool_asserts::assert_matches;
use std::collections::HashSet;
#[tokio::test]
async fn can_create_and_list_users() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.list_users().await, Ok(names) => {
let names = names.into_iter().collect::<HashSet<Username>>();
assert!(names.contains(&Username::from("savanni")));
})
}
#[tokio::test]
async fn unknown_auth_token_returns_nothing() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let token = AuthToken::from("0000000000");
assert_matches!(db.authenticate(token).await, Ok(None));
}
#[tokio::test]
async fn auth_token_becomes_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.authenticate(token).await, Ok(_));
}
#[tokio::test]
async fn can_validate_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let session = db
.authenticate(token)
.await
.expect("token authentication should succeed")
.expect("session token should be found");
assert_matches!(
db.validate_session(session).await,
Ok(Some(username)) => {
assert_eq!(username, Username::from("savanni"));
});
}
}

View File

@ -0,0 +1,12 @@
[build]
target = "thumbv6m-none-eabi"
[target.thumbv6m-none-eabi]
rustflags = [
"-C", "link-arg=--nmagic",
"-C", "link-arg=-Tlink.x",
"-C", "inline-threshold=5",
"-C", "no-vectorize-loops",
]
runner = "elf2uf2-rs -d"

View File

@ -0,0 +1,18 @@
[package]
name = "bike"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
az = { version = "1" }
cortex-m-rt = { version = "0.7.3" }
cortex-m = { version = "0.7.7" }
embedded-alloc = { version = "0.5.1" }
embedded-hal = { version = "0.2.7" }
fixed = { version = "1" }
fugit = { version = "0.3.7" }
lights-core = { path = "../core" }
panic-halt = { version = "0.2.0" }
rp-pico = { version = "0.8.0" }

View File

@ -0,0 +1,241 @@
#![no_main]
#![no_std]
extern crate alloc;
use alloc::boxed::Box;
use az::*;
use core::cell::RefCell;
use cortex_m::delay::Delay;
use embedded_alloc::Heap;
use embedded_hal::{blocking::spi::Write, digital::v2::InputPin, digital::v2::OutputPin};
use fixed::types::I16F16;
use fugit::RateExtU32;
use lights_core::{App, BodyPattern, DashboardPattern, Event, Instant, FPS, UI};
use panic_halt as _;
use rp_pico::{
entry,
hal::{
clocks::init_clocks_and_plls,
gpio::{FunctionSio, Pin, PinId, PullDown, PullUp, SioInput, SioOutput},
pac::{CorePeripherals, Peripherals},
spi::{Enabled, Spi, SpiDevice, ValidSpiPinout},
watchdog::Watchdog,
Clock, Sio,
},
Pins,
};
#[global_allocator]
static HEAP: Heap = Heap::empty();
const LIGHT_SCALE: I16F16 = I16F16::lit("256.0");
const DASHBOARD_BRIGHTESS: u8 = 1;
const BODY_BRIGHTNESS: u8 = 8;
struct DebouncedButton<P: PinId> {
debounce: Instant,
pin: Pin<P, FunctionSio<SioInput>, PullUp>,
}
impl<P: PinId> DebouncedButton<P> {
fn new(pin: Pin<P, FunctionSio<SioInput>, PullUp>) -> Self {
Self {
debounce: Instant((0 as u32).into()),
pin,
}
}
fn is_low(&self, time: Instant) -> bool {
if time <= self.debounce {
return false;
}
self.pin.is_low().unwrap_or(false)
}
fn set_debounce(&mut self, time: Instant) {
self.debounce = time + Instant((250 as u32).into());
}
}
struct BikeUI<
D: SpiDevice,
P: ValidSpiPinout<D>,
LeftId: PinId,
RightId: PinId,
PreviousId: PinId,
NextId: PinId,
BrakeId: PinId,
> {
spi: RefCell<Spi<Enabled, D, P, 8>>,
left_blinker_button: DebouncedButton<LeftId>,
right_blinker_button: DebouncedButton<RightId>,
previous_animation_button: DebouncedButton<PreviousId>,
next_animation_button: DebouncedButton<NextId>,
brake_sensor: Pin<BrakeId, FunctionSio<SioInput>, PullUp>,
brake_enabled: bool,
}
impl<
D: SpiDevice,
P: ValidSpiPinout<D>,
LeftId: PinId,
RightId: PinId,
PreviousId: PinId,
NextId: PinId,
BrakeId: PinId,
> BikeUI<D, P, LeftId, RightId, PreviousId, NextId, BrakeId>
{
fn new(
spi: Spi<Enabled, D, P, 8>,
left_blinker_button: Pin<LeftId, FunctionSio<SioInput>, PullUp>,
right_blinker_button: Pin<RightId, FunctionSio<SioInput>, PullUp>,
previous_animation_button: Pin<PreviousId, FunctionSio<SioInput>, PullUp>,
next_animation_button: Pin<NextId, FunctionSio<SioInput>, PullUp>,
brake_sensor: Pin<BrakeId, FunctionSio<SioInput>, PullUp>,
) -> Self {
Self {
spi: RefCell::new(spi),
left_blinker_button: DebouncedButton::new(left_blinker_button),
right_blinker_button: DebouncedButton::new(right_blinker_button),
previous_animation_button: DebouncedButton::new(previous_animation_button),
next_animation_button: DebouncedButton::new(next_animation_button),
brake_sensor,
brake_enabled: false,
}
}
}
impl<
D: SpiDevice,
P: ValidSpiPinout<D>,
LeftId: PinId,
RightId: PinId,
PreviousId: PinId,
NextId: PinId,
BrakeId: PinId,
> UI for BikeUI<D, P, LeftId, RightId, PreviousId, NextId, BrakeId>
{
fn check_event(&mut self, current_time: Instant) -> Option<Event> {
if self.brake_sensor.is_high().unwrap_or(true) && !self.brake_enabled {
self.brake_enabled = true;
Some(Event::Brake)
} else if self.brake_sensor.is_low().unwrap_or(false) && self.brake_enabled {
self.brake_enabled = false;
Some(Event::BrakeRelease)
} else if self.left_blinker_button.is_low(current_time) {
self.left_blinker_button.set_debounce(current_time);
Some(Event::LeftBlinker)
} else if self.right_blinker_button.is_low(current_time) {
self.right_blinker_button.set_debounce(current_time);
Some(Event::RightBlinker)
} else if self.previous_animation_button.is_low(current_time) {
self.previous_animation_button.set_debounce(current_time);
Some(Event::PreviousPattern)
} else if self.next_animation_button.is_low(current_time) {
self.next_animation_button.set_debounce(current_time);
Some(Event::NextPattern)
} else {
None
}
}
fn update_lights(&self, dashboard_lights: DashboardPattern, body_lights: BodyPattern) {
let mut lights: [u8; 260] = [0; 260];
lights[256] = 0xff;
lights[257] = 0xff;
lights[258] = 0xff;
lights[259] = 0xff;
for (idx, rgb) in dashboard_lights.iter().enumerate() {
lights[(idx + 1) * 4 + 0] = 0xe0 + DASHBOARD_BRIGHTESS;
lights[(idx + 1) * 4 + 1] = (I16F16::from(rgb.r) * LIGHT_SCALE).saturating_as();
lights[(idx + 1) * 4 + 2] = (I16F16::from(rgb.b) * LIGHT_SCALE).saturating_as();
lights[(idx + 1) * 4 + 3] = (I16F16::from(rgb.g) * LIGHT_SCALE).saturating_as();
}
for (idx, rgb) in body_lights.iter().enumerate() {
lights[(idx + 4) * 4 + 0] = 0xe0 + BODY_BRIGHTNESS;
lights[(idx + 4) * 4 + 1] = (I16F16::from(rgb.b) * LIGHT_SCALE).saturating_as();
lights[(idx + 4) * 4 + 2] = (I16F16::from(rgb.g) * LIGHT_SCALE).saturating_as();
lights[(idx + 4) * 4 + 3] = (I16F16::from(rgb.r) * LIGHT_SCALE).saturating_as();
}
let mut spi = self.spi.borrow_mut();
spi.write(lights.as_slice());
}
}
#[entry]
fn main() -> ! {
{
use core::mem::MaybeUninit;
const HEAP_SIZE: usize = 8096;
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
}
let mut pac = Peripherals::take().unwrap();
let core = CorePeripherals::take().unwrap();
let sio = Sio::new(pac.SIO);
let mut watchdog = Watchdog::new(pac.WATCHDOG);
let pins = Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let clocks = init_clocks_and_plls(
12_000_000u32,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let mut delay = Delay::new(core.SYST, clocks.system_clock.freq().to_Hz());
let mut spi_clk = pins.gpio10.into_function();
let mut spi_sdo = pins.gpio11.into_function();
let spi = Spi::<_, _, _, 8>::new(pac.SPI1, (spi_sdo, spi_clk));
let mut spi = spi.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
1_u32.MHz(),
embedded_hal::spi::MODE_1,
);
let left_blinker_button = pins.gpio16.into_pull_up_input();
let right_blinker_button = pins.gpio17.into_pull_up_input();
let previous_animation_button = pins.gpio27.into_pull_up_input();
let next_animation_button = pins.gpio26.into_pull_up_input();
let brake_sensor = pins.gpio18.into_pull_up_input();
let mut led_pin = pins.led.into_push_pull_output();
let ui = BikeUI::new(
spi,
left_blinker_button,
right_blinker_button,
previous_animation_button,
next_animation_button,
brake_sensor,
);
let mut app = App::new(Box::new(ui));
led_pin.set_high();
let mut time = Instant::default();
let delay_ms = 1000 / (FPS as u32);
loop {
app.tick(time);
delay.delay_ms(delay_ms);
time = time + Instant(delay_ms.into());
}
}

View File

@ -0,0 +1,10 @@
[package]
name = "lights-core"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
az = { version = "1" }
fixed = { version = "1" }

481
bike-lights/core/src/lib.rs Normal file
View File

@ -0,0 +1,481 @@
#![no_std]
extern crate alloc;
use alloc::boxed::Box;
use az::*;
use core::{
clone::Clone,
cmp::PartialEq,
default::Default,
ops::{Add, Sub},
option::Option,
};
use fixed::types::{I48F16, I8F8, U128F0, U16F0};
mod patterns;
pub use patterns::*;
mod types;
pub use types::{BodyPattern, DashboardPattern, RGB};
fn calculate_frames(starting_time: U128F0, now: U128F0) -> U16F0 {
let frames_128 = (now - starting_time) / U128F0::from(FPS);
(frames_128 % U128F0::from(U16F0::MAX)).cast()
}
fn calculate_slope(start: I8F8, end: I8F8, frames: U16F0) -> I8F8 {
let slope_i16f16 = (I48F16::from(end) - I48F16::from(start)) / I48F16::from(frames);
slope_i16f16.saturating_as()
}
fn linear_ease(value: I8F8, frames: U16F0, slope: I8F8) -> I8F8 {
let value_i16f16 = I48F16::from(value) + I48F16::from(frames) * I48F16::from(slope);
value_i16f16.saturating_as()
}
#[derive(Clone, Copy, PartialOrd, Ord, PartialEq, Eq)]
pub struct Instant(pub U128F0);
impl Default for Instant {
fn default() -> Self {
Self(U128F0::from(0 as u8))
}
}
impl Add for Instant {
type Output = Self;
fn add(self, r: Self) -> Self::Output {
Self(self.0 + r.0)
}
}
impl Sub for Instant {
type Output = Self;
fn sub(self, r: Self) -> Self::Output {
Self(self.0 - r.0)
}
}
pub const FPS: u8 = 30;
pub trait UI {
fn check_event(&mut self, current_time: Instant) -> Option<Event>;
fn update_lights(&self, dashboard_lights: DashboardPattern, body_lights: BodyPattern);
}
pub trait Animation {
fn tick(&mut self, time: Instant) -> (DashboardPattern, BodyPattern);
}
/*
pub struct DefaultAnimation {}
impl Animation for DefaultAnimation {
fn tick(&mut self, _: Instant) -> (DashboardPattern, BodyPattern) {
(WATER_DASHBOARD, WATER_BODY)
}
}
*/
pub struct Fade {
starting_dashboard: DashboardPattern,
starting_lights: BodyPattern,
start_time: Instant,
dashboard_slope: [RGB<I8F8>; 3],
body_slope: [RGB<I8F8>; 60],
frames: U16F0,
}
impl Fade {
fn new(
dashboard: DashboardPattern,
lights: BodyPattern,
ending_dashboard: DashboardPattern,
ending_lights: BodyPattern,
frames: U16F0,
time: Instant,
) -> Self {
let mut dashboard_slope = [Default::default(); 3];
let mut body_slope = [Default::default(); 60];
for i in 0..3 {
let slope = RGB {
r: calculate_slope(dashboard[i].r, ending_dashboard[i].r, frames),
g: calculate_slope(dashboard[i].g, ending_dashboard[i].g, frames),
b: calculate_slope(dashboard[i].b, ending_dashboard[i].b, frames),
};
dashboard_slope[i] = slope;
}
for i in 0..60 {
let slope = RGB {
r: calculate_slope(lights[i].r, ending_lights[i].r, frames),
g: calculate_slope(lights[i].g, ending_lights[i].g, frames),
b: calculate_slope(lights[i].b, ending_lights[i].b, frames),
};
body_slope[i] = slope;
}
Self {
starting_dashboard: dashboard,
starting_lights: lights,
start_time: time,
dashboard_slope,
body_slope,
frames,
}
}
}
impl Animation for Fade {
fn tick(&mut self, time: Instant) -> (DashboardPattern, BodyPattern) {
let mut frames = calculate_frames(self.start_time.0, time.0);
if frames > self.frames {
frames = self.frames
}
let mut dashboard_pattern: DashboardPattern = OFF_DASHBOARD;
let mut body_pattern: BodyPattern = OFF_BODY;
for i in 0..3 {
dashboard_pattern[i].r = linear_ease(
self.starting_dashboard[i].r,
frames,
self.dashboard_slope[i].r,
);
dashboard_pattern[i].g = linear_ease(
self.starting_dashboard[i].g,
frames,
self.dashboard_slope[i].g,
);
dashboard_pattern[i].b = linear_ease(
self.starting_dashboard[i].b,
frames,
self.dashboard_slope[i].b,
);
}
for i in 0..60 {
body_pattern[i].r =
linear_ease(self.starting_lights[i].r, frames, self.body_slope[i].r);
body_pattern[i].g =
linear_ease(self.starting_lights[i].g, frames, self.body_slope[i].g);
body_pattern[i].b =
linear_ease(self.starting_lights[i].b, frames, self.body_slope[i].b);
}
(dashboard_pattern, body_pattern)
}
}
#[derive(Debug)]
pub enum FadeDirection {
Transition,
FadeIn,
FadeOut,
}
pub enum BlinkerDirection {
Left,
Right,
}
pub struct Blinker {
transition: Fade,
fade_in: Fade,
fade_out: Fade,
direction: FadeDirection,
start_time: Instant,
frames: U16F0,
}
impl Blinker {
fn new(
starting_dashboard: DashboardPattern,
starting_body: BodyPattern,
direction: BlinkerDirection,
time: Instant,
) -> Self {
let mut ending_dashboard = OFF_DASHBOARD.clone();
match direction {
BlinkerDirection::Left => {
ending_dashboard[0].r = LEFT_BLINKER_DASHBOARD[0].r;
ending_dashboard[0].g = LEFT_BLINKER_DASHBOARD[0].g;
ending_dashboard[0].b = LEFT_BLINKER_DASHBOARD[0].b;
}
BlinkerDirection::Right => {
ending_dashboard[2].r = RIGHT_BLINKER_DASHBOARD[2].r;
ending_dashboard[2].g = RIGHT_BLINKER_DASHBOARD[2].g;
ending_dashboard[2].b = RIGHT_BLINKER_DASHBOARD[2].b;
}
}
let mut ending_body = OFF_BODY.clone();
match direction {
BlinkerDirection::Left => {
for i in 0..30 {
ending_body[i].r = LEFT_BLINKER_BODY[i].r;
ending_body[i].g = LEFT_BLINKER_BODY[i].g;
ending_body[i].b = LEFT_BLINKER_BODY[i].b;
}
}
BlinkerDirection::Right => {
for i in 30..60 {
ending_body[i].r = RIGHT_BLINKER_BODY[i].r;
ending_body[i].g = RIGHT_BLINKER_BODY[i].g;
ending_body[i].b = RIGHT_BLINKER_BODY[i].b;
}
}
}
Blinker {
transition: Fade::new(
starting_dashboard.clone(),
starting_body.clone(),
ending_dashboard.clone(),
ending_body.clone(),
BLINKER_FRAMES,
time,
),
fade_in: Fade::new(
OFF_DASHBOARD.clone(),
OFF_BODY.clone(),
ending_dashboard.clone(),
ending_body.clone(),
BLINKER_FRAMES,
time,
),
fade_out: Fade::new(
ending_dashboard.clone(),
ending_body.clone(),
OFF_DASHBOARD.clone(),
OFF_BODY.clone(),
BLINKER_FRAMES,
time,
),
direction: FadeDirection::Transition,
start_time: time,
frames: BLINKER_FRAMES,
}
}
}
impl Animation for Blinker {
fn tick(&mut self, time: Instant) -> (DashboardPattern, BodyPattern) {
let frames = calculate_frames(self.start_time.0, time.0);
if frames > self.frames {
match self.direction {
FadeDirection::Transition => {
self.direction = FadeDirection::FadeOut;
self.fade_out.start_time = time;
}
FadeDirection::FadeIn => {
self.direction = FadeDirection::FadeOut;
self.fade_out.start_time = time;
}
FadeDirection::FadeOut => {
self.direction = FadeDirection::FadeIn;
self.fade_in.start_time = time;
}
}
self.start_time = time;
}
match self.direction {
FadeDirection::Transition => self.transition.tick(time),
FadeDirection::FadeIn => self.fade_in.tick(time),
FadeDirection::FadeOut => self.fade_out.tick(time),
}
}
}
#[derive(Clone, Debug)]
pub enum Event {
Brake,
BrakeRelease,
LeftBlinker,
NextPattern,
PreviousPattern,
RightBlinker,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pattern {
Water,
GayPride,
TransPride,
}
impl Pattern {
fn previous(&self) -> Pattern {
match self {
Pattern::Water => Pattern::TransPride,
Pattern::GayPride => Pattern::Water,
Pattern::TransPride => Pattern::GayPride,
}
}
fn next(&self) -> Pattern {
match self {
Pattern::Water => Pattern::GayPride,
Pattern::GayPride => Pattern::TransPride,
Pattern::TransPride => Pattern::Water,
}
}
fn dashboard(&self) -> DashboardPattern {
match self {
Pattern::Water => WATER_DASHBOARD,
Pattern::GayPride => PRIDE_DASHBOARD,
Pattern::TransPride => TRANS_PRIDE_DASHBOARD,
}
}
fn body(&self) -> BodyPattern {
match self {
Pattern::Water => OFF_BODY,
Pattern::GayPride => PRIDE_BODY,
Pattern::TransPride => TRANS_PRIDE_BODY,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum State {
Pattern(Pattern),
Brake,
LeftBlinker,
RightBlinker,
BrakeLeftBlinker,
BrakeRightBlinker,
}
pub struct App {
ui: Box<dyn UI>,
state: State,
home_pattern: Pattern,
current_animation: Box<dyn Animation>,
dashboard_lights: DashboardPattern,
lights: BodyPattern,
}
impl App {
pub fn new(ui: Box<dyn UI>) -> Self {
let pattern = Pattern::Water;
Self {
ui,
state: State::Pattern(pattern),
home_pattern: pattern,
current_animation: Box::new(Fade::new(
OFF_DASHBOARD,
OFF_BODY,
pattern.dashboard(),
pattern.body(),
DEFAULT_FRAMES,
Instant((0 as u32).into()),
)),
dashboard_lights: OFF_DASHBOARD,
lights: OFF_BODY,
}
}
fn update_animation(&mut self, time: Instant) {
match self.state {
State::Pattern(ref pattern) => {
self.current_animation = Box::new(Fade::new(
self.dashboard_lights.clone(),
self.lights.clone(),
pattern.dashboard(),
pattern.body(),
DEFAULT_FRAMES,
time,
))
}
State::Brake => {
self.current_animation = Box::new(Fade::new(
self.dashboard_lights.clone(),
self.lights.clone(),
BRAKES_DASHBOARD,
BRAKES_BODY,
BRAKES_FRAMES,
time,
));
}
State::LeftBlinker => {
self.current_animation = Box::new(Blinker::new(
self.dashboard_lights.clone(),
self.lights.clone(),
BlinkerDirection::Left,
time,
));
}
State::RightBlinker => {
self.current_animation = Box::new(Blinker::new(
self.dashboard_lights.clone(),
self.lights.clone(),
BlinkerDirection::Right,
time,
));
}
State::BrakeLeftBlinker => (),
State::BrakeRightBlinker => (),
}
}
fn update_state(&mut self, event: Event) {
match event {
Event::Brake => {
if self.state == State::Brake {
self.state = State::Pattern(self.home_pattern);
} else {
self.state = State::Brake;
}
}
Event::BrakeRelease => self.state = State::Pattern(self.home_pattern),
Event::LeftBlinker => match self.state {
State::Brake => self.state = State::BrakeLeftBlinker,
State::BrakeLeftBlinker => self.state = State::Brake,
State::LeftBlinker => self.state = State::Pattern(self.home_pattern),
_ => self.state = State::LeftBlinker,
},
Event::NextPattern => match self.state {
State::Pattern(ref pattern) => {
self.home_pattern = pattern.next();
self.state = State::Pattern(self.home_pattern);
}
_ => (),
},
Event::PreviousPattern => match self.state {
State::Pattern(ref pattern) => {
self.home_pattern = pattern.previous();
self.state = State::Pattern(self.home_pattern);
}
_ => (),
},
Event::RightBlinker => match self.state {
State::Brake => self.state = State::BrakeRightBlinker,
State::BrakeRightBlinker => self.state = State::Brake,
State::RightBlinker => self.state = State::Pattern(self.home_pattern),
_ => self.state = State::RightBlinker,
},
}
}
pub fn tick(&mut self, time: Instant) {
match self.ui.check_event(time) {
Some(event) => {
self.update_state(event);
self.update_animation(time);
}
None => {}
};
let (dashboard, lights) = self.current_animation.tick(time);
self.dashboard_lights = dashboard.clone();
self.lights = lights.clone();
self.ui.update_lights(dashboard, lights);
}
}

View File

@ -0,0 +1,333 @@
use crate::{BodyPattern, DashboardPattern, RGB};
use fixed::types::{I8F8, U16F0};
pub const RGB_OFF: RGB<I8F8> = RGB {
r: I8F8::lit("0"),
g: I8F8::lit("0"),
b: I8F8::lit("0"),
};
pub const RGB_WHITE: RGB<I8F8> = RGB {
r: I8F8::lit("1"),
g: I8F8::lit("1"),
b: I8F8::lit("1"),
};
pub const BRAKES_RED: RGB<I8F8> = RGB {
r: I8F8::lit("1"),
g: I8F8::lit("0"),
b: I8F8::lit("0"),
};
pub const BLINKER_AMBER: RGB<I8F8> = RGB {
r: I8F8::lit("1"),
g: I8F8::lit("0.15"),
b: I8F8::lit("0"),
};
pub const PRIDE_RED: RGB<I8F8> = RGB {
r: I8F8::lit("0.95"),
g: I8F8::lit("0.00"),
b: I8F8::lit("0.00"),
};
pub const PRIDE_ORANGE: RGB<I8F8> = RGB {
r: I8F8::lit("1.0"),
g: I8F8::lit("0.25"),
b: I8F8::lit("0"),
};
pub const PRIDE_YELLOW: RGB<I8F8> = RGB {
r: I8F8::lit("1.0"),
g: I8F8::lit("0.85"),
b: I8F8::lit("0"),
};
pub const PRIDE_GREEN: RGB<I8F8> = RGB {
r: I8F8::lit("0"),
g: I8F8::lit("0.95"),
b: I8F8::lit("0.05"),
};
pub const PRIDE_INDIGO: RGB<I8F8> = RGB {
r: I8F8::lit("0.04"),
g: I8F8::lit("0.15"),
b: I8F8::lit("0.55"),
};
pub const PRIDE_VIOLET: RGB<I8F8> = RGB {
r: I8F8::lit("0.75"),
g: I8F8::lit("0.0"),
b: I8F8::lit("0.80"),
};
pub const TRANS_BLUE: RGB<I8F8> = RGB {
r: I8F8::lit("0.06"),
g: I8F8::lit("0.41"),
b: I8F8::lit("0.98"),
};
pub const TRANS_PINK: RGB<I8F8> = RGB {
r: I8F8::lit("0.96"),
g: I8F8::lit("0.16"),
b: I8F8::lit("0.32"),
};
pub const WATER_1: RGB<I8F8> = RGB {
r: I8F8::lit("0.0"),
g: I8F8::lit("0.0"),
b: I8F8::lit("0.75"),
};
pub const WATER_2: RGB<I8F8> = RGB {
r: I8F8::lit("0.8"),
g: I8F8::lit("0.8"),
b: I8F8::lit("0.8"),
};
pub const WATER_3: RGB<I8F8> = RGB {
r: I8F8::lit("0.00"),
g: I8F8::lit("0.75"),
b: I8F8::lit("0.75"),
};
pub const OFF_DASHBOARD: DashboardPattern = [RGB_OFF; 3];
pub const OFF_BODY: BodyPattern = [RGB_OFF; 60];
pub const DEFAULT_FRAMES: U16F0 = U16F0::lit("30");
pub const WATER_DASHBOARD: DashboardPattern = [WATER_1, WATER_2, WATER_3];
pub const WATER_BODY: BodyPattern = [RGB_OFF; 60];
pub const PRIDE_DASHBOARD: DashboardPattern = [PRIDE_RED, PRIDE_GREEN, PRIDE_INDIGO];
pub const PRIDE_BODY: BodyPattern = [
// Left Side
// Red
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
// Orange
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
// Yellow
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
// Green
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
// Indigo
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
// Violet
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
// Right Side
// Violet
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
PRIDE_VIOLET,
// Indigo
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
PRIDE_INDIGO,
// Green
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
PRIDE_GREEN,
// Yellow
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
PRIDE_YELLOW,
// Orange
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
PRIDE_ORANGE,
// Red
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
PRIDE_RED,
];
pub const TRANS_PRIDE_DASHBOARD: DashboardPattern = [TRANS_BLUE, RGB_WHITE, TRANS_PINK];
pub const TRANS_PRIDE_BODY: BodyPattern = [
// Left Side
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_PINK, TRANS_PINK,
TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, RGB_WHITE, RGB_WHITE, RGB_WHITE, RGB_WHITE,
RGB_WHITE, RGB_WHITE, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK,
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE,
// Right side
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_PINK, TRANS_PINK,
TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, RGB_WHITE, RGB_WHITE, RGB_WHITE, RGB_WHITE,
RGB_WHITE, RGB_WHITE, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK, TRANS_PINK,
TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE, TRANS_BLUE,
];
pub const BRAKES_FRAMES: U16F0 = U16F0::lit("15");
pub const BRAKES_DASHBOARD: DashboardPattern = [BRAKES_RED; 3];
pub const BRAKES_BODY: BodyPattern = [BRAKES_RED; 60];
pub const BLINKER_FRAMES: U16F0 = U16F0::lit("10");
pub const LEFT_BLINKER_DASHBOARD: DashboardPattern = [BLINKER_AMBER, RGB_OFF, RGB_OFF];
pub const LEFT_BLINKER_BODY: BodyPattern = [
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
];
pub const RIGHT_BLINKER_DASHBOARD: DashboardPattern = [RGB_OFF, RGB_OFF, BLINKER_AMBER];
pub const RIGHT_BLINKER_BODY: BodyPattern = [
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
RGB_OFF,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
BLINKER_AMBER,
];

View File

@ -0,0 +1,17 @@
use core::default::Default;
use fixed::types::I8F8;
#[derive(Clone, Copy, Default, Debug)]
pub struct RGB<T> {
pub r: T,
pub g: T,
pub b: T,
}
const DASHBOARD_LIGHT_COUNT: usize = 3;
pub type DashboardPattern = [RGB<I8F8>; DASHBOARD_LIGHT_COUNT];
const BODY_LIGHT_COUNT: usize = 60;
pub type BodyPattern = [RGB<I8F8>; BODY_LIGHT_COUNT];

View File

@ -0,0 +1,16 @@
[package]
name = "simulator"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.18" }
fixed = { version = "1" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_8" ] }
lights-core = { path = "../core" }
pango = { version = "*" }

View File

@ -0,0 +1,288 @@
use adw::prelude::*;
use fixed::types::{I8F8, U128F0};
use glib::{Object, Sender};
use gtk::subclass::prelude::*;
use lights_core::{
App, BodyPattern, DashboardPattern, Event, Instant, FPS, OFF_BODY, OFF_DASHBOARD, RGB, UI,
};
use std::{
cell::RefCell,
env,
rc::Rc,
sync::mpsc::{Receiver, TryRecvError},
};
const WIDTH: i32 = 640;
const HEIGHT: i32 = 480;
pub struct Update {
dashboard: DashboardPattern,
lights: BodyPattern,
}
pub struct DashboardLightsPrivate {
lights: Rc<RefCell<DashboardPattern>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DashboardLightsPrivate {
const NAME: &'static str = "DashboardLights";
type Type = DashboardLights;
type ParentType = gtk::DrawingArea;
fn new() -> Self {
Self {
lights: Rc::new(RefCell::new(OFF_DASHBOARD)),
}
}
}
impl ObjectImpl for DashboardLightsPrivate {}
impl WidgetImpl for DashboardLightsPrivate {}
impl DrawingAreaImpl for DashboardLightsPrivate {}
glib::wrapper! {
pub struct DashboardLights(ObjectSubclass<DashboardLightsPrivate>) @extends gtk::DrawingArea, gtk::Widget;
}
impl DashboardLights {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(WIDTH);
s.set_height_request(100);
s.set_draw_func({
let s = s.clone();
move |_, context, width, _| {
let start = width as f64 / 2. - 150.;
let lights = s.imp().lights.borrow();
for i in 0..3 {
context.set_source_rgb(
lights[i].r.into(),
lights[i].g.into(),
lights[i].b.into(),
);
context.rectangle(start + 100. * i as f64, 10., 80., 80.);
let _ = context.fill();
}
}
});
s
}
pub fn set_lights(&self, lights: DashboardPattern) {
*self.imp().lights.borrow_mut() = lights;
self.queue_draw();
}
}
pub struct BikeLightsPrivate {
lights: Rc<RefCell<BodyPattern>>,
}
#[glib::object_subclass]
impl ObjectSubclass for BikeLightsPrivate {
const NAME: &'static str = "BikeLights";
type Type = BikeLights;
type ParentType = gtk::DrawingArea;
fn new() -> Self {
Self {
lights: Rc::new(RefCell::new(OFF_BODY)),
}
}
}
impl ObjectImpl for BikeLightsPrivate {}
impl WidgetImpl for BikeLightsPrivate {}
impl DrawingAreaImpl for BikeLightsPrivate {}
glib::wrapper! {
pub struct BikeLights(ObjectSubclass<BikeLightsPrivate>) @extends gtk::DrawingArea, gtk::Widget;
}
impl BikeLights {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(WIDTH);
s.set_height_request(640);
let center = WIDTH as f64 / 2.;
s.set_draw_func({
let s = s.clone();
move |_, context, _, _| {
let lights = s.imp().lights.borrow();
for i in 0..30 {
context.set_source_rgb(
lights[i].r.into(),
lights[i].g.into(),
lights[i].b.into(),
);
context.rectangle(center - 45., 5. + 20. * i as f64, 15., 15.);
let _ = context.fill();
}
for i in 0..30 {
context.set_source_rgb(
lights[i + 30].r.into(),
lights[i + 30].g.into(),
lights[i + 30].b.into(),
);
context.rectangle(center + 15., 5. + 20. * (30. - (i + 1) as f64), 15., 15.);
let _ = context.fill();
}
}
});
s
}
pub fn set_lights(&self, lights: [RGB<I8F8>; 60]) {
*self.imp().lights.borrow_mut() = lights;
self.queue_draw();
}
}
struct GTKUI {
tx: Sender<Update>,
rx: Receiver<Event>,
}
impl UI for GTKUI {
fn check_event(&mut self, _: Instant) -> Option<Event> {
match self.rx.try_recv() {
Ok(event) => Some(event),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => None,
}
}
fn update_lights(&self, dashboard_lights: DashboardPattern, lights: BodyPattern) {
self.tx
.send(Update {
dashboard: dashboard_lights,
lights,
})
.unwrap();
}
}
fn main() {
let adw_app = adw::Application::builder()
.application_id("com.luminescent-dreams.bike-light-simulator")
.build();
adw_app.connect_activate(move |adw_app| {
let (update_tx, update_rx) =
gtk::glib::MainContext::channel::<Update>(gtk::glib::Priority::DEFAULT);
let (event_tx, event_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut bike_app = App::new(Box::new(GTKUI {
tx: update_tx,
rx: event_rx,
}));
loop {
bike_app.tick(Instant(U128F0::from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis(),
)));
std::thread::sleep(std::time::Duration::from_millis(1000 / (FPS as u64)));
}
});
let window = adw::ApplicationWindow::builder()
.application(adw_app)
.default_width(WIDTH)
.default_height(HEIGHT)
.build();
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
let controls = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let dashboard_lights = DashboardLights::new();
let bike_lights = BikeLights::new();
let left_button = gtk::Button::builder().label("L").build();
let brake_button = gtk::Button::builder().label("Brakes").build();
let right_button = gtk::Button::builder().label("R").build();
left_button.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::LeftBlinker);
}
});
brake_button.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::Brake);
}
});
right_button.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::RightBlinker);
}
});
controls.append(&left_button);
controls.append(&brake_button);
controls.append(&right_button);
layout.append(&controls);
let pattern_controls = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let previous_pattern = gtk::Button::builder().label("Previous").build();
let next_pattern = gtk::Button::builder().label("Next").build();
previous_pattern.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::PreviousPattern);
}
});
next_pattern.connect_clicked({
let event_tx = event_tx.clone();
move |_| {
let _ = event_tx.send(Event::NextPattern);
}
});
pattern_controls.append(&previous_pattern);
pattern_controls.append(&next_pattern);
layout.append(&pattern_controls);
layout.append(&dashboard_lights);
layout.append(&bike_lights);
update_rx.attach(None, {
let dashboard_lights = dashboard_lights.clone();
let bike_lights = bike_lights.clone();
move |Update { dashboard, lights }| {
dashboard_lights.set_lights(dashboard);
bike_lights.set_lights(lights);
glib::ControlFlow::Continue
}
});
window.set_content(Some(&layout));
window.present();
});
let args: Vec<String> = env::args().collect();
ApplicationExtManual::run_with_args(&adw_app, &args);
}

View File

@ -1,68 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RUST_ALL_TARGETS=(
"changeset"
"config"
"config-derive"
"coordinates"
"cyberpunk-splash"
"dashboard"
"emseries"
"flow"
"fluent-ergonomics"
"geo-types"
"gm-control-panel"
"hex-grid"
"ifc"
"kifu-core"
"kifu-gtk"
"memorycache"
"screenplay"
"sgf"
)
build_rust_targets() {
local CMD=$1
local TARGETS=${@/$CMD}
for target in $TARGETS; do
MODULE=$target CMD=$CMD ./builders/rust.sh
done
}
build_dist() {
local TARGETS=${@/$CMD}
for target in $TARGETS; do
if [ -f $target/dist.sh ]; then
cd $target && ./dist.sh
fi
done
}
export CARGO=`which cargo`
if [ -z "${TARGET-}" ]; then
TARGET="all"
fi
if [ -z "${CMD-}" ]; then
CMD="test release"
fi
if [ "${CMD}" == "clean" ]; then
cargo clean
exit 0
fi
for cmd in $CMD; do
if [ "${CMD}" == "dist" ]; then
build_dist $TARGET
elif [ "${TARGET}" == "all" ]; then
build_rust_targets $cmd ${RUST_ALL_TARGETS[*]}
else
build_rust_targets $cmd $TARGET
fi
done

View File

@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ ! -z "$MODULE" ]; then
MODULE="-p $MODULE"
fi
if [ -z "${PARAMS-}" ]; then
PARAMS=""
fi
case $CMD in
build)
$CARGO build $MODULE $PARAMS
;;
test)
$CARGO test $MODULE $PARAMS
;;
run)
$CARGO run $MODULE $PARAMS
;;
release)
$CARGO build --release $MODULE $PARAMS
;;
clean)
$CARGO clean $MODULE
;;
"")
echo "No command specified. Use build | test | run | release | clean"
;;
*)
echo "$CMD is unknown. Use build | test | run | release | clean"
;;
esac

View File

@ -3,7 +3,6 @@ name = "changeset"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-only"
license-file = "../COPYING"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -26,7 +26,7 @@ pub enum Change<Key: Eq + Hash, Value> {
NewRecord(Value),
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
pub struct Changeset<Key: Clone + Eq + Hash, Value> {
delete: HashSet<Key>,
update: HashMap<Key, Value>,
@ -34,14 +34,6 @@ pub struct Changeset<Key: Clone + Eq + Hash, Value> {
}
impl<Key: Clone + Constructable + Eq + Hash, Value> Changeset<Key, Value> {
pub fn new() -> Self {
Self {
delete: HashSet::new(),
update: HashMap::new(),
new: HashMap::new(),
}
}
pub fn add(&mut self, r: Value) -> Key {
let k = Key::new();
self.new.insert(k.clone(), r);
@ -90,7 +82,7 @@ impl<Key: Clone + Eq + Hash, Value> From<Changeset<Key, Value>> for Vec<Change<K
.into_iter()
.map(|(k, v)| Change::UpdateRecord((k, v))),
)
.chain(new.into_iter().map(|(_, v)| Change::NewRecord(v)))
.chain(new.into_values().map(|v| Change::NewRecord(v)))
.collect()
}
}
@ -100,7 +92,7 @@ mod tests {
use super::*;
use uuid::Uuid;
#[derive(Clone, PartialEq, Eq, Hash)]
#[derive(Clone, PartialEq, Eq, Hash, Default)]
struct Id(Uuid);
impl Constructable for Id {
fn new() -> Self {
@ -110,7 +102,7 @@ mod tests {
#[test]
fn it_generates_a_new_record() {
let mut set: Changeset<Id, String> = Changeset::new();
let mut set: Changeset<Id, String> = Changeset::default();
set.add("efgh".to_string());
let changes = Vec::from(set.clone());
assert_eq!(changes.len(), 1);
@ -125,7 +117,7 @@ mod tests {
#[test]
fn it_generates_a_delete_record() {
let mut set: Changeset<Id, String> = Changeset::new();
let mut set: Changeset<Id, String> = Changeset::default();
let id1 = Id::new();
set.delete(id1.clone());
let changes = Vec::from(set.clone());
@ -142,7 +134,7 @@ mod tests {
#[test]
fn update_unrelated_records() {
let mut set: Changeset<Id, String> = Changeset::new();
let mut set: Changeset<Id, String> = Changeset::default();
let id1 = Id::new();
let id2 = Id::new();
set.update(id1.clone(), "abcd".to_owned());
@ -155,7 +147,7 @@ mod tests {
#[test]
fn delete_cancels_new() {
let mut set: Changeset<Id, String> = Changeset::new();
let mut set: Changeset<Id, String> = Changeset::default();
let key = set.add("efgh".to_string());
set.delete(key);
let changes = Vec::from(set);
@ -164,7 +156,7 @@ mod tests {
#[test]
fn delete_cancels_update() {
let mut set: Changeset<Id, String> = Changeset::new();
let mut set: Changeset<Id, String> = Changeset::default();
let id = Id::new();
set.update(id.clone(), "efgh".to_owned());
set.delete(id.clone());
@ -175,7 +167,7 @@ mod tests {
#[test]
fn update_atop_new_is_new() {
let mut set: Changeset<Id, String> = Changeset::new();
let mut set: Changeset<Id, String> = Changeset::default();
let key = set.add("efgh".to_owned());
set.update(key, "wxyz".to_owned());
let changes = Vec::from(set);
@ -185,7 +177,7 @@ mod tests {
#[test]
fn updates_get_squashed() {
let mut set: Changeset<Id, String> = Changeset::new();
let mut set: Changeset<Id, String> = Changeset::default();
let id1 = Id::new();
let id2 = Id::new();
set.update(id1.clone(), "efgh".to_owned());

View File

@ -35,7 +35,7 @@ macro_rules! define_config {
$($name($struct)),+
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Config {
values: std::collections::HashMap<ConfigName, ConfigOption>,
}

View File

@ -33,12 +33,12 @@ fn main() {
let filename = args
.next()
.map(|p| PathBuf::from(p))
.map(PathBuf::from)
.expect("A filename is required");
let size = args
.next()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(3);
let map: hex_map::Map<MapVal> = hex_map::Map::new_hexagonal(size);
hex_map::write_file(filename, map);
hex_map::write_file(filename, map).expect("to write file");
}

View File

@ -10,10 +10,9 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
/// Ĉi-tiu modulo enhavas la elementojn por kub-koordinato.
/// This module contains the elements of cube coordinates.
///
/// This code is based on https://www.redblobgames.com/grids/hexagons/
use crate::Error;
use std::collections::HashSet;
/// An address within the hex coordinate system
@ -62,7 +61,7 @@ impl AxialAddr {
pub fn is_adjacent(&self, dest: &AxialAddr) -> bool {
dest.adjacencies()
.collect::<Vec<AxialAddr>>()
.contains(&self)
.contains(self)
}
/// Measure the distance to a destination
@ -79,7 +78,7 @@ impl AxialAddr {
positions.push(item);
while positions.len() > 0 {
while !positions.is_empty() {
let elem = positions.remove(0);
for adj in elem.adjacencies() {
if self.distance(&adj) <= distance && !results.contains(&adj) {

View File

@ -14,7 +14,6 @@ use crate::{hex::AxialAddr, Error};
use nom::{
bytes::complete::tag,
character::complete::alphanumeric1,
error::ParseError,
multi::many1,
sequence::{delimited, separated_pair},
Finish, IResult, Parser,
@ -81,7 +80,7 @@ pub fn parse_data<'a, A: Default + From<String>>(
}
let cells = data
.map(|line| parse_line::<A>(&line).unwrap())
.map(|line| parse_line::<A>(line).unwrap())
.collect::<Vec<(AxialAddr, A)>>();
let cells = cells.into_iter().collect::<HashMap<AxialAddr, A>>();
Map { cells }

View File

@ -9,9 +9,9 @@ Lumeto is distributed in the hope that it will be useful, but WITHOUT ANY WARRAN
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
use thiserror;
use thiserror::Error;
#[derive(Debug, thiserror::Error)]
#[derive(Debug, Error)]
pub enum Error {
#[error("IO error on reading or writing: {0}")]
IO(std::io::Error),

474
crate-hashes.json Normal file
View File

@ -0,0 +1,474 @@
{
"registry+https://github.com/rust-lang/crates.io-index#addr2line@0.21.0": "1jx0k3iwyqr8klqbzk6kjvr496yd94aspis10vwsj5wy7gib4c4a",
"registry+https://github.com/rust-lang/crates.io-index#adler32@1.2.0": "0d7jq7jsjyhsgbhnfq5fvrlh9j0i9g1fqrl2735ibv5f75yjgqda",
"registry+https://github.com/rust-lang/crates.io-index#adler@1.0.2": "1zim79cvzd5yrkzl3nyfx0avijwgk9fqv3yrscdy1cc79ih02qpj",
"registry+https://github.com/rust-lang/crates.io-index#ahash@0.8.6": "0yn9i8nc6mmv28ig9w3dga571q09vg9f1f650mi5z8phx42r6hli",
"registry+https://github.com/rust-lang/crates.io-index#aho-corasick@1.1.2": "1w510wnixvlgimkx1zjbvlxh6xps2vjgfqgwf5a6adlbjp5rv5mj",
"registry+https://github.com/rust-lang/crates.io-index#allocator-api2@0.2.16": "1iayppgq4wqbfbfcqmsbwgamj0s65012sskfvyx07pxavk3gyhh9",
"registry+https://github.com/rust-lang/crates.io-index#android-tzdata@0.1.1": "1w7ynjxrfs97xg3qlcdns4kgfpwcdv824g611fq32cag4cdr96g9",
"registry+https://github.com/rust-lang/crates.io-index#android_system_properties@0.1.5": "04b3wrz12837j7mdczqd95b732gw5q7q66cv4yn4646lvccp57l1",
"registry+https://github.com/rust-lang/crates.io-index#anstream@0.6.5": "1dm1mdbs1x6y3m3pz0qlamgiskb50i4q859676kx0pz8r8pajr6n",
"registry+https://github.com/rust-lang/crates.io-index#anstyle-parse@0.2.3": "134jhzrz89labrdwxxnjxqjdg06qvaflj1wkfnmyapwyldfwcnn7",
"registry+https://github.com/rust-lang/crates.io-index#anstyle-query@1.0.2": "0j3na4b1nma39g4x7cwvj009awxckjf3z2vkwhldgka44hqj72g2",
"registry+https://github.com/rust-lang/crates.io-index#anstyle-wincon@3.0.2": "19v0fv400bmp4niqpzxnhg83vz12mmqv7l2l8vi80qcdxj0lpm8w",
"registry+https://github.com/rust-lang/crates.io-index#anstyle@1.0.4": "11yxw02b6parn29s757z96rgiqbn8qy0fk9a3p3bhczm85dhfybh",
"registry+https://github.com/rust-lang/crates.io-index#anyhow@1.0.75": "1rmcjkim91c5mw7h9wn8nv0k6x118yz0xg0z1q18svgn42mqqrm4",
"registry+https://github.com/rust-lang/crates.io-index#async-channel@1.9.0": "0dbdlkzlncbibd3ij6y6jmvjd0cmdn48ydcfdpfhw09njd93r5c1",
"registry+https://github.com/rust-lang/crates.io-index#async-channel@2.1.1": "1337ywc1paw03rdlwh100kh8pa0zyp0nrlya8bpsn6zdqi5kz8qw",
"registry+https://github.com/rust-lang/crates.io-index#async-executor@1.8.0": "0z7rpayidhdqs4sdzjhh26z5155c1n94fycqni9793n4zjz5xbhp",
"registry+https://github.com/rust-lang/crates.io-index#async-global-executor@2.4.1": "1762s45cc134d38rrv0hyp41hv4iv6nmx59vswid2p0il8rvdc85",
"registry+https://github.com/rust-lang/crates.io-index#async-io@1.13.0": "1byj7lpw0ahk6k63sbc9859v68f28hpaab41dxsjj1ggjdfv9i8g",
"registry+https://github.com/rust-lang/crates.io-index#async-io@2.3.1": "0rggn074kbqxxajci1aq14b17gp75rw9l6rpbazcv9q0bc6ap5wg",
"registry+https://github.com/rust-lang/crates.io-index#async-lock@2.8.0": "0asq5xdzgp3d5m82y5rg7a0k9q0g95jy6mgc7ivl334x7qlp4wi8",
"registry+https://github.com/rust-lang/crates.io-index#async-lock@3.3.0": "0yxflkfw46rad4lv86f59b5z555dlfmg1riz1n8830rgi0qb8d6h",
"registry+https://github.com/rust-lang/crates.io-index#async-std@1.12.0": "0pbgxhyb97h4n0451r26njvr20ywqsbm6y1wjllnp4if82s5nmk2",
"registry+https://github.com/rust-lang/crates.io-index#async-task@4.7.0": "16975vx6aqy5yf16fs9xz5vx1zq8mwkzfmykvcilc1j7b6c6xczv",
"registry+https://github.com/rust-lang/crates.io-index#async-trait@0.1.77": "1adf1jh2yg39rkpmqjqyr9xyd6849p0d95425i6imgbhx0syx069",
"registry+https://github.com/rust-lang/crates.io-index#atoi@2.0.0": "0a05h42fggmy7h0ajjv6m7z72l924i7igbx13hk9d8pyign9k3gj",
"registry+https://github.com/rust-lang/crates.io-index#atomic-waker@1.1.2": "1h5av1lw56m0jf0fd3bchxq8a30xv0b4wv8s4zkp4s0i7mfvs18m",
"registry+https://github.com/rust-lang/crates.io-index#atomic-write-file@0.1.2": "0dl4x0srdwjxm3zz3fj1c7m44i3b7mjiad550fqklj1n4bfbxkgd",
"registry+https://github.com/rust-lang/crates.io-index#autocfg@0.1.8": "0y4vw4l4izdxq1v0rrhvmlbqvalrqrmk60v1z0dqlgnlbzkl7phd",
"registry+https://github.com/rust-lang/crates.io-index#autocfg@1.1.0": "1ylp3cb47ylzabimazvbz9ms6ap784zhb6syaz6c1jqpmcmq0s6l",
"registry+https://github.com/rust-lang/crates.io-index#backtrace@0.3.69": "0dsq23dhw4pfndkx2nsa1ml2g31idm7ss7ljxp8d57avygivg290",
"registry+https://github.com/rust-lang/crates.io-index#base64@0.21.5": "1y8x2xs9nszj5ix7gg4ycn5a6wy7ca74zxwqri3bdqzdjha6lqrm",
"registry+https://github.com/rust-lang/crates.io-index#base64@0.9.3": "0hs62r35bgxslawyrn1vp9rmvrkkm76fqv0vqcwd048vs876r7a8",
"registry+https://github.com/rust-lang/crates.io-index#base64ct@1.6.0": "0nvdba4jb8aikv60az40x2w1y96sjdq8z3yp09rwzmkhiwv1lg4c",
"registry+https://github.com/rust-lang/crates.io-index#bit-set@0.5.3": "1wcm9vxi00ma4rcxkl3pzzjli6ihrpn9cfdi0c5b4cvga2mxs007",
"registry+https://github.com/rust-lang/crates.io-index#bit-vec@0.6.3": "1ywqjnv60cdh1slhz67psnp422md6jdliji6alq0gmly2xm9p7rl",
"registry+https://github.com/rust-lang/crates.io-index#bit_field@0.10.2": "0qav5rpm4hqc33vmf4vc4r0mh51yjx5vmd9zhih26n9yjs3730nw",
"registry+https://github.com/rust-lang/crates.io-index#bitflags@1.3.2": "12ki6w8gn1ldq7yz9y680llwk5gmrhrzszaa17g1sbrw2r2qvwxy",
"registry+https://github.com/rust-lang/crates.io-index#bitflags@2.4.1": "01ryy3kd671b0ll4bhdvhsz67vwz1lz53fz504injrd7wpv64xrj",
"registry+https://github.com/rust-lang/crates.io-index#block-buffer@0.10.4": "0w9sa2ypmrsqqvc20nhwr75wbb5cjr4kkyhpjm1z1lv2kdicfy1h",
"registry+https://github.com/rust-lang/crates.io-index#blocking@1.5.1": "064i3d6b8ln34fgdw49nmx9m36bwi3r3nv8c9xhcrpf4ilz92dva",
"registry+https://github.com/rust-lang/crates.io-index#build_html@2.4.0": "188nibbsv33vgjjiq9cn2irsgdb75gxfipavcavnyydcwxpzw21i",
"registry+https://github.com/rust-lang/crates.io-index#bumpalo@3.14.0": "1v4arnv9kwk54v5d0qqpv4vyw2sgr660nk0w3apzixi1cm3yfc3z",
"registry+https://github.com/rust-lang/crates.io-index#bytemuck@1.14.0": "1ik1ma5n3bg700skkzhx50zjk7kj7mbsphi773if17l04pn2hk9p",
"registry+https://github.com/rust-lang/crates.io-index#byteorder@1.5.0": "0jzncxyf404mwqdbspihyzpkndfgda450l0893pz5xj685cg5l0z",
"registry+https://github.com/rust-lang/crates.io-index#bytes@1.5.0": "08w2i8ac912l8vlvkv3q51cd4gr09pwlg3sjsjffcizlrb0i5gd2",
"registry+https://github.com/rust-lang/crates.io-index#cairo-rs@0.18.3": "18d80lk853bjhx36rjaj78clzfjrmlgi01863drnmshdgxi16dpk",
"registry+https://github.com/rust-lang/crates.io-index#cairo-sys-rs@0.18.2": "0lfsxl7ylw3phbnwmz3k58j1gnqi6kc2hdc7g3bb7f4hwnl9yp38",
"registry+https://github.com/rust-lang/crates.io-index#cc@1.0.83": "1l643zidlb5iy1dskc5ggqs4wqa29a02f44piczqc8zcnsq4y5zi",
"registry+https://github.com/rust-lang/crates.io-index#cfg-expr@0.15.5": "1cqicd9qi8mzzgh63dw03zhbdihqfl3lbiklrkynyzkq67s5m483",
"registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.0": "1za0vb97n4brpzpv8lsbnzmq5r8f2b0cpqqr0sy8h5bn751xxwds",
"registry+https://github.com/rust-lang/crates.io-index#chrono-tz-build@0.2.1": "03rmzd69cn7fp0fgkjr5042b3g54s2l941afjm3001ls7kqkjgj3",
"registry+https://github.com/rust-lang/crates.io-index#chrono-tz@0.8.4": "0xhd3dsfs72im0sbc7w889lfy7bxgjlbvqhj5a1yvxhxwb08acg2",
"registry+https://github.com/rust-lang/crates.io-index#chrono@0.4.31": "0f6vg67pipm8cziad2yms6a639pssnvysk1m05dd9crymmdnhb3z",
"registry+https://github.com/rust-lang/crates.io-index#clap@4.4.11": "1wj5gb2fnqls00zfahg3490bdfc36d9cwpl80qjacb5jyrqzdbxz",
"registry+https://github.com/rust-lang/crates.io-index#clap_builder@4.4.11": "1fxdsmw1ilgswz3lg2hjlvsdyyz04k78scjirlbd7c9bc83ba5m2",
"registry+https://github.com/rust-lang/crates.io-index#clap_derive@4.4.7": "0hk4hcxl56qwqsf4hmf7c0gr19r9fbxk0ah2bgkr36pmmaph966g",
"registry+https://github.com/rust-lang/crates.io-index#clap_lex@0.6.0": "1l8bragdvim7mva9flvd159dskn2bdkpl0jqrr41wnjfn8pcfbvh",
"registry+https://github.com/rust-lang/crates.io-index#cloudabi@0.0.3": "0kxcg83jlihy0phnd2g8c2c303px3l2p3pkjz357ll6llnd5pz6x",
"registry+https://github.com/rust-lang/crates.io-index#color_quant@1.1.0": "12q1n427h2bbmmm1mnglr57jaz2dj9apk0plcxw7nwqiai7qjyrx",
"registry+https://github.com/rust-lang/crates.io-index#colorchoice@1.0.0": "1ix7w85kwvyybwi2jdkl3yva2r2bvdcc3ka2grjfzfgrapqimgxc",
"registry+https://github.com/rust-lang/crates.io-index#concurrent-queue@2.4.0": "0qvk23ynj311adb4z7v89wk3bs65blps4n24q8rgl23vjk6lhq6i",
"registry+https://github.com/rust-lang/crates.io-index#const-oid@0.9.6": "1y0jnqaq7p2wvspnx7qj76m7hjcqpz73qzvr9l2p9n2s51vr6if2",
"registry+https://github.com/rust-lang/crates.io-index#cookie@0.17.0": "096c52jg9iq4lfcps2psncswv33fc30mmnaa2sbzzcfcw71kgyvy",
"registry+https://github.com/rust-lang/crates.io-index#cool_asserts@2.0.3": "1v18dg7ifx41k2f82j3gsnpm1fg9wk5s4zv7sf42c7pnad72b7zf",
"registry+https://github.com/rust-lang/crates.io-index#core-foundation-sys@0.8.6": "13w6sdf06r0hn7bx2b45zxsg1mm2phz34jikm6xc5qrbr6djpsh6",
"registry+https://github.com/rust-lang/crates.io-index#core-foundation@0.9.4": "13zvbbj07yk3b61b8fhwfzhy35535a583irf23vlcg59j7h9bqci",
"registry+https://github.com/rust-lang/crates.io-index#cpufeatures@0.2.11": "1l0gzsyy576n017g9bf0vkv5hhg9cpz1h1libxyfdlzcgbh0yhnf",
"registry+https://github.com/rust-lang/crates.io-index#crc-catalog@2.4.0": "1xg7sz82w3nxp1jfn425fvn1clvbzb3zgblmxsyqpys0dckp9lqr",
"registry+https://github.com/rust-lang/crates.io-index#crc32fast@1.3.2": "03c8f29yx293yf43xar946xbls1g60c207m9drf8ilqhr25vsh5m",
"registry+https://github.com/rust-lang/crates.io-index#crc@3.0.1": "1zkx87a5x06xfd6xm5956w4vmdfs0wcxpsn7iwj5jbp2rcapmv46",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-deque@0.8.4": "0la7fx9n1vbx3h23va0xmcy36hziql1pkik08s3j3asv4479ma7w",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-epoch@0.9.16": "1anr32r8px0vb65cgwbwp3zhqz69scz5dgq9bmx54w5qa59yjbrd",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-queue@0.3.9": "0lz17pgydh29w8brld8dysi1m4n5bxfpnj8w9bxk0q6xpyyzbg5r",
"registry+https://github.com/rust-lang/crates.io-index#crossbeam-utils@0.8.17": "13y7wh993i7q71kg6wcfj65w3rlmizzrz7cqgz1l9whlgw9rcvf0",
"registry+https://github.com/rust-lang/crates.io-index#crunchy@0.2.2": "1dx9mypwd5mpfbbajm78xcrg5lirqk7934ik980mmaffg3hdm0bs",
"registry+https://github.com/rust-lang/crates.io-index#crypto-common@0.1.6": "1cvby95a6xg7kxdz5ln3rl9xh66nz66w46mm3g56ri1z5x815yqv",
"registry+https://github.com/rust-lang/crates.io-index#data-encoding@2.5.0": "1rcbnwfmfxhlshzbn3r7srm3azqha3mn33yxyqxkzz2wpqcjm5ky",
"registry+https://github.com/rust-lang/crates.io-index#deflate@0.8.6": "0x6iqlayg129w63999kz97m279m0jj4x4sm6gkqlvmp73y70yxvk",
"registry+https://github.com/rust-lang/crates.io-index#der@0.7.8": "070bwiyr80800h31c5zd96ckkgagfjgnrrdmz3dzg2lccsd3dypz",
"registry+https://github.com/rust-lang/crates.io-index#deranged@0.3.10": "1p4i64nkadamksa943d6gk39sl1kximz0xr69n408fvsl1q0vcwf",
"registry+https://github.com/rust-lang/crates.io-index#digest@0.10.7": "14p2n6ih29x81akj097lvz7wi9b6b9hvls0lwrv7b6xwyy0s5ncy",
"registry+https://github.com/rust-lang/crates.io-index#dimensioned@0.7.0": "09ky8s3higkf677lmyqg30hmj66gpg7hx907s6hfvbk2a9av05r5",
"registry+https://github.com/rust-lang/crates.io-index#dimensioned@0.8.0": "15s3j4ry943xqlac63bp81sgdk9s3yilysabzww35j9ibmnaic50",
"registry+https://github.com/rust-lang/crates.io-index#displaydoc@0.2.4": "0p8pyg10csc782qlwx3znr6qx46ni96m1qh597kmyrf6s3s8axa8",
"registry+https://github.com/rust-lang/crates.io-index#dotenvy@0.15.7": "16s3n973n5aqym02692i1npb079n5mb0fwql42ikmwn8wnrrbbqs",
"registry+https://github.com/rust-lang/crates.io-index#either@1.9.0": "01qy3anr7jal5lpc20791vxrw0nl6vksb5j7x56q2fycgcyy8sm2",
"registry+https://github.com/rust-lang/crates.io-index#encoding_rs@0.8.33": "1qa5k4a0ipdrxq4xg9amms9r9pnnfn7nfh2i9m3mw0ka563b6s3j",
"registry+https://github.com/rust-lang/crates.io-index#env_logger@0.10.1": "1kmy9xmfjaqfvd4wkxr1f7d16ld3h9b487vqs2q9r0s8f3kg7cwm",
"registry+https://github.com/rust-lang/crates.io-index#equivalent@1.0.1": "1malmx5f4lkfvqasz319lq6gb3ddg19yzf9s8cykfsgzdmyq0hsl",
"registry+https://github.com/rust-lang/crates.io-index#errno@0.3.8": "0ia28ylfsp36i27g1qih875cyyy4by2grf80ki8vhgh6vinf8n52",
"registry+https://github.com/rust-lang/crates.io-index#etcetera@0.8.0": "0hxrsn75dirbjhwgkdkh0pnpqrnq17ypyhjpjaypgax1hd91nv8k",
"registry+https://github.com/rust-lang/crates.io-index#event-listener-strategy@0.4.0": "1lwprdjqp2ibbxhgm9khw7s7y7k4xiqj5i5yprqiks6mnrq4v3lm",
"registry+https://github.com/rust-lang/crates.io-index#event-listener@2.5.3": "1q4w3pndc518crld6zsqvvpy9lkzwahp2zgza9kbzmmqh9gif1h2",
"registry+https://github.com/rust-lang/crates.io-index#event-listener@4.0.1": "04k7qbi5kgs36s905gxijj41kcr78xs2s6cp6vbg50254z7wvwl4",
"registry+https://github.com/rust-lang/crates.io-index#exr@1.71.0": "1a58k179b0h8zpf1cfgc2vl60j2syg7cdgdzp9j6cgmb6lgpcal3",
"registry+https://github.com/rust-lang/crates.io-index#fastrand@1.9.0": "1gh12m56265ihdbzh46bhh0jf74i197wm51jg1cw75q7ggi96475",
"registry+https://github.com/rust-lang/crates.io-index#fastrand@2.0.1": "19flpv5zbzpf0rk4x77z4zf25in0brg8l7m304d3yrf47qvwxjr5",
"registry+https://github.com/rust-lang/crates.io-index#fdeflate@0.3.1": "0s5885wdsih2hqx3hsl7l8cl3666fgsgiwvglifzy229hpydmmk4",
"registry+https://github.com/rust-lang/crates.io-index#field-offset@0.3.6": "0zq5sssaa2ckmcmxxbly8qgz3sxpb8g1lwv90sdh1z74qif2gqiq",
"registry+https://github.com/rust-lang/crates.io-index#finl_unicode@1.2.0": "1ipdx778849czik798sjbgk5yhwxqybydac18d2g9jb20dxdrkwg",
"registry+https://github.com/rust-lang/crates.io-index#flate2@1.0.28": "03llhsh4gqdirnfxxb9g2w9n0721dyn4yjir3pz7z4vjaxb3yc26",
"registry+https://github.com/rust-lang/crates.io-index#fluent-bundle@0.15.2": "1zbzm13rfz7fay7bps7jd4j1pdnlxmdzzfymyq2iawf9vq0wchp2",
"registry+https://github.com/rust-lang/crates.io-index#fluent-langneg@0.13.0": "152yxplc11vmxkslvmaqak9x86xnavnhdqyhrh38ym37jscd0jic",
"registry+https://github.com/rust-lang/crates.io-index#fluent-syntax@0.11.0": "0y6ac7z7sbv51nsa6km5z8rkjj4nvqk91vlghq1ck5c3cjbyvay0",
"registry+https://github.com/rust-lang/crates.io-index#fluent@0.16.0": "19s7z0gw95qdsp9hhc00xcy11nwhnx93kknjmdvdnna435w97xk1",
"registry+https://github.com/rust-lang/crates.io-index#flume@0.11.0": "10girdbqn77wi802pdh55lwbmymy437k7kklnvj12aaiwaflbb2m",
"registry+https://github.com/rust-lang/crates.io-index#fnv@1.0.7": "1hc2mcqha06aibcaza94vbi81j6pr9a1bbxrxjfhc91zin8yr7iz",
"registry+https://github.com/rust-lang/crates.io-index#foreign-types-shared@0.1.1": "0jxgzd04ra4imjv8jgkmdq59kj8fsz6w4zxsbmlai34h26225c00",
"registry+https://github.com/rust-lang/crates.io-index#foreign-types@0.3.2": "1cgk0vyd7r45cj769jym4a6s7vwshvd0z4bqrb92q1fwibmkkwzn",
"registry+https://github.com/rust-lang/crates.io-index#form_urlencoded@1.2.1": "0milh8x7nl4f450s3ddhg57a3flcv6yq8hlkyk6fyr3mcb128dp1",
"registry+https://github.com/rust-lang/crates.io-index#fuchsia-cprng@0.1.1": "1fnkqrbz7ixxzsb04bsz9p0zzazanma8znfdqjvh39n14vapfvx0",
"registry+https://github.com/rust-lang/crates.io-index#futures-channel@0.3.29": "1jxsifvrbqzdadk0svbax71cba5d3qg3wgjq8i160mxmd1kdckgz",
"registry+https://github.com/rust-lang/crates.io-index#futures-core@0.3.29": "1308bpj0g36nhx2y6bl4mm6f1gnh9xyvvw2q2wpdgnb6dv3247gb",
"registry+https://github.com/rust-lang/crates.io-index#futures-executor@0.3.29": "1g4pjni0sw28djx6mlcfz584abm2lpifz86cmng0kkxh7mlvhkqg",
"registry+https://github.com/rust-lang/crates.io-index#futures-intrusive@0.5.0": "0vwm08d1pli6bdaj0i7xhk3476qlx4pll6i0w03gzdnh7lh0r4qx",
"registry+https://github.com/rust-lang/crates.io-index#futures-io@0.3.29": "1ajsljgny3zfxwahba9byjzclrgvm1ypakca8z854k2w7cb4mwwb",
"registry+https://github.com/rust-lang/crates.io-index#futures-lite@1.13.0": "1kkbqhaib68nzmys2dc8j9fl2bwzf2s91jfk13lb2q3nwhfdbaa9",
"registry+https://github.com/rust-lang/crates.io-index#futures-lite@2.2.0": "1flj85i6xm0rjicxixmajrp6rhq8i4bnbzffmrd6h23ln8jshns4",
"registry+https://github.com/rust-lang/crates.io-index#futures-macro@0.3.29": "1nwd18i8kvpkdfwm045hddjli0n96zi7pn6f99zi9c74j7ym7cak",
"registry+https://github.com/rust-lang/crates.io-index#futures-sink@0.3.29": "05q8jykqddxzp8nwf00wjk5m5mqi546d7i8hsxma7hiqxrw36vg3",
"registry+https://github.com/rust-lang/crates.io-index#futures-task@0.3.29": "1qmsss8rb5ppql4qvd4r70h9gpfcpd0bg2b3qilxrnhdkc397lgg",
"registry+https://github.com/rust-lang/crates.io-index#futures-util@0.3.29": "0141rkqh0psj4h8x8lgsl1p29dhqr7z2wcixkcbs60z74kb2d5d1",
"registry+https://github.com/rust-lang/crates.io-index#futures@0.3.29": "0dak2ilpcmyjrb1j54fzy9hlw6vd10vqljq9gd59pbrq9dqr00ns",
"registry+https://github.com/rust-lang/crates.io-index#gdk-pixbuf-sys@0.18.0": "1xya543c4ffd2n7aiwwrdxsyc9casdbasafi6ixcknafckm3k61z",
"registry+https://github.com/rust-lang/crates.io-index#gdk-pixbuf@0.18.3": "0b68ssdyapvq3bgsna9frabbzhjkvvzz8jld4mxkphr29nvk4vs4",
"registry+https://github.com/rust-lang/crates.io-index#gdk4-sys@0.7.2": "1w7yvir565sjrrw828lss07749hfpfsr19jdjzwivkx36brl7ayv",
"registry+https://github.com/rust-lang/crates.io-index#gdk4@0.7.3": "1xiacc63p73apr033gjrb9dsk0y4yxnsljwfxbwfry41snd03nvy",
"registry+https://github.com/rust-lang/crates.io-index#generic-array@0.11.2": "0a7w8w0rg47nmcinnfzv443lcyb8mplwc251p1jyr5xj2yh6wzv6",
"registry+https://github.com/rust-lang/crates.io-index#generic-array@0.14.7": "16lyyrzrljfq424c3n8kfwkqihlimmsg5nhshbbp48np3yjrqr45",
"registry+https://github.com/rust-lang/crates.io-index#getrandom@0.2.11": "03q7120cc2kn7ry013i67zmjl2g9q73h1ks5z08hq5v9syz0d47y",
"registry+https://github.com/rust-lang/crates.io-index#gif@0.11.4": "01hbw3isapzpzff8l6aw55jnaqx2bcscrbwyf3rglkbbfp397p9y",
"registry+https://github.com/rust-lang/crates.io-index#gif@0.12.0": "0ibhjyrslfv9qm400gp4hd50v9ibva01j4ab9bwiq1aycy9jayc0",
"registry+https://github.com/rust-lang/crates.io-index#gimli@0.28.1": "0lv23wc8rxvmjia3mcxc6hj9vkqnv1bqq0h8nzjcgf71mrxx6wa2",
"registry+https://github.com/rust-lang/crates.io-index#gio-sys@0.18.1": "1lip8z35iy9d184x2qwjxlbxi64q9cpayy7v1p5y9xdsa3w6smip",
"registry+https://github.com/rust-lang/crates.io-index#gio@0.18.4": "0wsc6mnx057s4ailacg99dwgna38dbqli5x7a6y9rdw75x9qzz6l",
"registry+https://github.com/rust-lang/crates.io-index#glib-build-tools@0.16.3": "1z73bl10zmxwrv16v4f5wcky1f3z5a2v0hknca54al4k2p5ka695",
"registry+https://github.com/rust-lang/crates.io-index#glib-build-tools@0.17.10": "05p7ab2vn8962cbchi7a6hndhvw64nqk4w5kpg5z53iizsgdfrbs",
"registry+https://github.com/rust-lang/crates.io-index#glib-build-tools@0.18.0": "0p5c2ayiam5bkp9wvq9f9ihwp06nqs5j801npjlwnhrl8rpwac9l",
"registry+https://github.com/rust-lang/crates.io-index#glib-macros@0.18.3": "19crnw5a57w02njpbsmdqwbkncl6hw6g3mv554y8dqzcrri3jybj",
"registry+https://github.com/rust-lang/crates.io-index#glib-sys@0.18.1": "164qhsfmlzd5mhyxs8123jzbdfldwxbikfpq5cysj3lddbmy4g06",
"registry+https://github.com/rust-lang/crates.io-index#glib@0.18.4": "0kjws6ns6dym48nzxz9skhipk55flc2hy5q5kzg4w12wvizvs6wm",
"registry+https://github.com/rust-lang/crates.io-index#gloo-timers@0.2.6": "0p2yqcxw0q9kclhwpgshq1r4ijns07nmmagll3lvrgl7pdk5m6cv",
"registry+https://github.com/rust-lang/crates.io-index#gobject-sys@0.18.0": "0i6fhp3m6vs3wkzyc22rk2cqj68qvgddxmpaai34l72da5xi4l08",
"registry+https://github.com/rust-lang/crates.io-index#graphene-rs@0.18.1": "00f4q1ra4haap5i7lazwhkdgnb49fs8adk2nm6ki6mjhl76jh8iv",
"registry+https://github.com/rust-lang/crates.io-index#graphene-sys@0.18.1": "0n8zlg7z26lwpnvlqp1hjlgrs671skqwagdpm7r8i1zwx3748hfc",
"registry+https://github.com/rust-lang/crates.io-index#grid@0.9.0": "0iswdcxggyxp9m1rz0m7bfg4xacinvn78zp2fgfp0l0079x10d06",
"registry+https://github.com/rust-lang/crates.io-index#gsk4-sys@0.7.3": "0mbdlm9qi1hql48rr29vsj9vlqwc7gxg67wg1q19z67azwz9xg8j",
"registry+https://github.com/rust-lang/crates.io-index#gsk4@0.7.3": "0zhzs2dkgiinhgc11akpn2harq3x5n1iq21dnc4h689g3lsqx58d",
"registry+https://github.com/rust-lang/crates.io-index#gtk4-macros@0.7.2": "0bw3cchiycf7dw1bw4p8946gv38azxy05a5w0ndgcmxnz6fc8znm",
"registry+https://github.com/rust-lang/crates.io-index#gtk4-sys@0.7.3": "1f2ylskyqkjdik9fij2m46pra4jagnif5xyalbxfk3334fmc9n2l",
"registry+https://github.com/rust-lang/crates.io-index#gtk4@0.7.3": "0hh8nzglmz94v1m1h6vy8z12m6fr7ia467ry0md5fa4p7sm53sss",
"registry+https://github.com/rust-lang/crates.io-index#h2@0.3.22": "0y41jlflvw8niifdirgng67zdmic62cjf5m2z69hzrpn5qr50qjd",
"registry+https://github.com/rust-lang/crates.io-index#half@2.2.1": "1l1gdlzxgm7wc8xl5fxas20kfi1j35iyb7vfjkghbdzijcvazd02",
"registry+https://github.com/rust-lang/crates.io-index#hashbrown@0.14.3": "012nywlg0lj9kwanh69my5x67vjlfmzfi9a0rq4qvis2j8fil3r9",
"registry+https://github.com/rust-lang/crates.io-index#hashlink@0.8.4": "1xy8agkyp0llbqk9fcffc1xblayrrywlyrm2a7v93x8zygm4y2g8",
"registry+https://github.com/rust-lang/crates.io-index#headers-core@0.2.0": "0ab469xfpd411mc3dhmjhmzrhqikzyj8a17jn5bkj9zfpy0n9xp7",
"registry+https://github.com/rust-lang/crates.io-index#headers@0.3.9": "0w62gnwh2p1lml0zqdkrx9dp438881nhz32zrzdy61qa0a9kns06",
"registry+https://github.com/rust-lang/crates.io-index#heck@0.4.1": "1a7mqsnycv5z4z5vnv1k34548jzmc0ajic7c1j8jsaspnhw5ql4m",
"registry+https://github.com/rust-lang/crates.io-index#hermit-abi@0.3.3": "1dyc8qsjh876n74a3rcz8h43s27nj1sypdhsn2ms61bd3b47wzyp",
"registry+https://github.com/rust-lang/crates.io-index#hex-string@0.1.0": "02sgrgrbp693jv0v5iga7z47y6aj93cq0ia39finby9x17fw53l4",
"registry+https://github.com/rust-lang/crates.io-index#hex@0.4.3": "0w1a4davm1lgzpamwnba907aysmlrnygbqmfis2mqjx5m552a93z",
"registry+https://github.com/rust-lang/crates.io-index#hkdf@0.12.4": "1xxxzcarz151p1b858yn5skmhyrvn8fs4ivx5km3i1kjmnr8wpvv",
"registry+https://github.com/rust-lang/crates.io-index#hmac@0.12.1": "0pmbr069sfg76z7wsssfk5ddcqd9ncp79fyz6zcm6yn115yc6jbc",
"registry+https://github.com/rust-lang/crates.io-index#home@0.5.9": "19grxyg35rqfd802pcc9ys1q3lafzlcjcv2pl2s5q8xpyr5kblg3",
"registry+https://github.com/rust-lang/crates.io-index#http-body@0.4.6": "1lmyjfk6bqk6k9gkn1dxq770sb78pqbqshga241hr5p995bb5skw",
"registry+https://github.com/rust-lang/crates.io-index#http@0.2.11": "1fwz3mhh86h5kfnr5767jlx9agpdggclq7xsqx930fflzakb2iw9",
"registry+https://github.com/rust-lang/crates.io-index#http@1.0.0": "1sllw565jn8r5w7h928nsfqq33x586pyasdfr7vid01scwwgsamk",
"registry+https://github.com/rust-lang/crates.io-index#httparse@1.8.0": "010rrfahm1jss3p022fqf3j3jmm72vhn4iqhykahb9ynpaag75yq",
"registry+https://github.com/rust-lang/crates.io-index#httpdate@1.0.3": "1aa9rd2sac0zhjqh24c9xvir96g188zldkx0hr6dnnlx5904cfyz",
"registry+https://github.com/rust-lang/crates.io-index#humantime@2.1.0": "1r55pfkkf5v0ji1x6izrjwdq9v6sc7bv99xj6srywcar37xmnfls",
"registry+https://github.com/rust-lang/crates.io-index#hyper-tls@0.5.0": "01crgy13102iagakf6q4mb75dprzr7ps1gj0l5hxm1cvm7gks66n",
"registry+https://github.com/rust-lang/crates.io-index#hyper@0.10.16": "0wwjh9p3mzvg3fss2lqz5r7ddcgl1fh9w6my2j69d6k0lbcm41ha",
"registry+https://github.com/rust-lang/crates.io-index#hyper@0.14.28": "107gkvqx4h9bl17d602zkm2dgpfq86l2dr36yzfsi8l3xcsy35mz",
"registry+https://github.com/rust-lang/crates.io-index#iana-time-zone-haiku@0.1.2": "17r6jmj31chn7xs9698r122mapq85mfnv98bb4pg6spm0si2f67k",
"registry+https://github.com/rust-lang/crates.io-index#iana-time-zone@0.1.58": "081vcr8z8ddhl5r1ywif6grnswk01b2ac4nks2bhn8zzdimvh9l3",
"registry+https://github.com/rust-lang/crates.io-index#idna@0.1.5": "0kl4gs5kaydn4v07c6ka33spm9qdh2np0x7iw7g5zd8z1c7rxw1q",
"registry+https://github.com/rust-lang/crates.io-index#idna@0.5.0": "1xhjrcjqq0l5bpzvdgylvpkgk94panxgsirzhjnnqfdgc4a9nkb3",
"registry+https://github.com/rust-lang/crates.io-index#image@0.23.14": "18gn2f7xp30pf9aqka877knlq308khxqiwjvsccvzaa4f9zcpzr4",
"registry+https://github.com/rust-lang/crates.io-index#image@0.24.7": "04d7f25b8nlszfv9a474n4a0al4m2sv9gqj3yiphhqr0syyzsgbg",
"registry+https://github.com/rust-lang/crates.io-index#indent_write@2.2.0": "1hqjp80argdskrhd66g9sh542yxy8qi77j6rc69qd0l7l52rdzhc",
"registry+https://github.com/rust-lang/crates.io-index#indexmap@2.1.0": "07rxrqmryr1xfnmhrjlz8ic6jw28v6h5cig3ws2c9d0wifhy2c6m",
"registry+https://github.com/rust-lang/crates.io-index#instant@0.1.12": "0b2bx5qdlwayriidhrag8vhy10kdfimfhmb3jnjmsz2h9j1bwnvs",
"registry+https://github.com/rust-lang/crates.io-index#intl-memoizer@0.5.1": "0vx6cji8ifw77zrgipwmvy1i3v43dcm58hwjxpb1h29i98z46463",
"registry+https://github.com/rust-lang/crates.io-index#intl_pluralrules@7.0.2": "0wprd3h6h8nfj62d8xk71h178q7zfn3srxm787w4sawsqavsg3h7",
"registry+https://github.com/rust-lang/crates.io-index#io-lifetimes@1.0.11": "1hph5lz4wd3drnn6saakwxr497liznpfnv70via6s0v8x6pbkrza",
"registry+https://github.com/rust-lang/crates.io-index#ipnet@2.9.0": "1hzrcysgwf0knf83ahb3535hrkw63mil88iqc6kjaryfblrqylcg",
"registry+https://github.com/rust-lang/crates.io-index#iron@0.6.1": "1s4mf8395f693nhwsr0znw3j5frzn56gzllypyl50il85p50ily6",
"registry+https://github.com/rust-lang/crates.io-index#is-terminal@0.4.9": "12xgvc7nsrp3pn8hcxajfhbli2l5wnh3679y2fmky88nhj4qj26b",
"registry+https://github.com/rust-lang/crates.io-index#itertools@0.12.0": "1c07gzdlc6a1c8p8jrvvw3gs52bss3y58cs2s21d9i978l36pnr5",
"registry+https://github.com/rust-lang/crates.io-index#itoa@1.0.10": "0k7xjfki7mnv6yzjrbnbnjllg86acmbnk4izz2jmm1hx2wd6v95i",
"registry+https://github.com/rust-lang/crates.io-index#jpeg-decoder@0.1.22": "1wnh0bmmswpgwhgmlizz545x8334nlbmkq8imy9k224ri3am7792",
"registry+https://github.com/rust-lang/crates.io-index#jpeg-decoder@0.3.0": "0gkv0zx95i4fr40fj1a10d70lqi6lfyia8r5q8qjxj8j4pj0005w",
"registry+https://github.com/rust-lang/crates.io-index#js-sys@0.3.66": "1ji9la5ydg0vy17q54i7dnwc0wwb9zkx662w1583pblylm6wdsff",
"registry+https://github.com/rust-lang/crates.io-index#kv-log-macro@1.0.7": "0zwp4bxkkp87rl7xy2dain77z977rvcry1gmr5bssdbn541v7s0d",
"registry+https://github.com/rust-lang/crates.io-index#language-tags@0.2.2": "16hrjdpa827carq5x4b8zhas24d8kg4s16m6nmmn1kb7cr5qh7d9",
"registry+https://github.com/rust-lang/crates.io-index#lazy_static@1.4.0": "0in6ikhw8mgl33wjv6q6xfrb5b9jr16q8ygjy803fay4zcisvaz2",
"registry+https://github.com/rust-lang/crates.io-index#lebe@0.5.2": "1j2l6chx19qpa5gqcw434j83gyskq3g2cnffrbl3842ymlmpq203",
"registry+https://github.com/rust-lang/crates.io-index#libadwaita-sys@0.5.3": "16n6xsy6jhbj0jbpz8yvql6c9b89a99v9vhdz5s37mg1inisl42y",
"registry+https://github.com/rust-lang/crates.io-index#libadwaita@0.5.3": "174pzn9dwsk8ikvrhx13vkh0zrpvb3rhg9yd2q5d2zjh0q6fgrrg",
"registry+https://github.com/rust-lang/crates.io-index#libc@0.2.151": "1x28f0zgp4zcwr891p8n9ag9w371sbib30vp4y6hi2052frplb9h",
"registry+https://github.com/rust-lang/crates.io-index#libm@0.2.8": "0n4hk1rs8pzw8hdfmwn96c4568s93kfxqgcqswr7sajd2diaihjf",
"registry+https://github.com/rust-lang/crates.io-index#libsqlite3-sys@0.27.0": "05pp60ncrmyjlxxjj187808jkvpxm06w5lvvdwwvxd2qrmnj4kng",
"registry+https://github.com/rust-lang/crates.io-index#linux-raw-sys@0.3.8": "068mbigb3frrxvbi5g61lx25kksy98f2qgkvc4xg8zxznwp98lzg",
"registry+https://github.com/rust-lang/crates.io-index#linux-raw-sys@0.4.12": "0mhlla3gk1jgn6mrq9s255rvvq8a1w3yk2vpjiwsd6hmmy1imkf4",
"registry+https://github.com/rust-lang/crates.io-index#lock_api@0.4.11": "0iggx0h4jx63xm35861106af3jkxq06fpqhpkhgw0axi2n38y5iw",
"registry+https://github.com/rust-lang/crates.io-index#log@0.3.9": "0jq23hhn5h35k7pa8r7wqnsywji6x3wn1q5q7lif5q536if8v7p1",
"registry+https://github.com/rust-lang/crates.io-index#log@0.4.20": "13rf7wphnwd61vazpxr7fiycin6cb1g8fmvgqg18i464p0y1drmm",
"registry+https://github.com/rust-lang/crates.io-index#logger@0.4.0": "14xlxvkspcfnspjil0xi63qj5cybxn1hjmr5gq8m4v1g9k5p54bc",
"registry+https://github.com/rust-lang/crates.io-index#matches@0.1.10": "1994402fq4viys7pjhzisj4wcw894l53g798kkm2y74laxk0jci5",
"registry+https://github.com/rust-lang/crates.io-index#md-5@0.10.6": "1kvq5rnpm4fzwmyv5nmnxygdhhb2369888a06gdc9pxyrzh7x7nq",
"registry+https://github.com/rust-lang/crates.io-index#memchr@2.6.4": "0rq1ka8790ns41j147npvxcqcl2anxyngsdimy85ag2api0fwrgn",
"registry+https://github.com/rust-lang/crates.io-index#memoffset@0.9.0": "0v20ihhdzkfw1jx00a7zjpk2dcp5qjq6lz302nyqamd9c4f4nqss",
"registry+https://github.com/rust-lang/crates.io-index#mime@0.2.6": "1q1s1ax1gaz8ld3513nvhidfwnik5asbs1ma3hp6inp5dn56nqms",
"registry+https://github.com/rust-lang/crates.io-index#mime@0.3.17": "16hkibgvb9klh0w0jk5crr5xv90l3wlf77ggymzjmvl1818vnxv8",
"registry+https://github.com/rust-lang/crates.io-index#mime_guess@1.8.8": "18qcd5aa3363mb742y7lf39j7ha88pkzbv9ff2qidlsdxsjjjs91",
"registry+https://github.com/rust-lang/crates.io-index#mime_guess@2.0.4": "1vs28rxnbfwil6f48hh58lfcx90klcvg68gxdc60spwa4cy2d4j1",
"registry+https://github.com/rust-lang/crates.io-index#minimal-lexical@0.2.1": "16ppc5g84aijpri4jzv14rvcnslvlpphbszc7zzp6vfkddf4qdb8",
"registry+https://github.com/rust-lang/crates.io-index#miniz_oxide@0.3.7": "0dblrhgbm0wa8jjl8cjp81akaj36yna92df4z1h9b26n3spal7br",
"registry+https://github.com/rust-lang/crates.io-index#miniz_oxide@0.4.4": "0jsfv00hl5rmx1nijn59sr9jmjd4rjnjhh4kdjy8d187iklih9d9",
"registry+https://github.com/rust-lang/crates.io-index#miniz_oxide@0.7.1": "1ivl3rbbdm53bzscrd01g60l46lz5krl270487d8lhjvwl5hx0g7",
"registry+https://github.com/rust-lang/crates.io-index#mio@0.8.10": "02gyaxvaia9zzi4drrw59k9s0j6pa5d1y2kv7iplwjipdqlhngcg",
"registry+https://github.com/rust-lang/crates.io-index#modifier@0.1.0": "0n3fmgli1nsskl0whrfzm1gk0rmwwl6pw1q4nb9sqqmn5h8wkxa1",
"registry+https://github.com/rust-lang/crates.io-index#multer@2.1.0": "1hjiphaypj3phqaj5igrzcia9xfmf4rr4ddigbh8zzb96k1bvb01",
"registry+https://github.com/rust-lang/crates.io-index#nary_tree@0.4.3": "1iqray1a716995l9mmvz5sfqrwg9a235bvrkpcn8bcqwjnwfv1pv",
"registry+https://github.com/rust-lang/crates.io-index#native-tls@0.2.11": "0bmrlg0fmzxaycjpkgkchi93av07v2yf9k33gc12ca9gqdrn28h7",
"registry+https://github.com/rust-lang/crates.io-index#nix@0.27.1": "0ly0kkmij5f0sqz35lx9czlbk6zpihb7yh1bsy4irzwfd2f4xc1f",
"registry+https://github.com/rust-lang/crates.io-index#no-std-compat@0.4.1": "132vrf710zsdp40yp1z3kgc2ss8pi0z4gmihsz3y7hl4dpd56f5r",
"registry+https://github.com/rust-lang/crates.io-index#nom@7.1.3": "0jha9901wxam390jcf5pfa0qqfrgh8li787jx2ip0yk5b8y9hwyj",
"registry+https://github.com/rust-lang/crates.io-index#num-bigint-dig@0.8.4": "0lb12df24wgxxbspz4gw1sf1kdqwvpdcpwq4fdlwg4gj41c1k16w",
"registry+https://github.com/rust-lang/crates.io-index#num-integer@0.1.45": "1ncwavvwdmsqzxnn65phv6c6nn72pnv9xhpmjd6a429mzf4k6p92",
"registry+https://github.com/rust-lang/crates.io-index#num-iter@0.1.43": "0lp22isvzmmnidbq9n5kbdh8gj0zm3yhxv1ddsn5rp65530fc0vx",
"registry+https://github.com/rust-lang/crates.io-index#num-rational@0.3.2": "01sgiwny9iflyxh2xz02sak71v2isc3x608hfdpwwzxi3j5l5b0j",
"registry+https://github.com/rust-lang/crates.io-index#num-rational@0.4.1": "1c0rb8x4avxy3jvvzv764yk7afipzxncfnqlb10r3h53s34s2f06",
"registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.17": "0z16bi5zwgfysz6765v3rd6whfbjpihx3mhsn4dg8dzj2c221qrr",
"registry+https://github.com/rust-lang/crates.io-index#num_cpus@1.16.0": "0hra6ihpnh06dvfvz9ipscys0xfqa9ca9hzp384d5m02ssvgqqa1",
"registry+https://github.com/rust-lang/crates.io-index#object@0.32.1": "1c02x4kvqpnl3wn7gz9idm4jrbirbycyqjgiw6lm1g9k77fzkxcw",
"registry+https://github.com/rust-lang/crates.io-index#once_cell@1.19.0": "14kvw7px5z96dk4dwdm1r9cqhhy2cyj1l5n5b29mynbb8yr15nrz",
"registry+https://github.com/rust-lang/crates.io-index#openssl-macros@0.1.1": "173xxvfc63rr5ybwqwylsir0vq6xsj4kxiv4hmg4c3vscdmncj59",
"registry+https://github.com/rust-lang/crates.io-index#openssl-probe@0.1.5": "1kq18qm48rvkwgcggfkqq6pm948190czqc94d6bm2sir5hq1l0gz",
"registry+https://github.com/rust-lang/crates.io-index#openssl-sys@0.9.97": "02s670ir38fsavphdna07144y41dkvrcfkwnjzg82zfrrlsavsn3",
"registry+https://github.com/rust-lang/crates.io-index#openssl@0.10.61": "0idv3n9n9f2sxq8cqzxvq44633vg5sx4n9q1p3g6dn66ikf1k13b",
"registry+https://github.com/rust-lang/crates.io-index#pango-sys@0.18.0": "1iaxalcaaj59cl9n10svh4g50v8jrc1a36kd7n9yahx8j7ikfrs3",
"registry+https://github.com/rust-lang/crates.io-index#pango@0.18.3": "1r5ygq7036sv7w32kp8yxr6vgggd54iaavh3yckanmq4xg0px8kw",
"registry+https://github.com/rust-lang/crates.io-index#parking@2.2.0": "1blwbkq6im1hfxp5wlbr475mw98rsyc0bbr2d5n16m38z253p0dv",
"registry+https://github.com/rust-lang/crates.io-index#parking_lot@0.12.1": "13r2xk7mnxfc5g0g6dkdxqdqad99j7s7z8zhzz4npw5r0g0v4hip",
"registry+https://github.com/rust-lang/crates.io-index#parking_lot_core@0.9.9": "13h0imw1aq86wj28gxkblhkzx6z1gk8q18n0v76qmmj6cliajhjc",
"registry+https://github.com/rust-lang/crates.io-index#parse-zoneinfo@0.3.0": "0h8g6jy4kckn2gk8sd5adaws180n1ip65xhzw5jxlq4w8ibg41f7",
"registry+https://github.com/rust-lang/crates.io-index#paste@1.0.14": "0k7d54zz8zrz0623l3xhvws61z5q2wd3hkwim6gylk8212placfy",
"registry+https://github.com/rust-lang/crates.io-index#pem-rfc7468@0.7.0": "04l4852scl4zdva31c1z6jafbak0ni5pi0j38ml108zwzjdrrcw8",
"registry+https://github.com/rust-lang/crates.io-index#percent-encoding@1.0.1": "0cgq08v1fvr6bs5fvy390cz830lq4fak8havdasdacxcw790s09i",
"registry+https://github.com/rust-lang/crates.io-index#percent-encoding@2.3.1": "0gi8wgx0dcy8rnv1kywdv98lwcx67hz0a0zwpib5v2i08r88y573",
"registry+https://github.com/rust-lang/crates.io-index#phf@0.11.2": "1p03rsw66l7naqhpgr1a34r9yzi1gv9jh16g3fsk6wrwyfwdiqmd",
"registry+https://github.com/rust-lang/crates.io-index#phf@0.7.24": "066xwv4dr6056a9adlkarwp4n94kbpwngbmd47ngm3cfbyw49nmk",
"registry+https://github.com/rust-lang/crates.io-index#phf_codegen@0.11.2": "0nia6h4qfwaypvfch3pnq1nd2qj64dif4a6kai3b7rjrsf49dlz8",
"registry+https://github.com/rust-lang/crates.io-index#phf_codegen@0.7.24": "0zjiblicfm0nrmr2xxrs6pnf6zz2394wgch6dcbd8jijkq98agmh",
"registry+https://github.com/rust-lang/crates.io-index#phf_generator@0.11.2": "1c14pjyxbcpwkdgw109f7581cc5fa3fnkzdq1ikvx7mdq9jcrr28",
"registry+https://github.com/rust-lang/crates.io-index#phf_generator@0.7.24": "0qi62gxk3x3whrmw5c4i71406icqk11qmpgln438p6qm7k4lqdh9",
"registry+https://github.com/rust-lang/crates.io-index#phf_shared@0.11.2": "0azphb0a330ypqx3qvyffal5saqnks0xvl8rj73jlk3qxxgbkz4h",
"registry+https://github.com/rust-lang/crates.io-index#phf_shared@0.7.24": "18371fla0vsj7d6d5rlfb747xbr2in11ar9vgv5qna72bnhp2kr3",
"registry+https://github.com/rust-lang/crates.io-index#pin-project-internal@1.1.3": "01a4l3vb84brv9v7wl71chzxra2kynm6yvcjca66xv3ij6fgsna3",
"registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.13": "0n0bwr5qxlf0mhn2xkl36sy55118s9qmvx2yl5f3ixkb007lbywa",
"registry+https://github.com/rust-lang/crates.io-index#pin-project@1.1.3": "08k4cpy8q3j93qqgnrbzkcgpn7g0a88l4a9nm33kyghpdhffv97x",
"registry+https://github.com/rust-lang/crates.io-index#pin-utils@0.1.0": "117ir7vslsl2z1a7qzhws4pd01cg2d3338c47swjyvqv2n60v1wb",
"registry+https://github.com/rust-lang/crates.io-index#piper@0.2.1": "1m45fkdq7q5l9mv3b0ra10qwm0kb67rjp2q8y91958gbqjqk33b6",
"registry+https://github.com/rust-lang/crates.io-index#pkcs1@0.7.5": "0zz4mil3nchnxljdfs2k5ab1cjqn7kq5lqp62n9qfix01zqvkzy8",
"registry+https://github.com/rust-lang/crates.io-index#pkcs8@0.10.2": "1dx7w21gvn07azszgqd3ryjhyphsrjrmq5mmz1fbxkj5g0vv4l7r",
"registry+https://github.com/rust-lang/crates.io-index#pkg-config@0.3.27": "0r39ryh1magcq4cz5g9x88jllsnxnhcqr753islvyk4jp9h2h1r6",
"registry+https://github.com/rust-lang/crates.io-index#plugin@0.2.6": "1q7nghkpvxxr168y2jnzh3w7qc9vfrby9n7ygy3xpj0bj71hsshs",
"registry+https://github.com/rust-lang/crates.io-index#png@0.16.8": "1ipl44q3vy4kvx6j296vk7d4v8gvcg203lrkvvixwixq1j98fciw",
"registry+https://github.com/rust-lang/crates.io-index#png@0.17.10": "0r5a8a25ad0jq2pkp2zbab3wwhpgp6jmdg6d0ybjnw6kilnvyxfx",
"registry+https://github.com/rust-lang/crates.io-index#polling@2.8.0": "1kixxfq1af1k7gkmmk9yv4j2krpp4fji2r8j4cz6p6d7ihz34bab",
"registry+https://github.com/rust-lang/crates.io-index#polling@3.4.0": "052am20b5r03nwhpnjw86rv3dwsdabvb07anv3fqxfbs65r4w19h",
"registry+https://github.com/rust-lang/crates.io-index#powerfmt@0.2.0": "14ckj2xdpkhv3h6l5sdmb9f1d57z8hbfpdldjc2vl5givq2y77j3",
"registry+https://github.com/rust-lang/crates.io-index#ppv-lite86@0.2.17": "1pp6g52aw970adv3x2310n7glqnji96z0a9wiamzw89ibf0ayh2v",
"registry+https://github.com/rust-lang/crates.io-index#pretty_env_logger@0.5.0": "076w9dnvcpx6d3mdbkqad8nwnsynb7c8haxmscyrz7g3vga28mw6",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-crate@1.3.1": "069r1k56bvgk0f58dm5swlssfcp79im230affwk6d9ck20g04k3z",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-crate@2.0.1": "06jbv5w6s04dbjbwq0iv7zil12ildf3w8dvvb4pqvhig4gm5zp4p",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-error-attr@1.0.4": "0sgq6m5jfmasmwwy8x4mjygx5l7kp8s4j60bv25ckv2j1qc41gm1",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro-error@1.0.4": "1373bhxaf0pagd8zkyd03kkx6bchzf6g0dkwrwzsnal9z47lj9fs",
"registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.78": "1bjak27pqdn4f4ih1c9nr3manzyavsgqmf76ygw9k76q8pb2lhp2",
"registry+https://github.com/rust-lang/crates.io-index#proptest@1.4.0": "1gzmw40pgmwzb7x6jsyr88z5w151snv5rp1g0dlcp1iw3h9pdd1i",
"registry+https://github.com/rust-lang/crates.io-index#qoi@0.4.1": "00c0wkb112annn2wl72ixyd78mf56p4lxkhlmsggx65l3v3n8vbz",
"registry+https://github.com/rust-lang/crates.io-index#quick-error@1.2.3": "1q6za3v78hsspisc197bg3g7rpc989qycy8ypr8ap8igv10ikl51",
"registry+https://github.com/rust-lang/crates.io-index#quote@1.0.35": "1vv8r2ncaz4pqdr78x7f138ka595sp2ncr1sa2plm4zxbsmwj7i9",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.3.23": "0v679h38pjjqj5h4md7v2slsvj6686qgcn7p9fbw3h43iwnk1b34",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.4.6": "14qjfv3gggzhnma20k0sc1jf8y6pplsaq7n1j9ls5c8kf2wl0a2m",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.6.5": "1jl4449jcl4wgmzld6ffwqj5gwxrp8zvx8w573g1z368qg6xlwbd",
"registry+https://github.com/rust-lang/crates.io-index#rand@0.8.5": "013l6931nn7gkc23jz5mm3qdhf93jjf0fg64nz2lp4i51qd8vbrl",
"registry+https://github.com/rust-lang/crates.io-index#rand_chacha@0.1.1": "1vxwyzs4fy1ffjc8l00fsyygpiss135irjf7nyxgq2v0lqf3lvam",
"registry+https://github.com/rust-lang/crates.io-index#rand_chacha@0.3.1": "123x2adin558xbhvqb8w4f6syjsdkmqff8cxwhmjacpsl1ihmhg6",
"registry+https://github.com/rust-lang/crates.io-index#rand_core@0.3.1": "0jzdgszfa4bliigiy4hi66k7fs3gfwi2qxn8vik84ph77fwdwvvs",
"registry+https://github.com/rust-lang/crates.io-index#rand_core@0.4.2": "1p09ynysrq1vcdlmcqnapq4qakl2yd1ng3kxh3qscpx09k2a6cww",
"registry+https://github.com/rust-lang/crates.io-index#rand_core@0.6.4": "0b4j2v4cb5krak1pv6kakv4sz6xcwbrmy2zckc32hsigbrwy82zc",
"registry+https://github.com/rust-lang/crates.io-index#rand_hc@0.1.0": "1i0vl8q5ddvvy0x8hf1zxny393miyzxkwqnw31ifg6p0gdy6fh3v",
"registry+https://github.com/rust-lang/crates.io-index#rand_isaac@0.1.1": "027flpjr4znx2csxk7gxb7vrf9c7y5mydmvg5az2afgisp4rgnfy",
"registry+https://github.com/rust-lang/crates.io-index#rand_jitter@0.1.4": "16z387y46bfz3csc42zxbjq89vcr1axqacncvv8qhyy93p4xarhi",
"registry+https://github.com/rust-lang/crates.io-index#rand_os@0.1.3": "0wahppm0s64gkr2vmhcgwc0lij37in1lgfxg5rbgqlz0l5vgcxbv",
"registry+https://github.com/rust-lang/crates.io-index#rand_pcg@0.1.2": "0i0bdla18a8x4jn1w0fxsbs3jg7ajllz6azmch1zw33r06dv1ydb",
"registry+https://github.com/rust-lang/crates.io-index#rand_xorshift@0.1.1": "0p2x8nr00hricpi2m6ca5vysiha7ybnghz79yqhhx6sl4gkfkxyb",
"registry+https://github.com/rust-lang/crates.io-index#rand_xorshift@0.3.0": "13vcag7gmqspzyabfl1gr9ykvxd2142q2agrj8dkyjmfqmgg4nyj",
"registry+https://github.com/rust-lang/crates.io-index#rayon-core@1.12.0": "1vaq0q71yfvcwlmia0iqf6ixj2fibjcf2xjy92n1m1izv1mgpqsw",
"registry+https://github.com/rust-lang/crates.io-index#rayon@1.8.0": "1cfdnvchf7j4cpha5jkcrrsr61li9i9lp5ak7xdq6d3pvc1xn9ww",
"registry+https://github.com/rust-lang/crates.io-index#rdrand@0.4.0": "1cjq0kwx1bk7jx3kzyciiish5gqsj7620dm43dc52sr8fzmm9037",
"registry+https://github.com/rust-lang/crates.io-index#redox_syscall@0.4.1": "1aiifyz5dnybfvkk4cdab9p2kmphag1yad6iknc7aszlxxldf8j7",
"registry+https://github.com/rust-lang/crates.io-index#regex-automata@0.4.3": "0gs8q9yhd3kcg4pr00ag4viqxnh5l7jpyb9fsfr8hzh451w4r02z",
"registry+https://github.com/rust-lang/crates.io-index#regex-syntax@0.8.2": "17rd2s8xbiyf6lb4aj2nfi44zqlj98g2ays8zzj2vfs743k79360",
"registry+https://github.com/rust-lang/crates.io-index#regex@1.10.2": "0hxkd814n4irind8im5c9am221ri6bprx49nc7yxv02ykhd9a2rq",
"registry+https://github.com/rust-lang/crates.io-index#remove_dir_all@0.5.3": "1rzqbsgkmr053bxxl04vmvsd1njyz0nxvly97aip6aa2cmb15k9s",
"registry+https://github.com/rust-lang/crates.io-index#reqwest@0.11.23": "0hgvzb7r46656r9vqhl5qk1kbr2xzjb91yr2cb321160ka6sxc9p",
"registry+https://github.com/rust-lang/crates.io-index#rsa@0.9.6": "1z0d1aavfm0v4pv8jqmqhhvvhvblla1ydzlvwykpc3mkzhj523jx",
"registry+https://github.com/rust-lang/crates.io-index#rustc-demangle@0.1.23": "0xnbk2bmyzshacjm2g1kd4zzv2y2az14bw3sjccq5qkpmsfvn9nn",
"registry+https://github.com/rust-lang/crates.io-index#rustc-hash@1.1.0": "1qkc5khrmv5pqi5l5ca9p5nl5hs742cagrndhbrlk3dhlrx3zm08",
"registry+https://github.com/rust-lang/crates.io-index#rustc_version@0.4.0": "0rpk9rcdk405xhbmgclsh4pai0svn49x35aggl4nhbkd4a2zb85z",
"registry+https://github.com/rust-lang/crates.io-index#rustix@0.37.27": "1lidfswa8wbg358yrrkhfvsw0hzlvl540g4lwqszw09sg8vcma7y",
"registry+https://github.com/rust-lang/crates.io-index#rustix@0.38.28": "05m3vacvbqbg6r6ksmx9k5afpi0lppjdv712crrpsrfax2jp5rbj",
"registry+https://github.com/rust-lang/crates.io-index#rustls-pemfile@1.0.4": "1324n5bcns0rnw6vywr5agff3rwfvzphi7rmbyzwnv6glkhclx0w",
"registry+https://github.com/rust-lang/crates.io-index#rusty-fork@0.3.0": "0kxwq5c480gg6q0j3bg4zzyfh2kwmc3v2ba94jw8ncjc8mpcqgfb",
"registry+https://github.com/rust-lang/crates.io-index#ryu@1.0.16": "0k7b90xr48ag5bzmfjp82rljasw2fx28xr3bg1lrpx7b5sljm3gr",
"registry+https://github.com/rust-lang/crates.io-index#safemem@0.3.3": "0wp0d2b2284lw11xhybhaszsczpbq1jbdklkxgifldcknmy3nw7g",
"registry+https://github.com/rust-lang/crates.io-index#schannel@0.1.22": "126zy5jb95fc5hvzyjwiq6lc81r08rdcn6affn00ispp9jzk6dqc",
"registry+https://github.com/rust-lang/crates.io-index#scoped-tls@1.0.1": "15524h04mafihcvfpgxd8f4bgc3k95aclz8grjkg9a0rxcvn9kz1",
"registry+https://github.com/rust-lang/crates.io-index#scoped_threadpool@0.1.9": "1a26d3lk40s9mrf4imhbik7caahmw2jryhhb6vqv6fplbbgzal8x",
"registry+https://github.com/rust-lang/crates.io-index#scopeguard@1.2.0": "0jcz9sd47zlsgcnm1hdw0664krxwb5gczlif4qngj2aif8vky54l",
"registry+https://github.com/rust-lang/crates.io-index#security-framework-sys@2.9.1": "0yhciwlsy9dh0ps1gw3197kvyqx1bvc4knrhiznhid6kax196cp9",
"registry+https://github.com/rust-lang/crates.io-index#security-framework@2.9.2": "1pplxk15s5yxvi2m1sz5xfmjibp96cscdcl432w9jzbk0frlzdh5",
"registry+https://github.com/rust-lang/crates.io-index#self_cell@0.10.3": "0pci3zh23b7dg6jmlxbn8k4plb7hcg5jprd1qiz0rp04p1ilskp1",
"registry+https://github.com/rust-lang/crates.io-index#self_cell@1.0.2": "1rmdglwnd77wcw2gv76finpgzjhkynx422d0jpahrf2fsqn37273",
"registry+https://github.com/rust-lang/crates.io-index#semver@1.0.20": "140hmbfa743hbmah1zjf07s8apavhvn04204qjigjiz5w6iscvw3",
"registry+https://github.com/rust-lang/crates.io-index#serde@0.9.15": "1bsla8l5xr9pp5sirkal6mngxcq6q961km88jvf339j5ff8j7dil",
"registry+https://github.com/rust-lang/crates.io-index#serde@1.0.193": "129b0j67594f8qg5cbyi3nyk31y97wrqihi026mba34dwrsrkp95",
"registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.193": "1lwlx2k7wxr1v160kpyqjfabs37gm1yxqg65383rnyrm06jnqms3",
"registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.108": "0ssj59s7lpzqh1m50kfzlnrip0p0jg9lmhn4098i33a0mhz7w71x",
"registry+https://github.com/rust-lang/crates.io-index#serde_spanned@0.6.5": "1hgh6s3jjwyzhfk3xwb6pnnr1misq9nflwq0f026jafi37s24dpb",
"registry+https://github.com/rust-lang/crates.io-index#serde_urlencoded@0.7.1": "1zgklbdaysj3230xivihs30qi5vkhigg323a9m62k8jwf4a1qjfk",
"registry+https://github.com/rust-lang/crates.io-index#sha1@0.10.6": "1fnnxlfg08xhkmwf2ahv634as30l1i3xhlhkvxflmasi5nd85gz3",
"registry+https://github.com/rust-lang/crates.io-index#sha2@0.10.8": "1j1x78zk9il95w9iv46dh9wm73r6xrgj32y6lzzw7bxws9dbfgbr",
"registry+https://github.com/rust-lang/crates.io-index#signal-hook-registry@1.4.1": "18crkkw5k82bvcx088xlf5g4n3772m24qhzgfan80nda7d3rn8nq",
"registry+https://github.com/rust-lang/crates.io-index#signature@2.2.0": "1pi9hd5vqfr3q3k49k37z06p7gs5si0in32qia4mmr1dancr6m3p",
"registry+https://github.com/rust-lang/crates.io-index#simd-adler32@0.3.7": "1zkq40c3iajcnr5936gjp9jjh1lpzhy44p3dq3fiw75iwr1w2vfn",
"registry+https://github.com/rust-lang/crates.io-index#siphasher@0.2.3": "1b53m53l24lyhr505lwqzrpjyq5qfnic71mynrcfvm43rybf938b",
"registry+https://github.com/rust-lang/crates.io-index#siphasher@0.3.11": "03axamhmwsrmh0psdw3gf7c0zc4fyl5yjxfifz9qfka6yhkqid9q",
"registry+https://github.com/rust-lang/crates.io-index#slab@0.4.9": "0rxvsgir0qw5lkycrqgb1cxsvxzjv9bmx73bk5y42svnzfba94lg",
"registry+https://github.com/rust-lang/crates.io-index#smallvec@1.11.2": "0w79x38f7c0np7hqfmzrif9zmn0avjvvm31b166zdk9d1aad1k2d",
"registry+https://github.com/rust-lang/crates.io-index#snowflake@1.3.0": "1wadr7bxdxbmkbqkqsvzan6q1h3mxqpxningi3ss3v9jaav7n817",
"registry+https://github.com/rust-lang/crates.io-index#socket2@0.4.10": "03ack54dxhgfifzsj14k7qa3r5c9wqy3v6mqhlim99cc03y1cycz",
"registry+https://github.com/rust-lang/crates.io-index#socket2@0.5.5": "1sgq315f1njky114ip7wcy83qlphv9qclprfjwvxcpfblmcsqpvv",
"registry+https://github.com/rust-lang/crates.io-index#spin@0.5.2": "0b84m6dbzrwf2kxylnw82d3dr8w06av7rfkr8s85fb5f43rwyqvf",
"registry+https://github.com/rust-lang/crates.io-index#spin@0.9.8": "0rvam5r0p3a6qhc18scqpvpgb3ckzyqxpgdfyjnghh8ja7byi039",
"registry+https://github.com/rust-lang/crates.io-index#spki@0.7.3": "17fj8k5fmx4w9mp27l970clrh5qa7r5sjdvbsln987xhb34dc7nr",
"registry+https://github.com/rust-lang/crates.io-index#sqlformat@0.2.3": "0v0p70wjdshj18zgjjac9xlx8hmpx33xhq7g8x9rg4s4gjyvg0ff",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-core@0.7.3": "1gdz44yb9qwxv4xl4hv6w4vbqx0zzdlzsf9j9gcj1qir6wy0ljyq",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-macros-core@0.7.3": "0h88wahkxa6nam536lhwr1y0yxlr6la8b1x0hs0n88v790clbgfh",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-macros@0.7.3": "19gjwisiym07q7ibkp9nkvvbywjh0r5rc572msvzyzadvh01r5l9",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-mysql@0.7.3": "190ygz5a3pqcd9vvqjv2i4r1xh8vi53j4272yrld07zpblwrawg3",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-postgres@0.7.3": "090wm9s6mm53ggn1xwr183cnn8yxly8rgcksdk4hrlfcnz1hmb6n",
"registry+https://github.com/rust-lang/crates.io-index#sqlx-sqlite@0.7.3": "143laha7wf8dmi0xwycwqmvxdcnb25dq7jnqrsgvmis8v6vpc291",
"registry+https://github.com/rust-lang/crates.io-index#sqlx@0.7.3": "1kv3hyx7izmmsjqh3l47zrfhjlcblpg20cvnk7pr8dm7klkkr86v",
"registry+https://github.com/rust-lang/crates.io-index#stringprep@0.1.4": "1rkfsf7riynsmqj3hbldfrvmna0i9chx2sz39qdpl40s4d7dfhdv",
"registry+https://github.com/rust-lang/crates.io-index#strsim@0.10.0": "08s69r4rcrahwnickvi0kq49z524ci50capybln83mg6b473qivk",
"registry+https://github.com/rust-lang/crates.io-index#subtle@2.5.0": "1g2yjs7gffgmdvkkq0wrrh0pxds3q0dv6dhkw9cdpbib656xdkc1",
"registry+https://github.com/rust-lang/crates.io-index#syn@1.0.109": "0ds2if4600bd59wsv7jjgfkayfzy3hnazs394kz6zdkmna8l3dkj",
"registry+https://github.com/rust-lang/crates.io-index#syn@2.0.48": "0gqgfygmrxmp8q32lia9p294kdd501ybn6kn2h4gqza0irik2d8g",
"registry+https://github.com/rust-lang/crates.io-index#system-configuration-sys@0.5.0": "1jckxvdr37bay3i9v52izgy52dg690x5xfg3hd394sv2xf4b2px7",
"registry+https://github.com/rust-lang/crates.io-index#system-configuration@0.5.1": "1rz0r30xn7fiyqay2dvzfy56cvaa3km74hnbz2d72p97bkf3lfms",
"registry+https://github.com/rust-lang/crates.io-index#system-deps@6.2.0": "0c836abhh3k8yn5ymg8wx383ay7n731gkrbbp3gma352yq7mhb9a",
"registry+https://github.com/rust-lang/crates.io-index#target-lexicon@0.12.12": "02lk65ik5ffb8vl9qzq02v0df8kxrp16zih78a33mji49789zhql",
"registry+https://github.com/rust-lang/crates.io-index#tempdir@0.3.7": "1n5n86zxpgd85y0mswrp5cfdisizq2rv3la906g6ipyc03xvbwhm",
"registry+https://github.com/rust-lang/crates.io-index#tempfile@3.8.1": "1r88v07zdafzf46y63vs39rmzwl4vqd4g2c5qarz9mqa8nnavwby",
"registry+https://github.com/rust-lang/crates.io-index#termcolor@1.4.0": "0jfllflbxxffghlq6gx4csv0bv0qv77943dcx01h9zssy39w66zz",
"registry+https://github.com/rust-lang/crates.io-index#thiserror-impl@1.0.51": "1ps9ylhlk2vn19fv3cxp40j3wcg1xmb117g2z2fbf4vmg2bj4x01",
"registry+https://github.com/rust-lang/crates.io-index#thiserror@1.0.51": "1drvyim21w5sga3izvnvivrdp06l2c24xwbhp0vg1mhn2iz2277i",
"registry+https://github.com/rust-lang/crates.io-index#tiff@0.6.1": "0ds48vs919ccxa3fv1www7788pzkvpg434ilqkq7sjb5dmqg8lws",
"registry+https://github.com/rust-lang/crates.io-index#tiff@0.9.0": "04b2fd3clxm0pmdlfip8xj594zyrsfwmh641i6x1gfiz9l7jn5vd",
"registry+https://github.com/rust-lang/crates.io-index#time-core@0.1.2": "1wx3qizcihw6z151hywfzzyd1y5dl804ydyxci6qm07vbakpr4pg",
"registry+https://github.com/rust-lang/crates.io-index#time-macros@0.2.16": "0gx4ngf5g7ydqa8lf7kh9sy72rd4dhvpi31y1jvswi0288rpw696",
"registry+https://github.com/rust-lang/crates.io-index#time@0.1.45": "0nl0pzv9yf56djy8y5dx25nka5pr2q1ivlandb3d24pksgx7ly8v",
"registry+https://github.com/rust-lang/crates.io-index#time@0.3.31": "0gjqcdsdbh0r5vi4c2vrj5a6prdviapx731wwn07cvpqqd1blmzn",
"registry+https://github.com/rust-lang/crates.io-index#tinystr@0.7.5": "1khf3j95bwwksj2hw76nlvwlwpwi4d1j421lj6x35arqqprjph43",
"registry+https://github.com/rust-lang/crates.io-index#tinyvec@1.6.0": "0l6bl2h62a5m44jdnpn7lmj14rd44via8180i7121fvm73mmrk47",
"registry+https://github.com/rust-lang/crates.io-index#tinyvec_macros@0.1.1": "081gag86208sc3y6sdkshgw3vysm5d34p431dzw0bshz66ncng0z",
"registry+https://github.com/rust-lang/crates.io-index#tokio-macros@2.2.0": "0fwjy4vdx1h9pi4g2nml72wi0fr27b5m954p13ji9anyy8l1x2jv",
"registry+https://github.com/rust-lang/crates.io-index#tokio-native-tls@0.3.1": "1wkfg6zn85zckmv4im7mv20ca6b1vmlib5xwz9p7g19wjfmpdbmv",
"registry+https://github.com/rust-lang/crates.io-index#tokio-stream@0.1.14": "0hi8hcwavh5sdi1ivc9qc4yvyr32f153c212dpd7sb366y6rhz1r",
"registry+https://github.com/rust-lang/crates.io-index#tokio-tungstenite@0.20.1": "0v1v24l27hxi5hlchs7hfd5rgzi167x0ygbw220nvq0w5b5msb91",
"registry+https://github.com/rust-lang/crates.io-index#tokio-util@0.7.10": "058y6x4mf0fsqji9rfyb77qbfyc50y4pk2spqgj6xsyr693z66al",
"registry+https://github.com/rust-lang/crates.io-index#tokio@1.35.1": "01613rkziqp812a288ga65aqygs254wgajdi57v8brivjkx4x6y8",
"registry+https://github.com/rust-lang/crates.io-index#toml@0.8.2": "0g9ysjaqvm2mv8q85xpqfn7hi710hj24sd56k49wyddvvyq8lp8q",
"registry+https://github.com/rust-lang/crates.io-index#toml_datetime@0.6.3": "0jsy7v8bdvmzsci6imj8fzgd255fmy5fzp6zsri14yrry7i77nkw",
"registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.19.15": "08bl7rp5g6jwmfpad9s8jpw8wjrciadpnbaswgywpr9hv9qbfnqv",
"registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.20.2": "0f7k5svmxw98fhi28jpcyv7ldr2s3c867pjbji65bdxjpd44svir",
"registry+https://github.com/rust-lang/crates.io-index#tower-service@0.3.2": "0lmfzmmvid2yp2l36mbavhmqgsvzqf7r2wiwz73ml4xmwaf1rg5n",
"registry+https://github.com/rust-lang/crates.io-index#tracing-attributes@0.1.27": "1rvb5dn9z6d0xdj14r403z0af0bbaqhg02hq4jc97g5wds6lqw1l",
"registry+https://github.com/rust-lang/crates.io-index#tracing-core@0.1.32": "0m5aglin3cdwxpvbg6kz0r9r0k31j48n0kcfwsp6l49z26k3svf0",
"registry+https://github.com/rust-lang/crates.io-index#tracing@0.1.40": "1vv48dac9zgj9650pg2b4d0j3w6f3x9gbggf43scq5hrlysklln3",
"registry+https://github.com/rust-lang/crates.io-index#traitobject@0.1.0": "0yb0n8822mr59j200fyr2fxgzzgqljyxflx9y8bdy3rlaqngilgg",
"registry+https://github.com/rust-lang/crates.io-index#try-lock@0.2.5": "0jqijrrvm1pyq34zn1jmy2vihd4jcrjlvsh4alkjahhssjnsn8g4",
"registry+https://github.com/rust-lang/crates.io-index#tungstenite@0.20.1": "1fbgcv3h4h1bhhf5sqbwqsp7jnc44bi4m41sgmhzdsk2zl8aqgcy",
"registry+https://github.com/rust-lang/crates.io-index#type-map@0.4.0": "0ilsqq7pcl3k9ggxv2x5fbxxfd6x7ljsndrhc38jmjwnbr63dlxn",
"registry+https://github.com/rust-lang/crates.io-index#typeable@0.1.2": "11w8dywgnm32hb291izjvh4zjd037ccnkk77ahk63l913zwzc40l",
"registry+https://github.com/rust-lang/crates.io-index#typemap@0.3.3": "1xm1gbvz9qisj1l6d36hrl9pw8imr8ngs6qyanjnsad3h0yfcfv5",
"registry+https://github.com/rust-lang/crates.io-index#typenum@1.17.0": "09dqxv69m9lj9zvv6xw5vxaqx15ps0vxyy5myg33i0kbqvq0pzs2",
"registry+https://github.com/rust-lang/crates.io-index#typeshare-annotation@1.0.2": "1adpfhyz3lqjjbq2ym69mv62ymqyd5651gxlqdy8aa446l70srzw",
"registry+https://github.com/rust-lang/crates.io-index#typeshare@1.0.1": "1mi7snkx2b4g84x8vx38v1myg5r6g48c865j0nz5zcsc8lpilkgl",
"registry+https://github.com/rust-lang/crates.io-index#unarray@0.1.4": "154smf048k84prsdgh09nkm2n0w0336v84jd4zikyn6v6jrqbspa",
"registry+https://github.com/rust-lang/crates.io-index#unic-langid-impl@0.9.4": "1ijvqmsrg6qw3b1h9bh537pvwk2jn2kl6ck3z3qlxspxcch5mmab",
"registry+https://github.com/rust-lang/crates.io-index#unic-langid@0.9.4": "05pm5p3j29c9jw9a4dr3v64g3x6g3zh37splj47i7vclszk251r3",
"registry+https://github.com/rust-lang/crates.io-index#unicase@1.4.2": "0cwazh4qsmm9msckjk86zc1z35xg7hjxjykrgjalzdv367w6aivz",
"registry+https://github.com/rust-lang/crates.io-index#unicase@2.7.0": "12gd74j79f94k4clxpf06l99wiv4p30wjr0qm04ihqk9zgdd9lpp",
"registry+https://github.com/rust-lang/crates.io-index#unicode-bidi@0.3.14": "05i4ps31vskq1wdp8yf315fxivyh1frijly9d4gb5clygbr2h9bg",
"registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.12": "0jzf1znfpb2gx8nr8mvmyqs1crnv79l57nxnbiszc7xf7ynbjm1k",
"registry+https://github.com/rust-lang/crates.io-index#unicode-normalization@0.1.22": "08d95g7b1irc578b2iyhzv4xhsa4pfvwsqxcl9lbcpabzkq16msw",
"registry+https://github.com/rust-lang/crates.io-index#unicode-segmentation@1.10.1": "0dky2hm5k51xy11hc3nk85p533rvghd462b6i0c532b7hl4j9mhx",
"registry+https://github.com/rust-lang/crates.io-index#unicode_categories@0.1.1": "0kp1d7fryxxm7hqywbk88yb9d1avsam9sg76xh36k5qx2arj9v1r",
"registry+https://github.com/rust-lang/crates.io-index#unsafe-any@0.4.2": "0zwwphsqkw5qaiqmjwngnfpv9ym85qcsyj7adip9qplzjzbn00zk",
"registry+https://github.com/rust-lang/crates.io-index#url@1.7.2": "0nim1c90mxpi9wgdw2xh8dqd72vlklwlzam436akcrhjac6pqknx",
"registry+https://github.com/rust-lang/crates.io-index#url@2.5.0": "0cs65961miawncdg2z20171w0vqrmraswv2ihdpd8lxp7cp31rii",
"registry+https://github.com/rust-lang/crates.io-index#urlencoding@2.1.3": "1nj99jp37k47n0hvaz5fvz7z6jd0sb4ppvfy3nphr1zbnyixpy6s",
"registry+https://github.com/rust-lang/crates.io-index#utf-8@0.7.6": "1a9ns3fvgird0snjkd3wbdhwd3zdpc2h5gpyybrfr6ra5pkqxk09",
"registry+https://github.com/rust-lang/crates.io-index#utf8parse@0.2.1": "02ip1a0az0qmc2786vxk2nqwsgcwf17d3a38fkf0q7hrmwh9c6vi",
"registry+https://github.com/rust-lang/crates.io-index#uuid@0.4.0": "0cdj2v6v2yy3zyisij69waksd17cyir1n58kwyk1n622105wbzkw",
"registry+https://github.com/rust-lang/crates.io-index#uuid@0.8.2": "1dy4ldcp7rnzjy56dxh7d2sgrcvn4q77y0a8r0a48946h66zjp5w",
"registry+https://github.com/rust-lang/crates.io-index#uuid@1.6.1": "0q45jxahvysldn3iy04m8xmr8hgig80855y9gq9di8x72v7myfay",
"registry+https://github.com/rust-lang/crates.io-index#value-bag@1.7.0": "02r8wccrzi3bzlkrslkcfw9pwp8kwif9szif2i9arn9dzqx44vhj",
"registry+https://github.com/rust-lang/crates.io-index#vcpkg@0.2.15": "09i4nf5y8lig6xgj3f7fyrvzd3nlaw4znrihw8psidvv5yk4xkdc",
"registry+https://github.com/rust-lang/crates.io-index#version-compare@0.1.1": "0acg4pmjdbmclg0m7yhijn979mdy66z3k8qrcnvn634f1gy456jp",
"registry+https://github.com/rust-lang/crates.io-index#version_check@0.1.5": "1pf91pvj8n6akh7w6j5ypka6aqz08b3qpzgs0ak2kjf4frkiljwi",
"registry+https://github.com/rust-lang/crates.io-index#version_check@0.9.4": "0gs8grwdlgh0xq660d7wr80x14vxbizmd8dbp29p2pdncx8lp1s9",
"registry+https://github.com/rust-lang/crates.io-index#wait-timeout@0.2.0": "1xpkk0j5l9pfmjfh1pi0i89invlavfrd9av5xp0zhxgb29dhy84z",
"registry+https://github.com/rust-lang/crates.io-index#waker-fn@1.1.1": "142n74wlmpwcazfb5v7vhnzj3lb3r97qy8mzpjdpg345aizm3i7k",
"registry+https://github.com/rust-lang/crates.io-index#want@0.3.1": "03hbfrnvqqdchb5kgxyavb9jabwza0dmh2vw5kg0dq8rxl57d9xz",
"registry+https://github.com/rust-lang/crates.io-index#warp@0.3.6": "0sfimrpxkyka1mavfhg5wa4x977qs8vyxa510c627w9zw0i2xsf1",
"registry+https://github.com/rust-lang/crates.io-index#wasi@0.10.0+wasi-snapshot-preview1": "07y3l8mzfzzz4cj09c8y90yak4hpsi9g7pllyzpr6xvwrabka50s",
"registry+https://github.com/rust-lang/crates.io-index#wasi@0.11.0+wasi-snapshot-preview1": "08z4hxwkpdpalxjps1ai9y7ihin26y9f476i53dv98v45gkqg3cw",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-backend@0.2.89": "09l8lyylsdssz993h4fzja69zpvpykaw84fivs210fjgwqjzcmhv",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-futures@0.4.39": "04lsxpw4jqfwh7c0crzx0smj52nvwp1w3bh4098sq90149da2dmc",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-macro-support@0.2.89": "10sj1gr2naxv5q116yjb929hhpvz45dxbkvyk8hyc2lknzy85szh",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-macro@0.2.89": "1cl2w7k5jn2jbd5kx613c8k8vjvda22hfgcgx7y2mk93fbrxnqh1",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen-shared@0.2.89": "17s5rppad113c6ggkaq8c3cg7a3zz15i78wxcg6mcl1n15iv7fbs",
"registry+https://github.com/rust-lang/crates.io-index#wasm-bindgen@0.2.89": "0kh6akdldy13z9xqj0skz6b4npq1d98bjkgzb8ccq59hibvd9l0f",
"registry+https://github.com/rust-lang/crates.io-index#web-sys@0.3.66": "03q1z22djv5ncqkyydcvnchmdsl5gvnyzcyixkxnifw6xi24mhjh",
"registry+https://github.com/rust-lang/crates.io-index#weezl@0.1.7": "1frdbq6y5jn2j93i20hc80swpkj30p1wffwxj1nr4fp09m6id4wi",
"registry+https://github.com/rust-lang/crates.io-index#whoami@1.4.1": "0l6ca9pl92wmngsn1dh9ih716v216nmn2zvcn94k04x9p1b3gz12",
"registry+https://github.com/rust-lang/crates.io-index#winapi-i686-pc-windows-gnu@0.4.0": "1dmpa6mvcvzz16zg6d5vrfy4bxgg541wxrcip7cnshi06v38ffxc",
"registry+https://github.com/rust-lang/crates.io-index#winapi-util@0.1.6": "15i5lm39wd44004i9d5qspry2cynkrpvwzghr6s2c3dsk28nz7pj",
"registry+https://github.com/rust-lang/crates.io-index#winapi-x86_64-pc-windows-gnu@0.4.0": "0gqq64czqb64kskjryj8isp62m2sgvx25yyj3kpc2myh85w24bki",
"registry+https://github.com/rust-lang/crates.io-index#winapi@0.3.9": "06gl025x418lchw1wxj64ycr7gha83m44cjr5sarhynd9xkrm0sw",
"registry+https://github.com/rust-lang/crates.io-index#windows-core@0.51.1": "0r1f57hsshsghjyc7ypp2s0i78f7b1vr93w68sdb8baxyf2czy7i",
"registry+https://github.com/rust-lang/crates.io-index#windows-sys@0.48.0": "1aan23v5gs7gya1lc46hqn9mdh8yph3fhxmhxlw36pn6pqc28zb7",
"registry+https://github.com/rust-lang/crates.io-index#windows-sys@0.52.0": "0gd3v4ji88490zgb6b5mq5zgbvwv7zx1ibn8v3x83rwcdbryaar8",
"registry+https://github.com/rust-lang/crates.io-index#windows-targets@0.48.5": "034ljxqshifs1lan89xwpcy1hp0lhdh4b5n0d2z4fwjx2piacbws",
"registry+https://github.com/rust-lang/crates.io-index#windows-targets@0.52.0": "1kg7a27ynzw8zz3krdgy6w5gbqcji27j1sz4p7xk2j5j8082064a",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_gnullvm@0.48.5": "1n05v7qblg1ci3i567inc7xrkmywczxrs1z3lj3rkkxw18py6f1b",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_gnullvm@0.52.0": "1shmn1kbdc0bpphcxz0vlph96bxz0h1jlmh93s9agf2dbpin8xyb",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_msvc@0.48.5": "1g5l4ry968p73g6bg6jgyvy9lb8fyhcs54067yzxpcpkf44k2dfw",
"registry+https://github.com/rust-lang/crates.io-index#windows_aarch64_msvc@0.52.0": "1vvmy1ypvzdvxn9yf0b8ygfl85gl2gpcyvsvqppsmlpisil07amv",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_gnu@0.48.5": "0gklnglwd9ilqx7ac3cn8hbhkraqisd0n83jxzf9837nvvkiand7",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_gnu@0.52.0": "04zkglz4p3pjsns5gbz85v4s5aw102raz4spj4b0lmm33z5kg1m2",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_msvc@0.48.5": "01m4rik437dl9rdf0ndnm2syh10hizvq0dajdkv2fjqcywrw4mcg",
"registry+https://github.com/rust-lang/crates.io-index#windows_i686_msvc@0.52.0": "16kvmbvx0vr0zbgnaz6nsks9ycvfh5xp05bjrhq65kj623iyirgz",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnu@0.48.5": "13kiqqcvz2vnyxzydjh73hwgigsdr2z1xpzx313kxll34nyhmm2k",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnu@0.52.0": "1zdy4qn178sil5sdm63lm7f0kkcjg6gvdwmcprd2yjmwn8ns6vrx",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnullvm@0.48.5": "1k24810wfbgz8k48c2yknqjmiigmql6kk3knmddkv8k8g1v54yqb",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_gnullvm@0.52.0": "17lllq4l2k1lqgcnw1cccphxp9vs7inq99kjlm2lfl9zklg7wr8s",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_msvc@0.48.5": "0f4mdp895kkjh9zv8dxvn4pc10xr7839lf5pa9l0193i2pkgr57d",
"registry+https://github.com/rust-lang/crates.io-index#windows_x86_64_msvc@0.52.0": "012wfq37f18c09ij5m6rniw7xxn5fcvrxbqd0wd8vgnl3hfn9yfz",
"registry+https://github.com/rust-lang/crates.io-index#winnow@0.5.30": "1ifj9vnqna5qp0d7nb9mrinzf8j7zi1m0gv75870vm91jyw3sp4v",
"registry+https://github.com/rust-lang/crates.io-index#winreg@0.50.0": "1cddmp929k882mdh6i9f2as848f13qqna6czwsqzkh1pqnr5fkjj",
"registry+https://github.com/rust-lang/crates.io-index#zerocopy-derive@0.7.31": "06k0zk4x4n9s1blgxmxqb1g81y8q334aayx61gyy6v9y1dajkhdk",
"registry+https://github.com/rust-lang/crates.io-index#zerocopy@0.7.31": "0gcfyrmlrhmsz16qxjp2qzr6vixyaw1p04zl28f08lxkvfz62h0w",
"registry+https://github.com/rust-lang/crates.io-index#zeroize@1.7.0": "0bfvby7k9pdp6623p98yz2irqnamcyzpn7zh20nqmdn68b0lwnsj",
"registry+https://github.com/rust-lang/crates.io-index#zune-inflate@0.2.54": "00kg24jh3zqa3i6rg6yksnb71bch9yi1casqydl00s7nw8pk7avk"
}

View File

@ -2,11 +2,12 @@
name = "cyberpunk-splash"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-only"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cairo-rs = { version = "0.17" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gtk = { version = "0.6", package = "gtk4" }
cairo-rs = { version = "0.18" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4" }

View File

@ -2,8 +2,8 @@ use cairo::{
Context, FontSlant, FontWeight, Format, ImageSurface, LineCap, LinearGradient, Pattern,
TextExtents,
};
use glib::{GString, Object};
use gtk::{gdk::Key, prelude::*, subclass::prelude::*, EventControllerKey};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*, EventControllerKey};
use std::{
cell::RefCell,
rc::Rc,
@ -14,12 +14,6 @@ use std::{
const WIDTH: i32 = 1600;
const HEIGHT: i32 = 600;
#[derive(Clone, Copy, Debug)]
enum Event {
Frames(u8),
Time(Duration),
}
#[derive(Clone, Copy, Debug)]
pub enum State {
Running {
@ -50,7 +44,7 @@ impl State {
*self = Self::Running {
last_update: Instant::now(),
deadline: Instant::now() + *time_remaining,
timeout: timeout.clone(),
timeout: *timeout,
};
}
}
@ -62,7 +56,7 @@ impl State {
{
*self = Self::Paused {
time_remaining: *deadline - Instant::now(),
timeout: timeout.clone(),
timeout: *timeout,
}
}
}
@ -108,13 +102,13 @@ impl TimeoutAnimation {
fn tick(&mut self, frames_elapsed: u8) {
let step_size = 1. / (self.duration * 60.);
if self.ascending {
self.intensity = self.intensity + step_size * frames_elapsed as f64;
self.intensity += step_size * frames_elapsed as f64;
if self.intensity > 1. {
self.intensity = 1.0;
self.ascending = false;
}
} else {
self.intensity = self.intensity - step_size * frames_elapsed as f64;
self.intensity -= step_size * frames_elapsed as f64;
if self.intensity < 0. {
self.intensity = 0.0;
self.ascending = true;
@ -148,7 +142,6 @@ impl SplashPrivate {
*self.height.borrow(),
2.,
8.,
8.,
(0.7, 0., 1.),
);
@ -333,7 +326,7 @@ impl Splash {
let _ = context.set_source(&*background);
let _ = context.paint();
let state = s.imp().state.borrow().clone();
let state = *s.imp().state.borrow();
let time = match state {
State::Running { deadline, .. } => deadline - Instant::now(),
@ -359,7 +352,7 @@ impl Splash {
let mut saved_extents = s.imp().time_extents.borrow_mut();
if saved_extents.is_none() {
*saved_extents = Some(time_extents.clone());
*saved_extents = Some(time_extents);
}
let time_baseline_x = center_x - time_extents.width() / 2.;
@ -372,8 +365,8 @@ impl Splash {
time_baseline_y,
);
let (running, timeout_animation) = match state {
State::Running { timeout, .. } => (true, timeout.clone()),
State::Paused { timeout, .. } => (false, timeout.clone()),
State::Running { timeout, .. } => (true, timeout),
State::Paused { timeout, .. } => (false, timeout),
};
match timeout_animation {
Some(ref animation) => {
@ -395,21 +388,18 @@ impl Splash {
let _ = context.show_text(&time);
};
match *s.imp().time_extents.borrow() {
Some(extents) => {
context.set_source_rgb(0.7, 0.0, 1.0);
let time_meter = SlashMeter {
orientation: gtk::Orientation::Horizontal,
start_x: center_x + extents.width() / 2. + 50.,
start_y: center_y + 100.,
count: 5,
fill_count: minutes as u8,
height: 60.,
length: 100.,
};
time_meter.draw(&context);
}
None => {}
if let Some(extents) = *s.imp().time_extents.borrow() {
context.set_source_rgb(0.7, 0.0, 1.0);
let time_meter = SlashMeter {
orientation: gtk::Orientation::Horizontal,
start_x: center_x + extents.width() / 2. + 50.,
start_y: center_y + 100.,
count: 5,
fill_count: minutes as u8,
height: 60.,
length: 100.,
};
time_meter.draw(context);
}
}
});
@ -544,7 +534,7 @@ impl SlashMeter {
gtk::Orientation::Horizontal => {
let angle: f64 = 0.8;
let run = self.height / angle.tan();
let width = self.length as f64 / (self.count as f64 * 2.);
let width = self.length / (self.count as f64 * 2.);
for c in 0..self.count {
context.set_line_width(1.);
@ -579,10 +569,6 @@ trait Pen {
struct GlowPen {
blur_context: Context,
draw_context: Context,
line_width: f64,
blur_line_width: f64,
blur_size: f64,
}
impl GlowPen {
@ -591,7 +577,6 @@ impl GlowPen {
height: i32,
line_width: f64,
blur_line_width: f64,
blur_size: f64,
color: (f64, f64, f64),
) -> Self {
let blur_context =
@ -611,9 +596,6 @@ impl GlowPen {
Self {
blur_context,
draw_context,
line_width,
blur_line_width,
blur_size,
}
}
}
@ -630,8 +612,10 @@ impl Pen for GlowPen {
}
fn stroke(&self) {
self.blur_context.stroke();
self.draw_context.stroke();
self.blur_context.stroke().expect("to draw the blur line");
self.draw_context
.stroke()
.expect("to draw the regular line");
}
fn finish(self) -> Pattern {
@ -681,7 +665,7 @@ fn main() {
let countdown = match options.lookup::<String>("countdown") {
Ok(Some(countdown_str)) => {
let parts = countdown_str.split(':').collect::<Vec<&str>>();
let duration = match parts.len() {
match parts.len() {
2 => {
let minutes = parts[0].parse::<u64>().unwrap();
let seconds = parts[1].parse::<u64>().unwrap();
@ -692,8 +676,7 @@ fn main() {
Duration::from_secs(seconds)
}
_ => Duration::from_secs(300),
};
duration
}
}
_ => Duration::from_secs(300),
};
@ -720,12 +703,12 @@ fn main() {
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<State>(gtk::glib::PRIORITY_DEFAULT);
gtk::glib::MainContext::channel::<State>(gtk::glib::Priority::DEFAULT);
let window = gtk::ApplicationWindow::new(app);
window.present();
let splash = Splash::new(title.read().unwrap().clone(), state.read().unwrap().clone());
let splash = Splash::new(title.read().unwrap().clone(), *state.read().unwrap());
window.set_child(Some(&splash));
@ -753,7 +736,7 @@ fn main() {
gtk_rx.attach(None, move |state| {
splash.set_state(state);
Continue(true)
glib::ControlFlow::Continue
});
std::thread::spawn({
@ -763,7 +746,7 @@ fn main() {
loop {
std::thread::sleep(Duration::from_millis(1000 / 60));
state.write().unwrap().run(Instant::now());
let _ = gtk_tx.send(state.read().unwrap().clone());
let _ = gtk_tx.send(*state.read().unwrap());
}
}
});

View File

@ -1,22 +1,22 @@
[package]
name = "dashboard"
version = "0.1.0"
version = "0.1.2"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.17" }
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.18" }
chrono = { version = "0.4", features = ["serde"] }
fluent-ergonomics = { path = "../fluent-ergonomics/" }
fluent = { version = "0.16" }
futures = { version = "0.3" }
geo-types = { path = "../geo-types/" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gdk = { version = "0.6", package = "gdk4" }
gtk = { version = "0.6", package = "gtk4" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4" }
ifc = { path = "../ifc/" }
lazy_static = { version = "1.4" }
memorycache = { path = "../memorycache/" }
@ -28,5 +28,5 @@ tokio = { version = "1", features = ["full"] }
unic-langid = { version = "0.9" }
[build-dependencies]
glib-build-tools = "0.16"
glib-build-tools = "0.18"

View File

@ -1,7 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
"resources",
"resources/gresources.xml",
&["resources"],
"gresources.xml",
"com.luminescent-dreams.dashboard.gresource",
);
}

View File

@ -1,11 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
set -x
VERSION=`cat Cargo.toml | grep "^version =" | sed -r 's/^version = "(.+)"$/\1/'`
mkdir -p dist
cp dashboard.desktop dist
cp ../target/release/dashboard dist
strip dist/dashboard
tar -cf dashboard.tgz dist/
tar -czf dashboard-${VERSION}.tgz dist/

View File

@ -40,16 +40,16 @@ impl ApplicationWindow {
.vexpand(true)
.build();
let date_label = Date::new();
let date_label = Date::default();
layout.append(&date_label);
let events = Events::new();
let events = Events::default();
layout.append(&events);
let transit_card = TransitCard::new();
let transit_card = TransitCard::default();
layout.append(&transit_card);
let transit_clock = TransitClock::new();
let transit_clock = TransitClock::default();
layout.append(&transit_clock);
window.set_content(Some(&layout));

View File

@ -11,10 +11,11 @@ pub struct DatePrivate {
impl Default for DatePrivate {
fn default() -> Self {
let date = chrono::Local::now().date_naive();
let year = date.year();
let date = date.with_year(year + 10000).unwrap();
Self {
date: Rc::new(RefCell::new(IFC::from(
chrono::Local::now().date_naive().with_year(12023).unwrap(),
))),
date: Rc::new(RefCell::new(IFC::from(date))),
label: Rc::new(RefCell::new(gtk::Label::new(None))),
}
}
@ -35,8 +36,8 @@ glib::wrapper! {
pub struct Date(ObjectSubclass<DatePrivate>) @extends gtk::Box, gtk::Widget;
}
impl Date {
pub fn new() -> Self {
impl Default for Date {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_margin_bottom(8);
s.set_margin_top(8);
@ -48,7 +49,9 @@ impl Date {
s.redraw();
s
}
}
impl Date {
pub fn update_date(&self, date: IFC) {
*self.imp().date.borrow_mut() = date;
self.redraw();

View File

@ -1,13 +1,12 @@
use crate::{
components::Date,
solstices::{self, YearlyEvents},
soluna_client::SunMoon,
};
use chrono::TimeZone;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*, IconLookupFlags};
use gtk::{prelude::*, subclass::prelude::*};
use ifc::IFC;
/*
#[derive(PartialEq)]
pub enum UpcomingEvent {
SpringEquinox,
@ -15,25 +14,15 @@ pub enum UpcomingEvent {
AutumnEquinox,
WinterSolstice,
}
*/
#[derive(Default)]
pub struct EventsPrivate {
spring_equinox: Date,
summer_solstice: Date,
autumn_equinox: Date,
winter_solstice: Date,
next: UpcomingEvent,
}
impl Default for EventsPrivate {
fn default() -> Self {
Self {
spring_equinox: Date::new(),
summer_solstice: Date::new(),
autumn_equinox: Date::new(),
winter_solstice: Date::new(),
next: UpcomingEvent::SpringEquinox,
}
}
// next: UpcomingEvent,
}
#[glib::object_subclass]
@ -51,8 +40,8 @@ glib::wrapper! {
pub struct Events(ObjectSubclass<EventsPrivate>) @extends gtk::Widget, gtk::Box, @implements gtk::Orientable;
}
impl Events {
pub fn new() -> Self {
impl Default for Events {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Horizontal);
s.set_spacing(8);
@ -64,7 +53,9 @@ impl Events {
s
}
}
impl Events {
pub fn set_events(&self, events: YearlyEvents, next_event: solstices::Event) {
self.imp()
.spring_equinox

View File

@ -1,6 +1,5 @@
use crate::soluna_client::SunMoon;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*, IconLookupFlags};
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct LabelPrivate {

View File

@ -1,6 +1,6 @@
use crate::{components::Label, soluna_client::SunMoon};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*, IconLookupFlags};
use gtk::{prelude::*, subclass::prelude::*};
pub struct TransitCardPrivate {
sunrise: Label,
@ -35,8 +35,8 @@ glib::wrapper! {
pub struct TransitCard(ObjectSubclass<TransitCardPrivate>) @extends gtk::Grid, gtk::Widget;
}
impl TransitCard {
pub fn new() -> Self {
impl Default for TransitCard {
fn default() -> Self {
let s: Self = Object::builder().build();
s.add_css_class("card");
s.set_column_homogeneous(true);
@ -48,7 +48,9 @@ impl TransitCard {
s
}
}
impl TransitCard {
pub fn update_transit(&self, transit_info: &SunMoon) {
self.imp()
.sunrise

View File

@ -7,18 +7,11 @@ use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, f64::consts::PI, rc::Rc};
#[derive(Default)]
pub struct TransitClockPrivate {
info: Rc<RefCell<Option<SunMoon>>>,
}
impl Default for TransitClockPrivate {
fn default() -> Self {
Self {
info: Rc::new(RefCell::new(None)),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for TransitClockPrivate {
const NAME: &'static str = "TransitClock";
@ -34,8 +27,8 @@ glib::wrapper! {
pub struct TransitClock(ObjectSubclass<TransitClockPrivate>) @extends gtk::DrawingArea, gtk::Widget;
}
impl TransitClock {
pub fn new() -> Self {
impl Default for TransitClock {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(500);
s.set_height_request(500);
@ -100,7 +93,9 @@ impl TransitClock {
s
}
}
impl TransitClock {
pub fn update_transit(&self, transit_info: SunMoon) {
*self.imp().info.borrow_mut() = Some(transit_info);
self.queue_draw();

View File

@ -90,7 +90,7 @@ pub fn main() {
tx: Arc::new(RwLock::new(None)),
};
let _ = runtime.spawn({
runtime.spawn({
let core = core.clone();
async move {
let soluna_client = SolunaClient::new();
@ -102,7 +102,7 @@ pub fn main() {
let now = Local::now();
let state = State {
date: IFC::from(now.date_naive().with_year(12023).unwrap()),
date: IFC::from(now.date_naive().with_year(now.year() + 10000).unwrap()),
next_event: EVENTS.next_event(now.with_timezone(&Utc)).unwrap(),
events: EVENTS.yearly_events(now.year()).unwrap(),
transit: Some(transit),
@ -110,15 +110,17 @@ pub fn main() {
if let Some(ref gtk_tx) = *core.tx.read().unwrap() {
let _ = gtk_tx.send(Message::Refresh(state.clone()));
std::thread::sleep(std::time::Duration::from_secs(60));
} else {
std::thread::sleep(std::time::Duration::from_secs(1));
}
std::thread::sleep(std::time::Duration::from_secs(60));
}
}
});
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<Message>(gtk::glib::PRIORITY_DEFAULT);
gtk::glib::MainContext::channel::<Message>(gtk::glib::Priority::DEFAULT);
*core.tx.write().unwrap() = Some(gtk_tx);
@ -131,11 +133,9 @@ pub fn main() {
let Message::Refresh(state) = msg;
ApplicationWindow::update_state(&window, state);
Continue(true)
glib::ControlFlow::Continue
}
});
std::thread::spawn(move || {});
});
let args: Vec<String> = env::args().collect();

View File

@ -1,11 +1,10 @@
use chrono;
use chrono::prelude::*;
use lazy_static::lazy_static;
use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
// http://astropixels.com/ephemeris/soleq2001.html
const SOLSTICE_TEXT: &'static str = "
const SOLSTICE_TEXT: &str = "
2001 Mar 20 13:31 Jun 21 07:38 Sep 22 23:05 Dec 21 19:22
2002 Mar 20 19:16 Jun 21 13:25 Sep 23 04:56 Dec 22 01:15
2003 Mar 21 01:00 Jun 21 19:11 Sep 23 10:47 Dec 22 07:04
@ -91,12 +90,14 @@ impl Event {
}
fn parse_time<'a>(
jaro: &str,
year: &str,
iter: impl Iterator<Item = &'a str>,
) -> chrono::DateTime<chrono::Utc> {
let partoj = iter.collect::<Vec<&str>>();
let p = format!("{} {} {} {}", jaro, partoj[0], partoj[1], partoj[2]);
chrono::Utc.datetime_from_str(&p, "%Y %b %d %H:%M").unwrap()
let parts = iter.collect::<Vec<&str>>();
let p = format!("{} {} {} {}", year, parts[0], parts[1], parts[2]);
NaiveDateTime::parse_from_str(&p, "%Y %b %d %H:%M")
.unwrap()
.and_utc()
}
fn parse_line(year: &str, rest: &[&str]) -> YearlyEvents {
@ -118,7 +119,7 @@ fn parse_events() -> Vec<Option<YearlyEvents>> {
.lines()
.map(|line| {
match line
.split(" ")
.split(' ')
.filter(|elem| !elem.is_empty())
.collect::<Vec<&str>>()
.as_slice()
@ -134,7 +135,7 @@ pub struct Solstices(HashMap<i32, YearlyEvents>);
impl Solstices {
pub fn yearly_events(&self, year: i32) -> Option<YearlyEvents> {
self.0.get(&year).map(|c| c.clone())
self.0.get(&year).copied()
}
pub fn next_event(&self, date: chrono::DateTime<chrono::Utc>) -> Option<Event> {
@ -142,17 +143,17 @@ impl Solstices {
match year_events {
Some(year_events) => {
if date <= year_events.spring_equinox {
Some(Event::SpringEquinox(year_events.spring_equinox.clone()))
Some(Event::SpringEquinox(year_events.spring_equinox))
} else if date <= year_events.summer_solstice {
Some(Event::SummerSolstice(year_events.summer_solstice.clone()))
Some(Event::SummerSolstice(year_events.summer_solstice))
} else if date <= year_events.autumn_equinox {
Some(Event::AutumnEquinox(year_events.autumn_equinox.clone()))
Some(Event::AutumnEquinox(year_events.autumn_equinox))
} else if date <= year_events.winter_solstice {
Some(Event::WinterSolstice(year_events.winter_solstice.clone()))
Some(Event::WinterSolstice(year_events.winter_solstice))
} else {
self.0
.get(&(date.year() + 1))
.map(|_| Event::SpringEquinox(year_events.spring_equinox.clone()))
.map(|_| Event::SpringEquinox(year_events.spring_equinox))
}
}
None => None,
@ -165,7 +166,7 @@ impl From<Vec<Option<YearlyEvents>>> for Solstices {
Solstices(event_list.iter().fold(HashMap::new(), |mut m, record| {
match record {
Some(record) => {
m.insert(record.year, record.clone());
m.insert(record.year, *record);
}
None => (),
}
@ -177,3 +178,24 @@ impl From<Vec<Option<YearlyEvents>>> for Solstices {
lazy_static! {
pub static ref EVENTS: Solstices = Solstices::from(parse_events());
}
#[cfg(test)]
mod test {
use chrono::{NaiveDate, NaiveDateTime};
#[test]
fn it_can_parse_a_solstice_time() {
let p = "2001 Mar 20 13:31".to_owned();
let parsed_date = NaiveDateTime::parse_from_str(&p, "%Y %b %d %H:%M")
.unwrap()
.and_utc();
assert_eq!(
parsed_date,
NaiveDate::from_ymd_opt(2001, 03, 20)
.unwrap()
.and_hms_opt(13, 31, 0)
.unwrap()
.and_utc()
);
}
}

View File

@ -4,7 +4,6 @@
use chrono::{DateTime, Duration, Local, NaiveTime, Offset, TimeZone, Timelike, Utc};
use geo_types::{Latitude, Longitude};
use memorycache::MemoryCache;
use reqwest;
use serde::Deserialize;
const ENDPOINT: &str = "https://api.solunar.org/solunar";
@ -26,8 +25,8 @@ impl SunMoon {
let sunrise = parse_time(val.sunrise).unwrap();
let sunset = parse_time(val.sunset).unwrap();
let moonrise = val.moonrise.and_then(|v| parse_time(v));
let moonset = val.moonset.and_then(|v| parse_time(v));
let moonrise = val.moonrise.and_then(parse_time);
let moonset = val.moonset.and_then(parse_time);
Self {
sunrise,
@ -82,7 +81,7 @@ impl SolunaClient {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
memory_cache: MemoryCache::new(),
memory_cache: MemoryCache::default(),
}
}
@ -110,7 +109,7 @@ impl SolunaClient {
.get(reqwest::header::EXPIRES)
.and_then(|header| header.to_str().ok())
.and_then(|expiration| DateTime::parse_from_rfc2822(expiration).ok())
.map(|dt_local| DateTime::<Utc>::from(dt_local))
.map(DateTime::<Utc>::from)
.unwrap_or(
Local::now()
.with_hour(0)

View File

@ -10,7 +10,6 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
use date_time_tz::DateTimeTz;
use types::{Recordable, Timestamp};
/// This trait is used for constructing queries for searching the database.

View File

@ -1,177 +0,0 @@
/*
Copyright 2020-2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of the Luminescent Dreams Tools.
Luminescent Dreams Tools is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Luminescent Dreams Tools is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
extern crate chrono;
extern crate chrono_tz;
use chrono::SecondsFormat;
use chrono_tz::Etc::UTC;
use serde::de::{self, Deserialize, Deserializer, Visitor};
use serde::ser::{Serialize, Serializer};
use std::{fmt, str::FromStr};
/// This is a wrapper around date time objects, using timezones from the chroon-tz database and
/// providing string representation and parsing of the form "<RFC3339> <Timezone Name>", i.e.,
/// "2019-05-15T14:30:00Z US/Central". The to_string method, and serde serialization will
/// produce a string of this format. The parser will accept an RFC3339-only string of the forms
/// "2019-05-15T14:30:00Z", "2019-05-15T14:30:00+00:00", and also an "RFC3339 Timezone Name"
/// string.
///
/// The function here is to generate as close to unambiguous time/date strings, (for earth's
/// gravitational frame of reference), as possible. Clumping together the time, offset from UTC,
/// and the named time zone allows future parsers to know the exact interpretation of the time in
/// the frame of reference of the original recording.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct DateTimeTz(pub chrono::DateTime<chrono_tz::Tz>);
impl DateTimeTz {
pub fn map<F>(&self, f: F) -> DateTimeTz
where
F: FnOnce(chrono::DateTime<chrono_tz::Tz>) -> chrono::DateTime<chrono_tz::Tz>,
{
DateTimeTz(f(self.0))
}
pub fn to_string(&self) -> String {
if self.0.timezone() == UTC {
self.0.to_rfc3339_opts(SecondsFormat::Secs, true)
} else {
format!(
"{} {}",
self.0
.with_timezone(&chrono_tz::Etc::UTC)
.to_rfc3339_opts(SecondsFormat::Secs, true,),
self.0.timezone().name()
)
}
}
}
impl std::str::FromStr for DateTimeTz {
type Err = chrono::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v: Vec<&str> = s.split_terminator(" ").collect();
if v.len() == 2 {
let tz = v[1].parse::<chrono_tz::Tz>().unwrap();
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&tz)))
} else {
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&UTC)))
}
}
}
impl From<chrono::DateTime<chrono_tz::Tz>> for DateTimeTz {
fn from(dt: chrono::DateTime<chrono_tz::Tz>) -> DateTimeTz {
DateTimeTz(dt)
}
}
struct DateTimeTzVisitor;
impl<'de> Visitor<'de> for DateTimeTzVisitor {
type Value = DateTimeTz;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string date time representation that can be parsed")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
DateTimeTz::from_str(s).or(Err(E::custom(format!(
"string is not a parsable datetime representation"
))))
}
}
impl Serialize for DateTimeTz {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for DateTimeTz {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(DateTimeTzVisitor)
}
}
#[cfg(test)]
mod test {
extern crate serde_json;
use super::*;
use chrono::TimeZone;
use chrono_tz::America::Phoenix;
use chrono_tz::Etc::UTC;
use chrono_tz::US::{Arizona, Central};
use std::str::FromStr;
#[test]
fn it_creates_timestamp_with_z() {
let t = DateTimeTz(UTC.ymd(2019, 5, 15).and_hms(12, 0, 0));
assert_eq!(t.to_string(), "2019-05-15T12:00:00Z");
}
#[test]
fn it_parses_utc_rfc3339_z() {
let t = DateTimeTz::from_str("2019-05-15T12:00:00Z").unwrap();
assert_eq!(t, DateTimeTz(UTC.ymd(2019, 5, 15).and_hms(12, 0, 0)));
}
#[test]
fn it_parses_rfc3339_with_offset() {
let t = DateTimeTz::from_str("2019-05-15T12:00:00-06:00").unwrap();
assert_eq!(t, DateTimeTz(UTC.ymd(2019, 5, 15).and_hms(18, 0, 0)));
}
#[test]
fn it_parses_rfc3339_with_tz() {
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z US/Arizona").unwrap();
assert_eq!(t, DateTimeTz(UTC.ymd(2019, 6, 15).and_hms(19, 0, 0)));
assert_eq!(t, DateTimeTz(Arizona.ymd(2019, 6, 15).and_hms(12, 0, 0)));
assert_eq!(t, DateTimeTz(Central.ymd(2019, 6, 15).and_hms(14, 0, 0)));
assert_eq!(t.to_string(), "2019-06-15T19:00:00Z US/Arizona");
}
#[derive(Serialize)]
struct DemoStruct {
id: String,
dt: DateTimeTz,
}
// I used Arizona here specifically because large parts of Arizona do not honor DST, and so
// that adds in more ambiguity of the -0700 offset with Pacific time.
#[test]
fn it_json_serializes() {
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z America/Phoenix").unwrap();
assert_eq!(
serde_json::to_string(&t).unwrap(),
"\"2019-06-15T19:00:00Z America/Phoenix\""
);
let demo = DemoStruct {
id: String::from("abcdefg"),
dt: t,
};
assert_eq!(
serde_json::to_string(&demo).unwrap(),
"{\"id\":\"abcdefg\",\"dt\":\"2019-06-15T19:00:00Z America/Phoenix\"}"
);
}
#[test]
fn it_json_parses() {
let t =
serde_json::from_str::<DateTimeTz>("\"2019-06-15T19:00:00Z America/Phoenix\"").unwrap();
assert_eq!(t, DateTimeTz(Phoenix.ymd(2019, 6, 15).and_hms(12, 0, 0)));
}
}

View File

@ -71,11 +71,9 @@ extern crate thiserror;
extern crate uuid;
mod criteria;
mod date_time_tz;
mod series;
mod types;
pub use criteria::*;
pub use date_time_tz::DateTimeTz;
pub use series::Series;
pub use types::{EmseriesReadError, EmseriesWriteError, Recordable, Timestamp, UniqueId};
pub use types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable, Timestamp};

View File

@ -18,13 +18,51 @@ use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, LineWriter, Write};
use std::iter::Iterator;
use criteria::Criteria;
use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId};
use types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable};
// A RecordOnDisk, a private data structure, is useful for handling all of the on-disk
// representations of a record. Unlike [Record], this one can accept an empty data value to
// represent that the data may have been deleted. This is not made public because, so far as the
// user is concerned, any record in the system must have data associated with it.
#[derive(Clone, Deserialize, Serialize)]
struct RecordOnDisk<T: Clone + Recordable> {
id: RecordId,
data: Option<T>,
}
/*
impl<T> FromStr for RecordOnDisk<T>
where
T: Clone + Recordable + DeserializeOwned + Serialize,
{
type Err = EmseriesReadError;
fn from_str(line: &str) -> Result<Self, Self::Err> {
serde_json::from_str(line).map_err(EmseriesReadError::JSONParseError)
}
}
*/
impl<T: Clone + Recordable> TryFrom<RecordOnDisk<T>> for Record<T> {
type Error = EmseriesReadError;
fn try_from(disk_record: RecordOnDisk<T>) -> Result<Self, Self::Error> {
match disk_record.data {
Some(data) => Ok(Record {
id: disk_record.id,
data,
}),
None => Err(Self::Error::RecordDeleted(disk_record.id)),
}
}
}
/// An open time series database.
///
@ -33,7 +71,7 @@ use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId}
pub struct Series<T: Clone + Recordable + DeserializeOwned + Serialize> {
//path: String,
writer: LineWriter<File>,
records: HashMap<UniqueId, T>,
records: HashMap<RecordId, Record<T>>,
}
impl<T> Series<T>
@ -42,12 +80,12 @@ where
{
/// Open a time series database at the specified path. `path` is the full path and filename for
/// the database.
pub fn open(path: &str) -> Result<Series<T>, EmseriesReadError> {
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Series<T>, EmseriesReadError> {
let f = OpenOptions::new()
.read(true)
.append(true)
.create(true)
.open(&path)
.open(path)
.map_err(EmseriesReadError::IOError)?;
let records = Series::load_file(&f)?;
@ -62,20 +100,18 @@ where
}
/// Load a file and return all of the records in it.
fn load_file(f: &File) -> Result<HashMap<UniqueId, T>, EmseriesReadError> {
let mut records: HashMap<UniqueId, T> = HashMap::new();
fn load_file(f: &File) -> Result<HashMap<RecordId, Record<T>>, EmseriesReadError> {
let mut records: HashMap<RecordId, Record<T>> = HashMap::new();
let reader = BufReader::new(f);
for line in reader.lines() {
match line {
Ok(line_) => {
/* Can't create a JSONParseError because I can't actually create the underlying error.
fail_point!("parse-line", Err(Error::JSONParseError()))
*/
match line_.parse::<Record<T>>() {
Ok(record) => match record.data {
Some(val) => records.insert(record.id.clone(), val),
None => records.remove(&record.id.clone()),
},
match serde_json::from_str::<RecordOnDisk<T>>(line_.as_ref())
.map_err(EmseriesReadError::JSONParseError)
.and_then(Record::try_from)
{
Ok(record) => records.insert(record.id, record.clone()),
Err(EmseriesReadError::RecordDeleted(id)) => records.remove(&id),
Err(err) => return Err(err),
};
}
@ -87,18 +123,20 @@ where
/// Put a new record into the database. A unique id will be assigned to the record and
/// returned.
pub fn put(&mut self, entry: T) -> Result<UniqueId, EmseriesWriteError> {
let uuid = UniqueId::new();
self.update(uuid.clone(), entry).and_then(|_| Ok(uuid))
pub fn put(&mut self, entry: T) -> Result<RecordId, EmseriesWriteError> {
let id = RecordId::default();
let record = Record { id, data: entry };
self.update(record)?;
Ok(id)
}
/// Update an existing record. The `UniqueId` of the record passed into this function must match
/// the `UniqueId` of a record already in the database.
pub fn update(&mut self, uuid: UniqueId, entry: T) -> Result<(), EmseriesWriteError> {
self.records.insert(uuid.clone(), entry.clone());
let write_res = match serde_json::to_string(&Record {
id: uuid,
data: Some(entry),
/// Update an existing record. The [RecordId] of the record passed into this function must match
/// the [RecordId] of a record already in the database.
pub fn update(&mut self, record: Record<T>) -> Result<(), EmseriesWriteError> {
self.records.insert(record.id, record.clone());
let write_res = match serde_json::to_string(&RecordOnDisk {
id: record.id,
data: Some(record.data),
}) {
Ok(rec_str) => self
.writer
@ -118,14 +156,14 @@ where
/// Future note: while this deletes a record from the view, it only adds an entry to the
/// database that indicates `data: null`. If record histories ever become important, the record
/// and its entire history (including this delete) will still be available.
pub fn delete(&mut self, uuid: &UniqueId) -> Result<(), EmseriesWriteError> {
pub fn delete(&mut self, uuid: &RecordId) -> Result<(), EmseriesWriteError> {
if !self.records.contains_key(uuid) {
return Ok(());
};
self.records.remove(uuid);
let rec: Record<T> = Record {
id: uuid.clone(),
let rec: RecordOnDisk<T> = RecordOnDisk {
id: *uuid,
data: None,
};
match serde_json::to_string(&rec) {
@ -138,8 +176,8 @@ where
}
/// Get all of the records in the database.
pub fn records<'s>(&'s self) -> impl Iterator<Item = (&'s UniqueId, &'s T)> + 's {
self.records.iter()
pub fn records(&self) -> impl Iterator<Item = &Record<T>> {
self.records.values()
}
/* The point of having Search is so that a lot of internal optimizations can happen once the
@ -148,29 +186,29 @@ where
pub fn search<'s>(
&'s self,
criteria: impl Criteria + 's,
) -> impl Iterator<Item = (&'s UniqueId, &'s T)> + 's {
self.records().filter(move |&tr| criteria.apply(tr.1))
) -> impl Iterator<Item = &'s Record<T>> + 's {
self.records().filter(move |&tr| criteria.apply(&tr.data))
}
/// Perform a search and sort the resulting records based on the comparison.
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<(&UniqueId, &T)>
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<&'s Record<T>>
where
C: Criteria + 's,
CMP: FnMut(&(&UniqueId, &T), &(&UniqueId, &T)) -> Ordering,
CMP: FnMut(&&Record<T>, &&Record<T>) -> Ordering,
{
let search_iter = self.search(criteria);
let mut records: Vec<(&UniqueId, &T)> = search_iter.collect();
let mut records: Vec<&Record<T>> = search_iter.collect();
records.sort_by(compare);
records
}
/// Get an exact record from the database based on unique id.
pub fn get(&self, uuid: &UniqueId) -> Option<T> {
self.records.get(uuid).map(|v| v.clone())
pub fn get(&self, uuid: &RecordId) -> Option<Record<T>> {
self.records.get(uuid).cloned()
}
/*
pub fn remove(&self, uuid: UniqueId) -> Result<(), EmseriesError> {
pub fn remove(&self, uuid: RecordId) -> Result<(), EmseriesError> {
unimplemented!()
}
*/

View File

@ -10,10 +10,7 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
*/
use chrono::NaiveDate;
use date_time_tz::DateTimeTz;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use chrono::{DateTime, FixedOffset, NaiveDate};
use std::{cmp::Ordering, fmt, io, str};
use thiserror::Error;
use uuid::Uuid;
@ -28,6 +25,9 @@ pub enum EmseriesReadError {
#[error("Error parsing JSON: {0}")]
JSONParseError(serde_json::error::Error),
#[error("Record was deleted")]
RecordDeleted(RecordId),
/// Indicates a general IO error
#[error("IO Error: {0}")]
IOError(io::Error),
@ -44,19 +44,49 @@ pub enum EmseriesWriteError {
JSONWriteError(serde_json::error::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[derive(Debug, Clone, PartialEq, Eq)]
/// A Timestamp, stored with reference to human reckoning. This could be either a Naive Date or a
/// date and a time with a timezone. The idea of the "human reckoning" is that, no matter what
/// timezone the record was created in, we want to group things based on the date that the human
/// was perceiving at the time it was recorded.
pub enum Timestamp {
DateTime(DateTimeTz),
DateTime(DateTime<FixedOffset>),
Date(NaiveDate),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TimestampJS {
DateTime(String),
Date(String),
}
impl From<Timestamp> for TimestampJS {
fn from(s: Timestamp) -> TimestampJS {
match s {
Timestamp::DateTime(ts) => TimestampJS::DateTime(ts.to_rfc3339()),
Timestamp::Date(ts) => TimestampJS::Date(ts.to_string()),
}
}
}
impl From<TimestampJS> for Timestamp {
fn from(s: TimestampJS) -> Timestamp {
match s {
TimestampJS::DateTime(ts) => {
Timestamp::DateTime(DateTime::parse_from_rfc3339(&ts).unwrap())
}
TimestampJS::Date(ts) => Timestamp::Date(ts.parse::<NaiveDate>().unwrap()),
}
}
}
impl str::FromStr for Timestamp {
type Err = chrono::ParseError;
fn from_str(line: &str) -> Result<Self, Self::Err> {
DateTimeTz::from_str(line)
.map(|dtz| Timestamp::DateTime(dtz))
.or(NaiveDate::from_str(line).map(|d| Timestamp::Date(d)))
DateTime::parse_from_rfc3339(line)
.map(Timestamp::DateTime)
.or(NaiveDate::from_str(line).map(Timestamp::Date))
}
}
@ -70,25 +100,13 @@ impl Ord for Timestamp {
fn cmp(&self, other: &Timestamp) -> Ordering {
match (self, other) {
(Timestamp::DateTime(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(dt2),
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.0.date().naive_utc().cmp(&dt2),
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.0.date().naive_utc()),
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.date_naive().cmp(dt2),
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.date_naive()),
(Timestamp::Date(dt1), Timestamp::Date(dt2)) => dt1.cmp(dt2),
}
}
}
impl From<DateTimeTz> for Timestamp {
fn from(d: DateTimeTz) -> Self {
Self::DateTime(d)
}
}
impl From<NaiveDate> for Timestamp {
fn from(d: NaiveDate) -> Self {
Self::Date(d)
}
}
/// Any element to be put into the database needs to be Recordable. This is the common API that
/// will aid in searching and later in indexing records.
pub trait Recordable {
@ -102,77 +120,88 @@ pub trait Recordable {
/// Uniquely identifies a record.
///
/// This is a wrapper around a basic uuid with some extra convenience methods.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub struct UniqueId(Uuid);
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub struct RecordId(Uuid);
impl UniqueId {
/// Create a new V4 UUID (this is the most common type in use these days).
pub fn new() -> UniqueId {
let id = Uuid::new_v4();
UniqueId(id)
impl Default for RecordId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
impl str::FromStr for UniqueId {
impl str::FromStr for RecordId {
type Err = EmseriesReadError;
/// Parse a UniqueId from a string. Raise UUIDParseError if the parsing fails.
/// Parse a RecordId from a string. Raise UUIDParseError if the parsing fails.
fn from_str(val: &str) -> Result<Self, Self::Err> {
Uuid::parse_str(val)
.map(UniqueId)
.map_err(|err| EmseriesReadError::UUIDParseError(err))
.map(RecordId)
.map_err(EmseriesReadError::UUIDParseError)
}
}
impl fmt::Display for UniqueId {
impl fmt::Display for RecordId {
/// Convert to a hyphenated string
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "{}", self.0.to_hyphenated().to_string())
write!(f, "{}", self.0.to_hyphenated())
}
}
/// Every record contains a unique ID and then the primary data, which itself must implementd the
/// Recordable trait.
#[derive(Clone, Deserialize, Serialize)]
/// A record represents data that actually exists in the database. Users cannot make the record
/// directly, as the database will create them.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Record<T: Clone + Recordable> {
pub id: UniqueId,
pub data: Option<T>,
pub id: RecordId,
pub data: T,
}
impl<T> str::FromStr for Record<T>
where
T: Clone + Recordable + DeserializeOwned + Serialize,
{
type Err = EmseriesReadError;
impl<T: Clone + Recordable> Record<T> {
pub fn date(&self) -> NaiveDate {
match self.data.timestamp() {
Timestamp::DateTime(dt) => dt.date_naive(),
Timestamp::Date(dt) => dt,
}
}
fn from_str(line: &str) -> Result<Self, Self::Err> {
serde_json::from_str(&line).map_err(|err| EmseriesReadError::JSONParseError(err))
pub fn timestamp(&self) -> Timestamp {
self.data.timestamp()
}
pub fn map<Map, U>(self, map: Map) -> Record<U>
where
Map: Fn(T) -> U,
U: Clone + Recordable,
{
Record {
id: self.id,
data: map(self.data),
}
}
}
#[cfg(test)]
mod test {
extern crate dimensioned;
extern crate serde_json;
use self::dimensioned::si::{Kilogram, KG};
use super::*;
use chrono::TimeZone;
use chrono_tz::{Etc::UTC, US::Central};
use date_time_tz::DateTimeTz;
use chrono_tz::Etc::UTC;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct Weight(Kilogram<f64>);
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct WeightRecord {
pub date: Timestamp,
pub date: NaiveDate,
pub weight: Weight,
}
impl Recordable for WeightRecord {
fn timestamp(&self) -> Timestamp {
self.date.clone()
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
@ -181,10 +210,14 @@ mod test {
}
#[test]
fn timestamp_parses_datetimetz_without_timezone() {
fn timestamp_parses_utc_time() {
assert_eq!(
"2003-11-10T06:00:00Z".parse::<Timestamp>().unwrap(),
Timestamp::DateTime(DateTimeTz(UTC.ymd(2003, 11, 10).and_hms(6, 0, 0))),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
),
);
}
@ -196,9 +229,10 @@ mod test {
);
}
#[test]
/*
#[ignore]
fn v_alpha_serialization() {
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109,\"date\":\"2003-11-10T06:00:00.000000000000Z\"},\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109},\"date\":\"2003-11-10\",\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
let rec: Record<WeightRecord> = WEIGHT_ENTRY
.parse()
@ -209,52 +243,65 @@ mod test {
);
assert_eq!(
rec.data,
Some(WeightRecord {
date: Timestamp::DateTime(DateTimeTz(UTC.ymd(2003, 11, 10).and_hms(6, 0, 0))),
WeightRecord {
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.79109 * KG),
})
}
);
}
*/
#[test]
fn serialization_output() {
let rec = WeightRecord {
date: Timestamp::DateTime(DateTimeTz(UTC.ymd(2003, 11, 10).and_hms(6, 0, 0))),
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.0 * KG),
};
assert_eq!(
serde_json::to_string(&rec).unwrap(),
"{\"date\":\"2003-11-10T06:00:00Z\",\"weight\":77.0}"
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
);
let rec2 = WeightRecord {
date: Timestamp::DateTime(Central.ymd(2003, 11, 10).and_hms(0, 0, 0).into()),
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
weight: Weight(77.0 * KG),
};
assert_eq!(
serde_json::to_string(&rec2).unwrap(),
"{\"date\":\"2003-11-10T06:00:00Z US/Central\",\"weight\":77.0}"
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
);
}
#[test]
fn two_datetimes_can_be_compared() {
let time1 = Timestamp::DateTime(DateTimeTz(UTC.ymd(2003, 11, 10).and_hms(6, 0, 0)));
let time2 = Timestamp::DateTime(DateTimeTz(UTC.ymd(2003, 11, 11).and_hms(6, 0, 0)));
let time1 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
let time2 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 11, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
assert!(time1 < time2);
}
#[test]
fn two_dates_can_be_compared() {
let time1 = Timestamp::Date(NaiveDate::from_ymd(2003, 11, 10));
let time2 = Timestamp::Date(NaiveDate::from_ymd(2003, 11, 11));
let time1: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 10).unwrap());
let time2: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
assert!(time1 < time2);
}
#[test]
fn datetime_and_date_can_be_compared() {
let time1 = Timestamp::DateTime(DateTimeTz(UTC.ymd(2003, 11, 10).and_hms(6, 0, 0)));
let time2 = Timestamp::Date(NaiveDate::from_ymd(2003, 11, 11));
let time1 = Timestamp::DateTime(
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
);
let time2 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
assert!(time1 < time2)
}
}

View File

@ -20,9 +20,9 @@ extern crate emseries;
#[cfg(test)]
mod test {
use chrono::prelude::*;
use chrono::{prelude::*};
use chrono_tz::Etc::UTC;
use dimensioned::si::{Kilogram, Meter, Second, KG, M, S};
use dimensioned::si::{Kilogram, Meter, Second, M, S};
use emseries::*;
@ -34,7 +34,7 @@ mod test {
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
struct BikeTrip {
datetime: DateTimeTz,
datetime: DateTime<FixedOffset>,
distance: Distance,
duration: Duration,
comments: String,
@ -42,7 +42,7 @@ mod test {
impl Recordable for BikeTrip {
fn timestamp(&self) -> Timestamp {
self.datetime.clone().into()
Timestamp::DateTime(self.datetime)
}
fn tags(&self) -> Vec<String> {
Vec::new()
@ -52,31 +52,46 @@ mod test {
fn mk_trips() -> [BikeTrip; 5] {
[
BikeTrip {
datetime: DateTimeTz(UTC.ymd(2011, 10, 29).and_hms(0, 0, 0)),
datetime: UTC
.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(58741.055 * M),
duration: Duration(11040.0 * S),
comments: String::from("long time ago"),
},
BikeTrip {
datetime: DateTimeTz(UTC.ymd(2011, 10, 31).and_hms(0, 0, 0)),
datetime: UTC
.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(17702.0 * M),
duration: Duration(2880.0 * S),
comments: String::from("day 2"),
},
BikeTrip {
datetime: DateTimeTz(UTC.ymd(2011, 11, 02).and_hms(0, 0, 0)),
datetime: UTC
.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(41842.945 * M),
duration: Duration(7020.0 * S),
comments: String::from("Do Some Distance!"),
},
BikeTrip {
datetime: DateTimeTz(UTC.ymd(2011, 11, 04).and_hms(0, 0, 0)),
datetime: UTC
.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(34600.895 * M),
duration: Duration(5580.0 * S),
comments: String::from("I did a lot of distance back then"),
},
BikeTrip {
datetime: DateTimeTz(UTC.ymd(2011, 11, 05).and_hms(0, 0, 0)),
datetime: UTC
.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
distance: Distance(6437.376 * M),
duration: Duration(960.0 * S),
comments: String::from("day 5"),
@ -84,7 +99,7 @@ mod test {
]
}
fn run_test<T>(test: T) -> ()
fn run_test<T>(test: T)
where
T: FnOnce(tempfile::TempPath),
{
@ -93,14 +108,14 @@ mod test {
test(tmp_path);
}
fn run<T>(test: T) -> ()
fn run<T>(test: T)
where
T: FnOnce(Series<BikeTrip>),
{
let tmp_file = tempfile::NamedTempFile::new().expect("temporary path created");
let tmp_path = tmp_file.into_temp_path();
let ts: Series<BikeTrip> = Series::open(&tmp_path.to_string_lossy())
.expect("the time series should open correctly");
let ts: Series<BikeTrip> =
Series::open(&tmp_path).expect("the time series should open correctly");
test(ts);
}
@ -122,11 +137,15 @@ mod test {
Some(tr) => {
assert_eq!(
tr.timestamp(),
DateTimeTz(UTC.ymd(2011, 10, 29).and_hms(0, 0, 0)).into()
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
)
);
assert_eq!(tr.duration, Duration(11040.0 * S));
assert_eq!(tr.comments, String::from("long time ago"));
assert_eq!(tr, trips[0]);
assert_eq!(tr.data.duration, Duration(11040.0 * S));
assert_eq!(tr.data.comments, String::from("long time ago"));
assert_eq!(tr.data, trips[0]);
}
}
})
@ -136,20 +155,22 @@ mod test {
pub fn can_search_for_an_entry_with_exact_time() {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
}
let v: Vec<(&UniqueId, &BikeTrip)> = ts
.search(exact_time(
DateTimeTz(UTC.ymd(2011, 10, 31).and_hms(0, 0, 0)).into(),
))
let v: Vec<&Record<BikeTrip>> = ts
.search(exact_time(Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
)))
.collect();
assert_eq!(v.len(), 1);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(v[0].data, trips[1]);
})
}
@ -157,26 +178,34 @@ mod test {
pub fn can_get_entries_in_time_range() {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
}
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.ymd(2011, 10, 31).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.ymd(2011, 11, 04).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 3);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
})
}
@ -186,8 +215,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=4] {
ts.put(trip.clone()).expect("expect a successful put");
@ -195,21 +224,29 @@ mod test {
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.ymd(2011, 10, 31).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.ymd(2011, 11, 04).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 3);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
}
})
}
@ -220,8 +257,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
for trip in &trips[0..=2] {
ts.put(trip.clone()).expect("expect a successful put");
@ -229,41 +266,57 @@ mod test {
}
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.ymd(2011, 10, 31).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.ymd(2011, 11, 04).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 2);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
ts.put(trips[3].clone()).expect("expect a successful put");
ts.put(trips[4].clone()).expect("expect a successful put");
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
time_range(
DateTimeTz(UTC.ymd(2011, 10, 31).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
DateTimeTz(UTC.ymd(2011, 11, 05).and_hms(0, 0, 0)).into(),
Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
),
true,
),
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|l, r| l.timestamp().cmp(&r.timestamp()),
);
assert_eq!(v.len(), 4);
assert_eq!(*v[0].1, trips[1]);
assert_eq!(*v[1].1, trips[2]);
assert_eq!(*v[2].1, trips[3]);
assert_eq!(*v[3].1, trips[4]);
assert_eq!(v[0].data, trips[1]);
assert_eq!(v[1].data, trips[2]);
assert_eq!(v[2].data, trips[3]);
assert_eq!(v[3].data, trips[4]);
}
})
}
@ -273,8 +326,8 @@ mod test {
run_test(|path| {
let trips = mk_trips();
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
@ -283,9 +336,8 @@ mod test {
match ts.get(&trip_id) {
None => assert!(false, "record not found"),
Some(mut trip) => {
trip.distance = Distance(50000.0 * M);
ts.update(trip_id.clone(), trip)
.expect("expect record to update");
trip.data.distance = Distance(50000.0 * M);
ts.update(trip).expect("expect record to update");
}
};
@ -293,12 +345,12 @@ mod test {
None => assert!(false, "record not found"),
Some(trip) => {
assert_eq!(
trip.datetime,
DateTimeTz(UTC.ymd(2011, 11, 02).and_hms(0, 0, 0))
trip.data.datetime,
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()
);
assert_eq!(trip.distance, Distance(50000.0 * M));
assert_eq!(trip.duration, Duration(7020.0 * S));
assert_eq!(trip.comments, String::from("Do Some Distance!"));
assert_eq!(trip.data.distance, Distance(50000.0 * M));
assert_eq!(trip.data.duration, Duration(7020.0 * S));
assert_eq!(trip.data.comments, String::from("Do Some Distance!"));
}
}
})
@ -310,8 +362,8 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
@ -320,32 +372,36 @@ mod test {
match ts.get(&trip_id) {
None => assert!(false, "record not found"),
Some(mut trip) => {
trip.distance = Distance(50000.0 * M);
ts.update(trip_id, trip).expect("expect record to update");
trip.data.distance = Distance(50000.0 * M);
ts.update(trip).expect("expect record to update");
}
};
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let trips: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let trips: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(trips.len(), 3);
let trips: Vec<(&UniqueId, &BikeTrip)> = ts
.search(exact_time(
DateTimeTz(UTC.ymd(2011, 11, 02).and_hms(0, 0, 0)).into(),
))
let trips: Vec<&Record<BikeTrip>> = ts
.search(exact_time(Timestamp::DateTime(
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
)))
.collect();
assert_eq!(trips.len(), 1);
assert_eq!(
trips[0].1.datetime,
DateTimeTz(UTC.ymd(2011, 11, 02).and_hms(0, 0, 0))
trips[0].data.datetime,
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
.unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap())
);
assert_eq!(trips[0].1.distance, Distance(50000.0 * M));
assert_eq!(trips[0].1.duration, Duration(7020.0 * S));
assert_eq!(trips[0].1.comments, String::from("Do Some Distance!"));
assert_eq!(trips[0].data.distance, Distance(50000.0 * M));
assert_eq!(trips[0].data.duration, Duration(7020.0 * S));
assert_eq!(trips[0].data.comments, String::from("Do Some Distance!"));
}
})
}
@ -356,22 +412,21 @@ mod test {
let trips = mk_trips();
{
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let mut ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let trip_id = ts.put(trips[0].clone()).expect("expect a successful put");
ts.put(trips[1].clone()).expect("expect a successful put");
ts.put(trips[2].clone()).expect("expect a successful put");
ts.delete(&trip_id).expect("successful delete");
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(recs.len(), 2);
}
{
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
.expect("expect the time series to open correctly");
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
let ts: Series<BikeTrip> =
Series::open(&path).expect("expect the time series to open correctly");
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
assert_eq!(recs.len(), 2);
}
})
@ -388,7 +443,7 @@ mod test {
impl Recordable for WeightRecord {
fn timestamp(&self) -> Timestamp {
self.date.into()
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {

1
file-service/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
fixtures

2
file-service/.ignore Normal file
View File

@ -0,0 +1,2 @@
fixtures
var

47
file-service/Cargo.toml Normal file
View File

@ -0,0 +1,47 @@
[package]
name = "file-service"
version = "0.2.0"
authors = ["savanni@luminescent-dreams.com"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "file_service"
path = "src/lib.rs"
[[bin]]
name = "file-service"
path = "src/main.rs"
[target.auth-cli.dependencies]
[dependencies]
authdb = { path = "../authdb/" }
base64ct = { version = "1", features = [ "alloc" ] }
build_html = { version = "2" }
bytes = { version = "1" }
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = [ "derive" ] }
cookie = { version = "0.17" }
futures-util = { version = "0.3" }
hex-string = "0.1.0"
http = { version = "0.2" }
image = "0.23.5"
logger = "*"
log = { version = "0.4" }
mime = "0.3.16"
mime_guess = "2.0.3"
pretty_env_logger = { version = "0.5" }
serde_json = "*"
serde = { version = "1.0", features = ["derive"] }
sha2 = { version = "0.10" }
thiserror = { version = "1" }
tokio = { version = "1", features = [ "full" ] }
uuid = { version = "0.4", features = [ "serde", "v4" ] }
warp = { version = "0.3" }
[dev-dependencies]
cool_asserts = { version = "2" }
tempdir = { version = "0.3" }

1
file-service/authdb.json Normal file
View File

@ -0,0 +1 @@
[{"jti":"ac3a46c6-3fa1-4d0a-af12-e7d3fefdc878","aud":"savanni","exp":1621351436,"iss":"savanni","iat":1589729036,"sub":"https://savanni.luminescent-dreams.com/file-service/","perms":["admin"]}]

13
file-service/dist.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION=`cat Cargo.toml | grep "^version =" | sed -r 's/^version = "(.+)"$/\1/'`
mkdir -p dist
cp ../target/release/file-service dist
cp ../target/release/auth-cli dist
strip dist/file-service
strip dist/auth-cli
tar -czf file-service-${VERSION}.tgz dist/

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,279 @@
use build_html::Html;
use bytes::Buf;
use file_service::WriteFileError;
use futures_util::StreamExt;
use http::{Error, StatusCode};
use std::collections::HashMap;
use std::io::Read;
use warp::{filters::multipart::FormData, http::Response, multipart::Part};
use crate::{pages, App, AuthToken, FileId, FileInfo, ReadFileError, SessionToken};
const CSS: &str = include_str!("../templates/style.css");
pub async fn handle_index(
app: App,
token: Option<SessionToken>,
) -> Result<Response<String>, Error> {
match token {
Some(token) => match app.validate_session(token).await {
Ok(_) => render_gallery_page(app).await,
Err(err) => render_auth_page(Some(format!("session expired: {:?}", err))),
},
None => render_auth_page(None),
}
}
pub async fn handle_css() -> Result<Response<String>, Error> {
Response::builder()
.header("content-type", "text/css")
.status(StatusCode::OK)
.body(CSS.to_owned())
}
pub fn render_auth_page(message: Option<String>) -> Result<Response<String>, Error> {
Response::builder()
.status(StatusCode::OK)
.body(pages::auth(message).to_html_string())
}
pub async fn render_gallery_page(app: App) -> Result<Response<String>, Error> {
match app.list_files().await {
Ok(ids) => {
let mut files = vec![];
for id in ids.into_iter() {
let file = app.get_file(&id).await;
files.push(file);
}
Response::builder()
.header("content-type", "text/html")
.status(StatusCode::OK)
.body(pages::gallery(files).to_html_string())
}
Err(_) => Response::builder()
.header("content-type", "text/html")
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body("".to_owned()),
}
}
pub async fn thumbnail(
app: App,
id: String,
old_etags: Option<String>,
) -> Result<Response<Vec<u8>>, Error> {
match app.get_file(&FileId::from(id)).await {
Ok(file) => serve_file(file.info.clone(), || file.thumbnail(), old_etags),
Err(_err) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(vec![]),
}
}
pub async fn file(
app: App,
id: String,
old_etags: Option<String>,
) -> Result<Response<Vec<u8>>, Error> {
match app.get_file(&FileId::from(id)).await {
Ok(file) => serve_file(file.info.clone(), || file.content(), old_etags),
Err(_err) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(vec![]),
}
}
pub async fn handle_auth(
app: App,
form: HashMap<String, String>,
) -> Result<http::Response<String>, Error> {
match form.get("password") {
Some(token) => match app.authenticate(AuthToken::from(token.clone())).await {
Ok(Some(session_token)) => Response::builder()
.header("location", "/")
.header(
"set-cookie",
format!(
"session={}; Secure; HttpOnly; SameSite=Strict",
*session_token
),
)
.status(StatusCode::SEE_OTHER)
.body("".to_owned()),
Ok(None) => render_auth_page(Some("no user found".to_owned())),
Err(_) => render_auth_page(Some("invalid auth token".to_owned())),
},
None => render_auth_page(Some("no token available".to_owned())),
}
}
pub async fn handle_upload(
app: App,
token: SessionToken,
form: FormData,
) -> Result<http::Response<String>, Error> {
match app.validate_session(token).await {
Ok(Some(_)) => match process_file_upload(app, form).await {
Ok(_) => Response::builder()
.header("location", "/")
.status(StatusCode::SEE_OTHER)
.body("".to_owned()),
Err(UploadError::FilenameMissing) => Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("filename is required for all files".to_owned()),
Err(UploadError::WriteFileError(err)) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(format!("could not write to the file system: {:?}", err)),
Err(UploadError::WarpError(err)) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(format!("error with the app framework: {:?}", err)),
},
_ => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("".to_owned()),
}
}
pub async fn handle_delete(
app: App,
token: SessionToken,
id: FileId,
) -> Result<http::Response<String>, Error> {
match app.validate_session(token).await {
Ok(Some(_)) => match app.delete_file(id).await {
Ok(_) => Response::builder()
.header("location", "/")
.status(StatusCode::SEE_OTHER)
.body("".to_owned()),
Err(_) => unimplemented!(),
},
_ => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("".to_owned()),
}
}
fn serve_file<F>(
info: FileInfo,
file: F,
old_etags: Option<String>,
) -> http::Result<http::Response<Vec<u8>>>
where
F: FnOnce() -> Result<Vec<u8>, ReadFileError>,
{
match old_etags {
Some(old_etags) if old_etags != info.hash => Response::builder()
.header("content-type", info.file_type)
.status(StatusCode::NOT_MODIFIED)
.body(vec![]),
_ => match file() {
Ok(content) => Response::builder()
.header("content-type", info.file_type)
.header("etag", info.hash)
.status(StatusCode::OK)
.body(content),
Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(vec![]),
},
}
}
async fn collect_multipart(
mut stream: warp::filters::multipart::FormData,
) -> Result<Vec<(Option<String>, Option<String>, Vec<u8>)>, warp::Error> {
let mut content: Vec<(Option<String>, Option<String>, Vec<u8>)> = Vec::new();
while let Some(part) = stream.next().await {
match part {
Ok(part) => content.push(collect_content(part).await.unwrap()),
Err(err) => return Err(err),
}
}
Ok(content)
}
async fn collect_content(
mut part: Part,
) -> Result<(Option<String>, Option<String>, Vec<u8>), String> {
let mut content: Vec<u8> = Vec::new();
while let Some(Ok(data)) = part.data().await {
let mut reader = data.reader();
reader.read_to_end(&mut content).unwrap();
}
Ok((
part.content_type().map(|s| s.to_owned()),
part.filename().map(|s| s.to_owned()),
content,
))
}
/*
async fn handle_upload(
form: warp::filters::multipart::FormData,
app: App,
) -> warp::http::Result<warp::http::Response<String>> {
let files = collect_multipart(form).await;
match files {
Ok(files) => {
for (_, filename, content) in files {
match filename {
Some(filename) => {
app.add_file(filename, content).unwrap();
}
None => {
return warp::http::Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("".to_owned())
}
}
}
}
Err(_err) => {
return warp::http::Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("".to_owned())
}
}
// println!("file length: {:?}", files.map(|f| f.len()));
warp::http::Response::builder()
.header("location", "/")
.status(StatusCode::SEE_OTHER)
.body("".to_owned())
}
*/
enum UploadError {
FilenameMissing,
WriteFileError(WriteFileError),
WarpError(warp::Error),
}
impl From<WriteFileError> for UploadError {
fn from(err: WriteFileError) -> Self {
Self::WriteFileError(err)
}
}
impl From<warp::Error> for UploadError {
fn from(err: warp::Error) -> Self {
Self::WarpError(err)
}
}
async fn process_file_upload(app: App, form: FormData) -> Result<(), UploadError> {
let files = collect_multipart(form).await?;
for (_, filename, content) in files {
match filename {
Some(filename) => {
app.add_file(filename, content).await?;
}
None => return Err(UploadError::FilenameMissing),
}
}
Ok(())
}

208
file-service/src/html.rs Normal file
View File

@ -0,0 +1,208 @@
use build_html::{self, Html, HtmlContainer};
#[derive(Clone, Debug, Default)]
pub struct Attributes(Vec<(String, String)>);
/*
impl FromIterator<(String, String)> for Attributes {
fn from_iter<T>(iter: T) -> Self
where
T: IntoIterator<Item = (String, String)>,
{
Attributes(iter.collect::<Vec<(String, String)>>())
}
}
impl FromIterator<(&str, &str)> for Attributes {
fn from_iter<T>(iter: T) -> Self
where
T: IntoIterator<Item = (&str, &str)>,
{
unimplemented!()
}
}
*/
impl ToString for Attributes {
fn to_string(&self) -> String {
self.0
.iter()
.map(|(key, value)| format!("{}=\"{}\"", key, value))
.collect::<Vec<String>>()
.join(" ")
}
}
#[derive(Clone, Debug)]
pub struct Form {
path: String,
method: String,
encoding: Option<String>,
elements: String,
}
impl Form {
pub fn new() -> Self {
Self {
path: "/".to_owned(),
method: "get".to_owned(),
encoding: None,
elements: "".to_owned(),
}
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = path.to_owned();
self
}
pub fn with_method(mut self, method: &str) -> Self {
self.method = method.to_owned();
self
}
pub fn with_encoding(mut self, encoding: &str) -> Self {
self.encoding = Some(encoding.to_owned());
self
}
}
impl Html for Form {
fn to_html_string(&self) -> String {
let encoding = match self.encoding {
Some(ref encoding) => format!("enctype=\"{encoding}\"", encoding = encoding),
None => "".to_owned(),
};
format!(
"<form action=\"{path}\" method=\"{method}\" {encoding}>\n{elements}\n</form>\n",
path = self.path,
method = self.method,
encoding = encoding,
elements = self.elements.to_html_string(),
)
}
}
impl HtmlContainer for Form {
fn add_html<H: Html>(&mut self, html: H) {
self.elements.push_str(&html.to_html_string());
}
}
#[derive(Clone, Debug)]
pub struct Input {
ty: String,
name: String,
id: Option<String>,
value: Option<String>,
attributes: Attributes,
}
impl Html for Input {
fn to_html_string(&self) -> String {
let id = match self.id {
Some(ref id) => format!("id=\"{}\"", id),
None => "".to_owned(),
};
let value = match self.value {
Some(ref value) => format!("value=\"{}\"", value),
None => "".to_owned(),
};
let attrs = self.attributes.to_string();
format!(
"<input type=\"{ty}\" name=\"{name}\" {id} {value} {attrs} />\n",
ty = self.ty,
name = self.name,
id = id,
value = value,
attrs = attrs,
)
}
}
impl Input {
pub fn new(ty: &str, name: &str) -> Self {
Self {
ty: ty.to_owned(),
name: name.to_owned(),
id: None,
value: None,
attributes: Attributes::default(),
}
}
pub fn with_id(mut self, val: &str) -> Self {
self.id = Some(val.to_owned());
self
}
pub fn with_attributes<'a>(
mut self,
values: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Self {
self.attributes = Attributes(
values
.into_iter()
.map(|(a, b)| (a.to_owned(), b.to_owned()))
.collect::<Vec<(String, String)>>(),
);
self
}
}
#[derive(Clone, Debug)]
pub struct Button {
ty: Option<String>,
name: Option<String>,
label: String,
attributes: Attributes,
}
impl Button {
pub fn new(label: &str) -> Self {
Self {
ty: None,
name: None,
label: label.to_owned(),
attributes: Attributes::default(),
}
}
pub fn with_type(mut self, ty: &str) -> Self {
self.ty = Some(ty.to_owned());
self
}
pub fn with_attributes<'a>(
mut self,
values: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Self {
self.attributes = Attributes(
values
.into_iter()
.map(|(a, b)| (a.to_owned(), b.to_owned()))
.collect::<Vec<(String, String)>>(),
);
self
}
}
impl Html for Button {
fn to_html_string(&self) -> String {
let ty = match self.ty {
Some(ref ty) => format!("type={}", ty),
None => "".to_owned(),
};
let name = match self.name {
Some(ref name) => format!("name={}", name),
None => "".to_owned(),
};
format!(
"<button {ty} {name} {attrs}>{label}</button>",
name = name,
label = self.label,
attrs = self.attributes.to_string()
)
}
}

5
file-service/src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
mod store;
pub use store::{
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
};

174
file-service/src/main.rs Normal file
View File

@ -0,0 +1,174 @@
extern crate log;
use cookie::Cookie;
use handlers::{file, handle_auth, handle_css, handle_delete, handle_upload, thumbnail};
use std::{
collections::{HashMap, HashSet},
convert::Infallible,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::PathBuf,
sync::Arc,
};
use tokio::sync::RwLock;
use warp::{Filter, Rejection};
mod handlers;
mod html;
mod pages;
const MAX_UPLOAD: u64 = 15 * 1024 * 1024;
use authdb::{AuthDB, AuthError, AuthToken, SessionToken, Username};
use file_service::{
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
};
pub use handlers::handle_index;
#[derive(Clone)]
pub struct App {
authdb: Arc<RwLock<AuthDB>>,
store: Arc<RwLock<Store>>,
}
impl App {
pub fn new(authdb: AuthDB, store: Store) -> Self {
Self {
authdb: Arc::new(RwLock::new(authdb)),
store: Arc::new(RwLock::new(store)),
}
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
self.authdb.read().await.authenticate(token).await
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
self.authdb.read().await.validate_session(token).await
}
pub async fn list_files(&self) -> Result<HashSet<FileId>, ReadFileError> {
self.store.read().await.list_files()
}
pub async fn get_file(&self, id: &FileId) -> Result<FileHandle, ReadFileError> {
self.store.read().await.get_file(id)
}
pub async fn add_file(
&self,
filename: String,
content: Vec<u8>,
) -> Result<FileHandle, WriteFileError> {
self.store.write().await.add_file(filename, content)
}
pub async fn delete_file(&self, id: FileId) -> Result<(), DeleteFileError> {
self.store.write().await.delete_file(&id)?;
Ok(())
}
}
fn with_app(app: App) -> impl Filter<Extract = (App,), Error = Infallible> + Clone {
warp::any().map(move || app.clone())
}
fn parse_cookies(cookie_str: &str) -> Result<HashMap<String, String>, cookie::ParseError> {
Cookie::split_parse(cookie_str)
.map(|c| c.map(|c| (c.name().to_owned(), c.value().to_owned())))
.collect::<Result<HashMap<String, String>, cookie::ParseError>>()
}
fn get_session_token(cookies: HashMap<String, String>) -> Option<SessionToken> {
cookies.get("session").cloned().map(SessionToken::from)
}
fn maybe_with_session() -> impl Filter<Extract = (Option<SessionToken>,), Error = Rejection> + Copy
{
warp::any()
.and(warp::header::optional::<String>("cookie"))
.map(|cookie_str: Option<String>| match cookie_str {
Some(cookie_str) => parse_cookies(&cookie_str).ok().and_then(get_session_token),
None => None,
})
}
fn with_session() -> impl Filter<Extract = (SessionToken,), Error = Rejection> + Copy {
warp::any()
.and(warp::header::<String>("cookie"))
.and_then(|cookie_str: String| async move {
match parse_cookies(&cookie_str).ok().and_then(get_session_token) {
Some(session_token) => Ok(session_token),
None => Err(warp::reject()),
}
})
}
#[tokio::main]
pub async fn main() {
pretty_env_logger::init();
let authdb = AuthDB::new(PathBuf::from(&std::env::var("AUTHDB").unwrap()))
.await
.unwrap();
let store = Store::new(PathBuf::from(&std::env::var("FILE_SHARE_DIR").unwrap()));
let app = App::new(authdb, store);
let log = warp::log("file_service");
let root = warp::path!()
.and(warp::get())
.and(with_app(app.clone()))
.and(maybe_with_session())
.then(handle_index);
let styles = warp::path!("css").and(warp::get()).then(handle_css);
let auth = warp::path!("auth")
.and(warp::post())
.and(with_app(app.clone()))
.and(warp::filters::body::form())
.then(handle_auth);
let upload_via_form = warp::path!("upload")
.and(warp::post())
.and(with_app(app.clone()))
.and(with_session())
.and(warp::multipart::form().max_length(MAX_UPLOAD))
.then(handle_upload);
let delete_via_form = warp::path!("delete" / String)
.and(warp::post())
.and(with_app(app.clone()))
.and(with_session())
.then(|id, app, token| handle_delete(app, token, FileId::from(id)));
let thumbnail = warp::path!(String / "tn")
.and(warp::get())
.and(warp::header::optional::<String>("if-none-match"))
.and(with_app(app.clone()))
.then(move |id, old_etags, app: App| thumbnail(app, id, old_etags));
let file = warp::path!(String)
.and(warp::get())
.and(warp::header::optional::<String>("if-none-match"))
.and(with_app(app.clone()))
.then(move |id, old_etags, app: App| file(app, id, old_etags));
let server = warp::serve(
root.or(styles)
.or(auth)
.or(upload_via_form)
.or(delete_via_form)
.or(thumbnail)
.or(file)
.with(log),
);
server
.run(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8002))
.await;
}

114
file-service/src/pages.rs Normal file
View File

@ -0,0 +1,114 @@
use crate::html::*;
use build_html::{self, Container, ContainerType, Html, HtmlContainer};
use file_service::{FileHandle, FileInfo, ReadFileError};
pub fn auth(_message: Option<String>) -> build_html::HtmlPage {
build_html::HtmlPage::new()
.with_title("Sign In")
.with_stylesheet("/css")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "authentication-page")])
.with_container(auth_form()),
)
}
fn auth_form() -> Container {
Container::default()
.with_attributes([("class", "card authentication-form")])
.with_html(
Form::new()
.with_path("/auth")
.with_method("post")
.with_container(
Container::new(ContainerType::Div)
.with_html(
Input::new("password", "password")
.with_id("for-token-input")
.with_attributes([
("size", "50"),
("class", "authentication-form__input"),
]),
)
.with_html(
Button::new("Sign In")
.with_attributes([("class", "authentication-form__button")]),
),
),
)
}
pub fn gallery(handles: Vec<Result<FileHandle, ReadFileError>>) -> build_html::HtmlPage {
let mut page = build_html::HtmlPage::new()
.with_title("Gallery")
.with_stylesheet("/css")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "gallery-page")])
.with_header(1, "Gallery")
.with_html(upload_form()),
);
let mut gallery = Container::new(ContainerType::Div).with_attributes([("class", "gallery")]);
for handle in handles {
let container = match handle {
Ok(ref handle) => thumbnail(&handle.info),
Err(err) => Container::new(ContainerType::Div)
.with_attributes(vec![("class", "file")])
.with_paragraph(format!("{:?}", err)),
};
gallery.add_container(container);
}
page.add_container(gallery);
page
}
pub fn upload_form() -> Form {
Form::new()
.with_path("/upload")
.with_method("post")
.with_encoding("multipart/form-data")
.with_container(
Container::new(ContainerType::Div)
.with_attributes([("class", "card upload-form")])
.with_html(Input::new("file", "file").with_attributes([
("id", "for-selector-input"),
("placeholder", "select file"),
("class", "upload-form__selector"),
]))
.with_html(
Button::new("Upload file")
.with_attributes([("class", "upload-form__button")])
.with_type("submit"),
),
)
}
pub fn thumbnail(info: &FileInfo) -> Container {
Container::new(ContainerType::Div)
.with_attributes(vec![("class", "card thumbnail")])
.with_html(
Container::new(ContainerType::Div).with_link(
format!("/{}", *info.id),
Container::default()
.with_attributes([("class", "thumbnail")])
.with_image(format!("{}/tn", *info.id), "test data")
.to_html_string(),
),
)
.with_html(
Container::new(ContainerType::Div)
.with_html(
Container::new(ContainerType::UnorderedList)
.with_attributes(vec![("class", "thumbnail__metadata")])
.with_html(info.name.clone())
.with_html(format!("{}", info.created.format("%Y-%m-%d"))),
)
.with_html(
Form::new()
.with_path(&format!("/delete/{}", *info.id))
.with_method("post")
.with_html(Button::new("Delete")),
),
)
}

View File

@ -0,0 +1,299 @@
use super::{fileinfo::FileInfo, FileId, ReadFileError, WriteFileError};
use chrono::prelude::*;
use hex_string::HexString;
use image::imageops::FilterType;
use sha2::{Digest, Sha256};
use std::{
convert::TryFrom,
io::{Read, Write},
path::{Path, PathBuf},
};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum PathError {
#[error("path cannot be derived from input")]
InvalidPath,
}
#[derive(Clone, Debug)]
pub struct PathResolver {
base: PathBuf,
id: FileId,
extension: String,
}
impl PathResolver {
pub fn new(base: &Path, id: FileId, extension: String) -> Self {
Self {
base: base.to_owned(),
id,
extension,
}
}
pub fn metadata_path_by_id(base: &Path, id: FileId) -> PathBuf {
let mut path = base.to_path_buf();
path.push(PathBuf::from(id.clone()));
path.set_extension("json");
path
}
pub fn id(&self) -> FileId {
self.id.clone()
}
pub fn file_path(&self) -> PathBuf {
let mut path = self.base.clone();
path.push(PathBuf::from(self.id.clone()));
path.set_extension(self.extension.clone());
path
}
pub fn metadata_path(&self) -> PathBuf {
let mut path = self.base.clone();
path.push(PathBuf::from(self.id.clone()));
path.set_extension("json");
path
}
pub fn thumbnail_path(&self) -> PathBuf {
let mut path = self.base.clone();
path.push(PathBuf::from(self.id.clone()));
path.set_extension(format!("tn.{}", self.extension));
path
}
}
impl TryFrom<String> for PathResolver {
type Error = PathError;
fn try_from(s: String) -> Result<Self, Self::Error> {
PathResolver::try_from(s.as_str())
}
}
impl TryFrom<&str> for PathResolver {
type Error = PathError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
PathResolver::try_from(Path::new(s))
}
}
impl TryFrom<PathBuf> for PathResolver {
type Error = PathError;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
PathResolver::try_from(path.as_path())
}
}
impl TryFrom<&Path> for PathResolver {
type Error = PathError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
Ok(Self {
base: path
.parent()
.map(|s| s.to_owned())
.ok_or(PathError::InvalidPath)?,
id: path
.file_stem()
.and_then(|s| s.to_str().map(FileId::from))
.ok_or(PathError::InvalidPath)?,
extension: path
.extension()
.and_then(|s| s.to_str().map(|s| s.to_owned()))
.ok_or(PathError::InvalidPath)?,
})
}
}
/// One file in the database, complete with the path of the file and information about the
/// thumbnail of the file.
#[derive(Debug)]
pub struct FileHandle {
pub id: FileId,
pub path: PathResolver,
pub info: FileInfo,
}
impl FileHandle {
/// Create a new entry in the database
pub fn new(filename: String, root: PathBuf) -> Result<Self, WriteFileError> {
let id = FileId::from(Uuid::new_v4().hyphenated().to_string());
let path = PathBuf::from(filename);
let name = path
.file_stem()
.and_then(|s| s.to_str().map(|s| s.to_owned()))
.ok_or(WriteFileError::InvalidPath)?;
let extension = path
.extension()
.and_then(|s| s.to_str().map(|s| s.to_owned()))
.ok_or(WriteFileError::InvalidPath)?;
let path = PathResolver {
base: root.clone(),
id: id.clone(),
extension: extension.clone(),
};
let file_type = mime_guess::from_ext(&extension)
.first_or_text_plain()
.essence_str()
.to_owned();
let info = FileInfo {
id: id.clone(),
name,
size: 0,
created: Utc::now(),
file_type,
hash: "".to_owned(),
extension,
};
let mut md_file = std::fs::File::create(path.metadata_path())?;
let _ = md_file.write(&serde_json::to_vec(&info)?)?;
Ok(Self { id, path, info })
}
pub fn load(id: &FileId, root: &Path) -> Result<Self, ReadFileError> {
let info = FileInfo::load(PathResolver::metadata_path_by_id(root, id.clone()))?;
let resolver = PathResolver::new(root, id.clone(), info.extension.clone());
Ok(Self {
id: info.id.clone(),
path: resolver,
info,
})
}
pub fn set_content(&mut self, content: Vec<u8>) -> Result<(), WriteFileError> {
let mut content_file = std::fs::File::create(self.path.file_path())?;
let byte_count = content_file.write(&content)?;
self.info.size = byte_count;
self.info.hash = self.hash_content(&content).as_string();
let mut md_file = std::fs::File::create(self.path.metadata_path())?;
let _ = md_file.write(&serde_json::to_vec(&self.info)?)?;
self.write_thumbnail()?;
Ok(())
}
pub fn content(&self) -> Result<Vec<u8>, ReadFileError> {
load_content(&self.path.file_path())
}
pub fn thumbnail(&self) -> Result<Vec<u8>, ReadFileError> {
load_content(&self.path.thumbnail_path())
}
fn hash_content(&self, data: &Vec<u8>) -> HexString {
HexString::from_bytes(&Sha256::digest(data).to_vec())
}
fn write_thumbnail(&self) -> Result<(), WriteFileError> {
let img = image::open(self.path.file_path())?;
let tn = img.resize(640, 640, FilterType::Nearest);
tn.save(self.path.thumbnail_path())?;
Ok(())
}
pub fn delete(self) {
let _ = std::fs::remove_file(self.path.thumbnail_path());
let _ = std::fs::remove_file(self.path.file_path());
let _ = std::fs::remove_file(self.path.metadata_path());
}
}
fn load_content(path: &Path) -> Result<Vec<u8>, ReadFileError> {
let mut buf = Vec::new();
let mut file = std::fs::File::open(path)?;
file.read_to_end(&mut buf)?;
Ok(buf)
}
#[cfg(test)]
mod test {
use super::*;
use std::{convert::TryFrom, path::PathBuf};
use tempdir::TempDir;
#[test]
fn paths() {
let resolver = PathResolver::try_from("path/82420255-d3c8-4d90-a582-f94be588c70c.png")
.expect("to have a valid path");
assert_eq!(
resolver.file_path(),
PathBuf::from("path/82420255-d3c8-4d90-a582-f94be588c70c.png")
);
assert_eq!(
resolver.metadata_path(),
PathBuf::from("path/82420255-d3c8-4d90-a582-f94be588c70c.json")
);
assert_eq!(
resolver.thumbnail_path(),
PathBuf::from("path/82420255-d3c8-4d90-a582-f94be588c70c.tn.png")
);
}
#[test]
fn it_creates_file_info() {
let tmp = TempDir::new("var").unwrap();
let handle =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
assert_eq!(handle.info.name, "rawr");
assert_eq!(handle.info.size, 0);
assert_eq!(handle.info.file_type, "image/png");
assert_eq!(handle.info.extension, "png");
}
#[test]
fn it_opens_a_file() {
let tmp = TempDir::new("var").unwrap();
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
}
#[test]
fn it_deletes_a_file() {
let tmp = TempDir::new("var").unwrap();
let f =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
f.delete();
}
#[test]
fn it_can_return_a_thumbnail() {
let tmp = TempDir::new("var").unwrap();
let _ =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
/*
assert_eq!(
f.thumbnail(),
Thumbnail {
id: String::from("rawr.png"),
root: PathBuf::from("var/"),
},
);
*/
}
#[test]
fn it_can_return_a_file_stream() {
let tmp = TempDir::new("var").unwrap();
let _ =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
// f.stream().expect("to succeed");
}
#[test]
fn it_raises_an_error_when_file_not_found() {
let tmp = TempDir::new("var").unwrap();
match FileHandle::load(&FileId::from("rawr"), tmp.path()) {
Err(ReadFileError::FileNotFound(_)) => assert!(true),
_ => assert!(false),
}
}
}

View File

@ -0,0 +1,76 @@
use crate::FileId;
use super::{ReadFileError, WriteFileError};
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::{
io::{Read, Write},
path::PathBuf,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileInfo {
pub id: FileId,
// Early versions of the application didn't support a name field, so it is possible that
// metadata won't contain the name. We can just default to an empty string when loading the
// metadata, as all future versions will require a filename when the file gets uploaded.
#[serde(default)]
pub name: String,
pub size: usize,
pub created: DateTime<Utc>,
pub file_type: String,
pub hash: String,
pub extension: String,
}
impl FileInfo {
pub fn load(path: PathBuf) -> Result<Self, ReadFileError> {
let mut content: Vec<u8> = Vec::new();
let mut file =
std::fs::File::open(path.clone()).map_err(|_| ReadFileError::FileNotFound(path))?;
file.read_to_end(&mut content)?;
let js = serde_json::from_slice(&content)?;
Ok(js)
}
pub fn save(&self, path: PathBuf) -> Result<(), WriteFileError> {
let ser = serde_json::to_string(self).unwrap();
let mut file = std::fs::File::create(path)?;
let _ = file.write(ser.as_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::store::FileId;
use tempdir::TempDir;
#[test]
fn it_saves_and_loads_metadata() {
let tmp = TempDir::new("var").unwrap();
let created = Utc::now();
let info = FileInfo {
id: FileId("temp-id".to_owned()),
name: "test-image".to_owned(),
size: 23777,
created,
file_type: "image/png".to_owned(),
hash: "abcdefg".to_owned(),
extension: "png".to_owned(),
};
let mut path = tmp.path().to_owned();
path.push(&PathBuf::from(info.id.clone()));
info.save(path.clone()).unwrap();
let info_ = FileInfo::load(path).unwrap();
assert_eq!(info_.size, 23777);
assert_eq!(info_.created, info.created);
assert_eq!(info_.file_type, "image/png");
assert_eq!(info_.hash, info.hash);
}
}

View File

@ -0,0 +1,269 @@
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, ops::Deref, path::PathBuf};
use thiserror::Error;
mod filehandle;
mod fileinfo;
pub use filehandle::FileHandle;
pub use fileinfo::FileInfo;
#[derive(Debug, Error)]
pub enum WriteFileError {
#[error("root file path does not exist")]
RootNotFound,
#[error("permission denied")]
PermissionDenied,
#[error("invalid path")]
InvalidPath,
#[error("no metadata available")]
NoMetadata,
#[error("file could not be loaded")]
LoadError(#[from] ReadFileError),
#[error("image conversion failed")]
ImageError(#[from] image::ImageError),
#[error("JSON error")]
JSONError(#[from] serde_json::error::Error),
#[error("IO error")]
IOError(#[from] std::io::Error),
}
#[derive(Debug, Error)]
pub enum ReadFileError {
#[error("file not found")]
FileNotFound(PathBuf),
#[error("path is not a file")]
NotAFile,
#[error("permission denied")]
PermissionDenied,
#[error("JSON error")]
JSONError(#[from] serde_json::error::Error),
#[error("IO error")]
IOError(#[from] std::io::Error),
}
#[derive(Debug, Error)]
pub enum DeleteFileError {
#[error("file not found")]
FileNotFound(PathBuf),
#[error("metadata path is not a file")]
NotAFile,
#[error("cannot read metadata")]
PermissionDenied,
#[error("invalid metadata path")]
MetadataParseError(serde_json::error::Error),
#[error("IO error")]
IOError(#[from] std::io::Error),
}
impl From<ReadFileError> for DeleteFileError {
fn from(err: ReadFileError) -> Self {
match err {
ReadFileError::FileNotFound(path) => DeleteFileError::FileNotFound(path),
ReadFileError::NotAFile => DeleteFileError::NotAFile,
ReadFileError::PermissionDenied => DeleteFileError::PermissionDenied,
ReadFileError::JSONError(err) => DeleteFileError::MetadataParseError(err),
ReadFileError::IOError(err) => DeleteFileError::IOError(err),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct FileId(String);
impl From<String> for FileId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for FileId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<FileId> for PathBuf {
fn from(s: FileId) -> Self {
Self::from(&s)
}
}
impl From<&FileId> for PathBuf {
fn from(s: &FileId) -> Self {
let FileId(s) = s;
Self::from(s)
}
}
impl Deref for FileId {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub trait FileRoot {
fn root(&self) -> PathBuf;
}
pub struct Context(PathBuf);
impl FileRoot for Context {
fn root(&self) -> PathBuf {
self.0.clone()
}
}
pub struct Store {
files_root: PathBuf,
}
impl Store {
pub fn new(files_root: PathBuf) -> Self {
Self { files_root }
}
pub fn list_files(&self) -> Result<HashSet<FileId>, ReadFileError> {
let paths = std::fs::read_dir(&self.files_root)?;
let info_files = paths
.into_iter()
.filter_map(|path| {
let path_ = path.unwrap().path();
if path_.extension().and_then(|s| s.to_str()) == Some("json") {
let stem = path_.file_stem().and_then(|s| s.to_str()).unwrap();
Some(FileId::from(stem))
} else {
None
}
})
.collect::<HashSet<FileId>>();
Ok(info_files)
}
pub fn add_file(
&mut self,
filename: String,
content: Vec<u8>,
) -> Result<FileHandle, WriteFileError> {
let mut file = FileHandle::new(filename, self.files_root.clone())?;
file.set_content(content)?;
Ok(file)
}
pub fn get_file(&self, id: &FileId) -> Result<FileHandle, ReadFileError> {
FileHandle::load(id, &self.files_root)
}
pub fn delete_file(&mut self, id: &FileId) -> Result<(), DeleteFileError> {
let handle = FileHandle::load(id, &self.files_root)?;
handle.delete();
Ok(())
}
pub fn get_metadata(&self, id: &FileId) -> Result<FileInfo, ReadFileError> {
let mut path = self.files_root.clone();
path.push(PathBuf::from(id));
path.set_extension("json");
FileInfo::load(path)
}
}
#[cfg(test)]
mod test {
use super::*;
use cool_asserts::assert_matches;
use std::{collections::HashSet, io::Read};
use tempdir::TempDir;
fn with_file<F>(test_fn: F)
where
F: FnOnce(Store, FileId, TempDir),
{
let tmp = TempDir::new("var").unwrap();
let mut buf = Vec::new();
let mut file = std::fs::File::open("fixtures/rawr.png").unwrap();
file.read_to_end(&mut buf).unwrap();
let mut store = Store::new(PathBuf::from(tmp.path()));
let file_record = store.add_file("rawr.png".to_owned(), buf).unwrap();
test_fn(store, file_record.id, tmp);
}
#[test]
fn adds_files() {
with_file(|store, id, tmp| {
let file = store.get_file(&id).expect("to retrieve the file");
assert_eq!(file.content().map(|file| file.len()).unwrap(), 23777);
assert!(tmp.path().join(&(*id)).with_extension("png").exists());
assert!(tmp.path().join(&(*id)).with_extension("json").exists());
assert!(tmp.path().join(&(*id)).with_extension("tn.png").exists());
});
}
#[test]
fn sets_up_metadata_for_file() {
with_file(|store, id, tmp| {
assert!(tmp.path().join(&(*id)).with_extension("png").exists());
let info = store.get_metadata(&id).expect("to retrieve the metadata");
assert_matches!(info, FileInfo { size, file_type, hash, extension, .. } => {
assert_eq!(size, 23777);
assert_eq!(file_type, "image/png");
assert_eq!(hash, "b6cd35e113b95d62f53d9cbd27ccefef47d3e324aef01a2db6c0c6d3a43c89ee".to_owned());
assert_eq!(extension, "png".to_owned());
});
});
}
/*
#[test]
fn sets_up_thumbnail_for_file() {
with_file(|store, id| {
let (_, thumbnail) = store.get_thumbnail(&id).expect("to retrieve the thumbnail");
assert_eq!(thumbnail.content().map(|file| file.len()).unwrap(), 48869);
});
}
*/
#[test]
fn deletes_associated_files() {
with_file(|mut store, id, tmp| {
store.delete_file(&id).expect("file to be deleted");
assert!(!tmp.path().join(&(*id)).with_extension("png").exists());
assert!(!tmp.path().join(&(*id)).with_extension("json").exists());
assert!(!tmp.path().join(&(*id)).with_extension("tn.png").exists());
});
}
#[test]
fn lists_files_in_the_db() {
with_file(|store, id, _| {
let resolvers = store.list_files().expect("file listing to succeed");
let ids = resolvers.into_iter().collect::<HashSet<FileId>>();
assert_eq!(ids.len(), 1);
assert!(ids.contains(&id));
});
}
}

View File

@ -0,0 +1,91 @@
use super::{ReadFileError, WriteFileError};
use image::imageops::FilterType;
use std::{
fs::remove_file,
io::Read,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, PartialEq)]
pub struct Thumbnail {
pub path: PathBuf,
}
impl Thumbnail {
pub fn open(
origin_path: PathBuf,
thumbnail_path: PathBuf,
) -> Result<Thumbnail, WriteFileError> {
let s = Thumbnail {
path: PathBuf::from(thumbnail_path),
};
if !s.path.exists() {
let img = image::open(&origin_path)?;
let tn = img.resize(640, 640, FilterType::Nearest);
tn.save(&s.path)?;
}
Ok(s)
}
pub fn load(path: PathBuf) -> Result<Thumbnail, ReadFileError> {
let s = Thumbnail { path: path.clone() };
if !s.path.exists() {
return Err(ReadFileError::FileNotFound(path));
}
Ok(s)
}
/*
pub fn from_path(path: &Path) -> Result<Thumbnail, ReadFileError> {
let id = path
.file_name()
.map(|s| String::from(s.to_string_lossy()))
.ok_or(ReadFileError::NotAnImage(PathBuf::from(path)))?;
let path = path
.parent()
.ok_or(ReadFileError::FileNotFound(PathBuf::from(path)))?;
Thumbnail::open(&id, root)
}
*/
/*
pub fn stream(&self) -> Result<std::fs::File, ReadFileError> {
std::fs::File::open(self.path.clone()).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
ReadFileError::FileNotFound
} else {
ReadFileError::from(err)
}
})
}
*/
/*
pub fn delete(self) -> Result<(), WriteFileError> {
remove_file(self.path).map_err(WriteFileError::from)
}
*/
}
#[cfg(test)]
mod test {
use super::*;
use crate::store::utils::FileCleanup;
#[test]
fn it_creates_a_thumbnail_if_one_does_not_exist() {
let _ = FileCleanup(PathBuf::from("var/rawr.tn.png"));
let _ = Thumbnail::open(
PathBuf::from("fixtures/rawr.png"),
PathBuf::from("var/rawr.tn.png"),
)
.expect("thumbnail open must work");
assert!(Path::new("var/rawr.tn.png").is_file());
}
}

View File

@ -0,0 +1,15 @@
<html>
<head>
<title> {{title}} </title>
<link href="/css" rel="stylesheet" type="text/css" media="screen" />
<script src="/script"></script>
</head>
<body>
<a href="/file/{{id}}"><img src="/tn/{{id}}" /></a>
</body>
</html>

View File

@ -0,0 +1,54 @@
<html>
<head>
<title> Admin list of files </title>
<link href="/css" rel="stylesheet" type="text/css" media="screen" />
<script src="/script"></script>
</head>
<body>
<h1> Admin list of files </h1>
<div class="uploadform">
<form action="/" method="post" enctype="multipart/form-data">
<div id="file-selector">
<input type="file" name="file" id="file-selector-input" />
<label for="file-selector-input" onclick="selectFile('file-selector')">Select a file</label>
</div>
<input type="submit" value="Upload file" />
</form>
</div>
<div class="files">
{{#files}}
<div class="file">
{{#error}}
<div>
<p> {{error}} </p>
</div>
{{/error}}
{{#file}}
<div class="thumbnail">
<a href="/file/{{id}}"><img src="/tn/{{id}}" /></a>
</div>
<div>
<ul>
<li> {{date}} </li>
<li> {{type_}} </li>
<li> {{size}} </li>
</ul>
<div>
<form action="/{{id}}" method="post">
<input type="hidden" name="_method" value="delete" />
<input type="submit" value="Delete" />
</form>
</div>
</div>
{{/file}}
</div>
{{/files}}
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
const selectFile = (selectorId) => {
console.log("wide arrow functions work: " + selectorId);
const input = document.querySelector("#" + selectorId + " input[type='file']")
const label = document.querySelector("#" + selectorId + " label")
input.addEventListener("change", (e) => {
if (input.files.length > 0) {
label.innerHTML = input.files[0].name
}
})
}

View File

@ -0,0 +1,186 @@
:root {
--main-bg-color: #e5f0fc;
--fg-color: #449dfc;
--space-small: 4px;
--space-medium: 8px;
--space-large: 12px;
--hover-low: 4px 4px 4px gray;
}
body {
font-family: 'Ariel', sans-serif;
background-color: var(--main-bg-color);
}
.card {
border: 1px solid black;
border-radius: 5px;
box-shadow: var(--hover-low);
margin: var(--space-large);
padding: var(--space-medium);
}
.authentication-page {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 200px;
margin: 8px;
}
.authentication-form {
}
.authentication-form__label {
margin: var(--space-small);
}
.authentication-form__input {
margin: var(--space-small);
}
.gallery-page {
display: flex;
flex-direction: column;
}
.upload-form {
display: flex;
flex-direction: column;
}
.upload-form__selector {
margin: var(--space-small);
}
.upload-form__button {
margin: var(--space-small);
}
.gallery {
display: flex;
flex-wrap: wrap;
}
.thumbnail {
width: 300px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.thumbnail__image {
max-width: 100%;
border: none;
}
.thumbnail__metadata {
list-style: none;
}
/*
[type="submit"] {
border-radius: 1em;
margin: 1em;
padding: 1em;
}
.uploadform {
}
*/
/*
[type="file"] {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
overflow: hidden;
padding: 0;
position: absolute !important;
white-space: nowrap;
width: 1px;
}
[type="file"] + label {
background-color: rgb(0, 86, 112);
border-radius: 1em;
color: #fff;
cursor: pointer;
display: inline-block;
padding: 1em;
margin: 1em;
transition: background-color 0.3s;
}
[type="file"]:focus + label,
[type="file"] + label:hover {
background-color: #67b0ff;
}
[type="file"]:focus + label {
outline: 1px dotted #000;
outline: -webkit-focus-ring-color auto 5px;
}
*/
@media screen and (max-width: 1080px) { /* This is the screen width of a OnePlus 8 */
body {
font-size: xx-large;
}
.authentication-form {
width: 100%;
display: flex;
flex-direction: column;
}
.authentication-form__input {
font-size: x-large;
}
.authentication-form__button {
font-size: x-large;
}
.upload-form__selector {
font-size: larger;
}
.upload-form__button {
font-size: larger;
}
.thumbnail {
width: 100%;
}
/*
[type="submit"] {
font-size: xx-large;
width: 100%;
}
.uploadform {
display: flex;
}
[type="file"] + label {
width: 100%;
}
.thumbnail {
max-width: 100%;
margin: 1em;
}
.file {
display: flex;
flex-direction: column;
width: 100%;
}
*/
}

View File

@ -0,0 +1,26 @@
[package]
name = "fitnesstrax"
version = "0.6.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
async-channel = { version = "2.1" }
async-trait = { version = "0.1" }
chrono = { version = "0.4" }
chrono-tz = { version = "0.8" }
dimensioned = { version = "0.8", features = [ "serde" ] }
emseries = { path = "../../emseries" }
ft-core = { path = "../core" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
thiserror = { version = "1.0" }
tokio = { version = "1.34", features = [ "full" ] }
[build-dependencies]
glib-build-tools = "0.18"

7
fitnesstrax/app/build.rs Normal file
View File

@ -0,0 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
&["resources"],
"gresources.xml",
"com.luminescent-dreams.fitnesstrax.gresource",
);
}

12
fitnesstrax/app/dist.sh Executable file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION=`cat Cargo.toml | grep "^version =" | sed -r 's/^version = "(.+)"$/\1/'`
mkdir -p dist
cp ../../target/release/fitnesstrax dist
cp resources/com.luminescent-dreams.fitnesstrax.gschema.xml resources/fitnesstrax.desktop dist
strip dist/fitnesstrax
tar -czf fitnesstrax-${VERSION}.tgz dist/

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/">
<file>style.css</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">cycling-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">running-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions/">
<file preprocess="xml-stripblanks">walking-symbolic.svg</file>
</gresource>
</gresources>

View File

@ -0,0 +1,14 @@
{ gtkNativeInputs }:
attrs: {
nativeBuildInputs = gtkNativeInputs;
postInstall = ''
install -Dt $out/share/applications resources/fitnesstrax.desktop
install -Dt $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas resources/com.luminescent-dreams.fitnesstrax.gschema.xml
glib-compile-schemas $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas
'';
preFixup = ''
gappsWrapperArgs+=(
--prefix XDG_DATA_DIRS : $out/gsettings-schemas/${attrs.crateName}-${attrs.version}
)
'';
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<schema id="com.luminescent-dreams.fitnesstrax.dev" path="/com/luminescent-dreams/fitnesstrax/dev/">
<key name="series-path" type="s">
<default>""</default>
<summary>Path to the series</summary>
</key>
</schema>
</schemalist>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<schemalist>
<schema id="com.luminescent-dreams.fitnesstrax" path="/com/luminescent-dreams/fitnesstrax/">
<key name="series-path" type="s">
<default>""</default>
<summary>Path to the series</summary>
</key>
</schema>
</schemalist>

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 2 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m 0 0"/><path d="m 4.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><path d="m 8.992188 13.007812 v -3.003906 c 0 -0.359375 -0.1875 -0.6875 -0.5 -0.867187 l -2.558594 -1.476563 l 0.363281 1.363282 l 1.671875 -2.890626 l -1.367188 0.363282 l 0.910157 0.527344 l -0.40625 -0.4375 c 0.773437 1.621093 1.96875 1.933593 1.96875 1.933593 s 0.578125 0.242188 1.9375 0.429688 c 0.546875 0.074219 1.050781 -0.304688 1.128906 -0.851563 c 0.074219 -0.550781 -0.308594 -1.054687 -0.855469 -1.128906 c -1.179687 -0.164062 -1.601562 -0.355469 -1.601562 -0.355469 s -0.425782 -0.164062 -0.769532 -0.886719 c -0.089843 -0.183593 -0.226562 -0.335937 -0.402343 -0.4375 l -0.910157 -0.523437 c -0.476562 -0.277344 -1.089843 -0.113281 -1.363281 0.367187 l -1.671875 2.890626 c -0.277344 0.480468 -0.113281 1.089843 0.367188 1.367187 l 2.558594 1.480469 l -0.5 -0.867188 v 3.003906 c 0 0.550782 0.449218 1 1 1 c 0.554687 0 1 -0.449218 1 -1 z m 0 0"/><path d="m 14.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,5 @@
[Desktop Entry]
Version=0.2
Type=Application
Name=FitnessTrax
Exec=fitnesstrax

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 8.5 0 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m -2.5 4 c -0.117188 0 -0.230469 0.027344 -0.335938 0.082031 l -2 1 c -0.144531 0.070313 -0.261718 0.1875 -0.332031 0.332031 l -1 2 c -0.1875 0.371094 -0.039062 0.820313 0.332031 1.007813 c 0.371094 0.183594 0.820313 0.035156 1.003907 -0.335937 l 0.890625 -1.777344 l 1.5625 -0.773438 c -0.042969 0.074219 -0.726563 2.835938 -0.726563 2.835938 c -0.230469 0.949218 0.398438 1.523437 0.398438 1.523437 l 3.351562 2.703125 l 0.90625 2.71875 c 0.175781 0.523438 0.742188 0.808594 1.265625 0.632813 c 0.523438 -0.175781 0.808594 -0.742188 0.632813 -1.265625 l -1 -3 c -0.0625 -0.183594 -0.171875 -0.34375 -0.324219 -0.464844 l -2 -1.597656 l 0.679688 -2.714844 l 0.25 0.625 c 0.085937 0.222656 0.28125 0.390625 0.515624 0.449219 l 2 0.5 c 0.402344 0.097656 0.808594 -0.144531 0.910157 -0.546875 c 0.097656 -0.40625 -0.144531 -0.8125 -0.546875 -0.910156 l -1.628906 -0.40625 l -0.855469 -2.144532 c -0.117188 -0.285156 -0.390625 -0.472656 -0.699219 -0.472656 z m -1.164062 6.328125 l -0.710938 2.128906 l -1.832031 1.835938 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 2 -2 c 0.109375 -0.109375 0.191407 -0.242187 0.242188 -0.390625 l 0.542969 -1.628906 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,85 @@
.welcome {
margin: 64px;
}
.welcome__title {
font-size: x-large;
padding: 8px;
}
.welcome__content {
padding: 8px;
}
.historical {
margin: 32px;
border-radius: 8px;
}
.date-range-picker {
margin-bottom: 16px;
}
/*
.date-range-picker > box:not(:last-child) {
margin-bottom: 8px;
}
*/
.date-range-picker__date-field {
margin: 8px;
}
.date-range-picker__search-button {
margin: 8px;
}
.date-range-picker__range-button {
margin: 8px;
}
.date-field__year {
margin: 0px 4px 0px 0px;
}
.date-field__month {
margin: 0px 4px 0px 4px;
}
.date-field__day {
margin: 0px 0px 0px 4px;
}
.day-summary {
padding: 8px;
}
.day-summary > *:not(:last-child) {
margin-bottom: 8px;
}
.day-summary__date {
font-size: x-large;
}
.day-summary__weight {
margin: 4px;
}
.weight-view {
padding: 8px;
margin: 8px;
}
.step-view {
padding: 8px;
margin: 8px;
}
.about__content {
padding: 32px;
}
.about label {
margin-bottom: 16px;
}

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 1.5 c 0 0.828125 -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 s 1.5 0.671875 1.5 1.5 z m 0 0"/><path d="m 7 4 c -0.550781 0 -1 0.449219 -1 1 v 4 c 0 0.265625 0.105469 0.519531 0.292969 0.707031 l 0.445312 0.449219 l -2.59375 4.328125 c -0.285156 0.476563 -0.132812 1.089844 0.34375 1.375 c 0.472657 0.28125 1.085938 0.128906 1.367188 -0.34375 l 2.34375 -3.902344 l 0.925781 0.929688 l 0.925781 2.773437 c 0.082031 0.25 0.265625 0.460938 0.5 0.578125 c 0.238281 0.121094 0.515625 0.140625 0.765625 0.054688 c 0.25 -0.082031 0.460938 -0.265625 0.578125 -0.5 c 0.121094 -0.238281 0.140625 -0.515625 0.054688 -0.765625 l -1 -3 c -0.050781 -0.148438 -0.132813 -0.28125 -0.242188 -0.390625 l -1.707031 -1.707031 v -4.585938 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/><path d="m 6 4 c -0.101562 0 -0.207031 0.019531 -0.300781 0.0625 c 0 0 -2.113281 0.847656 -2.199219 2.90625 v 0.03125 v 2.25 c 0 0.414062 0.335938 0.75 0.75 0.75 s 0.75 -0.335938 0.75 -0.75 v -2.21875 c 0.039062 -0.894531 1.050781 -1.449219 1.207031 -1.53125 h 2.332031 l 1.042969 2.085938 c 0.097657 0.195312 0.273438 0.339843 0.488281 0.394531 l 2 0.5 c 0.191407 0.046875 0.394532 0.015625 0.566407 -0.085938 c 0.171875 -0.101562 0.292969 -0.269531 0.34375 -0.460937 c 0.046875 -0.195313 0.015625 -0.398438 -0.085938 -0.570313 c -0.101562 -0.171875 -0.269531 -0.292969 -0.464843 -0.34375 l -1.664063 -0.414062 l -1.097656 -2.191407 c -0.125 -0.253906 -0.382813 -0.414062 -0.667969 -0.414062 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,80 @@
/*
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct AboutWindowPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for AboutWindowPrivate {
const NAME: &'static str = "AboutWindow";
type Type = AboutWindow;
type ParentType = gtk::Window;
}
impl ObjectImpl for AboutWindowPrivate {}
impl WidgetImpl for AboutWindowPrivate {}
impl WindowImpl for AboutWindowPrivate {}
glib::wrapper! {
pub struct AboutWindow(ObjectSubclass<AboutWindowPrivate>) @extends gtk::Window, gtk::Widget;
}
impl Default for AboutWindow {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_width_request(600);
s.set_height_request(700);
s.add_css_class("about");
s.set_title(Some("About Fitnesstrax"));
let copyright = gtk::Label::builder()
.label("Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>")
.halign(gtk::Align::Start)
.build();
let gtk_rs_thanks = gtk::Label::builder()
.label("I owe a huge debt of gratitude to the GTK-RS project (https://gtk-rs.org/), which makes it possible for me to write this application to begin with. Further, I owe a particular debt to Julian Hofer and his book, GUI development with Rust and GTK 4 (https://gtk-rs.org/gtk4-rs/stable/latest/book/). Without this book, I would have continued to stumble around writing bad user interfaces with even worse code.")
.halign(gtk::Align::Start).wrap(true)
.build();
let dependencies = gtk::Label::builder()
.label("This application depends on many libraries, most of which are licensed under the BSD-3 or GPL-3 licenses.")
.halign(gtk::Align::Start).wrap(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.css_classes(["about__content"])
.build();
content.append(&copyright);
content.append(&gtk_rs_thanks);
content.append(&dependencies);
let scroller = gtk::ScrolledWindow::builder()
.child(&content)
.hexpand(true)
.vexpand(true)
.hscrollbar_policy(gtk::PolicyType::Never)
.build();
s.set_child(Some(&scroller));
s
}
}

167
fitnesstrax/app/src/app.rs Normal file
View File

@ -0,0 +1,167 @@
/*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use async_trait::async_trait;
use chrono::NaiveDate;
use emseries::{time_range, Record, RecordId, Series, Timestamp};
use ft_core::TraxRecord;
use std::{
path::PathBuf,
sync::{Arc, RwLock},
};
use thiserror::Error;
use tokio::runtime::Runtime;
#[derive(Debug, Error)]
pub enum AppError {
#[error("no database loaded")]
NoDatabase,
#[error("failed to open the database")]
FailedToOpenDatabase,
#[error("unhandled error")]
Unhandled,
}
#[derive(Debug, Error)]
pub enum ReadError {
#[error("no database loaded")]
NoDatabase,
}
#[derive(Debug, Error)]
pub enum WriteError {
#[error("no database loaded")]
NoDatabase,
#[error("unhandled error")]
Unhandled,
}
#[async_trait]
pub trait RecordProvider: Send + Sync {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError>;
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError>;
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError>;
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>;
}
/// The real, headless application. This is where all of the logic will reside.
#[derive(Clone)]
pub struct App {
runtime: Arc<Runtime>,
database: Arc<RwLock<Option<Series<TraxRecord>>>>,
}
impl App {
pub fn new(db_path: Option<PathBuf>) -> Self {
let database = db_path.map(|path| Series::open(path).unwrap());
let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap(),
);
Self {
runtime,
database: Arc::new(RwLock::new(database)),
}
}
pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> {
let db_ref = self.database.clone();
self.runtime
.spawn_blocking(move || {
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
*db_ref.write().unwrap() = Some(db);
Ok(())
})
.await
.unwrap()
}
pub fn database_is_open(&self) -> bool {
self.database.read().unwrap().is_some()
}
}
#[async_trait]
impl RecordProvider for App {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref db) = *db.read().unwrap() {
let records = db
.search(time_range(
Timestamp::Date(start),
true,
Timestamp::Date(end),
true,
))
.cloned()
.collect::<Vec<Record<TraxRecord>>>();
Ok(records)
} else {
Err(ReadError::NoDatabase)
}
})
.await
.unwrap()
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref mut db) = *db.write().unwrap() {
let id = db.put(record).unwrap();
Ok(id)
} else {
Err(AppError::NoDatabase)
}
})
.await
.unwrap()
.map_err(|_| WriteError::Unhandled)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
let db = self.database.clone();
self.runtime
.spawn_blocking(move || {
if let Some(ref mut db) = *db.write().unwrap() {
db.update(record).map_err(|_| AppError::Unhandled)
} else {
Err(AppError::NoDatabase)
}
})
.await
.unwrap()
.map_err(|_| WriteError::Unhandled)
}
async fn delete_record(&self, _id: RecordId) -> Result<(), WriteError> {
unimplemented!()
}
}

View File

@ -0,0 +1,215 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
app::App,
types::DayInterval,
view_models::DayDetailViewModel,
views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView},
};
use adw::prelude::*;
use chrono::{Duration, Local};
use gio::resources_lookup_data;
use gtk::STYLE_PROVIDER_PRIORITY_USER;
use std::{cell::RefCell, path::PathBuf, rc::Rc};
/// The application window, or the main window, is the main user interface for the app. Almost
/// everything occurs here.
#[derive(Clone)]
pub struct AppWindow {
app: App,
layout: gtk::Box,
current_view: Rc<RefCell<View>>,
settings: gio::Settings,
navigation: adw::NavigationView,
}
impl AppWindow {
/// Construct a new App Window.
///
/// adw_app is an Adwaita application. Application windows need to have access to this, but
/// otherwise we don't use this.
///
/// app is a core [crate::app::App] object which encapsulates all of the basic logic.
pub fn new(
app_id: &str,
resource_path: &str,
adw_app: &adw::Application,
ft_app: App,
) -> AppWindow {
let window = adw::ApplicationWindow::builder()
.application(adw_app)
.width_request(800)
.height_request(746)
.build();
window.connect_destroy(|s| {
let _ = gtk::prelude::WidgetExt::activate_action(s, "app.quit", None);
});
let stylesheet = String::from_utf8(
resources_lookup_data(
&format!("{}style.css", resource_path),
gio::ResourceLookupFlags::NONE,
)
.expect("stylesheet must be available in the resources")
.to_vec(),
)
.expect("to parse stylesheet");
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
#[allow(deprecated)]
let context = window.style_context();
#[allow(deprecated)]
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
let navigation = adw::NavigationView::new();
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
let initial_view = View::Placeholder(PlaceholderView::default().upcast());
let header_bar = adw::HeaderBar::new();
let main_menu = gio::Menu::new();
main_menu.append(Some("About"), Some("app.about"));
main_menu.append(Some("Quit"), Some("app.quit"));
let main_menu_button = gtk::MenuButton::builder()
.icon_name("open-menu")
.direction(gtk::ArrowType::Down)
.halign(gtk::Align::End)
.menu_model(&main_menu)
.build();
header_bar.pack_end(&main_menu_button);
layout.append(&initial_view.widget());
let nav_layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
nav_layout.append(&header_bar);
nav_layout.append(&layout);
navigation.push(
&adw::NavigationPage::builder()
.can_pop(false)
.title("FitnessTrax")
.child(&nav_layout)
.build(),
);
window.set_content(Some(&navigation));
window.present();
let s = Self {
app: ft_app,
layout,
current_view: Rc::new(RefCell::new(initial_view)),
settings: gio::Settings::new(app_id),
navigation,
};
s.load_records();
s.navigation.connect_popped({
let s = s.clone();
move |_, _| {
if let View::Historical(_) = *s.current_view.borrow() {
s.load_records();
}
}
});
s
}
fn show_welcome_view(&self) {
let view = View::Welcome(WelcomeView::new({
let s = self.clone();
move |path| s.on_apply_config(path)
}));
self.swap_main(view);
}
fn show_historical_view(&self, interval: DayInterval) {
let on_select_day = {
let s = self.clone();
move |date| {
let s = s.clone();
glib::spawn_future_local(async move {
let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap();
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
layout.append(&adw::HeaderBar::new());
// layout.append(&DayDetailView::new(date, records, s.app.clone()));
layout.append(&DayDetailView::new(view_model));
let page = &adw::NavigationPage::builder()
.title(date.format("%Y-%m-%d").to_string())
.child(&layout)
.build();
s.navigation.push(page);
});
}
};
let view = View::Historical(HistoricalView::new(
self.app.clone(),
interval,
Rc::new(on_select_day),
));
self.swap_main(view);
}
fn load_records(&self) {
glib::spawn_future_local({
let s = self.clone();
async move {
if s.app.database_is_open() {
let end = Local::now().date_naive();
let start = end - Duration::days(7);
s.show_historical_view(DayInterval { start, end });
} else {
s.show_welcome_view();
}
}
});
}
// Switch views.
//
// This function only replaces the old view with the one which matches the current view state.
// It is responsible for ensuring that the new view goes into the layout in the correct
// position.
fn swap_main(&self, view: View) {
let mut current_widget = self.current_view.borrow_mut();
self.layout.remove(&current_widget.widget());
*current_widget = view;
self.layout.append(&current_widget.widget());
}
#[allow(unused)]
fn on_apply_config(&self, path: PathBuf) {
glib::spawn_future_local({
let s = self.clone();
async move {
if s.app.open_db(path.clone()).await.is_ok() {
let _ = s.settings.set("series-path", path.to_str().unwrap());
s.load_records();
}
}
});
}
}

View File

@ -0,0 +1,137 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! ActionGroup and related structures
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct ActionGroupPrivate;
#[glib::object_subclass]
impl ObjectSubclass for ActionGroupPrivate {
const NAME: &'static str = "ActionGroup";
type Type = ActionGroup;
type ParentType = gtk::Box;
}
impl ObjectImpl for ActionGroupPrivate {}
impl WidgetImpl for ActionGroupPrivate {}
impl BoxImpl for ActionGroupPrivate {}
glib::wrapper! {
pub struct ActionGroup(ObjectSubclass<ActionGroupPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl ActionGroup {
fn new(builder: ActionGroupBuilder) -> Self {
let s: Self = Object::builder().build();
s.set_orientation(builder.orientation);
let primary_button = builder.primary_action.button();
let secondary_button = builder.secondary_action.map(|action| action.button());
let tertiary_button = builder.tertiary_action.map(|action| action.button());
if let Some(button) = tertiary_button {
s.append(&button);
}
s.set_halign(gtk::Align::End);
if let Some(button) = secondary_button {
s.append(&button);
}
s.append(&primary_button);
s
}
pub fn builder() -> ActionGroupBuilder {
ActionGroupBuilder {
orientation: gtk::Orientation::Horizontal,
primary_action: Action {
label: "Ok".to_owned(),
action: Box::new(|| {}),
},
secondary_action: None,
tertiary_action: None,
}
}
}
struct Action {
label: String,
action: Box<dyn Fn()>,
}
impl Action {
fn button(self) -> gtk::Button {
let button = gtk::Button::builder().label(self.label).build();
button.connect_clicked(move |_| (self.action)());
button
}
}
pub struct ActionGroupBuilder {
orientation: gtk::Orientation,
primary_action: Action,
secondary_action: Option<Action>,
tertiary_action: Option<Action>,
}
impl ActionGroupBuilder {
pub fn orientation(mut self, orientation: gtk::Orientation) -> Self {
self.orientation = orientation;
self
}
pub fn primary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.primary_action = Action {
label: label.to_owned(),
action: Box::new(action),
};
self
}
pub fn secondary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.secondary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn tertiary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.tertiary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn build(self) -> ActionGroup {
ActionGroup::new(self)
}
}

View File

@ -0,0 +1,175 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::{i32_field_builder, TextEntry, month_field_builder}, types::ParseError};
use chrono::{Datelike, Local};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
pub struct DateFieldPrivate {
date: Rc<RefCell<chrono::NaiveDate>>,
year: TextEntry<i32>,
month: TextEntry<u32>,
day: TextEntry<u32>,
}
#[glib::object_subclass]
impl ObjectSubclass for DateFieldPrivate {
const NAME: &'static str = "DateField";
type Type = DateField;
type ParentType = gtk::Box;
fn new() -> Self {
let date = Rc::new(RefCell::new(Local::now().date_naive()));
let year = i32_field_builder()
.with_value(date.borrow().year())
.with_on_update(
{
let date = date.clone();
move |value| {
if let Some(year) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_year(year) {
*date = new_date;
}
}
}
})
.with_length(4)
.with_css_classes(vec!["date-field__year".to_owned()]).build();
let month = month_field_builder()
.with_value(date.borrow().month())
.with_on_update(
{
let date = date.clone();
move |value| {
if let Some(month) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_month(month) {
*date = new_date;
}
}
}
})
.with_css_classes(vec!["date-field__month".to_owned()])
.build();
/* Modify this so that it enforces the number of days per month */
let day = TextEntry::builder()
.with_placeholder("day".to_owned())
.with_value(date.borrow().day())
.with_renderer(|v| format!("{}", v))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update({
let date = date.clone();
move |value| {
if let Some(day) = value {
let mut date = date.borrow_mut();
if let Some(new_date) = date.with_day(day) {
*date = new_date;
}
}
}
})
.with_css_classes(vec!["date-field__day".to_owned()])
.build();
Self {
date,
year,
month,
day,
}
}
}
impl ObjectImpl for DateFieldPrivate {}
impl WidgetImpl for DateFieldPrivate {}
impl BoxImpl for DateFieldPrivate {}
glib::wrapper! {
pub struct DateField(ObjectSubclass<DateFieldPrivate>) @extends gtk::Box, gtk::Widget;
}
/* Render a date in the format 2006 Jan 01. The entire date is editable. When the user moves to one part of the date, it will be erased and replaced with a grey placeholder.
*/
impl DateField {
pub fn new(date: chrono::NaiveDate) -> Self {
let s: Self = Object::builder().build();
s.add_css_class("date-field");
s.append(&s.imp().year.widget());
s.append(&gtk::Label::new(Some("-")));
s.append(&s.imp().month.widget());
s.append(&gtk::Label::new(Some("-")));
s.append(&s.imp().day.widget());
s.set_date(date);
s
}
pub fn set_date(&self, date: chrono::NaiveDate) {
self.imp().year.set_value(Some(date.year()));
self.imp().month.set_value(Some(date.month()));
self.imp().day.set_value(Some(date.day()));
*self.imp().date.borrow_mut() = date;
}
pub fn date(&self) -> chrono::NaiveDate {
*self.imp().date.borrow()
}
/*
pub fn is_valid(&self) -> bool {
false
}
*/
}
#[cfg(test)]
mod test {
use super::*;
// use crate::gtk_init::gtk_init;
// Enabling this test pushes tests on the TextField into an infinite loop. That likely indicates a bad interaction within the TextField itself, and that is going to need to be fixed.
#[test]
#[ignore]
fn it_allows_valid_dates() {
let reference = chrono::NaiveDate::from_ymd_opt(2006, 01, 02).unwrap();
let field = DateField::new(reference);
field.imp().year.set_value(Some(2023));
field.imp().month.set_value(Some(10));
field.imp().day.set_value(Some(13));
// assert!(field.is_valid());
}
#[test]
#[ignore]
fn it_disallows_out_of_range_months() {
unimplemented!()
}
#[test]
#[ignore]
fn it_allows_days_within_range_for_month() {
unimplemented!()
}
}

View File

@ -0,0 +1,189 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::DateField, types::DayInterval};
use chrono::{Duration, Local, Months};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
type OnSearch = dyn Fn(DayInterval) + 'static;
pub struct DateRangePickerPrivate {
start: DateField,
end: DateField,
on_search: RefCell<Box<OnSearch>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DateRangePickerPrivate {
const NAME: &'static str = "DateRangePicker";
type Type = DateRangePicker;
type ParentType = gtk::Box;
fn new() -> Self {
let default_date = Local::now().date_naive();
let start = DateField::new(default_date);
start.add_css_class("date-range-picker__date-field");
let end = DateField::new(default_date);
end.add_css_class("date-range-picker__date-field");
Self {
start,
end,
on_search: RefCell::new(Box::new(|_| {})),
}
}
}
impl ObjectImpl for DateRangePickerPrivate {}
impl WidgetImpl for DateRangePickerPrivate {}
impl BoxImpl for DateRangePickerPrivate {}
glib::wrapper! {
pub struct DateRangePicker(ObjectSubclass<DateRangePickerPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DateRangePicker {
pub fn connect_on_search<OnSearch>(&self, f: OnSearch)
where
OnSearch: Fn(DayInterval) + 'static,
{
*self.imp().on_search.borrow_mut() = Box::new(f);
}
pub fn set_interval(&self, start: chrono::NaiveDate, end: chrono::NaiveDate) {
self.imp().start.set_date(start);
self.imp().end.set_date(end);
}
pub fn interval(&self) -> DayInterval {
DayInterval {
start: self.imp().start.date(),
end: self.imp().end.date(),
}
}
}
impl Default for DateRangePicker {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.add_css_class("date-range-picker");
let search_button = gtk::Button::builder()
.css_classes(["date-range-picker__search-button"])
.label("Search")
.build();
search_button.connect_clicked({
let s = s.clone();
move |_| (s.imp().on_search.borrow())(s.interval())
});
let last_week_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("week")
.build();
last_week_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Duration::days(7);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let two_weeks_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("two weeks")
.build();
two_weeks_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Duration::days(14);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let last_month_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("month")
.build();
last_month_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(1);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let six_months_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("six months")
.build();
six_months_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(6);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let last_year_button = gtk::Button::builder()
.css_classes(["date-range-picker__range-button"])
.label("year")
.build();
last_year_button.connect_clicked({
let s = s.clone();
move |_| {
let end = Local::now().date_naive();
let start = end - Months::new(12);
s.set_interval(start, end);
(s.imp().on_search.borrow())(s.interval());
}
});
let date_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
date_row.append(&s.imp().start);
date_row.append(&gtk::Label::new(Some("to")));
date_row.append(&s.imp().end);
date_row.append(&search_button);
let quick_picker = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
quick_picker.append(&last_week_button);
quick_picker.append(&two_weeks_button);
quick_picker.append(&last_month_button);
quick_picker.append(&six_months_button);
quick_picker.append(&last_year_button);
s.append(&date_row);
s.append(&quick_picker);
s
}
}

View File

@ -0,0 +1,400 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
// use chrono::NaiveDate;
// use ft_core::TraxRecord;
use crate::{
components::{
steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, WeightLabel,
},
types::{DistanceFormatter, DurationFormatter, WeightFormatter},
view_models::DayDetailViewModel,
};
use emseries::{Record, RecordId};
use ft_core::{TimeDistanceActivity, TraxRecord, TIME_DISTANCE_ACTIVITIES};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
use super::{time_distance::TimeDistanceEdit, time_distance_detail};
pub struct DaySummaryPrivate {
date: gtk::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for DaySummaryPrivate {
const NAME: &'static str = "DaySummary";
type Type = DaySummary;
type ParentType = gtk::Box;
fn new() -> Self {
let date = gtk::Label::builder()
.css_classes(["day-summary__date"])
.halign(gtk::Align::Start)
.build();
Self { date }
}
}
impl ObjectImpl for DaySummaryPrivate {}
impl WidgetImpl for DaySummaryPrivate {}
impl BoxImpl for DaySummaryPrivate {}
glib::wrapper! {
/// The DaySummary displays one day's activities in a narrative style. This is meant to give
/// an overall feel of everything that happened during the day without going into details.
pub struct DaySummary(ObjectSubclass<DaySummaryPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Default for DaySummary {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["day-summary"]);
s.append(&s.imp().date);
s
}
}
impl DaySummary {
pub fn new() -> Self {
Self::default()
}
pub fn set_data(&self, view_model: DayDetailViewModel) {
self.imp()
.date
.set_text(&view_model.date.format("%Y-%m-%d").to_string());
let row = gtk::Box::builder().build();
let weight_label = gtk::Label::builder()
.halign(gtk::Align::Start)
.css_classes(["day-summary__weight"])
.build();
if let Some(w) = view_model.weight() {
weight_label.set_label(&w.to_string())
}
let steps_label = gtk::Label::builder()
.halign(gtk::Align::Start)
.css_classes(["day-summary__steps"])
.build();
if let Some(s) = view_model.steps() {
steps_label.set_label(&format!("{} steps", s));
}
row.append(&weight_label);
row.append(&steps_label);
self.append(&row);
for activity in TIME_DISTANCE_ACTIVITIES {
let summary = view_model.time_distance_summary(activity);
if let Some(label) = time_distance_summary(
activity,
DistanceFormatter::from(summary.0),
DurationFormatter::from(summary.1),
) {
self.append(&label);
}
}
}
}
#[derive(Default)]
pub struct DayDetailPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for DayDetailPrivate {
const NAME: &'static str = "DayDetail";
type Type = DayDetail;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayDetailPrivate {}
impl WidgetImpl for DayDetailPrivate {}
impl BoxImpl for DayDetailPrivate {}
glib::wrapper! {
pub struct DayDetail(ObjectSubclass<DayDetailPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DayDetail {
pub fn new<OnEdit>(view_model: DayDetailViewModel, on_edit: OnEdit) -> Self
where
OnEdit: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.append(
&ActionGroup::builder()
.primary_action("Edit", Box::new(on_edit))
.build(),
);
let top_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let weight_view = WeightLabel::new(view_model.weight().map(WeightFormatter::from));
top_row.append(&weight_view.widget());
let steps_view = Steps::new(view_model.steps());
top_row.append(&steps_view.widget());
s.append(&top_row);
let records = view_model.time_distance_records();
for emseries::Record { data, .. } in records {
s.append(&time_distance_detail(data));
}
s
}
}
pub struct DayEditPrivate {
on_finished: RefCell<Box<dyn Fn()>>,
#[allow(unused)]
workout_rows: RefCell<gtk::Box>,
view_model: RefCell<Option<DayDetailViewModel>>,
}
impl Default for DayEditPrivate {
fn default() -> Self {
Self {
on_finished: RefCell::new(Box::new(|| {})),
workout_rows: RefCell::new(
gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.build(),
),
view_model: RefCell::new(None),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for DayEditPrivate {
const NAME: &'static str = "DayEdit";
type Type = DayEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayEditPrivate {}
impl WidgetImpl for DayEditPrivate {}
impl BoxImpl for DayEditPrivate {}
glib::wrapper! {
pub struct DayEdit(ObjectSubclass<DayEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DayEdit {
pub fn new<OnFinished>(view_model: DayDetailViewModel, on_finished: OnFinished) -> Self
where
OnFinished: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
*s.imp().on_finished.borrow_mut() = Box::new(on_finished);
*s.imp().view_model.borrow_mut() = Some(view_model.clone());
let workout_buttons = workout_buttons(view_model.clone(), {
let s = s.clone();
move |workout| s.add_row(workout)
});
view_model
.records()
.into_iter()
.filter_map({
let s = s.clone();
move |record| match record.data {
TraxRecord::TimeDistance(workout) => Some(TimeDistanceEdit::new(workout, {
let s = s.clone();
move |data| {
s.update_workout(record.id, data);
}
})),
_ => None,
}
})
.for_each(|row| s.imp().workout_rows.borrow().append(&row));
s.append(&control_buttons(&s, &view_model));
s.append(&weight_and_steps_row(&view_model));
s.append(&*s.imp().workout_rows.borrow());
s.append(&workout_buttons);
s
}
fn finish(&self) {
glib::spawn_future_local({
let s = self.clone();
async move {
let view_model = {
let view_model = s.imp().view_model.borrow();
view_model
.as_ref()
.expect("DayEdit has not been initialized with the view model")
.clone()
};
let _ = view_model.async_save().await;
(s.imp().on_finished.borrow())()
}
});
}
fn add_row(&self, workout: Record<TraxRecord>) {
let workout_rows = self.imp().workout_rows.borrow();
#[allow(clippy::single_match)]
match workout.data {
TraxRecord::TimeDistance(r) => workout_rows.append(&TimeDistanceEdit::new(r, {
let s = self.clone();
move |data| {
println!("update workout callback on workout: {:?}", workout.id);
s.update_workout(workout.id, data)
}
})),
_ => {}
}
}
fn update_workout(&self, id: RecordId, data: ft_core::TimeDistance) {
if let Some(ref view_model) = *self.imp().view_model.borrow() {
let record = Record {
id,
data: TraxRecord::TimeDistance(data),
};
view_model.update_record(record);
}
}
}
fn control_buttons(s: &DayEdit, view_model: &DayDetailViewModel) -> ActionGroup {
ActionGroup::builder()
.primary_action("Save", {
let s = s.clone();
move || s.finish()
})
.secondary_action("Cancel", {
let s = s.clone();
let view_model = view_model.clone();
move || {
let s = s.clone();
let view_model = view_model.clone();
glib::spawn_future_local(async move {
view_model.revert().await;
s.finish();
});
}
})
.build()
}
fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box {
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
row.append(
&weight_field(view_model.weight().map(WeightFormatter::from), {
let view_model = view_model.clone();
move |w| match w {
Some(w) => view_model.set_weight(*w),
None => eprintln!("have not implemented record delete"),
}
})
.widget(),
);
row.append(
&steps_editor(view_model.steps(), {
let view_model = view_model.clone();
move |s| match s {
Some(s) => view_model.set_steps(s),
None => eprintln!("have not implemented record delete"),
}
})
.widget(),
);
row
}
fn workout_buttons<AddRow>(view_model: DayDetailViewModel, add_row: AddRow) -> gtk::Box
where
AddRow: Fn(Record<TraxRecord>) + 'static,
{
let add_row = Rc::new(add_row);
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
for (activity, icon, label) in [
(
TimeDistanceActivity::Biking,
"cycling-symbolic",
"Bike Ride",
),
(TimeDistanceActivity::Rowing, "rowing-symbolic", "Rowing"),
(TimeDistanceActivity::Running, "running-symbolic", "Run"),
(TimeDistanceActivity::Swimming, "swimming-symbolic", "Swim"),
(TimeDistanceActivity::Walking, "walking-symbolic", "Walk"),
] {
let button = workout_button(activity, icon, label, view_model.clone(), {
let add_row = add_row.clone();
move |record| add_row(record)
});
layout.append(&button);
}
layout
}
fn workout_button<AddRow>(
activity: TimeDistanceActivity,
_icon: &str,
label: &str,
view_model: DayDetailViewModel,
add_row: AddRow,
) -> gtk::Button
where
AddRow: Fn(Record<TraxRecord>) + 'static,
{
let button = gtk::Button::builder()
.label(label)
.width_request(64)
.height_request(64)
.build();
button.connect_clicked({
let view_model = view_model.clone();
move |_| {
let workout = view_model.new_time_distance(activity);
add_row(workout.map(TraxRecord::TimeDistance));
}
});
button
}

View File

@ -0,0 +1,155 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not,
see <https://www.gnu.org/licenses/>.
*/
mod action_group;
pub use action_group::ActionGroup;
mod day;
pub use day::{DayDetail, DayEdit, DaySummary};
mod date_field;
pub use date_field::DateField;
mod date_range;
pub use date_range::DateRangePicker;
mod singleton;
pub use singleton::{Singleton, SingletonImpl};
mod steps;
pub use steps::{steps_editor, Steps};
mod text_entry;
pub use text_entry::{distance_field, duration_field, time_field, weight_field, i32_field_builder, month_field_builder, TextEntry};
mod time_distance;
pub use time_distance::{time_distance_detail, time_distance_summary};
mod weight;
pub use weight::WeightLabel;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, path::PathBuf, rc::Rc};
pub struct FileChooserRowPrivate {
path: RefCell<Option<PathBuf>>,
label: gtk::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for FileChooserRowPrivate {
const NAME: &'static str = "FileChooser";
type Type = FileChooserRow;
type ParentType = gtk::Box;
fn new() -> Self {
Self {
path: RefCell::new(None),
label: gtk::Label::builder().hexpand(true).build(),
}
}
}
impl ObjectImpl for FileChooserRowPrivate {}
impl WidgetImpl for FileChooserRowPrivate {}
impl BoxImpl for FileChooserRowPrivate {}
glib::wrapper! {
pub struct FileChooserRow(ObjectSubclass<FileChooserRowPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl FileChooserRow {
pub fn new<F>(on_selected: F) -> Self
where
F: Fn(PathBuf) + 'static,
{
let s: Self = Object::builder().build();
s.set_css_classes(&["dialog-row", "card"]);
s.set_orientation(gtk::Orientation::Horizontal);
s.set_spacing(8);
// The database selection row should be a box that shows a default database path, along with a
// button that triggers a file chooser dialog. Once the dialog returns, the box should be
// updated to reflect the chosen path.
s.imp().label.set_text("No database selected");
let on_selected = Rc::new(Box::new(on_selected));
let import_button = gtk::Button::builder().label("Import a Database").build();
let handle_file_selection = Rc::new(Box::new({
let s = s.clone();
let on_selected = on_selected.clone();
move |file_id: Result<gio::File, glib::Error>| match file_id {
Ok(file_id) => match file_id.path() {
Some(path) => {
s.imp().label.set_text(path.to_str().unwrap());
on_selected(path.clone());
*s.imp().path.borrow_mut() = Some(path);
}
None => {
*s.imp().path.borrow_mut() = None;
s.imp().label.set_text("No database selected");
}
},
Err(err) => println!("file opening failed: {}", err),
}
}));
import_button.connect_clicked({
let handle_file_selection = handle_file_selection.clone();
move |_| {
let no_window: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let handle_file_selection = handle_file_selection.clone();
gtk::FileDialog::builder().build().open(
no_window,
not_cancellable,
move |file_id| handle_file_selection(file_id),
);
}
});
let new_button = gtk::Button::builder().label("Create Database").build();
new_button.connect_clicked({
let handle_file_selection = handle_file_selection.clone();
move |_| {
let no_window: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let handle_file_selection = handle_file_selection.clone();
gtk::FileDialog::builder().build().save(
no_window,
not_cancellable,
move |file_id| handle_file_selection(file_id),
);
}
});
s.imp().label.set_halign(gtk::Align::Start);
s.append(&s.imp().label);
s.append(&import_button);
s.append(&new_button);
s
}
pub fn path(&self) -> Option<PathBuf> {
self.imp().path.borrow().clone()
}
}

View File

@ -0,0 +1,71 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! A Widget container for a single components
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
pub struct SingletonPrivate {
widget: RefCell<gtk::Widget>,
}
impl Default for SingletonPrivate {
fn default() -> Self {
Self {
widget: RefCell::new(gtk::Label::new(None).upcast()),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for SingletonPrivate {
const NAME: &'static str = "Singleton";
type Type = Singleton;
type ParentType = gtk::Box;
}
impl ObjectImpl for SingletonPrivate {}
impl WidgetImpl for SingletonPrivate {}
impl BoxImpl for SingletonPrivate {}
glib::wrapper! {
/// The Singleton component contains exactly one child widget. The swap function makes it easy
/// to handle the job of swapping that child out for a different one.
pub struct Singleton(ObjectSubclass<SingletonPrivate>) @extends gtk::Box, gtk::Widget;
}
impl Default for Singleton {
fn default() -> Self {
let s: Self = Object::builder().build();
s.append(&*s.imp().widget.borrow());
s
}
}
impl Singleton {
pub fn swap(&self, new_widget: &impl IsA<gtk::Widget>) {
let new_widget = new_widget.clone().upcast();
self.remove(&*self.imp().widget.borrow());
self.append(&new_widget);
*self.imp().widget.borrow_mut() = new_widget;
}
}
pub trait SingletonImpl: WidgetImpl + BoxImpl {}
unsafe impl<T: SingletonImpl> IsSubclassable<T> for Singleton {}

View File

@ -0,0 +1,60 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::TextEntry, types::ParseError};
use gtk::prelude::*;
#[derive(Default)]
pub struct Steps {
label: gtk::Label,
}
impl Steps {
pub fn new(steps: Option<u32>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "step-view"])
.can_focus(true)
.build();
match steps {
Some(s) => label.set_text(&format!("{}", s)),
None => label.set_text("No steps recorded"),
}
Self { label }
}
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}
pub fn steps_editor<OnUpdate>(value: Option<u32>, on_update: OnUpdate) -> TextEntry<u32>
where
OnUpdate: Fn(Option<u32>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder( "0".to_owned())
.with_renderer(|v| format!("{}", v))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update(on_update);
if let Some(time) = value {
text_entry.with_value(time)
} else {
text_entry
}.build()
}

View File

@ -0,0 +1,373 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::types::{
DistanceFormatter, DurationFormatter, FormatOption, ParseError, TimeFormatter, WeightFormatter,
};
use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc};
pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
pub type OnUpdate<T> = dyn Fn(Option<T>);
// TextEntry is not a proper widget because I was never able to figure out how to do a type parameterization on a GTK widget.
#[derive(Clone)]
pub struct TextEntry<T: Clone + std::fmt::Debug> {
value: Rc<RefCell<Option<T>>>,
widget: gtk::Entry,
renderer: Rc<dyn Fn(&T) -> String>,
parser: Rc<Parser<T>>,
on_update: Rc<OnUpdate<T>>,
}
impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(
f,
"{{ value: {:?}, widget: {:?} }}",
self.value, self.widget
)
}
}
// I do not understand why the data should be 'static.
impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
fn from_builder(builder: TextEntryBuilder<T>) -> TextEntry<T> {
let widget = gtk::Entry::builder()
.placeholder_text(builder.placeholder)
.build();
if let Some(ref v) = builder.value {
widget.set_text(&(builder.renderer)(v))
}
let s = Self {
value: Rc::new(RefCell::new(builder.value)),
widget,
renderer: Rc::new(builder.renderer),
parser: Rc::new(builder.parser),
on_update: Rc::new(builder.on_update),
};
s.widget.buffer().connect_text_notify({
let s = s.clone();
move |buffer| s.handle_text_change(buffer)
});
if let Some(length) = builder.length {
s.widget.set_max_length(length.try_into().unwrap());
}
// let classes: Vec<&str> = builder.css_classes.iter(|v| v.as_ref()).collect();
let classes: Vec<&str> = builder.css_classes.iter().map(AsRef::as_ref).collect();
s.widget.set_css_classes(&classes);
s
}
pub fn builder() -> TextEntryBuilder<T> {
TextEntryBuilder::default()
}
pub fn set_value(&self, val: Option<T>) {
if let Some(ref v) = val {
self.widget.set_text(&(self.renderer)(v));
}
}
fn handle_text_change(&self, buffer: &gtk::EntryBuffer) {
if buffer.text().is_empty() {
*self.value.borrow_mut() = None;
self.widget.remove_css_class("error");
(self.on_update)(None);
return;
}
match (self.parser)(buffer.text().as_str()) {
Ok(v) => {
*self.value.borrow_mut() = Some(v.clone());
self.widget.remove_css_class("error");
(self.on_update)(Some(v));
}
// need to change the border to provide a visual indicator of an error
Err(_) => {
self.widget.add_css_class("error");
}
}
}
pub fn widget(&self) -> gtk::Widget {
self.widget.clone().upcast::<gtk::Widget>()
}
#[cfg(test)]
fn has_parse_error(&self) -> bool {
self.widget.has_css_class("error")
}
}
pub struct TextEntryBuilder<T: Clone + std::fmt::Debug + 'static> {
placeholder: String,
value: Option<T>,
length: Option<usize>,
css_classes: Vec<String>,
renderer: Box<dyn Fn(&T) -> String>,
parser: Box<Parser<T>>,
on_update: Box<OnUpdate<T>>,
}
impl<T: Clone + std::fmt::Debug + 'static> Default for TextEntryBuilder<T> {
fn default() -> TextEntryBuilder<T> {
TextEntryBuilder {
placeholder: "".to_owned(),
value: None,
length: None,
css_classes: vec![],
renderer: Box::new(|_| "".to_owned()),
parser: Box::new(|_| Err(ParseError)),
on_update: Box::new(|_| {}),
}
}
}
impl<T: Clone + std::fmt::Debug + 'static> TextEntryBuilder<T> {
pub fn build(self) -> TextEntry<T> {
TextEntry::from_builder(self)
}
pub fn with_placeholder(self, placeholder: String) -> Self {
Self {
placeholder,
..self
}
}
pub fn with_value(self, value: T) -> Self {
Self {
value: Some(value),
..self
}
}
pub fn with_length(self, length: usize) -> Self {
Self {
length: Some(length),
..self
}
}
pub fn with_css_classes(self, classes: Vec<String>) -> Self {
Self {
css_classes: classes,
..self
}
}
pub fn with_renderer(self, renderer: impl Fn(&T) -> String + 'static) -> Self {
Self {
renderer: Box::new(renderer),
..self
}
}
pub fn with_parser(self, parser: impl Fn(&str) -> Result<T, ParseError> + 'static) -> Self {
Self {
parser: Box::new(parser),
..self
}
}
pub fn with_on_update(self, on_update: impl Fn(Option<T>) + 'static) -> Self {
Self {
on_update: Box::new(on_update),
..self
}
}
}
pub fn time_field<OnUpdate>(
value: Option<TimeFormatter>,
on_update: OnUpdate,
) -> TextEntry<TimeFormatter>
where
OnUpdate: Fn(Option<TimeFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("HH:MM".to_owned())
.with_renderer(|val: &TimeFormatter| val.format(FormatOption::Abbreviated))
.with_parser(TimeFormatter::parse)
.with_on_update(on_update);
if let Some(time) = value {
text_entry.with_value(time)
} else {
text_entry
}
.build()
}
pub fn distance_field<OnUpdate>(
value: Option<DistanceFormatter>,
on_update: OnUpdate,
) -> TextEntry<DistanceFormatter>
where
OnUpdate: Fn(Option<DistanceFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 km".to_owned())
.with_renderer(|val: &DistanceFormatter| val.format(FormatOption::Abbreviated))
.with_parser(DistanceFormatter::parse)
.with_on_update(on_update);
if let Some(distance) = value {
text_entry.with_value(distance)
} else {
text_entry
}
.build()
}
pub fn duration_field<OnUpdate>(
value: Option<DurationFormatter>,
on_update: OnUpdate,
) -> TextEntry<DurationFormatter>
where
OnUpdate: Fn(Option<DurationFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 m".to_owned())
.with_renderer(|val: &DurationFormatter| val.format(FormatOption::Abbreviated))
.with_parser(DurationFormatter::parse)
.with_on_update(on_update);
if let Some(duration) = value {
text_entry.with_value(duration)
} else {
text_entry
}
.build()
}
pub fn weight_field<OnUpdate>(
weight: Option<WeightFormatter>,
on_update: OnUpdate,
) -> TextEntry<WeightFormatter>
where
OnUpdate: Fn(Option<WeightFormatter>) + 'static,
{
let text_entry = TextEntry::builder()
.with_placeholder("0 kg".to_owned())
.with_renderer(|val: &WeightFormatter| val.format(FormatOption::Abbreviated))
.with_parser(WeightFormatter::parse)
.with_on_update(on_update);
if let Some(weight) = weight {
text_entry.with_value(weight)
} else {
text_entry
}
.build()
}
pub fn i32_field_builder() -> TextEntryBuilder<i32>
{
TextEntry::builder()
.with_placeholder("0".to_owned())
.with_renderer(|val| format!("{}", val))
.with_parser(|v| v.parse::<i32>().map_err(|_| ParseError))
}
pub fn month_field_builder() -> TextEntryBuilder<u32>
{
TextEntry::builder()
.with_placeholder("0".to_owned())
.with_renderer(|val| format!("{}", val))
.with_parser(|v| {
let val = v.parse::<u32>().map_err(|_| ParseError)?;
if val == 0 || val > 12 {
return Err(ParseError);
}
Ok(val)
})
}
#[cfg(test)]
mod test {
use super::*;
use crate::gtk_init::gtk_init;
fn setup_u32_entry() -> (Rc<RefCell<Option<u32>>>, TextEntry<u32>) {
let current_value = Rc::new(RefCell::new(None));
let entry = TextEntry::builder()
.with_placeholder("step count".to_owned())
.with_renderer(|steps| format!("{}", steps))
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
.with_on_update({
let current_value = current_value.clone();
move |v| *current_value.borrow_mut() = v
})
.build();
(current_value, entry)
}
#[test]
fn it_responds_to_field_changes() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("15");
assert_eq!(*current_value.borrow(), Some(15));
}
#[test]
fn it_preserves_last_value_in_parse_error() {
crate::gtk_init::gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("a5");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
}
#[test]
fn it_update_on_empty_strings() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("1a");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
assert!(!entry.has_parse_error());
}
}

View File

@ -0,0 +1,271 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
components::{distance_field, duration_field, time_field},
types::{DistanceFormatter, DurationFormatter, FormatOption, TimeFormatter},
};
use dimensioned::si;
use ft_core::{TimeDistance, TimeDistanceActivity, TIME_DISTANCE_ACTIVITIES};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
pub fn time_distance_summary(
activity: TimeDistanceActivity,
distance: DistanceFormatter,
duration: DurationFormatter,
) -> Option<gtk::Label> {
let text = match (*distance > si::M, *duration > si::S) {
(true, true) => Some(format!(
"{} of {:?} in {}",
distance.format(FormatOption::Full),
activity,
duration.format(FormatOption::Full)
)),
(true, false) => Some(format!(
"{} of {:?}",
distance.format(FormatOption::Full),
activity
)),
(false, true) => Some(format!(
"{} of {:?}",
duration.format(FormatOption::Full),
activity
)),
(false, false) => None,
};
text.map(|text| gtk::Label::builder().halign(gtk::Align::Start).label(&text).build())
}
pub fn time_distance_detail(record: ft_core::TimeDistance) -> gtk::Box {
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.build();
let first_row = gtk::Box::builder().homogeneous(true).build();
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(record.datetime.format("%H:%M").to_string())
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(format!("{:?}", record.activity))
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.distance
.map(|dist| DistanceFormatter::from(dist).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()),
)
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.duration
.map(|duration| {
DurationFormatter::from(duration).format(FormatOption::Abbreviated)
})
.unwrap_or("".to_owned()),
)
.build(),
);
layout.append(&first_row);
layout.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.comments
.map(|comments| comments.to_string())
.unwrap_or("".to_owned()),
)
.build(),
);
layout
}
type OnUpdate = Rc<RefCell<Box<dyn Fn(TimeDistance)>>>;
pub struct TimeDistanceEditPrivate {
#[allow(unused)]
workout: RefCell<ft_core::TimeDistance>,
on_update: OnUpdate,
}
impl Default for TimeDistanceEditPrivate {
fn default() -> Self {
Self {
workout: RefCell::new(TimeDistance {
datetime: chrono::Utc::now().into(),
activity: TimeDistanceActivity::Biking,
duration: None,
distance: None,
comments: None,
}),
on_update: Rc::new(RefCell::new(Box::new(|_| {}))),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for TimeDistanceEditPrivate {
const NAME: &'static str = "TimeDistanceEdit";
type Type = TimeDistanceEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for TimeDistanceEditPrivate {}
impl WidgetImpl for TimeDistanceEditPrivate {}
impl BoxImpl for TimeDistanceEditPrivate {}
glib::wrapper! {
pub struct TimeDistanceEdit(ObjectSubclass<TimeDistanceEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Default for TimeDistanceEdit {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.set_css_classes(&["time-distance-edit"]);
s
}
}
impl TimeDistanceEdit {
pub fn new<OnUpdate>(workout: TimeDistance, on_update: OnUpdate) -> Self
where
OnUpdate: Fn(TimeDistance) + 'static,
{
let s = Self::default();
*s.imp().workout.borrow_mut() = workout.clone();
*s.imp().on_update.borrow_mut() = Box::new(on_update);
let details_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
details_row.append(
&time_field(
Some(TimeFormatter::from(workout.datetime.naive_local().time())),
{
let s = s.clone();
move |t| s.update_time(t)
},
)
.widget(),
);
details_row.append(&s.activity_menu(workout.activity));
details_row.append(
&distance_field(workout.distance.map(DistanceFormatter::from), {
let s = s.clone();
move |d| s.update_distance(d)
})
.widget(),
);
details_row.append(
&duration_field(workout.duration.map(DurationFormatter::from), {
let s = s.clone();
move |d| s.update_duration(d)
})
.widget(),
);
s.append(&details_row);
s.append(&gtk::Entry::new());
s
}
fn update_time(&self, time: Option<TimeFormatter>) {
if let Some(time_formatter) = time {
let mut workout = self.imp().workout.borrow_mut();
let tz = workout.datetime.timezone();
let new_time = workout
.datetime
.date_naive()
.and_time(*time_formatter)
.and_local_timezone(tz)
.unwrap()
.fixed_offset();
workout.datetime = new_time;
(self.imp().on_update.borrow())(workout.clone());
}
}
fn update_workout_type(&self, type_: TimeDistanceActivity) {
let mut workout = self.imp().workout.borrow_mut();
workout.activity = type_;
(self.imp().on_update.borrow())(workout.clone())
}
fn update_distance(&self, distance: Option<DistanceFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.distance = distance.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
fn update_duration(&self, duration: Option<DurationFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.duration = duration.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
fn activity_menu(&self, selected: TimeDistanceActivity) -> gtk::DropDown {
let options = TIME_DISTANCE_ACTIVITIES
.iter()
.map(|item| format!("{:?}", item))
.collect::<Vec<String>>();
let options = options.iter().map(|o| o.as_ref()).collect::<Vec<&str>>();
let selected_idx = TIME_DISTANCE_ACTIVITIES
.iter()
.position(|&v| v == selected)
.unwrap_or(0);
let menu = gtk::DropDown::from_strings(&options);
menu.set_selected(selected_idx as u32);
menu.connect_selected_item_notify({
let s = self.clone();
move |menu| {
let new_item = TIME_DISTANCE_ACTIVITIES[menu.selected() as usize];
s.update_workout_type(new_item);
}
});
menu
}
}

View File

@ -0,0 +1,42 @@
/*
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::types::{FormatOption, WeightFormatter};
use gtk::prelude::*;
pub struct WeightLabel {
label: gtk::Label,
}
impl WeightLabel {
pub fn new(weight: Option<WeightFormatter>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "weight-view"])
.can_focus(true)
.build();
match weight {
Some(w) => label.set_text(&w.format(FormatOption::Abbreviated)),
None => label.set_text("No weight recorded"),
}
Self { label }
}
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}

View File

@ -0,0 +1,10 @@
use std::sync::Once;
static INITIALIZED: Once = Once::new();
pub fn gtk_init() {
INITIALIZED.call_once(|| {
eprintln!("initializing GTK");
let _ = gtk::init();
});
}

101
fitnesstrax/app/src/main.rs Normal file
View File

@ -0,0 +1,101 @@
/*
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
mod about;
mod app;
mod app_window;
mod components;
#[cfg(test)]
mod gtk_init;
mod types;
mod view_models;
mod views;
use adw::prelude::*;
use app_window::AppWindow;
use gio::ActionEntry;
use std::{env, path::PathBuf};
const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev";
const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax";
const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/";
fn setup_app_about_action(app: &adw::Application) {
let action = ActionEntry::builder("about")
.activate(|_app: &adw::Application, _, _| {
let window = about::AboutWindow::default();
window.present();
}).build();
app.add_action_entries([action]);
}
/// Sets up an application-global action, `app.quit`, which will terminate the application.
fn setup_app_close_action(app: &adw::Application) {
let action = ActionEntry::builder("quit")
.activate(|app: &adw::Application, _, _| {
// right now, stopping the application is dirt simple. But we could use this
// block to add extra code that does additional shutdown steps if we ever want
// some states that shouldn't be discarded.
app.quit();
})
.build();
app.add_action_entries([action]);
app.set_accels_for_action("app.quit", &["<Ctrl>Q"]);
}
fn main() {
// I still don't fully understand gio resources. resources_register_include! is convenient
// because I don't have to deal with filesystem locations at runtime. However, I think other
// GTK applications do that rather than compiling the resources directly into the app. So, I'm
// unclear as to how I want to handle this.
gio::resources_register_include!("com.luminescent-dreams.fitnesstrax.gresource")
.expect("to register resources");
let app_id = if std::env::var_os("ENV") == Some("dev".into()) {
APP_ID_DEV
} else {
APP_ID_PROD
};
let settings = gio::Settings::new(app_id);
let ft_app = app::App::new({
let path = settings.string("series-path");
if path.is_empty() {
None
} else {
Some(PathBuf::from(path))
}
});
let adw_app = adw::Application::builder()
.application_id(app_id)
.resource_base_path(RESOURCE_BASE_PATH)
.build();
adw_app.connect_activate(move |adw_app| {
let icon_theme = gtk::IconTheme::for_display(&gdk::Display::default().unwrap());
icon_theme.add_resource_path(&(RESOURCE_BASE_PATH.to_owned() + "/icons/scalable/actions"));
setup_app_about_action(adw_app);
setup_app_close_action(adw_app);
AppWindow::new(app_id, RESOURCE_BASE_PATH, adw_app, ft_app.clone());
});
let args: Vec<String> = env::args().collect();
ApplicationExtManual::run_with_args(&adw_app, &args);
}

View File

@ -0,0 +1,349 @@
use chrono::{Local, NaiveDate};
use dimensioned::si;
#[derive(Clone, Debug, PartialEq)]
pub struct ParseError;
// This interval doesn't feel right, either. The idea that I have a specific interval type for just
// NaiveDate is odd. This should be genericized, as should the iterator. Also, it shouldn't live
// here, but in utilities.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DayInterval {
pub start: NaiveDate,
pub end: NaiveDate,
}
impl Default for DayInterval {
fn default() -> Self {
Self {
start: (Local::now() - chrono::Duration::days(7)).date_naive(),
end: Local::now().date_naive(),
}
}
}
impl DayInterval {
pub fn days(&self) -> impl Iterator<Item = NaiveDate> {
DayIterator {
current: self.start,
end: self.end,
}
}
}
struct DayIterator {
current: NaiveDate,
end: NaiveDate,
}
impl Iterator for DayIterator {
type Item = NaiveDate;
fn next(&mut self) -> Option<Self::Item> {
if self.current <= self.end {
let val = self.current;
self.current += chrono::Duration::days(1);
Some(val)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatOption {
Abbreviated,
#[allow(unused)]
Full,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TimeFormatter(chrono::NaiveTime);
impl TimeFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => self.0.format("%H:%M"),
FormatOption::Full => self.0.format("%H:%M:%S"),
}
.to_string()
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<TimeFormatter, ParseError> {
let parts = s
.split(':')
.map(|part| part.parse::<u32>().map_err(|_| ParseError))
.collect::<Result<Vec<u32>, ParseError>>()?;
match parts.len() {
0 => Err(ParseError),
1 => Err(ParseError),
2 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], 0)
.map(|v| TimeFormatter(v))
.ok_or(ParseError),
3 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], parts[2])
.map(|v| TimeFormatter(v))
.ok_or(ParseError),
_ => Err(ParseError),
}
}
}
impl std::ops::Deref for TimeFormatter {
type Target = chrono::NaiveTime;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<chrono::NaiveTime> for TimeFormatter {
fn from(value: chrono::NaiveTime) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct WeightFormatter(si::Kilogram<f64>);
impl WeightFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => format!("{} kg", self.0.value_unsafe),
FormatOption::Full => format!("{} kilograms", self.0.value_unsafe),
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<WeightFormatter, ParseError> {
s.parse::<f64>()
.map(|w| WeightFormatter(w * si::KG))
.map_err(|_| ParseError)
}
}
impl std::ops::Add for WeightFormatter {
type Output = WeightFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for WeightFormatter {
type Output = WeightFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for WeightFormatter {
type Target = si::Kilogram<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Kilogram<f64>> for WeightFormatter {
fn from(value: si::Kilogram<f64>) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct DistanceFormatter(si::Meter<f64>);
impl DistanceFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => format!("{} km", self.0.value_unsafe / 1000.),
FormatOption::Full => format!("{} kilometers", self.0.value_unsafe / 1000.),
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<DistanceFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DistanceFormatter(value * 1000. * si::M))
}
}
impl std::ops::Add for DistanceFormatter {
type Output = DistanceFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for DistanceFormatter {
type Output = DistanceFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for DistanceFormatter {
type Target = si::Meter<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Meter<f64>> for DistanceFormatter {
fn from(value: si::Meter<f64>) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct DurationFormatter(si::Second<f64>);
impl DurationFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String {
let (hours, minutes) = self.hours_and_minutes();
let (h, m) = match option {
FormatOption::Abbreviated => ("h", "m"),
FormatOption::Full => (" hours", " minutes"),
};
if hours > 0 {
format!("{}{} {}{}", hours, h, minutes, m)
} else {
format!("{}{}", minutes, m)
}
}
#[allow(unused)]
pub fn parse(s: &str) -> Result<DurationFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DurationFormatter(value * 60. * si::S))
}
#[allow(unused)]
fn hours_and_minutes(&self) -> (i64, i64) {
let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64;
let hours: i64 = minutes / 60;
let minutes = minutes - (hours * 60);
(hours, minutes)
}
}
impl std::ops::Add for DurationFormatter {
type Output = DurationFormatter;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 + rside.0)
}
}
impl std::ops::Sub for DurationFormatter {
type Output = DurationFormatter;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.0 - rside.0)
}
}
impl std::ops::Deref for DurationFormatter {
type Target = si::Second<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Second<f64>> for DurationFormatter {
fn from(value: si::Second<f64>) -> Self {
Self(value)
}
}
#[cfg(test)]
mod test {
use super::*;
use dimensioned::si;
#[test]
fn it_parses_weight_values() {
assert_eq!(
WeightFormatter::parse("15.3"),
Ok(WeightFormatter(15.3 * si::KG))
);
assert_eq!(WeightFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_weight_values() {
assert_eq!(
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Abbreviated),
"15.3 kg"
);
assert_eq!(
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Full),
"15.3 kilograms"
);
}
#[test]
fn it_parses_distance_values() {
assert_eq!(
DistanceFormatter::parse("70"),
Ok(DistanceFormatter(70000. * si::M))
);
assert_eq!(DistanceFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_distance_values() {
assert_eq!(
DistanceFormatter::from(70000. * si::M).format(FormatOption::Abbreviated),
"70 km"
);
assert_eq!(
DistanceFormatter::from(70000. * si::M).format(FormatOption::Full),
"70 kilometers"
);
}
#[test]
fn it_parses_duration_values() {
assert_eq!(
DurationFormatter::parse("70"),
Ok(DurationFormatter(4200. * si::S))
);
assert_eq!(DurationFormatter::parse("15.ab"), Err(ParseError));
}
#[test]
fn it_formats_duration_values() {
assert_eq!(
DurationFormatter::from(4200. * si::S).format(FormatOption::Abbreviated),
"1h 10m"
);
assert_eq!(
DurationFormatter::from(4200. * si::S).format(FormatOption::Full),
"1 hours 10 minutes"
);
}
#[test]
fn it_parses_time_values() {
assert_eq!(
TimeFormatter::parse("13:25"),
Ok(TimeFormatter::from(
chrono::NaiveTime::from_hms_opt(13, 25, 0).unwrap()
)),
);
assert_eq!(
TimeFormatter::parse("13:25:50"),
Ok(TimeFormatter::from(
chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap()
)),
);
}
#[test]
fn it_formats_time_values() {
let time = TimeFormatter::from(chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap());
assert_eq!(time.format(FormatOption::Abbreviated), "13:25".to_owned());
assert_eq!(time.format(FormatOption::Full), "13:25:50".to_owned());
}
}

View File

@ -0,0 +1,657 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::app::{ReadError, RecordProvider};
use dimensioned::si;
use emseries::{Record, RecordId, Recordable};
use ft_core::{TimeDistance, TimeDistanceActivity, TraxRecord};
use std::{
collections::HashMap,
ops::Deref,
sync::{Arc, RwLock},
};
// These are actually a used imports. Clippy isn't detecting their use, probably because of complexity around the async trait macros.
#[allow(unused_imports)]
use crate::app::WriteError;
#[allow(unused_imports)]
use chrono::NaiveDate;
#[derive(Clone, Debug)]
enum RecordState<T: Clone + Recordable> {
Original(Record<T>),
New(Record<T>),
Updated(Record<T>),
Deleted(Record<T>),
}
impl<T: Clone + emseries::Recordable> RecordState<T> {
fn exists(&self) -> bool {
match self {
RecordState::Original(_) => true,
RecordState::New(_) => true,
RecordState::Updated(_) => true,
RecordState::Deleted(_) => false,
}
}
#[allow(unused)]
fn data(&self) -> Option<&Record<T>> {
match self {
RecordState::Original(ref r) => Some(r),
RecordState::New(ref r) => None,
RecordState::Updated(ref r) => Some(r),
RecordState::Deleted(ref r) => Some(r),
}
}
fn set_value(&mut self, value: T) {
*self = match self {
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..*r }),
RecordState::New(r) => RecordState::New(Record { data: value, ..*r }),
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..*r }),
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..*r }),
};
}
fn with_value(mut self, value: T) -> RecordState<T> {
self.set_value(value);
self
}
#[allow(unused)]
fn with_delete(self) -> Option<RecordState<T>> {
match self {
RecordState::Original(r) => Some(RecordState::Deleted(r)),
RecordState::New(r) => None,
RecordState::Updated(r) => Some(RecordState::Deleted(r)),
RecordState::Deleted(r) => Some(RecordState::Deleted(r)),
}
}
}
impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
match self {
RecordState::Original(ref r) => &r.data,
RecordState::New(ref r) => &r.data,
RecordState::Updated(ref r) => &r.data,
RecordState::Deleted(ref r) => &r.data,
}
}
}
impl<T: Clone + emseries::Recordable> std::ops::DerefMut for RecordState<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
match self {
RecordState::Original(ref mut r) => &mut r.data,
RecordState::New(ref mut r) => &mut r.data,
RecordState::Updated(ref mut r) => &mut r.data,
RecordState::Deleted(ref mut r) => &mut r.data,
}
}
}
#[derive(Clone)]
pub struct DayDetailViewModel {
provider: Arc<dyn RecordProvider>,
pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>,
}
impl DayDetailViewModel {
pub async fn new(
date: chrono::NaiveDate,
provider: impl RecordProvider + 'static,
) -> Result<Self, ReadError> {
let s = Self {
provider: Arc::new(provider),
date,
weight: Arc::new(RwLock::new(None)),
steps: Arc::new(RwLock::new(None)),
records: Arc::new(RwLock::new(HashMap::new())),
};
s.populate_records().await;
Ok(s)
}
pub fn weight(&self) -> Option<si::Kilogram<f64>> {
(*self.weight.read().unwrap()).as_ref().map(|w| w.weight)
}
pub fn set_weight(&self, new_weight: si::Kilogram<f64>) {
let mut record = self.weight.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
date: self.date,
weight: new_weight,
}),
None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Weight {
date: self.date,
weight: new_weight,
},
}),
};
*record = Some(new_record);
}
pub fn steps(&self) -> Option<u32> {
(*self.steps.read().unwrap()).as_ref().map(|w| w.count)
}
pub fn set_steps(&self, new_count: u32) {
let mut record = self.steps.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Steps {
date: self.date,
count: new_count,
}),
None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Steps {
date: self.date,
count: new_count,
},
}),
};
*record = Some(new_record);
}
pub fn new_time_distance(&self, activity: TimeDistanceActivity) -> Record<TimeDistance> {
let now = chrono::Local::now();
let base_time = now.time();
let tz = now.timezone();
let datetime = self
.date
.clone()
.and_time(base_time)
.and_local_timezone(tz)
.unwrap()
.into();
let id = RecordId::default();
let workout = TimeDistance {
datetime,
activity,
distance: None,
duration: None,
comments: None,
};
let tr = TraxRecord::from(workout.clone());
self.records
.write()
.unwrap()
.insert(id, RecordState::New(Record { id, data: tr }));
Record { id, data: workout }
}
pub fn time_distance_records(&self) -> Vec<Record<TimeDistance>> {
self.records
.read()
.unwrap()
.iter()
.filter(|(_, record)| record.exists())
.filter_map(|(id, record_state)| match **record_state {
TraxRecord::TimeDistance(ref workout) => Some(Record {
id: *id,
data: workout.clone(),
}),
_ => None,
})
.collect()
}
pub fn time_distance_summary(
&self,
activity: TimeDistanceActivity,
) -> (si::Meter<f64>, si::Second<f64>) {
self.time_distance_records()
.into_iter()
.filter(|rec| rec.data.activity == activity)
.fold(
(0. * si::M, 0. * si::S),
|(distance, duration), workout| match (workout.data.distance, workout.data.duration)
{
(Some(distance_), Some(duration_)) => {
(distance + distance_, duration + duration_)
}
(Some(distance_), None) => (distance + distance_, duration),
(None, Some(duration_)) => (distance, duration + duration_),
(None, None) => (distance, duration),
},
)
}
pub fn update_record(&self, update: Record<TraxRecord>) {
let mut records = self.records.write().unwrap();
records
.entry(update.id)
.and_modify(|record| record.set_value(update.data));
}
pub fn records(&self) -> Vec<Record<TraxRecord>> {
let read_lock = self.records.read().unwrap();
read_lock
.iter()
.filter_map(|(_, record_state)| record_state.data())
.cloned()
.collect::<Vec<Record<TraxRecord>>>()
}
#[allow(unused)]
fn get_record(&self, id: &RecordId) -> Option<Record<TraxRecord>> {
let record_set = self.records.read().unwrap();
record_set.get(id).map(|record| Record {
id: *id,
data: (**record).clone(),
})
}
pub fn remove_record(&self, id: RecordId) {
let mut record_set = self.records.write().unwrap();
let updated_record = match record_set.remove(&id) {
Some(RecordState::Original(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::New(_)) => None,
Some(RecordState::Updated(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::Deleted(r)) => Some(RecordState::Deleted(r)),
None => None,
};
if let Some(updated_record) = updated_record {
record_set.insert(id, updated_record);
}
}
pub fn save(&self) {
let s = self.clone();
glib::spawn_future(async move { s.async_save().await });
}
pub async fn async_save(&self) {
let weight_record = self.weight.read().unwrap().clone();
match weight_record {
Some(RecordState::New(data)) => {
let _ = self
.provider
.put_record(TraxRecord::Weight(data.data))
.await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(weight)) => {
let _ = self
.provider
.update_record(Record {
id: weight.id,
data: TraxRecord::Weight(weight.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let steps_record = self.steps.read().unwrap().clone();
match steps_record {
Some(RecordState::New(data)) => {
let _ = self.provider.put_record(TraxRecord::Steps(data.data)).await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(steps)) => {
let _ = self
.provider
.update_record(Record {
id: steps.id,
data: TraxRecord::Steps(steps.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let records = self
.records
.write()
.unwrap()
.drain()
.map(|(_, record)| record)
.collect::<Vec<RecordState<TraxRecord>>>();
for record in records {
println!("saving record: {:?}", record);
match record {
RecordState::New(data) => {
let _ = self.provider.put_record(data.data).await;
}
RecordState::Original(_) => {}
RecordState::Updated(r) => {
let _ = self.provider.update_record(r.clone()).await;
}
RecordState::Deleted(r) => {
let _ = self.provider.delete_record(r.id).await;
}
}
}
self.populate_records().await;
}
pub async fn revert(&self) {
self.populate_records().await;
}
async fn populate_records(&self) {
let records = self.provider.records(self.date, self.date).await.unwrap();
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_weight());
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps());
*self.weight.write().unwrap() = weight_records
.first()
.and_then(|r| match r.data {
TraxRecord::Weight(ref w) => Some((r.id, w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
*self.steps.write().unwrap() = step_records
.first()
.and_then(|r| match r.data {
TraxRecord::Steps(ref w) => Some((r.id, w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
*self.records.write().unwrap() = records
.into_iter()
.map(|r| (r.id, RecordState::Original(r)))
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>();
}
}
#[cfg(test)]
mod test {
use super::*;
use async_trait::async_trait;
use chrono::{DateTime, FixedOffset};
use dimensioned::si;
use emseries::Record;
#[derive(Clone, Debug)]
struct MockProvider {
records: Arc<RwLock<HashMap<RecordId, Record<TraxRecord>>>>,
put_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
updated_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
deleted_records: Arc<RwLock<Vec<RecordId>>>,
}
impl MockProvider {
fn new(records: Vec<Record<TraxRecord>>) -> Self {
let record_map = records
.into_iter()
.map(|r| (r.id, r))
.collect::<HashMap<RecordId, Record<TraxRecord>>>();
Self {
records: Arc::new(RwLock::new(record_map)),
put_records: Arc::new(RwLock::new(vec![])),
updated_records: Arc::new(RwLock::new(vec![])),
deleted_records: Arc::new(RwLock::new(vec![])),
}
}
}
#[async_trait]
impl RecordProvider for MockProvider {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let start = emseries::Timestamp::Date(start);
let end = emseries::Timestamp::Date(end);
Ok(self
.records
.read()
.unwrap()
.iter()
.map(|(_, r)| r)
.filter(|r| r.timestamp() >= start && r.timestamp() <= end)
.cloned()
.collect::<Vec<Record<TraxRecord>>>())
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let id = RecordId::default();
let record = Record {
id: id,
data: record,
};
self.put_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(id, record);
Ok(id)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
println!("updated record: {:?}", record);
self.updated_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(record.id, record);
Ok(())
}
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> {
self.deleted_records.write().unwrap().push(id);
let _ = self.records.write().unwrap().remove(&id);
Ok(())
}
}
async fn create_empty_view_model() -> (DayDetailViewModel, MockProvider) {
let provider = MockProvider::new(vec![]);
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
async fn create_view_model() -> (DayDetailViewModel, MockProvider) {
let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap();
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let oct_13_am: DateTime<FixedOffset> = oct_13
.clone()
.and_hms_opt(3, 28, 0)
.unwrap()
.and_utc()
.with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap());
let provider = MockProvider::new(vec![
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_12,
weight: 93. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_13,
weight: 95. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Steps(ft_core::Steps {
date: oct_13,
count: 2500,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::TimeDistance(ft_core::TimeDistance {
datetime: oct_13_am.clone(),
activity: TimeDistanceActivity::Biking,
distance: Some(15000. * si::M),
duration: Some(3600. * si::S),
comments: Some("somecomments present".to_owned()),
}),
},
]);
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
#[tokio::test]
async fn it_honors_only_the_first_weight_and_step_record() {
let (view_model, _provider) = create_view_model().await;
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(2500));
}
#[tokio::test]
async fn it_can_create_a_weight_and_stepcount() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(view_model.weight(), None);
assert_eq!(view_model.steps(), None);
view_model.set_weight(95. * si::KG);
view_model.set_steps(250);
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(250));
view_model.set_weight(93. * si::KG);
view_model.set_steps(255);
assert_eq!(view_model.weight(), Some(93. * si::KG));
assert_eq!(view_model.steps(), Some(255));
view_model.async_save().await;
println!("provider: {:?}", provider);
assert_eq!(provider.put_records.read().unwrap().len(), 2);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_construct_new_records() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
record.data.duration = Some(60. * si::S);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_a_new_record_before_saving() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
record.data.duration = Some(60. * si::S);
let record = record.map(TraxRecord::TimeDistance);
view_model.update_record(record.clone());
assert_eq!(view_model.get_record(&record.id), Some(record));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 60. * si::S)
);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Running),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let mut workout = view_model.time_distance_records().first().cloned().unwrap();
workout.data.duration = Some(1800. * si::S);
view_model.update_record(workout.map(TraxRecord::TimeDistance));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(15000. * si::M, 1800. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 1);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_remove_a_new_record() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
let record = view_model.new_time_distance(TimeDistanceActivity::Biking);
view_model.remove_record(record.id);
view_model.save();
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_delete_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let workout = view_model.time_distance_records().first().cloned().unwrap();
view_model.remove_record(workout.id);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Biking),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 1);
}
}

View File

@ -0,0 +1,18 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
mod day_detail;
pub use day_detail::DayDetailViewModel;

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