Compare commits
203 Commits
mobile_app
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e8681e03a | |||
| 15094fd954 | |||
| 29eb1dfcdc | |||
| e967ef5322 | |||
| 7a20af72fa | |||
| da351cc53b | |||
| 060bdf5114 | |||
| f167c6eed7 | |||
| 3624fd051f | |||
| ac18b73c07 | |||
| 5f916461ac | |||
| 3394be4ee9 | |||
| e24d290127 | |||
| 7e8545f8db | |||
| ae2737fed1 | |||
| 4641ca9b72 | |||
| 7cec9541e2 | |||
| 37e91af5bd | |||
| 08f451ec71 | |||
| cf7ce027b1 | |||
| fa14d91359 | |||
| b781193d44 | |||
| 5287b98bc1 | |||
| 0e5044eb06 | |||
| 75f7fa8810 | |||
| 5255e24184 | |||
| c59fc0073f | |||
| a142e8732f | |||
| 13859a34d3 | |||
| 1dca00d5e3 | |||
| fa61801580 | |||
| 2af29a460b | |||
| 0d6bf57932 | |||
| 447d56a960 | |||
| 2f5251e9fe | |||
| c9b544ab55 | |||
| b827792d16 | |||
| 94cd3f7eb4 | |||
| bdee036204 | |||
| 7db7bf91e0 | |||
| 801140ac51 | |||
| 49feef66c5 | |||
| b23b3de1bb | |||
| 5bf426df29 | |||
| 40ccec0e2d | |||
| e553e08663 | |||
| aca9f79b46 | |||
| 40aa51be4d | |||
| e5c5383471 | |||
| 693f720cbd | |||
| 56932f7f25 | |||
| 02edb0b0f9 | |||
| df025873c6 | |||
| 7f2a751065 | |||
| 793b719983 | |||
| d4e5b11f71 | |||
| 418e3a13e8 | |||
| 84eff1f3b0 | |||
| 835968e8fe | |||
| 29c6e399c0 | |||
| 1f6239e7d2 | |||
| 5d2e2443a3 | |||
| 90283c45f4 | |||
| cd80b8e32e | |||
| adaa075e6e | |||
| bbfab72138 | |||
| 6faf63c2cd | |||
| c0f6c4da6d | |||
| 766da0320b | |||
| 7a44cbbef0 | |||
| 979a6c527f | |||
| 6bc77486f1 | |||
| 9521a64da4 | |||
| 7953e05241 | |||
| db9b4ce32c | |||
| 14a4a0b994 | |||
| 0dc450ba30 | |||
| 1cca485062 | |||
| f71fe2ddf5 | |||
| 08e8e54c36 | |||
| 003b540481 | |||
| 7cd8a6b030 | |||
| baf20b51ba | |||
| de602ff5d9 | |||
| 2d9620c6d1 | |||
| 2c69e75842 | |||
| 0eb25620ef | |||
| 307f1fbbc1 | |||
| c465e518e5 | |||
| fe437626e6 | |||
| d3bce49445 | |||
| 8a06227243 | |||
| 1f3f5b3d3b | |||
| d2151a4acf | |||
| 9cc70269f5 | |||
| afbcaa5011 | |||
| 15e9969ca2 | |||
| c905449114 | |||
| ed6a7ed39c | |||
| 3b675a68b0 | |||
| c12f5336f5 | |||
| 4ea2292e2b | |||
| 0fbb7822df | |||
| a863cdd663 | |||
| 9f1e9e4d3b | |||
| de07d8d4cf | |||
| 1ce94b8536 | |||
| b509db4940 | |||
| 653db2428f | |||
| 5167f2a988 | |||
| 8af6b7b04e | |||
| 16965a7645 | |||
| c36b95e041 | |||
| 862226305a | |||
| 8ff781661e | |||
| 4d6859b927 | |||
| b32553b0b1 | |||
| 8804bdec37 | |||
| 487ce42361 | |||
| 46445dd1cb | |||
| ab112788b4 | |||
| 8d799e8e64 | |||
| cfb7198d64 | |||
| 2b9e080b4c | |||
| 20bb5bfb60 | |||
| dc719a55d5 | |||
| 5593764fdb | |||
| e7228c2be8 | |||
| 298fe3ea39 | |||
| 4e32cf4f21 | |||
| a75fabecb9 | |||
| b3c41967f6 | |||
| 6d13993f98 | |||
| 537d1bb712 | |||
| 5307ae287c | |||
| 2daa66d7b0 | |||
| 1a7d1dc8c3 | |||
| e7c5af0d01 | |||
| a10164b932 | |||
| c85d2edf39 | |||
| 7ec91b0e6a | |||
| 27f6d141f7 | |||
| 8380b1d2cc | |||
| 2ec4d9157c | |||
| 9dd533825f | |||
| e61d05fc41 | |||
| cd97e4cc87 | |||
| 58a5d5b450 | |||
| fb033e3da2 | |||
| e4b53dde44 | |||
| a4b4d11fc0 | |||
| fc012b5311 | |||
| b5a1e881fb | |||
| b9a21e8bcc | |||
| aa1c0b38c0 | |||
| c2c4cb9f3a | |||
| d82033fd84 | |||
| c30a15d295 | |||
| 38f2e51788 | |||
| 9553ca5ce7 | |||
| cf9817e853 | |||
| 6e92ea4fce | |||
| 994f4287ef | |||
| ad9e428b1e | |||
| c837464a28 | |||
| 2395a6e566 | |||
| cb3c9b6e41 | |||
| 861748a059 | |||
| f00e5e47b2 | |||
| 0ff5473dfd | |||
| 59cf99f0af | |||
| b8fd4e4ded | |||
| d7fd585e77 | |||
| f2075e29d2 | |||
| c7f0013e57 | |||
| 6c9de35426 | |||
| e9e7b5d0e7 | |||
| 4d2df860ce | |||
| 61db0734d2 | |||
| dd9f7a82dc | |||
| 79cad29ff1 | |||
| 6b2698c0c5 | |||
| c46e91d0f5 | |||
| bd0595ee79 | |||
| f1fec6d825 | |||
| a5db6142b3 | |||
| 1298586a74 | |||
| 3231fdb4b7 | |||
| 0b266d208c | |||
| 867da767eb | |||
| 93f6109028 | |||
| 8fbbf460a9 | |||
| 14313ec59c | |||
| 1eaf5c4e0b | |||
| 5be58f4e1c | |||
| 695dc9fdce | |||
| 8f028101c7 | |||
| 55d59112ad | |||
| 2287d6e2ee | |||
| 12693dbd60 | |||
| 680ef9d440 | |||
| 48ffc5be8e | |||
| 8c10ff5574 |
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [unreleased] — 2026-05-19
|
||||
|
||||
### Performance — activity detail page
|
||||
|
||||
Four targeted fixes that together eliminate the blank loading screen and
|
||||
reduce timeseries payload size for the dominant use case.
|
||||
|
||||
**sessionStorage summary passthrough** (`ActivityFeed.svelte`,
|
||||
`ActivityDetailLoader.svelte`): when the user clicks an activity from the
|
||||
feed, the summary object is written to sessionStorage before navigation and
|
||||
read back synchronously at module init on the detail page — before the first
|
||||
render. The "Loading activity…" screen and the two sequential index-fetch
|
||||
round trips are eliminated entirely for this path. Direct URLs and bookmarks
|
||||
fall through to the existing slow path unchanged.
|
||||
|
||||
**Spatial 10 m downsampling** (`bincio/extract/timeseries.py`): timeseries
|
||||
are now downsampled to one sample per 10 m of distance traveled (GPS
|
||||
haversine primary, speed × Δt fallback) instead of one per second. Indoor
|
||||
activities with neither GPS nor speed data are left at 1 s resolution.
|
||||
Running activities see ~67 % fewer points; long cycling rides ~30 %. A
|
||||
`bincio render --downsample-timeseries` migration flag retroactively
|
||||
downsamples all existing stored files without re-extracting from FIT/GPX.
|
||||
|
||||
**nginx timeseries caching** (`deploy/vps/nginx-activity.conf`): a regex
|
||||
location block before the generic `/data/` handler serves `*.timeseries.json`
|
||||
with `Cache-Control: public, max-age=3600, stale-while-revalidate=3600`.
|
||||
Previously every page view triggered a conditional GET even when nothing had
|
||||
changed.
|
||||
|
||||
**asyncio.to_thread for segment_efforts** (`bincio/serve/routers/activities.py`):
|
||||
the synchronous file scan in `GET /api/activities/{id}/segment_efforts` is
|
||||
now dispatched via `asyncio.to_thread` so it runs in a thread pool instead of
|
||||
blocking the event loop during concurrent fetches.
|
||||
|
||||
### Performance — static asset caching
|
||||
|
||||
**Immutable JS/CSS caching** (`deploy/vps/nginx-activity.conf`): Astro
|
||||
content-hashes all `/_astro/*.js` and `/_astro/*.css` filenames at build time.
|
||||
A new nginx location block serves them with `max-age=31536000, immutable` so
|
||||
browsers never revalidate until the hash changes. HTML pages get an explicit
|
||||
`no-cache, must-revalidate` header so the latest asset URLs are always fetched
|
||||
after a deploy.
|
||||
|
||||
### Tooling
|
||||
|
||||
**VPS backup script** (`deploy/vps/backup-vps.sh`): extended to pull
|
||||
`nginx-wiki.conf` and `nginx-planner.conf` in addition to the existing files.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-04-22
|
||||
|
||||
### Improvement — DEM & hysteresis algorithm refinements
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
Copyright (C) 2026 Davide Scaini
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program 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 Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -320,3 +320,7 @@ Databases add operational complexity — backups, migrations, running processes,
|
||||
## Why federation?
|
||||
|
||||
Strava, Garmin Connect, and similar platforms are silos. If the company shuts down or changes its terms, your data and your social graph go with it. BincioActivity's federation model is inspired by the open web: you host your own data at a URL, friends subscribe to that URL, and no central authority is involved.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL v3](LICENSE) — see the `LICENSE` file for details.
|
||||
|
||||
@@ -19,6 +19,27 @@ from bincio.serve.init_cmd import init # noqa: E402
|
||||
from bincio.serve.cli import serve # noqa: E402
|
||||
from bincio.dev import dev # noqa: E402
|
||||
from bincio.reextract_cmd import reextract_originals # noqa: E402
|
||||
from bincio.sync_strava import sync_strava_cmd # noqa: E402
|
||||
from bincio.sync_garmin import sync_garmin_cmd # noqa: E402
|
||||
from bincio.segments.cli import segments_group # noqa: E402
|
||||
|
||||
|
||||
@main.command("bake-tracks")
|
||||
@click.option("--data-dir", required=True, help="BAS data store directory.")
|
||||
@click.option("--handle", default=None, help="Bake one user only (default: all).")
|
||||
def bake_tracks_cmd(data_dir: str, handle: str | None) -> None:
|
||||
"""Pre-bake GPS tracks.json for the Explore heatmap page."""
|
||||
from pathlib import Path
|
||||
from bincio.explore import bake_tracks
|
||||
from bincio.render.cli import _user_dirs
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
data = Path(data_dir).expanduser().resolve()
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
n = bake_tracks(user_dir.name, data)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
|
||||
|
||||
|
||||
main.add_command(extract)
|
||||
main.add_command(render)
|
||||
@@ -28,3 +49,6 @@ main.add_command(init)
|
||||
main.add_command(serve)
|
||||
main.add_command(dev)
|
||||
main.add_command(reextract_originals)
|
||||
main.add_command(sync_strava_cmd)
|
||||
main.add_command(sync_garmin_cmd)
|
||||
main.add_command(segments_group)
|
||||
|
||||
+3
-2
@@ -87,9 +87,10 @@ def _start_serve(data: Path, api_port: int, site: Path, api_host: str = "127.0.0
|
||||
"""Start bincio serve in a background thread."""
|
||||
import uvicorn
|
||||
import bincio.serve.server as srv
|
||||
from bincio.serve import deps
|
||||
|
||||
srv.data_dir = data
|
||||
srv.site_dir = site
|
||||
deps.data_dir = data
|
||||
deps.site_dir = site
|
||||
|
||||
config = uvicorn.Config(
|
||||
srv.app,
|
||||
|
||||
@@ -13,7 +13,10 @@ from typing import Any, Optional
|
||||
|
||||
# ── Shared constants (imported by edit/server.py and serve/server.py) ─────────
|
||||
|
||||
from bincio.extract.sport import SUB_SPORTS as _SUB_SPORTS
|
||||
|
||||
SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
|
||||
_VALID_SUB_SPORTS = {v for vs in _SUB_SPORTS.values() for v in vs}
|
||||
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
|
||||
VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$')
|
||||
|
||||
@@ -36,6 +39,8 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path
|
||||
lines.append(f"title: {json.dumps(payload['title'])}")
|
||||
if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other":
|
||||
lines.append(f"sport: {payload['sport']}")
|
||||
if payload.get("sub_sport") and payload["sub_sport"] in _VALID_SUB_SPORTS:
|
||||
lines.append(f"sub_sport: {payload['sub_sport']}")
|
||||
if payload.get("gear"):
|
||||
lines.append(f"gear: {json.dumps(payload['gear'])}")
|
||||
if payload.get("highlight"):
|
||||
@@ -45,6 +50,12 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path
|
||||
hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS]
|
||||
if hide:
|
||||
lines.append(f"hide_stats: [{', '.join(hide)}]")
|
||||
dd = payload.get("download_disabled")
|
||||
if dd is True:
|
||||
lines.append("download_disabled: true")
|
||||
elif dd is False:
|
||||
# Explicit false: allows per-activity opt-in against a user-level default
|
||||
lines.append("download_disabled: false")
|
||||
|
||||
description = (payload.get("description") or "").strip()
|
||||
|
||||
|
||||
+6
-303
@@ -43,308 +43,12 @@ def _check_id(activity_id: str) -> str:
|
||||
return activity_id
|
||||
|
||||
|
||||
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name
|
||||
|
||||
|
||||
def _unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 1
|
||||
while (directory / candidate).exists():
|
||||
candidate = f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
# ── HTML UI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_HTML = """\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit Activity</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #09090b; --surface: #18181b; --border: #27272a;
|
||||
--text: #fafafa; --muted: #71717a; --accent: #3b82f6;
|
||||
--accent-dim: #1d3461; --danger: #ef4444;
|
||||
--radius: 10px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: var(--font);
|
||||
font-size: 14px; line-height: 1.5; padding: 24px; min-height: 100vh; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
input, select, textarea {
|
||||
width: 100%; padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text); font-size: 14px; font-family: var(--font);
|
||||
outline: none; transition: border-color .15s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 140px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.check-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
|
||||
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
user-select: none; transition: border-color .15s, background .15s; }
|
||||
.check-item:hover { border-color: var(--muted); }
|
||||
.check-item input[type=checkbox] { width: auto; accent-color: var(--accent); }
|
||||
.check-item.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||
.toggle-row { display: flex; gap: 16px; }
|
||||
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||
transition: border-color .15s, background .15s; }
|
||||
.toggle:hover { border-color: var(--muted); }
|
||||
.toggle.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||
.toggle input { width: auto; accent-color: var(--accent); }
|
||||
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius);
|
||||
padding: 24px; text-align: center; color: var(--muted); cursor: pointer;
|
||||
transition: border-color .15s; margin-top: 4px; }
|
||||
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); color: var(--text); }
|
||||
.image-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.image-chip { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
|
||||
font-size: 12px; }
|
||||
.image-chip button { background: none; border: none; color: var(--muted);
|
||||
cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
|
||||
.image-chip button:hover { color: var(--danger); }
|
||||
.actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
|
||||
.btn { padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500;
|
||||
cursor: pointer; border: none; transition: opacity .15s; }
|
||||
.btn:disabled { opacity: .4; cursor: default; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: .85; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover:not(:disabled) { border-color: var(--muted); }
|
||||
.status { font-size: 13px; }
|
||||
.status.ok { color: #4ade80; }
|
||||
.status.err { color: var(--danger); }
|
||||
.header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; }
|
||||
.back { font-size: 13px; color: var(--muted); }
|
||||
.meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 20px; max-width: 780px; margin: 0 auto; }
|
||||
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); margin-bottom: 14px; padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width:780px;margin:0 auto">
|
||||
<div class="header">
|
||||
<a class="back" href="__SITE_URL__">← Back to site</a>
|
||||
<h1 id="page-title">Edit Activity</h1>
|
||||
</div>
|
||||
<p id="meta" class="meta" style="margin-bottom:16px"></p>
|
||||
|
||||
<div class="card">
|
||||
<form id="form" autocomplete="off">
|
||||
<p class="section-title">Identity</p>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="title">Title</label>
|
||||
<input id="title" name="title" type="text" placeholder="Leave blank to keep extracted title">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sport">Sport</label>
|
||||
<select id="sport" name="sport">
|
||||
__SPORT_OPTIONS__
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gear">Gear</label>
|
||||
<input id="gear" name="gear" type="text" placeholder="e.g. Trek Domane SL6">
|
||||
</div>
|
||||
|
||||
<p class="section-title" style="margin-top:20px">Description</p>
|
||||
<div class="field">
|
||||
<label for="description">Markdown supported</label>
|
||||
<textarea id="description" name="description" placeholder="Write about this activity…"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="section-title" style="margin-top:20px">Display</p>
|
||||
<div class="field">
|
||||
<label>Hide stat panels</label>
|
||||
<div class="check-group" id="hide-stats-group">
|
||||
__STAT_CHECKBOXES__
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-top:12px">
|
||||
<label>Flags</label>
|
||||
<div class="toggle-row">
|
||||
<label class="toggle" id="toggle-highlight">
|
||||
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
|
||||
</label>
|
||||
<label class="toggle" id="toggle-private">
|
||||
<input type="checkbox" id="private" name="private"> Unlisted (hide from feed)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="section-title" style="margin-top:20px">Images</p>
|
||||
<div class="field">
|
||||
<label>Drag & drop images or click to browse</label>
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<span id="drop-label">Drop images here or click to upload</span>
|
||||
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
|
||||
</div>
|
||||
<div class="image-list" id="image-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
|
||||
<span class="status" id="status"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const id = location.pathname.split('/edit/')[1];
|
||||
const api = '/api/activity/' + id;
|
||||
let uploadedImages = [];
|
||||
|
||||
// Fetch current data
|
||||
fetch(api).then(r => r.json()).then(data => {
|
||||
document.getElementById('page-title').textContent = 'Edit: ' + (data.title || id);
|
||||
document.getElementById('meta').textContent = data.started_at
|
||||
? new Date(data.started_at).toLocaleString() : '';
|
||||
document.getElementById('title').value = data.title || '';
|
||||
document.getElementById('sport').value = data.sport || 'other';
|
||||
document.getElementById('gear').value = data.gear || '';
|
||||
document.getElementById('description').value = data.description || '';
|
||||
if (data.highlight) setToggle('highlight', true);
|
||||
if (data.private) setToggle('private', true);
|
||||
(data.hide_stats || []).forEach(s => {
|
||||
const cb = document.querySelector(`input[data-stat="${s}"]`);
|
||||
if (cb) { cb.checked = true; cb.closest('.check-item').classList.add('active'); }
|
||||
});
|
||||
uploadedImages = data.images || [];
|
||||
renderImageList();
|
||||
}).catch(() => {
|
||||
document.getElementById('status').textContent = 'Could not load activity data.';
|
||||
document.getElementById('status').className = 'status err';
|
||||
});
|
||||
|
||||
// Toggle active class on check items
|
||||
document.querySelectorAll('.check-item input[type=checkbox]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
cb.closest('.check-item').classList.toggle('active', cb.checked);
|
||||
});
|
||||
});
|
||||
|
||||
function setToggle(name, val) {
|
||||
const cb = document.getElementById(name);
|
||||
cb.checked = val;
|
||||
document.getElementById('toggle-' + name).classList.toggle('active', val);
|
||||
}
|
||||
document.getElementById('highlight').addEventListener('change', e => {
|
||||
document.getElementById('toggle-highlight').classList.toggle('active', e.target.checked);
|
||||
});
|
||||
document.getElementById('private').addEventListener('change', e => {
|
||||
document.getElementById('toggle-private').classList.toggle('active', e.target.checked);
|
||||
});
|
||||
|
||||
// Image upload
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
uploadFiles([...e.dataTransfer.files]);
|
||||
});
|
||||
fileInput.addEventListener('change', () => uploadFiles([...fileInput.files]));
|
||||
|
||||
async function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch(api + '/images', { method: 'POST', body: fd });
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (!uploadedImages.includes(d.filename)) uploadedImages.push(d.filename);
|
||||
renderImageList();
|
||||
// Insert markdown image reference at end of description
|
||||
const ta = document.getElementById('description');
|
||||
const ref = '\\n![' + d.filename.replace(/\\.[^.]+$/, '') + '](' + d.filename + ')';
|
||||
ta.value = ta.value.trimEnd() + ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
function renderImageList() {
|
||||
const list = document.getElementById('image-list');
|
||||
list.innerHTML = uploadedImages.map(f =>
|
||||
`<span class="image-chip">${escapeHtml(f)}
|
||||
<button type="button" onclick="removeImage('${escapeHtml(f)}')" title="Remove">×</button>
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
async function removeImage(filename) {
|
||||
await fetch(api + '/images/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||
uploadedImages = uploadedImages.filter(f => f !== filename);
|
||||
renderImageList();
|
||||
}
|
||||
|
||||
// Save
|
||||
document.getElementById('form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('save-btn');
|
||||
const status = document.getElementById('status');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Saving…';
|
||||
status.className = 'status';
|
||||
|
||||
const hideStats = [...document.querySelectorAll('input[data-stat]:checked')]
|
||||
.map(cb => cb.dataset.stat);
|
||||
|
||||
const payload = {
|
||||
title: document.getElementById('title').value.trim(),
|
||||
sport: document.getElementById('sport').value,
|
||||
gear: document.getElementById('gear').value.trim(),
|
||||
description: document.getElementById('description').value.trim(),
|
||||
highlight: document.getElementById('highlight').checked,
|
||||
private: document.getElementById('private').checked,
|
||||
hide_stats: hideStats,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch(api, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
status.textContent = 'Saved! Re-run `bincio render` to rebuild.';
|
||||
status.className = 'status ok';
|
||||
} catch (err) {
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
status.className = 'status err';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
_TEMPLATE_PATH = Path(__file__).parent / "templates" / "edit.html"
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -369,13 +73,12 @@ async def edit_page(activity_id: str) -> str:
|
||||
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
|
||||
for s in STAT_PANELS
|
||||
)
|
||||
html = (
|
||||
_HTML
|
||||
return (
|
||||
_TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
.replace("__SITE_URL__", site_url)
|
||||
.replace("__SPORT_OPTIONS__", sport_opts)
|
||||
.replace("__STAT_CHECKBOXES__", stat_cbs)
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
@app.get("/api/activity/{activity_id}")
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit Activity</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #09090b; --surface: #18181b; --border: #27272a;
|
||||
--text: #fafafa; --muted: #71717a; --accent: #3b82f6;
|
||||
--accent-dim: #1d3461; --danger: #ef4444;
|
||||
--radius: 10px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: var(--font);
|
||||
font-size: 14px; line-height: 1.5; padding: 24px; min-height: 100vh; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
input, select, textarea {
|
||||
width: 100%; padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text); font-size: 14px; font-family: var(--font);
|
||||
outline: none; transition: border-color .15s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 140px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.check-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
|
||||
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
user-select: none; transition: border-color .15s, background .15s; }
|
||||
.check-item:hover { border-color: var(--muted); }
|
||||
.check-item input[type=checkbox] { width: auto; accent-color: var(--accent); }
|
||||
.check-item.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||
.toggle-row { display: flex; gap: 16px; }
|
||||
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||
transition: border-color .15s, background .15s; }
|
||||
.toggle:hover { border-color: var(--muted); }
|
||||
.toggle.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||
.toggle input { width: auto; accent-color: var(--accent); }
|
||||
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius);
|
||||
padding: 24px; text-align: center; color: var(--muted); cursor: pointer;
|
||||
transition: border-color .15s; margin-top: 4px; }
|
||||
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); color: var(--text); }
|
||||
.image-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.image-chip { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
|
||||
font-size: 12px; }
|
||||
.image-chip button { background: none; border: none; color: var(--muted);
|
||||
cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
|
||||
.image-chip button:hover { color: var(--danger); }
|
||||
.actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
|
||||
.btn { padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500;
|
||||
cursor: pointer; border: none; transition: opacity .15s; }
|
||||
.btn:disabled { opacity: .4; cursor: default; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: .85; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover:not(:disabled) { border-color: var(--muted); }
|
||||
.status { font-size: 13px; }
|
||||
.status.ok { color: #4ade80; }
|
||||
.status.err { color: var(--danger); }
|
||||
.header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; }
|
||||
.back { font-size: 13px; color: var(--muted); }
|
||||
.meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 20px; max-width: 780px; margin: 0 auto; }
|
||||
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); margin-bottom: 14px; padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width:780px;margin:0 auto">
|
||||
<div class="header">
|
||||
<a class="back" href="__SITE_URL__">← Back to site</a>
|
||||
<h1 id="page-title">Edit Activity</h1>
|
||||
</div>
|
||||
<p id="meta" class="meta" style="margin-bottom:16px"></p>
|
||||
|
||||
<div class="card">
|
||||
<form id="form" autocomplete="off">
|
||||
<p class="section-title">Identity</p>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="title">Title</label>
|
||||
<input id="title" name="title" type="text" placeholder="Leave blank to keep extracted title">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sport">Sport</label>
|
||||
<select id="sport" name="sport">
|
||||
__SPORT_OPTIONS__
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gear">Gear</label>
|
||||
<input id="gear" name="gear" type="text" placeholder="e.g. Trek Domane SL6">
|
||||
</div>
|
||||
|
||||
<p class="section-title" style="margin-top:20px">Description</p>
|
||||
<div class="field">
|
||||
<label for="description">Markdown supported</label>
|
||||
<textarea id="description" name="description" placeholder="Write about this activity…"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="section-title" style="margin-top:20px">Display</p>
|
||||
<div class="field">
|
||||
<label>Hide stat panels</label>
|
||||
<div class="check-group" id="hide-stats-group">
|
||||
__STAT_CHECKBOXES__
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-top:12px">
|
||||
<label>Flags</label>
|
||||
<div class="toggle-row">
|
||||
<label class="toggle" id="toggle-highlight">
|
||||
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
|
||||
</label>
|
||||
<label class="toggle" id="toggle-private">
|
||||
<input type="checkbox" id="private" name="private"> Unlisted (hide from feed)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="section-title" style="margin-top:20px">Images</p>
|
||||
<div class="field">
|
||||
<label>Drag & drop images or click to browse</label>
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<span id="drop-label">Drop images here or click to upload</span>
|
||||
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
|
||||
</div>
|
||||
<div class="image-list" id="image-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
|
||||
<span class="status" id="status"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const id = location.pathname.split('/edit/')[1];
|
||||
const api = '/api/activity/' + id;
|
||||
let uploadedImages = [];
|
||||
|
||||
// Fetch current data
|
||||
fetch(api).then(r => r.json()).then(data => {
|
||||
document.getElementById('page-title').textContent = 'Edit: ' + (data.title || id);
|
||||
document.getElementById('meta').textContent = data.started_at
|
||||
? new Date(data.started_at).toLocaleString() : '';
|
||||
document.getElementById('title').value = data.title || '';
|
||||
document.getElementById('sport').value = data.sport || 'other';
|
||||
document.getElementById('gear').value = data.gear || '';
|
||||
document.getElementById('description').value = data.description || '';
|
||||
if (data.highlight) setToggle('highlight', true);
|
||||
if (data.private) setToggle('private', true);
|
||||
(data.hide_stats || []).forEach(s => {
|
||||
const cb = document.querySelector(`input[data-stat="${s}"]`);
|
||||
if (cb) { cb.checked = true; cb.closest('.check-item').classList.add('active'); }
|
||||
});
|
||||
uploadedImages = data.images || [];
|
||||
renderImageList();
|
||||
}).catch(() => {
|
||||
document.getElementById('status').textContent = 'Could not load activity data.';
|
||||
document.getElementById('status').className = 'status err';
|
||||
});
|
||||
|
||||
// Toggle active class on check items
|
||||
document.querySelectorAll('.check-item input[type=checkbox]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
cb.closest('.check-item').classList.toggle('active', cb.checked);
|
||||
});
|
||||
});
|
||||
|
||||
function setToggle(name, val) {
|
||||
const cb = document.getElementById(name);
|
||||
cb.checked = val;
|
||||
document.getElementById('toggle-' + name).classList.toggle('active', val);
|
||||
}
|
||||
document.getElementById('highlight').addEventListener('change', e => {
|
||||
document.getElementById('toggle-highlight').classList.toggle('active', e.target.checked);
|
||||
});
|
||||
document.getElementById('private').addEventListener('change', e => {
|
||||
document.getElementById('toggle-private').classList.toggle('active', e.target.checked);
|
||||
});
|
||||
|
||||
// Image upload
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
uploadFiles([...e.dataTransfer.files]);
|
||||
});
|
||||
fileInput.addEventListener('change', () => uploadFiles([...fileInput.files]));
|
||||
|
||||
async function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch(api + '/images', { method: 'POST', body: fd });
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (!uploadedImages.includes(d.filename)) uploadedImages.push(d.filename);
|
||||
renderImageList();
|
||||
// Insert markdown image reference at end of description
|
||||
const ta = document.getElementById('description');
|
||||
const ref = '\n![' + d.filename.replace(/\.[^.]+$/, '') + '](' + d.filename + ')';
|
||||
ta.value = ta.value.trimEnd() + ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
function renderImageList() {
|
||||
const list = document.getElementById('image-list');
|
||||
list.innerHTML = uploadedImages.map(f =>
|
||||
`<span class="image-chip">${escapeHtml(f)}
|
||||
<button type="button" onclick="removeImage('${escapeHtml(f)}')" title="Remove">×</button>
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
async function removeImage(filename) {
|
||||
await fetch(api + '/images/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||
uploadedImages = uploadedImages.filter(f => f !== filename);
|
||||
renderImageList();
|
||||
}
|
||||
|
||||
// Save
|
||||
document.getElementById('form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('save-btn');
|
||||
const status = document.getElementById('status');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Saving…';
|
||||
status.className = 'status';
|
||||
|
||||
const hideStats = [...document.querySelectorAll('input[data-stat]:checked')]
|
||||
.map(cb => cb.dataset.stat);
|
||||
|
||||
const payload = {
|
||||
title: document.getElementById('title').value.trim(),
|
||||
sport: document.getElementById('sport').value,
|
||||
gear: document.getElementById('gear').value.trim(),
|
||||
description: document.getElementById('description').value.trim(),
|
||||
highlight: document.getElementById('highlight').checked,
|
||||
private: document.getElementById('private').checked,
|
||||
hide_stats: hideStats,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch(api, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
status.textContent = 'Saved! Re-run `bincio render` to rebuild.';
|
||||
status.className = 'status ok';
|
||||
} catch (err) {
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
status.className = 'status err';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Pre-bake per-handle GPS tracks for the Explore page.
|
||||
|
||||
Reads all activity GeoJSON files for a handle, applies RDP simplification,
|
||||
and writes per-year tracks_YYYY.json shards plus a tracks_index.json manifest
|
||||
for progressive client-side loading.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from bincio.extract.simplify import _rdp_mask
|
||||
|
||||
_VERSION = 2
|
||||
_RDP_EPSILON = 0.0001 # ~10 m on the ground
|
||||
|
||||
|
||||
_SPORT_MAP: dict[str, str] = {
|
||||
"cycling": "cycling", "road_cycling": "cycling", "gravel_cycling": "cycling",
|
||||
"mountain_biking": "cycling", "e_biking": "cycling", "indoor_cycling": "cycling",
|
||||
"biking": "cycling", "bike": "cycling", "ride": "cycling",
|
||||
"running": "running", "trail_running": "running", "treadmill_running": "running",
|
||||
"jogging": "running",
|
||||
"hiking": "hiking", "walking": "hiking", "trekking": "hiking",
|
||||
"mountaineering": "hiking",
|
||||
"skiing": "skiing", "cross_country_skiing": "skiing", "alpine_skiing": "skiing",
|
||||
"snowboarding": "skiing",
|
||||
}
|
||||
|
||||
|
||||
def _sport_to_type(sport: str | None) -> str:
|
||||
if not sport:
|
||||
return "other"
|
||||
return _SPORT_MAP.get(sport.lower(), "other")
|
||||
|
||||
|
||||
def bake_tracks(handle: str, data_dir: Path) -> int:
|
||||
"""Build tracks.json for handle. Returns number of tracks included."""
|
||||
acts_dir = data_dir / handle / "activities"
|
||||
if not acts_dir.exists():
|
||||
return 0
|
||||
|
||||
tracks = []
|
||||
for gj_path in sorted(acts_dir.glob("*.geojson")):
|
||||
act_id = gj_path.stem
|
||||
|
||||
meta: dict = {}
|
||||
meta_path = acts_dir / f"{act_id}.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
else:
|
||||
# bare-timestamp geojson with no metadata — superseded by a slug version
|
||||
if list(acts_dir.glob(f"{act_id}-*.geojson")):
|
||||
continue
|
||||
|
||||
if meta.get("virtual") or meta.get("sub_sport") == "indoor":
|
||||
continue
|
||||
|
||||
try:
|
||||
gj = json.loads(gj_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
raw_coords = gj.get("geometry", {}).get("coordinates") or []
|
||||
if len(raw_coords) < 2:
|
||||
continue
|
||||
|
||||
lng_lat = [[float(c[0]), float(c[1])] for c in raw_coords if len(c) >= 2]
|
||||
if len(lng_lat) < 2:
|
||||
continue
|
||||
|
||||
mask = _rdp_mask(lng_lat, epsilon=_RDP_EPSILON)
|
||||
simplified = [pt for pt, keep in zip(lng_lat, mask) if keep]
|
||||
if len(simplified) < 2:
|
||||
continue
|
||||
|
||||
tracks.append({
|
||||
"id": act_id,
|
||||
"date": (meta.get("started_at") or "")[:10],
|
||||
"type": _sport_to_type(meta.get("sport")),
|
||||
"name": meta.get("title") or act_id,
|
||||
"dist": int(meta.get("distance_m") or 0),
|
||||
"coords": simplified,
|
||||
})
|
||||
|
||||
tracks.sort(key=lambda t: t["date"], reverse=True)
|
||||
|
||||
user_dir = data_dir / handle
|
||||
now = int(time.time())
|
||||
|
||||
# Group into per-year buckets
|
||||
by_year: dict[str, list] = {}
|
||||
for t in tracks:
|
||||
year = t["date"][:4] or "0000"
|
||||
by_year.setdefault(year, []).append(t)
|
||||
|
||||
# Remove stale year shards that no longer have data
|
||||
for old in user_dir.glob("tracks_*.json"):
|
||||
stem = old.stem # e.g. "tracks_2024" or "tracks_index"
|
||||
if stem == "tracks_index":
|
||||
continue
|
||||
year_part = stem[len("tracks_"):]
|
||||
if year_part not in by_year:
|
||||
old.unlink(missing_ok=True)
|
||||
|
||||
# Write per-year shards
|
||||
for year, year_tracks in by_year.items():
|
||||
shard_path = user_dir / f"tracks_{year}.json"
|
||||
shard_path.write_text(
|
||||
json.dumps({
|
||||
"v": _VERSION,
|
||||
"handle": handle,
|
||||
"year": year,
|
||||
"generated_at": now,
|
||||
"tracks": year_tracks,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Write manifest
|
||||
years_sorted = sorted(by_year.keys(), reverse=True)
|
||||
index_path = user_dir / "tracks_index.json"
|
||||
index_path.write_text(
|
||||
json.dumps({
|
||||
"v": _VERSION,
|
||||
"handle": handle,
|
||||
"generated_at": now,
|
||||
"total": len(tracks),
|
||||
"years": years_sorted,
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Remove legacy monolithic file if present
|
||||
legacy = user_dir / "tracks.json"
|
||||
legacy.unlink(missing_ok=True)
|
||||
|
||||
return len(tracks)
|
||||
+29
-16
@@ -19,6 +19,8 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bincio.extract.metrics import elevation_params
|
||||
|
||||
# Sample one GPS point per N seconds when building the DEM query.
|
||||
# SRTM30 resolution is ~30 m; at 30 km/h cycling that's ~3 s per tile —
|
||||
# sampling every 10 s is more than enough.
|
||||
@@ -297,7 +299,9 @@ def recalculate_elevation(
|
||||
}
|
||||
|
||||
|
||||
def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
|
||||
def recalculate_elevation_hysteresis(
|
||||
user_dir: Path, activity_id: str, *, patch_index: bool = True
|
||||
) -> dict:
|
||||
"""Recompute elevation gain/loss from the original recorded elevation data.
|
||||
|
||||
Algorithm
|
||||
@@ -346,13 +350,19 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
|
||||
if len(elevations) < 2:
|
||||
raise ValueError("Not enough elevation data to compute gain/loss")
|
||||
|
||||
# Determine source-aware threshold
|
||||
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
altitude_source = detail.get("altitude_source", "unknown")
|
||||
threshold = 1.0 if altitude_source == "barometric" else 3.0
|
||||
source = detail.get("source") or ""
|
||||
ma_window, threshold = elevation_params(altitude_source, source)
|
||||
|
||||
# Pre-smooth to suppress noise, then accumulate with low dead-band
|
||||
smoothed = _moving_average(elevations, _MA_WINDOW_S)
|
||||
# Strip leading no-fix zeros (same logic as metrics._elevation)
|
||||
if elevations and abs(elevations[0]) < 0.5:
|
||||
for i, e in enumerate(elevations):
|
||||
if abs(e) > threshold:
|
||||
elevations = elevations[i:]
|
||||
break
|
||||
|
||||
smoothed = _moving_average(elevations, ma_window) if ma_window > 1 else elevations
|
||||
gain, loss = _hysteresis_gain_loss(smoothed, threshold)
|
||||
gain_r = round(gain, 1)
|
||||
loss_r = round(loss, 1)
|
||||
@@ -362,21 +372,24 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
|
||||
detail["elevation_loss_m"] = loss_r
|
||||
json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# Patch index.json summary
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
for s in index.get("activities", []):
|
||||
if s.get("id") == activity_id:
|
||||
s["elevation_gain_m"] = gain_r
|
||||
break
|
||||
index_path.write_text(
|
||||
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
# Patch index.json summary (skip for bulk callers who batch this themselves)
|
||||
if patch_index:
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
for s in index.get("activities", []):
|
||||
if s.get("id") == activity_id:
|
||||
s["elevation_gain_m"] = gain_r
|
||||
break
|
||||
index_path.write_text(
|
||||
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
return {
|
||||
"elevation_gain_m": gain_r,
|
||||
"elevation_loss_m": loss_r,
|
||||
"threshold_m": threshold,
|
||||
"ma_window_s": ma_window,
|
||||
"altitude_source": altitude_source,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
_SYNC_FILE = "garmin_sync.json"
|
||||
|
||||
@@ -73,9 +73,13 @@ def garmin_sync_iter(
|
||||
data_dir: Root data directory (used for encryption key lookup).
|
||||
user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.).
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, get_client
|
||||
from bincio.extract.ingest import ingest_parsed
|
||||
from bincio.extract.parsers.fit import FitParser
|
||||
from bincio.serve.routers.gear import _load as _gear_load
|
||||
from bincio.serve.routers.gear import _save as _gear_save
|
||||
|
||||
# ── Login ──────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
@@ -86,6 +90,41 @@ def garmin_sync_iter(
|
||||
|
||||
yield {"type": "fetching"}
|
||||
|
||||
# ── Sync gear registry ─────────────────────────────────────────────────────
|
||||
_garmin_uuid_to_name: dict[str, str] = {}
|
||||
try:
|
||||
prof = client.connectapi("/userprofile-service/socialProfile")
|
||||
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
|
||||
if profile_id:
|
||||
garmin_gear = client.get_gear(profile_id)
|
||||
if isinstance(garmin_gear, list):
|
||||
registry = _gear_load(user_dir)
|
||||
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
|
||||
for g in garmin_gear:
|
||||
guuid = g.get("uuid") or ""
|
||||
name = (g.get("customMakeModel") or g.get("displayName") or
|
||||
f"{g.get('gearMakeName','')} {g.get('gearModelName','')}".strip())
|
||||
if not name or not guuid:
|
||||
continue
|
||||
_garmin_uuid_to_name[guuid] = name
|
||||
if guuid not in known:
|
||||
gear_type = g.get("gearTypeName", "").lower()
|
||||
if gear_type not in ("bike", "shoes", "skis"):
|
||||
gear_type = "other"
|
||||
retired = g.get("gearStatusName") == "retired"
|
||||
registry.append({"id": str(_uuid.uuid4()), "name": name,
|
||||
"type": gear_type, "retired": retired,
|
||||
"garmin_id": guuid})
|
||||
known.add(guuid)
|
||||
else:
|
||||
# Update name in case it changed
|
||||
for item in registry:
|
||||
if item.get("garmin_id") == guuid:
|
||||
item["name"] = name
|
||||
_gear_save(user_dir, registry)
|
||||
except Exception:
|
||||
pass # gear sync is best-effort; don't abort activity sync
|
||||
|
||||
# ── Determine date range ───────────────────────────────────────────────────
|
||||
state = _load_sync_state(user_dir)
|
||||
last = state.get("last_sync_at")
|
||||
@@ -144,6 +183,16 @@ def garmin_sync_iter(
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"FIT parse error: {exc}") from exc
|
||||
|
||||
# Resolve gear for this activity
|
||||
if garmin_id and _garmin_uuid_to_name:
|
||||
try:
|
||||
act_gear = client.get_activity_gear(garmin_id)
|
||||
if isinstance(act_gear, list) and act_gear:
|
||||
guuid = act_gear[0].get("uuid") or ""
|
||||
parsed.gear = _garmin_uuid_to_name.get(guuid) or None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ingest — raises FileExistsError if already present (dedup)
|
||||
ingest_parsed(parsed, user_dir)
|
||||
imported += 1
|
||||
@@ -173,7 +222,8 @@ def garmin_sync_iter(
|
||||
}
|
||||
|
||||
# ── Persist sync state ─────────────────────────────────────────────────────
|
||||
state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
state["last_sync_at"] = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
state["total_imported"] = state.get("total_imported", 0) + imported
|
||||
_save_sync_state(user_dir, state)
|
||||
|
||||
yield {
|
||||
@@ -194,3 +244,145 @@ def run_garmin_sync(data_dir: Path, user_dir: Path) -> dict:
|
||||
elif event["type"] == "error":
|
||||
raise RuntimeError(event["message"])
|
||||
return result
|
||||
|
||||
|
||||
def import_garmin_gear(data_dir: Path, user_dir: Path) -> dict:
|
||||
"""Backfill gear for all existing activities by querying Garmin's gear-activities API.
|
||||
|
||||
For each gear item, fetches the list of activities from Garmin and matches them
|
||||
to local activities by UTC start timestamp (±60 s). Writes a sidecar and calls
|
||||
merge_one for each match that doesn't already have gear set.
|
||||
|
||||
Returns {"gear_added": int, "activities_updated": int}.
|
||||
"""
|
||||
import contextlib
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import yaml
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, get_client
|
||||
from bincio.render.merge import merge_one
|
||||
from bincio.serve.routers.gear import _load as _gear_load
|
||||
from bincio.serve.routers.gear import _save as _gear_save
|
||||
|
||||
client = get_client(data_dir, user_dir)
|
||||
|
||||
# Fetch gear list from Garmin
|
||||
prof = client.connectapi("/userprofile-service/socialProfile")
|
||||
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
|
||||
if not profile_id:
|
||||
raise GarminError("Could not read Garmin profile ID")
|
||||
garmin_gear = client.get_gear(profile_id)
|
||||
|
||||
if not isinstance(garmin_gear, list) or not garmin_gear:
|
||||
return {"gear_added": 0, "activities_updated": 0}
|
||||
|
||||
# Build / update local gear registry
|
||||
registry = _gear_load(user_dir)
|
||||
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
|
||||
uuid_to_name: dict[str, str] = {}
|
||||
gear_added = 0
|
||||
|
||||
for g in garmin_gear:
|
||||
guuid = g.get("uuid") or ""
|
||||
name = (g.get("customMakeModel") or g.get("displayName") or
|
||||
f"{g.get('gearMakeName', '')} {g.get('gearModelName', '')}".strip())
|
||||
if not name or not guuid:
|
||||
continue
|
||||
uuid_to_name[guuid] = name
|
||||
if guuid not in known:
|
||||
gear_type = g.get("gearTypeName", "").lower()
|
||||
if gear_type not in ("bike", "shoes", "skis"):
|
||||
gear_type = "other"
|
||||
retired = g.get("gearStatusName") == "retired"
|
||||
registry.append({"id": str(uuid.uuid4()), "name": name,
|
||||
"type": gear_type, "retired": retired, "garmin_id": guuid})
|
||||
known.add(guuid)
|
||||
gear_added += 1
|
||||
else:
|
||||
for item in registry:
|
||||
if item.get("garmin_id") == guuid:
|
||||
item["name"] = name
|
||||
|
||||
_gear_save(user_dir, registry)
|
||||
|
||||
# Build timestamp → activity_id map from index shards
|
||||
ts_to_id: dict[int, str] = {}
|
||||
merged_dir = user_dir / "_merged"
|
||||
shard_dir = merged_dir if merged_dir.exists() else user_dir
|
||||
for shard_path in sorted(shard_dir.glob("index*.json")):
|
||||
try:
|
||||
idx = json.loads(shard_path.read_text(encoding="utf-8"))
|
||||
for a in idx.get("activities", []):
|
||||
started = a.get("started_at") or ""
|
||||
if started and a.get("id"):
|
||||
dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
||||
ts_to_id[int(dt.astimezone(UTC).timestamp())] = a["id"]
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
edits_dir = user_dir / "edits"
|
||||
edits_dir.mkdir(exist_ok=True)
|
||||
activities_updated = 0
|
||||
|
||||
for guuid, gear_name in uuid_to_name.items():
|
||||
try:
|
||||
gear_acts = client.get_gear_activities(guuid, limit=10000)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(gear_acts, list):
|
||||
continue
|
||||
|
||||
for ga in gear_acts:
|
||||
gmt = ga.get("startTimeGMT") or ""
|
||||
if not gmt:
|
||||
continue
|
||||
try:
|
||||
dt = datetime.strptime(gmt, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
|
||||
ts = int(dt.timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
act_id = None
|
||||
for delta in range(0, 61):
|
||||
act_id = ts_to_id.get(ts + delta) or ts_to_id.get(ts - delta)
|
||||
if act_id:
|
||||
break
|
||||
if not act_id:
|
||||
continue
|
||||
|
||||
# Skip if activity already has gear set
|
||||
act_json = user_dir / "activities" / f"{act_id}.json"
|
||||
if act_json.exists():
|
||||
try:
|
||||
if json.loads(act_json.read_text(encoding="utf-8")).get("gear"):
|
||||
continue
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
sidecar = edits_dir / f"{act_id}.md"
|
||||
fm, body = {}, ""
|
||||
if sidecar.exists():
|
||||
try:
|
||||
text = sidecar.read_text(encoding="utf-8")
|
||||
parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE)
|
||||
if len(parts) >= 3:
|
||||
fm = yaml.safe_load(parts[1]) or {}
|
||||
body = parts[2].strip()
|
||||
except Exception:
|
||||
pass
|
||||
if fm.get("gear"):
|
||||
continue
|
||||
|
||||
fm["gear"] = gear_name
|
||||
fm_text = yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip()
|
||||
content = f"---\n{fm_text}\n---\n"
|
||||
if body:
|
||||
content += f"\n{body}\n"
|
||||
sidecar.write_text(content, encoding="utf-8")
|
||||
with contextlib.suppress(Exception):
|
||||
merge_one(user_dir, act_id)
|
||||
activities_updated += 1
|
||||
|
||||
return {"gear_added": gear_added, "activities_updated": activities_updated}
|
||||
|
||||
@@ -74,6 +74,15 @@ def ingest_parsed(
|
||||
pass
|
||||
write_athlete_json(list(summaries.values()), data_dir, athlete_config)
|
||||
|
||||
# Detect segment efforts for this activity (non-fatal if it fails).
|
||||
try:
|
||||
from bincio.segments.detect import track_from_parsed, detect_all
|
||||
track = track_from_parsed(parsed, activity_id)
|
||||
if track is not None:
|
||||
detect_all(track, data_dir.name, data_dir.parent)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return activity_id
|
||||
|
||||
|
||||
@@ -91,18 +100,22 @@ def strava_sync_iter(
|
||||
- ``"done"`` — final summary; keys: imported, skipped, error_count, errors
|
||||
- ``"error"`` — fatal error before processing started; key: message
|
||||
"""
|
||||
import contextlib
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from bincio.extract.strava_api import (
|
||||
StravaError,
|
||||
ensure_fresh,
|
||||
fetch_activities,
|
||||
fetch_gear,
|
||||
fetch_streams,
|
||||
save_token,
|
||||
strava_meta_to_partial,
|
||||
strava_to_parsed,
|
||||
)
|
||||
from bincio.extract.writer import make_activity_id
|
||||
from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save
|
||||
|
||||
if not client_id or not client_secret:
|
||||
yield {"type": "error", "message": "Strava not configured"}
|
||||
@@ -128,6 +141,35 @@ def strava_sync_iter(
|
||||
skipped = 0
|
||||
errors: list[str] = []
|
||||
|
||||
# Cache: strava gear_id → gear name (avoid duplicate API calls within one sync)
|
||||
_gear_name_cache: dict[str, str] = {}
|
||||
|
||||
def _resolve_gear(gear_id: str) -> str:
|
||||
"""Return gear name for a Strava gear_id, adding to registry if new."""
|
||||
if gear_id in _gear_name_cache:
|
||||
return _gear_name_cache[gear_id]
|
||||
# Check registry first
|
||||
registry = _gear_load(data_dir)
|
||||
existing = next((g for g in registry if g.get("strava_id") == gear_id), None)
|
||||
if existing:
|
||||
name = existing["name"]
|
||||
_gear_name_cache[gear_id] = name
|
||||
return name
|
||||
# Fetch from Strava
|
||||
details = fetch_gear(token["access_token"], gear_id)
|
||||
name = details.get("name") or ""
|
||||
if not name:
|
||||
_gear_name_cache[gear_id] = ""
|
||||
return ""
|
||||
# Strava gear IDs: "b" prefix = bike, "g" prefix = shoes
|
||||
gear_type = "shoes" if gear_id.startswith("g") else "bike"
|
||||
# Add to registry
|
||||
new_item: dict = {"id": str(uuid.uuid4()), "name": name, "type": gear_type, "retired": False, "strava_id": gear_id}
|
||||
registry.append(new_item)
|
||||
_gear_save(data_dir, registry)
|
||||
_gear_name_cache[gear_id] = name
|
||||
return name
|
||||
|
||||
for n, meta in enumerate(activities, 1):
|
||||
name = meta.get("name", "Untitled")
|
||||
try:
|
||||
@@ -137,6 +179,11 @@ def strava_sync_iter(
|
||||
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
|
||||
continue
|
||||
streams = fetch_streams(token["access_token"], meta["id"])
|
||||
# Resolve gear name before converting
|
||||
gear_id = meta.get("gear_id") or ""
|
||||
if gear_id:
|
||||
with contextlib.suppress(Exception):
|
||||
meta["_gear_name"] = _resolve_gear(gear_id)
|
||||
if originals_dir is not None:
|
||||
orig_path = originals_dir / f"{activity_id}.json"
|
||||
orig_path.write_text(
|
||||
|
||||
+268
-36
@@ -14,6 +14,8 @@ from bincio.extract.models import DataPoint, ParsedActivity
|
||||
# Standard MMP durations (seconds). Log-spaced so the curve looks good on a log-x axis.
|
||||
MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600]
|
||||
|
||||
_VAM_SPORTS = frozenset({"cycling", "running", "hiking", "walking"})
|
||||
|
||||
# Standard best-effort distances (km) per sport.
|
||||
BEST_EFFORT_DISTANCES: dict[str, list[float]] = {
|
||||
"running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195],
|
||||
@@ -53,6 +55,7 @@ class ComputedMetrics:
|
||||
max_hr_bpm: Optional[int]
|
||||
avg_cadence_rpm: Optional[int]
|
||||
avg_power_w: Optional[int]
|
||||
np_power_w: Optional[int]
|
||||
max_power_w: Optional[int]
|
||||
bbox: Optional[tuple[float, float, float, float]] # min_lon, min_lat, max_lon, max_lat
|
||||
start_latlng: Optional[tuple[float, float]]
|
||||
@@ -61,6 +64,8 @@ class ComputedMetrics:
|
||||
# [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets
|
||||
best_efforts: Optional[list[list[float]]]
|
||||
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
|
||||
climbing_vam_mh: Optional[int] # average VAM on ascending segments only (m/h)
|
||||
climbing_time_s: Optional[int] # total ascending seconds used to compute VAM
|
||||
|
||||
|
||||
def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
@@ -70,15 +75,19 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
|
||||
duration_s = _duration(pts)
|
||||
distance_m, moving_time_s, avg_speed_kmh, max_speed_kmh = _gps_stats(pts)
|
||||
gain, loss = _elevation(pts, activity.altitude_source)
|
||||
inferred_source = "strava_export" if activity.strava_id else ""
|
||||
gain, loss = _elevation(pts, activity.altitude_source, inferred_source)
|
||||
avg_hr, max_hr = _hr_stats(pts)
|
||||
avg_cad = _avg_nonnull([p.cadence_rpm for p in pts])
|
||||
avg_pow = _avg_nonnull([p.power_w for p in pts])
|
||||
np_pow = _np_power(pts, activity.started_at)
|
||||
max_pow = _max_nonnull([p.power_w for p in pts])
|
||||
bbox = _bbox(pts)
|
||||
start_ll, end_ll = _endpoints(pts)
|
||||
mmp = compute_mmp(pts, activity.started_at)
|
||||
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
|
||||
_vam = compute_vam(pts, activity.started_at, activity.sport)
|
||||
climbing_vam_mh, climbing_time_s = _vam if _vam else (None, None)
|
||||
|
||||
return ComputedMetrics(
|
||||
distance_m=distance_m,
|
||||
@@ -92,6 +101,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
max_hr_bpm=max_hr,
|
||||
avg_cadence_rpm=avg_cad,
|
||||
avg_power_w=avg_pow,
|
||||
np_power_w=np_pow,
|
||||
max_power_w=max_pow,
|
||||
bbox=bbox,
|
||||
start_latlng=start_ll,
|
||||
@@ -99,6 +109,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
mmp=mmp,
|
||||
best_efforts=best_efforts,
|
||||
best_climb_m=best_climb_m,
|
||||
climbing_vam_mh=climbing_vam_mh,
|
||||
climbing_time_s=climbing_time_s,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,6 +170,97 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis
|
||||
return results if results else None
|
||||
|
||||
|
||||
# ── VAM (Velocità Ascensionale Media) ────────────────────────────────────────
|
||||
|
||||
def _rolling_mean_ele(data: list[float], win: int) -> list[float]:
|
||||
"""O(n) rolling mean via prefix sums."""
|
||||
n = len(data)
|
||||
prefix = [0.0] * (n + 1)
|
||||
for i, v in enumerate(data):
|
||||
prefix[i + 1] = prefix[i] + v
|
||||
half = win // 2
|
||||
result = []
|
||||
for i in range(n):
|
||||
lo = max(0, i - half)
|
||||
hi = min(n, i + half + 1)
|
||||
result.append((prefix[hi] - prefix[lo]) / (hi - lo))
|
||||
return result
|
||||
|
||||
|
||||
def _vam_from_ele_1hz(ele_1hz: list[float]) -> Optional[tuple[int, int]]:
|
||||
"""Climbing VAM from a dense 1 Hz elevation array.
|
||||
|
||||
Accumulates gain and time only on ascending seconds, identified by a 30 s
|
||||
forward-lookahead on the smoothed elevation signal.
|
||||
Returns (climbing_vam_mh, climbing_time_s), or None when there is too little
|
||||
climbing data.
|
||||
"""
|
||||
n = len(ele_1hz)
|
||||
if n < 60:
|
||||
return None
|
||||
ele_smooth = _rolling_mean_ele(ele_1hz, 30)
|
||||
|
||||
climbing_gain = 0.0
|
||||
climbing_time = 0
|
||||
for i in range(n - 1):
|
||||
look = min(i + 30, n - 1)
|
||||
if ele_smooth[look] - ele_smooth[i] >= 2.0:
|
||||
inst = ele_smooth[i + 1] - ele_smooth[i]
|
||||
if inst > 0:
|
||||
climbing_gain += inst
|
||||
climbing_time += 1
|
||||
|
||||
if climbing_time >= 60 and climbing_gain >= 5.0:
|
||||
return round(climbing_gain * 3600.0 / climbing_time), climbing_time
|
||||
return None
|
||||
|
||||
|
||||
def _build_ele_1hz(sparse: dict[int, Optional[float]]) -> Optional[list[float]]:
|
||||
"""Build a dense 1 Hz elevation array from a {t: ele} sparse dict, forward-filling gaps."""
|
||||
if not sparse:
|
||||
return None
|
||||
t_min = min(sparse)
|
||||
t_max = max(sparse)
|
||||
if t_max - t_min > 7 * 24 * 3600:
|
||||
return None
|
||||
ele_raw: list[Optional[float]] = []
|
||||
last_known: Optional[float] = None
|
||||
for t in range(t_min, t_max + 1):
|
||||
v = sparse.get(t)
|
||||
if v is not None:
|
||||
last_known = v
|
||||
ele_raw.append(last_known)
|
||||
if sum(1 for e in ele_raw if e is not None) < 60:
|
||||
return None
|
||||
first_valid = next((e for e in ele_raw if e is not None), None)
|
||||
if first_valid is None:
|
||||
return None
|
||||
return [e if e is not None else first_valid for e in ele_raw]
|
||||
|
||||
|
||||
def compute_vam(pts: list[DataPoint], started_at: datetime, sport: str) -> Optional[tuple[int, int]]:
|
||||
"""Compute average climbing VAM (m/h) from DataPoints.
|
||||
|
||||
Only computed for cycling, running, hiking, walking.
|
||||
Returns (climbing_vam_mh, climbing_time_s), or None when there is insufficient
|
||||
climbing data.
|
||||
"""
|
||||
if sport not in _VAM_SPORTS:
|
||||
return None
|
||||
sparse: dict[int, Optional[float]] = {}
|
||||
last_t = -1
|
||||
for p in pts:
|
||||
t = int((p.timestamp - started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
sparse[t] = p.elevation_m
|
||||
last_t = t
|
||||
ele_1hz = _build_ele_1hz(sparse)
|
||||
if ele_1hz is None:
|
||||
return None
|
||||
return _vam_from_ele_1hz(ele_1hz)
|
||||
|
||||
|
||||
# ── best efforts & best climb ─────────────────────────────────────────────────
|
||||
|
||||
def compute_best_efforts(
|
||||
@@ -178,16 +281,32 @@ def compute_best_efforts(
|
||||
# Build dense 1 Hz speed (km/h) and elevation (m) arrays with gap zero-filling.
|
||||
# Zero-filling speed gaps (0 km/h) prevents best-effort windows from spanning
|
||||
# recording pauses and producing artificially fast times.
|
||||
# When the device didn't record speed (common in older FIT files), fall back to
|
||||
# GPS-derived speed: spread the haversine segment speed evenly across the interval
|
||||
# so the sliding window accumulates the correct distance.
|
||||
sparse_speed: dict[int, float] = {}
|
||||
sparse_ele: dict[int, Optional[float]] = {}
|
||||
last_t = -1
|
||||
_prev: Optional[DataPoint] = None
|
||||
for p in pts:
|
||||
t = int((p.timestamp - started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
last_t = t
|
||||
sparse_speed[t] = p.speed_kmh if p.speed_kmh is not None else 0.0
|
||||
sparse_ele[t] = p.elevation_m
|
||||
if p.speed_kmh is not None:
|
||||
sparse_speed[t] = p.speed_kmh
|
||||
elif (_prev is not None
|
||||
and _prev.lat is not None and _prev.lon is not None
|
||||
and p.lat is not None and p.lon is not None):
|
||||
dt_s = t - last_t
|
||||
seg_m = _haversine_m(_prev.lat, _prev.lon, p.lat, p.lon)
|
||||
seg_kmh = (seg_m / dt_s) * 3.6
|
||||
for slot in range(last_t, t):
|
||||
sparse_speed[slot] = seg_kmh
|
||||
else:
|
||||
sparse_speed[t] = 0.0
|
||||
last_t = t
|
||||
_prev = p
|
||||
|
||||
if not sparse_speed:
|
||||
return None, None
|
||||
@@ -212,7 +331,23 @@ def compute_best_efforts(
|
||||
|
||||
best_climb_m: Optional[float] = None
|
||||
if sport == "cycling":
|
||||
best_climb_m = _best_climb(ele_1hz)
|
||||
# Use cumulative device distance as the x-axis so recording pauses
|
||||
# (where distance doesn't increase) don't create gaps that reset the window.
|
||||
# Fall back to elapsed-time ordering when no device distance is recorded.
|
||||
dist_ele = sorted(
|
||||
(p.distance_m, p.elevation_m)
|
||||
for p in pts
|
||||
if p.distance_m is not None and p.elevation_m is not None
|
||||
)
|
||||
if not dist_ele:
|
||||
dist_ele = sorted(
|
||||
(int((p.timestamp - started_at).total_seconds()), p.elevation_m)
|
||||
for p in pts
|
||||
if p.elevation_m is not None
|
||||
and int((p.timestamp - started_at).total_seconds()) >= 0
|
||||
)
|
||||
if len(dist_ele) >= 2:
|
||||
best_climb_m = _best_climb(dist_ele)
|
||||
|
||||
return best_efforts, best_climb_m
|
||||
|
||||
@@ -242,32 +377,26 @@ def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Opti
|
||||
return best_s
|
||||
|
||||
|
||||
def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
|
||||
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas).
|
||||
def _best_climb(pts_sorted: list[tuple[float, float]]) -> Optional[float]:
|
||||
"""Maximum net elevation gain over any contiguous uphill window (Kadane's).
|
||||
|
||||
None samples are treated as breaks between segments — the Kadane window is
|
||||
reset to 0 at each gap so non-contiguous elevation data is never joined.
|
||||
Returns None if fewer than two non-None samples exist.
|
||||
pts_sorted: list of (x, elevation_m) pairs sorted by x, where x is
|
||||
cumulative distance (m) or elapsed time (s). Using cumulative distance
|
||||
means recording pauses (x doesn't increase while stopped) don't create
|
||||
gaps that falsely reset the climbing window.
|
||||
"""
|
||||
non_null = sum(1 for e in ele_1hz if e is not None)
|
||||
if non_null < 2:
|
||||
if len(pts_sorted) < 2:
|
||||
return None
|
||||
|
||||
max_gain = 0.0
|
||||
current = 0.0
|
||||
prev: Optional[float] = None
|
||||
prev_e = pts_sorted[0][1]
|
||||
|
||||
for e in ele_1hz:
|
||||
if e is None:
|
||||
# Gap — reset window so we don't bridge the discontinuity
|
||||
current = 0.0
|
||||
prev = None
|
||||
continue
|
||||
if prev is not None:
|
||||
current = max(0.0, current + (e - prev))
|
||||
if current > max_gain:
|
||||
max_gain = current
|
||||
prev = e
|
||||
for _, e in pts_sorted[1:]:
|
||||
current = max(0.0, current + (e - prev_e))
|
||||
if current > max_gain:
|
||||
max_gain = current
|
||||
prev_e = e
|
||||
|
||||
return round(max_gain, 1) if max_gain > 0 else None
|
||||
|
||||
@@ -347,33 +476,91 @@ def _duration(pts: list[DataPoint]) -> Optional[int]:
|
||||
return int((pts[-1].timestamp - pts[0].timestamp).total_seconds())
|
||||
|
||||
|
||||
# Hysteresis thresholds per altitude source.
|
||||
# Only commit a new elevation when it differs from the last committed value by
|
||||
# at least this amount, filtering out GPS noise and barometric quantization steps.
|
||||
_ELEVATION_THRESHOLD: dict[str, float] = {
|
||||
"barometric": 5.0, # barometric altimeter: smaller steps are real
|
||||
"gps": 10.0, # GPS altitude: noisier, needs wider dead-band
|
||||
"unknown": 10.0, # treat unknown as GPS to be conservative
|
||||
}
|
||||
def elevation_params(altitude_source: str, source: str = "") -> tuple[int, float]:
|
||||
"""Return (ma_window_s, threshold_m) for elevation gain/loss computation.
|
||||
|
||||
Tuned on 37 activities cross-referenced against Strava-reported elevation:
|
||||
|
||||
strava_export — elevation already pre-processed by Strava (smooth 1 m
|
||||
quantisation, 0 steps > 5 m). Light 5 s MA + 1.0 m
|
||||
threshold gives avg −2.8 %, std 4.8 %, 34/37 within ±10 %.
|
||||
|
||||
barometric — raw barometric altimeter from a FIT file. No smoothing
|
||||
needed; 1.5 m threshold gives ~0 % error on available data.
|
||||
|
||||
gps / unknown — raw GPS or unidentified non-Strava source. Light 5 s MA
|
||||
+ 1.5–2.0 m threshold suppresses GPS jitter while keeping
|
||||
real terrain changes.
|
||||
"""
|
||||
if source == "strava_export":
|
||||
return (5, 1.0)
|
||||
if altitude_source == "barometric":
|
||||
return (0, 1.5)
|
||||
if altitude_source == "gps":
|
||||
return (5, 2.0)
|
||||
return (5, 1.5) # unknown non-strava: conservative middle ground
|
||||
|
||||
|
||||
def _ele_moving_average(values: list[float], window: int) -> list[float]:
|
||||
if window <= 1:
|
||||
return list(values)
|
||||
half = window // 2
|
||||
n = len(values)
|
||||
cumsum = [0.0] * (n + 1)
|
||||
for i, v in enumerate(values):
|
||||
cumsum[i + 1] = cumsum[i] + v
|
||||
return [
|
||||
(cumsum[min(n, i + half + 1)] - cumsum[max(0, i - half)])
|
||||
/ (min(n, i + half + 1) - max(0, i - half))
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _elevation(
|
||||
pts: list[DataPoint],
|
||||
altitude_source: str = "unknown",
|
||||
source: str = "",
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""Hysteresis-based elevation accumulation.
|
||||
|
||||
Only commits a new elevation when it differs from the last committed value
|
||||
by at least the source-specific threshold, filtering GPS jitter and
|
||||
barometric quantization noise that would otherwise inflate the gain figure.
|
||||
Applies a short moving-average pre-smoothing then commits a new elevation
|
||||
level only when it differs from the last committed value by at least the
|
||||
source-specific threshold. Parameters are chosen per data source via
|
||||
:func:`elevation_params`.
|
||||
"""
|
||||
elevations = [p.elevation_m for p in pts if p.elevation_m is not None]
|
||||
if len(elevations) < 2:
|
||||
return None, None
|
||||
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
|
||||
ma_window, threshold = elevation_params(altitude_source, source)
|
||||
|
||||
# Some devices (e.g. Apple Watch) record exactly 0.0 for the initial samples
|
||||
# while waiting for barometric/GPS lock, then jump to the real altitude.
|
||||
# Only activate when there are at least 2 consecutive near-zero leading
|
||||
# values — a single 0.0 is a legitimate sea-level starting point.
|
||||
start = 0
|
||||
if abs(elevations[0]) < 0.5:
|
||||
n_leading = 0
|
||||
for e in elevations:
|
||||
if abs(e) < 0.5:
|
||||
n_leading += 1
|
||||
else:
|
||||
break
|
||||
if n_leading > 1:
|
||||
for i, e in enumerate(elevations):
|
||||
if abs(e) > threshold:
|
||||
start = i
|
||||
break
|
||||
|
||||
elevations = _ele_moving_average(elevations[start:], ma_window)
|
||||
|
||||
gain = loss = 0.0
|
||||
committed = elevations[0]
|
||||
for e in elevations[1:]:
|
||||
# Skip near-zero values that appear mid-recording while we are at a
|
||||
# significant elevation — these are sensor dropouts (device lost GPS/
|
||||
# barometric lock), not genuine sea-level crossings.
|
||||
if abs(e) < 1.0 and abs(committed) > threshold:
|
||||
continue
|
||||
diff = e - committed
|
||||
if abs(diff) >= threshold:
|
||||
if diff > 0:
|
||||
@@ -401,6 +588,50 @@ def _max_nonnull(values: list) -> Optional[int]:
|
||||
return max(v) if v else None
|
||||
|
||||
|
||||
def _np_power(pts: list[DataPoint], started_at: datetime) -> Optional[int]:
|
||||
"""Normalized power (Coggan method): 30 s rolling average → 4th power → mean → 4th root.
|
||||
|
||||
Uses a dense 1 Hz series (gaps zero-filled) identical to the MMP pipeline.
|
||||
Returns None when the activity has no power data or is shorter than 30 s.
|
||||
"""
|
||||
sparse: dict[int, int] = {}
|
||||
last_t = -1
|
||||
for p in pts:
|
||||
t = int((p.timestamp - started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
last_t = t
|
||||
if p.power_w is not None:
|
||||
sparse[t] = p.power_w
|
||||
|
||||
if len(sparse) < 2:
|
||||
return None
|
||||
|
||||
t_min, t_max = min(sparse), max(sparse)
|
||||
if t_max - t_min > 7 * 24 * 3600:
|
||||
return None
|
||||
|
||||
power_1hz = [sparse.get(t, 0) for t in range(t_min, t_max + 1)]
|
||||
n = len(power_1hz)
|
||||
win = 30
|
||||
if n < win:
|
||||
return None
|
||||
|
||||
# 30 s centred rolling mean, then raise to 4th power
|
||||
half = win // 2
|
||||
total = sum(power_1hz[:win])
|
||||
fourth_powers: list[float] = []
|
||||
for i in range(half, n - half):
|
||||
avg = total / win
|
||||
fourth_powers.append(avg ** 4)
|
||||
if i + half + 1 < n:
|
||||
total += power_1hz[i + half + 1] - power_1hz[i - half]
|
||||
|
||||
if not fourth_powers:
|
||||
return None
|
||||
return int(round((sum(fourth_powers) / len(fourth_powers)) ** 0.25))
|
||||
|
||||
|
||||
def _bbox(pts: list[DataPoint]) -> Optional[tuple[float, float, float, float]]:
|
||||
lats = [p.lat for p in pts if p.lat is not None]
|
||||
lons = [p.lon for p in pts if p.lon is not None]
|
||||
@@ -424,7 +655,8 @@ def _empty() -> ComputedMetrics:
|
||||
elevation_gain_m=None, elevation_loss_m=None,
|
||||
avg_speed_kmh=None, max_speed_kmh=None,
|
||||
avg_hr_bpm=None, max_hr_bpm=None,
|
||||
avg_cadence_rpm=None, avg_power_w=None, max_power_w=None,
|
||||
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
|
||||
bbox=None, start_latlng=None, end_latlng=None,
|
||||
mmp=None, best_efforts=None, best_climb_m=None,
|
||||
climbing_vam_mh=None, climbing_time_s=None,
|
||||
)
|
||||
|
||||
@@ -5,9 +5,27 @@ It gets fed into metrics computation and the BAS JSON writer.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Any timestamp before this is almost certainly an uninitialised sensor value
|
||||
# (epoch 0, FIT "no-data" sentinel, RTC not yet synced, etc.).
|
||||
_MIN_TIMESTAMP = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def strip_bogus_leading_points(points: list["DataPoint"]) -> list["DataPoint"]:
|
||||
"""Drop leading points whose timestamp predates the year 2000.
|
||||
|
||||
FIT files occasionally emit a record with timestamp=0 (or another
|
||||
pre-2000 value) as an uninitialised sentinel before the real data
|
||||
begins. Keeping such a point as points[0] produces a 1970 start
|
||||
time and an absurdly large duration_s.
|
||||
"""
|
||||
i = 0
|
||||
while i < len(points) and points[i].timestamp < _MIN_TIMESTAMP:
|
||||
i += 1
|
||||
return points[i:]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataPoint:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
import fitdecode
|
||||
|
||||
from bincio.extract.models import DataPoint, LapData, ParsedActivity
|
||||
from bincio.extract.models import DataPoint, LapData, ParsedActivity, strip_bogus_leading_points
|
||||
from bincio.extract.sport import normalise_sport
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ class FitParser:
|
||||
)
|
||||
)
|
||||
|
||||
points = strip_bogus_leading_points(points)
|
||||
if not points:
|
||||
raise ValueError(f"No record messages found in {path.name}")
|
||||
|
||||
@@ -146,11 +147,13 @@ def _normalise_sub_sport(value: Any) -> str | None:
|
||||
mapping = {
|
||||
"generic": None, # FIT default — unspecified
|
||||
"virtual_activity": "indoor",
|
||||
"virtual": "indoor",
|
||||
"road": "road",
|
||||
"mountain": "mountain",
|
||||
"gravel_cycling": "gravel",
|
||||
"cyclocross": "gravel",
|
||||
"indoor_cycling": "indoor",
|
||||
"treadmill": "indoor",
|
||||
"trail": "trail",
|
||||
"track": "track",
|
||||
"cross_country_skiing": "nordic",
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
import gpxpy
|
||||
import gpxpy.gpx
|
||||
|
||||
from bincio.extract.models import DataPoint, ParsedActivity
|
||||
from bincio.extract.models import DataPoint, ParsedActivity, strip_bogus_leading_points
|
||||
from bincio.extract.parsers.base import BaseParser
|
||||
from bincio.extract.sport import normalise_sport, normalise_sub_sport
|
||||
|
||||
@@ -38,6 +38,7 @@ class GpxParser(BaseParser):
|
||||
_apply_extensions(pt, dp)
|
||||
points.append(dp)
|
||||
|
||||
points = strip_bogus_leading_points(points)
|
||||
if not points:
|
||||
raise ValueError(f"No trackpoints found in {path.name}")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from bincio.extract.models import DataPoint, ParsedActivity
|
||||
from bincio.extract.models import DataPoint, ParsedActivity, strip_bogus_leading_points
|
||||
from bincio.extract.sport import normalise_sport, normalise_sub_sport
|
||||
|
||||
_NS_HTTP = {
|
||||
@@ -73,6 +73,7 @@ class TcxParser:
|
||||
)
|
||||
points.append(dp)
|
||||
|
||||
points = strip_bogus_leading_points(points)
|
||||
if not points:
|
||||
raise ValueError(f"No trackpoints found in {path.name}")
|
||||
|
||||
|
||||
@@ -99,6 +99,14 @@ _SUB_SPORT_MAPPING: dict[str, str] = {
|
||||
|
||||
BAS_SPORTS = {"cycling", "running", "hiking", "walking", "swimming", "skiing", "other"}
|
||||
|
||||
# Valid sub_sport values per sport, in display order.
|
||||
SUB_SPORTS: dict[str, list[str]] = {
|
||||
"cycling": ["road", "mountain", "gravel", "indoor"],
|
||||
"running": ["trail", "track", "indoor"],
|
||||
"swimming": ["open_water", "pool"],
|
||||
"skiing": ["nordic", "alpine"],
|
||||
}
|
||||
|
||||
|
||||
def _normalise_key(raw: object) -> str:
|
||||
key = str(raw).strip()
|
||||
|
||||
@@ -150,6 +150,15 @@ def fetch_streams(access_token: str, activity_id: int) -> dict:
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def fetch_gear(access_token: str, gear_id: str) -> dict:
|
||||
"""Fetch gear details for a single gear item. Returns {} on error."""
|
||||
try:
|
||||
result = _api_get(f"{_API_BASE}/gear/{gear_id}", access_token)
|
||||
return result if isinstance(result, dict) else {}
|
||||
except StravaError:
|
||||
return {}
|
||||
|
||||
|
||||
# ── Model conversion ───────────────────────────────────────────────────────────
|
||||
|
||||
def strava_meta_to_partial(meta: dict) -> ParsedActivity:
|
||||
@@ -215,4 +224,5 @@ def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
|
||||
description=meta.get("description") or None,
|
||||
strava_id=str(meta["id"]),
|
||||
privacy="unlisted" if is_private else "public",
|
||||
gear=meta.get("_gear_name") or None,
|
||||
)
|
||||
|
||||
@@ -115,6 +115,8 @@ def strava_zip_iter(
|
||||
parsed.description = meta_row["Activity Description"].strip()
|
||||
if not parsed.strava_id and meta_row.get("Activity ID"):
|
||||
parsed.strava_id = meta_row["Activity ID"].strip()
|
||||
if not parsed.gear and meta_row.get("Gear"):
|
||||
parsed.gear = meta_row["Gear"].strip()
|
||||
|
||||
if originals_dir is not None:
|
||||
import shutil
|
||||
|
||||
@@ -2,11 +2,104 @@
|
||||
the BAS timeseries object (parallel arrays)."""
|
||||
|
||||
from datetime import datetime
|
||||
from math import atan2, cos, radians, sin, sqrt
|
||||
from typing import Optional
|
||||
|
||||
from bincio.extract.models import DataPoint
|
||||
|
||||
|
||||
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Great-circle distance in metres between two GPS points."""
|
||||
dlat = radians(lat2 - lat1)
|
||||
dlon = radians(lon2 - lon1)
|
||||
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
|
||||
return 2 * 6_371_000.0 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
|
||||
_SPATIAL_RESOLUTION_M = 10.0
|
||||
|
||||
|
||||
def _spatial_downsample(
|
||||
sampled: list[DataPoint],
|
||||
resolution_m: float = _SPATIAL_RESOLUTION_M,
|
||||
) -> list[DataPoint]:
|
||||
"""Keep one sample per `resolution_m` of cumulative distance traveled.
|
||||
|
||||
Distance source priority:
|
||||
1. GPS haversine (lat/lon present on both consecutive points)
|
||||
2. speed_kmh × Δt (fallback when GPS absent or gapped)
|
||||
If neither source is available (indoor, no speed data), returns `sampled`
|
||||
unchanged. Always retains the first and last points.
|
||||
"""
|
||||
if len(sampled) < 2:
|
||||
return sampled
|
||||
|
||||
has_gps = any(p.lat is not None and p.lon is not None for p in sampled)
|
||||
has_speed = any(p.speed_kmh is not None for p in sampled)
|
||||
if not has_gps and not has_speed:
|
||||
return sampled
|
||||
|
||||
result: list[DataPoint] = [sampled[0]]
|
||||
cum_dist = 0.0
|
||||
last_kept = 0.0
|
||||
prev_speed = 0.0
|
||||
|
||||
for i in range(1, len(sampled)):
|
||||
prev, cur = sampled[i - 1], sampled[i]
|
||||
dt = (cur.timestamp - prev.timestamp).total_seconds()
|
||||
|
||||
if (has_gps
|
||||
and prev.lat is not None and prev.lon is not None
|
||||
and cur.lat is not None and cur.lon is not None):
|
||||
dist_m = _haversine_m(prev.lat, prev.lon, cur.lat, cur.lon)
|
||||
else:
|
||||
spd = cur.speed_kmh if cur.speed_kmh is not None else prev_speed
|
||||
dist_m = (spd / 3.6) * max(dt, 0)
|
||||
|
||||
if cur.speed_kmh is not None:
|
||||
prev_speed = cur.speed_kmh
|
||||
|
||||
cum_dist += dist_m
|
||||
if cum_dist - last_kept >= resolution_m:
|
||||
result.append(cur)
|
||||
last_kept = cum_dist
|
||||
|
||||
if result[-1] is not sampled[-1]:
|
||||
result.append(sampled[-1])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _gps_speed_kmh(
|
||||
lat_vals: list[Optional[float]],
|
||||
lon_vals: list[Optional[float]],
|
||||
ts_vals: list[int],
|
||||
) -> list[Optional[float]]:
|
||||
"""Compute speed (km/h) from consecutive GPS coordinates via haversine.
|
||||
Applies a 5-point centred moving-average to reduce GPS noise.
|
||||
"""
|
||||
n = len(ts_vals)
|
||||
raw: list[Optional[float]] = [None] * n
|
||||
for i in range(1, n):
|
||||
la0, lo0 = lat_vals[i - 1], lon_vals[i - 1]
|
||||
la1, lo1 = lat_vals[i], lon_vals[i]
|
||||
dt = ts_vals[i] - ts_vals[i - 1]
|
||||
if la0 is None or lo0 is None or la1 is None or lo1 is None or dt <= 0:
|
||||
continue
|
||||
d_km = _haversine_m(la0, lo0, la1, lo1) / 1000.0
|
||||
raw[i] = d_km / dt * 3600.0
|
||||
|
||||
# 5-point centred moving average (skip None anchors)
|
||||
half = 2
|
||||
smoothed: list[Optional[float]] = [None] * n
|
||||
for i in range(n):
|
||||
vals = [raw[j] for j in range(max(0, i - half), min(n, i + half + 1)) if raw[j] is not None]
|
||||
if vals:
|
||||
smoothed[i] = round(sum(vals) / len(vals), 2)
|
||||
|
||||
return smoothed
|
||||
|
||||
|
||||
def build_timeseries(
|
||||
points: list[DataPoint],
|
||||
started_at: datetime,
|
||||
@@ -35,11 +128,18 @@ def build_timeseries(
|
||||
sampled.append(p)
|
||||
last_t = t
|
||||
|
||||
sampled = _spatial_downsample(sampled)
|
||||
|
||||
ts_vals = [int((p.timestamp - started_at).total_seconds()) for p in sampled]
|
||||
lat_vals = [round(p.lat, 7) if p.lat is not None else None for p in sampled] if include_gps else None
|
||||
lon_vals = [round(p.lon, 7) if p.lon is not None else None for p in sampled] if include_gps else None
|
||||
ele_vals = [round(p.elevation_m, 1) if p.elevation_m is not None else None for p in sampled]
|
||||
spd_vals = [round(p.speed_kmh, 2) if p.speed_kmh is not None else None for p in sampled]
|
||||
|
||||
# Derive speed from GPS when the device didn't record per-second speed.
|
||||
if include_gps and lat_vals and lon_vals and all(v is None for v in spd_vals):
|
||||
spd_vals = _gps_speed_kmh(lat_vals, lon_vals, ts_vals)
|
||||
|
||||
hr_vals = [p.hr_bpm for p in sampled]
|
||||
cad_vals = [p.cadence_rpm for p in sampled]
|
||||
pwr_vals = [p.power_w for p in sampled]
|
||||
|
||||
@@ -10,6 +10,18 @@ from bincio.extract.models import LapData, ParsedActivity
|
||||
from bincio.extract.simplify import build_geojson, preview_coords
|
||||
from bincio.extract.timeseries import build_timeseries
|
||||
|
||||
# Titles that reliably identify indoor/virtual activities regardless of sub_sport metadata.
|
||||
# Strava imports from Zwift and FTP-builder platforms lose sub_sport on export.
|
||||
_INDOOR_TITLE_RE = re.compile(
|
||||
r'\b(zwift|ftp[\s\-]builder|turbo[\s\-]?trainer|rodillo)\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _infer_indoor_title(title: str) -> bool:
|
||||
"""Return True if the title reliably identifies an indoor/virtual activity."""
|
||||
return bool(_INDOOR_TITLE_RE.search(title))
|
||||
|
||||
|
||||
def make_activity_id(activity: ParsedActivity) -> str:
|
||||
"""Generate a BAS activity ID from started_at + optional title slug.
|
||||
@@ -79,6 +91,7 @@ def write_activity(
|
||||
"max_hr_bpm": metrics.max_hr_bpm,
|
||||
"avg_cadence_rpm": metrics.avg_cadence_rpm,
|
||||
"avg_power_w": metrics.avg_power_w,
|
||||
"np_power_w": metrics.np_power_w,
|
||||
"max_power_w": metrics.max_power_w,
|
||||
"gear": activity.gear,
|
||||
"device": activity.device,
|
||||
@@ -88,6 +101,8 @@ def write_activity(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||
"climbing_time_s": metrics.climbing_time_s,
|
||||
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
||||
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
||||
"source": source,
|
||||
@@ -244,6 +259,9 @@ def build_summary(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||
"climbing_time_s": metrics.climbing_time_s,
|
||||
"gear": activity.gear,
|
||||
"source": _infer_source(activity),
|
||||
"privacy": privacy,
|
||||
"detail_url": f"activities/{activity_id}.json",
|
||||
@@ -276,9 +294,16 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
best[d] = w
|
||||
return [[d, w] for d, w in sorted(best.items())]
|
||||
|
||||
all_mmps = [s["mmp"] for s in summaries if s.get("mmp")]
|
||||
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_365]
|
||||
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and s["started_at"] >= cutoff_90]
|
||||
_INDOOR_SUB_SPORTS = {"indoor", "treadmill", "virtual"}
|
||||
|
||||
def _is_outdoor(s: dict) -> bool:
|
||||
if s.get("sub_sport") in _INDOOR_SUB_SPORTS:
|
||||
return False
|
||||
return not _infer_indoor_title(s.get("title") or "")
|
||||
|
||||
all_mmps = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s)]
|
||||
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_365]
|
||||
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_90]
|
||||
|
||||
# ── Personal records aggregation ──────────────────────────────────────────
|
||||
# records[sport][distance_km] = {time_s, activity_id, started_at, title}
|
||||
@@ -289,6 +314,8 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
best_climb: list[dict] = [] # top 10 best climbs for cycling
|
||||
|
||||
for s in summaries:
|
||||
if not _is_outdoor(s):
|
||||
continue
|
||||
sport = s.get("sport", "other")
|
||||
act_id = s.get("id", "")
|
||||
started = s.get("started_at", "")
|
||||
@@ -355,7 +382,9 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
**athlete_config,
|
||||
}
|
||||
(output_dir / "athlete.json").write_text(
|
||||
json.dumps(athlete, indent=2, ensure_ascii=False)
|
||||
json.dumps(athlete, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -343,9 +343,24 @@ def sync(
|
||||
owner = index_data.get("owner", {})
|
||||
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
|
||||
|
||||
# ── build timestamp-prefix index of existing activities ──────────────────
|
||||
# Maps "YYYY-MM-DDTHHMMSSZ" → first matching activity filename (stem).
|
||||
# Used to detect when a FIT-file upload already covers a Strava activity.
|
||||
acts_dir = output_dir / "activities"
|
||||
existing_ts: set[str] = set()
|
||||
if acts_dir.is_dir():
|
||||
for p in acts_dir.iterdir():
|
||||
if p.suffix == ".json" and not p.name.endswith(".timeseries.json"):
|
||||
stem = p.stem
|
||||
# ID format: YYYY-MM-DDTHHMMSSZ[-optional-slug]
|
||||
z_pos = stem.find("Z")
|
||||
if z_pos != -1:
|
||||
existing_ts.add(stem[: z_pos + 1])
|
||||
|
||||
# ── import loop ───────────────────────────────────────────────────────────
|
||||
errors: list[tuple[str, str]] = []
|
||||
imported = 0
|
||||
skipped_existing = 0
|
||||
|
||||
with Progress(
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
@@ -362,11 +377,20 @@ def sync(
|
||||
try:
|
||||
streams = client.get_streams(act["id"])
|
||||
parsed = _strava_to_parsed(act, streams)
|
||||
|
||||
# Skip if any activity already exists for the same start time
|
||||
ts_part = parsed.started_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ")
|
||||
if ts_part in existing_ts:
|
||||
imported_ids.add(strava_id)
|
||||
skipped_existing += 1
|
||||
continue
|
||||
|
||||
metrics = compute(parsed)
|
||||
metrics = _patch_from_summary(metrics, act)
|
||||
act_id = make_activity_id(parsed)
|
||||
write_activity(parsed, metrics, output_dir, privacy="public")
|
||||
summaries[act_id] = build_summary(parsed, metrics, act_id, "public")
|
||||
existing_ts.add(ts_part)
|
||||
imported_ids.add(strava_id)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
@@ -384,9 +408,11 @@ def sync(
|
||||
from bincio.render.merge import merge_all
|
||||
merge_all(output_dir)
|
||||
|
||||
skipped_msg = f", skipped [bold]{skipped_existing}[/bold] already covered by local uploads" if skipped_existing else ""
|
||||
console.print(
|
||||
f"\n[green]Done.[/green] "
|
||||
f"Imported [bold]{imported}[/bold] activities, "
|
||||
f"Imported [bold]{imported}[/bold] activities"
|
||||
f"{skipped_msg}, "
|
||||
f"errors [bold]{len(errors)}[/bold]."
|
||||
)
|
||||
if errors:
|
||||
|
||||
+508
-1
@@ -92,6 +92,225 @@ def _merge_edits(data: Path, handle: str | None = None) -> None:
|
||||
console.print("No sidecars found — _merged/ dirs mirror extracted data.")
|
||||
|
||||
|
||||
def _bake_tracks(data: Path, handle: str | None = None) -> None:
|
||||
"""Bake tracks.json for one user or all users."""
|
||||
from bincio.explore import bake_tracks
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
try:
|
||||
n = bake_tracks(user_dir.name, data)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
|
||||
except Exception as exc:
|
||||
console.print(f" [yellow]{user_dir.name}[/yellow]: bake_tracks failed: {exc}")
|
||||
|
||||
|
||||
def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None:
|
||||
"""Rebuild athlete.json for one user or all users.
|
||||
|
||||
Reads raw index.json summaries, applies any sidecar edits in-memory (so
|
||||
overrides like sub_sport: indoor are respected), then calls write_athlete_json.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.writer import write_athlete_json
|
||||
from bincio.render.merge import parse_sidecar, _apply_sidecar_summary
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
|
||||
for user_dir in targets:
|
||||
index_path = user_dir / "index.json"
|
||||
if not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
summaries = index_data.get("activities", [])
|
||||
if not summaries:
|
||||
continue
|
||||
|
||||
# Apply sidecar edits so overrides (e.g. sub_sport: indoor) are visible
|
||||
# to write_athlete_json without stripping best_efforts/best_climb_m.
|
||||
edits_dir = user_dir / "edits"
|
||||
if edits_dir.exists():
|
||||
sidecars: dict[str, dict] = {}
|
||||
for sc_path in edits_dir.glob("*.md"):
|
||||
try:
|
||||
fm, _ = parse_sidecar(sc_path)
|
||||
sidecars[sc_path.stem] = fm
|
||||
except Exception:
|
||||
pass
|
||||
if sidecars:
|
||||
summaries = [
|
||||
_apply_sidecar_summary(s, sidecars[s["id"]])
|
||||
if s.get("id") in sidecars else s
|
||||
for s in summaries
|
||||
]
|
||||
|
||||
athlete_config: dict = {}
|
||||
athlete_path = user_dir / "athlete.json"
|
||||
if athlete_path.exists():
|
||||
try:
|
||||
existing = json.loads(athlete_path.read_text(encoding="utf-8"))
|
||||
athlete_config = {k: v for k, v in existing.items() if k not in _COMPUTED}
|
||||
except Exception:
|
||||
pass
|
||||
write_athlete_json(summaries, user_dir, athlete_config)
|
||||
except Exception as exc:
|
||||
console.print(f" [yellow]{user_dir.name}[/yellow]: rebuild_athlete failed: {exc}")
|
||||
|
||||
|
||||
def _recompute_best_climbs(data: Path, handle: str | None = None) -> None:
|
||||
"""Recompute best_climb_m for all cycling activities from their stored timeseries.
|
||||
|
||||
Updates activities/*.json and index.json in-place. Run this once after
|
||||
upgrading the climb algorithm to fix values computed by the old code.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.metrics import _best_climb
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
updated = 0
|
||||
for act_path in acts_dir.glob("*.json"):
|
||||
if act_path.stem.endswith((".timeseries", ".geojson")):
|
||||
continue
|
||||
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
if detail.get("sport") != "cycling":
|
||||
continue
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
t_vals = ts.get("t", [])
|
||||
e_vals = ts.get("elevation_m", [])
|
||||
pairs = sorted(
|
||||
(t, e) for t, e in zip(t_vals, e_vals) if e is not None
|
||||
)
|
||||
if len(pairs) < 2:
|
||||
continue
|
||||
new_val = _best_climb(pairs)
|
||||
if new_val == detail.get("best_climb_m"):
|
||||
continue
|
||||
detail["best_climb_m"] = new_val
|
||||
act_path.write_text(
|
||||
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
act_id = act_path.stem
|
||||
for s in index_data.get("activities", []):
|
||||
if s.get("id") == act_id:
|
||||
s["best_climb_m"] = new_val
|
||||
break
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} climb(s) recomputed")
|
||||
|
||||
|
||||
def _recompute_elevation(data: Path, handle: str | None = None) -> None:
|
||||
"""Recompute elevation_gain_m / elevation_loss_m for all activities.
|
||||
|
||||
Applies the dropout-skip fix (near-zero values mid-recording) so stored
|
||||
values computed by older code are corrected. Updates activities/*.json
|
||||
and index.json in-place.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.metrics import _ELEVATION_THRESHOLD
|
||||
|
||||
def _accumulate(elevations: list[float], altitude_source: str) -> tuple[float, float]:
|
||||
if len(elevations) < 2:
|
||||
return 0.0, 0.0
|
||||
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
|
||||
# Skip leading near-zeros (device acquiring lock)
|
||||
start = 0
|
||||
if abs(elevations[0]) < 0.5:
|
||||
n_leading = sum(1 for e in elevations if abs(e) < 0.5)
|
||||
if n_leading > 1:
|
||||
for i, e in enumerate(elevations):
|
||||
if abs(e) > threshold:
|
||||
start = i
|
||||
break
|
||||
gain = loss = 0.0
|
||||
committed = elevations[start]
|
||||
for e in elevations[start + 1:]:
|
||||
if abs(e) < 1.0 and abs(committed) > threshold:
|
||||
continue
|
||||
diff = e - committed
|
||||
if abs(diff) >= threshold:
|
||||
if diff > 0:
|
||||
gain += diff
|
||||
else:
|
||||
loss += diff
|
||||
committed = e
|
||||
return gain, loss
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
updated = 0
|
||||
for act_path in acts_dir.glob("*.json"):
|
||||
if act_path.stem.endswith((".timeseries", ".geojson")):
|
||||
continue
|
||||
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
raw = ts.get("elevation_m", [])
|
||||
elevations = [e for e in raw if e is not None]
|
||||
if len(elevations) < 2:
|
||||
continue
|
||||
alt_src = detail.get("altitude_source", "unknown")
|
||||
new_gain, new_loss = _accumulate(elevations, alt_src)
|
||||
new_gain_r = round(new_gain, 1) if new_gain else None
|
||||
new_loss_r = round(abs(new_loss), 1) if new_loss else None
|
||||
if (new_gain_r == detail.get("elevation_gain_m") and
|
||||
new_loss_r == detail.get("elevation_loss_m")):
|
||||
continue
|
||||
detail["elevation_gain_m"] = new_gain_r
|
||||
detail["elevation_loss_m"] = new_loss_r
|
||||
act_path.write_text(
|
||||
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
act_id = act_path.stem
|
||||
for s in index_data.get("activities", []):
|
||||
if s.get("id") == act_id:
|
||||
s["elevation_gain_m"] = new_gain_r
|
||||
s["elevation_loss_m"] = new_loss_r
|
||||
break
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} elevation(s) recomputed")
|
||||
|
||||
|
||||
def _write_root_manifest(data: Path) -> None:
|
||||
"""Rewrite the root index.json shard manifest from current user dirs."""
|
||||
import json
|
||||
@@ -158,6 +377,236 @@ def _link_data(site: Path, data: Path) -> None:
|
||||
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
|
||||
|
||||
|
||||
def _recompute_vam(data: Path, handle: str | None = None) -> None:
|
||||
"""Recompute climbing_vam_mh and climbing_time_s for all activities.
|
||||
|
||||
Reads the stored timeseries, re-runs the VAM algorithm, and patches both
|
||||
activities/*.json and index.json in-place. Run once after adding
|
||||
climbing_time_s to the schema so the NerdCorner VAM chart can filter short
|
||||
climbs and opacity-encode confidence.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.metrics import _VAM_SPORTS, _build_ele_1hz, _vam_from_ele_1hz
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
index_by_id = {s["id"]: s for s in index_data.get("activities", [])}
|
||||
updated = 0
|
||||
|
||||
for act_path in sorted(acts_dir.glob("*.json")):
|
||||
if act_path.stem.endswith((".timeseries", ".geojson")):
|
||||
continue
|
||||
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
if detail.get("sport") not in _VAM_SPORTS:
|
||||
continue
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
t_vals = ts.get("t", [])
|
||||
e_vals = ts.get("elevation_m", [])
|
||||
sparse: dict[int, float | None] = {int(t): e for t, e in zip(t_vals, e_vals)}
|
||||
ele_1hz = _build_ele_1hz(sparse)
|
||||
result = _vam_from_ele_1hz(ele_1hz) if ele_1hz else None
|
||||
new_vam, new_climb_t = result if result else (None, None)
|
||||
if (new_vam == detail.get("climbing_vam_mh") and
|
||||
new_climb_t == detail.get("climbing_time_s")):
|
||||
continue
|
||||
detail["climbing_vam_mh"] = new_vam
|
||||
detail["climbing_time_s"] = new_climb_t
|
||||
act_path.write_text(
|
||||
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
summary = index_by_id.get(act_path.stem)
|
||||
if summary is not None:
|
||||
summary["climbing_vam_mh"] = new_vam
|
||||
summary["climbing_time_s"] = new_climb_t
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} VAM(s) recomputed")
|
||||
|
||||
|
||||
def _backfill_vam_summary(data: Path, handle: str | None = None) -> None:
|
||||
"""Copy climbing_vam_mh from detail JSONs into index.json summaries.
|
||||
|
||||
Needed once after the vam_curve→climbing_vam_mh-in-summary migration.
|
||||
"""
|
||||
import json
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
index_path = user_dir / "index.json"
|
||||
if not acts_dir.exists() or not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
updated = 0
|
||||
for s in index_data.get("activities", []):
|
||||
if "climbing_vam_mh" in s:
|
||||
continue # already backfilled
|
||||
act_path = acts_dir / f"{s['id']}.json"
|
||||
if not act_path.exists():
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(act_path.read_text(encoding="utf-8"))
|
||||
vam = detail.get("climbing_vam_mh")
|
||||
if vam is not None:
|
||||
s["climbing_vam_mh"] = vam
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if updated:
|
||||
index_path.write_text(
|
||||
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated")
|
||||
|
||||
|
||||
def _backfill_speed(data: Path, handle: str | None = None) -> None:
|
||||
"""Compute GPS-derived speed for timeseries files where speed_kmh is all null.
|
||||
|
||||
Reads each *.timeseries.json, fills speed_kmh from haversine distances when
|
||||
the device did not record per-second speed, and writes the file back.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.timeseries import _gps_speed_kmh
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
if not acts_dir.exists():
|
||||
continue
|
||||
updated = 0
|
||||
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
spd = ts.get("speed_kmh", [])
|
||||
if not spd or any(v is not None for v in spd):
|
||||
continue # already has speed data
|
||||
lat_vals = ts.get("lat") or []
|
||||
lon_vals = ts.get("lon") or []
|
||||
t_vals = ts.get("t") or []
|
||||
if not lat_vals or not lon_vals or not t_vals:
|
||||
continue
|
||||
ts["speed_kmh"] = _gps_speed_kmh(lat_vals, lon_vals, t_vals)
|
||||
ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
updated += 1
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} timeseries updated with GPS speed")
|
||||
|
||||
|
||||
def _downsample_timeseries(data: Path, handle: str | None = None) -> None:
|
||||
"""Apply 10 m spatial downsampling to all stored timeseries files in activities/.
|
||||
|
||||
Reads the parallel JSON arrays, computes which indices to keep using the
|
||||
same distance logic as _spatial_downsample, slices every channel, and
|
||||
writes the file back. Run bincio render --no-build afterward so _merge_edits
|
||||
regenerates _merged/ from the smaller source files.
|
||||
"""
|
||||
import json
|
||||
from bincio.extract.timeseries import _haversine_m, _SPATIAL_RESOLUTION_M
|
||||
|
||||
_CHANNELS = ("t", "lat", "lon", "elevation_m", "speed_kmh",
|
||||
"hr_bpm", "cadence_rpm", "power_w", "temperature_c")
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
for user_dir in targets:
|
||||
acts_dir = user_dir / "activities"
|
||||
if not acts_dir.exists():
|
||||
continue
|
||||
updated = skipped = 0
|
||||
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
t_vals = ts.get("t") or []
|
||||
lat_vals = ts.get("lat") or []
|
||||
lon_vals = ts.get("lon") or []
|
||||
spd_vals = ts.get("speed_kmh") or []
|
||||
n = len(t_vals)
|
||||
if n < 2:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
has_gps = any(v is not None for v in lat_vals)
|
||||
has_speed = any(v is not None for v in spd_vals)
|
||||
if not has_gps and not has_speed:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
kept: list[int] = [0]
|
||||
cum_dist = last_kept = prev_speed = 0.0
|
||||
|
||||
for i in range(1, n):
|
||||
dt = t_vals[i] - t_vals[i - 1]
|
||||
la0 = lat_vals[i - 1] if lat_vals else None
|
||||
lo0 = lon_vals[i - 1] if lon_vals else None
|
||||
la1 = lat_vals[i] if lat_vals else None
|
||||
lo1 = lon_vals[i] if lon_vals else None
|
||||
|
||||
if (has_gps and la0 is not None and lo0 is not None
|
||||
and la1 is not None and lo1 is not None):
|
||||
dist_m = _haversine_m(la0, lo0, la1, lo1)
|
||||
else:
|
||||
spd = (spd_vals[i] if spd_vals and spd_vals[i] is not None
|
||||
else prev_speed)
|
||||
dist_m = (spd / 3.6) * max(dt, 0)
|
||||
|
||||
if spd_vals and spd_vals[i] is not None:
|
||||
prev_speed = spd_vals[i]
|
||||
|
||||
cum_dist += dist_m
|
||||
if cum_dist - last_kept >= _SPATIAL_RESOLUTION_M:
|
||||
kept.append(i)
|
||||
last_kept = cum_dist
|
||||
|
||||
if kept[-1] != n - 1:
|
||||
kept.append(n - 1)
|
||||
|
||||
if len(kept) >= n:
|
||||
skipped += 1
|
||||
continue # already sparse (very short / indoor / rest-stop heavy)
|
||||
|
||||
for key in _CHANNELS:
|
||||
ch = ts.get(key)
|
||||
if ch:
|
||||
ts[key] = [ch[i] for i in kept]
|
||||
|
||||
ts_path.write_text(
|
||||
json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
updated += 1
|
||||
|
||||
console.print(
|
||||
f" [cyan]{user_dir.name}[/cyan]: "
|
||||
f"{updated} downsampled, {skipped} skipped (indoor / short / already sparse)"
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--config", "config_path", default=None,
|
||||
help="Path to extract_config.yaml (reads output.dir from it).")
|
||||
@@ -175,6 +624,24 @@ def _link_data(site: Path, data: Path) -> None:
|
||||
help="(Multi-user) Incrementally re-merge one user's shard only.")
|
||||
@click.option("--no-build", "no_build", is_flag=True,
|
||||
help="Skip the Astro build step (just merge sidecars and update manifests).")
|
||||
@click.option("--recompute-climbs", "recompute_climbs", is_flag=True,
|
||||
help="Recompute best_climb_m for all cycling activities from stored timeseries "
|
||||
"(run once after upgrading the climb algorithm).")
|
||||
@click.option("--recompute-elevation", "recompute_elevation", is_flag=True,
|
||||
help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries "
|
||||
"(run once after upgrading the dropout-skip fix).")
|
||||
@click.option("--recompute-vam", "recompute_vam", is_flag=True,
|
||||
help="Recompute climbing_vam_mh and climbing_time_s for all activities from stored "
|
||||
"timeseries (run once after adding climbing_time_s to the schema).")
|
||||
@click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True,
|
||||
help="Copy climbing_vam_mh from detail JSONs into index.json summaries "
|
||||
"(run once after the VAM curve → summary migration).")
|
||||
@click.option("--backfill-speed", "backfill_speed", is_flag=True,
|
||||
help="Compute GPS-derived speed for timeseries where the device didn't record "
|
||||
"per-second speed (run once to enable speed map coloring on older activities).")
|
||||
@click.option("--downsample-timeseries", "downsample_timeseries", is_flag=True,
|
||||
help="Apply 10 m spatial downsampling to all stored timeseries files "
|
||||
"(run once after deploying the downsampling code).")
|
||||
def render(
|
||||
config_path: Optional[str],
|
||||
data_dir: Optional[str],
|
||||
@@ -184,6 +651,12 @@ def render(
|
||||
deploy: Optional[str],
|
||||
handle: Optional[str],
|
||||
no_build: bool,
|
||||
recompute_climbs: bool,
|
||||
recompute_elevation: bool,
|
||||
recompute_vam: bool,
|
||||
backfill_vam_summary: bool,
|
||||
backfill_speed: bool,
|
||||
downsample_timeseries: bool,
|
||||
) -> None:
|
||||
"""Build (or serve) the BincioActivity static site from a BAS data store."""
|
||||
|
||||
@@ -193,7 +666,33 @@ def render(
|
||||
console.print(f"Site: [cyan]{site}[/cyan]")
|
||||
console.print(f"Data: [cyan]{data}[/cyan]")
|
||||
|
||||
if recompute_climbs:
|
||||
console.print("Recomputing best climbs from timeseries…")
|
||||
_recompute_best_climbs(data, handle=handle)
|
||||
|
||||
if recompute_elevation:
|
||||
console.print("Recomputing elevation gain/loss from timeseries…")
|
||||
_recompute_elevation(data, handle=handle)
|
||||
|
||||
if recompute_vam:
|
||||
console.print("Recomputing VAM and climbing time from timeseries…")
|
||||
_recompute_vam(data, handle=handle)
|
||||
|
||||
if backfill_vam_summary:
|
||||
console.print("Backfilling climbing_vam_mh into summaries…")
|
||||
_backfill_vam_summary(data, handle=handle)
|
||||
|
||||
if backfill_speed:
|
||||
console.print("Backfilling GPS-derived speed into timeseries…")
|
||||
_backfill_speed(data, handle=handle)
|
||||
|
||||
if downsample_timeseries:
|
||||
console.print("Applying spatial downsampling to timeseries…")
|
||||
_downsample_timeseries(data, handle=handle)
|
||||
|
||||
_merge_edits(data, handle=handle)
|
||||
_rebuild_athlete_json(data, handle=handle)
|
||||
_bake_tracks(data, handle=handle)
|
||||
_write_root_manifest(data)
|
||||
|
||||
if no_build:
|
||||
@@ -201,15 +700,23 @@ def render(
|
||||
return
|
||||
|
||||
_ensure_npm(site)
|
||||
_link_data(site, data)
|
||||
|
||||
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
|
||||
|
||||
if serve:
|
||||
# Dev server needs to serve /data/ files at runtime from public/
|
||||
_link_data(site, data)
|
||||
console.print("Starting [cyan]astro dev[/cyan]…")
|
||||
subprocess.run(["npm", "run", "dev"], cwd=site, env=env)
|
||||
return
|
||||
|
||||
# Production build: BINCIO_DATA_DIR is already set so manifest.ts reads
|
||||
# data directly; remove any leftover public/data symlink so Astro doesn't
|
||||
# copy the full data directory (9+ GB) into dist/.
|
||||
public_data = site / "public" / "data"
|
||||
if public_data.is_symlink():
|
||||
public_data.unlink()
|
||||
|
||||
# Build
|
||||
cmd = ["npm", "run", "build"]
|
||||
if out_dir:
|
||||
|
||||
+121
-34
@@ -21,6 +21,31 @@ import yaml
|
||||
|
||||
# Per-user-directory lock so concurrent upload requests and the dev file-watcher
|
||||
# cannot run merge_all simultaneously on the same directory.
|
||||
|
||||
|
||||
def _fix_surrogates(obj: object) -> object:
|
||||
"""Recursively replace surrogate pairs in strings with proper Unicode code points.
|
||||
|
||||
Surrogate pairs (U+D800–U+DFFF) are valid in Python str but not in UTF-8.
|
||||
They typically arise when emoji from UTF-16-encoded sources (Strava, some FIT
|
||||
devices) are decoded incorrectly. encode/decode via utf-16 with surrogatepass
|
||||
reconstructs the intended characters.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
try:
|
||||
obj.encode("utf-8")
|
||||
return obj
|
||||
except UnicodeEncodeError:
|
||||
return obj.encode("utf-16", "surrogatepass").decode("utf-16")
|
||||
if isinstance(obj, dict):
|
||||
return {k: _fix_surrogates(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_fix_surrogates(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _dumps(obj: object) -> str:
|
||||
return json.dumps(_fix_surrogates(obj), indent=2, ensure_ascii=False)
|
||||
_merge_locks: dict[str, threading.Lock] = {}
|
||||
_merge_locks_mu = threading.Lock()
|
||||
|
||||
@@ -44,8 +69,9 @@ def parse_sidecar(path: Path) -> tuple[dict, str]:
|
||||
return {}, text.strip()
|
||||
|
||||
|
||||
def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
|
||||
def apply_sidecar(detail: dict, fm: dict, body: str, *, download_disabled_default: bool = False) -> dict:
|
||||
"""Apply sidecar overrides to a detail JSON dict. Returns a modified copy."""
|
||||
from bincio.extract.writer import _infer_indoor_title
|
||||
d = dict(detail)
|
||||
d.setdefault("custom", {})
|
||||
d["custom"] = dict(d["custom"]) # don't mutate original
|
||||
@@ -54,6 +80,11 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
|
||||
d["title"] = str(fm["title"])
|
||||
if "sport" in fm:
|
||||
d["sport"] = str(fm["sport"])
|
||||
if "sub_sport" in fm:
|
||||
d["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None
|
||||
# Infer indoor from title when sub_sport is still absent after sidecar
|
||||
if not d.get("sub_sport") and _infer_indoor_title(d.get("title") or ""):
|
||||
d["sub_sport"] = "indoor"
|
||||
if "gear" in fm:
|
||||
d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear")
|
||||
if body:
|
||||
@@ -66,12 +97,19 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
|
||||
d["privacy"] = "unlisted" if fm["private"] else detail.get("privacy", "public")
|
||||
if "hide_stats" in fm:
|
||||
d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])]
|
||||
dd = fm.get("download_disabled") # True, False, or None (absent)
|
||||
if dd is True:
|
||||
d["download_disabled"] = True
|
||||
elif dd is None and download_disabled_default:
|
||||
d["download_disabled"] = True
|
||||
# dd is False → explicit per-activity opt-in, leave unset
|
||||
|
||||
return d
|
||||
|
||||
|
||||
def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
|
||||
"""Apply sidecar overrides to an index summary entry."""
|
||||
from bincio.extract.writer import _infer_indoor_title
|
||||
s = dict(summary)
|
||||
s.setdefault("custom", {})
|
||||
s["custom"] = dict(s["custom"])
|
||||
@@ -80,10 +118,17 @@ def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
|
||||
s["title"] = str(fm["title"])
|
||||
if "sport" in fm:
|
||||
s["sport"] = str(fm["sport"])
|
||||
if "sub_sport" in fm:
|
||||
s["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None
|
||||
if "gear" in fm:
|
||||
s["gear"] = str(fm["gear"]) if fm["gear"] else s.get("gear")
|
||||
if "highlight" in fm:
|
||||
s["custom"]["highlight"] = bool(fm["highlight"])
|
||||
if "private" in fm:
|
||||
s["privacy"] = "unlisted" if fm["private"] else summary.get("privacy", "public")
|
||||
# Infer indoor from title when sub_sport is still absent
|
||||
if not s.get("sub_sport") and _infer_indoor_title(s.get("title") or ""):
|
||||
s["sub_sport"] = "indoor"
|
||||
|
||||
return s
|
||||
|
||||
@@ -127,6 +172,12 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
|
||||
)
|
||||
|
||||
needs_merge = has_sidecar or bool(image_files)
|
||||
# Also need a real file (not symlink) when title inference would change sub_sport
|
||||
if not needs_merge and not has_sidecar:
|
||||
from bincio.extract.writer import _infer_indoor_title
|
||||
_peek = json.loads(src.read_text(encoding="utf-8"))
|
||||
if not _peek.get("sub_sport") and _infer_indoor_title(_peek.get("title") or ""):
|
||||
needs_merge = True
|
||||
|
||||
# Symlink the timeseries file (never merged — always points to the extract output)
|
||||
ts_src = acts_dir / f"{activity_id}.timeseries.json"
|
||||
@@ -141,14 +192,17 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
|
||||
dest.unlink()
|
||||
|
||||
if needs_merge:
|
||||
detail = json.loads(src.read_text(encoding="utf-8"))
|
||||
detail = locals().get("_peek") or json.loads(src.read_text(encoding="utf-8"))
|
||||
if has_sidecar:
|
||||
fm, body = parse_sidecar(sidecar_path) # type: ignore[arg-type]
|
||||
detail = apply_sidecar(detail, fm, body)
|
||||
else:
|
||||
# No sidecar — still apply title inference
|
||||
detail = apply_sidecar(detail, {}, "")
|
||||
if image_files:
|
||||
detail["custom"] = dict(detail.get("custom") or {})
|
||||
detail["custom"]["images"] = image_files
|
||||
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
||||
dest.write_text(_dumps(detail))
|
||||
else:
|
||||
dest.symlink_to(src.resolve())
|
||||
|
||||
@@ -166,9 +220,8 @@ def _merge_one_locked(data_dir: Path, activity_id: str) -> None:
|
||||
activities = []
|
||||
for s in index.get("activities", []):
|
||||
aid = s.get("id", "")
|
||||
if aid in all_sidecars:
|
||||
fm, _ = all_sidecars[aid]
|
||||
s = _apply_sidecar_summary(s, fm)
|
||||
fm, _ = all_sidecars[aid] if aid in all_sidecars else ({}, "")
|
||||
s = _apply_sidecar_summary(s, fm)
|
||||
activities.append(s)
|
||||
|
||||
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
||||
@@ -192,6 +245,13 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
merged_dir = data_dir / "_merged"
|
||||
merged_acts = merged_dir / "activities"
|
||||
|
||||
_settings_path = data_dir / "_user_settings.json"
|
||||
try:
|
||||
_user_settings = json.loads(_settings_path.read_text(encoding="utf-8")) if _settings_path.exists() else {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
_user_settings = {}
|
||||
_dl_default: bool = bool(_user_settings.get("download_disabled_default", False))
|
||||
|
||||
# Collect sidecars upfront
|
||||
sidecars: dict[str, tuple[dict, str]] = {}
|
||||
if edits_dir.exists():
|
||||
@@ -214,6 +274,17 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
|
||||
to_merge = set(sidecars) | set(image_lists)
|
||||
|
||||
# Also include activities whose title implies indoor (no sidecar required)
|
||||
_index_path = data_dir / "index.json"
|
||||
_cached_index: dict | None = None
|
||||
if _index_path.exists():
|
||||
from bincio.extract.writer import _infer_indoor_title
|
||||
_cached_index = json.loads(_index_path.read_text(encoding="utf-8"))
|
||||
for _s in _cached_index.get("activities", []):
|
||||
_aid = _s.get("id", "")
|
||||
if _aid and not _s.get("sub_sport") and _infer_indoor_title(_s.get("title") or ""):
|
||||
to_merge.add(_aid)
|
||||
|
||||
# Wipe and recreate _merged/activities/
|
||||
shutil.rmtree(merged_acts, ignore_errors=True)
|
||||
merged_acts.mkdir(parents=True, exist_ok=True)
|
||||
@@ -229,11 +300,13 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
detail = json.loads(src.read_text(encoding="utf-8"))
|
||||
if activity_id in sidecars:
|
||||
fm, body = sidecars[activity_id]
|
||||
detail = apply_sidecar(detail, fm, body)
|
||||
detail = apply_sidecar(detail, fm, body, download_disabled_default=_dl_default)
|
||||
else:
|
||||
detail = apply_sidecar(detail, {}, "", download_disabled_default=_dl_default)
|
||||
if activity_id in image_lists:
|
||||
detail["custom"] = dict(detail.get("custom") or {})
|
||||
detail["custom"]["images"] = image_lists[activity_id]
|
||||
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
||||
dest.write_text(_dumps(detail))
|
||||
else:
|
||||
if not dest.exists() and not dest.is_symlink():
|
||||
dest.symlink_to(src.resolve())
|
||||
@@ -253,7 +326,7 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
athlete_dest = merged_dir / "athlete.json"
|
||||
if athlete_dest.exists() or athlete_dest.is_symlink():
|
||||
athlete_dest.unlink()
|
||||
if athlete_src.exists():
|
||||
if athlete_src.exists() and athlete_src.stat().st_size > 0:
|
||||
athlete_edits_path = data_dir / "edits" / "athlete.yaml"
|
||||
if athlete_edits_path.exists():
|
||||
try:
|
||||
@@ -265,22 +338,24 @@ def _merge_all_locked(data_dir: Path) -> int:
|
||||
edits = {}
|
||||
_ATHLETE_EDITABLE = {"max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"}
|
||||
if edits:
|
||||
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
||||
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
|
||||
athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False))
|
||||
try:
|
||||
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
||||
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
|
||||
athlete_dest.write_text(_dumps(athlete_data))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
else:
|
||||
athlete_dest.symlink_to(athlete_src.resolve())
|
||||
|
||||
# Write merged index.json (private filtered, highlight sorted)
|
||||
index_path = data_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
index = _cached_index or json.loads(index_path.read_text(encoding="utf-8"))
|
||||
activities = []
|
||||
for s in index.get("activities", []):
|
||||
aid = s.get("id", "")
|
||||
if aid in sidecars:
|
||||
fm, _ = sidecars[aid]
|
||||
s = _apply_sidecar_summary(s, fm)
|
||||
fm, _ = sidecars[aid] if aid in sidecars else ({}, "")
|
||||
s = _apply_sidecar_summary(s, fm)
|
||||
activities.append(s)
|
||||
|
||||
# "unlisted" (and legacy "private") activities are kept in the index so
|
||||
@@ -334,7 +409,7 @@ def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dic
|
||||
"activities": by_year[year],
|
||||
}
|
||||
fname = f"index-{year}.json"
|
||||
(merged_dir / fname).write_text(json.dumps(shard_doc, indent=2, ensure_ascii=False))
|
||||
(merged_dir / fname).write_text(_dumps(shard_doc))
|
||||
shards.append({"url": fname, "year": int(year) if year.isdigit() else 0,
|
||||
"count": len(by_year[year])})
|
||||
|
||||
@@ -343,7 +418,7 @@ def _write_year_shards(merged_dir: Path, activities: list[dict], index_meta: dic
|
||||
"shards": shards,
|
||||
"activities": [],
|
||||
}
|
||||
(merged_dir / "index.json").write_text(json.dumps(root_doc, indent=2, ensure_ascii=False))
|
||||
(merged_dir / "index.json").write_text(_dumps(root_doc))
|
||||
|
||||
|
||||
FEED_PAGE_SIZE = 50
|
||||
@@ -355,10 +430,11 @@ _COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"}
|
||||
|
||||
|
||||
def write_combined_feed(data_dir: Path) -> int:
|
||||
"""Build data_dir/feed.json — the N most recent activities across all users.
|
||||
"""Build data_dir/feed.json and per-month data_dir/feed-YYYY-MM.json shards.
|
||||
|
||||
The global feed page loads this single file instead of resolving 20+ user
|
||||
shards recursively. Returns the number of activities written.
|
||||
feed.json is a BAS shard index (same format as per-user index.json).
|
||||
Each feed-YYYY-MM.json contains all activities for that month across all users,
|
||||
sorted newest-first. Returns the number of activities written.
|
||||
"""
|
||||
user_dirs = sorted(
|
||||
p for p in data_dir.iterdir()
|
||||
@@ -401,24 +477,35 @@ def write_combined_feed(data_dir: Path) -> int:
|
||||
|
||||
all_activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
||||
|
||||
# Remove stale feed pages
|
||||
# Remove stale feed files (sequential pages and old year shards)
|
||||
for f in data_dir.glob("feed*.json"):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
if not all_activities:
|
||||
return 0
|
||||
|
||||
pages = [all_activities[i:i + FEED_PAGE_SIZE] for i in range(0, len(all_activities), FEED_PAGE_SIZE)]
|
||||
for page_num, page in enumerate(pages):
|
||||
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in page]
|
||||
fname = "feed.json" if page_num == 0 else f"feed-{page_num + 1}.json"
|
||||
doc = {
|
||||
"bas_version": "1.0",
|
||||
"page": page_num + 1,
|
||||
"total_pages": len(pages),
|
||||
"total_activities": len(all_activities),
|
||||
"activities": slim,
|
||||
}
|
||||
(data_dir / fname).write_text(json.dumps(doc, indent=2, ensure_ascii=False))
|
||||
# Group by YYYY-MM (month), preserving newest-first order within each bucket
|
||||
by_month: dict[str, list[dict]] = {}
|
||||
for a in all_activities:
|
||||
ym = (a.get("started_at") or "")[:7] # "YYYY-MM"
|
||||
if len(ym) == 7 and ym[4] == "-":
|
||||
by_month.setdefault(ym, []).append(a)
|
||||
|
||||
months_desc = sorted(by_month.keys(), reverse=True)
|
||||
|
||||
# Write per-month shard files (~150-200 acts each → ~25 KB gzip)
|
||||
for ym, acts in by_month.items():
|
||||
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in acts]
|
||||
doc: dict = {"bas_version": "1.0", "activities": slim}
|
||||
(data_dir / f"feed-{ym}.json").write_text(_dumps(doc))
|
||||
|
||||
# Write feed.json as a BAS shard index (same pattern as per-user index.json)
|
||||
index_doc: dict = {
|
||||
"bas_version": "1.0",
|
||||
"total_activities": len(all_activities),
|
||||
"shards": [{"url": f"feed-{ym}.json"} for ym in months_desc],
|
||||
"activities": [],
|
||||
}
|
||||
(data_dir / "feed.json").write_text(_dumps(index_doc))
|
||||
|
||||
return len(all_activities)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""OG image generation — 400×400 track-on-dark PNG for social link previews."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Colour stops matching ActivityMap.svelte _linearColor stops
|
||||
_STOPS: list[tuple[float, tuple[int, int, int]]] = [
|
||||
(0.00, (59, 130, 246)), # blue-500 (low)
|
||||
(0.33, (74, 222, 128)), # green-400
|
||||
(0.66, (250, 204, 21)), # yellow-400
|
||||
(1.00, (239, 68, 68)), # red-500 (high)
|
||||
]
|
||||
|
||||
_BG = (9, 9, 11) # zinc-950
|
||||
_SIZE = 400
|
||||
_PAD = 28
|
||||
_WIDTH = 5 # logical line width; rendered at 2× then downscaled
|
||||
|
||||
|
||||
def _lerp_color(t: float) -> tuple[int, int, int]:
|
||||
t = max(0.0, min(1.0, t))
|
||||
for i in range(len(_STOPS) - 1):
|
||||
t0, c0 = _STOPS[i]
|
||||
t1, c1 = _STOPS[i + 1]
|
||||
if t <= t1:
|
||||
f = (t - t0) / (t1 - t0) if t1 > t0 else 0.0
|
||||
return (
|
||||
round(c0[0] + f * (c1[0] - c0[0])),
|
||||
round(c0[1] + f * (c1[1] - c0[1])),
|
||||
round(c0[2] + f * (c1[2] - c0[2])),
|
||||
)
|
||||
return _STOPS[-1][1]
|
||||
|
||||
|
||||
def generate(
|
||||
lat_arr: list[Optional[float]],
|
||||
lon_arr: list[Optional[float]],
|
||||
ele_arr: list[Optional[float]],
|
||||
) -> bytes:
|
||||
"""Return PNG bytes for a 400×400 elevation-coloured track image.
|
||||
|
||||
Any of the three arrays may have None gaps (no-GPS seconds).
|
||||
Returns a plain dark square if there are fewer than 2 valid GPS points.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image, ImageDraw # type: ignore[import]
|
||||
except ImportError as e:
|
||||
raise RuntimeError("Pillow is required for OG image generation") from e
|
||||
|
||||
# Collect valid GPS points paired with elevation (None → 0 for colouring)
|
||||
pts: list[tuple[float, float, float]] = []
|
||||
for lat, lon, ele in zip(lat_arr, lon_arr, ele_arr):
|
||||
if lat is not None and lon is not None:
|
||||
pts.append((float(lat), float(lon), float(ele) if ele is not None else 0.0))
|
||||
|
||||
if len(pts) < 2:
|
||||
img = Image.new("RGB", (_SIZE, _SIZE), _BG)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
lats = [p[0] for p in pts]
|
||||
lons = [p[1] for p in pts]
|
||||
eles = [p[2] for p in pts]
|
||||
|
||||
min_lat, max_lat = min(lats), max(lats)
|
||||
min_lon, max_lon = min(lons), max(lons)
|
||||
min_ele, max_ele = min(eles), max(eles)
|
||||
ele_range = max_ele - min_ele or 1.0
|
||||
|
||||
# Mercator correction: compress longitude range by cos(mid_lat) so the
|
||||
# track doesn't look stretched horizontally at higher latitudes.
|
||||
cos_lat = math.cos(math.radians((min_lat + max_lat) / 2))
|
||||
|
||||
usable = _SIZE - 2 * _PAD
|
||||
lat_span = max_lat - min_lat or 1e-6
|
||||
lon_span = (max_lon - min_lon) * cos_lat or 1e-6
|
||||
scale = min(usable / lat_span, usable / lon_span)
|
||||
|
||||
# Centre the track within the canvas
|
||||
x_off = _PAD + (usable - (max_lon - min_lon) * cos_lat * scale) / 2
|
||||
y_off = _PAD + (usable - lat_span * scale) / 2
|
||||
|
||||
def project(lat: float, lon: float) -> tuple[float, float]:
|
||||
x = x_off + (lon - min_lon) * cos_lat * scale
|
||||
y = _SIZE - (y_off + (lat - min_lat) * scale)
|
||||
return x, y
|
||||
|
||||
# Render at 2× resolution then downscale for cheap anti-aliasing
|
||||
S = _SIZE * 2
|
||||
lw = _WIDTH * 2
|
||||
img = Image.new("RGB", (S, S), _BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for i in range(len(pts) - 1):
|
||||
x0, y0 = project(pts[i][0], pts[i][1])
|
||||
x1, y1 = project(pts[i+1][0], pts[i+1][1])
|
||||
t = (eles[i] - min_ele) / ele_range
|
||||
color = _lerp_color(t)
|
||||
draw.line([(x0 * 2, y0 * 2), (x1 * 2, y1 * 2)], fill=color, width=lw)
|
||||
|
||||
img = img.resize((_SIZE, _SIZE), Image.LANCZOS)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def generate_for_activity(ts_path: Path) -> bytes:
|
||||
"""Convenience wrapper: read a .timeseries.json file and call generate()."""
|
||||
import json
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
return generate(
|
||||
ts.get("lat") or [],
|
||||
ts.get("lon") or [],
|
||||
ts.get("elevation_m") or [],
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""bincio segments — segment management CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _dt(s: str) -> datetime:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@click.group("segments")
|
||||
def segments_group() -> None:
|
||||
"""Manage segments and detect efforts."""
|
||||
|
||||
|
||||
@segments_group.command("detect")
|
||||
@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory (e.g. /var/bincio)")
|
||||
@click.option("--handle", required=True, help="User handle to run detection for")
|
||||
@click.option("--activity-id", default=None, help="Limit to a single activity ID (optional)")
|
||||
@click.option("--segment-id", default=None, help="Limit to a single segment ID (optional)")
|
||||
@click.option("--fresh", is_flag=True, default=False, help="Clear existing efforts before detecting")
|
||||
def detect_cmd(data_dir: str, handle: str, activity_id: str | None, segment_id: str | None, fresh: bool) -> None:
|
||||
"""Retroactively detect segment efforts for stored activities.
|
||||
|
||||
Walks every activity with GPS data, runs the detection algorithm against
|
||||
all (or a single) segment, and persists any new efforts found.
|
||||
"""
|
||||
from bincio.segments.detect import track_from_timeseries_json, detect_one, detect_all
|
||||
from bincio.segments import store as _store
|
||||
|
||||
dd = Path(data_dir).expanduser().resolve()
|
||||
user_dir = dd / handle
|
||||
acts_dir = user_dir / "activities"
|
||||
|
||||
if not acts_dir.exists():
|
||||
click.echo(f"No activities directory at {acts_dir}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Choose which segments to check.
|
||||
if segment_id:
|
||||
seg = _store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
click.echo(f"Segment not found: {segment_id}", err=True)
|
||||
sys.exit(1)
|
||||
segments = [seg]
|
||||
else:
|
||||
segments = _store.list_segments(dd)
|
||||
|
||||
if not segments:
|
||||
click.echo("No segments defined.", err=True)
|
||||
sys.exit(0)
|
||||
|
||||
if fresh:
|
||||
for seg in segments:
|
||||
_store.save_efforts(dd, handle, seg.id, [])
|
||||
click.echo(f"Cleared existing efforts for {len(segments)} segment(s).")
|
||||
|
||||
# Choose which activities to process.
|
||||
if activity_id:
|
||||
detail_files = [acts_dir / f"{activity_id}.json"]
|
||||
else:
|
||||
detail_files = sorted(acts_dir.glob("*.json"))
|
||||
# Exclude timeseries files.
|
||||
detail_files = [f for f in detail_files if ".timeseries." not in f.name]
|
||||
|
||||
total_efforts = 0
|
||||
processed = 0
|
||||
|
||||
for detail_path in detail_files:
|
||||
try:
|
||||
detail = json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ts_url = detail.get("timeseries_url")
|
||||
if not ts_url:
|
||||
continue
|
||||
|
||||
act_id = detail.get("id", detail_path.stem)
|
||||
sport = detail.get("sport", "other")
|
||||
started = detail.get("started_at")
|
||||
if not started:
|
||||
continue
|
||||
try:
|
||||
started_at = _dt(started)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ts_path = user_dir / ts_url
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
track = track_from_timeseries_json(ts, act_id, sport, started_at)
|
||||
if track is None:
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
for seg in segments:
|
||||
from bincio.segments.detect import detect_one
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_store.add_effort(dd, handle, seg.id, effort)
|
||||
if efforts:
|
||||
click.echo(
|
||||
f" {act_id}: {len(efforts)} effort(s) on '{seg.name}' "
|
||||
f"({', '.join(str(e.elapsed_s) + 's' for e in efforts)})"
|
||||
)
|
||||
total_efforts += len(efforts)
|
||||
|
||||
click.echo(f"\nProcessed {processed} activities, found {total_efforts} effort(s).")
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Segment effort detection.
|
||||
|
||||
Matches GPS tracks against stored segment polylines and produces SegmentEffort
|
||||
records. Works from either a live ParsedActivity (ingest path) or from a
|
||||
stored timeseries JSON (retroactive path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bincio.segments.models import Segment, SegmentEffort
|
||||
|
||||
# ── tuning constants ──────────────────────────────────────────────────────────
|
||||
|
||||
MATCH_RADIUS_M = 25 # max distance to segment start/end to open/close an effort
|
||||
CONFORMANCE_MAX_DEV_M = 50 # max allowed deviation for each interior segment point
|
||||
CONFORMANCE_MAX_FRAC = 0.30 # max fraction of interior points allowed to deviate
|
||||
|
||||
# Minimum geometric speed (segment_distance / elapsed_s) per sport, in m/s.
|
||||
# Rejects false matches from long circuit rides where the track passes the
|
||||
# segment start early and the segment end hours later.
|
||||
_MIN_SPEED_MS: dict[str, float] = {
|
||||
'cycling': 2.0, # ~7.2 km/h — below any realistic cyclist even on brutal climbs
|
||||
'running': 0.8, # ~2.9 km/h
|
||||
}
|
||||
_MIN_SPEED_DEFAULT = 0.3 # hiking / walking / unknown
|
||||
|
||||
# Maximum geometric speed per sport in m/s — rejects GPS glitch matches.
|
||||
_MAX_SPEED_MS: dict[str, float] = {
|
||||
'cycling': 30.0, # ~108 km/h
|
||||
'running': 12.0, # ~43 km/h
|
||||
}
|
||||
_MAX_SPEED_DEFAULT = 20.0
|
||||
|
||||
# ── fast distance approximation ───────────────────────────────────────────────
|
||||
|
||||
_R = 6_371_000.0 # Earth radius in metres
|
||||
|
||||
|
||||
def _dist(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Equirectangular approximation — fast, accurate to <0.1% within 100 km."""
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
mlat = math.radians((lat1 + lat2) / 2.0)
|
||||
return math.hypot(dlat * _R, dlon * _R * math.cos(mlat))
|
||||
|
||||
|
||||
# ── activity track representation ────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ActivityTrack:
|
||||
"""Common internal representation for detection, independent of source format."""
|
||||
activity_id: str
|
||||
sport: str
|
||||
started_at: datetime
|
||||
# Parallel arrays — all same length, GPS-only points (lat/lon not None).
|
||||
lats: list[float]
|
||||
lons: list[float]
|
||||
times: list[int] # seconds from started_at
|
||||
speeds: list[Optional[float]]
|
||||
hrs: list[Optional[int]]
|
||||
powers: list[Optional[int]]
|
||||
bbox: list[float] = field(default_factory=list) # [lon_min, lat_min, lon_max, lat_max]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.lats and not self.bbox:
|
||||
self.bbox = [
|
||||
min(self.lons), min(self.lats),
|
||||
max(self.lons), max(self.lats),
|
||||
]
|
||||
|
||||
|
||||
def track_from_parsed(parsed: "ParsedActivity", activity_id: str) -> Optional[ActivityTrack]: # noqa: F821
|
||||
"""Build an ActivityTrack from a ParsedActivity (used during ingest)."""
|
||||
lats, lons, times, speeds, hrs, powers = [], [], [], [], [], []
|
||||
last_t = -1
|
||||
for p in parsed.points:
|
||||
if p.lat is None or p.lon is None:
|
||||
continue
|
||||
t = int((p.timestamp - parsed.started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
last_t = t
|
||||
lats.append(p.lat)
|
||||
lons.append(p.lon)
|
||||
times.append(t)
|
||||
speeds.append(p.speed_kmh)
|
||||
hrs.append(p.hr_bpm)
|
||||
powers.append(p.power_w)
|
||||
if len(lats) < 2:
|
||||
return None
|
||||
return ActivityTrack(
|
||||
activity_id=activity_id,
|
||||
sport=parsed.sport,
|
||||
started_at=parsed.started_at,
|
||||
lats=lats, lons=lons, times=times,
|
||||
speeds=speeds, hrs=hrs, powers=powers,
|
||||
)
|
||||
|
||||
|
||||
def track_from_timeseries_json(
|
||||
ts: dict,
|
||||
activity_id: str,
|
||||
sport: str,
|
||||
started_at: datetime,
|
||||
) -> Optional[ActivityTrack]:
|
||||
"""Build an ActivityTrack from a stored timeseries JSON dict."""
|
||||
raw_lats = ts.get("lat") or []
|
||||
raw_lons = ts.get("lon") or []
|
||||
raw_t = ts.get("t") or []
|
||||
raw_spd = ts.get("speed_kmh") or []
|
||||
raw_hr = ts.get("hr_bpm") or []
|
||||
raw_pwr = ts.get("power_w") or []
|
||||
n = len(raw_t)
|
||||
if n < 2 or not raw_lats or len(raw_lats) != n:
|
||||
return None
|
||||
|
||||
def _pad(arr: list, length: int) -> list:
|
||||
return arr + [None] * (length - len(arr))
|
||||
|
||||
raw_spd = _pad(raw_spd, n)
|
||||
raw_hr = _pad(raw_hr, n)
|
||||
raw_pwr = _pad(raw_pwr, n)
|
||||
|
||||
lats, lons, times, speeds, hrs, powers = [], [], [], [], [], []
|
||||
for i in range(n):
|
||||
if raw_lats[i] is None or raw_lons[i] is None:
|
||||
continue
|
||||
lats.append(float(raw_lats[i]))
|
||||
lons.append(float(raw_lons[i]))
|
||||
times.append(int(raw_t[i]))
|
||||
speeds.append(raw_spd[i])
|
||||
hrs.append(raw_hr[i])
|
||||
powers.append(raw_pwr[i])
|
||||
|
||||
if len(lats) < 2:
|
||||
return None
|
||||
return ActivityTrack(
|
||||
activity_id=activity_id,
|
||||
sport=sport,
|
||||
started_at=started_at,
|
||||
lats=lats, lons=lons, times=times,
|
||||
speeds=speeds, hrs=hrs, powers=powers,
|
||||
)
|
||||
|
||||
|
||||
# ── effort metric helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _avg_nonnull(vals: list, lo: int, hi: int) -> Optional[float]:
|
||||
nums = [v for v in vals[lo:hi + 1] if v is not None]
|
||||
return sum(nums) / len(nums) if nums else None
|
||||
|
||||
|
||||
def _np_power(powers: list[Optional[int]], lo: int, hi: int) -> Optional[int]:
|
||||
"""Coggan NP from a slice of 1Hz power data (may have gaps/nulls)."""
|
||||
WIN = 30
|
||||
chunk = powers[lo:hi + 1]
|
||||
filled = [v if v is not None else 0 for v in chunk]
|
||||
n = len(filled)
|
||||
if n < WIN:
|
||||
# Too short for rolling average — just return avg power.
|
||||
non_null = [v for v in chunk if v is not None]
|
||||
return int(round(sum(non_null) / len(non_null))) if non_null else None
|
||||
half = WIN // 2
|
||||
window_sum = sum(filled[:WIN])
|
||||
fourth_powers = []
|
||||
for i in range(half, n - half):
|
||||
fourth_powers.append((window_sum / WIN) ** 4)
|
||||
if i + half + 1 < n:
|
||||
window_sum += filled[i + half + 1] - filled[i - half]
|
||||
if not fourth_powers:
|
||||
return None
|
||||
return int(round((sum(fourth_powers) / len(fourth_powers)) ** 0.25))
|
||||
|
||||
|
||||
# ── detection algorithm ───────────────────────────────────────────────────────
|
||||
|
||||
def _bboxes_overlap(a: list[float], b: list[float]) -> bool:
|
||||
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
|
||||
|
||||
|
||||
def _conformance_ok(
|
||||
track: ActivityTrack,
|
||||
seg: Segment,
|
||||
i: int,
|
||||
j: int,
|
||||
) -> bool:
|
||||
"""Check that the track slice [i..j] follows the segment polyline."""
|
||||
interior = seg.polyline[1:-1]
|
||||
if not interior:
|
||||
return True # trivial 2-point segment
|
||||
failing = 0
|
||||
for sp in interior:
|
||||
slat, slon = sp[0], sp[1]
|
||||
min_d = min(
|
||||
_dist(slat, slon, track.lats[k], track.lons[k])
|
||||
for k in range(i, j + 1)
|
||||
)
|
||||
if min_d > CONFORMANCE_MAX_DEV_M:
|
||||
failing += 1
|
||||
return (failing / len(interior)) <= CONFORMANCE_MAX_FRAC
|
||||
|
||||
|
||||
def _extract_effort(
|
||||
track: ActivityTrack,
|
||||
seg: Segment,
|
||||
i: int,
|
||||
j: int,
|
||||
) -> SegmentEffort:
|
||||
elapsed_s = track.times[j] - track.times[i]
|
||||
started_at = (track.started_at + timedelta(seconds=track.times[i])).replace(microsecond=0)
|
||||
# Always derive avg speed from segment distance / elapsed time. Device-recorded
|
||||
# speed is unreliable across formats (m/s vs km/h in older FIT files) and
|
||||
# averaging instantaneous GPS speed over a slice gives different results anyway.
|
||||
avg_speed = (seg.distance_m / elapsed_s * 3.6) if elapsed_s > 0 else None
|
||||
avg_hr_raw = _avg_nonnull(track.hrs, i, j)
|
||||
avg_hr = int(round(avg_hr_raw)) if avg_hr_raw is not None else None
|
||||
avg_pwr_raw = _avg_nonnull(track.powers, i, j)
|
||||
avg_pwr = int(round(avg_pwr_raw)) if avg_pwr_raw is not None else None
|
||||
np_pwr = _np_power(track.powers, i, j) if any(v is not None for v in track.powers[i:j + 1]) else None
|
||||
return SegmentEffort(
|
||||
activity_id=track.activity_id,
|
||||
started_at=started_at,
|
||||
elapsed_s=max(1, elapsed_s),
|
||||
avg_speed_kmh=round(avg_speed, 2) if avg_speed is not None else None,
|
||||
avg_hr_bpm=avg_hr,
|
||||
avg_power_w=avg_pwr,
|
||||
np_power_w=np_pwr,
|
||||
detected_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def detect_one(track: ActivityTrack, seg: Segment) -> list[SegmentEffort]:
|
||||
"""Return all matching efforts for a single segment against a track."""
|
||||
if not track.bbox or not _bboxes_overlap(track.bbox, seg.bbox):
|
||||
return []
|
||||
if seg.sport and seg.sport != track.sport:
|
||||
return []
|
||||
|
||||
seg_start_lat, seg_start_lon = seg.polyline[0][0], seg.polyline[0][1]
|
||||
seg_end_lat, seg_end_lon = seg.polyline[-1][0], seg.polyline[-1][1]
|
||||
n = len(track.lats)
|
||||
efforts: list[SegmentEffort] = []
|
||||
|
||||
search_from = 0
|
||||
while search_from < n - 1:
|
||||
# Find next start candidate from search_from.
|
||||
start_idx = None
|
||||
for i in range(search_from, n):
|
||||
if _dist(seg_start_lat, seg_start_lon, track.lats[i], track.lons[i]) <= MATCH_RADIUS_M:
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx is None:
|
||||
break
|
||||
|
||||
# Scan forward from start_idx for an end candidate.
|
||||
end_idx = None
|
||||
for j in range(start_idx + 1, n):
|
||||
if _dist(seg_end_lat, seg_end_lon, track.lats[j], track.lons[j]) <= MATCH_RADIUS_M:
|
||||
end_idx = j
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
# No end found — no more efforts possible starting at or after start_idx.
|
||||
break
|
||||
|
||||
# Reject implausibly slow or fast matches.
|
||||
elapsed = track.times[end_idx] - track.times[start_idx]
|
||||
if elapsed > 0:
|
||||
geo_speed = seg.distance_m / elapsed
|
||||
min_speed = _MIN_SPEED_MS.get(track.sport, _MIN_SPEED_DEFAULT)
|
||||
max_speed = _MAX_SPEED_MS.get(track.sport, _MAX_SPEED_DEFAULT)
|
||||
if geo_speed < min_speed or geo_speed > max_speed:
|
||||
search_from = start_idx + 1
|
||||
continue
|
||||
|
||||
if _conformance_ok(track, seg, start_idx, end_idx):
|
||||
efforts.append(_extract_effort(track, seg, start_idx, end_idx))
|
||||
search_from = end_idx + 1
|
||||
else:
|
||||
# Conformance failed; try next start candidate after start_idx.
|
||||
search_from = start_idx + 1
|
||||
|
||||
return efforts
|
||||
|
||||
|
||||
def detect_all(
|
||||
track: ActivityTrack,
|
||||
handle: str,
|
||||
data_dir: Path,
|
||||
) -> int:
|
||||
"""Detect efforts for all segments and persist them. Returns effort count."""
|
||||
from bincio.segments import store as _store
|
||||
|
||||
segments = _store.list_segments(data_dir)
|
||||
total = 0
|
||||
for seg in segments:
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_store.add_effort(data_dir, handle, seg.id, effort)
|
||||
total += len(efforts)
|
||||
return total
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Segment and SegmentEffort data models."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
id: str
|
||||
name: str
|
||||
polyline: list[list[float]] # [[lat, lon], ...]
|
||||
distance_m: float
|
||||
bbox: list[float] # [lon_min, lat_min, lon_max, lat_max]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
sport: Optional[str] = None # None = any sport
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentEffort:
|
||||
activity_id: str
|
||||
started_at: datetime
|
||||
elapsed_s: int
|
||||
detected_at: datetime
|
||||
avg_speed_kmh: Optional[float] = None
|
||||
avg_hr_bpm: Optional[int] = None
|
||||
avg_power_w: Optional[int] = None
|
||||
np_power_w: Optional[int] = None
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Read/write segments and segment efforts to/from /var/bincio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bincio.segments.models import Segment, SegmentEffort
|
||||
|
||||
# /var/bincio/segments/{id}.json
|
||||
_SEGMENTS_DIR = "segments"
|
||||
# /var/bincio/data/{handle}/segment_efforts/{segment_id}.json
|
||||
_EFFORTS_SUBDIR = "segment_efforts"
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _segments_dir(data_dir: Path) -> Path:
|
||||
d = data_dir / _SEGMENTS_DIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _efforts_dir(data_dir: Path, handle: str) -> Path:
|
||||
d = data_dir / handle / _EFFORTS_SUBDIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
s = name.lower().strip()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")[:48]
|
||||
|
||||
|
||||
def _make_id(name: str) -> str:
|
||||
slug = _slugify(name)
|
||||
suffix = hashlib.sha256(name.encode()).hexdigest()[:4]
|
||||
return f"{slug}-{suffix}"
|
||||
|
||||
|
||||
def _dt(s: str) -> datetime:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
# ── serialisation ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _segment_to_dict(seg: Segment) -> dict:
|
||||
return {
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"polyline": seg.polyline,
|
||||
"distance_m": round(seg.distance_m, 1),
|
||||
"bbox": [round(v, 6) for v in seg.bbox],
|
||||
"created_by": seg.created_by,
|
||||
"created_at": _iso(seg.created_at),
|
||||
}
|
||||
|
||||
|
||||
def _segment_from_dict(d: dict) -> Segment:
|
||||
return Segment(
|
||||
id=d["id"],
|
||||
name=d["name"],
|
||||
sport=d.get("sport"),
|
||||
polyline=d["polyline"],
|
||||
distance_m=float(d["distance_m"]),
|
||||
bbox=d["bbox"],
|
||||
created_by=d["created_by"],
|
||||
created_at=_dt(d["created_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _effort_to_dict(e: SegmentEffort) -> dict:
|
||||
return {
|
||||
"activity_id": e.activity_id,
|
||||
"started_at": _iso(e.started_at),
|
||||
"elapsed_s": e.elapsed_s,
|
||||
"avg_speed_kmh": e.avg_speed_kmh,
|
||||
"avg_hr_bpm": e.avg_hr_bpm,
|
||||
"avg_power_w": e.avg_power_w,
|
||||
"np_power_w": e.np_power_w,
|
||||
"detected_at": _iso(e.detected_at),
|
||||
}
|
||||
|
||||
|
||||
def _effort_from_dict(d: dict) -> SegmentEffort:
|
||||
return SegmentEffort(
|
||||
activity_id=d["activity_id"],
|
||||
started_at=_dt(d["started_at"]),
|
||||
elapsed_s=int(d["elapsed_s"]),
|
||||
avg_speed_kmh=d.get("avg_speed_kmh"),
|
||||
avg_hr_bpm=d.get("avg_hr_bpm"),
|
||||
avg_power_w=d.get("avg_power_w"),
|
||||
np_power_w=d.get("np_power_w"),
|
||||
detected_at=_dt(d["detected_at"]),
|
||||
)
|
||||
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def make_segment_id(name: str) -> str:
|
||||
return _make_id(name)
|
||||
|
||||
|
||||
def save_segment(data_dir: Path, seg: Segment) -> None:
|
||||
path = _segments_dir(data_dir) / f"{seg.id}.json"
|
||||
path.write_text(json.dumps(_segment_to_dict(seg), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_segment(data_dir: Path, segment_id: str) -> Optional[Segment]:
|
||||
path = _segments_dir(data_dir) / f"{segment_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return _segment_from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def delete_segment(data_dir: Path, segment_id: str) -> bool:
|
||||
path = _segments_dir(data_dir) / f"{segment_id}.json"
|
||||
if not path.exists():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def list_segments(data_dir: Path, bbox: Optional[list[float]] = None) -> list[Segment]:
|
||||
"""Return all segments, optionally filtered to those overlapping bbox.
|
||||
|
||||
bbox = [lon_min, lat_min, lon_max, lat_max]
|
||||
"""
|
||||
segs = []
|
||||
for path in sorted(_segments_dir(data_dir).glob("*.json")):
|
||||
try:
|
||||
seg = _segment_from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
continue
|
||||
if bbox is not None and not _bboxes_overlap(seg.bbox, bbox):
|
||||
continue
|
||||
segs.append(seg)
|
||||
return segs
|
||||
|
||||
|
||||
def _bboxes_overlap(a: list[float], b: list[float]) -> bool:
|
||||
"""True if two [lon_min, lat_min, lon_max, lat_max] boxes overlap."""
|
||||
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
|
||||
|
||||
|
||||
# ── efforts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_efforts(data_dir: Path, handle: str, segment_id: str) -> list[SegmentEffort]:
|
||||
path = _efforts_dir(data_dir, handle) / f"{segment_id}.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
return [_effort_from_dict(d) for d in json.loads(path.read_text(encoding="utf-8"))]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def save_efforts(data_dir: Path, handle: str, segment_id: str, efforts: list[SegmentEffort]) -> None:
|
||||
path = _efforts_dir(data_dir, handle) / f"{segment_id}.json"
|
||||
data = [_effort_to_dict(e) for e in efforts]
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def add_effort(data_dir: Path, handle: str, segment_id: str, effort: SegmentEffort) -> None:
|
||||
"""Append an effort, replacing any existing effort at the same start time.
|
||||
|
||||
Deduplicating by started_at (not activity_id) handles the case where the
|
||||
same ride is stored under two activity IDs (e.g. re-imported with a different
|
||||
source hash), which would otherwise produce two identical-time efforts.
|
||||
"""
|
||||
efforts = load_efforts(data_dir, handle, segment_id)
|
||||
key = _iso(effort.started_at)
|
||||
efforts = [e for e in efforts if _iso(e.started_at) != key]
|
||||
efforts.append(effort)
|
||||
efforts.sort(key=lambda e: e.started_at, reverse=True)
|
||||
save_efforts(data_dir, handle, segment_id, efforts)
|
||||
+41
-20
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -22,10 +21,16 @@ console = Console()
|
||||
@click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.")
|
||||
@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
|
||||
@click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).")
|
||||
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
strava_client_id: Optional[str], strava_client_secret: Optional[str],
|
||||
max_users: Optional[int], public_url: Optional[str],
|
||||
webroot: Optional[str], dem_url: Optional[str]) -> None:
|
||||
@click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).")
|
||||
@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.")
|
||||
@click.option("--auth-api", default=None, envvar="BINCIO_AUTH_API", help="Internal URL of the bincio-auth API (e.g. http://127.0.0.1:4040). When set, admin user-state operations are proxied to bincio-auth.")
|
||||
@click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER", help="OIDC issuer URL (e.g. https://bincio.org). When set, validates RS256 id_tokens via JWKS (preferred over HS256).")
|
||||
def serve(data_dir: str, site_dir: str | None, host: str, port: int,
|
||||
strava_client_id: str | None, strava_client_secret: str | None,
|
||||
max_users: int | None, public_url: str | None,
|
||||
webroot: str | None, dem_url: str | None,
|
||||
sync_secret: str | None, jwt_secret: str | None, auth_api: str | None,
|
||||
oidc_issuer: str | None) -> None:
|
||||
"""Start the bincio multi-user application server.
|
||||
|
||||
Handles auth, user management, and write operations.
|
||||
@@ -34,8 +39,10 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
Requires a data directory initialised with `bincio init`.
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
import bincio.serve.server as srv
|
||||
from bincio.serve.db import open_db, set_setting, get_setting
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.db import get_setting, open_db, set_setting
|
||||
|
||||
dd = Path(data_dir).expanduser().resolve()
|
||||
if not (dd / "instance.db").exists():
|
||||
@@ -48,36 +55,50 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
set_setting(db, "max_users", str(max_users))
|
||||
db.close()
|
||||
|
||||
srv.data_dir = dd
|
||||
deps.data_dir = dd
|
||||
if site_dir:
|
||||
srv.site_dir = Path(site_dir).expanduser().resolve()
|
||||
deps.site_dir = Path(site_dir).expanduser().resolve()
|
||||
if strava_client_id:
|
||||
srv.strava_client_id = strava_client_id
|
||||
deps.strava_client_id = strava_client_id
|
||||
if strava_client_secret:
|
||||
srv.strava_client_secret = strava_client_secret
|
||||
deps.strava_client_secret = strava_client_secret
|
||||
if public_url:
|
||||
srv.public_url = public_url
|
||||
deps.public_url = public_url
|
||||
if webroot and site_dir:
|
||||
srv.webroot = Path(webroot).expanduser().resolve()
|
||||
deps.webroot = Path(webroot).expanduser().resolve()
|
||||
if dem_url:
|
||||
srv.dem_url = dem_url
|
||||
deps.dem_url = dem_url
|
||||
if sync_secret:
|
||||
deps.sync_secret = sync_secret
|
||||
if jwt_secret:
|
||||
deps.jwt_secret = jwt_secret
|
||||
if auth_api:
|
||||
deps.auth_api = auth_api.rstrip("/")
|
||||
if oidc_issuer:
|
||||
deps.oidc_issuer = oidc_issuer
|
||||
|
||||
db = open_db(dd)
|
||||
current_limit = get_setting(db, "max_users")
|
||||
db.close()
|
||||
|
||||
console.print(f"[bold]bincio serve[/bold]")
|
||||
console.print("[bold]bincio serve[/bold]")
|
||||
console.print(f" Data: [cyan]{dd}[/cyan]")
|
||||
if srv.site_dir:
|
||||
console.print(f" Site: [cyan]{srv.site_dir}[/cyan]")
|
||||
if srv.webroot:
|
||||
console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)")
|
||||
if deps.site_dir:
|
||||
console.print(f" Site: [cyan]{deps.site_dir}[/cyan]")
|
||||
if deps.webroot:
|
||||
console.print(f" Web: [cyan]{deps.webroot}[/cyan] (auto-rebuild on upload)")
|
||||
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
|
||||
if current_limit and int(current_limit) > 0:
|
||||
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
|
||||
else:
|
||||
console.print(f" Users: [dim]unlimited[/dim]")
|
||||
console.print(f" DEM: [cyan]{srv.dem_url}[/cyan]")
|
||||
console.print(" Users: [dim]unlimited[/dim]")
|
||||
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
|
||||
if deps.oidc_issuer:
|
||||
console.print(f" Auth: [green]RS256 via {deps.oidc_issuer}[/green]" + (" + HS256 fallback" if deps.jwt_secret else ""))
|
||||
elif deps.jwt_secret:
|
||||
console.print(" Auth: [green]JWT HS256 (bincio-auth)[/green]")
|
||||
else:
|
||||
console.print(" Auth: [dim]local DB sessions[/dim]")
|
||||
console.print()
|
||||
|
||||
log_config = uvicorn.config.LOGGING_CONFIG.copy()
|
||||
|
||||
+24
-4
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
wiki_access INTEGER NOT NULL DEFAULT 1,
|
||||
activity_access INTEGER NOT NULL DEFAULT 0,
|
||||
suspended INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -89,6 +90,7 @@ class User:
|
||||
is_admin: bool
|
||||
wiki_access: bool
|
||||
activity_access: bool
|
||||
suspended: bool
|
||||
created_at: int
|
||||
|
||||
|
||||
@@ -115,6 +117,10 @@ def open_db(data_dir: Path) -> sqlite3.Connection:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.executescript(_SCHEMA)
|
||||
# Migration: add suspended column to pre-existing databases
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(users)")}
|
||||
if "suspended" not in cols:
|
||||
db.execute("ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0")
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
@@ -140,7 +146,8 @@ def create_user(
|
||||
)
|
||||
db.commit()
|
||||
return User(handle=handle, display_name=display_name, is_admin=is_admin,
|
||||
wiki_access=wiki_access, activity_access=activity_access, created_at=now)
|
||||
wiki_access=wiki_access, activity_access=activity_access,
|
||||
suspended=False, created_at=now)
|
||||
|
||||
|
||||
def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]:
|
||||
@@ -153,12 +160,13 @@ def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]:
|
||||
is_admin=bool(row["is_admin"]),
|
||||
wiki_access=bool(row["wiki_access"]),
|
||||
activity_access=bool(row["activity_access"]),
|
||||
suspended=bool(row["suspended"]),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional[User]:
|
||||
"""Return the User if credentials are valid, else None."""
|
||||
"""Return the User if credentials are valid and account is not suspended, else None."""
|
||||
row = db.execute(
|
||||
"SELECT * FROM users WHERE handle = ?", (handle,)
|
||||
).fetchone()
|
||||
@@ -166,12 +174,15 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional
|
||||
return None
|
||||
if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
|
||||
return None
|
||||
if row["suspended"]:
|
||||
return None
|
||||
return User(
|
||||
handle=row["handle"],
|
||||
display_name=row["display_name"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
wiki_access=bool(row["wiki_access"]),
|
||||
activity_access=bool(row["activity_access"]),
|
||||
suspended=False,
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
@@ -188,6 +199,7 @@ def list_users(db: sqlite3.Connection) -> list[User]:
|
||||
return [User(handle=r["handle"], display_name=r["display_name"],
|
||||
is_admin=bool(r["is_admin"]), wiki_access=bool(r["wiki_access"]),
|
||||
activity_access=bool(r["activity_access"]),
|
||||
suspended=bool(r["suspended"]),
|
||||
created_at=r["created_at"]) for r in rows]
|
||||
|
||||
|
||||
@@ -196,6 +208,11 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def set_suspended(db: sqlite3.Connection, handle: str, suspended: bool) -> None:
|
||||
db.execute("UPDATE users SET suspended = ? WHERE handle = ?", (int(suspended), handle))
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_member_tree(db: sqlite3.Connection) -> list[dict]:
|
||||
"""Return users with their inviter handle and join timestamp.
|
||||
|
||||
@@ -271,10 +288,10 @@ def create_session(db: sqlite3.Connection, handle: str) -> str:
|
||||
|
||||
|
||||
def get_session(db: sqlite3.Connection, token: str) -> Optional[User]:
|
||||
"""Return the User owning this session, or None if expired/invalid."""
|
||||
"""Return the User owning this session, or None if expired/invalid/suspended."""
|
||||
row = db.execute(
|
||||
"SELECT s.handle, s.expires_at, u.display_name, u.is_admin, "
|
||||
"u.wiki_access, u.activity_access, u.created_at "
|
||||
"u.wiki_access, u.activity_access, u.suspended, u.created_at "
|
||||
"FROM sessions s JOIN users u ON s.handle = u.handle "
|
||||
"WHERE s.token = ?",
|
||||
(token,),
|
||||
@@ -284,12 +301,15 @@ def get_session(db: sqlite3.Connection, token: str) -> Optional[User]:
|
||||
if row["expires_at"] < int(time.time()):
|
||||
delete_session(db, token)
|
||||
return None
|
||||
if row["suspended"]:
|
||||
return None
|
||||
return User(
|
||||
handle=row["handle"],
|
||||
display_name=row["display_name"],
|
||||
is_admin=bool(row["is_admin"]),
|
||||
wiki_access=bool(row["wiki_access"]),
|
||||
activity_access=bool(row["activity_access"]),
|
||||
suspended=False,
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Shared state and FastAPI dependency functions for bincio.serve.
|
||||
|
||||
All module-level globals live here so routers can import them without
|
||||
creating circular dependencies through server.py.
|
||||
The CLI sets these before uvicorn starts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import jwt as _jwt
|
||||
from fastapi import Cookie, HTTPException, Request, Response
|
||||
|
||||
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
get_session,
|
||||
open_db,
|
||||
)
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES # noqa: F401
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES # noqa: F401
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name # noqa: F401
|
||||
|
||||
# ── Module-level state (set by CLI before uvicorn starts) ─────────────────────
|
||||
|
||||
data_dir: Path | None = None
|
||||
site_dir: Path | None = None
|
||||
webroot: Path | None = None
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
public_url: str = ""
|
||||
dem_url: str = "https://api.open-elevation.com"
|
||||
sync_secret: str = ""
|
||||
jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup
|
||||
auth_api: str = "" # when set, proxies user-state admin ops to bincio-auth (e.g. http://127.0.0.1:4040)
|
||||
oidc_issuer: str = "" # when set, validates RS256 id_tokens via bincio-auth JWKS
|
||||
_db = None
|
||||
_strava_sync_running = False
|
||||
_strava_sync_lock = threading.Lock()
|
||||
_garmin_sync_running = False
|
||||
_garmin_sync_lock = threading.Lock()
|
||||
|
||||
# ── JWKS cache ────────────────────────────────────────────────────────────────
|
||||
|
||||
_jwks_public_key: object = None
|
||||
_jwks_fetched_at: float = 0.0
|
||||
_jwks_lock = threading.Lock()
|
||||
_JWKS_TTL = 3600
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||
_SESSION_COOKIE = "bincio_session"
|
||||
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
|
||||
_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
|
||||
|
||||
_STRAVA_CREDS_FILE = "strava_credentials.json"
|
||||
|
||||
_login_attempts: dict[str, list[float]] = {}
|
||||
_register_attempts: dict[str, list[float]] = {}
|
||||
_RATE_WINDOW = 900 # 15 minutes
|
||||
_LOGIN_RATE_LIMIT = 10
|
||||
_REGISTER_RATE_LIMIT = 5
|
||||
|
||||
# ── Core helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_data_dir() -> Path:
|
||||
if data_dir is None:
|
||||
raise HTTPException(500, "Server not configured")
|
||||
return data_dir
|
||||
|
||||
|
||||
def _get_db():
|
||||
global _db
|
||||
if _db is None:
|
||||
_db = open_db(_get_data_dir())
|
||||
return _db
|
||||
|
||||
|
||||
def _strava_creds(handle: str) -> tuple[str, str]:
|
||||
"""Return (client_id, client_secret) for a user.
|
||||
|
||||
Per-user credentials take precedence over the instance-level globals.
|
||||
Returns ("", "") when neither is configured.
|
||||
"""
|
||||
creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
|
||||
if creds_path.exists():
|
||||
try:
|
||||
d = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
cid = str(d.get("client_id", "")).strip()
|
||||
csec = str(d.get("client_secret", "")).strip()
|
||||
if cid and csec:
|
||||
return cid, csec
|
||||
except (OSError, json.JSONDecodeError, KeyError, ValueError):
|
||||
pass
|
||||
return strava_client_id, strava_client_secret
|
||||
|
||||
|
||||
def _check_id(activity_id: str) -> str:
|
||||
if not _VALID_ACTIVITY_ID.match(activity_id):
|
||||
raise HTTPException(400, "Invalid activity ID")
|
||||
return activity_id
|
||||
|
||||
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_rate_limit(
|
||||
ip: str,
|
||||
store: dict[str, list[float]],
|
||||
limit: int,
|
||||
msg: str = "Too many attempts. Try again later.",
|
||||
) -> None:
|
||||
now = time.time()
|
||||
attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
|
||||
store[ip] = attempts
|
||||
if len(attempts) >= limit:
|
||||
raise HTTPException(429, msg)
|
||||
attempts.append(now)
|
||||
store[ip] = attempts
|
||||
|
||||
# ── Auth dependency functions ─────────────────────────────────────────────────
|
||||
|
||||
def _get_jwks_public_key() -> object:
|
||||
"""Fetch and cache the RSA public key from bincio-auth's JWKS endpoint."""
|
||||
global _jwks_public_key, _jwks_fetched_at
|
||||
now = time.time()
|
||||
with _jwks_lock:
|
||||
if _jwks_public_key is not None and now - _jwks_fetched_at < _JWKS_TTL:
|
||||
return _jwks_public_key
|
||||
import base64
|
||||
import urllib.request
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
url = f"{oidc_issuer.rstrip('/')}/.well-known/jwks.json"
|
||||
with urllib.request.urlopen(url, timeout=5) as r:
|
||||
jwks = json.loads(r.read())
|
||||
k = jwks["keys"][0]
|
||||
|
||||
def _b64i(s: str) -> int:
|
||||
s += "=" * (-len(s) % 4)
|
||||
return int.from_bytes(base64.urlsafe_b64decode(s), "big")
|
||||
|
||||
pub = RSAPublicNumbers(_b64i(k["e"]), _b64i(k["n"])).public_key(default_backend())
|
||||
_jwks_public_key = pub
|
||||
_jwks_fetched_at = now
|
||||
return pub
|
||||
|
||||
|
||||
def _decode_rs256(token: str) -> User | None:
|
||||
"""Decode an RS256 id_token from bincio-auth. Returns None on any failure."""
|
||||
try:
|
||||
pub = _get_jwks_public_key()
|
||||
payload = _jwt.decode(
|
||||
token, pub, algorithms=["RS256"],
|
||||
options={"verify_aud": False},
|
||||
issuer=oidc_issuer,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
handle = payload.get("sub")
|
||||
if not handle:
|
||||
return None
|
||||
return User(
|
||||
handle=handle,
|
||||
display_name=payload.get("name") or payload.get("display_name", ""),
|
||||
is_admin=bool(payload.get("is_admin", False)),
|
||||
wiki_access=bool(payload.get("wiki_access", True)),
|
||||
activity_access=bool(payload.get("activity_access", False)),
|
||||
suspended=False,
|
||||
created_at=0,
|
||||
)
|
||||
|
||||
|
||||
def _decode_hs256(token: str) -> User | None:
|
||||
"""Decode a bincio-auth HS256 JWT and return a User. Returns None on any failure."""
|
||||
try:
|
||||
payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
except _jwt.PyJWTError:
|
||||
return None
|
||||
handle = payload.get("sub")
|
||||
if not handle:
|
||||
return None
|
||||
return User(
|
||||
handle=handle,
|
||||
display_name=payload.get("display_name", ""),
|
||||
is_admin=bool(payload.get("is_admin", False)),
|
||||
wiki_access=bool(payload.get("wiki_access", True)),
|
||||
activity_access=bool(payload.get("activity_access", False)),
|
||||
suspended=False,
|
||||
created_at=0,
|
||||
)
|
||||
|
||||
|
||||
def _decode_token(token: str) -> User | None:
|
||||
"""Try RS256 first (if oidc_issuer set), then HS256, then DB session."""
|
||||
if oidc_issuer:
|
||||
user = _decode_rs256(token)
|
||||
if user:
|
||||
return user
|
||||
if jwt_secret:
|
||||
return _decode_hs256(token)
|
||||
return get_session(_get_db(), token)
|
||||
|
||||
|
||||
def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None:
|
||||
if not bincio_session:
|
||||
return None
|
||||
return _decode_token(bincio_session)
|
||||
|
||||
|
||||
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||
user = _current_user(bincio_session)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _require_admin(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||
user = _require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
|
||||
|
||||
def _require_auth(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> User:
|
||||
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
|
||||
token = bincio_session
|
||||
if not token:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
if not token:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
user = _decode_token(token)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired session")
|
||||
return user
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
kwargs: dict = dict(
|
||||
key=_SESSION_COOKIE,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
)
|
||||
if _SESSION_DOMAIN:
|
||||
kwargs["domain"] = _SESSION_DOMAIN
|
||||
response.set_cookie(**kwargs)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Pydantic request/response models for bincio.serve."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
handle: str = Field(..., description="User handle (username)")
|
||||
password: str = Field(..., description="User password")
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
handle: str = Field(..., description="User handle")
|
||||
display_name: str = Field(..., description="User's display name")
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
handle: str = Field(..., description="User handle")
|
||||
code: str = Field(..., description="Reset code (24 hours valid)")
|
||||
password: str = Field(..., description="New password (min 8 chars)")
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
code: str = Field(..., description="Invite code")
|
||||
handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)")
|
||||
password: str = Field(..., description="Password (min 8 characters)")
|
||||
display_name: str = Field(default="", description="Full name (optional, defaults to handle)")
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
handle: str = Field(..., description="New user's handle")
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
handle: str = Field(..., description="User handle")
|
||||
display_name: str = Field(..., description="User's display name")
|
||||
is_admin: bool = Field(..., description="Whether user is an admin")
|
||||
wiki_access: bool = Field(default=True, description="Whether user has wiki access")
|
||||
activity_access: bool = Field(default=False, description="Whether user has activity access")
|
||||
store_originals_default: bool = Field(
|
||||
default=True,
|
||||
description="Instance-wide default for storing original files"
|
||||
)
|
||||
dem_configured: bool = Field(default=False, description="Whether DEM elevation lookup is configured")
|
||||
|
||||
|
||||
class ActivityEditRequest(BaseModel):
|
||||
title: str | None = Field(default=None, description="Activity title")
|
||||
description: str | None = Field(default=None, description="Activity description (markdown)")
|
||||
sport: str | None = Field(default=None, description="Sport type")
|
||||
sub_sport: str | None = Field(default=None, description="Sport sub-category")
|
||||
private: bool | None = Field(default=None, description="Hide from public feed")
|
||||
highlight: bool | None = Field(default=None, description="Mark as favorite")
|
||||
gear: str | None = Field(default=None, description="Gear used")
|
||||
download_disabled: bool | None = Field(default=None, description="Prevent others from downloading files")
|
||||
|
||||
|
||||
class ActivityEditResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
|
||||
|
||||
class ResetPasswordCodeResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
code: str = Field(..., description="One-time reset code")
|
||||
expires_in_hours: int = Field(24, description="Code validity period in hours")
|
||||
|
||||
|
||||
class GenericResponse(BaseModel):
|
||||
ok: bool = Field(True, description="Success flag")
|
||||
|
||||
|
||||
class CreateSegmentRequest(BaseModel):
|
||||
name: str = Field(..., description="Segment name")
|
||||
sport: Optional[str] = Field(default=None, description="Sport filter")
|
||||
polyline: list[list[float]] = Field(..., description="[[lat, lon], ...] GPS points")
|
||||
distance_m: float = Field(..., description="Segment length in metres")
|
||||
|
||||
|
||||
class CreateInviteRequest(BaseModel):
|
||||
grants_activity: bool = Field(default=False)
|
||||
|
||||
|
||||
class IdeaBody(BaseModel):
|
||||
title: str
|
||||
body: str = ""
|
||||
|
||||
|
||||
class IdeaCommentBody(BaseModel):
|
||||
comment: str = ""
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Activity CRUD and athlete endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.models import ActivityEditRequest, ActivityEditResponse, GenericResponse
|
||||
from bincio.serve.db import User
|
||||
from bincio.shared.images import (
|
||||
ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES,
|
||||
MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES,
|
||||
unique_image_name as _unique_image_name,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_data_dir(handle: str) -> Path:
|
||||
"""Return the merged data dir for a user, for reading activity files."""
|
||||
dd = deps._get_data_dir()
|
||||
merged = dd / handle / "_merged"
|
||||
return merged if merged.exists() else dd / handle
|
||||
|
||||
|
||||
def _require_owns(activity_id: str, user: User) -> Path:
|
||||
"""Verify the user owns this activity (it lives in their data dir)."""
|
||||
activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json"
|
||||
if not activity_path.exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
return activity_path
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/geojson")
|
||||
async def get_activity_geojson(
|
||||
activity_id: str,
|
||||
user: User = Depends(deps._require_auth),
|
||||
) -> JSONResponse:
|
||||
"""Return GeoJSON track for an activity (mobile detail screen)."""
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||
p = base / f"{activity_id}.geojson"
|
||||
if p.exists():
|
||||
return JSONResponse(json.loads(p.read_text()))
|
||||
raise HTTPException(404, "GeoJSON not found")
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/timeseries")
|
||||
async def get_activity_timeseries(
|
||||
activity_id: str,
|
||||
user: User = Depends(deps._require_auth),
|
||||
) -> JSONResponse:
|
||||
"""Return timeseries for an activity (mobile detail screen)."""
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||
p = base / f"{activity_id}.timeseries.json"
|
||||
if p.exists():
|
||||
return JSONResponse(json.loads(p.read_text()))
|
||||
raise HTTPException(404, "Timeseries not found")
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}")
|
||||
async def get_activity(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
path = _require_owns(activity_id, user)
|
||||
detail = json.loads(path.read_text())
|
||||
# Normalise for EditDrawer: add `private` bool so the drawer works regardless
|
||||
# of whether the raw JSON uses the old "private" or the new "unlisted" value.
|
||||
detail["private"] = detail.get("privacy") in ("private", "unlisted")
|
||||
return JSONResponse(detail)
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}", response_model=ActivityEditResponse)
|
||||
async def post_activity(
|
||||
activity_id: str,
|
||||
edit_req: ActivityEditRequest,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
# Verify the activity belongs to this user before writing
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
|
||||
from bincio.edit.ops import apply_sidecar_edit
|
||||
body = edit_req.model_dump(exclude_none=True)
|
||||
apply_sidecar_edit(activity_id, body, dd)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}/recalculate-elevation/dem")
|
||||
async def recalculate_elevation_dem_endpoint(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
|
||||
|
||||
Requires --dem-url to be set when starting bincio serve.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
if not deps.dem_url:
|
||||
raise HTTPException(503, "DEM URL not configured.")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
try:
|
||||
from bincio.extract.dem import recalculate_elevation
|
||||
from bincio.render.merge import merge_one
|
||||
result = recalculate_elevation(dd, activity_id, deps.dem_url)
|
||||
merge_one(dd, activity_id)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse(result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis")
|
||||
async def recalculate_elevation_hysteresis_endpoint(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Recompute gain/loss from original recorded elevation using source-aware hysteresis."""
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
try:
|
||||
from bincio.extract.dem import recalculate_elevation_hysteresis
|
||||
from bincio.render.merge import merge_one
|
||||
result = recalculate_elevation_hysteresis(dd, activity_id)
|
||||
merge_one(dd, activity_id)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse(result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
|
||||
@router.delete("/api/activity/{activity_id}", response_model=GenericResponse)
|
||||
async def delete_activity(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete a single activity and all associated files for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
acts_dir = dd / "activities"
|
||||
|
||||
json_path = acts_dir / f"{activity_id}.json"
|
||||
if not json_path.exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
|
||||
import shutil
|
||||
|
||||
# Remove the source files (activities dir)
|
||||
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||
p = acts_dir / f"{activity_id}{suffix}"
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
# Remove sidecar edit and images
|
||||
sidecar = dd / "edits" / f"{activity_id}.md"
|
||||
sidecar.unlink(missing_ok=True)
|
||||
images_dir = dd / "edits" / "images" / activity_id
|
||||
if images_dir.exists():
|
||||
shutil.rmtree(images_dir)
|
||||
|
||||
# Remove from the extract-level flat index so merge_all doesn't re-add
|
||||
# the summary even though the detail file is gone.
|
||||
index_path = dd / "index.json"
|
||||
if index_path.exists():
|
||||
try:
|
||||
idx = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
|
||||
index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass # corrupt index — merge_all will clean up on next run
|
||||
|
||||
# Remove from dedup cache so the file can be re-uploaded if needed
|
||||
cache_path = dd / ".bincio_cache.json"
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
if isinstance(cache, dict) and "activities" in cache:
|
||||
cache["activities"] = [
|
||||
a for a in cache["activities"] if a.get("id") != activity_id
|
||||
]
|
||||
cache_path.write_text(json.dumps(cache, indent=2, ensure_ascii=False))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass # corrupt cache — leave it; next extract will rebuild
|
||||
|
||||
# Full merge needed: activity removed from index
|
||||
from bincio.render.merge import merge_all
|
||||
merge_all(dd)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/images")
|
||||
async def list_images(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
images_dir = dd / "edits" / "images" / activity_id
|
||||
images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else []
|
||||
return JSONResponse({"images": images})
|
||||
|
||||
|
||||
@router.post("/api/activity/{activity_id}/images")
|
||||
async def upload_image(
|
||||
activity_id: str,
|
||||
file: UploadFile = File(...),
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||
raise HTTPException(404, "Activity not found")
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "No filename")
|
||||
ct = file.content_type or ""
|
||||
if ct not in _ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted")
|
||||
contents = await file.read()
|
||||
if len(contents) > _MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)")
|
||||
images_dir = dd / "edits" / "images" / activity_id
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = _unique_image_name(images_dir, Path(file.filename).name)
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
from bincio.render.merge import merge_one
|
||||
merge_one(dd, activity_id)
|
||||
return JSONResponse({"ok": True, "filename": safe_name})
|
||||
|
||||
|
||||
@router.delete("/api/activity/{activity_id}/images/{filename}")
|
||||
async def delete_image(
|
||||
activity_id: str,
|
||||
filename: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
deps._check_id(activity_id)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
import shutil
|
||||
safe_name = Path(filename).name
|
||||
target = dd / "edits" / "images" / activity_id / safe_name
|
||||
if target.exists() and target.is_file():
|
||||
target.unlink()
|
||||
if target.parent.exists() and not any(target.parent.iterdir()):
|
||||
shutil.rmtree(target.parent)
|
||||
from bincio.render.merge import merge_one
|
||||
merge_one(dd, activity_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/athlete")
|
||||
async def get_athlete(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
athlete_path = dd / "athlete.json"
|
||||
data: dict = {}
|
||||
if athlete_path.exists():
|
||||
try:
|
||||
data = json.loads(athlete_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
# Layer edits/athlete.yaml on top
|
||||
edits_path = dd / "edits" / "athlete.yaml"
|
||||
if edits_path.exists():
|
||||
import yaml
|
||||
try:
|
||||
edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {}
|
||||
for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
|
||||
if k in edits:
|
||||
data[k] = edits[k]
|
||||
except (OSError, yaml.YAMLError):
|
||||
pass
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
@router.post("/api/athlete")
|
||||
async def save_athlete(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
athlete_path = dd / "athlete.json"
|
||||
if not athlete_path.exists():
|
||||
from datetime import datetime, timezone
|
||||
athlete_path.write_text(json.dumps({
|
||||
"bas_version": "1.0",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"power_curve": {},
|
||||
}), encoding="utf-8")
|
||||
payload = await request.json()
|
||||
edits_dir = dd / "edits"
|
||||
edits_dir.mkdir(exist_ok=True)
|
||||
overrides: dict[str, Any] = {}
|
||||
if payload.get("max_hr") is not None:
|
||||
overrides["max_hr"] = int(payload["max_hr"])
|
||||
if payload.get("ftp_w") is not None:
|
||||
overrides["ftp_w"] = int(payload["ftp_w"])
|
||||
if payload.get("hr_zones") is not None:
|
||||
overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]]
|
||||
if payload.get("power_zones") is not None:
|
||||
overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]]
|
||||
if payload.get("seasons") is not None:
|
||||
overrides["seasons"] = [
|
||||
{"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])}
|
||||
for s in payload["seasons"]
|
||||
]
|
||||
if payload.get("gear") is not None:
|
||||
overrides["gear"] = payload["gear"]
|
||||
import yaml
|
||||
(edits_dir / "athlete.yaml").write_text(
|
||||
yaml.dump(overrides, allow_unicode=True, default_flow_style=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
from bincio.render.merge import merge_all
|
||||
merge_all(dd)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/activities/{activity_id}/segment_efforts")
|
||||
async def activity_segment_efforts(
|
||||
activity_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return segment efforts that belong to a specific activity for the logged-in user."""
|
||||
import asyncio
|
||||
from bincio.segments import store as _seg_store
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
|
||||
def _collect() -> list[dict]:
|
||||
efforts_dir = dd / user.handle / "segment_efforts"
|
||||
result: list[dict] = []
|
||||
if not efforts_dir.exists():
|
||||
return result
|
||||
for ef_file in sorted(efforts_dir.glob("*.json")):
|
||||
seg_id = ef_file.stem
|
||||
all_efforts = _seg_store.load_efforts(dd, user.handle, seg_id)
|
||||
matching = [e for e in all_efforts if e.activity_id == activity_id]
|
||||
if not matching:
|
||||
continue
|
||||
seg = _seg_store.load_segment(dd, seg_id)
|
||||
if not seg:
|
||||
continue
|
||||
pr_elapsed = min(e.elapsed_s for e in all_efforts)
|
||||
for eff in matching:
|
||||
result.append({
|
||||
"segment_id": seg.id,
|
||||
"segment_name": seg.name,
|
||||
"segment_distance_m": seg.distance_m,
|
||||
"elapsed_s": eff.elapsed_s,
|
||||
"pr_elapsed_s": pr_elapsed,
|
||||
"started_at": _seg_store._iso(eff.started_at),
|
||||
})
|
||||
return result
|
||||
|
||||
return JSONResponse(await asyncio.to_thread(_collect))
|
||||
@@ -0,0 +1,714 @@
|
||||
"""Admin endpoints (/api/admin/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
|
||||
|
||||
async def _auth_proxy(method: str, path: str, cookie: str | None) -> JSONResponse:
|
||||
"""Forward a user-state admin request to bincio-auth and relay the response."""
|
||||
if not deps.auth_api:
|
||||
raise HTTPException(503, "User management is handled by bincio-auth but BINCIO_AUTH_API is not configured.")
|
||||
url = f"{deps.auth_api}{path}"
|
||||
cookies = {"bincio_session": cookie} if cookie else {}
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.request(method, url, cookies=cookies)
|
||||
return JSONResponse(r.json(), status_code=r.status_code)
|
||||
from bincio.serve.models import ResetPasswordCodeResponse
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
get_user,
|
||||
list_users,
|
||||
)
|
||||
|
||||
log = logging.getLogger("bincio.serve")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _wipe_user_activities(user_dir: Path) -> int:
|
||||
"""Delete all extracted activity files and caches for a user.
|
||||
|
||||
Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
|
||||
_merged/, index.json, athlete.json, and the dedup cache.
|
||||
Leaves the user directory itself intact (account remains in the DB).
|
||||
Returns the number of files deleted.
|
||||
"""
|
||||
import shutil
|
||||
deleted = 0
|
||||
|
||||
for subdir in ("activities", "edits", "originals"):
|
||||
d = user_dir / subdir
|
||||
if d.exists():
|
||||
for f in d.rglob("*"):
|
||||
if f.is_file():
|
||||
deleted += 1
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("_merged", ):
|
||||
d = user_dir / name
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("index.json", "athlete.json", ".bincio_cache.json"):
|
||||
f = user_dir / name
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
@router.get("/api/admin/stats")
|
||||
async def admin_stats(bincio_session: str | None = Cookie(default=None)) -> FileResponse:
|
||||
"""Serve the latest usage stats figure. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
path = deps._get_data_dir().parent / "stats" / "latest.png"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Stats not yet generated — run scripts/usage_stats.py first")
|
||||
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache, no-store"})
|
||||
|
||||
|
||||
@router.get("/api/admin/users")
|
||||
async def admin_users(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
deps._require_admin(bincio_session)
|
||||
users = list_users(deps._get_db())
|
||||
return JSONResponse([{
|
||||
"handle": u.handle,
|
||||
"display_name": u.display_name,
|
||||
"is_admin": u.is_admin,
|
||||
"suspended": u.suspended,
|
||||
"created_at": u.created_at,
|
||||
} for u in users])
|
||||
|
||||
|
||||
@router.get("/api/admin/jobs")
|
||||
async def admin_jobs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return currently active upload/processing jobs. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
with tasks._jobs_lock:
|
||||
jobs = list(tasks._active_jobs.values())
|
||||
return JSONResponse(jobs)
|
||||
|
||||
|
||||
@router.get("/api/admin/disk")
|
||||
async def admin_disk(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Per-user disk usage breakdown. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
import shutil
|
||||
|
||||
data_dir = deps._get_data_dir()
|
||||
|
||||
def _mb(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
# Use lstat to count symlink entries (few bytes each) rather than following
|
||||
# the link to the target — prevents _merged/ from double-counting activities/.
|
||||
total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
|
||||
return round(total / 1_048_576, 1)
|
||||
|
||||
def _count(path: Path, pattern: str = "*") -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
return sum(1 for f in path.glob(pattern) if f.is_file())
|
||||
|
||||
db = deps._get_db()
|
||||
from bincio.serve.db import get_user as _get_user
|
||||
users = []
|
||||
for user_dir in sorted(data_dir.iterdir()):
|
||||
if not user_dir.is_dir() or user_dir.name.startswith("_"):
|
||||
continue
|
||||
# leaked tmp zips
|
||||
leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
|
||||
db_user = _get_user(db, user_dir.name)
|
||||
users.append({
|
||||
"handle": user_dir.name,
|
||||
"in_db": db_user is not None,
|
||||
"suspended": db_user.suspended if db_user else False,
|
||||
"total_mb": _mb(user_dir),
|
||||
"activities_mb": _mb(user_dir / "activities"),
|
||||
"activities_count": _count(user_dir / "activities", "*.json"),
|
||||
"merged_mb": _mb(user_dir / "_merged"),
|
||||
"originals_mb": _mb(user_dir / "originals"),
|
||||
"originals_strava_mb": _mb(user_dir / "originals" / "strava"),
|
||||
"images_mb": _mb(user_dir / "edits" / "images"),
|
||||
"leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1),
|
||||
"leaked_zips_count": len(leaked),
|
||||
})
|
||||
|
||||
disk = shutil.disk_usage("/")
|
||||
return JSONResponse({
|
||||
"disk": {
|
||||
"total_gb": round(disk.total / 1_073_741_824, 1),
|
||||
"used_gb": round(disk.used / 1_073_741_824, 1),
|
||||
"free_gb": round(disk.free / 1_073_741_824, 1),
|
||||
"percent": round(disk.used / disk.total * 100, 1),
|
||||
},
|
||||
"users": users,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse)
|
||||
async def admin_reset_password_code(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Generate a one-time password reset code for a user. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/reset-password-code", bincio_session)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/suspend")
|
||||
async def admin_suspend(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Suspend a user account. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/suspend", bincio_session)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/unsuspend")
|
||||
async def admin_unsuspend(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Re-enable a suspended user account. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("POST", f"/api/admin/users/{handle}/unsuspend", bincio_session)
|
||||
|
||||
|
||||
@router.delete("/api/admin/users/{handle}/account")
|
||||
async def admin_delete_account(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete a user account. Proxied to bincio-auth."""
|
||||
deps._require_admin(bincio_session)
|
||||
return await _auth_proxy("DELETE", f"/api/admin/users/{handle}/account", bincio_session)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/rebuild")
|
||||
async def admin_rebuild(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Trigger a merge_all + site rebuild for a user. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
tasks._trigger_rebuild(handle)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/rebuild-sync")
|
||||
async def admin_rebuild_sync(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Run merge+rebuild synchronously and return full output. Admin only.
|
||||
|
||||
Unlike /rebuild (fire-and-forget), this blocks until done and returns stdout/stderr.
|
||||
Use for debugging when you need to see what went wrong.
|
||||
"""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
if deps.site_dir is None:
|
||||
raise HTTPException(503, "Server has no --site-dir configured; rebuild not available")
|
||||
|
||||
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
|
||||
cmd = [uv, "run", "bincio", "render",
|
||||
"--data-dir", str(deps.data_dir),
|
||||
"--site-dir", str(deps.site_dir),
|
||||
"--handle", handle,
|
||||
"--no-build"]
|
||||
if deps.webroot:
|
||||
cmd = [uv, "run", "bincio", "render",
|
||||
"--data-dir", str(deps.data_dir),
|
||||
"--site-dir", str(deps.site_dir),
|
||||
"--handle", handle]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
resp: dict[str, Any] = {
|
||||
"ok": result.returncode == 0,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
|
||||
if result.returncode == 0 and deps.webroot:
|
||||
dist_data = deps.site_dir / "dist" / "data"
|
||||
if dist_data.exists():
|
||||
shutil.rmtree(dist_data)
|
||||
rsync = subprocess.run(
|
||||
["rsync", "-a", "--delete", "--exclude=data/",
|
||||
f"{deps.site_dir}/dist/", str(deps.webroot) + "/"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
resp["rsync_returncode"] = rsync.returncode
|
||||
resp["rsync_stdout"] = rsync.stdout
|
||||
resp["rsync_stderr"] = rsync.stderr
|
||||
resp["ok"] = rsync.returncode == 0
|
||||
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/reextract-originals")
|
||||
async def admin_reextract_originals(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> StreamingResponse:
|
||||
"""Re-extract activities from stored Strava originals without hitting the API.
|
||||
|
||||
Spawns `bincio reextract-originals` as a subprocess so heavy memory use
|
||||
is isolated from the server process. Streams its JSON-lines output as SSE.
|
||||
Triggers a full rebuild on completion.
|
||||
"""
|
||||
import asyncio
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
originals_dir = user_dir / "originals" / "strava"
|
||||
if not originals_dir.exists():
|
||||
raise HTTPException(404, f"No Strava originals directory for '{handle}'")
|
||||
|
||||
# Use the bincio script from the same venv bin dir as the running Python.
|
||||
# This is reliable in systemd environments where PATH may not include uv.
|
||||
import sys as _sys
|
||||
bincio_exe = str(Path(_sys.executable).parent / "bincio")
|
||||
data_dir = str(deps._get_data_dir())
|
||||
|
||||
# Count originals so we can split into memory-safe batches.
|
||||
total_originals = len(list(originals_dir.glob("*.json")))
|
||||
# Each activity can briefly peak at ~10–30 MB; 100 per batch keeps RSS
|
||||
# well under 3 GB even on a cheap VPS.
|
||||
_BATCH = 100
|
||||
log.info("reextract[%s]: %d originals, batch size %d, via %s",
|
||||
handle, total_originals, _BATCH, bincio_exe)
|
||||
|
||||
async def event_stream():
|
||||
total_imported = total_skipped = total_errors = 0
|
||||
offset = 0
|
||||
|
||||
while offset < total_originals:
|
||||
limit = min(_BATCH, total_originals - offset)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
bincio_exe, "reextract-originals",
|
||||
"--data-dir", data_dir,
|
||||
"--handle", handle,
|
||||
"--offset", str(offset),
|
||||
"--limit", str(limit),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
|
||||
async for raw_line in proc.stdout:
|
||||
line = raw_line.decode(errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
yield f"data: {line}\n\n"
|
||||
try:
|
||||
evt = json.loads(line)
|
||||
if evt.get("type") == "done":
|
||||
total_imported += evt.get("imported", 0)
|
||||
total_skipped += evt.get("skipped", 0)
|
||||
total_errors += evt.get("errors", 0)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await proc.wait()
|
||||
if proc.returncode != 0:
|
||||
stderr_out = await proc.stderr.read() if proc.stderr else b""
|
||||
log.error("reextract[%s]: batch offset=%d exited %d — stderr: %s",
|
||||
handle, offset, proc.returncode,
|
||||
stderr_out.decode(errors="replace")[:500])
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'Batch {offset}–{offset+limit} exited with code {proc.returncode}'})}\n\n"
|
||||
return # stop on batch failure
|
||||
|
||||
offset += limit
|
||||
|
||||
# All batches complete
|
||||
log.info("reextract[%s]: all batches done — imported=%d skipped=%d errors=%d; triggering rebuild",
|
||||
handle, total_imported, total_skipped, total_errors)
|
||||
tasks._trigger_rebuild(handle)
|
||||
yield f"data: {json.dumps({'type': 'done', 'imported': total_imported, 'skipped': total_skipped, 'errors': total_errors})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/admin/users/{handle}/diag")
|
||||
async def admin_diag(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return a diagnostic snapshot of a user's data directory. Admin only."""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
|
||||
def _count(path: Path, glob: str = "*") -> int:
|
||||
return sum(1 for f in path.glob(glob) if f.is_file()) if path.exists() else 0
|
||||
|
||||
def _size_mb(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576
|
||||
|
||||
activities_dir = user_dir / "activities"
|
||||
merged_dir = user_dir / "_merged"
|
||||
originals_dir = user_dir / "originals"
|
||||
uploads_dir = user_dir / "_uploads"
|
||||
|
||||
merged_index = merged_dir / "index.json"
|
||||
root_index = user_dir / "index.json"
|
||||
|
||||
merged_activity_count: int | None = None
|
||||
if merged_index.exists():
|
||||
try:
|
||||
idx = json.loads(merged_index.read_text())
|
||||
merged_activity_count = len(idx.get("activities", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
merged_activity_count = -1
|
||||
|
||||
root_activity_count: int | None = None
|
||||
if root_index.exists():
|
||||
try:
|
||||
idx = json.loads(root_index.read_text())
|
||||
root_activity_count = len(idx.get("activities", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
root_activity_count = -1
|
||||
|
||||
# Peek at a few filenames in activities/ to understand the actual state
|
||||
acts_sample: list[str] = []
|
||||
acts_symlinks = 0
|
||||
if activities_dir.exists():
|
||||
for f in sorted(activities_dir.iterdir())[:10]:
|
||||
acts_sample.append(f.name + (" → symlink" if f.is_symlink() else ""))
|
||||
if f.is_symlink():
|
||||
acts_symlinks += 1
|
||||
|
||||
# Check _merged/activities/ separately
|
||||
merged_acts_dir = merged_dir / "activities"
|
||||
merged_acts_json = _count(merged_acts_dir, "*.json")
|
||||
merged_acts_geojson = _count(merged_acts_dir, "*.geojson")
|
||||
|
||||
# List pending files
|
||||
pending_files: list[str] = []
|
||||
if uploads_dir.exists():
|
||||
pending_files = [f.name for f in uploads_dir.iterdir() if f.is_file()]
|
||||
|
||||
return JSONResponse({
|
||||
"handle": handle,
|
||||
"user_dir": str(user_dir),
|
||||
"activities": {
|
||||
"json_files": _count(activities_dir, "*.json"),
|
||||
"geojson_files": _count(activities_dir, "*.geojson"),
|
||||
"size_mb": round(_size_mb(activities_dir), 2),
|
||||
"sample": acts_sample,
|
||||
"symlink_count": acts_symlinks,
|
||||
},
|
||||
"originals": {
|
||||
"exists": originals_dir.exists(),
|
||||
"size_mb": round(_size_mb(originals_dir), 2),
|
||||
"strava_originals": _count(originals_dir / "strava", "*.json") if (originals_dir / "strava").exists() else 0,
|
||||
},
|
||||
"merged": {
|
||||
"exists": merged_dir.exists(),
|
||||
"activity_count_in_index": merged_activity_count,
|
||||
"size_mb": round(_size_mb(merged_dir), 2),
|
||||
"activities_json": merged_acts_json,
|
||||
"activities_geojson": merged_acts_geojson,
|
||||
},
|
||||
"root_index": {
|
||||
"exists": root_index.exists(),
|
||||
"activity_count": root_activity_count,
|
||||
},
|
||||
"pending_uploads": len(pending_files),
|
||||
"pending_files": pending_files,
|
||||
"dedup_cache_exists": (user_dir / ".bincio_cache.json").exists(),
|
||||
"athlete_json_exists": (user_dir / "athlete.json").exists(),
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/api/admin/users/{handle}/activities")
|
||||
async def admin_delete_activities(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete all activity data for a user and wipe the merged cache."""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
|
||||
deleted = _wipe_user_activities(user_dir)
|
||||
tasks._trigger_rebuild(handle)
|
||||
return JSONResponse({"ok": True, "deleted": deleted})
|
||||
|
||||
|
||||
@router.delete("/api/admin/users/{handle}/directory")
|
||||
async def admin_delete_user_directory(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete the entire user directory from disk (for ghost users not in the DB).
|
||||
|
||||
Refuses if the handle exists as an account in the database — use
|
||||
DELETE /api/admin/users/{handle}/activities for registered users.
|
||||
"""
|
||||
import shutil
|
||||
deps._require_admin(bincio_session)
|
||||
db = deps._get_db()
|
||||
from bincio.serve.db import get_user as _get_user
|
||||
if _get_user(db, handle) is not None:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"User '{handle}' is still in the database. Remove the account first, "
|
||||
"or use 'Reset data' to wipe only activity files.",
|
||||
)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No directory for '{handle}'")
|
||||
shutil.rmtree(user_dir)
|
||||
# Rebuild root manifest so the ghost shard disappears from the site
|
||||
from bincio.render.cli import _write_root_manifest
|
||||
try:
|
||||
_write_root_manifest(deps._get_data_dir())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/admin/strava-sync")
|
||||
async def admin_strava_sync_status(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return per-user Strava sync status for the admin panel."""
|
||||
deps._require_admin(bincio_session)
|
||||
root = deps._get_data_dir()
|
||||
users = []
|
||||
for tf in sorted(root.glob("*/strava_token.json")):
|
||||
user_dir = tf.parent
|
||||
handle = user_dir.name
|
||||
has_creds = (user_dir / "strava_credentials.json").exists()
|
||||
|
||||
last_sync: str | None = None
|
||||
total_imported = 0
|
||||
sync_path = user_dir / "_strava_sync.json"
|
||||
if sync_path.exists():
|
||||
try:
|
||||
sc = json.loads(sync_path.read_text(encoding="utf-8"))
|
||||
last_sync = sc.get("last_sync")
|
||||
total_imported = len(sc.get("imported_ids", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
run_status: str | None = None
|
||||
run_imported = 0
|
||||
run_errors = 0
|
||||
run_error_message: str | None = None
|
||||
last_run: str | None = None
|
||||
status_path = user_dir / "_strava_sync_status.json"
|
||||
if status_path.exists():
|
||||
try:
|
||||
ss = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
run_status = ss.get("status")
|
||||
run_imported = ss.get("imported", 0)
|
||||
run_errors = ss.get("errors", 0)
|
||||
run_error_message = ss.get("error_message")
|
||||
last_run = ss.get("last_run")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
users.append({
|
||||
"handle": handle,
|
||||
"has_credentials": has_creds,
|
||||
"last_sync": last_sync,
|
||||
"total_imported": total_imported,
|
||||
"run_status": run_status,
|
||||
"run_imported": run_imported,
|
||||
"run_errors": run_errors,
|
||||
"run_error_message": run_error_message,
|
||||
"last_run": last_run,
|
||||
})
|
||||
|
||||
return JSONResponse({"running": deps._strava_sync_running, "users": users})
|
||||
|
||||
|
||||
@router.post("/api/admin/strava-sync/run")
|
||||
async def admin_strava_sync_run(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Trigger an immediate Strava sync for all users (admin only)."""
|
||||
deps._require_admin(bincio_session)
|
||||
with deps._strava_sync_lock:
|
||||
if deps._strava_sync_running:
|
||||
raise HTTPException(409, "Sync already running")
|
||||
deps._strava_sync_running = True
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
from bincio.sync_strava import sync_all
|
||||
results = sync_all(deps._get_data_dir())
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
if total_new > 0:
|
||||
tasks._site_rebuild_event.set()
|
||||
except Exception:
|
||||
log.exception("admin_strava_sync_run: unexpected error")
|
||||
finally:
|
||||
deps._strava_sync_running = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start()
|
||||
return JSONResponse({"ok": True}, status_code=202)
|
||||
|
||||
|
||||
@router.get("/api/admin/garmin-sync")
|
||||
async def admin_garmin_sync_status(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return per-user Garmin sync status for the admin panel."""
|
||||
deps._require_admin(bincio_session)
|
||||
root = deps._get_data_dir()
|
||||
users = []
|
||||
for cf in sorted(root.glob("*/garmin_creds.json")):
|
||||
user_dir = cf.parent
|
||||
handle = user_dir.name
|
||||
|
||||
last_sync: str | None = None
|
||||
total_imported = 0
|
||||
sync_path = user_dir / "garmin_sync.json"
|
||||
if sync_path.exists():
|
||||
try:
|
||||
sc = json.loads(sync_path.read_text(encoding="utf-8"))
|
||||
last_sync = sc.get("last_sync_at")
|
||||
total_imported = sc.get("total_imported", 0)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
run_status: str | None = None
|
||||
run_imported = 0
|
||||
run_errors = 0
|
||||
run_error_message: str | None = None
|
||||
last_run: str | None = None
|
||||
status_path = user_dir / "_garmin_sync_status.json"
|
||||
if status_path.exists():
|
||||
try:
|
||||
ss = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
run_status = ss.get("status")
|
||||
run_imported = ss.get("imported", 0)
|
||||
run_errors = ss.get("errors", 0)
|
||||
run_error_message = ss.get("error_message")
|
||||
last_run = ss.get("last_run")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
users.append({
|
||||
"handle": handle,
|
||||
"last_sync": last_sync,
|
||||
"total_imported": total_imported,
|
||||
"run_status": run_status,
|
||||
"run_imported": run_imported,
|
||||
"run_errors": run_errors,
|
||||
"run_error_message": run_error_message,
|
||||
"last_run": last_run,
|
||||
})
|
||||
|
||||
return JSONResponse({"running": deps._garmin_sync_running, "users": users})
|
||||
|
||||
|
||||
@router.post("/api/admin/garmin-sync/run")
|
||||
async def admin_garmin_sync_run(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Trigger an immediate Garmin sync for all users (admin only)."""
|
||||
deps._require_admin(bincio_session)
|
||||
with deps._garmin_sync_lock:
|
||||
if deps._garmin_sync_running:
|
||||
raise HTTPException(409, "Sync already running")
|
||||
deps._garmin_sync_running = True
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
from bincio.sync_garmin import sync_all
|
||||
results = sync_all(deps._get_data_dir())
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
if total_new > 0:
|
||||
tasks._site_rebuild_event.set()
|
||||
except Exception:
|
||||
log.exception("admin_garmin_sync_run: unexpected error")
|
||||
finally:
|
||||
deps._garmin_sync_running = False
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="admin-garmin-sync").start()
|
||||
return JSONResponse({"ok": True}, status_code=202)
|
||||
|
||||
|
||||
@router.post("/api/admin/users/{handle}/recompute-elevation")
|
||||
async def admin_recompute_elevation(
|
||||
handle: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Recompute elevation gain/loss for all activities of a user from stored timeseries.
|
||||
|
||||
Skips activities with altitude_source == 'dem' (already DEM-corrected).
|
||||
Applies the leading-zero no-fix fix and source-aware hysteresis.
|
||||
Returns patched/skipped/error counts.
|
||||
"""
|
||||
deps._require_admin(bincio_session)
|
||||
user_dir = deps._get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for '{handle}'")
|
||||
|
||||
from bincio.extract.dem import recalculate_elevation_hysteresis
|
||||
from bincio.render.merge import merge_one
|
||||
|
||||
patched = skipped = errors = 0
|
||||
acts_dir = user_dir / "activities"
|
||||
for json_path in sorted(acts_dir.glob("*.json")):
|
||||
if json_path.name.endswith(".timeseries.json"):
|
||||
continue
|
||||
activity_id = json_path.stem
|
||||
try:
|
||||
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if detail.get("altitude_source") == "dem":
|
||||
skipped += 1
|
||||
continue
|
||||
ts_path = acts_dir / f"{activity_id}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
ele_arr = ts.get("elevation_m") or []
|
||||
if not any(e for e in ele_arr if e is not None):
|
||||
skipped += 1
|
||||
continue
|
||||
recalculate_elevation_hysteresis(user_dir, activity_id)
|
||||
merge_one(user_dir, activity_id)
|
||||
patched += 1
|
||||
except Exception as exc:
|
||||
log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc)
|
||||
errors += 1
|
||||
|
||||
if patched > 0:
|
||||
tasks._trigger_rebuild(handle)
|
||||
|
||||
return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors})
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Authentication and registration endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.models import (
|
||||
CreateInviteRequest,
|
||||
GenericResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
ResetPasswordRequest,
|
||||
)
|
||||
from bincio.serve.db import (
|
||||
authenticate,
|
||||
count_activity_users,
|
||||
count_wiki_users,
|
||||
create_invite,
|
||||
create_session,
|
||||
create_user,
|
||||
delete_session,
|
||||
get_invite,
|
||||
get_setting,
|
||||
get_user,
|
||||
list_invites,
|
||||
use_invite,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/auth/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_req: LoginRequest,
|
||||
request: Request,
|
||||
) -> JSONResponse:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
|
||||
|
||||
handle = login_req.handle.strip().lower()
|
||||
password = login_req.password
|
||||
|
||||
user = authenticate(deps._get_db(), handle, password)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
|
||||
token = create_session(deps._get_db(), handle)
|
||||
resp = JSONResponse({
|
||||
"ok": True,
|
||||
"handle": user.handle,
|
||||
"display_name": user.display_name,
|
||||
"wiki_access": user.wiki_access,
|
||||
"activity_access": user.activity_access,
|
||||
})
|
||||
deps._set_session_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/auth/logout", response_model=GenericResponse)
|
||||
async def logout(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
if bincio_session:
|
||||
delete_session(deps._get_db(), bincio_session)
|
||||
resp = JSONResponse({"ok": True})
|
||||
kwargs: dict = dict(key=deps._SESSION_COOKIE)
|
||||
if deps._SESSION_DOMAIN:
|
||||
kwargs["domain"] = deps._SESSION_DOMAIN
|
||||
resp.delete_cookie(**kwargs)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/auth/token")
|
||||
async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse:
|
||||
"""Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie."""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
deps._check_rate_limit(ip, deps._login_attempts, deps._LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
|
||||
handle = login_req.handle.strip().lower()
|
||||
user = authenticate(deps._get_db(), handle, login_req.password)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
token = create_session(deps._get_db(), handle)
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"token": token,
|
||||
"handle": user.handle,
|
||||
"display_name": user.display_name,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/auth/reset-password", response_model=GenericResponse)
|
||||
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
|
||||
"""Validate a reset code and set a new password. Public endpoint."""
|
||||
from bincio.serve.db import use_reset_code, change_password
|
||||
handle = reset_req.handle.strip().lower()
|
||||
code = reset_req.code.strip().upper()
|
||||
new_pw = reset_req.password
|
||||
if len(new_pw) < 8:
|
||||
raise HTTPException(400, "Password must be at least 8 characters")
|
||||
db = deps._get_db()
|
||||
if not use_reset_code(db, code, handle):
|
||||
raise HTTPException(400, "Invalid or expired reset code")
|
||||
change_password(db, handle, new_pw)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/register", response_model=RegisterResponse)
|
||||
async def register(
|
||||
register_req: RegisterRequest,
|
||||
request: Request,
|
||||
) -> JSONResponse:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
deps._check_rate_limit(ip, deps._register_attempts, deps._REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")
|
||||
|
||||
code = register_req.code.strip().upper()
|
||||
handle = register_req.handle.strip().lower()
|
||||
password = register_req.password
|
||||
display = register_req.display_name.strip() or handle
|
||||
|
||||
if not deps._VALID_HANDLE.match(handle):
|
||||
raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)")
|
||||
if len(password) < 8:
|
||||
raise HTTPException(400, "Password must be at least 8 characters")
|
||||
|
||||
invite = get_invite(deps._get_db(), code)
|
||||
if not invite or invite.used:
|
||||
raise HTTPException(400, "Invalid or already-used invite code")
|
||||
if get_user(deps._get_db(), handle):
|
||||
raise HTTPException(409, "Handle already taken")
|
||||
|
||||
db = deps._get_db()
|
||||
max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users")
|
||||
if max_wiki_val is not None:
|
||||
limit = int(max_wiki_val)
|
||||
if limit > 0 and count_wiki_users(db) >= limit:
|
||||
raise HTTPException(403, f"This instance has reached its wiki user limit ({limit})")
|
||||
|
||||
if invite.grants_activity:
|
||||
max_act_val = get_setting(db, "max_activity_users")
|
||||
if max_act_val is not None:
|
||||
limit = int(max_act_val)
|
||||
if limit > 0 and count_activity_users(db) >= limit:
|
||||
raise HTTPException(403, f"This instance has reached its activity user limit ({limit})")
|
||||
|
||||
create_user(deps._get_db(), handle, display, password, is_admin=False,
|
||||
wiki_access=True, activity_access=invite.grants_activity)
|
||||
use_invite(deps._get_db(), code, handle)
|
||||
|
||||
# Create per-user directories
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / handle
|
||||
(user_dir / "activities").mkdir(parents=True, exist_ok=True)
|
||||
(user_dir / "edits").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write an empty index.json so the shard URL resolves immediately,
|
||||
# even before the user uploads any activities.
|
||||
from bincio.extract.writer import write_index
|
||||
index_path = user_dir / "index.json"
|
||||
if not index_path.exists():
|
||||
write_index([], user_dir, {"handle": handle, "display_name": display or handle})
|
||||
|
||||
# Update root manifest so the new user's shard is discoverable immediately
|
||||
from bincio.render.cli import _write_root_manifest
|
||||
_write_root_manifest(dd)
|
||||
|
||||
# Rebuild site so the new user's profile pages exist immediately
|
||||
tasks._trigger_rebuild(handle)
|
||||
|
||||
token = create_session(deps._get_db(), handle)
|
||||
resp = JSONResponse({"ok": True, "handle": handle})
|
||||
deps._set_session_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
|
||||
# ── Invites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/invites")
|
||||
async def get_invites(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
invites = list_invites(deps._get_db(), user.handle)
|
||||
return JSONResponse([{
|
||||
"code": i.code,
|
||||
"used": i.used,
|
||||
"used_by": i.used_by,
|
||||
"created_at": i.created_at,
|
||||
"used_at": i.used_at,
|
||||
"grants_activity": i.grants_activity,
|
||||
} for i in invites])
|
||||
|
||||
|
||||
@router.post("/api/invites")
|
||||
async def post_invite(
|
||||
body: CreateInviteRequest = CreateInviteRequest(),
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
try:
|
||||
code = create_invite(deps._get_db(), user.handle, grants_activity=body.grants_activity)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity})
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Budget transparency endpoints (/api/budget)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.db import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_BUDGET_FILE = "budget.json"
|
||||
|
||||
|
||||
def _budget_path() -> Path:
|
||||
return deps._get_data_dir() / _BUDGET_FILE
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
p = _budget_path()
|
||||
if not p.exists():
|
||||
return {"monthly_target_eur": None, "entries": []}
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"monthly_target_eur": None, "entries": []}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
_budget_path().write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _materialise_recurring(data: dict) -> bool:
|
||||
"""Auto-add a copy of each recurring entry for the current month if absent.
|
||||
|
||||
Returns True if data was modified (caller should save).
|
||||
Copies reference the template via recurring_from so they're not re-generated.
|
||||
"""
|
||||
current = date.today().strftime("%Y-%m")
|
||||
templates = [e for e in data.get("entries", []) if e.get("recurring")]
|
||||
if not templates:
|
||||
return False
|
||||
modified = False
|
||||
for t in templates:
|
||||
if t["month"] == current:
|
||||
continue # template is already this month
|
||||
already = any(
|
||||
e.get("recurring_from") == t["id"] and e["month"] == current
|
||||
for e in data["entries"]
|
||||
)
|
||||
if not already:
|
||||
data["entries"].append({
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"type": t["type"],
|
||||
"label": t["label"],
|
||||
"amount_eur": t["amount_eur"],
|
||||
"month": current,
|
||||
"note": t.get("note", ""),
|
||||
"recurring_from": t["id"],
|
||||
})
|
||||
modified = True
|
||||
return modified
|
||||
|
||||
|
||||
@router.get("/api/budget")
|
||||
async def get_budget() -> JSONResponse:
|
||||
data = _load()
|
||||
if _materialise_recurring(data):
|
||||
_save(data)
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
@router.post("/api/budget/settings")
|
||||
async def update_settings(
|
||||
request: Request,
|
||||
_: User = Depends(deps._require_admin),
|
||||
) -> JSONResponse:
|
||||
body = await request.json()
|
||||
data = _load()
|
||||
if "monthly_target_eur" in body:
|
||||
v = body["monthly_target_eur"]
|
||||
data["monthly_target_eur"] = round(float(v), 2) if v is not None else None
|
||||
_save(data)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/budget/entries")
|
||||
async def add_entry(
|
||||
request: Request,
|
||||
_: User = Depends(deps._require_admin),
|
||||
) -> JSONResponse:
|
||||
body = await request.json()
|
||||
entry_type = body.get("type")
|
||||
if entry_type not in ("donation", "expense"):
|
||||
raise HTTPException(400, "type must be 'donation' or 'expense'")
|
||||
label = str(body.get("label", "")).strip()
|
||||
if not label:
|
||||
raise HTTPException(400, "label is required")
|
||||
try:
|
||||
amount = round(float(body["amount_eur"]), 2)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise HTTPException(400, "amount_eur must be a number")
|
||||
month = str(body.get("month", "")).strip()
|
||||
if len(month) != 7 or month[4] != "-":
|
||||
raise HTTPException(400, "month must be YYYY-MM")
|
||||
note = str(body.get("note", "")).strip()
|
||||
|
||||
entry: dict = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"type": entry_type,
|
||||
"label": label,
|
||||
"amount_eur": amount,
|
||||
"month": month,
|
||||
"note": note,
|
||||
}
|
||||
if body.get("recurring"):
|
||||
entry["recurring"] = True
|
||||
data = _load()
|
||||
data.setdefault("entries", []).append(entry)
|
||||
_save(data)
|
||||
return JSONResponse(entry, status_code=201)
|
||||
|
||||
|
||||
@router.patch("/api/budget/entries/{entry_id}")
|
||||
async def update_entry(
|
||||
entry_id: str,
|
||||
request: Request,
|
||||
_: User = Depends(deps._require_admin),
|
||||
) -> JSONResponse:
|
||||
body = await request.json()
|
||||
data = _load()
|
||||
entry = next((e for e in data.get("entries", []) if e["id"] == entry_id), None)
|
||||
if not entry:
|
||||
raise HTTPException(404, "Entry not found")
|
||||
if "label" in body:
|
||||
entry["label"] = str(body["label"]).strip()
|
||||
if "type" in body:
|
||||
if body["type"] not in ("donation", "expense"):
|
||||
raise HTTPException(400, "type must be 'donation' or 'expense'")
|
||||
entry["type"] = body["type"]
|
||||
if "amount_eur" in body:
|
||||
entry["amount_eur"] = round(float(body["amount_eur"]), 2)
|
||||
if "month" in body:
|
||||
entry["month"] = str(body["month"]).strip()
|
||||
if "note" in body:
|
||||
entry["note"] = str(body["note"]).strip()
|
||||
if "recurring" in body:
|
||||
if body["recurring"]:
|
||||
entry["recurring"] = True
|
||||
else:
|
||||
entry.pop("recurring", None)
|
||||
_save(data)
|
||||
return JSONResponse(entry)
|
||||
|
||||
|
||||
@router.delete("/api/budget/entries/{entry_id}")
|
||||
async def delete_entry(
|
||||
entry_id: str,
|
||||
_: User = Depends(deps._require_admin),
|
||||
) -> JSONResponse:
|
||||
data = _load()
|
||||
before = len(data.get("entries", []))
|
||||
data["entries"] = [e for e in data.get("entries", []) if e["id"] != entry_id]
|
||||
if len(data["entries"]) == before:
|
||||
raise HTTPException(404, "Entry not found")
|
||||
_save(data)
|
||||
return JSONResponse({"ok": True})
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Activity file download endpoint.
|
||||
|
||||
GET /api/activity/{activity_id}/download/{fmt}
|
||||
fmt: bas | original | gpx
|
||||
|
||||
Permission:
|
||||
- If activity.download_disabled is true: only the owner (authenticated) may download.
|
||||
- Otherwise: no auth required — anyone who can see the activity can download.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _find_activity(activity_id: str) -> tuple[str, Path] | None:
|
||||
"""Return (handle, detail_path) for whichever user owns this activity."""
|
||||
data_dir = deps._get_data_dir()
|
||||
for user_dir in sorted(data_dir.iterdir()):
|
||||
if not user_dir.is_dir() or user_dir.name.startswith(("_", ".")):
|
||||
continue
|
||||
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
|
||||
p = base / f"{activity_id}.json"
|
||||
if p.exists():
|
||||
return user_dir.name, p
|
||||
return None
|
||||
|
||||
|
||||
def _check_download_permission(
|
||||
detail: dict, handle: str, bincio_session: Optional[str]
|
||||
) -> None:
|
||||
if not detail.get("download_disabled"):
|
||||
return
|
||||
try:
|
||||
user = deps._require_user(bincio_session)
|
||||
except HTTPException:
|
||||
raise HTTPException(403, "Downloads are disabled for this activity")
|
||||
if user.handle != handle:
|
||||
raise HTTPException(403, "Downloads are disabled for this activity")
|
||||
|
||||
|
||||
def _generate_gpx(detail: dict, ts: dict) -> str:
|
||||
t_vals = ts.get("t") or []
|
||||
lat_vals = ts.get("lat") or []
|
||||
lon_vals = ts.get("lon") or []
|
||||
ele_vals = ts.get("elevation_m") or []
|
||||
hr_vals = ts.get("hr_bpm") or []
|
||||
|
||||
title = (detail.get("title") or "Activity").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
started = detail.get("started_at") or "1970-01-01T00:00:00+00:00"
|
||||
try:
|
||||
t0 = datetime.fromisoformat(started)
|
||||
except ValueError:
|
||||
t0 = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<gpx version="1.1" creator="bincio"'
|
||||
' xmlns="http://www.topografix.com/GPX/1/1"'
|
||||
' xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">',
|
||||
f' <trk><name>{title}</name><trkseg>',
|
||||
]
|
||||
|
||||
for i, t in enumerate(t_vals):
|
||||
lat = lat_vals[i] if i < len(lat_vals) else None
|
||||
lon = lon_vals[i] if i < len(lon_vals) else None
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
ele = ele_vals[i] if i < len(ele_vals) else None
|
||||
hr = hr_vals[i] if i < len(hr_vals) else None
|
||||
ts_str = (t0 + timedelta(seconds=t)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
trkpt = f' <trkpt lat="{lat}" lon="{lon}">'
|
||||
if ele is not None:
|
||||
trkpt += f"<ele>{ele}</ele>"
|
||||
trkpt += f"<time>{ts_str}</time>"
|
||||
if hr is not None:
|
||||
trkpt += (
|
||||
f"<extensions><gpxtpx:TrackPointExtension>"
|
||||
f"<gpxtpx:hr>{hr}</gpxtpx:hr>"
|
||||
f"</gpxtpx:TrackPointExtension></extensions>"
|
||||
)
|
||||
trkpt += "</trkpt>"
|
||||
lines.append(trkpt)
|
||||
|
||||
lines += [" </trkseg></trk>", "</gpx>"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.get("/api/activity/{activity_id}/download/{fmt}")
|
||||
async def download_activity(
|
||||
activity_id: str,
|
||||
fmt: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> Response:
|
||||
deps._check_id(activity_id)
|
||||
if fmt not in ("bas", "original", "gpx"):
|
||||
raise HTTPException(400, "fmt must be bas, original, or gpx")
|
||||
|
||||
result = _find_activity(activity_id)
|
||||
if result is None:
|
||||
raise HTTPException(404, "Activity not found")
|
||||
handle, detail_path = result
|
||||
|
||||
detail = json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
_check_download_permission(detail, handle, bincio_session)
|
||||
|
||||
if fmt == "bas":
|
||||
# Embed the timeseries so the downloaded file is self-contained.
|
||||
ts_path: Path | None = None
|
||||
data_dir = deps._get_data_dir()
|
||||
for base in (
|
||||
data_dir / handle / "_merged" / "activities",
|
||||
data_dir / handle / "activities",
|
||||
):
|
||||
p = base / f"{activity_id}.timeseries.json"
|
||||
if p.exists():
|
||||
ts_path = p
|
||||
break
|
||||
if ts_path:
|
||||
try:
|
||||
detail["timeseries"] = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
detail.pop("timeseries_url", None)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
content = json.dumps(detail, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{activity_id}.json"'},
|
||||
)
|
||||
|
||||
if fmt == "original":
|
||||
source = detail.get("source") or ""
|
||||
source_file = detail.get("source_file") or ""
|
||||
if source not in ("fit_file", "gpx_file") or not source_file:
|
||||
raise HTTPException(404, "No original file available for this activity")
|
||||
safe_name = Path(source_file).name # strip any directory traversal
|
||||
orig_path = deps._get_data_dir() / handle / "originals" / safe_name
|
||||
if not orig_path.exists():
|
||||
raise HTTPException(404, "Original file not found on disk")
|
||||
media_type = "application/octet-stream"
|
||||
if safe_name.endswith(".fit"):
|
||||
media_type = "application/vnd.ant.fit"
|
||||
elif safe_name.endswith(".gpx"):
|
||||
media_type = "application/gpx+xml"
|
||||
return FileResponse(
|
||||
orig_path,
|
||||
media_type=media_type,
|
||||
filename=safe_name,
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
|
||||
)
|
||||
|
||||
# fmt == "gpx"
|
||||
data_dir = deps._get_data_dir()
|
||||
ts_path: Path | None = None
|
||||
for base in (
|
||||
data_dir / handle / "_merged" / "activities",
|
||||
data_dir / handle / "activities",
|
||||
):
|
||||
p = base / f"{activity_id}.timeseries.json"
|
||||
if p.exists():
|
||||
ts_path = p
|
||||
break
|
||||
|
||||
if ts_path is None:
|
||||
raise HTTPException(404, "No GPS data available for this activity")
|
||||
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
lat_vals = ts.get("lat") or []
|
||||
if not any(v is not None for v in lat_vals):
|
||||
raise HTTPException(404, "No GPS data available for this activity")
|
||||
|
||||
gpx_content = _generate_gpx(detail, ts)
|
||||
raw_title = detail.get("title") or activity_id
|
||||
safe_title = "".join(c for c in raw_title if c.isalnum() or c in " -_")[:50].strip()
|
||||
filename = f"{safe_title or activity_id}.gpx"
|
||||
return Response(
|
||||
content=gpx_content,
|
||||
media_type="application/gpx+xml",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Feed and wheel endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.models import CurrentUserResponse
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
get_member_tree,
|
||||
get_setting,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/me", response_model=CurrentUserResponse)
|
||||
async def me(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._current_user(bincio_session)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
store_orig = get_setting(deps._get_db(), "store_originals")
|
||||
return JSONResponse({
|
||||
"handle": user.handle,
|
||||
"display_name": user.display_name,
|
||||
"is_admin": user.is_admin,
|
||||
"wiki_access": user.wiki_access,
|
||||
"activity_access": user.activity_access,
|
||||
"store_originals_default": store_orig != "false",
|
||||
"dem_configured": bool(deps.dem_url),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/stats")
|
||||
async def stats() -> JSONResponse:
|
||||
"""Public endpoint: member count, join dates, and invitation tree."""
|
||||
import time as _time
|
||||
now = int(_time.time())
|
||||
members = get_member_tree(deps._get_db())
|
||||
return JSONResponse({
|
||||
"user_count": len(members),
|
||||
"members": [
|
||||
{
|
||||
"handle": m["handle"],
|
||||
"display_name": m["display_name"],
|
||||
"member_since": m["created_at"],
|
||||
"member_for_days": (now - m["created_at"]) // 86400,
|
||||
"invited_by": m["invited_by"],
|
||||
}
|
||||
for m in members
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/internal/rebuild")
|
||||
async def internal_rebuild(request: Request) -> JSONResponse:
|
||||
"""Trigger a site rebuild. Authenticated via X-Sync-Secret header.
|
||||
|
||||
Called by the bincio sync-strava systemd timer after syncing new activities.
|
||||
Returns 503 if webroot is not configured (rebuild not possible).
|
||||
Returns 403 if the secret is missing or wrong.
|
||||
"""
|
||||
if not deps.sync_secret:
|
||||
raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)")
|
||||
if request.headers.get("X-Sync-Secret") != deps.sync_secret:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
if deps.site_dir is None:
|
||||
raise HTTPException(503, "No site dir configured")
|
||||
tasks._site_rebuild_event.set()
|
||||
return JSONResponse({"status": "rebuild queued"})
|
||||
|
||||
|
||||
@router.get("/api/wheel/version")
|
||||
async def wheel_version() -> JSONResponse:
|
||||
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
||||
import importlib.metadata
|
||||
try:
|
||||
version = importlib.metadata.version("bincio")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
version = "0.1.0"
|
||||
return JSONResponse({
|
||||
"version": version,
|
||||
"url": f"/bincio-{version}-py3-none-any.whl",
|
||||
"api_url": f"/api/wheel/download",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/wheel/download")
|
||||
async def wheel_download() -> FileResponse:
|
||||
"""Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl)."""
|
||||
import importlib.metadata
|
||||
from pathlib import Path
|
||||
try:
|
||||
version = importlib.metadata.version("bincio")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
version = "0.1.0"
|
||||
wheel_name = f"bincio-{version}-py3-none-any.whl"
|
||||
# Look in dist/ relative to repo root (two levels up from this file)
|
||||
dist_dir = Path(__file__).parent.parent.parent.parent / "dist"
|
||||
wheel_path = dist_dir / wheel_name
|
||||
if not wheel_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/")
|
||||
return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name)
|
||||
|
||||
|
||||
@router.get("/api/feed")
|
||||
async def get_feed(user: User = Depends(deps._require_auth)) -> JSONResponse:
|
||||
"""Return the authenticated user's activity summaries (mobile feed sync).
|
||||
|
||||
_merged/index.json is a shard manifest (activities: []) when the user has
|
||||
more than FEED_PAGE_SIZE activities. Collect from all shard files.
|
||||
"""
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"):
|
||||
if not index_path.exists():
|
||||
continue
|
||||
index = json.loads(index_path.read_text())
|
||||
activities: list[dict] = index.get("activities", [])
|
||||
for shard in index.get("shards", []):
|
||||
shard_path = index_path.parent / shard["url"]
|
||||
if shard_path.exists():
|
||||
shard_doc = json.loads(shard_path.read_text())
|
||||
activities.extend(shard_doc.get("activities", []))
|
||||
return JSONResponse({"activities": activities})
|
||||
return JSONResponse({"activities": []})
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Garmin Connect endpoints (/api/garmin/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _garmin_user_message(exc: Exception) -> str:
|
||||
"""Return a human-friendly error message for common Garmin login failures."""
|
||||
msg = str(exc)
|
||||
fallback = (
|
||||
" In the meantime, you can export your activities from Garmin Connect "
|
||||
"(garmin.com → Activities → Export) or Garmin Express as FIT files "
|
||||
"and upload them directly."
|
||||
)
|
||||
if "429" in msg or "rate limit" in msg.lower():
|
||||
return (
|
||||
"Garmin is rate-limiting this server's IP address (HTTP 429). "
|
||||
"Wait a few hours and try again." + fallback
|
||||
)
|
||||
if "403" in msg:
|
||||
return (
|
||||
"Cloudflare is blocking the login request (HTTP 403). "
|
||||
"This is a known upstream issue — try again later or update garminconnect "
|
||||
"(uv sync --extra garmin)." + fallback
|
||||
)
|
||||
if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower():
|
||||
return (
|
||||
"Garmin's login page returned a CAPTCHA or MFA challenge that "
|
||||
"cannot be completed automatically. Try again later, or disable "
|
||||
"two-factor authentication on your Garmin account." + fallback
|
||||
)
|
||||
return f"Login failed: {exc}" + fallback
|
||||
|
||||
|
||||
@router.get("/api/garmin/status")
|
||||
async def garmin_status(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return whether Garmin credentials are stored for the current user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.garmin_api import has_credentials
|
||||
from bincio.extract.garmin_sync import _load_sync_state
|
||||
connected = has_credentials(dd)
|
||||
last_sync = None
|
||||
if connected:
|
||||
state = _load_sync_state(dd)
|
||||
last_sync = state.get("last_sync_at")
|
||||
return JSONResponse({"connected": connected, "last_sync": last_sync})
|
||||
|
||||
|
||||
@router.post("/api/garmin/connect")
|
||||
async def garmin_connect(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Test Garmin login with the supplied credentials and save them on success."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
email = (body.get("email") or "").strip()
|
||||
password = body.get("password") or ""
|
||||
if not email or not password:
|
||||
raise HTTPException(400, "email and password are required")
|
||||
|
||||
data_dir = deps._get_data_dir()
|
||||
user_dir = data_dir / user.handle
|
||||
from bincio.extract.garmin_api import GarminError, test_login
|
||||
try:
|
||||
info = test_login(data_dir, user_dir, email, password)
|
||||
except GarminError as exc:
|
||||
raise HTTPException(400, _garmin_user_message(exc))
|
||||
return JSONResponse({"ok": True, **info})
|
||||
|
||||
|
||||
@router.post("/api/garmin/disconnect")
|
||||
async def garmin_disconnect(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Remove stored Garmin credentials and session for the current user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.garmin_api import delete_credentials
|
||||
delete_credentials(dd)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/garmin/sync/stream")
|
||||
async def garmin_sync_stream(bincio_session: str | None = Cookie(default=None)) -> StreamingResponse:
|
||||
"""SSE endpoint — streams per-activity Garmin sync progress."""
|
||||
user = deps._require_user(bincio_session)
|
||||
data_dir = deps._get_data_dir()
|
||||
user_dir = data_dir / user.handle
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials
|
||||
if not has_credentials(user_dir):
|
||||
raise HTTPException(400, "No Garmin credentials stored — connect first")
|
||||
|
||||
from bincio.extract.garmin_sync import garmin_sync_iter
|
||||
|
||||
def event_stream():
|
||||
try:
|
||||
for event in garmin_sync_iter(data_dir, user_dir):
|
||||
if event["type"] == "done":
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except GarminError as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/garmin/import-gear")
|
||||
async def garmin_import_gear(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""One-time backfill: fetch gear registry from Garmin and match to existing activities by timestamp."""
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials
|
||||
from bincio.extract.garmin_sync import import_garmin_gear
|
||||
|
||||
user = deps._require_user(bincio_session)
|
||||
data_dir = deps._get_data_dir()
|
||||
user_dir = data_dir / user.handle
|
||||
|
||||
if not has_credentials(user_dir):
|
||||
raise HTTPException(400, "No Garmin credentials stored — connect first")
|
||||
|
||||
try:
|
||||
result = import_garmin_gear(data_dir, user_dir)
|
||||
except GarminError as exc:
|
||||
raise HTTPException(502, _garmin_user_message(exc))
|
||||
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True, **result})
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Gear registry endpoints (/api/gear)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_GEAR_TYPES = {"bike", "shoes", "skis", "other"}
|
||||
|
||||
|
||||
def _gear_path(user_dir: Path) -> Path:
|
||||
return user_dir / "gear.json"
|
||||
|
||||
|
||||
def _load(user_dir: Path) -> list[dict]:
|
||||
p = _gear_path(user_dir)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return data.get("items", [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def _save(user_dir: Path, items: list[dict]) -> None:
|
||||
_gear_path(user_dir).write_text(
|
||||
json.dumps({"items": items}, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/gear")
|
||||
async def gear_list(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
items = _load(deps._get_data_dir() / user.handle)
|
||||
return JSONResponse({"items": items})
|
||||
|
||||
|
||||
@router.post("/api/gear")
|
||||
async def gear_add(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
name = str(body.get("name", "")).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
gear_type = str(body.get("type", "other")).strip()
|
||||
if gear_type not in _GEAR_TYPES:
|
||||
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
|
||||
strava_id = str(body.get("strava_id", "")).strip() or None
|
||||
weight_g = body.get("weight_g")
|
||||
if weight_g is not None:
|
||||
try:
|
||||
weight_g = int(weight_g)
|
||||
if weight_g < 0:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "weight_g must be a non-negative integer (grams)")
|
||||
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
# Deduplicate by strava_id if provided
|
||||
if strava_id and any(i.get("strava_id") == strava_id for i in items):
|
||||
existing = next(i for i in items if i.get("strava_id") == strava_id)
|
||||
return JSONResponse({"ok": True, "item": existing, "created": False})
|
||||
|
||||
item: dict = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"type": gear_type,
|
||||
"retired": False,
|
||||
}
|
||||
if strava_id:
|
||||
item["strava_id"] = strava_id
|
||||
if weight_g is not None:
|
||||
item["weight_g"] = weight_g
|
||||
|
||||
items.append(item)
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "item": item, "created": True}, status_code=201)
|
||||
|
||||
|
||||
@router.patch("/api/gear/{item_id}")
|
||||
async def gear_update(
|
||||
item_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
|
||||
body = await request.json()
|
||||
item = dict(items[idx])
|
||||
|
||||
if "name" in body:
|
||||
name = str(body["name"]).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name cannot be empty")
|
||||
item["name"] = name
|
||||
if "type" in body:
|
||||
gear_type = str(body["type"]).strip()
|
||||
if gear_type not in _GEAR_TYPES:
|
||||
raise HTTPException(400, f"type must be one of: {', '.join(sorted(_GEAR_TYPES))}")
|
||||
item["type"] = gear_type
|
||||
if "retired" in body:
|
||||
item["retired"] = bool(body["retired"])
|
||||
if "weight_g" in body:
|
||||
w = body["weight_g"]
|
||||
if w is None:
|
||||
item.pop("weight_g", None)
|
||||
else:
|
||||
try:
|
||||
w = int(w)
|
||||
if w < 0:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "weight_g must be a non-negative integer (grams)")
|
||||
item["weight_g"] = w
|
||||
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "item": item})
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}")
|
||||
async def gear_delete(
|
||||
item_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
|
||||
before = len(items)
|
||||
items = [g for g in items if g["id"] != item_id]
|
||||
if len(items) == before:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Parts ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_item(items: list[dict], item_id: str) -> tuple[int, dict]:
|
||||
idx = next((i for i, g in enumerate(items) if g["id"] == item_id), None)
|
||||
if idx is None:
|
||||
raise HTTPException(404, "Gear item not found")
|
||||
return idx, items[idx]
|
||||
|
||||
|
||||
@router.post("/api/gear/{item_id}/parts")
|
||||
async def part_add(
|
||||
item_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
body = await request.json()
|
||||
name = str(body.get("name", "")).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
threshold_km = body.get("threshold_km")
|
||||
if threshold_km is not None:
|
||||
try:
|
||||
threshold_km = float(threshold_km)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "threshold_km must be a number")
|
||||
|
||||
part: dict = {"id": str(uuid.uuid4()), "name": name, "replacements": []}
|
||||
if threshold_km is not None:
|
||||
part["threshold_km"] = threshold_km
|
||||
|
||||
item = dict(item)
|
||||
item.setdefault("parts", [])
|
||||
item["parts"] = [*item["parts"], part]
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "part": part}, status_code=201)
|
||||
|
||||
|
||||
@router.patch("/api/gear/{item_id}/parts/{part_id}")
|
||||
async def part_update(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
body = await request.json()
|
||||
part = dict(parts[pidx])
|
||||
if "name" in body:
|
||||
name = str(body["name"]).strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name cannot be empty")
|
||||
part["name"] = name
|
||||
if "threshold_km" in body:
|
||||
try:
|
||||
part["threshold_km"] = float(body["threshold_km"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "threshold_km must be a number")
|
||||
|
||||
parts[pidx] = part
|
||||
item = {**item, "parts": parts}
|
||||
items[idx] = item
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "part": part})
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}/parts/{part_id}")
|
||||
async def part_delete(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = [p for p in item.get("parts", []) if p["id"] != part_id]
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/gear/{item_id}/parts/{part_id}/replacements")
|
||||
async def replacement_add(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Log a replacement event for a part. date defaults to today (UTC)."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
body = await request.json()
|
||||
date = str(body.get("date", "")).strip() or datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
note = str(body.get("note", "")).strip() or None
|
||||
|
||||
entry: dict = {"id": str(uuid.uuid4()), "date": date}
|
||||
if note:
|
||||
entry["note"] = note
|
||||
|
||||
part = dict(parts[pidx])
|
||||
part["replacements"] = [*part.get("replacements", []), entry]
|
||||
parts[pidx] = part
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True, "replacement": entry}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/gear/{item_id}/parts/{part_id}/replacements/{replacement_id}")
|
||||
async def replacement_delete(
|
||||
item_id: str,
|
||||
part_id: str,
|
||||
replacement_id: str,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
items = _load(user_dir)
|
||||
idx, item = _find_item(items, item_id)
|
||||
|
||||
parts = list(item.get("parts", []))
|
||||
pidx = next((i for i, p in enumerate(parts) if p["id"] == part_id), None)
|
||||
if pidx is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
part = dict(parts[pidx])
|
||||
part["replacements"] = [r for r in part.get("replacements", []) if r["id"] != replacement_id]
|
||||
parts[pidx] = part
|
||||
items[idx] = {**item, "parts": parts}
|
||||
_save(user_dir, items)
|
||||
return JSONResponse({"ok": True})
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Ideas and feedback endpoints (/api/ideas/*, /api/feedback)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl as _fcntl
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.models import IdeaBody, IdeaCommentBody
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
||||
_FEEDBACK_MAX_IMAGES = 3
|
||||
_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
|
||||
|
||||
def _ideas_dir(data_dir: Path) -> Path:
|
||||
d = data_dir / "_ideas"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/api/ideas")
|
||||
async def list_ideas(
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
ideas = []
|
||||
for path in sorted(_ideas_dir(dd).glob("*.json")):
|
||||
try:
|
||||
idea = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
votes = idea.get("votes", [])
|
||||
idea["vote_count"] = len(votes)
|
||||
idea["my_vote"] = user.handle in votes
|
||||
ideas.append(idea)
|
||||
def _sort_key(x: dict):
|
||||
s = x.get("status") or "open"
|
||||
order = {"awaiting": 0, "open": 1, "done": 2, "declined": 3}
|
||||
return (order.get(s, 1), -x["vote_count"], -x["created_at"])
|
||||
ideas.sort(key=_sort_key)
|
||||
return JSONResponse({"ideas": ideas})
|
||||
|
||||
|
||||
@router.post("/api/ideas")
|
||||
async def create_idea(
|
||||
data: IdeaBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
title = data.title.strip()[:200]
|
||||
body = data.body.strip()[:2000]
|
||||
if not title:
|
||||
raise HTTPException(400, "Title required")
|
||||
dd = deps._get_data_dir()
|
||||
idea_id = secrets.token_hex(8)
|
||||
idea = {
|
||||
"id": idea_id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"author": user.handle,
|
||||
"created_at": int(time.time()),
|
||||
"votes": [],
|
||||
}
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return JSONResponse({"id": idea_id})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/vote")
|
||||
async def toggle_idea_vote(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
votes: list = idea.get("votes", [])
|
||||
if user.handle in votes:
|
||||
votes.remove(user.handle)
|
||||
voted = False
|
||||
else:
|
||||
votes.append(user.handle)
|
||||
voted = True
|
||||
idea["votes"] = votes
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"voted": voted, "votes": len(votes)})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/status")
|
||||
async def toggle_idea_status(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
cycle = {"open": "awaiting", "awaiting": "done", "done": "open"}
|
||||
idea["status"] = cycle.get(idea.get("status") or "open", "awaiting")
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"status": idea["status"]})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/reopen")
|
||||
async def reopen_idea(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
idea["status"] = "open"
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"status": "open"})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/decline")
|
||||
async def decline_idea(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
idea["status"] = "open" if idea.get("status") == "declined" else "declined"
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"status": idea["status"]})
|
||||
|
||||
|
||||
@router.post("/api/ideas/{idea_id}/comment")
|
||||
async def set_idea_comment(
|
||||
idea_id: str,
|
||||
data: IdeaCommentBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
comment = data.comment.strip()[:1000]
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
if comment:
|
||||
idea["admin_comment"] = comment
|
||||
else:
|
||||
idea.pop("admin_comment", None)
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"ok": True, "admin_comment": comment or None})
|
||||
|
||||
|
||||
@router.patch("/api/ideas/{idea_id}")
|
||||
async def edit_idea(
|
||||
idea_id: str,
|
||||
data: IdeaBody,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
title = data.title.strip()[:200]
|
||||
body = data.body.strip()[:2000]
|
||||
if not title:
|
||||
raise HTTPException(400, "Title required")
|
||||
with open(path, "r+", encoding="utf-8") as f:
|
||||
_fcntl.flock(f, _fcntl.LOCK_EX)
|
||||
idea = json.load(f)
|
||||
if not user.is_admin and idea.get("author") != user.handle:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
idea["title"] = title
|
||||
idea["body"] = body
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(idea, f, ensure_ascii=False, indent=2)
|
||||
return JSONResponse({"ok": True, "title": title, "body": body})
|
||||
|
||||
|
||||
@router.delete("/api/ideas/{idea_id}")
|
||||
async def delete_idea(
|
||||
idea_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
path = _ideas_dir(dd) / f"{idea_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Not found")
|
||||
try:
|
||||
idea = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
raise HTTPException(500, "Could not read idea")
|
||||
if not user.is_admin and idea.get("author") != user.handle:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
path.unlink()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ── Feedback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/feedback")
|
||||
async def submit_feedback(
|
||||
text: str = Form(""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
|
||||
text = text.strip()
|
||||
if not text and not any(f.filename for f in images):
|
||||
raise HTTPException(400, "Feedback must include text or at least one image")
|
||||
if len(images) > _FEEDBACK_MAX_IMAGES:
|
||||
raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission")
|
||||
|
||||
feedback_dir = deps._get_data_dir() / "_feedback"
|
||||
feedback_dir.mkdir(exist_ok=True)
|
||||
images_dir = feedback_dir / user.handle
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
|
||||
now = int(time.time())
|
||||
submission_id = f"{now}_{secrets.token_hex(4)}"
|
||||
saved_images: list[str] = []
|
||||
|
||||
for img in images:
|
||||
if not img.filename:
|
||||
continue
|
||||
suffix = Path(img.filename).suffix.lower()
|
||||
if suffix not in _FEEDBACK_IMAGE_SUFFIXES:
|
||||
raise HTTPException(400, f"Unsupported image type '{suffix}'")
|
||||
contents = await img.read()
|
||||
if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit")
|
||||
safe_name = f"{submission_id}_{Path(img.filename).name}"
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
saved_images.append(safe_name)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
entry = {
|
||||
"id": submission_id,
|
||||
"handle": user.handle,
|
||||
"submitted_at": datetime.now(timezone.utc).isoformat(),
|
||||
"text": text,
|
||||
"images": saved_images,
|
||||
}
|
||||
|
||||
log_file = feedback_dir / f"{user.handle}.json"
|
||||
existing: list[dict] = []
|
||||
if log_file.exists():
|
||||
try:
|
||||
existing = json.loads(log_file.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
existing = []
|
||||
existing.append(entry)
|
||||
log_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
return JSONResponse({"ok": True, "id": submission_id})
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Self-service user settings endpoints (/api/me/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.db import (
|
||||
authenticate,
|
||||
get_user_prefs,
|
||||
set_user_prefs,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _wipe_user_activities(user_dir: Path) -> int:
|
||||
"""Delete all extracted activity files and caches for a user.
|
||||
|
||||
Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
|
||||
_merged/, index.json, athlete.json, and the dedup cache.
|
||||
Leaves the user directory itself intact (account remains in the DB).
|
||||
Returns the number of files deleted.
|
||||
"""
|
||||
import shutil
|
||||
deleted = 0
|
||||
|
||||
for subdir in ("activities", "edits", "originals"):
|
||||
d = user_dir / subdir
|
||||
if d.exists():
|
||||
for f in d.rglob("*"):
|
||||
if f.is_file():
|
||||
deleted += 1
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("_merged", ):
|
||||
d = user_dir / name
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
|
||||
for name in ("index.json", "athlete.json", ".bincio_cache.json", "tracks.json", "tracks_index.json"):
|
||||
f = user_dir / name
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
|
||||
for shard in user_dir.glob("tracks_*.json"):
|
||||
shard.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
@router.get("/api/me/tracks")
|
||||
async def me_tracks(bincio_session: str | None = Cookie(default=None)) -> Response:
|
||||
"""Return the tracks manifest (years list + total) for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
index_path = deps._get_data_dir() / user.handle / "tracks_index.json"
|
||||
if not index_path.exists():
|
||||
raise HTTPException(404, "Tracks not yet baked — upload an activity first")
|
||||
return Response(content=index_path.read_bytes(), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/api/me/tracks/{year}")
|
||||
async def me_tracks_year(year: str, bincio_session: str | None = Cookie(default=None)) -> Response:
|
||||
"""Return the pre-baked tracks shard for a specific year."""
|
||||
user = deps._require_user(bincio_session)
|
||||
if not year.isdigit() or len(year) != 4:
|
||||
raise HTTPException(400, "year must be a 4-digit string")
|
||||
shard_path = deps._get_data_dir() / user.handle / f"tracks_{year}.json"
|
||||
if not shard_path.exists():
|
||||
raise HTTPException(404, f"No tracks shard for year {year}")
|
||||
return Response(content=shard_path.read_bytes(), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/api/me/storage")
|
||||
async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return per-category disk usage for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
|
||||
def _mb(path: Path) -> float:
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
|
||||
return round(total / 1_048_576, 2)
|
||||
|
||||
def _count(path: Path, pattern: str = "*") -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
return sum(1 for f in path.glob(pattern) if f.is_file())
|
||||
|
||||
activities_mb = _mb(dd / "activities")
|
||||
originals_mb = _mb(dd / "originals")
|
||||
strava_mb = _mb(dd / "originals" / "strava")
|
||||
images_mb = _mb(dd / "edits" / "images")
|
||||
total_mb = _mb(dd)
|
||||
|
||||
return JSONResponse({
|
||||
"total_mb": total_mb,
|
||||
"activities_mb": activities_mb,
|
||||
"activities_count": _count(dd / "activities", "*.json"),
|
||||
"originals_mb": originals_mb,
|
||||
"strava_originals_mb": strava_mb,
|
||||
"strava_originals_count": _count(dd / "originals" / "strava", "*.json"),
|
||||
"images_mb": images_mb,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/api/me/originals")
|
||||
async def me_delete_originals(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Delete the user's originals/ directory (frees space after re-extraction)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
originals = deps._get_data_dir() / user.handle / "originals"
|
||||
if not originals.exists():
|
||||
return JSONResponse({"ok": True, "freed_mb": 0.0})
|
||||
|
||||
freed = round(
|
||||
sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2
|
||||
)
|
||||
shutil.rmtree(originals)
|
||||
return JSONResponse({"ok": True, "freed_mb": freed})
|
||||
|
||||
|
||||
@router.delete("/api/me/activities")
|
||||
async def me_delete_activities(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON).
|
||||
|
||||
Requires the user's current password in the request body for confirmation.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
password = body.get("password", "")
|
||||
if not authenticate(deps._get_db(), user.handle, password):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
deleted = _wipe_user_activities(user_dir)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True, "deleted": deleted})
|
||||
|
||||
|
||||
@router.delete("/api/me")
|
||||
async def me_delete_account(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete the account and all data permanently.
|
||||
|
||||
Requires the user's current password. Deletes the DB row, all sessions,
|
||||
and the entire user data directory. The root shard manifest is updated.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
password = body.get("password", "")
|
||||
if not authenticate(deps._get_db(), user.handle, password):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
|
||||
# Wipe data directory
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
if user_dir.is_dir():
|
||||
shutil.rmtree(user_dir)
|
||||
|
||||
# Remove from DB (cascades to sessions, invites, reset_codes)
|
||||
from bincio.serve.db import delete_user as _delete_user
|
||||
_delete_user(deps._get_db(), user.handle)
|
||||
|
||||
# Update root manifest so the shard disappears
|
||||
from bincio.render.cli import _write_root_manifest
|
||||
try:
|
||||
_write_root_manifest(deps._get_data_dir())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie(deps._SESSION_COOKIE)
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/api/me/display-name")
|
||||
async def me_update_display_name(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Update the logged-in user's display name."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
display_name = str(body.get("display_name", "")).strip()
|
||||
if len(display_name) > 60:
|
||||
raise HTTPException(400, "Display name too long (max 60 characters)")
|
||||
db = deps._get_db()
|
||||
db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle))
|
||||
db.commit()
|
||||
return JSONResponse({"ok": True, "display_name": display_name})
|
||||
|
||||
|
||||
@router.get("/api/me/prefs")
|
||||
async def me_get_prefs(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return all user preferences as a key→value dict."""
|
||||
user = deps._require_user(bincio_session)
|
||||
return JSONResponse(get_user_prefs(deps._get_db(), user.handle))
|
||||
|
||||
|
||||
@router.put("/api/me/prefs")
|
||||
async def me_set_prefs(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Upsert one or more user preferences. Body: {key: value, ...} (all strings)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Body must be a JSON object")
|
||||
# Coerce all values to strings; ignore unknown keys silently
|
||||
prefs = {str(k): str(v) for k, v in body.items()}
|
||||
set_user_prefs(deps._get_db(), user.handle, prefs)
|
||||
|
||||
# Mirror download_disabled_default to a file so the render pipeline can read it
|
||||
if "download_disabled_default" in prefs:
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
settings_path = user_dir / "_user_settings.json"
|
||||
try:
|
||||
current = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
current = {}
|
||||
current["download_disabled_default"] = prefs["download_disabled_default"] == "true"
|
||||
settings_path.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/me/strava-credentials")
|
||||
async def me_get_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Return whether per-user Strava credentials are configured (never returns the secret)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
|
||||
has_user_creds = False
|
||||
client_id_hint = ""
|
||||
if creds_path.exists():
|
||||
try:
|
||||
d = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
cid = str(d.get("client_id", "")).strip()
|
||||
csec = str(d.get("client_secret", "")).strip()
|
||||
if cid and csec:
|
||||
has_user_creds = True
|
||||
client_id_hint = cid
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return JSONResponse({
|
||||
"has_user_creds": has_user_creds,
|
||||
"client_id": client_id_hint,
|
||||
"instance_configured": bool(deps.strava_client_id),
|
||||
})
|
||||
|
||||
|
||||
@router.put("/api/me/strava-credentials")
|
||||
async def me_set_strava_credentials(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Save per-user Strava credentials. Body: {client_id, client_secret}."""
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
cid = str(body.get("client_id", "")).strip()
|
||||
csec = str(body.get("client_secret", "")).strip()
|
||||
if not cid:
|
||||
raise HTTPException(400, "client_id is required")
|
||||
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
|
||||
# If client_secret is omitted, preserve existing secret (if any)
|
||||
if not csec:
|
||||
if creds_path.exists():
|
||||
try:
|
||||
existing = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
csec = str(existing.get("client_secret", "")).strip()
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
if not csec:
|
||||
raise HTTPException(400, "client_secret is required (no existing secret to preserve)")
|
||||
|
||||
# If the client_id changed, the existing token belongs to a different OAuth
|
||||
# app and will fail on refresh — delete it so the user must re-authenticate.
|
||||
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
|
||||
if creds_path.exists() and token_path.exists():
|
||||
try:
|
||||
old_cid = str(json.loads(creds_path.read_text(encoding="utf-8")).get("client_id", "")).strip()
|
||||
if old_cid and old_cid != cid:
|
||||
token_path.unlink(missing_ok=True)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
creds_path.write_text(
|
||||
json.dumps({"client_id": cid, "client_secret": csec}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.delete("/api/me/strava-credentials")
|
||||
async def me_delete_strava_credentials(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||
"""Remove per-user Strava credentials (falls back to instance credentials)."""
|
||||
user = deps._require_user(bincio_session)
|
||||
creds_path = deps._get_data_dir() / user.handle / deps._STRAVA_CREDS_FILE
|
||||
creds_path.unlink(missing_ok=True)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.put("/api/me/password")
|
||||
async def me_change_password(
|
||||
request: Request,
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Change the logged-in user's password. Requires current password."""
|
||||
from bincio.serve.db import change_password as _change_password
|
||||
user = deps._require_user(bincio_session)
|
||||
body = await request.json()
|
||||
current = body.get("current_password", "")
|
||||
new_pw = body.get("new_password", "")
|
||||
if not authenticate(deps._get_db(), user.handle, current):
|
||||
raise HTTPException(401, "Current password is wrong")
|
||||
if len(new_pw) < 8:
|
||||
raise HTTPException(400, "New password must be at least 8 characters")
|
||||
_change_password(deps._get_db(), user.handle, new_pw)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/me/sync-status")
|
||||
async def get_sync_status(
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return the last sync status for Strava and Garmin for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
|
||||
def _read_status(filename: str) -> dict | None:
|
||||
p = user_dir / filename
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
strava = _read_status("_strava_sync_status.json")
|
||||
garmin = _read_status("_garmin_sync_status.json")
|
||||
return JSONResponse({
|
||||
"strava": strava,
|
||||
"garmin": garmin,
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Merge and unmerge activity endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.db import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_MERGES_FILE = "_merges.json"
|
||||
_BACKUP_DIR = "_merge_backup"
|
||||
|
||||
|
||||
def _read_merges(user_dir: Path) -> dict:
|
||||
p = user_dir / _MERGES_FILE
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return {"hidden": [], "merges": {}}
|
||||
|
||||
|
||||
def _write_merges(user_dir: Path, data: dict) -> None:
|
||||
(user_dir / _MERGES_FILE).write_text(
|
||||
json.dumps(data, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@router.get("/api/merges")
|
||||
async def get_merges(user: User = Depends(deps._require_auth)) -> JSONResponse:
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
return JSONResponse(_read_merges(user_dir))
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
activity_ids: list[str]
|
||||
|
||||
|
||||
@router.post("/api/merge")
|
||||
async def merge_activities(
|
||||
body: MergeRequest,
|
||||
user: User = Depends(deps._require_auth),
|
||||
) -> JSONResponse:
|
||||
if len(body.activity_ids) < 2:
|
||||
raise HTTPException(400, "Need at least 2 activities to merge")
|
||||
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
|
||||
activities: list[dict] = []
|
||||
for aid in body.activity_ids:
|
||||
deps._check_id(aid)
|
||||
p = acts_dir / f"{aid}.json"
|
||||
if not p.exists():
|
||||
raise HTTPException(404, f"Activity {aid} not found")
|
||||
try:
|
||||
a = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
raise HTTPException(500, f"Could not read activity {aid}")
|
||||
a["_id"] = aid
|
||||
activities.append(a)
|
||||
|
||||
sports = {a.get("sport") for a in activities}
|
||||
if len(sports) > 1:
|
||||
raise HTTPException(400, "Cannot merge activities of different sports")
|
||||
|
||||
activities.sort(key=lambda a: a.get("started_at", ""))
|
||||
primary = activities[0]
|
||||
secondaries = activities[1:]
|
||||
primary_id = primary["_id"]
|
||||
secondary_ids = [a["_id"] for a in secondaries]
|
||||
|
||||
def _sum(key: str) -> float | None:
|
||||
vals = [a.get(key) for a in activities if a.get(key) is not None]
|
||||
return sum(vals) if vals else None
|
||||
|
||||
def _wavg(key: str) -> float | None:
|
||||
pairs = [(a.get(key), a.get("moving_time_s")) for a in activities]
|
||||
pairs = [(v, w) for v, w in pairs if v is not None and w and w > 0]
|
||||
if not pairs:
|
||||
return None
|
||||
total_w = sum(w for _, w in pairs)
|
||||
return sum(v * w for v, w in pairs) / total_w if total_w > 0 else None
|
||||
|
||||
distance_m = _sum("distance_m")
|
||||
duration_s = _sum("duration_s")
|
||||
moving_time_s = _sum("moving_time_s")
|
||||
elevation_gain_m = _sum("elevation_gain_m")
|
||||
avg_speed_kmh = (distance_m / moving_time_s * 3.6) if distance_m and moving_time_s else None
|
||||
avg_hr_bpm_val = _wavg("avg_hr_bpm")
|
||||
avg_power_w_val = _wavg("avg_power_w")
|
||||
|
||||
backup_dir = user_dir / _BACKUP_DIR
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||
src = acts_dir / f"{primary_id}{suffix}"
|
||||
if src.exists():
|
||||
shutil.copy2(src, backup_dir / f"{primary_id}{suffix}.bak")
|
||||
|
||||
primary_data: dict[str, Any] = {k: v for k, v in primary.items() if not k.startswith("_")}
|
||||
primary_data["distance_m"] = distance_m
|
||||
primary_data["duration_s"] = duration_s
|
||||
primary_data["moving_time_s"] = moving_time_s
|
||||
primary_data["elevation_gain_m"] = elevation_gain_m
|
||||
if avg_speed_kmh is not None:
|
||||
primary_data["avg_speed_kmh"] = round(avg_speed_kmh, 3)
|
||||
if avg_hr_bpm_val is not None:
|
||||
primary_data["avg_hr_bpm"] = round(avg_hr_bpm_val)
|
||||
if avg_power_w_val is not None:
|
||||
primary_data["avg_power_w"] = round(avg_power_w_val)
|
||||
primary_data["merged_ids"] = secondary_ids
|
||||
|
||||
(acts_dir / f"{primary_id}.json").write_text(
|
||||
json.dumps(primary_data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
_merge_geojson(acts_dir, primary_id, secondary_ids)
|
||||
_merge_timeseries(acts_dir, primary_id, secondary_ids, activities)
|
||||
|
||||
merges = _read_merges(user_dir)
|
||||
for sid in secondary_ids:
|
||||
if sid not in merges["hidden"]:
|
||||
merges["hidden"].append(sid)
|
||||
merges.setdefault("merges", {})[primary_id] = {
|
||||
"secondary_ids": secondary_ids,
|
||||
"merged_at": _now_iso(),
|
||||
}
|
||||
_write_merges(user_dir, merges)
|
||||
|
||||
from bincio.render.merge import merge_one
|
||||
merge_one(user_dir, primary_id)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
|
||||
return JSONResponse({"ok": True, "primary_id": primary_id, "hidden": secondary_ids})
|
||||
|
||||
|
||||
@router.post("/api/unmerge/{primary_id}")
|
||||
async def unmerge_activity(
|
||||
primary_id: str,
|
||||
user: User = Depends(deps._require_auth),
|
||||
) -> JSONResponse:
|
||||
deps._check_id(primary_id)
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
backup_dir = user_dir / _BACKUP_DIR
|
||||
|
||||
merges = _read_merges(user_dir)
|
||||
merge_info = merges.get("merges", {}).get(primary_id)
|
||||
if not merge_info:
|
||||
raise HTTPException(404, "No merge record found for this activity")
|
||||
|
||||
secondary_ids: list[str] = merge_info.get("secondary_ids", [])
|
||||
|
||||
for suffix in (".json", ".geojson", ".timeseries.json"):
|
||||
bak = backup_dir / f"{primary_id}{suffix}.bak"
|
||||
if bak.exists():
|
||||
shutil.copy2(bak, acts_dir / f"{primary_id}{suffix}")
|
||||
bak.unlink()
|
||||
|
||||
merges["hidden"] = [h for h in merges.get("hidden", []) if h not in secondary_ids]
|
||||
merges["merges"].pop(primary_id, None)
|
||||
_write_merges(user_dir, merges)
|
||||
|
||||
from bincio.render.merge import merge_one
|
||||
merge_one(user_dir, primary_id)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
|
||||
return JSONResponse({"ok": True, "restored": secondary_ids})
|
||||
|
||||
|
||||
def _merge_geojson(acts_dir: Path, primary_id: str, secondary_ids: list[str]) -> None:
|
||||
primary_path = acts_dir / f"{primary_id}.geojson"
|
||||
if not primary_path.exists():
|
||||
return
|
||||
try:
|
||||
geo = json.loads(primary_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return
|
||||
features: list = list(geo.get("features", []))
|
||||
for sid in secondary_ids:
|
||||
p = acts_dir / f"{sid}.geojson"
|
||||
if not p.exists():
|
||||
continue
|
||||
try:
|
||||
sec = json.loads(p.read_text(encoding="utf-8"))
|
||||
features.extend(sec.get("features", []))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
geo["features"] = features
|
||||
primary_path.write_text(json.dumps(geo, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _merge_timeseries(
|
||||
acts_dir: Path,
|
||||
primary_id: str,
|
||||
secondary_ids: list[str],
|
||||
activities: list[dict],
|
||||
) -> None:
|
||||
primary_path = acts_dir / f"{primary_id}.timeseries.json"
|
||||
if not primary_path.exists():
|
||||
return
|
||||
try:
|
||||
merged = json.loads(primary_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return
|
||||
|
||||
merged = {k: list(v) if isinstance(v, list) else v for k, v in merged.items()}
|
||||
|
||||
_ARRAY_KEYS = ("lat", "lon", "elevation_m", "speed_kmh", "hr_bpm", "cadence_rpm", "power_w", "temperature_c")
|
||||
|
||||
for i, sid in enumerate(secondary_ids):
|
||||
sec_path = acts_dir / f"{sid}.timeseries.json"
|
||||
if not sec_path.exists():
|
||||
continue
|
||||
try:
|
||||
sec = json.loads(sec_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
sec_t: list = sec.get("t") or []
|
||||
if not sec_t:
|
||||
continue
|
||||
|
||||
try:
|
||||
p_start = datetime.fromisoformat(
|
||||
activities[0].get("started_at", "").replace("Z", "+00:00")
|
||||
).timestamp()
|
||||
s_start = datetime.fromisoformat(
|
||||
activities[i + 1].get("started_at", "").replace("Z", "+00:00")
|
||||
).timestamp()
|
||||
offset = s_start - p_start
|
||||
except (ValueError, TypeError):
|
||||
cur_t: list = merged.get("t") or []
|
||||
offset = (cur_t[-1] + 1) if cur_t else 0
|
||||
|
||||
adjusted_t = [offset + t for t in sec_t]
|
||||
primary_len = len(merged.get("t") or [])
|
||||
|
||||
if merged.get("t") is None:
|
||||
merged["t"] = []
|
||||
merged["t"].extend(adjusted_t)
|
||||
|
||||
for key in _ARRAY_KEYS:
|
||||
sec_vals = sec.get(key)
|
||||
if sec_vals is None:
|
||||
if merged.get(key) is not None:
|
||||
merged[key] = merged[key] + [None] * len(sec_t)
|
||||
else:
|
||||
if merged.get(key) is None:
|
||||
merged[key] = [None] * primary_len + list(sec_vals)
|
||||
else:
|
||||
merged[key] = merged[key] + list(sec_vals)
|
||||
|
||||
primary_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
|
||||
@@ -0,0 +1,161 @@
|
||||
"""OG preview endpoints.
|
||||
|
||||
GET /activity/{activity_id}
|
||||
Returns a minimal HTML page with Open Graph meta tags for social link
|
||||
previews (Telegram, WhatsApp, Slack, …). nginx proxies only bot
|
||||
User-Agents here; regular browsers still get the static SPA shell.
|
||||
|
||||
GET /api/og-image/{user_handle}/{activity_id}.png
|
||||
Returns the pre-generated 400×400 track PNG. Falls back to generating
|
||||
on the fly if the static file doesn't exist yet (e.g. a brand-new import
|
||||
before the next deploy-time generation run).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Sport → emoji map (extend as needed)
|
||||
_SPORT_EMOJI: dict[str, str] = {
|
||||
"cycling": "🚴",
|
||||
"running": "🏃",
|
||||
"swimming": "🏊",
|
||||
"hiking": "🥾",
|
||||
"walking": "🚶",
|
||||
"skiing": "⛷️",
|
||||
"rowing": "🚣",
|
||||
"triathlon": "🏊",
|
||||
"e_cycling": "🚴",
|
||||
"gravel": "🚴",
|
||||
}
|
||||
|
||||
|
||||
def _find_user(data_dir: Path, activity_id: str) -> str | None:
|
||||
"""Return the user handle that owns *activity_id*, or None."""
|
||||
for user_dir in sorted(data_dir.iterdir()):
|
||||
if not user_dir.is_dir() or user_dir.name.startswith("_") or user_dir.name == "segments":
|
||||
continue
|
||||
if (user_dir / "activities" / f"{activity_id}.json").exists():
|
||||
return user_dir.name
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_description(detail: dict, handle: str) -> str:
|
||||
parts: list[str] = []
|
||||
sport = (detail.get("sport") or "").lower()
|
||||
emoji = _SPORT_EMOJI.get(sport, "🏅")
|
||||
parts.append(emoji)
|
||||
|
||||
dist_m = detail.get("distance_m")
|
||||
if dist_m:
|
||||
parts.append(f"{dist_m / 1000:.1f} km")
|
||||
|
||||
gain = detail.get("elevation_gain_m")
|
||||
if gain:
|
||||
parts.append(f"{gain:.0f} m ↑")
|
||||
|
||||
dur = detail.get("moving_time_s") or detail.get("duration_s")
|
||||
if dur:
|
||||
h, rem = divmod(int(dur), 3600)
|
||||
m = rem // 60
|
||||
parts.append(f"{h}h {m:02d}m" if h else f"{m}m")
|
||||
|
||||
started = detail.get("started_at")
|
||||
if started:
|
||||
try:
|
||||
dt = datetime.fromisoformat(started).astimezone(timezone.utc)
|
||||
parts.append(dt.strftime("%-d %b %Y"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
parts.append(f"@{handle}")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
@router.get("/activity/{activity_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/activity/{activity_id}/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def og_preview(activity_id: str, request: Request) -> HTMLResponse:
|
||||
data_dir = deps._get_data_dir()
|
||||
handle = _find_user(data_dir, activity_id)
|
||||
if handle is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
json_path = data_dir / handle / "activities" / f"{activity_id}.json"
|
||||
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
|
||||
title = detail.get("title") or activity_id
|
||||
desc = _fmt_description(detail, handle)
|
||||
base = str(request.base_url).rstrip("/")
|
||||
img_url = f"{base}/og-image/{handle}/{activity_id}.png"
|
||||
act_url = f"{base}/activity/{activity_id}/"
|
||||
|
||||
h_title = html.escape(title)
|
||||
h_desc = html.escape(desc)
|
||||
h_img = html.escape(img_url)
|
||||
h_url = html.escape(act_url)
|
||||
|
||||
content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{h_title} – BincioActivity</title>
|
||||
<meta property="og:title" content="{h_title}" />
|
||||
<meta property="og:description" content="{h_desc}" />
|
||||
<meta property="og:image" content="{h_img}" />
|
||||
<meta property="og:image:width" content="400" />
|
||||
<meta property="og:image:height" content="400" />
|
||||
<meta property="og:url" content="{h_url}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="BincioActivity" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="{h_title}" />
|
||||
<meta name="twitter:description" content="{h_desc}" />
|
||||
<meta name="twitter:image" content="{h_img}" />
|
||||
<script>window.location.replace("{act_url}");</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=content)
|
||||
|
||||
|
||||
@router.get("/og-image/{user_handle}/{activity_id}.png", include_in_schema=False)
|
||||
async def og_image(user_handle: str, activity_id: str) -> Response:
|
||||
data_dir = deps._get_data_dir()
|
||||
www_root = Path("/var/www/activity")
|
||||
img_path = www_root / "og-image" / user_handle / f"{activity_id}.png"
|
||||
|
||||
if img_path.exists():
|
||||
return Response(
|
||||
content=img_path.read_bytes(),
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
# Fallback: generate on the fly (e.g. new activity before next deploy run)
|
||||
ts_path = data_dir / user_handle / "activities" / f"{activity_id}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
raise HTTPException(404)
|
||||
|
||||
try:
|
||||
from bincio.render.ogimage import generate_for_activity
|
||||
png = generate_for_activity(ts_path)
|
||||
img_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img_path.write_bytes(png)
|
||||
except Exception:
|
||||
raise HTTPException(500, "Image generation failed")
|
||||
|
||||
return Response(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Segments endpoints (/api/segments/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Cookie, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.models import CreateSegmentRequest
|
||||
from bincio.segments import models as _seg_models
|
||||
from bincio.segments import store as _seg_store
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _scan_segment_for_user(dd: Path, handle: str, segment_id: str) -> int:
|
||||
"""Scan all of a user's activities against one segment. Returns effort count."""
|
||||
from datetime import datetime as _datetime
|
||||
from bincio.segments.detect import track_from_timeseries_json, detect_one
|
||||
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
return 0
|
||||
user_dir = dd / handle
|
||||
acts_dir = user_dir / "activities"
|
||||
total = 0
|
||||
for detail_path in sorted(acts_dir.glob("*.json")):
|
||||
if ".timeseries." in detail_path.name:
|
||||
continue
|
||||
try:
|
||||
detail = json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
ts_url = detail.get("timeseries_url")
|
||||
if not ts_url:
|
||||
continue
|
||||
ts_path = user_dir / ts_url
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
started_raw = detail.get("started_at")
|
||||
if not started_raw:
|
||||
continue
|
||||
try:
|
||||
started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
track = track_from_timeseries_json(ts, detail.get("id", detail_path.stem),
|
||||
detail.get("sport", "other"), started_at)
|
||||
if track is None:
|
||||
continue
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_seg_store.add_effort(dd, handle, segment_id, effort)
|
||||
total += len(efforts)
|
||||
return total
|
||||
|
||||
|
||||
@router.get("/api/segments")
|
||||
async def get_segments(
|
||||
bbox: Optional[str] = None,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""List segments, optionally filtered to a map viewport bbox (lon_min,lat_min,lon_max,lat_max)."""
|
||||
deps._require_user(bincio_session)
|
||||
parsed_bbox: Optional[list[float]] = None
|
||||
if bbox:
|
||||
try:
|
||||
parts = [float(x) for x in bbox.split(",")]
|
||||
if len(parts) == 4:
|
||||
parsed_bbox = parts
|
||||
except ValueError:
|
||||
raise HTTPException(400, "bbox must be four comma-separated floats")
|
||||
dd = deps._get_data_dir()
|
||||
segs = _seg_store.list_segments(dd, parsed_bbox)
|
||||
return JSONResponse([{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"sport": s.sport,
|
||||
"distance_m": s.distance_m,
|
||||
"bbox": s.bbox,
|
||||
"polyline": s.polyline,
|
||||
"created_by": s.created_by,
|
||||
"created_at": _seg_store._iso(s.created_at),
|
||||
} for s in segs])
|
||||
|
||||
|
||||
@router.get("/api/segments/{segment_id}")
|
||||
async def get_segment(segment_id: str) -> JSONResponse:
|
||||
"""Return metadata for a single segment."""
|
||||
dd = deps._get_data_dir()
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
return JSONResponse({
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"polyline": seg.polyline,
|
||||
"distance_m": seg.distance_m,
|
||||
"bbox": seg.bbox,
|
||||
"created_by": seg.created_by,
|
||||
"created_at": _seg_store._iso(seg.created_at),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/segments")
|
||||
async def create_segment(
|
||||
body: CreateSegmentRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
if len(body.polyline) < 2:
|
||||
raise HTTPException(400, "polyline must have at least 2 points")
|
||||
if body.distance_m < 500:
|
||||
raise HTTPException(400, "segment must be at least 500 m long")
|
||||
|
||||
lats = [p[0] for p in body.polyline]
|
||||
lons = [p[1] for p in body.polyline]
|
||||
bbox = [min(lons), min(lats), max(lons), max(lats)]
|
||||
|
||||
seg_id = _seg_store.make_segment_id(body.name)
|
||||
from datetime import datetime, timezone as _tz
|
||||
seg = _seg_models.Segment(
|
||||
id=seg_id,
|
||||
name=body.name,
|
||||
sport=body.sport or None,
|
||||
polyline=body.polyline,
|
||||
distance_m=body.distance_m,
|
||||
bbox=bbox,
|
||||
created_by=user.handle,
|
||||
created_at=datetime.now(_tz.utc),
|
||||
)
|
||||
dd = deps._get_data_dir()
|
||||
_seg_store.save_segment(dd, seg)
|
||||
background_tasks.add_task(_scan_segment_for_user, dd, user.handle, seg_id)
|
||||
return JSONResponse({"id": seg_id}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/segments/{segment_id}")
|
||||
async def delete_segment(
|
||||
segment_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
if seg.created_by != user.handle and not user.is_admin:
|
||||
raise HTTPException(403, "Not allowed")
|
||||
_seg_store.delete_segment(dd, segment_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/segments/{segment_id}/efforts")
|
||||
async def get_segment_efforts(
|
||||
segment_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Return all efforts on a segment for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
seg = _seg_store.load_segment(dd, segment_id)
|
||||
if seg is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
efforts = _seg_store.load_efforts(dd, user.handle, segment_id)
|
||||
return JSONResponse([
|
||||
{
|
||||
"activity_id": e.activity_id,
|
||||
"started_at": _seg_store._iso(e.started_at),
|
||||
"elapsed_s": e.elapsed_s,
|
||||
"avg_speed_kmh": e.avg_speed_kmh,
|
||||
"avg_hr_bpm": e.avg_hr_bpm,
|
||||
"avg_power_w": e.avg_power_w,
|
||||
"np_power_w": e.np_power_w,
|
||||
}
|
||||
for e in efforts
|
||||
])
|
||||
|
||||
|
||||
@router.post("/api/segments/{segment_id}/detect")
|
||||
async def trigger_detect(
|
||||
segment_id: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Retroactively detect efforts on a segment for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
if _seg_store.load_segment(dd, segment_id) is None:
|
||||
raise HTTPException(404, "Segment not found")
|
||||
_seg_store.save_efforts(dd, user.handle, segment_id, [])
|
||||
total = _scan_segment_for_user(dd, user.handle, segment_id)
|
||||
return JSONResponse({"ok": True, "efforts_found": total})
|
||||
|
||||
|
||||
@router.post("/api/me/segment-rescan")
|
||||
async def me_segment_rescan(
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Retroactively detect efforts for ALL segments across ALL activities for the logged-in user."""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir()
|
||||
user_dir = dd / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
|
||||
from datetime import datetime as _datetime
|
||||
from bincio.segments.detect import track_from_timeseries_json, detect_one
|
||||
import json as _json
|
||||
|
||||
segments = _seg_store.list_segments(dd)
|
||||
if not segments:
|
||||
return JSONResponse({"ok": True, "efforts_found": 0})
|
||||
|
||||
for seg in segments:
|
||||
_seg_store.save_efforts(dd, user.handle, seg.id, [])
|
||||
|
||||
total = 0
|
||||
for detail_path in sorted(acts_dir.glob("*.json")):
|
||||
if ".timeseries." in detail_path.name:
|
||||
continue
|
||||
try:
|
||||
detail = _json.loads(detail_path.read_text(encoding="utf-8"))
|
||||
except (OSError, _json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
ts_url = detail.get("timeseries_url")
|
||||
if not ts_url:
|
||||
continue
|
||||
ts_path = user_dir / ts_url
|
||||
if not ts_path.exists():
|
||||
continue
|
||||
try:
|
||||
ts = _json.loads(ts_path.read_text(encoding="utf-8"))
|
||||
except (OSError, _json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
started_raw = detail.get("started_at")
|
||||
if not started_raw:
|
||||
continue
|
||||
try:
|
||||
started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
track = track_from_timeseries_json(
|
||||
ts, detail.get("id", detail_path.stem),
|
||||
detail.get("sport", "other"), started_at,
|
||||
)
|
||||
if track is None:
|
||||
continue
|
||||
for seg in segments:
|
||||
efforts = detect_one(track, seg)
|
||||
for effort in efforts:
|
||||
_seg_store.add_effort(dd, user.handle, seg.id, effort)
|
||||
total += len(efforts)
|
||||
|
||||
return JSONResponse({"ok": True, "efforts_found": total})
|
||||
|
||||
|
||||
@router.get("/api/users/{handle}/segment_summary")
|
||||
async def user_segment_summary(handle: str) -> JSONResponse:
|
||||
"""Public endpoint: segments where this user has efforts, with best time and count."""
|
||||
dd = deps._get_data_dir()
|
||||
efforts_dir = dd / handle / "segment_efforts"
|
||||
result = []
|
||||
if efforts_dir.exists():
|
||||
for ef_file in sorted(efforts_dir.glob("*.json")):
|
||||
seg_id = ef_file.stem
|
||||
efforts = _seg_store.load_efforts(dd, handle, seg_id)
|
||||
if not efforts:
|
||||
continue
|
||||
seg = _seg_store.load_segment(dd, seg_id)
|
||||
if not seg:
|
||||
continue
|
||||
best = min(efforts, key=lambda e: e.elapsed_s)
|
||||
result.append({
|
||||
"segment": {
|
||||
"id": seg.id,
|
||||
"name": seg.name,
|
||||
"sport": seg.sport,
|
||||
"distance_m": seg.distance_m,
|
||||
},
|
||||
"best_elapsed_s": best.elapsed_s,
|
||||
"best_activity_id": best.activity_id,
|
||||
"effort_count": len(efforts),
|
||||
})
|
||||
result.sort(key=lambda x: x["segment"]["name"].lower())
|
||||
return JSONResponse(result)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Strava integration endpoints (/api/strava/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.db import get_setting
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_strava_oauth_states: set[str] = set()
|
||||
|
||||
|
||||
@router.get("/api/strava/status")
|
||||
async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, _ = deps._strava_creds(user.handle)
|
||||
if not cid:
|
||||
return JSONResponse({"configured": False, "connected": False, "last_sync": None})
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import load_token
|
||||
token = load_token(dd)
|
||||
return JSONResponse({
|
||||
"configured": True,
|
||||
"connected": token is not None,
|
||||
"last_sync": token.get("last_sync_at") if token else None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/strava/disconnect")
|
||||
async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""Remove the stored Strava token, forcing a fresh OAuth on next connect."""
|
||||
user = deps._require_user(bincio_session)
|
||||
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
|
||||
token_path.unlink(missing_ok=True)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/strava/reset")
|
||||
async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""Reset last_sync_at so the next sync re-fetches from a chosen point.
|
||||
|
||||
mode=soft — set to the started_at of the most recent activity on disk
|
||||
(next sync only fetches activities newer than the last known one)
|
||||
mode=hard — clear last_sync_at entirely
|
||||
(next sync re-downloads full Strava history, skipping existing files)
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import load_token, save_token
|
||||
token = load_token(dd)
|
||||
if token is None:
|
||||
raise HTTPException(400, "Not connected to Strava")
|
||||
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "soft")
|
||||
|
||||
if mode == "hard":
|
||||
token.pop("last_sync_at", None)
|
||||
save_token(dd, token)
|
||||
return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None})
|
||||
|
||||
# soft: find the most recent started_at across the user's merged index
|
||||
from datetime import datetime, timezone
|
||||
last_ts: int | None = None
|
||||
for index_path in [dd / "_merged" / "index.json", dd / "index.json"]:
|
||||
if not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
started_ats = [
|
||||
a.get("started_at") for a in index_data.get("activities", [])
|
||||
if a.get("started_at")
|
||||
]
|
||||
if started_ats:
|
||||
latest = max(started_ats)
|
||||
dt = datetime.fromisoformat(latest.replace("Z", "+00:00"))
|
||||
last_ts = int(dt.astimezone(timezone.utc).timestamp())
|
||||
break
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
if last_ts is None:
|
||||
token.pop("last_sync_at", None)
|
||||
else:
|
||||
token["last_sync_at"] = last_ts
|
||||
save_token(dd, token)
|
||||
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})
|
||||
|
||||
|
||||
@router.get("/api/strava/auth-url")
|
||||
async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, _ = deps._strava_creds(user.handle)
|
||||
if not cid:
|
||||
raise HTTPException(400, "Strava client ID not configured on this server")
|
||||
state = secrets.token_urlsafe(16)
|
||||
_strava_oauth_states.add(state)
|
||||
if deps.public_url:
|
||||
redirect_uri = deps.public_url.rstrip("/") + "/api/strava/callback"
|
||||
else:
|
||||
redirect_uri = str(request.url_for("strava_callback"))
|
||||
from bincio.extract.strava_api import auth_url
|
||||
return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)})
|
||||
|
||||
|
||||
@router.get("/api/strava/callback", name="strava_callback")
|
||||
async def strava_callback(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
error: str = "",
|
||||
state: str = "",
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> RedirectResponse:
|
||||
site_origin = deps.public_url.rstrip("/") if deps.public_url else str(request.base_url).rstrip("/")
|
||||
if error or not code:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
if state not in _strava_oauth_states:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
_strava_oauth_states.discard(state)
|
||||
user = deps._current_user(bincio_session)
|
||||
if not user:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import StravaError, exchange_code, save_token
|
||||
try:
|
||||
token = exchange_code(cid, csec, code)
|
||||
except StravaError:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
save_token(dd, token)
|
||||
return RedirectResponse(f"{site_origin}/?strava=connected")
|
||||
|
||||
|
||||
@router.get("/api/strava/sync/stream")
|
||||
async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse:
|
||||
"""SSE endpoint — streams per-activity progress then a final summary event."""
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
store_orig_setting = get_setting(deps._get_db(), "store_originals")
|
||||
store_orig = store_orig_setting == "true"
|
||||
originals_dir = (dd / "originals" / "strava") if store_orig else None
|
||||
if originals_dir:
|
||||
originals_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from bincio.extract.ingest import strava_sync_iter
|
||||
|
||||
def event_stream():
|
||||
try:
|
||||
for event in strava_sync_iter(dd, cid, csec, originals_dir):
|
||||
if event["type"] == "done":
|
||||
tasks._trigger_rebuild(user.handle) # start before client closes connection
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/strava/import-gear")
|
||||
async def serve_strava_import_gear(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""One-time backfill: scan stored Strava originals for gear_ids, fetch names, populate gear registry."""
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
originals_dir = dd / "originals" / "strava"
|
||||
if not originals_dir.exists():
|
||||
return JSONResponse({"ok": True, "gear_added": 0, "activities_updated": 0, "message": "No stored originals found"})
|
||||
|
||||
import contextlib
|
||||
import uuid
|
||||
|
||||
from bincio.extract.strava_api import StravaError, ensure_fresh, fetch_gear
|
||||
from bincio.render.merge import merge_one
|
||||
from bincio.serve.routers.gear import _load as _gear_load, _save as _gear_save
|
||||
|
||||
try:
|
||||
token = ensure_fresh(dd, cid, csec)
|
||||
except StravaError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
|
||||
registry = _gear_load(dd)
|
||||
known_strava_ids = {g.get("strava_id") for g in registry if g.get("strava_id")}
|
||||
|
||||
# Collect all unique gear_ids from originals
|
||||
gear_id_to_activities: dict[str, list[str]] = {}
|
||||
for orig_path in originals_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(orig_path.read_text(encoding="utf-8"))
|
||||
gear_id = (data.get("meta") or {}).get("gear_id") or ""
|
||||
if gear_id:
|
||||
gear_id_to_activities.setdefault(gear_id, []).append(orig_path.stem)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
gear_added = 0
|
||||
activities_updated = 0
|
||||
|
||||
for gear_id, activity_ids in gear_id_to_activities.items():
|
||||
if gear_id in known_strava_ids:
|
||||
gear_name = next(g["name"] for g in registry if g.get("strava_id") == gear_id)
|
||||
else:
|
||||
details = fetch_gear(token["access_token"], gear_id)
|
||||
gear_name = details.get("name") or ""
|
||||
if not gear_name:
|
||||
continue
|
||||
gear_type = "shoes" if gear_id.startswith("g") else "bike"
|
||||
new_item: dict = {"id": str(uuid.uuid4()), "name": gear_name, "type": gear_type, "retired": False, "strava_id": gear_id}
|
||||
registry.append(new_item)
|
||||
known_strava_ids.add(gear_id)
|
||||
gear_added += 1
|
||||
|
||||
# Backfill: write sidecar for each activity that has no gear set yet
|
||||
import yaml as _yaml
|
||||
edits_dir = dd / "edits"
|
||||
edits_dir.mkdir(exist_ok=True)
|
||||
for activity_id in activity_ids:
|
||||
activity_json = dd / "activities" / f"{activity_id}.json"
|
||||
if not activity_json.exists():
|
||||
continue
|
||||
try:
|
||||
act = json.loads(activity_json.read_text(encoding="utf-8"))
|
||||
if act.get("gear"):
|
||||
continue # already has gear
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
sidecar = edits_dir / f"{activity_id}.md"
|
||||
fm: dict = {}
|
||||
body = ""
|
||||
if sidecar.exists():
|
||||
try:
|
||||
text = sidecar.read_text(encoding="utf-8")
|
||||
import re as _re
|
||||
parts = _re.split(r"^---[ \t]*$", text, maxsplit=2, flags=_re.MULTILINE)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
body = parts[2].strip()
|
||||
except Exception:
|
||||
pass
|
||||
if fm.get("gear"):
|
||||
continue # sidecar already sets gear
|
||||
fm["gear"] = gear_name
|
||||
fm_text = _yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip()
|
||||
content = f"---\n{fm_text}\n---\n"
|
||||
if body:
|
||||
content += f"\n{body}\n"
|
||||
sidecar.write_text(content, encoding="utf-8")
|
||||
with contextlib.suppress(Exception):
|
||||
merge_one(dd, activity_id)
|
||||
activities_updated += 1
|
||||
|
||||
_gear_save(dd, registry)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True, "gear_added": gear_added, "activities_updated": activities_updated})
|
||||
|
||||
|
||||
@router.post("/api/strava/sync")
|
||||
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
store_orig_setting = get_setting(deps._get_db(), "store_originals")
|
||||
store_orig = store_orig_setting == "true"
|
||||
originals_dir = (dd / "originals" / "strava") if store_orig else None
|
||||
if originals_dir:
|
||||
originals_dir.mkdir(parents=True, exist_ok=True)
|
||||
from bincio.edit.ops import run_strava_sync
|
||||
try:
|
||||
result = run_strava_sync(dd, cid, csec, originals_dir=originals_dir)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse(result)
|
||||
@@ -0,0 +1,506 @@
|
||||
"""File upload endpoints (/api/upload/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
|
||||
log = logging.getLogger("bincio.serve")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}
|
||||
|
||||
|
||||
def _file_suffix(name: str) -> str:
|
||||
"""Return the effective suffix, including .gz double-extension."""
|
||||
p = Path(name.lower())
|
||||
if p.suffix == ".gz":
|
||||
return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz"
|
||||
return p.suffix
|
||||
|
||||
|
||||
def _upsert_index_summary(user_dir: Path, activity_id: str, activity: dict, geojson: Optional[dict] = None) -> None:
|
||||
"""Add or update an activity summary in user_dir/index.json.
|
||||
|
||||
Called after writing BAS activity files so that merge_all can include the
|
||||
activity in year shards. Without this, uploaded activities exist on disk
|
||||
but never appear in the browser feed.
|
||||
"""
|
||||
# Build preview coords from geojson if available ([lat, lng] order)
|
||||
preview: Optional[list] = None
|
||||
if geojson:
|
||||
try:
|
||||
coords = geojson.get("geometry", {}).get("coordinates", [])
|
||||
if coords:
|
||||
step = max(1, len(coords) // 9)
|
||||
preview = [[c[1], c[0]] for c in coords[::step]][:9]
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
pass
|
||||
|
||||
has_track = (user_dir / "activities" / f"{activity_id}.geojson").exists()
|
||||
summary = {
|
||||
"id": activity_id,
|
||||
"title": activity.get("title", activity_id),
|
||||
"sport": activity.get("sport"),
|
||||
"sub_sport": activity.get("sub_sport"),
|
||||
"started_at": activity.get("started_at"),
|
||||
"distance_m": activity.get("distance_m"),
|
||||
"duration_s": activity.get("duration_s"),
|
||||
"moving_time_s": activity.get("moving_time_s"),
|
||||
"elevation_gain_m": activity.get("elevation_gain_m"),
|
||||
"avg_speed_kmh": activity.get("avg_speed_kmh"),
|
||||
"max_speed_kmh": activity.get("max_speed_kmh"),
|
||||
"avg_hr_bpm": activity.get("avg_hr_bpm"),
|
||||
"max_hr_bpm": activity.get("max_hr_bpm"),
|
||||
"avg_cadence_rpm": activity.get("avg_cadence_rpm"),
|
||||
"avg_power_w": activity.get("avg_power_w"),
|
||||
"mmp": activity.get("mmp"),
|
||||
"best_efforts": activity.get("best_efforts"),
|
||||
"best_climb_m": activity.get("best_climb_m"),
|
||||
"source": activity.get("source"),
|
||||
"privacy": activity.get("privacy", "public"),
|
||||
"detail_url": f"activities/{activity_id}.json",
|
||||
"track_url": f"activities/{activity_id}.geojson" if has_track else None,
|
||||
"preview_coords": preview,
|
||||
}
|
||||
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
index_data = {
|
||||
"bas_version": "1.0",
|
||||
"owner": {"handle": user_dir.name},
|
||||
"generated_at": None,
|
||||
"activities": [],
|
||||
}
|
||||
existing = {a["id"]: a for a in index_data.get("activities", [])}
|
||||
existing[activity_id] = summary
|
||||
index_data["activities"] = sorted(existing.values(), key=lambda a: a.get("started_at", ""), reverse=True)
|
||||
index_path.write_text(json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
@router.post("/api/upload/bas")
|
||||
async def upload_bas_activity(
|
||||
request: Request,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Accept a pre-extracted BAS activity JSON from the mobile app.
|
||||
|
||||
Body (JSON):
|
||||
activity – full BAS activity dict (required, must have 'id')
|
||||
timeseries – timeseries dict (optional)
|
||||
geojson – GeoJSON dict (optional)
|
||||
|
||||
Returns:
|
||||
{"ok": true, "id": "...", "status": "imported" | "duplicate"}
|
||||
"""
|
||||
user = deps._require_auth(request, bincio_session)
|
||||
body = await request.json()
|
||||
|
||||
activity = body.get("activity")
|
||||
if not activity or not activity.get("id"):
|
||||
raise HTTPException(400, "Missing activity.id")
|
||||
|
||||
activity_id = str(activity["id"])
|
||||
deps._check_id(activity_id)
|
||||
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
acts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
out = acts_dir / f"{activity_id}.json"
|
||||
if out.exists():
|
||||
return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"})
|
||||
|
||||
out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
if body.get("timeseries"):
|
||||
ts_path = acts_dir / f"{activity_id}.timeseries.json"
|
||||
if not ts_path.exists():
|
||||
ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
geojson_body: Optional[dict] = body.get("geojson") or None
|
||||
if geojson_body:
|
||||
gj_path = acts_dir / f"{activity_id}.geojson"
|
||||
if not gj_path.exists():
|
||||
gj_path.write_text(json.dumps(geojson_body, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
_upsert_index_summary(user_dir, activity_id, activity, geojson_body)
|
||||
|
||||
try:
|
||||
from bincio.render.merge import merge_one, write_combined_feed
|
||||
merge_one(user_dir, activity_id)
|
||||
write_combined_feed(deps._get_data_dir())
|
||||
except Exception as exc:
|
||||
log.warning("upload/bas[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)
|
||||
|
||||
log.info("upload/bas[%s]: imported %s", user.handle, activity_id)
|
||||
return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})
|
||||
|
||||
|
||||
@router.post("/api/upload/raw")
|
||||
async def upload_raw_activity(
|
||||
request: Request,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it
|
||||
server-side, store it in the user's activity library, and return the full
|
||||
extracted data so the mobile can cache it locally.
|
||||
|
||||
Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69).
|
||||
|
||||
Body (JSON):
|
||||
filename – original filename (used only to determine file extension)
|
||||
base64 – base64-encoded raw file bytes
|
||||
|
||||
Auth: Authorization: Bearer <token>
|
||||
|
||||
Returns:
|
||||
{"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null,
|
||||
"geojson": {...}|null, "source_hash": "<sha256-hex>"}
|
||||
"""
|
||||
import base64 as _b64
|
||||
import hashlib
|
||||
import shutil
|
||||
|
||||
user = deps._require_auth(request, bincio_session)
|
||||
|
||||
body = await request.json()
|
||||
filename_hint: str = body.get("filename") or "activity.fit"
|
||||
b64: str = body.get("base64") or ""
|
||||
user_title: Optional[str] = body.get("user_title") or None
|
||||
if not b64:
|
||||
raise HTTPException(400, "Missing base64 field")
|
||||
|
||||
try:
|
||||
raw = _b64.b64decode(b64)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid base64 encoding")
|
||||
|
||||
source_hash = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
suffix = Path(filename_hint).suffix or ".fit"
|
||||
tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}")
|
||||
tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}")
|
||||
try:
|
||||
tmp_in.write_bytes(raw)
|
||||
tmp_out.mkdir()
|
||||
|
||||
from bincio.extract.parsers.factory import parse_file
|
||||
from bincio.extract.metrics import compute
|
||||
from bincio.extract.writer import make_activity_id, write_activity
|
||||
from bincio.extract.timeseries import build_timeseries
|
||||
|
||||
activity = parse_file(tmp_in)
|
||||
metrics = compute(activity)
|
||||
write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001)
|
||||
act_id = make_activity_id(activity)
|
||||
|
||||
acts_tmp = tmp_out / "activities"
|
||||
detail_path = acts_tmp / f"{act_id}.json"
|
||||
ts_path = acts_tmp / f"{act_id}.timeseries.json"
|
||||
geojson_path = acts_tmp / f"{act_id}.geojson"
|
||||
|
||||
if not ts_path.exists():
|
||||
ts_data = build_timeseries(activity.points, activity.started_at, "public")
|
||||
if ts_data.get("t"):
|
||||
ts_path.write_text(json.dumps(ts_data))
|
||||
|
||||
detail = json.loads(detail_path.read_text())
|
||||
timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None
|
||||
geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None
|
||||
|
||||
# Also store on the server so the activity appears in the user's feed.
|
||||
user_dir = deps._get_data_dir() / user.handle
|
||||
acts_dir = user_dir / "activities"
|
||||
acts_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = acts_dir / f"{act_id}.json"
|
||||
if not out.exists():
|
||||
out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists():
|
||||
(acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8")
|
||||
if geojson and not (acts_dir / f"{act_id}.geojson").exists():
|
||||
(acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8")
|
||||
|
||||
_upsert_index_summary(user_dir, act_id, detail, geojson)
|
||||
|
||||
if user_title:
|
||||
import yaml as _yaml
|
||||
edits_dir = user_dir / "edits"
|
||||
edits_dir.mkdir(parents=True, exist_ok=True)
|
||||
(edits_dir / f"{act_id}.md").write_text(
|
||||
f"---\n{_yaml.dump({'title': user_title}, allow_unicode=True)}---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
|
||||
raise HTTPException(422, f"Could not extract activity: {exc}") from exc
|
||||
finally:
|
||||
tmp_in.unlink(missing_ok=True)
|
||||
shutil.rmtree(tmp_out, ignore_errors=True)
|
||||
|
||||
# Merge and update feed — best effort; a race or transient FS error here must
|
||||
# not turn a successful extraction into a 422 (the file is on disk; the mobile
|
||||
# would retry indefinitely and the activity would never be marked synced).
|
||||
try:
|
||||
from bincio.render.merge import merge_one, write_combined_feed
|
||||
merge_one(user_dir, act_id)
|
||||
write_combined_feed(deps._get_data_dir())
|
||||
except Exception as exc:
|
||||
log.warning("upload/raw[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)
|
||||
|
||||
log.info("upload/raw[%s]: imported %s", user.handle, act_id)
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"id": act_id,
|
||||
"detail": detail,
|
||||
"timeseries": timeseries,
|
||||
"geojson": geojson,
|
||||
"source_hash": source_hash,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/upload")
|
||||
async def upload_activity(
|
||||
files: list[UploadFile] = File(...),
|
||||
store_original: bool = Form(False),
|
||||
overwrite: bool = Form(False),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> StreamingResponse:
|
||||
"""Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing.
|
||||
|
||||
activities.csv (Strava export format) can be included in the batch to:
|
||||
- Enrich activity files in the same batch (matched by filename)
|
||||
- Retroactively update sidecars for existing activities (matched by strava_id)
|
||||
|
||||
SSE events:
|
||||
{"type": "progress", "n": N, "total": T, "name": "...", "status": "imported"|"overwritten"|"duplicate"|"error"}
|
||||
{"type": "csv", "updates": N} -- only when CSV was included
|
||||
{"type": "done", "added": N, "csv_updates": N, "duplicates": N, "overwritten": N, "errors": N}
|
||||
"""
|
||||
from bincio.extract.ingest import ingest_parsed
|
||||
from bincio.extract.parsers.factory import parse_file
|
||||
from bincio.extract.writer import make_activity_id
|
||||
from bincio.render.merge import merge_all
|
||||
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
staging = dd / "_uploads"
|
||||
staging.mkdir(exist_ok=True)
|
||||
|
||||
# Read all files into memory now (async), then process synchronously in the generator
|
||||
csv_bytes_list: list[bytes] = []
|
||||
activity_items: list[tuple[str, bytes]] = [] # (original_filename, bytes)
|
||||
|
||||
for f in files:
|
||||
fname = Path(f.filename or "").name
|
||||
raw = await f.read()
|
||||
if fname.lower().endswith(".csv"):
|
||||
csv_bytes_list.append(raw)
|
||||
else:
|
||||
activity_items.append((fname, raw))
|
||||
|
||||
# Build metadata from the first CSV
|
||||
metadata = None
|
||||
if csv_bytes_list:
|
||||
from bincio.extract.strava_csv import StravaMetadata
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
|
||||
tmp.write(csv_bytes_list[0])
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
metadata = StravaMetadata(tmp_path)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
total_files = len(activity_items)
|
||||
job_id = tasks._job_start(user.handle, total_files) if total_files > 0 else None
|
||||
|
||||
def event_stream():
|
||||
added = 0
|
||||
overwritten = 0
|
||||
duplicates = 0
|
||||
errors = 0
|
||||
any_added = False
|
||||
|
||||
for n, (name, contents) in enumerate(activity_items, 1):
|
||||
if job_id:
|
||||
tasks._job_update(job_id, n - 1, name)
|
||||
|
||||
suffix = _file_suffix(name)
|
||||
if suffix not in _SUPPORTED_SUFFIXES:
|
||||
errors += 1
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n"
|
||||
continue
|
||||
|
||||
if len(contents) > 50 * 1024 * 1024:
|
||||
errors += 1
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n"
|
||||
continue
|
||||
|
||||
staged = staging / name
|
||||
staged.write_bytes(contents)
|
||||
kept = False
|
||||
try:
|
||||
activity = parse_file(staged)
|
||||
if metadata is not None:
|
||||
metadata.enrich(name, activity)
|
||||
activity_id = make_activity_id(activity)
|
||||
was_overwrite = False
|
||||
if (dd / "activities" / f"{activity_id}.json").exists():
|
||||
if not overwrite:
|
||||
duplicates += 1
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
|
||||
continue
|
||||
# Overwrite: delete existing files before re-ingesting.
|
||||
for ext in (".json", ".geojson", ".timeseries.json"):
|
||||
(dd / "activities" / f"{activity_id}{ext}").unlink(missing_ok=True)
|
||||
# Remove stale summary from index so ingest_parsed writes a clean one
|
||||
index_path = dd / "index.json"
|
||||
if index_path.exists():
|
||||
idx = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
|
||||
index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
|
||||
# Remove from dedup hash cache so the new file isn't blocked
|
||||
cache_path = dd / ".bincio_cache.json"
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
cache.pop(activity_id, None)
|
||||
cache_path.write_text(json.dumps(cache, ensure_ascii=False))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
# Remove merged copies (merge_all will regenerate them after ingest)
|
||||
merged_acts = dd / "_merged" / "activities"
|
||||
if merged_acts.exists():
|
||||
for ext in (".json", ".geojson", ".timeseries.json"):
|
||||
p = merged_acts / f"{activity_id}{ext}"
|
||||
if p.exists() or p.is_symlink():
|
||||
p.unlink(missing_ok=True)
|
||||
was_overwrite = True
|
||||
ingest_parsed(activity, dd, privacy="public")
|
||||
if store_original:
|
||||
originals_dir = dd / "originals"
|
||||
originals_dir.mkdir(exist_ok=True)
|
||||
staged.rename(originals_dir / name)
|
||||
kept = True
|
||||
if was_overwrite:
|
||||
overwritten += 1
|
||||
else:
|
||||
added += 1
|
||||
any_added = True
|
||||
status = 'overwritten' if was_overwrite else 'imported'
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': status})}\n\n"
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
log.error("upload[%s]: failed to process %s: %s", user.handle, name, exc, exc_info=True)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': str(exc)})}\n\n"
|
||||
finally:
|
||||
if not kept:
|
||||
staged.unlink(missing_ok=True)
|
||||
|
||||
# Retroactively apply CSV metadata to existing activities
|
||||
csv_updates = 0
|
||||
if metadata is not None:
|
||||
from bincio.extract.strava_csv import apply_csv_to_data_dir
|
||||
csv_updates = apply_csv_to_data_dir(dd, metadata)
|
||||
if csv_updates:
|
||||
yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n"
|
||||
|
||||
if any_added or csv_updates:
|
||||
merge_all(dd)
|
||||
if any_added:
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'added': added, 'overwritten': overwritten, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"
|
||||
|
||||
if job_id:
|
||||
tasks._job_finish(job_id)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/upload/strava-zip")
|
||||
async def upload_strava_zip(
|
||||
file: UploadFile = File(...),
|
||||
private: str = Form(default="false"),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> StreamingResponse:
|
||||
"""Accept a Strava bulk export ZIP and stream SSE progress while processing.
|
||||
|
||||
The ZIP is written to a temp file, processed activity-by-activity, then deleted.
|
||||
Originals are never kept — the UI informs the user of this upfront.
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||
raise HTTPException(400, "Please upload a .zip file")
|
||||
|
||||
privacy = "unlisted" if private.lower() in ("true", "1", "yes") else "public"
|
||||
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
import tempfile
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||
zip_path = Path(tmp.name)
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024): # 1 MB chunks
|
||||
tmp.write(chunk)
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
from bincio.extract.strava_zip import strava_zip_iter
|
||||
from bincio.render.merge import merge_all
|
||||
|
||||
log.info("strava-zip[%s]: received %s, privacy=%s", user.handle, file.filename, privacy)
|
||||
|
||||
def event_stream():
|
||||
any_imported = False
|
||||
imported_count = 0
|
||||
error_count = 0
|
||||
try:
|
||||
for event in strava_zip_iter(zip_path, dd, privacy=privacy):
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
if event.get("type") == "progress":
|
||||
status = event.get("status")
|
||||
if status == "imported":
|
||||
any_imported = True
|
||||
imported_count += 1
|
||||
elif status == "error":
|
||||
error_count += 1
|
||||
log.warning("strava-zip[%s]: error on %s: %s",
|
||||
user.handle, event.get("name"), event.get("detail", ""))
|
||||
if event.get("type") == "done":
|
||||
log.info("strava-zip[%s]: done — imported=%d errors=%d",
|
||||
user.handle, imported_count, error_count)
|
||||
if any_imported:
|
||||
merge_all(dd)
|
||||
try:
|
||||
from bincio.explore import bake_tracks
|
||||
bake_tracks(user.handle, deps._get_data_dir())
|
||||
except Exception as exc:
|
||||
log.warning("strava-zip[%s]: bake_tracks failed (non-fatal): %s", user.handle, exc)
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
except Exception as exc:
|
||||
log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
|
||||
finally:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
+43
-2481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
"""Background workers and job tracker for bincio.serve."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from bincio.serve import deps
|
||||
|
||||
log = logging.getLogger("bincio.serve")
|
||||
|
||||
# ── Job tracker ───────────────────────────────────────────────────────────────
|
||||
|
||||
_jobs_lock = threading.Lock()
|
||||
_active_jobs: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _job_start(user_handle: str, total_files: int) -> str:
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
with _jobs_lock:
|
||||
_active_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user": user_handle,
|
||||
"started_at": int(time.time()),
|
||||
"total": total_files,
|
||||
"done": 0,
|
||||
"current": "",
|
||||
}
|
||||
return job_id
|
||||
|
||||
|
||||
def _job_update(job_id: str, done: int, current: str) -> None:
|
||||
with _jobs_lock:
|
||||
if job_id in _active_jobs:
|
||||
_active_jobs[job_id]["done"] = done
|
||||
_active_jobs[job_id]["current"] = current
|
||||
|
||||
|
||||
def _job_finish(job_id: str) -> None:
|
||||
with _jobs_lock:
|
||||
_active_jobs.pop(job_id, None)
|
||||
|
||||
|
||||
# ── Post-write rebuild ────────────────────────────────────────────────────────
|
||||
|
||||
_rebuild_lock = threading.Lock()
|
||||
_site_rebuild_event = threading.Event()
|
||||
|
||||
_low_priority = {"preexec_fn": lambda: os.nice(19)}
|
||||
|
||||
|
||||
def _site_rebuild_worker() -> None:
|
||||
"""Single background thread: debounced Astro build + rsync after uploads.
|
||||
|
||||
Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle,
|
||||
then runs one full build. Uploads that arrive during the build set the
|
||||
event again, so a follow-up build starts after the current one finishes.
|
||||
"""
|
||||
_webroot = str(deps.webroot)
|
||||
_data_dir = str(deps.data_dir)
|
||||
_site_dir = str(deps.site_dir)
|
||||
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
|
||||
while True:
|
||||
_site_rebuild_event.wait()
|
||||
_site_rebuild_event.clear()
|
||||
time.sleep(60)
|
||||
_site_rebuild_event.clear()
|
||||
log.info("site-rebuild: starting full build + rsync to %s", _webroot)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[uv, "run", "bincio", "render",
|
||||
"--data-dir", _data_dir,
|
||||
"--site-dir", _site_dir],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**_low_priority,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("site-rebuild: build failed (rc=%d):\n%s\n%s",
|
||||
result.returncode, result.stdout, result.stderr)
|
||||
continue
|
||||
dist_data = Path(_site_dir) / "dist" / "data"
|
||||
if dist_data.exists():
|
||||
shutil.rmtree(dist_data)
|
||||
rsync = subprocess.run(
|
||||
["rsync", "-a", "--delete", "--exclude=data/",
|
||||
f"{_site_dir}/dist/", _webroot + "/"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**_low_priority,
|
||||
)
|
||||
if rsync.returncode != 0:
|
||||
log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s",
|
||||
rsync.returncode, rsync.stdout, rsync.stderr)
|
||||
else:
|
||||
log.info("site-rebuild: rsync done, generating OG images")
|
||||
og_script = Path(_site_dir).parent / "scripts" / "generate_og_images.py"
|
||||
if og_script.exists() and deps.webroot is not None:
|
||||
og = subprocess.run(
|
||||
[uv, "run", "python3", str(og_script),
|
||||
"--data-dir", _data_dir,
|
||||
"--www-root", _webroot],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
preexec_fn=lambda: os.nice(19),
|
||||
)
|
||||
if og.returncode != 0:
|
||||
log.error("site-rebuild: og-images failed (rc=%d):\n%s\n%s",
|
||||
og.returncode, og.stdout, og.stderr)
|
||||
else:
|
||||
log.info("site-rebuild: done")
|
||||
except Exception:
|
||||
log.exception("site-rebuild: unexpected error")
|
||||
|
||||
|
||||
def _trigger_rebuild(handle: str) -> None:
|
||||
"""Merge sidecars for handle asynchronously; signal the site-rebuild worker."""
|
||||
if deps.site_dir is None:
|
||||
return
|
||||
if not deps._VALID_HANDLE.match(handle):
|
||||
return
|
||||
|
||||
uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
|
||||
_data_dir = str(deps.data_dir)
|
||||
_site_dir = str(deps.site_dir)
|
||||
_handle = handle
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
log.info("rebuild[%s]: merge-only", _handle)
|
||||
with _rebuild_lock:
|
||||
result = subprocess.run(
|
||||
[uv, "run", "bincio", "render",
|
||||
"--data-dir", _data_dir,
|
||||
"--site-dir", _site_dir,
|
||||
"--handle", _handle,
|
||||
"--no-build"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**_low_priority,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s",
|
||||
_handle, result.returncode, result.stdout, result.stderr)
|
||||
else:
|
||||
log.info("rebuild[%s]: merge done", _handle)
|
||||
if deps.webroot is not None:
|
||||
_site_rebuild_event.set()
|
||||
except Exception:
|
||||
log.exception("rebuild[%s]: unexpected error", _handle)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Shared image upload utilities used by both the edit and serve servers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED_IMAGE_TYPES: frozenset[str] = frozenset({
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
})
|
||||
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 1
|
||||
while (directory / candidate).exists():
|
||||
candidate = f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Headless multi-user Garmin sync — designed to run as a systemd timer.
|
||||
|
||||
For each user directory that contains garmin_creds.json, tries to refresh
|
||||
the cached garth OAuth2 session (fast, no full login), falls back to a full
|
||||
email/password re-login only when the session has expired, then fetches and
|
||||
ingests new activities via garmin_sync_iter.
|
||||
|
||||
After all users are synced, optionally POSTs to a server endpoint to trigger
|
||||
an Astro rebuild + rsync.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
_STATUS_FILE = "_garmin_sync_status.json"
|
||||
|
||||
log = logging.getLogger("bincio.sync_garmin")
|
||||
|
||||
|
||||
def _write_status(
|
||||
user_dir: Path,
|
||||
status: str,
|
||||
imported: int,
|
||||
errors: int,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
payload: dict = {
|
||||
"status": status,
|
||||
"imported": imported,
|
||||
"errors": errors,
|
||||
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if error_message is not None:
|
||||
payload["error_message"] = error_message
|
||||
try:
|
||||
(user_dir / _STATUS_FILE).write_text(
|
||||
json.dumps(payload, indent=2), encoding="utf-8"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _post_rebuild(url: str, secret: str | None) -> None:
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if secret:
|
||||
headers["X-Sync-Secret"] = secret
|
||||
req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
log.info("rebuild triggered: HTTP %d", resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100])
|
||||
except Exception as exc:
|
||||
log.error("rebuild trigger failed: %s", exc)
|
||||
|
||||
|
||||
def sync_user(data_dir: Path, user_dir: Path) -> tuple[int, int]:
|
||||
"""Sync one user's Garmin activities.
|
||||
|
||||
Returns (imported_count, error_count). Skips silently if no credentials.
|
||||
"""
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials, get_client
|
||||
from bincio.extract.garmin_sync import run_garmin_sync
|
||||
|
||||
handle = user_dir.name
|
||||
|
||||
if not has_credentials(user_dir):
|
||||
log.debug("sync[%s]: no garmin_creds.json — skipped", handle)
|
||||
_write_status(user_dir, "no_credentials", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
# Explicit auth step so we can distinguish auth failures from API failures.
|
||||
try:
|
||||
get_client(data_dir, user_dir)
|
||||
except GarminError as exc:
|
||||
log.error("sync[%s]: auth failed: %s", handle, exc)
|
||||
_write_status(user_dir, "auth_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
try:
|
||||
result = run_garmin_sync(data_dir, user_dir)
|
||||
except RuntimeError as exc:
|
||||
log.error("sync[%s]: sync failed: %s", handle, exc)
|
||||
_write_status(user_dir, "api_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
imported = result.get("imported", 0)
|
||||
error_count = result.get("error_count", 0)
|
||||
|
||||
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, error_count)
|
||||
_write_status(user_dir, "ok", imported, error_count)
|
||||
return imported, error_count
|
||||
|
||||
|
||||
def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]:
|
||||
"""Sync all users that have garmin_creds.json. Returns {handle: (imported, errors)}."""
|
||||
results: dict[str, tuple[int, int]] = {}
|
||||
cred_files = sorted(root_data_dir.glob("*/garmin_creds.json"))
|
||||
if not cred_files:
|
||||
log.info("sync_all: no users with garmin_creds.json found in %s", root_data_dir)
|
||||
return results
|
||||
log.info("sync_all: %d user(s) with Garmin credentials", len(cred_files))
|
||||
for cf in cred_files:
|
||||
user_dir = cf.parent
|
||||
handle = user_dir.name
|
||||
try:
|
||||
results[handle] = sync_user(root_data_dir, user_dir)
|
||||
except Exception as exc:
|
||||
log.exception("sync_all[%s]: unexpected error: %s", handle, exc)
|
||||
results[handle] = (0, -1)
|
||||
return results
|
||||
|
||||
|
||||
@click.command("sync-garmin")
|
||||
@click.option("--data-dir", "data_dir_str", required=True,
|
||||
help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).")
|
||||
@click.option("--user", "only_user", default=None,
|
||||
help="Sync only this handle instead of all users.")
|
||||
@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL",
|
||||
help="POST here after a successful sync to trigger a site rebuild.")
|
||||
@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET",
|
||||
help="Value sent as X-Sync-Secret header to the rebuild endpoint.")
|
||||
def sync_garmin_cmd(
|
||||
data_dir_str: str,
|
||||
only_user: str | None,
|
||||
rebuild_url: str | None,
|
||||
rebuild_secret: str | None,
|
||||
) -> None:
|
||||
"""Headless Garmin sync for all users (designed for systemd timer).
|
||||
|
||||
Discovers every user directory that has garmin_creds.json, tries to
|
||||
resume the cached garth session (no full re-login if the token is still
|
||||
valid), fetches new activities, and optionally triggers a site rebuild.
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
|
||||
root = Path(data_dir_str).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(f"Data dir not found: {root}")
|
||||
|
||||
if only_user:
|
||||
user_dir = root / only_user
|
||||
if not user_dir.is_dir():
|
||||
raise click.ClickException(f"User dir not found: {user_dir}")
|
||||
new_count, err_count = sync_user(root, user_dir)
|
||||
click.echo(f"{only_user}: {new_count} imported, {err_count} errors")
|
||||
total_new = new_count
|
||||
else:
|
||||
results = sync_all(root)
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
total_err = sum(e for _, e in results.values())
|
||||
click.echo(
|
||||
f"Sync complete: {len(results)} users, "
|
||||
f"{total_new} new activities, {total_err} errors"
|
||||
)
|
||||
|
||||
if total_new > 0 and rebuild_url:
|
||||
_post_rebuild(rebuild_url, rebuild_secret)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Headless multi-user Strava sync — designed to run as a systemd timer.
|
||||
|
||||
For each user directory that contains both strava_token.json and
|
||||
strava_credentials.json, refreshes the token, fetches new activities,
|
||||
writes them to the user's data dir, merges sidecars, and updates the
|
||||
_strava_sync.json checkpoint.
|
||||
|
||||
After all users are synced, optionally POSTs to a server endpoint
|
||||
to trigger an Astro rebuild + rsync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
_TOKEN_FILE = "strava_token.json"
|
||||
_CREDS_FILE = "strava_credentials.json"
|
||||
_SYNC_FILE = "_strava_sync.json"
|
||||
_STATUS_FILE = "_strava_sync_status.json"
|
||||
|
||||
log = logging.getLogger("bincio.sync_strava")
|
||||
|
||||
|
||||
def _write_status(
|
||||
user_dir: Path,
|
||||
status: str,
|
||||
imported: int,
|
||||
errors: int,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
payload: dict = {
|
||||
"status": status,
|
||||
"imported": imported,
|
||||
"errors": errors,
|
||||
"last_run": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if error_message is not None:
|
||||
payload["error_message"] = error_message
|
||||
try:
|
||||
(user_dir / _STATUS_FILE).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _load_creds(user_dir: Path) -> tuple[str, str] | None:
|
||||
"""Return (client_id, client_secret) from strava_credentials.json, or None."""
|
||||
p = user_dir / _CREDS_FILE
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
d = json.loads(p.read_text(encoding="utf-8"))
|
||||
cid = str(d.get("client_id", "")).strip()
|
||||
csec = str(d.get("client_secret", "")).strip()
|
||||
if cid and csec:
|
||||
return cid, csec
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def sync_user(user_dir: Path) -> tuple[int, int]:
|
||||
"""Sync one user's Strava activities.
|
||||
|
||||
Returns (new_count, error_count). Skips silently if no credentials.
|
||||
"""
|
||||
from bincio.extract.strava_api import ensure_fresh, fetch_activities, fetch_streams, StravaError
|
||||
from bincio.extract.metrics import compute
|
||||
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
|
||||
from bincio.import_.strava import _strava_to_parsed, _patch_from_summary
|
||||
from bincio.render.merge import merge_all
|
||||
|
||||
handle = user_dir.name
|
||||
|
||||
creds = _load_creds(user_dir)
|
||||
if creds is None:
|
||||
log.debug("sync[%s]: no strava_credentials.json — skipped", handle)
|
||||
_write_status(user_dir, "no_credentials", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
client_id, client_secret = creds
|
||||
|
||||
try:
|
||||
token = ensure_fresh(user_dir, client_id, client_secret)
|
||||
except StravaError as exc:
|
||||
log.error("sync[%s]: token refresh failed: %s", handle, exc)
|
||||
_write_status(user_dir, "token_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
access_token = token["access_token"]
|
||||
|
||||
# Load incremental sync state
|
||||
sync_path = user_dir / _SYNC_FILE
|
||||
sync_state: dict = (
|
||||
json.loads(sync_path.read_text(encoding="utf-8"))
|
||||
if sync_path.exists() else {}
|
||||
)
|
||||
imported_ids: set[str] = set(sync_state.get("imported_ids", []))
|
||||
|
||||
after_ts: int | None = None
|
||||
if sync_state.get("last_sync"):
|
||||
last = datetime.fromisoformat(sync_state["last_sync"])
|
||||
# 1-hour overlap to catch activities saved late to Strava
|
||||
after_ts = int((last - timedelta(hours=1)).timestamp())
|
||||
|
||||
try:
|
||||
all_acts = fetch_activities(access_token, after=after_ts)
|
||||
except StravaError as exc:
|
||||
log.error("sync[%s]: fetch_activities failed: %s", handle, exc)
|
||||
_write_status(user_dir, "api_error", 0, 1, str(exc))
|
||||
return 0, 1
|
||||
|
||||
new_acts = [a for a in all_acts if str(a["id"]) not in imported_ids]
|
||||
log.info(
|
||||
"sync[%s]: %d new, %d already imported",
|
||||
handle, len(new_acts), len(all_acts) - len(new_acts),
|
||||
)
|
||||
if not new_acts:
|
||||
_write_status(user_dir, "ok", 0, 0)
|
||||
return 0, 0
|
||||
|
||||
# Load existing index so we can update it in place
|
||||
index_path = user_dir / "index.json"
|
||||
if index_path.exists():
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
index_data = {"owner": {"handle": handle}, "activities": []}
|
||||
owner = index_data.get("owner", {})
|
||||
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
|
||||
|
||||
imported = 0
|
||||
errors = 0
|
||||
|
||||
for act in new_acts:
|
||||
strava_id = str(act["id"])
|
||||
try:
|
||||
try:
|
||||
streams = fetch_streams(access_token, int(strava_id))
|
||||
except StravaError as exc:
|
||||
if "404" in str(exc):
|
||||
# Activity exists in list but has no accessible streams (old/deleted GPS).
|
||||
# Still import it using summary-only stats via _patch_from_summary.
|
||||
streams = {}
|
||||
else:
|
||||
raise
|
||||
|
||||
# strava_api.fetch_streams returns {type: {"data": [...], ...}};
|
||||
# _strava_to_parsed (from import_/strava.py) expects {type: [...]}
|
||||
flat_streams = {
|
||||
k: v["data"] for k, v in streams.items()
|
||||
if isinstance(v, dict) and "data" in v
|
||||
}
|
||||
|
||||
parsed = _strava_to_parsed(act, flat_streams)
|
||||
metrics = compute(parsed)
|
||||
metrics = _patch_from_summary(metrics, act)
|
||||
act_id = make_activity_id(parsed)
|
||||
|
||||
# Respect Strava visibility: only_me → unlisted
|
||||
visibility = act.get("visibility") or ""
|
||||
privacy = "unlisted" if (act.get("private") or visibility == "only_me") else "public"
|
||||
|
||||
write_activity(parsed, metrics, user_dir, privacy=privacy)
|
||||
summaries[act_id] = build_summary(parsed, metrics, act_id, privacy)
|
||||
imported_ids.add(strava_id)
|
||||
imported += 1
|
||||
except Exception as exc:
|
||||
log.error("sync[%s]: activity %s failed: %s", handle, strava_id, exc)
|
||||
errors += 1
|
||||
|
||||
# Persist index and sync checkpoint
|
||||
write_index(list(summaries.values()), user_dir, owner)
|
||||
sync_state["imported_ids"] = sorted(imported_ids)
|
||||
sync_state["last_sync"] = datetime.now(timezone.utc).isoformat()
|
||||
sync_path.write_text(json.dumps(sync_state, indent=2), encoding="utf-8")
|
||||
|
||||
# Merge sidecars so _merged/ reflects any edits
|
||||
merge_all(user_dir)
|
||||
|
||||
log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors)
|
||||
_write_status(user_dir, "ok", imported, errors)
|
||||
return imported, errors
|
||||
|
||||
|
||||
def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]:
|
||||
"""Sync all users that have a strava_token.json. Returns {handle: (new, errors)}."""
|
||||
results: dict[str, tuple[int, int]] = {}
|
||||
token_files = sorted(root_data_dir.glob("*/strava_token.json"))
|
||||
if not token_files:
|
||||
log.info("sync_all: no users with strava_token.json found in %s", root_data_dir)
|
||||
return results
|
||||
log.info("sync_all: %d user(s) with Strava token", len(token_files))
|
||||
for tf in token_files:
|
||||
user_dir = tf.parent
|
||||
handle = user_dir.name
|
||||
try:
|
||||
results[handle] = sync_user(user_dir)
|
||||
except Exception as exc:
|
||||
log.exception("sync_all[%s]: unexpected error: %s", handle, exc)
|
||||
results[handle] = (0, -1)
|
||||
return results
|
||||
|
||||
|
||||
def _post_rebuild(url: str, secret: str | None) -> None:
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if secret:
|
||||
headers["X-Sync-Secret"] = secret
|
||||
req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
log.info("rebuild triggered: HTTP %d", resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100])
|
||||
except Exception as exc:
|
||||
log.error("rebuild trigger failed: %s", exc)
|
||||
|
||||
|
||||
@click.command("sync-strava")
|
||||
@click.option("--data-dir", "data_dir_str", required=True,
|
||||
help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).")
|
||||
@click.option("--user", "only_user", default=None,
|
||||
help="Sync only this handle instead of all users.")
|
||||
@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL",
|
||||
help="POST here after a successful sync to trigger a site rebuild.")
|
||||
@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET",
|
||||
help="Value sent as X-Sync-Secret header to the rebuild endpoint.")
|
||||
def sync_strava_cmd(
|
||||
data_dir_str: str,
|
||||
only_user: str | None,
|
||||
rebuild_url: str | None,
|
||||
rebuild_secret: str | None,
|
||||
) -> None:
|
||||
"""Headless Strava sync for all users (designed for systemd timer).
|
||||
|
||||
Discovers every user directory that has both strava_token.json and
|
||||
strava_credentials.json, syncs new activities, and optionally triggers
|
||||
a site rebuild via an HTTP POST.
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
|
||||
root = Path(data_dir_str).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(f"Data dir not found: {root}")
|
||||
|
||||
if only_user:
|
||||
user_dir = root / only_user
|
||||
if not user_dir.is_dir():
|
||||
raise click.ClickException(f"User dir not found: {user_dir}")
|
||||
new_count, err_count = sync_user(user_dir)
|
||||
click.echo(f"{only_user}: {new_count} imported, {err_count} errors")
|
||||
total_new = new_count
|
||||
else:
|
||||
results = sync_all(root)
|
||||
total_new = sum(n for n, _ in results.values())
|
||||
total_err = sum(e for _, e in results.values())
|
||||
click.echo(
|
||||
f"Sync complete: {len(results)} users, "
|
||||
f"{total_new} new activities, {total_err} errors"
|
||||
)
|
||||
|
||||
if total_new > 0 and rebuild_url:
|
||||
_post_rebuild(rebuild_url, rebuild_secret)
|
||||
@@ -0,0 +1,84 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Project notes
|
||||
|
||||
### Activity page power curve — comparison lines (future)
|
||||
|
||||
The activity page shows a single-activity power curve from `detail.mmp` (pre-computed at
|
||||
extract time, zero extra requests). Adding "last 365 d / last 90 d" comparison overlays
|
||||
requires the pre-computed `power_curve.last_365d` / `power_curve.last_90d` arrays, which
|
||||
currently live only in `athlete.json`. Loading `athlete.json` at activity-page time is
|
||||
wasteful (it's a large file with all activity summaries).
|
||||
|
||||
**Clean solution when the time comes:** at `render` time (inside `_merge_edits` or a
|
||||
dedicated step in `bincio/render/cli.py`), bake the comparison curves into each activity's
|
||||
detail JSON — e.g. add a `power_curve_context` key with `all_time`, `last_365d`, `last_90d`.
|
||||
The activity page then gets them for free with the detail JSON it already fetches.
|
||||
Requires a one-time `bincio render` (no code changes to the extractor).
|
||||
Component to update: `site/src/components/ActivityPowerCurve.svelte`.
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Garmin sync
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/bincio
|
||||
|
||||
# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve)
|
||||
# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in.
|
||||
EnvironmentFile=/etc/bincio/sync.env
|
||||
|
||||
ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-garmin \
|
||||
--data-dir /var/bincio/data \
|
||||
--rebuild-url http://localhost:4041/api/internal/rebuild
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=bincio-sync-garmin
|
||||
|
||||
# Don't restart on failure — the timer will retry in 3 hours.
|
||||
Restart=no
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Garmin sync — every 3 hours
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
|
||||
[Timer]
|
||||
# Fire at 01:30, 04:30, 07:30, 10:30, 13:30, 16:30, 19:30, 22:30 UTC
|
||||
# Offset by 1h30m from the Strava timer to avoid simultaneous rebuilds.
|
||||
OnCalendar=*-*-* 01,04,07,10,13,16,19,22:30:00
|
||||
# Catch up if the VPS was offline during a scheduled run
|
||||
Persistent=true
|
||||
# Spread load within a 2-minute window
|
||||
RandomizedDelaySec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Strava sync
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/bincio
|
||||
|
||||
# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve)
|
||||
# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in.
|
||||
EnvironmentFile=/etc/bincio/sync.env
|
||||
|
||||
ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-strava \
|
||||
--data-dir /var/bincio/data \
|
||||
--rebuild-url http://localhost:4041/api/internal/rebuild
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=bincio-sync
|
||||
|
||||
# Don't restart on failure — the timer will retry in 3 hours.
|
||||
Restart=no
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=BincioActivity Strava sync — every 3 hours
|
||||
Documentation=https://github.com/bincio/bincio-activity
|
||||
|
||||
[Timer]
|
||||
# Fire at 00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 UTC
|
||||
OnCalendar=*-*-* 00,03,06,09,12,15,18,21:00:00
|
||||
# Catch up if the VPS was offline during a scheduled run
|
||||
Persistent=true
|
||||
# Spread load within a 2-minute window to avoid exact midnight spikes
|
||||
RandomizedDelaySec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,7 @@
|
||||
# /etc/bincio/sync.env — secrets for bincio-sync.service
|
||||
# Copy this file to /etc/bincio/sync.env and fill in the values.
|
||||
# chmod 600 /etc/bincio/sync.env
|
||||
|
||||
# Must match the --sync-secret / BINCIO_SYNC_SECRET value passed to `bincio serve`.
|
||||
# Generate with: openssl rand -hex 32
|
||||
BINCIO_SYNC_SECRET=your-secret-here
|
||||
@@ -26,6 +26,13 @@ Welcome to BincioActivity — a federated, self-hosted activity stats platform.
|
||||
|
||||
**[CLI Reference](reference/cli.md)** — All bincio commands and options.
|
||||
|
||||
## Mobile App
|
||||
|
||||
The mobile app (Expo/React Native) source code is now in the separate `bincio_autarchive` repository. See:
|
||||
|
||||
- **[Mobile App Design](mobile-app.md)** — Architecture, Pyodide extraction, Karoo integration, sync protocol (reference documentation)
|
||||
- **`bincio_autarchive/DEVELOPMENT.md`** — Build & development instructions
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [GitHub repo](https://github.com/brutsalvadi/bincio-activity)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Bincio Mobile App — Design Document
|
||||
|
||||
> **Note:** The mobile app source code is now in the separate `bincio_autarchive` repository. This document remains here as the authoritative design reference. For build & development instructions, see the `bincio_autarchive/DEVELOPMENT.md` file.
|
||||
|
||||
## Vision
|
||||
|
||||
The long-term goal is full independence from Garmin Connect, Strava, Hammerhead,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Gear Feature Plan
|
||||
|
||||
## Why the gap exists
|
||||
|
||||
Neither sync path populates gear today. The Strava API returns `gear_id` per activity
|
||||
(brut's originals show `b3437566`, `g10422777` etc.) but `strava_to_parsed()` ignores it.
|
||||
The ZIP path also ignores the gear column in activities.csv.
|
||||
Diego_p's "Rose Backroad" was set manually via the EditDrawer free-text field.
|
||||
|
||||
---
|
||||
|
||||
## Data model — `{user_dir}/gear.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid-abc123",
|
||||
"name": "Rose Backroad",
|
||||
"type": "bike",
|
||||
"retired": false,
|
||||
"strava_id": "b3437566"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `type` enum: `bike | shoes | skis | other`
|
||||
- Per-activity gear stays as a plain string (the gear **name**) — backward compatible with existing sidecars
|
||||
- `strava_id` is optional, used for deduplication during Strava sync
|
||||
|
||||
---
|
||||
|
||||
## Build order
|
||||
|
||||
### [x] Step 1 — `gear.json` CRUD API ✓
|
||||
File: `bincio/serve/routers/gear.py`
|
||||
- `GET /api/gear` → list items (auth required)
|
||||
- `POST /api/gear` → add item (auto-generate UUID id)
|
||||
- `PATCH /api/gear/{id}` → update (name, type, retired)
|
||||
- `DELETE /api/gear/{id}` → delete
|
||||
File lives at `{user_dir}/gear.json`, same pattern as `athlete.json`.
|
||||
Add gear router to `server.py`.
|
||||
|
||||
### [x] Step 2 — Gear tab in AthleteView (ownerOnly) ✓
|
||||
- Added `'gear'` to `Tab` type and `ALL_TABS` in `AthleteView.svelte`
|
||||
- Inline gear management: list, add, edit, retire — no separate component
|
||||
|
||||
### [x] Step 3 — EditDrawer gear selector ✓
|
||||
- At drawer open, fetches `/api/gear`
|
||||
- Shows `<select>` from registry (if items exist), with "Other…" revealing text input
|
||||
- Falls back to plain text input if no gear items registered
|
||||
- Value still stored as gear name string — backward compatible
|
||||
|
||||
### [x] Step 4 — Strava sync gear extraction ✓
|
||||
- `strava_api.py`: added `fetch_gear()` + `gear` field on `strava_to_parsed()` via `_gear_name` meta key
|
||||
- `ingest.py`: during sync, resolves gear_id → name, adds new items to registry
|
||||
- New endpoint `POST /api/strava/import-gear`: one-time backfill from stored originals
|
||||
|
||||
### [x] Step 5 — ZIP import gear column ✓
|
||||
- `strava_zip.py`: reads `Gear` column from activities.csv and sets `parsed.gear`
|
||||
|
||||
### [x] Step 6 — One-time backfill endpoint ✓
|
||||
`POST /api/strava/import-gear` implemented in `strava.py`.
|
||||
+16
-9
@@ -4,6 +4,16 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bincio</title>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) {
|
||||
regs.forEach(function(r) { r.unregister(); });
|
||||
});
|
||||
caches.keys().then(function(keys) {
|
||||
keys.forEach(function(k) { caches.delete(k); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var palettes = {
|
||||
@@ -52,7 +62,6 @@
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body { visibility: hidden; }
|
||||
|
||||
.wrap {
|
||||
max-width: 384px;
|
||||
@@ -222,12 +231,13 @@
|
||||
|
||||
function showApps(user) {
|
||||
loginDiv.style.display = 'none';
|
||||
appsDiv.style.display = '';
|
||||
appsDiv.style.display = 'block';
|
||||
greeting.textContent = 'Ciao ' + (user.display_name || user.handle);
|
||||
cardsDiv.innerHTML = '';
|
||||
if (user.activity_access)
|
||||
// activity_access/wiki_access not in CurrentUserResponse yet — default to true
|
||||
if (user.activity_access !== false)
|
||||
cardsDiv.appendChild(appCard('BincioActivity', 'Tracks, strade e numeri', ACTIVITY_URL));
|
||||
if (user.wiki_access)
|
||||
if (user.wiki_access !== false)
|
||||
cardsDiv.appendChild(appCard('BincioWiki', 'La memoria collettiva del gruppo', WIKI_URL));
|
||||
}
|
||||
|
||||
@@ -237,11 +247,8 @@
|
||||
}
|
||||
|
||||
fetch('/api/me', { credentials: 'include' })
|
||||
.then(async r => {
|
||||
document.body.style.visibility = '';
|
||||
if (r.ok) showApps(await r.json());
|
||||
})
|
||||
.catch(() => { document.body.style.visibility = ''; });
|
||||
.then(async r => { if (r.ok) showApps(await r.json()); })
|
||||
.catch(() => {});
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil((async () => {
|
||||
await self.clients.claim();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map(k => caches.delete(k)));
|
||||
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
for (const c of all) c.navigate(c.url);
|
||||
await self.registration.unregister();
|
||||
})());
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
# Generated native projects (managed workflow — produced by EAS, not committed)
|
||||
android/
|
||||
ios/
|
||||
|
||||
# Local env overrides
|
||||
.env.local
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { getSetting, setSetting } from '@/db/queries';
|
||||
import { autoKey, PALETTES, type PaletteKey, type Theme } from '@/theme';
|
||||
|
||||
type ThemeCtx = {
|
||||
theme: Theme;
|
||||
paletteKey: PaletteKey;
|
||||
setPaletteOverride: (key: PaletteKey) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeCtx>({
|
||||
theme: PALETTES.default,
|
||||
paletteKey: 'auto',
|
||||
setPaletteOverride: () => {},
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const db = useSQLiteContext();
|
||||
const [paletteKey, setPaletteKey] = useState<PaletteKey>('auto');
|
||||
|
||||
useEffect(() => {
|
||||
getSetting(db, 'palette_override').then(val => {
|
||||
if (val) setPaletteKey(val as PaletteKey);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function setPaletteOverride(key: PaletteKey) {
|
||||
setPaletteKey(key);
|
||||
setSetting(db, 'palette_override', key);
|
||||
}
|
||||
|
||||
const resolved = paletteKey === 'auto' ? autoKey() : paletteKey;
|
||||
const theme = PALETTES[resolved as keyof typeof PALETTES] ?? PALETTES.default;
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, paletteKey, setPaletteOverride }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): Theme {
|
||||
return useContext(ThemeContext).theme;
|
||||
}
|
||||
|
||||
export function usePaletteControl(): Pick<ThemeCtx, 'paletteKey' | 'setPaletteOverride'> {
|
||||
const { paletteKey, setPaletteOverride } = useContext(ThemeContext);
|
||||
return { paletteKey, setPaletteOverride };
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
// Embed the JS bundle in debug builds so the APK runs without a Metro server
|
||||
// (needed for deployment to Karoo and other standalone devices).
|
||||
// debuggableVariants lists variants that skip bundling; empty = bundle all variants.
|
||||
debuggableVariants = []
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||
// reactNativeDir = file("../../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
|
||||
/* Autolinking */
|
||||
autolinkLibrariesWithApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace 'org.bincio.app'
|
||||
defaultConfig {
|
||||
applicationId 'org.bincio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "arm64-v8a", "armeabi-v7a"
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Bincio",
|
||||
"slug": "bincio",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"scheme": "bincio",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"platforms": ["ios", "android"],
|
||||
"icon": "./assets/icon.png",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#09090b"
|
||||
},
|
||||
"android": {
|
||||
"package": "org.bincio.app",
|
||||
"usesCleartextTraffic": true,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#09090b"
|
||||
},
|
||||
"permissions": [
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.POST_NOTIFICATIONS"
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "org.bincio.app",
|
||||
"supportsTablet": true
|
||||
},
|
||||
"plugins": [
|
||||
"expo-system-ui",
|
||||
"expo-router",
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-document-picker",
|
||||
{ "iCloudContainerEnvironment": "Production" }
|
||||
],
|
||||
"expo-background-fetch",
|
||||
"expo-task-manager",
|
||||
"@maplibre/maplibre-react-native"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
const isKaroo = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||
|
||||
export default function TabLayout() {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: { backgroundColor: '#18181b', borderTopColor: '#27272a' },
|
||||
tabBarActiveTintColor: theme.accent,
|
||||
tabBarInactiveTintColor: '#71717a',
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{ title: 'Feed', tabBarIcon: ({ color }) => <TabIcon label="⬡" color={color} /> }}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="import"
|
||||
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
options={{
|
||||
title: 'Search',
|
||||
tabBarIcon: ({ color }) => <TabIcon label="⌕" color={color} />,
|
||||
href: isKaroo ? null : '/search',
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function TabIcon({ label, color }: { label: string; color: string }) {
|
||||
const { Text } = require('react-native');
|
||||
return <Text style={{ color, fontSize: 18 }}>{label}</Text>;
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
|
||||
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
||||
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
|
||||
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
async function sha256hex(text: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
|
||||
const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz'];
|
||||
const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS];
|
||||
|
||||
type ImportState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading'; msg: string; current: number; total: number }
|
||||
| { status: 'done'; count: number; errors: Array<{ name: string; message: string }> }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export default function ImportScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const theme = useTheme();
|
||||
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
||||
const [watchPath, setWatchPath] = useState('');
|
||||
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
|
||||
const isImporting = useRef(false);
|
||||
|
||||
// Track engine availability so we can show the server-extraction notice.
|
||||
useEffect(() => {
|
||||
waitForEngine(30_000)
|
||||
.then(() => setEngineAvailable(true))
|
||||
.catch((e: unknown) => {
|
||||
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reload watch path every time the Import tab comes into focus so changes
|
||||
// saved in Settings are picked up without remounting the tab.
|
||||
useFocusEffect(useCallback(() => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
['auto_import_path'],
|
||||
);
|
||||
setWatchPath(row?.value ?? '');
|
||||
}, [db]));
|
||||
|
||||
// Auto-scan watch folder on mount and when app comes to foreground.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
runAutoScan();
|
||||
|
||||
const sub = AppState.addEventListener('change', (next) => {
|
||||
if (next === 'active') runAutoScan();
|
||||
});
|
||||
return () => sub.remove();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function runAutoScan() {
|
||||
if (isImporting.current) return;
|
||||
const path = await getSetting(db, 'auto_import_path');
|
||||
if (!path) return;
|
||||
const instanceUrl = await getSetting(db, 'instance_url');
|
||||
if (!instanceUrl) return;
|
||||
|
||||
// Wait for engine — skip auto-scan on init failure, but continue if device is
|
||||
// too old for local extraction (importNativeFile will use the server instead).
|
||||
try { await waitForEngine(120_000); } catch (e: unknown) {
|
||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
|
||||
}
|
||||
|
||||
// Server-mode requires a valid token — verify before touching any files.
|
||||
if (isEngineAvailable() === false) {
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!token) return;
|
||||
try { await checkServerAuth(instanceUrl, token); } catch { return; }
|
||||
}
|
||||
|
||||
const newFiles = await discoverNewFiles(db, path);
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
isImporting.current = true;
|
||||
try {
|
||||
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
|
||||
} finally {
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualScan() {
|
||||
if (isImporting.current) return;
|
||||
const path = await getSetting(db, 'auto_import_path');
|
||||
if (!path) return;
|
||||
const instanceUrl = await getSetting(db, 'instance_url');
|
||||
if (!instanceUrl) {
|
||||
setState({ status: 'error', message: 'No Bincio instance configured. Go to Settings and enter an instance URL first — it\'s needed to download the extraction engine.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const serverMode = isEngineAvailable() === false;
|
||||
if (!serverMode) {
|
||||
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
|
||||
const unsubScan = onEngineProgress((msg) =>
|
||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||
);
|
||||
try {
|
||||
await waitForEngine();
|
||||
} catch (e: unknown) {
|
||||
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
|
||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
// engine_unavailable — fall through to server mode
|
||||
} finally {
|
||||
unsubScan();
|
||||
}
|
||||
} else {
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!token) {
|
||||
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
|
||||
return;
|
||||
}
|
||||
// Verify the token is valid before processing any files.
|
||||
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
|
||||
try {
|
||||
await checkServerAuth(instanceUrl, token);
|
||||
} catch (e: unknown) {
|
||||
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
|
||||
const newFiles = await discoverNewFiles(db, path);
|
||||
if (newFiles.length === 0) {
|
||||
setState({ status: 'done', count: 0, errors: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.current = true;
|
||||
try {
|
||||
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
|
||||
} finally {
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFiles() {
|
||||
if (isImporting.current) return;
|
||||
setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 });
|
||||
try {
|
||||
let result: DocumentPicker.DocumentPickerResult;
|
||||
try {
|
||||
result = await DocumentPicker.getDocumentAsync({
|
||||
type: ['*/*'],
|
||||
copyToCacheDirectory: true,
|
||||
multiple: true,
|
||||
});
|
||||
} catch (pickerErr: unknown) {
|
||||
// Some Android devices (e.g. Karoo) have no system file picker app.
|
||||
const raw = pickerErr instanceof Error ? pickerErr.message : String(pickerErr);
|
||||
const noApp = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found');
|
||||
setState({
|
||||
status: 'error',
|
||||
message: noApp
|
||||
? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder.'
|
||||
: raw,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
setState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
isImporting.current = true;
|
||||
const unsubPick = onEngineProgress((msg) =>
|
||||
setState({ status: 'loading', msg, current: 0, total: 0 }),
|
||||
);
|
||||
try {
|
||||
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
|
||||
} finally {
|
||||
unsubPick();
|
||||
isImporting.current = false;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setState({ status: 'error', message: msg });
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function processBatch(files: Array<{ uri: string; name: string; sourcePath: string | null }>) {
|
||||
const total = files.length;
|
||||
const errors: Array<{ name: string; message: string }> = [];
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { uri, name, sourcePath } = files[i];
|
||||
const lower = name.toLowerCase();
|
||||
|
||||
setState({ status: 'loading', msg: `Processing ${name}…`, current: i + 1, total });
|
||||
|
||||
try {
|
||||
if (lower.endsWith('.json')) {
|
||||
await importBasJson(uri, name, sourcePath, (msg) =>
|
||||
setState({ status: 'loading', msg, current: i + 1, total }),
|
||||
);
|
||||
} else if (ALL_NATIVE_EXTENSIONS.some(ext => lower.endsWith(ext))) {
|
||||
await importNativeFile(uri, name, sourcePath, (msg) =>
|
||||
setState({ status: 'loading', msg, current: i + 1, total }),
|
||||
);
|
||||
} else {
|
||||
errors.push({ name, message: 'Unsupported file type' });
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
} catch (e: unknown) {
|
||||
errors.push({ name, message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: 'done', count, errors });
|
||||
}
|
||||
|
||||
// ── BAS JSON import (no extraction needed) ──────────────────────────────────
|
||||
|
||||
async function importBasJson(
|
||||
uri: string,
|
||||
_name: string,
|
||||
sourcePath: string | null,
|
||||
onStatus: (msg: string) => void,
|
||||
) {
|
||||
onStatus('Importing…');
|
||||
const text = await FileSystem.readAsStringAsync(uri);
|
||||
const detail = JSON.parse(text);
|
||||
|
||||
if (!detail.id || !detail.started_at) {
|
||||
throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
|
||||
}
|
||||
|
||||
const hash = detail.source_hash ?? await sha256hex(text);
|
||||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||||
const dest = `${origDir}${detail.id}.json`;
|
||||
await FileSystem.copyAsync({ from: uri, to: dest });
|
||||
|
||||
await insertActivity(db, {
|
||||
id: detail.id,
|
||||
source_hash: hash,
|
||||
detail_json: text,
|
||||
timeseries_json: null,
|
||||
geojson: null,
|
||||
original_path: dest,
|
||||
source_path: sourcePath,
|
||||
origin: 'local',
|
||||
});
|
||||
}
|
||||
|
||||
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
|
||||
|
||||
async function importNativeFile(
|
||||
uri: string,
|
||||
name: string,
|
||||
sourcePath: string | null,
|
||||
onStatus: (msg: string) => void,
|
||||
) {
|
||||
onStatus('Reading file…');
|
||||
|
||||
// Read the original file as base64 so we can (a) pass it to the extractor
|
||||
// and (b) copy it to permanent storage without a second read.
|
||||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
let result;
|
||||
|
||||
if (isEngineAvailable() === false) {
|
||||
// Device WebView is too old for WebAssembly.Global (Chrome <69).
|
||||
// Send the raw file to the Bincio instance for server-side extraction.
|
||||
const instanceUrl = await getInstanceUrl(db);
|
||||
const token = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?', ['api_token'],
|
||||
)?.value ?? '';
|
||||
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
|
||||
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
|
||||
} else {
|
||||
// Fetch the bincio wheel here (React Native networking), not inside the
|
||||
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
|
||||
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
|
||||
const instanceUrl = await getInstanceUrl(db);
|
||||
onStatus('Fetching Bincio engine…');
|
||||
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
||||
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
|
||||
}
|
||||
|
||||
onStatus('Saving…');
|
||||
|
||||
// Copy original file to permanent storage (keeps original bytes for future re-extraction)
|
||||
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
||||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||||
const dest = `${origDir}${result.id}${ext}`;
|
||||
await FileSystem.copyAsync({ from: uri, to: dest });
|
||||
|
||||
await insertActivity(db, {
|
||||
id: result.id,
|
||||
source_hash: result.sourceHash,
|
||||
detail_json: JSON.stringify(result.detail),
|
||||
timeseries_json: result.timeseries ? JSON.stringify(result.timeseries) : null,
|
||||
geojson: result.geojson ? JSON.stringify(result.geojson) : null,
|
||||
original_path: dest,
|
||||
source_path: sourcePath,
|
||||
origin: 'local',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
{/* Hidden WebView for Pyodide — only mounted on devices that can run it.
|
||||
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
|
||||
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
|
||||
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
|
||||
<View style={styles.hiddenEngine}>
|
||||
<PyodideWebView />
|
||||
</View>
|
||||
)}
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.header}>Import</Text>
|
||||
|
||||
<Text style={styles.body}>
|
||||
Import FIT, GPX, or TCX files — extracted on your device, nothing uploaded.
|
||||
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
|
||||
</Text>
|
||||
|
||||
{engineAvailable === false && (
|
||||
<View style={styles.serverNotice}>
|
||||
<Text style={styles.serverNoticeText}>
|
||||
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
|
||||
Activities are processed by your Bincio instance instead — a connected account is required.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{watchPath ? (
|
||||
<View style={styles.watchBox}>
|
||||
<Text style={styles.watchLabel}>Watch folder</Text>
|
||||
<Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text>
|
||||
<Pressable
|
||||
style={[styles.scanButton, state.status === 'loading' && styles.buttonDisabled]}
|
||||
onPress={state.status !== 'loading' ? manualScan : undefined}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.status === 'loading' ? 'Working…' : '↺ Scan for new rides'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
|
||||
onPress={state.status !== 'loading' ? pickFiles : undefined}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.status === 'loading' ? 'Working…' : '+ Pick files'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<View style={styles.statusBox}>
|
||||
{state.total > 1 && (
|
||||
<Text style={styles.statusCounter}>
|
||||
File {state.current} of {state.total}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
|
||||
{engineAvailable !== false && (
|
||||
<Text style={styles.statusHint}>
|
||||
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state.status === 'done' && (
|
||||
<View style={[styles.success, state.count === 0 && state.errors.length === 0 && styles.successEmpty]}>
|
||||
<Text style={styles.successText}>
|
||||
{state.count === 0 && state.errors.length === 0
|
||||
? 'No new rides found'
|
||||
: `✓ Imported ${state.count} ${state.count === 1 ? 'activity' : 'activities'}`}
|
||||
</Text>
|
||||
{state.errors.map((e, i) => (
|
||||
<Text key={i} style={styles.batchError}>✗ {e.name}: {e.message}</Text>
|
||||
))}
|
||||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
||||
<Text style={styles.errorRetry}>Dismiss</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<View style={styles.error}>
|
||||
<Text style={styles.errorText}>{state.message}</Text>
|
||||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
||||
<Text style={styles.errorRetry}>Try again</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Text style={styles.sectionTitle}>Supported formats</Text>
|
||||
{([
|
||||
['FIT', 'Garmin, Wahoo, Karoo native format'],
|
||||
['GPX', 'Most GPS devices and apps'],
|
||||
['TCX', 'Garmin Training Center'],
|
||||
['BAS JSON', 'Pre-extracted Bincio format (instant)'],
|
||||
] as [string, string][]).map(([fmt, desc]) => (
|
||||
<View key={fmt} style={styles.formatRow}>
|
||||
<Text style={styles.formatName}>{fmt}</Text>
|
||||
<Text style={styles.formatDesc}>{desc}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeText}>
|
||||
{engineAvailable === false
|
||||
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
|
||||
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
|
||||
{'\n\n'}
|
||||
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watch-folder helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function requestStoragePermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') return true;
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverNewFiles(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
watchPath: string,
|
||||
): Promise<string[]> {
|
||||
const ok = await requestStoragePermission();
|
||||
if (!ok) return [];
|
||||
|
||||
// Normalize: strip trailing slash, then use file:// URI for expo-fs
|
||||
const dir = watchPath.replace(/\/+$/, '');
|
||||
const uri = dir.startsWith('file://') ? dir : `file://${dir}`;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await FileSystem.readDirectoryAsync(uri);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newFiles: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const lower = entry.toLowerCase();
|
||||
if (!lower.endsWith('.fit')) continue;
|
||||
const fullPath = `${dir}/${entry}`;
|
||||
if (!isSourcePathImported(db, fullPath)) {
|
||||
newFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
return newFiles;
|
||||
}
|
||||
|
||||
// ── Module-level helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function getInstanceUrl(db: ReturnType<typeof useSQLiteContext>): Promise<string> {
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
['instance_url'],
|
||||
);
|
||||
return (row?.value ?? '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// In-memory cache so repeated imports in one session don't re-download the wheel.
|
||||
let _cachedWheel: { base64: string; filename: string } | null = null;
|
||||
|
||||
async function fetchWheelBase64(instanceUrl: string): Promise<{ base64: string; filename: string }> {
|
||||
if (_cachedWheel) return _cachedWheel;
|
||||
|
||||
const base = instanceUrl || 'https://bincio.org';
|
||||
|
||||
// Ask the instance for the canonical wheel URL (handles both dev and prod layouts).
|
||||
let wheelUrl = `${base}/api/wheel/download`;
|
||||
let wheelFilename = 'bincio-0.1.0-py3-none-any.whl';
|
||||
try {
|
||||
const vr = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) });
|
||||
if (vr.ok) {
|
||||
const d = await vr.json() as { api_url?: string; url?: string };
|
||||
const path = d.api_url ?? d.url ?? '/api/wheel/download';
|
||||
wheelUrl = path.startsWith('http') ? path : `${base}${path}`;
|
||||
// Extract the filename from the URL path (last segment after final /)
|
||||
const urlBasename = wheelUrl.split('/').pop() ?? '';
|
||||
if (urlBasename.endsWith('.whl')) wheelFilename = urlBasename;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fetch via React Native networking (supports local HTTP; WKWebView would block it).
|
||||
const resp = await fetch(wheelUrl);
|
||||
if (!resp.ok) throw new Error(`Could not download Bincio engine (${resp.status}). Is the instance running?`);
|
||||
const buf = await resp.arrayBuffer();
|
||||
_cachedWheel = { base64: arrayBufferToBase64(buf), filename: wheelFilename };
|
||||
return _cachedWheel;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
// Process in chunks to avoid spread-operator stack overflow on large arrays.
|
||||
const CHUNK = 8192;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...(bytes.subarray(i, i + CHUNK) as unknown as number[]));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// ── Styles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#09090b' },
|
||||
hiddenEngine: { position: 'absolute', width: 1, height: 1, overflow: 'hidden' },
|
||||
container: { flex: 1 },
|
||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||||
serverNotice: {
|
||||
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
|
||||
borderColor: '#854d0e', padding: 12, marginBottom: 16,
|
||||
},
|
||||
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
|
||||
watchBox: {
|
||||
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
|
||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
|
||||
},
|
||||
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
|
||||
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
|
||||
scanButton: {
|
||||
backgroundColor: '#16a34a', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
||||
},
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
statusBox: {
|
||||
backgroundColor: '#18181b', borderRadius: 8, borderWidth: 1,
|
||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 6,
|
||||
},
|
||||
statusCounter: { color: '#71717a', fontSize: 12, textAlign: 'center' },
|
||||
statusMsg: { color: '#60a5fa', fontSize: 14, textAlign: 'center' },
|
||||
statusHint: { color: '#52525b', fontSize: 12, textAlign: 'center', lineHeight: 16 },
|
||||
success: {
|
||||
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16, gap: 6,
|
||||
},
|
||||
successEmpty: { backgroundColor: '#1c1c1e' },
|
||||
successText: { color: '#86efac', fontSize: 14 },
|
||||
batchError: { color: '#fca5a5', fontSize: 12 },
|
||||
error: {
|
||||
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
|
||||
},
|
||||
errorText: { color: '#fca5a5', fontSize: 14 },
|
||||
errorRetry: { color: '#71717a', fontSize: 13, textDecorationLine: 'underline', marginTop: 4 },
|
||||
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
|
||||
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
|
||||
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
||||
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
|
||||
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
|
||||
notice: {
|
||||
marginTop: 8, backgroundColor: '#18181b',
|
||||
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
|
||||
},
|
||||
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
|
||||
noticeCode: { fontFamily: 'monospace', color: '#a1a1aa' },
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE } from '@/db/queries';
|
||||
import { downloadFeed, uploadFeed } from '@/db/sync';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
import { ActivityCard } from '@/components/ActivityCard';
|
||||
|
||||
export default function FeedScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const theme = useTheme();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
||||
const activities = useActivities(searchQuery, limit);
|
||||
const totalCount = useActivityCount(searchQuery);
|
||||
const hasMore = activities.length < totalCount;
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const selecting = selected.size > 0;
|
||||
|
||||
// Auto-refresh the local list whenever the tab comes into focus.
|
||||
// SQLite getAllSync is sub-millisecond — no network, no lag.
|
||||
useFocusEffect(useCallback(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []));
|
||||
|
||||
function showMsg(ok: boolean, text: string) {
|
||||
setStatusMsg({ ok, text });
|
||||
setTimeout(() => setStatusMsg(null), 3500);
|
||||
}
|
||||
|
||||
const doDownload = useCallback(async () => {
|
||||
setDownloading(true);
|
||||
setStatusMsg(null);
|
||||
const result = await downloadFeed(db);
|
||||
setDownloading(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
if (result.error) {
|
||||
showMsg(false, result.error);
|
||||
} else if (result.total === 0) {
|
||||
showMsg(true, 'No activities on instance');
|
||||
} else if (result.synced === 0 && !result.fetched) {
|
||||
showMsg(true, `Up to date (${result.total} activities)`);
|
||||
} else {
|
||||
const parts = [];
|
||||
if (result.synced > 0) parts.push(`${result.synced} new`);
|
||||
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
||||
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
|
||||
}
|
||||
}, [db]);
|
||||
|
||||
const doUpload = useCallback(async () => {
|
||||
setUploading(true);
|
||||
setStatusMsg(null);
|
||||
const result = await uploadFeed(db, (n, total) => {
|
||||
setStatusMsg({ ok: true, text: `Uploading ${n} / ${total}…` });
|
||||
});
|
||||
setUploading(false);
|
||||
if (result.error) {
|
||||
showMsg(false, result.error);
|
||||
} else if (!result.uploaded && !result.failed) {
|
||||
showMsg(true, 'Nothing to upload');
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
|
||||
if (result.failed) parts.push(`${result.failed} failed`);
|
||||
showMsg(result.failed ? false : true, parts.join(', '));
|
||||
}
|
||||
}, [db]);
|
||||
|
||||
function doRefresh() {
|
||||
setRefreshKey(k => k + 1);
|
||||
}
|
||||
|
||||
function handleSearch(q: string) {
|
||||
setSearchQuery(q);
|
||||
setLimit(PAGE_SIZE); // reset pagination when search changes
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (hasMore) setLimit(l => l + PAGE_SIZE);
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelSelect() { setSelected(new Set()); }
|
||||
|
||||
function confirmDeleteSelected() {
|
||||
const count = selected.size;
|
||||
Alert.alert(
|
||||
`Delete ${count} activit${count === 1 ? 'y' : 'ies'}`,
|
||||
'These activities will be permanently removed from your device.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const ids = Array.from(selected);
|
||||
const paths = await deleteActivities(db, ids);
|
||||
setSelected(new Set());
|
||||
for (const p of paths) {
|
||||
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const busy = downloading || uploading;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
{selecting ? (
|
||||
<>
|
||||
<Text style={styles.header}>{selected.size} selected</Text>
|
||||
<Pressable style={styles.cancelButton} onPress={cancelSelect}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.header}>Feed</Text>
|
||||
<View style={styles.actionButtons}>
|
||||
<ActionButton
|
||||
icon="↑"
|
||||
label="Upload"
|
||||
loading={uploading}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doUpload}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="↓"
|
||||
label="Download"
|
||||
loading={downloading}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doDownload}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="↺"
|
||||
label="Refresh"
|
||||
loading={false}
|
||||
disabled={busy}
|
||||
accent={theme.accent}
|
||||
dim={theme.dim}
|
||||
onPress={doRefresh}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{statusMsg && (
|
||||
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
|
||||
)}
|
||||
|
||||
{!selecting && (
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
placeholder="Search activities…"
|
||||
placeholderTextColor="#52525b"
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activities.length === 0 && !busy ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>🚴</Text>
|
||||
<Text style={styles.emptyTitle}>No activities yet</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Import a file or tap ↓ to pull from your instance.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={activities}
|
||||
keyExtractor={(a) => a.id}
|
||||
extraData={refreshKey}
|
||||
renderItem={({ item }) => (
|
||||
<ActivityCard
|
||||
activity={item}
|
||||
selecting={selecting}
|
||||
checked={selected.has(item.id)}
|
||||
onToggleSelect={() => toggleSelect(item.id)}
|
||||
onLongPress={() => toggleSelect(item.id)}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={doRefresh}
|
||||
tintColor="#60a5fa"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selecting && (
|
||||
<View style={styles.actionBar}>
|
||||
<Pressable style={styles.deleteBarButton} onPress={confirmDeleteSelected}>
|
||||
<Text style={styles.deleteBarText}>Delete {selected.size}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon, label, loading, disabled, accent, dim, onPress,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
accent: string;
|
||||
dim: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
|
||||
onPress={disabled ? undefined : onPress}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
|
||||
{loading ? '…' : icon}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||
},
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
||||
actionButtons: { flexDirection: 'row', gap: 8 },
|
||||
actionBtn: {
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
actionBtnDisabled: { opacity: 0.4 },
|
||||
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
|
||||
cancelButton: {
|
||||
backgroundColor: '#27272a', borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 7,
|
||||
},
|
||||
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
||||
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
||||
searchRow: { paddingHorizontal: 16, paddingBottom: 10 },
|
||||
searchInput: {
|
||||
backgroundColor: '#18181b', borderWidth: 1, borderColor: '#27272a',
|
||||
borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8,
|
||||
color: '#f4f4f5', fontSize: 14,
|
||||
},
|
||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
||||
empty: {
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||
},
|
||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||
actionBar: {
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
backgroundColor: '#18181b', borderTopWidth: 1, borderTopColor: '#27272a',
|
||||
paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 28,
|
||||
},
|
||||
deleteBarButton: {
|
||||
backgroundColor: '#7f1d1d', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center',
|
||||
},
|
||||
deleteBarText: { color: '#fca5a5', fontSize: 15, fontWeight: '700' },
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { PAGE_SIZE, useActivityYears, useFilteredActivities, useFilteredCount, type ActivityFilter } from '@/db/queries';
|
||||
import { ActivityCard } from '@/components/ActivityCard';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
type SortKey = 'date' | 'distance' | 'elevation';
|
||||
|
||||
const SPORTS = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'cycling', label: '🚴 Cycling' },
|
||||
{ value: 'running', label: '🏃 Running' },
|
||||
{ value: 'hiking', label: '🥾 Hiking' },
|
||||
{ value: 'swimming', label: '🏊 Swimming' },
|
||||
{ value: 'walking', label: '🚶 Walking' },
|
||||
];
|
||||
|
||||
const DATE_PRESETS = [
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '30d', label: '30 days' },
|
||||
{ value: '6mo', label: '6 months' },
|
||||
];
|
||||
|
||||
const SORTS: { value: SortKey; label: string }[] = [
|
||||
{ value: 'date', label: 'Newest' },
|
||||
{ value: 'distance', label: 'Distance' },
|
||||
{ value: 'elevation', label: 'Elevation' },
|
||||
];
|
||||
|
||||
function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
|
||||
if (preset === 'all') return { dateFrom: '', dateTo: '' };
|
||||
if (/^\d{4}$/.test(preset)) {
|
||||
const y = parseInt(preset, 10);
|
||||
return { dateFrom: `${y}-01-01T000000Z`, dateTo: `${y + 1}-01-01T000000Z` };
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const now = new Date();
|
||||
let d: Date;
|
||||
if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000);
|
||||
else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000);
|
||||
else { d = new Date(now); d.setMonth(d.getMonth() - 6); }
|
||||
return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`, dateTo: '' };
|
||||
}
|
||||
|
||||
export default function SearchScreen() {
|
||||
const theme = useTheme();
|
||||
const [sport, setSport] = useState('');
|
||||
const [datePre, setDatePre] = useState('all');
|
||||
const [sort, setSort] = useState<SortKey>('date');
|
||||
const [limit, setLimit] = useState(PAGE_SIZE);
|
||||
|
||||
const years = useActivityYears();
|
||||
const dateOptions = [...DATE_PRESETS, ...years.map(y => ({ value: y, label: y }))];
|
||||
|
||||
const { dateFrom, dateTo } = computeDateRange(datePre);
|
||||
const filter: ActivityFilter = { sport, dateFrom, dateTo, sort };
|
||||
const activities = useFilteredActivities(filter, limit);
|
||||
const total = useFilteredCount(filter);
|
||||
const hasMore = activities.length < total;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.header}>Filter</Text>
|
||||
{total > 0 && <Text style={styles.count}>{total} activities</Text>}
|
||||
</View>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
||||
{SPORTS.map(s => (
|
||||
<Pill key={s.value} label={s.label} active={sport === s.value} accent={theme.accent}
|
||||
onPress={() => { setSport(s.value); setLimit(PAGE_SIZE); }} />
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
||||
{dateOptions.map(d => (
|
||||
<Pill key={d.value} label={d.label} active={datePre === d.value} accent={theme.accent}
|
||||
onPress={() => { setDatePre(d.value); setLimit(PAGE_SIZE); }} />
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.sortRow}>
|
||||
{SORTS.map(s => (
|
||||
<Pressable key={s.value}
|
||||
style={[styles.sortBtn, sort === s.value && { borderBottomColor: theme.accent, borderBottomWidth: 2 }]}
|
||||
onPress={() => { setSort(s.value); setLimit(PAGE_SIZE); }}>
|
||||
<Text style={[styles.sortText, sort === s.value && { color: theme.accent }]}>{s.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>No activities match</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
style={{ flex: 1 }}
|
||||
data={activities}
|
||||
keyExtractor={a => a.id}
|
||||
renderItem={({ item }) => (
|
||||
<ActivityCard activity={item} selecting={false} checked={false}
|
||||
onToggleSelect={() => {}} onLongPress={() => {}} />
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
onEndReached={() => { if (hasMore) setLimit(l => l + PAGE_SIZE); }}
|
||||
onEndReachedThreshold={0.3}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ label, active, accent, onPress }: {
|
||||
label: string; active: boolean; accent: string; onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.pill, active && { backgroundColor: accent + '33', borderColor: accent }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[styles.pillText, active && { color: accent }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||
},
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
||||
count: { color: '#71717a', fontSize: 13 },
|
||||
pillScroll: { flexGrow: 0, flexShrink: 0 },
|
||||
pillRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 10 },
|
||||
pill: {
|
||||
borderRadius: 20, borderWidth: 1, borderColor: '#3f3f46',
|
||||
paddingHorizontal: 14, paddingVertical: 7,
|
||||
},
|
||||
pillText: { color: '#a1a1aa', fontSize: 13, fontWeight: '500' },
|
||||
sortRow: { flexDirection: 'row', paddingHorizontal: 16, marginBottom: 4 },
|
||||
sortBtn: { marginRight: 24, paddingBottom: 8, borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
||||
sortText: { color: '#71717a', fontSize: 13, fontWeight: '600' },
|
||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
emptyText: { color: '#52525b', fontSize: 15 },
|
||||
});
|
||||
@@ -1,388 +0,0 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
|
||||
Text, TextInput, View,
|
||||
} from 'react-native';
|
||||
import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries';
|
||||
import { PALETTES, type PaletteKey } from '@/theme';
|
||||
import { useTheme, usePaletteControl } from '@/ThemeContext';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const db = useSQLiteContext();
|
||||
|
||||
const storedUrl = useSetting('instance_url') ?? '';
|
||||
const storedHandle = useSetting('handle') ?? '';
|
||||
const storedPath = useSetting('auto_import_path') ?? '';
|
||||
const storedToken = useSetting('api_token');
|
||||
const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
|
||||
const storedSyncUpload = useSetting('sync_upload') === 'true';
|
||||
const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas';
|
||||
|
||||
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
|
||||
const [handle, setHandle] = useState(storedHandle);
|
||||
const [autoPath, setAutoPath] = useState(storedPath);
|
||||
const [syncMode, setSyncMode] = useState(storedSyncMode);
|
||||
const [syncUpload, setSyncUpload] = useState(storedSyncUpload);
|
||||
const [uploadFormat, setUploadFormat] = useState(storedUploadFormat);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const theme = useTheme();
|
||||
const { paletteKey: palette, setPaletteOverride } = usePaletteControl();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const [resetArmed, setResetArmed] = useState(false);
|
||||
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
await setSetting(db, 'instance_url', instanceUrl.trim());
|
||||
await setSetting(db, 'handle', handle.trim());
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const url = instanceUrl.trim().replace(/\/$/, '');
|
||||
const h = handle.trim();
|
||||
if (!url || !h || !password) {
|
||||
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
|
||||
return;
|
||||
}
|
||||
setConnecting(true);
|
||||
setConnectMsg(null);
|
||||
try {
|
||||
const resp = await fetch(`${url}/api/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ handle: h, password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
await setSetting(db, 'instance_url', url);
|
||||
await setSetting(db, 'handle', h);
|
||||
await setSetting(db, 'api_token', data.token);
|
||||
setPassword('');
|
||||
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
|
||||
} catch {
|
||||
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await setSetting(db, 'api_token', '');
|
||||
setConnectMsg(null);
|
||||
}
|
||||
|
||||
async function resetSyncedData() {
|
||||
if (!resetArmed) {
|
||||
setResetArmed(true);
|
||||
return;
|
||||
}
|
||||
const n = await deleteRemoteActivities(db);
|
||||
setResetArmed(false);
|
||||
setResetMsg(`Removed ${n} synced ${n === 1 ? 'activity' : 'activities'}`);
|
||||
setTimeout(() => setResetMsg(null), 3000);
|
||||
}
|
||||
|
||||
const isConnected = !!storedToken;
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.header}>Settings</Text>
|
||||
|
||||
<Section title="Instance">
|
||||
<Field
|
||||
label="Instance URL"
|
||||
placeholder="https://bincio.org"
|
||||
value={instanceUrl}
|
||||
onChangeText={setInstanceUrl}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
<Field
|
||||
label="Handle"
|
||||
placeholder="yourhandle"
|
||||
value={handle}
|
||||
onChangeText={setHandle}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Connect to a Bincio instance to sync your activities. Leave blank to use
|
||||
the app offline only.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Pressable style={styles.saveButton} onPress={save}>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{saved ? '✓ Saved' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Section title="Connection">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
|
||||
<Pressable style={styles.disconnectButton} onPress={disconnect}>
|
||||
<Text style={styles.disconnectText}>Disconnect</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
autoCapitalize="none"
|
||||
secureTextEntry
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.connectButton, connecting && styles.buttonDisabled]}
|
||||
onPress={connecting ? undefined : connect}
|
||||
>
|
||||
{connecting
|
||||
? <ActivityIndicator color="#fff" size="small" />
|
||||
: <Text style={styles.connectText}>Connect</Text>}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
{connectMsg && (
|
||||
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
|
||||
{connectMsg.text}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.hint}>
|
||||
Your password is used once to obtain a session token, then forgotten.
|
||||
The token is stored locally and sent with each sync request.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{Platform.OS === 'android' && (
|
||||
<Section title="Auto-import (Android)">
|
||||
{!storedUrl ? (
|
||||
<Text style={[styles.hint, styles.hintWarn]}>
|
||||
Configure and save a Bincio instance URL above first — it's needed to download the extraction engine.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Field
|
||||
label="Watch directory"
|
||||
placeholder="/sdcard/FitFiles"
|
||||
value={autoPath}
|
||||
onChangeText={setAutoPath}
|
||||
onBlur={() => setSetting(db, 'auto_import_path', autoPath.trim())}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
New FIT files in this folder are imported automatically when you
|
||||
open the app. Leave blank to disable. Requires storage permission.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Sync">
|
||||
<Text style={styles.subLabel}>Download</Text>
|
||||
<View style={styles.modeRow}>
|
||||
<ModeButton label="Summaries only" active={syncMode === 'summaries'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} />
|
||||
<ModeButton label="Full data" active={syncMode === 'full'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} />
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{syncMode === 'full'
|
||||
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
|
||||
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
|
||||
</Text>
|
||||
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
|
||||
<View style={styles.modeRow}>
|
||||
<ModeButton label="Off" active={!syncUpload} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} />
|
||||
<ModeButton label="Upload local activities" active={syncUpload} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} />
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{syncUpload
|
||||
? 'Local activities are uploaded to the instance during sync.'
|
||||
: 'Local activities stay on device only.'}
|
||||
</Text>
|
||||
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload format</Text>
|
||||
<View style={styles.modeRow}>
|
||||
<ModeButton label="Original file" active={uploadFormat === 'raw'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setUploadFormat('raw'); setSetting(db, 'upload_format', 'raw'); }} />
|
||||
<ModeButton label="Extracted JSON" active={uploadFormat === 'bas'} accent={theme.accent} dim={theme.dim}
|
||||
onPress={() => { setUploadFormat('bas'); setSetting(db, 'upload_format', 'bas'); }} />
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{uploadFormat === 'raw'
|
||||
? 'Uploads the original FIT/GPX/TCX file. The server re-extracts it with DEM elevation correction and updates your local copy.'
|
||||
: 'Uploads the pre-extracted JSON. Faster, but no DEM elevation correction.'}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="Palette">
|
||||
<Text style={[styles.hint, { paddingBottom: 0 }]}>
|
||||
Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing.
|
||||
</Text>
|
||||
<View style={styles.modeRow}>
|
||||
{(['auto', 'default', 'giro', 'tour', 'vuelta'] as PaletteKey[]).map(key => {
|
||||
const label = key === 'auto' ? 'Auto' : PALETTES[key as keyof typeof PALETTES].label;
|
||||
const keyAccent = key === 'auto' ? theme.accent : PALETTES[key as keyof typeof PALETTES].accent;
|
||||
const keyDim = key === 'auto' ? theme.dim : PALETTES[key as keyof typeof PALETTES].dim;
|
||||
return (
|
||||
<ModeButton
|
||||
key={key}
|
||||
label={label}
|
||||
active={palette === key}
|
||||
accent={keyAccent}
|
||||
dim={keyDim}
|
||||
onPress={() => setPaletteOverride(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
<Section title="Data">
|
||||
<Pressable
|
||||
style={[styles.resetButton, resetArmed && styles.resetButtonArmed]}
|
||||
onPress={resetSyncedData}
|
||||
onBlur={() => setResetArmed(false)}
|
||||
>
|
||||
<Text style={[styles.resetText, resetArmed && styles.resetTextArmed]}>
|
||||
{resetArmed ? 'Tap again to confirm' : 'Reset synced data'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{resetMsg && <Text style={styles.msgOk}>{resetMsg}</Text>}
|
||||
<Text style={styles.hint}>
|
||||
Removes all activities synced from the instance. Locally imported files are kept.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section title="About">
|
||||
<Row label="Version" value="0.1.0 (Phase 0.5)" />
|
||||
<Row label="Schema" value="BAS 1.0" />
|
||||
</Section>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.sectionBody}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label, placeholder, value, onChangeText, ...rest
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
[key: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#52525b"
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
{...rest}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({ label, active, accent, dim, onPress }: {
|
||||
label: string; active: boolean; accent: string; dim: string; onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.modeButton, active && { backgroundColor: dim, borderColor: accent }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[styles.modeButtonText, active && { color: accent }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<Text style={styles.rowValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
|
||||
section: { marginBottom: 28 },
|
||||
sectionTitle: {
|
||||
color: '#a1a1aa', fontSize: 11, fontWeight: '600',
|
||||
letterSpacing: 0.8, marginBottom: 8,
|
||||
},
|
||||
sectionBody: {
|
||||
backgroundColor: '#18181b', borderRadius: 10,
|
||||
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
|
||||
},
|
||||
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
fieldLabel: { color: '#71717a', fontSize: 11, marginBottom: 4 },
|
||||
input: { color: '#f4f4f5', fontSize: 15 },
|
||||
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
|
||||
hintWarn: { color: '#a16207' },
|
||||
row: {
|
||||
flexDirection: 'row', justifyContent: 'space-between',
|
||||
paddingHorizontal: 14, paddingVertical: 12,
|
||||
borderBottomWidth: 1, borderBottomColor: '#27272a',
|
||||
},
|
||||
rowLabel: { color: '#a1a1aa', fontSize: 14 },
|
||||
rowValue: { color: '#71717a', fontSize: 14 },
|
||||
saveButton: {
|
||||
backgroundColor: '#2563eb', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
|
||||
},
|
||||
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
connectButton: {
|
||||
backgroundColor: '#059669', borderRadius: 8, margin: 12,
|
||||
paddingVertical: 12, alignItems: 'center',
|
||||
},
|
||||
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
disconnectButton: {
|
||||
margin: 12, paddingVertical: 10, alignItems: 'center',
|
||||
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
|
||||
},
|
||||
disconnectText: { color: '#71717a', fontSize: 14 },
|
||||
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
||||
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
||||
subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 },
|
||||
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
||||
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
|
||||
modeButtonText: { color: '#71717a', fontSize: 13, fontWeight: '500' },
|
||||
resetButton: {
|
||||
margin: 12, paddingVertical: 10, alignItems: 'center',
|
||||
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
|
||||
},
|
||||
resetButtonArmed: { borderColor: '#ef4444', backgroundColor: '#1c0a0a' },
|
||||
resetText: { color: '#71717a', fontSize: 14 },
|
||||
resetTextArmed: { color: '#ef4444', fontWeight: '600' },
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { SQLiteProvider } from 'expo-sqlite';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { migrateDb } from '@/db';
|
||||
import { ThemeProvider } from '@/ThemeContext';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
|
||||
<ThemeProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</ThemeProvider>
|
||||
</SQLiteProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { deleteActivity, setActivityTitle, useActivity, useSetting } from '@/db/queries';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Timeseries = {
|
||||
t: number[];
|
||||
elevation_m: (number | null)[];
|
||||
speed_kmh?: (number | null)[] | null;
|
||||
hr_bpm?: (number | null)[] | null;
|
||||
cadence_rpm?: (number | null)[] | null;
|
||||
power_w?: (number | null)[] | null;
|
||||
lat?: (number | null)[] | null;
|
||||
lon?: (number | null)[] | null;
|
||||
};
|
||||
|
||||
// ── Screen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ActivityScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const db = useSQLiteContext();
|
||||
const theme = useTheme();
|
||||
const row = useActivity(id);
|
||||
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
|
||||
const token = useSetting('api_token') ?? '';
|
||||
|
||||
const [geojson, setGeojson] = useState<object | null>(null);
|
||||
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
|
||||
const [loadingMap, setLoadingMap] = useState(false);
|
||||
const [loadingChart, setLoadingChart] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
|
||||
async function confirmDelete() {
|
||||
Alert.alert(
|
||||
'Delete activity',
|
||||
'This will permanently remove this activity from your device.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const originalPath = await deleteActivity(db, id);
|
||||
if (originalPath) {
|
||||
try { await FileSystem.deleteAsync(originalPath, { idempotent: true }); } catch {}
|
||||
}
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// instanceUrl and token are in the dep array to avoid a stale-closure bug in
|
||||
// release builds: Hermes executes effects sooner and captures empty strings if
|
||||
// the deps are omitted. Guards on geojson/timeseries prevent double-fetching.
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
|
||||
if (row.geojson) {
|
||||
setGeojson(JSON.parse(row.geojson));
|
||||
} else if (row.origin === 'remote' && instanceUrl && token) {
|
||||
setLoadingMap(true);
|
||||
fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setGeojson(data); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingMap(false));
|
||||
}
|
||||
|
||||
if (row.timeseries_json) {
|
||||
setTimeseries(JSON.parse(row.timeseries_json));
|
||||
} else if (row.origin === 'remote' && instanceUrl && token) {
|
||||
setLoadingChart(true);
|
||||
fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setTimeseries(data); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingChart(false));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [row?.id, instanceUrl, token]);
|
||||
|
||||
if (!row) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.notFound}>Activity not found</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = JSON.parse(row.detail_json);
|
||||
const edits = row.edits_json ? JSON.parse(row.edits_json) : {};
|
||||
const displayTitle = edits.title ?? detail.title;
|
||||
const canEdit = row.origin === 'local';
|
||||
const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null;
|
||||
const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null;
|
||||
const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
|
||||
const movingTime = detail.moving_time_s != null ? formatDuration(detail.moving_time_s) : null;
|
||||
const speed = detail.avg_speed_kmh != null ? detail.avg_speed_kmh.toFixed(1) : null;
|
||||
const hr = detail.avg_hr_bpm != null ? Math.round(detail.avg_hr_bpm) : null;
|
||||
const power = detail.avg_power_w != null ? Math.round(detail.avg_power_w) : null;
|
||||
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={[styles.backText, { color: theme.accent }]}>← Back</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.deleteButton} onPress={confirmDelete}>
|
||||
<Text style={styles.deleteText}>Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
||||
{editingTitle ? (
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
value={titleDraft}
|
||||
onChangeText={setTitleDraft}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onEndEditing={(e) => {
|
||||
const trimmed = e.nativeEvent.text.trim();
|
||||
if (trimmed && trimmed !== displayTitle) {
|
||||
setActivityTitle(db, id, trimmed);
|
||||
}
|
||||
setEditingTitle(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={canEdit ? () => { setTitleDraft(displayTitle); setEditingTitle(true); } : undefined}
|
||||
style={styles.titleRow}
|
||||
>
|
||||
<Text style={styles.title}>{displayTitle}</Text>
|
||||
{canEdit && <Text style={styles.editHint}>✎</Text>}
|
||||
</Pressable>
|
||||
)}
|
||||
<Text style={styles.date}>{date}</Text>
|
||||
|
||||
{/* Map */}
|
||||
<RouteMap geojson={geojson} loading={loadingMap} accent={theme.accent} />
|
||||
|
||||
{/* Stats grid */}
|
||||
<View style={styles.grid}>
|
||||
{km && <StatCell label="Distance" value={km} unit="km" />}
|
||||
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
|
||||
{elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
|
||||
{elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
|
||||
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
|
||||
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
|
||||
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
||||
</View>
|
||||
|
||||
{/* Metric charts */}
|
||||
<MetricCharts timeseries={timeseries} loading={loadingChart} accent={theme.accent} />
|
||||
|
||||
{/* Meta */}
|
||||
<View style={styles.meta}>
|
||||
<MetaRow label="Source" value={detail.source ?? '—'} />
|
||||
<MetaRow label="Device" value={detail.device ?? '—'} />
|
||||
<MetaRow label="Origin" value={row.origin} />
|
||||
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [currentZoom, setCurrentZoom] = useState(12);
|
||||
const cameraRef = useRef<any>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={{ color: accent, fontSize: 13 }}>Loading map…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!geojson) return null;
|
||||
|
||||
// MapLibre uses OpenGL/SurfaceView which crashes the Karoo's Qualcomm GPU
|
||||
// driver (Android <29) even without any interaction. Render a pure SVG route
|
||||
// trace instead — no native GL surface, no crash.
|
||||
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
|
||||
return <SvgRouteView geojson={geojson} accent={accent} />;
|
||||
}
|
||||
|
||||
const bounds = geoJsonBounds(geojson);
|
||||
const routeSource = (
|
||||
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
||||
<Layer
|
||||
type="line"
|
||||
id="route-line"
|
||||
paint={{ 'line-color': accent, 'line-width': 3 }}
|
||||
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
|
||||
/>
|
||||
</GeoJSONSource>
|
||||
);
|
||||
const cameraBounds = bounds
|
||||
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Thumbnail — tap to expand */}
|
||||
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
|
||||
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
|
||||
{cameraBounds && <Camera initialViewState={cameraBounds} />}
|
||||
{routeSource}
|
||||
</Map>
|
||||
<View style={styles.mapExpandHint}>
|
||||
<Text style={styles.mapExpandText}>⤢ tap to explore</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Full-screen map with +/- zoom buttons */}
|
||||
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
|
||||
<View style={styles.fullscreenMap}>
|
||||
<Map
|
||||
style={styles.map}
|
||||
mapStyle={MAP_STYLE}
|
||||
onRegionDidChange={(e: any) => {
|
||||
const z = e?.properties?.zoomLevel;
|
||||
if (typeof z === 'number') setCurrentZoom(z);
|
||||
}}
|
||||
>
|
||||
{cameraBounds && <Camera ref={cameraRef} initialViewState={cameraBounds} />}
|
||||
{routeSource}
|
||||
</Map>
|
||||
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
|
||||
<Text style={styles.closeText}>✕</Text>
|
||||
</Pressable>
|
||||
<View style={styles.zoomButtons}>
|
||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}>
|
||||
<Text style={styles.zoomBtnText}>+</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}>
|
||||
<Text style={styles.zoomBtnText}>−</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// SVG route trace — used on Android <29 where MapLibre crashes the GPU driver.
|
||||
// Renders the GPS track as a colored path on a dark background with no tiles.
|
||||
function SvgRouteView({ geojson, accent }: { geojson: object; accent: string }) {
|
||||
const W = 320;
|
||||
const H = 180;
|
||||
const PAD = 16;
|
||||
|
||||
const all: [number, number][] = [];
|
||||
function collect(obj: unknown) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (o.type === 'Feature') { collect(o.geometry); return; }
|
||||
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
||||
if (o.type === 'LineString') { all.push(...(o.coordinates as [number, number][])); return; }
|
||||
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => all.push(...c)); return; }
|
||||
}
|
||||
collect(geojson);
|
||||
if (!all.length) return null;
|
||||
|
||||
const step = Math.max(1, Math.floor(all.length / 500));
|
||||
const pts = all.filter((_, i) => i % step === 0);
|
||||
|
||||
const lons = pts.map(c => c[0]);
|
||||
const lats = pts.map(c => c[1]);
|
||||
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
||||
const spanLon = maxLon - minLon || 0.001;
|
||||
const spanLat = maxLat - minLat || 0.001;
|
||||
|
||||
// Correct longitude for latitude (equirectangular)
|
||||
const midLat = (minLat + maxLat) / 2;
|
||||
const lonFactor = Math.cos((midLat * Math.PI) / 180);
|
||||
const adjLon = spanLon * lonFactor;
|
||||
|
||||
const scale = Math.min((W - PAD * 2) / adjLon, (H - PAD * 2) / spanLat);
|
||||
const offX = (W - adjLon * scale) / 2;
|
||||
const offY = (H - spanLat * scale) / 2;
|
||||
|
||||
const toX = (lon: number) => offX + (lon - minLon) * lonFactor * scale;
|
||||
const toY = (lat: number) => H - offY - (lat - minLat) * scale;
|
||||
|
||||
const d = pts.map((c, i) => `${i === 0 ? 'M' : 'L'}${toX(c[0]).toFixed(1)},${toY(c[1]).toFixed(1)}`).join(' ');
|
||||
|
||||
return (
|
||||
<View style={[styles.mapContainer, { alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||
<Path d={d} fill="none" stroke={accent} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Metric charts ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
||||
|
||||
const TAB_META: Record<TabKey, { label: string; unit: string; color: string; decimals: number }> = {
|
||||
elevation: { label: 'Elevation', unit: 'm', color: '#00c8ff', decimals: 0 },
|
||||
speed: { label: 'Speed', unit: 'km/h', color: '#ff6b35', decimals: 1 },
|
||||
hr: { label: 'HR', unit: 'bpm', color: '#f87171', decimals: 0 },
|
||||
cadence: { label: 'Cadence', unit: 'rpm', color: '#a78bfa', decimals: 0 },
|
||||
power: { label: 'Power', unit: 'W', color: '#facc15', decimals: 0 },
|
||||
};
|
||||
|
||||
function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries | null; loading: boolean; accent: string }) {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('elevation');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.chartPlaceholder}>
|
||||
<Text style={{ color: accent, fontSize: 13 }}>Loading chart…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!timeseries) return null;
|
||||
|
||||
const seriesMap: Record<TabKey, (number | null)[] | null | undefined> = {
|
||||
elevation: timeseries.elevation_m,
|
||||
speed: timeseries.speed_kmh,
|
||||
hr: timeseries.hr_bpm,
|
||||
cadence: timeseries.cadence_rpm,
|
||||
power: timeseries.power_w,
|
||||
};
|
||||
|
||||
const available = (Object.keys(TAB_META) as TabKey[]).filter(
|
||||
k => seriesMap[k]?.some(v => v != null)
|
||||
);
|
||||
|
||||
if (!available.length) return null;
|
||||
|
||||
const tab = available.includes(activeTab) ? activeTab : available[0];
|
||||
const { color, unit, decimals } = TAB_META[tab];
|
||||
const raw = seriesMap[tab]!;
|
||||
|
||||
return (
|
||||
<View style={styles.chartContainer}>
|
||||
{/* Tab row */}
|
||||
<View style={styles.chartTabs}>
|
||||
{available.map(k => (
|
||||
<Pressable
|
||||
key={k}
|
||||
style={[styles.chartTab, tab === k && { borderBottomColor: TAB_META[k].color, borderBottomWidth: 2 }]}
|
||||
onPress={() => setActiveTab(k)}
|
||||
>
|
||||
<Text style={[styles.chartTabText, tab === k && { color: TAB_META[k].color }]}>
|
||||
{TAB_META[k].label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
{/* Chart */}
|
||||
<MetricChart key={tab} times={timeseries.t} values={raw} color={color} unit={unit} decimals={decimals} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricChart({
|
||||
times, values, color, unit, decimals,
|
||||
}: {
|
||||
times: number[];
|
||||
values: (number | null)[];
|
||||
color: string;
|
||||
unit: string;
|
||||
decimals: number;
|
||||
}) {
|
||||
const W = 340;
|
||||
const H = 100;
|
||||
const PAD = 4;
|
||||
|
||||
// Downsample to ≤300 points
|
||||
const step = Math.max(1, Math.floor(values.length / 300));
|
||||
const ts = times.filter((_, i) => i % step === 0);
|
||||
const vs = values.filter((_, i) => i % step === 0).map(v => v ?? 0);
|
||||
|
||||
const minV = Math.min(...vs);
|
||||
const maxV = Math.max(...vs);
|
||||
const range = maxV - minV || 1;
|
||||
const maxT = ts[ts.length - 1] || 1;
|
||||
|
||||
const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2);
|
||||
const y = (v: number) => PAD + (1 - (v - minV) / range) * (H - PAD * 2);
|
||||
|
||||
const pts = ts.map((t, i) => `${x(t).toFixed(1)},${y(vs[i]).toFixed(1)}`);
|
||||
const linePath = `M ${pts.join(' L ')}`;
|
||||
const areaPath = `M ${x(ts[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`;
|
||||
const gradId = `grad-${color.replace('#', '')}`;
|
||||
|
||||
const fmt = (v: number) => decimals === 0 ? String(Math.round(v)) : v.toFixed(decimals);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={[styles.chartLabel, { color }]}>{fmt(maxV)} {unit}</Text>
|
||||
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||
<Defs>
|
||||
<LinearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor={color} stopOpacity="0.35" />
|
||||
<Stop offset="1" stopColor={color} stopOpacity="0.02" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Path d={areaPath} fill={`url(#${gradId})`} />
|
||||
<Path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.chartLabel, { color: '#3f3f46', marginBottom: 10 }]}>{fmt(minV)} {unit}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns [west, south, east, north] per LngLatBounds spec
|
||||
function geoJsonBounds(gj: object): [number, number, number, number] | null {
|
||||
const coords: [number, number][] = [];
|
||||
function collect(obj: unknown) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (o.type === 'Feature') { collect(o.geometry); return; }
|
||||
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
|
||||
if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; }
|
||||
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; }
|
||||
}
|
||||
collect(gj);
|
||||
if (!coords.length) return null;
|
||||
const lons = coords.map(c => c[0]);
|
||||
const lats = coords.map(c => c[1]);
|
||||
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||
return (
|
||||
<View style={styles.statCell}>
|
||||
<View style={styles.statValueRow}>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
{unit ? <Text style={styles.statUnit}>{unit}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{label}</Text>
|
||||
<Text style={styles.metaValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
content: { paddingBottom: 40 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
|
||||
notFound: { color: '#71717a', fontSize: 16 },
|
||||
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingBottom: 12 },
|
||||
backButton: { paddingHorizontal: 16 },
|
||||
backText: { fontSize: 15 },
|
||||
deleteButton: { paddingHorizontal: 16 },
|
||||
deleteText: { color: '#f87171', fontSize: 15 },
|
||||
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 4 },
|
||||
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', flexShrink: 1 },
|
||||
titleInput: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4, borderBottomWidth: 1, borderBottomColor: '#3b82f6' },
|
||||
editHint: { color: '#52525b', fontSize: 16, marginLeft: 8 },
|
||||
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
||||
mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
|
||||
map: { flex: 1 },
|
||||
mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 },
|
||||
mapExpandHint: { position: 'absolute', bottom: 8, right: 8, backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4 },
|
||||
mapExpandText: { color: '#a1a1aa', fontSize: 11 },
|
||||
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
|
||||
closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||
closeText: { color: '#fff', fontSize: 16 },
|
||||
zoomButtons: { position: 'absolute', bottom: 40, right: 16, gap: 8 },
|
||||
zoomBtn: { backgroundColor: 'rgba(0,0,0,0.65)', borderRadius: 20, width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
||||
zoomBtnText: { color: '#fff', fontSize: 22, fontWeight: '600', lineHeight: 28 },
|
||||
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' },
|
||||
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
|
||||
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
chartTab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
||||
chartTabText: { color: '#52525b', fontSize: 11, fontWeight: '600' },
|
||||
chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2, marginHorizontal: 12, marginTop: 10 },
|
||||
grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 },
|
||||
statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
|
||||
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
||||
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
|
||||
statUnit: { color: '#71717a', fontSize: 13 },
|
||||
statLabel: { color: '#71717a', fontSize: 12 },
|
||||
meta: { marginHorizontal: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a' },
|
||||
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||
metaLabel: { color: '#71717a', fontSize: 13 },
|
||||
metaValue: { color: '#a1a1aa', fontSize: 13 },
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,6 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import type { ActivitySummary } from '@/db/queries';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
|
||||
export function ActivityCard({
|
||||
activity,
|
||||
selecting,
|
||||
checked,
|
||||
onToggleSelect,
|
||||
onLongPress,
|
||||
}: {
|
||||
activity: ActivitySummary;
|
||||
selecting: boolean;
|
||||
checked: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onLongPress: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
|
||||
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
|
||||
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
});
|
||||
|
||||
function handlePress() {
|
||||
if (selecting) onToggleSelect();
|
||||
else router.push(`/activity/${activity.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.card, checked && { borderColor: theme.accent }]}
|
||||
onPress={handlePress}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<View style={styles.cardTop}>
|
||||
<View style={styles.cardLeft}>
|
||||
{selecting && (
|
||||
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
|
||||
{checked && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
||||
</View>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.cardDate}>{date}</Text>
|
||||
{activity.origin === 'remote'
|
||||
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>cloud</Text>
|
||||
: !activity.synced_at && <Text style={styles.localBadge}>local</Text>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>{activity.user_title ?? activity.title}</Text>
|
||||
<View style={styles.cardStats}>
|
||||
{km && <Stat label="km" value={km} />}
|
||||
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function sportIcon(sport: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
|
||||
};
|
||||
return icons[sport] ?? '🏅';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#18181b', borderRadius: 12,
|
||||
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
||||
},
|
||||
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||
cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
sportIcon: { fontSize: 20 },
|
||||
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
cardDate: { color: '#71717a', fontSize: 12 },
|
||||
remoteBadge: {
|
||||
fontSize: 10, borderWidth: 1,
|
||||
borderRadius: 4, paddingHorizontal: 4,
|
||||
},
|
||||
localBadge: {
|
||||
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
|
||||
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
|
||||
},
|
||||
cardTitle: { color: '#f4f4f5', fontSize: 15, fontWeight: '600', marginBottom: 10 },
|
||||
cardStats: { flexDirection: 'row', gap: 16 },
|
||||
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
|
||||
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
||||
statLabel: { color: '#71717a', fontSize: 12 },
|
||||
checkbox: {
|
||||
width: 20, height: 20, borderRadius: 4, borderWidth: 1.5,
|
||||
borderColor: '#52525b', alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
checkmark: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
|
||||
export async function migrateDb(db: SQLiteDatabase): Promise<void> {
|
||||
await db.execAsync('PRAGMA journal_mode = WAL;');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_hash TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL,
|
||||
timeseries_json TEXT,
|
||||
geojson TEXT,
|
||||
original_path TEXT,
|
||||
synced_at INTEGER,
|
||||
origin TEXT NOT NULL CHECK(origin IN ('local', 'remote')),
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_created_at
|
||||
ON activities(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration v2: source_path stores the original filesystem path a file was
|
||||
// imported from (e.g. /sdcard/Karoo/Rides/ride.fit), used for watch-folder
|
||||
// deduplication without re-hashing files.
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE activities ADD COLUMN source_path TEXT');
|
||||
await db.execAsync(
|
||||
'CREATE INDEX IF NOT EXISTS idx_activities_source_path ON activities(source_path)',
|
||||
);
|
||||
} catch {
|
||||
// Column already exists — migration already ran, ignore.
|
||||
}
|
||||
|
||||
// Migration v3: edits_json stores user overrides (e.g. {"title": "My title"})
|
||||
// kept separate from detail_json so server re-extraction (Option A) never
|
||||
// clobbers user edits.
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE activities ADD COLUMN edits_json TEXT');
|
||||
} catch {
|
||||
// Column already exists — migration already ran, ignore.
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ActivityRow = {
|
||||
id: string;
|
||||
source_hash: string;
|
||||
detail_json: string;
|
||||
timeseries_json: string | null;
|
||||
geojson: string | null;
|
||||
original_path: string | null;
|
||||
source_path: string | null;
|
||||
synced_at: number | null;
|
||||
origin: 'local' | 'remote';
|
||||
created_at: number;
|
||||
edits_json: string | null;
|
||||
};
|
||||
|
||||
export type ActivitySummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
user_title: string | null; // from edits_json; takes display priority over title
|
||||
sport: string;
|
||||
started_at: string;
|
||||
distance_m: number | null;
|
||||
duration_s: number | null;
|
||||
elevation_gain_m: number | null;
|
||||
origin: 'local' | 'remote';
|
||||
synced_at: number | null;
|
||||
};
|
||||
|
||||
// ── Activities ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function useActivities(searchQuery = '', limit = PAGE_SIZE): ActivitySummary[] {
|
||||
const db = useSQLiteContext();
|
||||
const like = `%${searchQuery}%`;
|
||||
const rows = db.getAllSync<ActivitySummary>(`
|
||||
SELECT
|
||||
id, origin, synced_at,
|
||||
json_extract(detail_json, '$.title') AS title,
|
||||
json_extract(edits_json, '$.title') AS user_title,
|
||||
json_extract(detail_json, '$.sport') AS sport,
|
||||
json_extract(detail_json, '$.started_at') AS started_at,
|
||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||
FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)
|
||||
ORDER BY json_extract(detail_json, '$.started_at') DESC
|
||||
LIMIT ?
|
||||
`, [searchQuery, like, limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function useActivityCount(searchQuery = ''): number {
|
||||
const db = useSQLiteContext();
|
||||
const like = `%${searchQuery}%`;
|
||||
const row = db.getFirstSync<{ n: number }>(
|
||||
`SELECT COUNT(*) as n FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)`,
|
||||
[searchQuery, like],
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export { PAGE_SIZE };
|
||||
|
||||
export type ActivityFilter = {
|
||||
sport: string; // '' = all sports
|
||||
dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison
|
||||
dateTo: string; // '' = no upper bound
|
||||
sort: 'date' | 'distance' | 'elevation';
|
||||
};
|
||||
|
||||
const SORT_SQL: Record<string, string> = {
|
||||
date: "json_extract(detail_json, '$.started_at') DESC",
|
||||
distance: "json_extract(detail_json, '$.distance_m') DESC",
|
||||
elevation: "json_extract(detail_json, '$.elevation_gain_m') DESC",
|
||||
};
|
||||
|
||||
export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE): ActivitySummary[] {
|
||||
const db = useSQLiteContext();
|
||||
const order = SORT_SQL[filter.sort] ?? SORT_SQL.date;
|
||||
return db.getAllSync<ActivitySummary>(`
|
||||
SELECT
|
||||
id, origin, synced_at,
|
||||
json_extract(detail_json, '$.title') AS title,
|
||||
json_extract(edits_json, '$.title') AS user_title,
|
||||
json_extract(detail_json, '$.sport') AS sport,
|
||||
json_extract(detail_json, '$.started_at') AS started_at,
|
||||
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||
FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
||||
ORDER BY ${order}
|
||||
LIMIT ?
|
||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo, limit]);
|
||||
}
|
||||
|
||||
export function useFilteredCount(filter: ActivityFilter): number {
|
||||
const db = useSQLiteContext();
|
||||
const row = db.getFirstSync<{ n: number }>(`
|
||||
SELECT COUNT(*) as n FROM activities
|
||||
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
|
||||
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
|
||||
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo]);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export function useActivityYears(): string[] {
|
||||
const db = useSQLiteContext();
|
||||
const rows = db.getAllSync<{ year: string }>(
|
||||
`SELECT DISTINCT substr(json_extract(detail_json, '$.started_at'), 1, 4) AS year
|
||||
FROM activities
|
||||
WHERE json_extract(detail_json, '$.started_at') IS NOT NULL
|
||||
ORDER BY year DESC`,
|
||||
);
|
||||
return rows.map(r => r.year).filter(Boolean);
|
||||
}
|
||||
|
||||
export function useActivity(id: string): ActivityRow | null {
|
||||
const db = useSQLiteContext();
|
||||
return db.getFirstSync<ActivityRow>(
|
||||
'SELECT * FROM activities WHERE id = ?',
|
||||
[id],
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export async function insertActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>
|
||||
& { source_path?: string | null },
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`INSERT OR IGNORE INTO activities
|
||||
(id, source_hash, detail_json, timeseries_json, geojson, original_path, source_path, origin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
row.source_hash,
|
||||
row.detail_json,
|
||||
row.timeseries_json ?? null,
|
||||
row.geojson ?? null,
|
||||
row.original_path ?? null,
|
||||
row.source_path ?? null,
|
||||
row.origin,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function isSourcePathImported(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
sourcePath: string,
|
||||
): boolean {
|
||||
const row = db.getFirstSync<{ id: string }>(
|
||||
'SELECT id FROM activities WHERE source_path = ?',
|
||||
[sourcePath],
|
||||
);
|
||||
return row != null;
|
||||
}
|
||||
|
||||
export async function upsertRemoteActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
detailJson: string,
|
||||
): Promise<boolean> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await db.runAsync(
|
||||
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
|
||||
VALUES (?, ?, ?, 'remote', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
detail_json = excluded.detail_json,
|
||||
synced_at = excluded.synced_at
|
||||
WHERE origin = 'remote'`,
|
||||
[id, id, detailJson, now],
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export async function deleteRemoteActivities(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
): Promise<number> {
|
||||
const result = await db.runAsync(`DELETE FROM activities WHERE origin = 'remote'`);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export async function deleteActivity(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
): Promise<string | null> {
|
||||
const row = db.getFirstSync<{ original_path: string | null }>(
|
||||
'SELECT original_path FROM activities WHERE id = ?',
|
||||
[id],
|
||||
);
|
||||
await db.runAsync('DELETE FROM activities WHERE id = ?', [id]);
|
||||
return row?.original_path ?? null;
|
||||
}
|
||||
|
||||
export async function setActivityTitle(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
id: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`UPDATE activities
|
||||
SET edits_json = json_set(COALESCE(edits_json, '{}'), '$.title', ?)
|
||||
WHERE id = ?`,
|
||||
[title, id],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteActivities(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
ids: string[],
|
||||
): Promise<Array<string | null>> {
|
||||
if (ids.length === 0) return [];
|
||||
const rows = db.getAllSync<{ original_path: string | null }>(
|
||||
`SELECT original_path FROM activities WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await db.runAsync(`DELETE FROM activities WHERE id IN (${placeholders})`, ids);
|
||||
return rows.map(r => r.original_path ?? null);
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSetting(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key],
|
||||
);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setSetting(
|
||||
db: ReturnType<typeof useSQLiteContext>,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await db.runAsync(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[key, value],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSetting(key: string): string | null {
|
||||
const db = useSQLiteContext();
|
||||
const row = db.getFirstSync<{ value: string }>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key],
|
||||
);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { getSetting, upsertRemoteActivity } from './queries';
|
||||
|
||||
export type SyncResult = {
|
||||
synced: number;
|
||||
total: number;
|
||||
fetched?: number;
|
||||
uploaded?: number;
|
||||
failed?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
|
||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||
const token = await getSetting(db, 'api_token');
|
||||
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
|
||||
return { instanceUrl, token };
|
||||
}
|
||||
|
||||
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const creds = await resolveCredentials(db);
|
||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||
const { instanceUrl, token } = creds;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
||||
}
|
||||
|
||||
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
||||
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
||||
|
||||
const data: { activities?: RemoteSummary[] } = await resp.json();
|
||||
const activities = data.activities ?? [];
|
||||
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
||||
|
||||
let synced = 0;
|
||||
for (const a of activities) {
|
||||
const detailJson = JSON.stringify({
|
||||
id: a.id,
|
||||
title: a.title ?? a.id,
|
||||
sport: a.sport ?? null,
|
||||
started_at: a.started_at ?? null,
|
||||
distance_m: a.distance_m ?? null,
|
||||
moving_time_s: a.moving_time_s ?? null,
|
||||
elevation_gain_m: a.elevation_gain_m ?? null,
|
||||
avg_speed_kmh: a.avg_speed_kmh ?? null,
|
||||
avg_hr_bpm: a.avg_hr_bpm ?? null,
|
||||
avg_power_w: a.avg_power_w ?? null,
|
||||
});
|
||||
const changed = await upsertRemoteActivity(db, a.id, detailJson);
|
||||
if (changed) synced++;
|
||||
}
|
||||
|
||||
if (syncMode !== 'full') return { synced, total: activities.length };
|
||||
|
||||
// Full mode: fetch geojson + timeseries for activities missing them
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
let fetched = 0;
|
||||
for (const a of activities) {
|
||||
const row = db.getFirstSync<{ g: number; t: number }>(
|
||||
'SELECT (geojson IS NOT NULL) as g, (timeseries_json IS NOT NULL) as t FROM activities WHERE id = ?',
|
||||
[a.id],
|
||||
);
|
||||
if (row?.g && row?.t) continue;
|
||||
|
||||
let gj: string | null = null;
|
||||
let ts: string | null = null;
|
||||
try {
|
||||
if (!row?.g) {
|
||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/geojson`, { headers });
|
||||
if (r.ok) gj = await r.text();
|
||||
}
|
||||
if (!row?.t) {
|
||||
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/timeseries`, { headers });
|
||||
if (r.ok) ts = await r.text();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (gj !== null || ts !== null) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities SET
|
||||
geojson = COALESCE(geojson, ?),
|
||||
timeseries_json = COALESCE(timeseries_json, ?)
|
||||
WHERE id = ? AND origin = 'remote'`,
|
||||
[gj, ts, a.id],
|
||||
);
|
||||
fetched++;
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, total: activities.length, fetched };
|
||||
}
|
||||
|
||||
export async function uploadFeed(
|
||||
db: SQLiteDatabase,
|
||||
onProgress?: (n: number, total: number) => void,
|
||||
): Promise<SyncResult> {
|
||||
const creds = await resolveCredentials(db);
|
||||
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
||||
const { instanceUrl, token } = creds;
|
||||
|
||||
// Reconcile local synced_at against what the server actually has.
|
||||
// If the server was wiped/reset, activities we thought were uploaded need
|
||||
// re-uploading — clear their synced_at so they re-enter the upload queue.
|
||||
try {
|
||||
const feedResp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (feedResp.ok) {
|
||||
const feedData: { activities?: { id: string }[] } = await feedResp.json();
|
||||
const serverIds = new Set((feedData.activities ?? []).map(a => a.id));
|
||||
const syncedRows = db.getAllSync<{ id: string }>(
|
||||
`SELECT id FROM activities WHERE origin = 'local' AND synced_at IS NOT NULL`,
|
||||
);
|
||||
for (const row of syncedRows) {
|
||||
if (!serverIds.has(row.id)) {
|
||||
await db.runAsync(`UPDATE activities SET synced_at = NULL WHERE id = ?`, [row.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — proceed with upload even if reconciliation fails
|
||||
}
|
||||
|
||||
const { uploaded, failed } = await uploadLocalActivities(db, instanceUrl, token, onProgress);
|
||||
return { synced: 0, total: 0, uploaded, failed: failed || undefined };
|
||||
}
|
||||
|
||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const dl = await downloadFeed(db);
|
||||
if (dl.error) return dl;
|
||||
|
||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
||||
let uploaded = 0;
|
||||
if (uploadEnabled) {
|
||||
const ul = await uploadFeed(db);
|
||||
uploaded = ul.uploaded ?? 0;
|
||||
}
|
||||
|
||||
return { ...dl, uploaded: uploaded || undefined };
|
||||
}
|
||||
|
||||
export async function countPendingUploads(db: SQLiteDatabase): Promise<number> {
|
||||
const row = db.getFirstSync<{ n: number }>(
|
||||
`SELECT COUNT(*) as n FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
async function uploadLocalActivities(
|
||||
db: SQLiteDatabase,
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
onProgress?: (n: number, total: number) => void,
|
||||
): Promise<{ uploaded: number; failed: number }> {
|
||||
const rows = db.getAllSync<{
|
||||
id: string;
|
||||
detail_json: string;
|
||||
timeseries_json: string | null;
|
||||
geojson: string | null;
|
||||
original_path: string | null;
|
||||
edits_json: string | null;
|
||||
}>(
|
||||
`SELECT id, detail_json, timeseries_json, geojson, original_path, edits_json
|
||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
|
||||
const preferRaw = (await getSetting(db, 'upload_format') ?? 'raw') === 'raw';
|
||||
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
let uploaded = 0;
|
||||
let failed = 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const total = rows.length;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
onProgress?.(i + 1, total);
|
||||
try {
|
||||
let resp: Response;
|
||||
|
||||
// When preferRaw is set and the original file is still on disk, send the raw
|
||||
// bytes to /api/upload/raw so the server re-extracts with DEM elevation correction.
|
||||
const useRaw = preferRaw &&
|
||||
row.original_path !== null &&
|
||||
(await FileSystem.getInfoAsync(row.original_path)).exists;
|
||||
|
||||
const userTitle: string | null = row.edits_json
|
||||
? (JSON.parse(row.edits_json).title ?? null)
|
||||
: null;
|
||||
|
||||
if (useRaw) {
|
||||
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
|
||||
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ filename, base64, ...(userTitle ? { user_title: userTitle } : {}) }),
|
||||
});
|
||||
} else {
|
||||
const detail = JSON.parse(row.detail_json);
|
||||
if (userTitle) detail.title = userTitle;
|
||||
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
|
||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
||||
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
||||
resp = await fetch(`${instanceUrl}/api/upload/bas`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
if (resp.ok) {
|
||||
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
||||
// Option A: after a raw upload, update local detail/timeseries/geojson with the
|
||||
// server's DEM-corrected extraction so the app shows better elevation data.
|
||||
if (useRaw) {
|
||||
try {
|
||||
const data = await resp.json() as {
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
source_hash: string;
|
||||
};
|
||||
if (data.id === row.id) {
|
||||
await db.runAsync(
|
||||
`UPDATE activities
|
||||
SET detail_json = ?,
|
||||
timeseries_json = COALESCE(?, timeseries_json),
|
||||
geojson = COALESCE(?, geojson),
|
||||
source_hash = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
JSON.stringify(data.detail),
|
||||
data.timeseries ? JSON.stringify(data.timeseries) : null,
|
||||
data.geojson ? JSON.stringify(data.geojson) : null,
|
||||
data.source_hash,
|
||||
row.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: synced_at is already set, local data stays as-is
|
||||
}
|
||||
}
|
||||
uploaded++;
|
||||
} else {
|
||||
console.warn(`upload ${row.id}: HTTP ${resp.status}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`upload ${row.id}:`, err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { uploaded, failed };
|
||||
}
|
||||
|
||||
type RemoteSummary = {
|
||||
id: string;
|
||||
title?: string;
|
||||
sport?: string;
|
||||
started_at?: string;
|
||||
distance_m?: number | null;
|
||||
moving_time_s?: number | null;
|
||||
elevation_gain_m?: number | null;
|
||||
avg_speed_kmh?: number | null;
|
||||
avg_hr_bpm?: number | null;
|
||||
avg_power_w?: number | null;
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import WebView from 'react-native-webview';
|
||||
import { handleWebViewMessage, pyodideRef } from './extractActivity';
|
||||
|
||||
const CDN = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
|
||||
// v0.18.1: last version whose JS wrapper avoids ??, ?., and other syntax
|
||||
// unavailable on Chrome <80 (e.g. Karoo WebView 61). Used in the compat path.
|
||||
const CDN_COMPAT = 'https://cdn.jsdelivr.net/pyodide/v0.18.1/full/';
|
||||
|
||||
// Python snippets embedded as JSON strings to avoid any JS/TS escaping issues.
|
||||
const PY_INSTALL_PACKAGES = [
|
||||
'import micropip',
|
||||
'await micropip.install(["fitdecode", "gpxpy"])',
|
||||
].join('\n');
|
||||
|
||||
// emfs:// is Pyodide's Emscripten-FS URL scheme — the only reliable way to
|
||||
// install a wheel from bytes without an http/https URL (blob: URLs are not
|
||||
// recognised by micropip and cause an InvalidRequirement parse error).
|
||||
// _wheel_path is set as a Pyodide global before this runs.
|
||||
const PY_INSTALL_WHEEL = [
|
||||
'import micropip',
|
||||
'await micropip.install("emfs://" + _wheel_path, deps=False)',
|
||||
].join('\n');
|
||||
|
||||
const PY_EXTRACT = [
|
||||
'import json, shutil',
|
||||
'from pathlib import Path',
|
||||
'from bincio.extract.parsers.factory import parse_file',
|
||||
'from bincio.extract.metrics import compute',
|
||||
'from bincio.extract.writer import make_activity_id, write_activity',
|
||||
'',
|
||||
'outdir = Path("/tmp/bincio_out")',
|
||||
'if outdir.exists(): shutil.rmtree(outdir)',
|
||||
'outdir.mkdir()',
|
||||
'',
|
||||
'activity = parse_file(Path("/tmp/" + _filename))',
|
||||
'metrics = compute(activity)',
|
||||
'write_activity(activity, metrics, outdir, privacy="public", rdp_epsilon=0.0001)',
|
||||
'act_id = make_activity_id(activity)',
|
||||
'',
|
||||
'detail_path = outdir / "activities" / (act_id + ".json")',
|
||||
'ts_path = outdir / "activities" / (act_id + ".timeseries.json")',
|
||||
'geojson_path = outdir / "activities" / (act_id + ".geojson")',
|
||||
'',
|
||||
'# write_activity in the installed wheel silently skips timeseries — write it directly.',
|
||||
'if not ts_path.exists():',
|
||||
' from bincio.extract.timeseries import build_timeseries as _bts',
|
||||
' _ts = _bts(activity.points, activity.started_at, "public")',
|
||||
' if _ts.get("t"):',
|
||||
' ts_path.write_text(json.dumps(_ts))',
|
||||
'',
|
||||
'json.dumps({',
|
||||
' "id": act_id,',
|
||||
' "detail": json.loads(detail_path.read_text()),',
|
||||
' "timeseries": json.loads(ts_path.read_text()) if ts_path.exists() else None,',
|
||||
' "geojson": json.loads(geojson_path.read_text()) if geojson_path.exists() else None,',
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
// JSON.stringify gives us safely-quoted JS string literals for embedding in HTML.
|
||||
const PYODIDE_HTML = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<script>
|
||||
var _PY_INSTALL_PACKAGES = ${JSON.stringify(PY_INSTALL_PACKAGES)};
|
||||
var _PY_INSTALL_WHEEL = ${JSON.stringify(PY_INSTALL_WHEEL)};
|
||||
var _PY_EXTRACT = ${JSON.stringify(PY_EXTRACT)};
|
||||
var _CDN = ${JSON.stringify(CDN)};
|
||||
var _CDN_COMPAT = ${JSON.stringify(CDN_COMPAT)};
|
||||
|
||||
function _post(m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); }
|
||||
|
||||
var pyodide = null;
|
||||
var packagesReady = false;
|
||||
var wheelReady = false;
|
||||
var initError = null;
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
// WebAssembly.Global was added in Chrome 69. Without it Pyodide cannot
|
||||
// initialise on any version. Bail out immediately so the mobile app can
|
||||
// fall back to server-side extraction without attempting a 35 MB download.
|
||||
if (typeof WebAssembly === 'undefined' || typeof WebAssembly.Global === 'undefined') {
|
||||
_post({ type: 'engine_unavailable', reason: 'wasm_global' });
|
||||
return;
|
||||
}
|
||||
|
||||
_post({ type: 'progress', msg: 'Loading Python runtime…' });
|
||||
|
||||
// Chrome <80 is missing features that modern Pyodide uses in its JS wrapper:
|
||||
// Chrome <71: no globalThis → factory throws ReferenceError immediately
|
||||
// Chrome <63: no dynamic import() / for-await-of → parse/runtime failure
|
||||
// Detection: read Chrome version from UA; absent means non-Chrome (assume modern).
|
||||
var _chromeVer = (navigator.userAgent.match(/Chrome\\/([0-9]+)/) || [])[1];
|
||||
var _needsPatch = _chromeVer && parseInt(_chromeVer) < 80;
|
||||
|
||||
if (_needsPatch) {
|
||||
// Use v0.18.1 — its JS wrapper avoids ??, ?., and other Chrome-80+ syntax.
|
||||
// Then apply three text patches before injecting via Blob URL (Blob scripts
|
||||
// bypass the browser's module pre-scanner, so patched keywords are invisible).
|
||||
//
|
||||
// Patches (split/join avoids regex escapes, which template literals corrupt):
|
||||
// 1. globalThis polyfill prepended — Chrome <71 lacks globalThis entirely
|
||||
// 2. import( → __loadScript( — Chrome <63 cannot parse dynamic import
|
||||
// 3. for await( → for( — Chrome <63 lacks async iteration;
|
||||
// the only affected fn (getFsHandles/NativeFS) is never called by us
|
||||
window.__loadScript = function(url) {
|
||||
return new Promise(function(res, rej) {
|
||||
var s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = res;
|
||||
s.onerror = function() { rej(new Error('Failed to load ' + url)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
};
|
||||
var _pyResp = await fetch(_CDN_COMPAT + 'pyodide.js');
|
||||
if (!_pyResp.ok) throw new Error('Could not fetch pyodide.js (' + _pyResp.status + ')');
|
||||
var _pyCode = await _pyResp.text();
|
||||
_pyCode = 'var globalThis=typeof globalThis!=="undefined"?globalThis:self;\\n' + _pyCode;
|
||||
_pyCode = _pyCode.split('import(').join('__loadScript(');
|
||||
_pyCode = _pyCode.split('for await(').join('for(');
|
||||
await new Promise(function(res, rej) {
|
||||
var blob = new Blob([_pyCode], { type: 'application/javascript' });
|
||||
var blobUrl = URL.createObjectURL(blob);
|
||||
var s = document.createElement('script');
|
||||
s.src = blobUrl;
|
||||
s.onload = function() { URL.revokeObjectURL(blobUrl); res(); };
|
||||
s.onerror = function() { URL.revokeObjectURL(blobUrl); rej(new Error('Failed to inject patched pyodide.js')); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
pyodide = await loadPyodide({ indexURL: _CDN_COMPAT });
|
||||
} else {
|
||||
await new Promise(function(res, rej) {
|
||||
var s = document.createElement('script');
|
||||
s.src = _CDN + 'pyodide.js';
|
||||
s.onload = res; s.onerror = rej;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
pyodide = await loadPyodide({ indexURL: _CDN });
|
||||
}
|
||||
|
||||
_post({ type: 'progress', msg: 'Loading packages…' });
|
||||
await pyodide.loadPackage(['lxml', 'pyyaml', 'micropip']);
|
||||
|
||||
_post({ type: 'progress', msg: 'Installing fitdecode, gpxpy…' });
|
||||
await pyodide.runPythonAsync(_PY_INSTALL_PACKAGES);
|
||||
|
||||
packagesReady = true;
|
||||
_post({ type: 'pyodide_ready' });
|
||||
} catch(e) {
|
||||
initError = String(e);
|
||||
_post({ type: 'init_error', message: initError });
|
||||
}
|
||||
})();
|
||||
|
||||
window._bincioExtract = async function(params) {
|
||||
var reqId = params.reqId;
|
||||
var filename = params.filename;
|
||||
var base64 = params.base64;
|
||||
var wheelBase64 = params.wheelBase64; // pre-fetched by React Native (avoids ATS/HTTP issues)
|
||||
var wheelFilename = params.wheelFilename; // e.g. "bincio-0.1.0-py3-none-any.whl"
|
||||
|
||||
function post(m) { _post(Object.assign({}, m, { reqId: reqId })); }
|
||||
|
||||
try {
|
||||
// Wait for base packages if still loading
|
||||
if (!packagesReady && !initError) {
|
||||
await new Promise(function(res, rej) {
|
||||
var n = 0;
|
||||
var id = setInterval(function() {
|
||||
if (packagesReady) { clearInterval(id); res(undefined); }
|
||||
else if (initError) { clearInterval(id); rej(new Error(initError)); }
|
||||
else if (++n > 300) { clearInterval(id); rej(new Error('Pyodide init timed out')); }
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
if (initError) throw new Error(initError);
|
||||
|
||||
// Install bincio wheel on first extraction.
|
||||
// Wheel bytes arrive pre-fetched from React Native (avoids ATS/HTTP issues).
|
||||
// Write to Pyodide's Emscripten FS so micropip can install via emfs:// URL
|
||||
// (blob: URLs are not recognised by micropip — they cause an InvalidRequirement error).
|
||||
if (!wheelReady) {
|
||||
post({ type: 'progress', msg: 'Loading Bincio…' });
|
||||
var wheelBytes = Uint8Array.from(atob(wheelBase64), function(c) { return c.charCodeAt(0); });
|
||||
var wheelPath = '/tmp/' + wheelFilename;
|
||||
pyodide.FS.writeFile(wheelPath, wheelBytes);
|
||||
pyodide.globals.set('_wheel_path', wheelPath);
|
||||
await pyodide.runPythonAsync(_PY_INSTALL_WHEEL);
|
||||
wheelReady = true;
|
||||
}
|
||||
|
||||
post({ type: 'progress', msg: 'Extracting…' });
|
||||
|
||||
// Decode base64 file bytes and write to Pyodide's virtual filesystem
|
||||
var bytes = Uint8Array.from(atob(base64), function(c) { return c.charCodeAt(0); });
|
||||
pyodide.FS.writeFile('/tmp/' + filename, bytes);
|
||||
|
||||
// SHA-256 of original file bytes (replaces the stub source_hash)
|
||||
var hashBuf = await crypto.subtle.digest('SHA-256', bytes.buffer);
|
||||
var sourceHash = Array.from(new Uint8Array(hashBuf))
|
||||
.map(function(b) { return b.toString(16).padStart(2, '0'); })
|
||||
.join('');
|
||||
|
||||
// Run the bincio extraction pipeline
|
||||
pyodide.globals.set('_filename', filename);
|
||||
var resultJson = await pyodide.runPythonAsync(_PY_EXTRACT);
|
||||
var result = JSON.parse(resultJson);
|
||||
|
||||
_post({
|
||||
type: 'result',
|
||||
reqId: reqId,
|
||||
id: result.id,
|
||||
detail: result.detail,
|
||||
timeseries: result.timeseries,
|
||||
geojson: result.geojson,
|
||||
sourceHash: sourceHash,
|
||||
});
|
||||
} catch(e) {
|
||||
_post({ type: 'error', reqId: reqId, message: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
export function PyodideWebView() {
|
||||
return (
|
||||
<WebView
|
||||
ref={pyodideRef}
|
||||
source={{ html: PYODIDE_HTML, baseUrl: 'https://localhost' }}
|
||||
style={styles.hidden}
|
||||
onMessage={handleWebViewMessage}
|
||||
javaScriptEnabled
|
||||
originWhitelist={['*']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Off-screen but still rendered — display:none / opacity:0 can suppress JS on some platforms.
|
||||
hidden: {
|
||||
position: 'absolute',
|
||||
top: -2000,
|
||||
left: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { createRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import type WebView from 'react-native-webview';
|
||||
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||
|
||||
export type ExtractionResult = {
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
sourceHash: string;
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (r: ExtractionResult) => void;
|
||||
reject: (e: Error) => void;
|
||||
onStatus: (msg: string) => void;
|
||||
};
|
||||
|
||||
export const pyodideRef = createRef<WebView>();
|
||||
|
||||
const pending = new Map<string, Pending>();
|
||||
let reqCounter = 0;
|
||||
let isExtracting = false;
|
||||
|
||||
// Engine readiness — tracked so callers can wait before batching files.
|
||||
let _engineReady = false;
|
||||
let _engineError: string | null = null;
|
||||
// Android <29 (API 27 = Android 8.1, e.g. Karoo) ships with a system WebView
|
||||
// (Chrome <69) that lacks WebAssembly.Global, so Pyodide cannot run. Mounting
|
||||
// a WebView on those devices also causes GPU driver crashes (SurfaceView
|
||||
// conflicts). Skip the engine entirely and route to server extraction instead.
|
||||
let _engineUnavailable = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||
const _engineResolvers: Array<() => void> = [];
|
||||
const _engineRejecters: Array<(e: Error) => void> = [];
|
||||
|
||||
// Init-phase progress listeners (messages sent before any extraction starts).
|
||||
const _progressListeners = new Set<(msg: string) => void>();
|
||||
export function onEngineProgress(cb: (msg: string) => void): () => void {
|
||||
_progressListeners.add(cb);
|
||||
return () => _progressListeners.delete(cb);
|
||||
}
|
||||
|
||||
export function isEngineAvailable(): boolean | null {
|
||||
// null = not yet determined; true = ready; false = unavailable
|
||||
if (_engineReady) return true;
|
||||
if (_engineUnavailable || _engineError) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function waitForEngine(timeoutMs = 300_000): Promise<void> {
|
||||
if (_engineReady) return Promise.resolve();
|
||||
if (_engineUnavailable) return Promise.reject(new Error('engine_unavailable'));
|
||||
if (_engineError) return Promise.reject(new Error(_engineError));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Extraction engine timed out — check network and Bincio instance URL'));
|
||||
}, timeoutMs);
|
||||
_engineResolvers.push(() => { clearTimeout(timer); resolve(); });
|
||||
_engineRejecters.push((e) => { clearTimeout(timer); reject(e); });
|
||||
});
|
||||
}
|
||||
|
||||
export function handleWebViewMessage(e: WebViewMessageEvent): void {
|
||||
let msg: Record<string, unknown>;
|
||||
try { msg = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
|
||||
const reqId = msg.reqId as string | undefined;
|
||||
const p = reqId ? pending.get(reqId) : undefined;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'pyodide_ready':
|
||||
_engineReady = true;
|
||||
_engineResolvers.splice(0).forEach(fn => fn());
|
||||
break;
|
||||
case 'engine_unavailable':
|
||||
_engineUnavailable = true;
|
||||
_engineRejecters.splice(0).forEach(fn => fn(new Error('engine_unavailable')));
|
||||
break;
|
||||
case 'init_error':
|
||||
_engineError = msg.message as string;
|
||||
_engineRejecters.splice(0).forEach(fn => fn(new Error(_engineError!)));
|
||||
break;
|
||||
case 'result':
|
||||
if (p) {
|
||||
pending.delete(reqId!);
|
||||
p.resolve({
|
||||
id: msg.id as string,
|
||||
detail: msg.detail as object,
|
||||
timeseries: (msg.timeseries as object | null) ?? null,
|
||||
geojson: (msg.geojson as object | null) ?? null,
|
||||
sourceHash: msg.sourceHash as string,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
if (p) {
|
||||
pending.delete(reqId!);
|
||||
p.reject(new Error(msg.message as string));
|
||||
}
|
||||
break;
|
||||
case 'progress':
|
||||
if (p) {
|
||||
p.onStatus(msg.msg as string);
|
||||
} else {
|
||||
_progressListeners.forEach(fn => fn(msg.msg as string));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// wheelBase64 is the bincio .whl file pre-fetched by the React Native side
|
||||
// (native networking supports HTTP on local network; WKWebView does not).
|
||||
export function extractFile(
|
||||
filename: string,
|
||||
base64: string,
|
||||
wheelBase64: string,
|
||||
wheelFilename: string,
|
||||
onStatus: (msg: string) => void = () => {},
|
||||
): Promise<ExtractionResult> {
|
||||
if (isExtracting) return Promise.reject(new Error('Another extraction is already in progress'));
|
||||
|
||||
const webview = pyodideRef.current;
|
||||
if (!webview) return Promise.reject(new Error('Extraction engine not ready — restart the app'));
|
||||
|
||||
isExtracting = true;
|
||||
const reqId = String(++reqCounter);
|
||||
const args = JSON.stringify({ reqId, filename, base64, wheelBase64, wheelFilename });
|
||||
|
||||
return new Promise<ExtractionResult>((resolve, reject) => {
|
||||
pending.set(reqId, {
|
||||
resolve: (r) => { isExtracting = false; resolve(r); },
|
||||
reject: (e) => { isExtracting = false; reject(e); },
|
||||
onStatus,
|
||||
});
|
||||
webview.injectJavaScript(`window._bincioExtract(${args}); true;`);
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ExtractionResult } from './extractActivity';
|
||||
|
||||
export async function checkServerAuth(instanceUrl: string, token: string): Promise<void> {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/feed`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||
}
|
||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function extractFileViaServer(
|
||||
filename: string,
|
||||
base64: string,
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
onStatus: (msg: string) => void = () => {},
|
||||
): Promise<ExtractionResult> {
|
||||
onStatus('Uploading to Bincio instance…');
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filename, base64 }),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Could not reach Bincio instance — check your connection.');
|
||||
}
|
||||
|
||||
if (resp.status === 401) throw new Error('Session expired — reconnect in Settings.');
|
||||
if (resp.status === 422) {
|
||||
const body = await resp.json().catch(() => ({})) as { detail?: string };
|
||||
throw new Error(body.detail ?? 'Server could not process this file.');
|
||||
}
|
||||
if (!resp.ok) throw new Error(`Server error (${resp.status})`);
|
||||
|
||||
onStatus('Processing on server…');
|
||||
const data = await resp.json() as {
|
||||
ok: boolean;
|
||||
id: string;
|
||||
detail: object;
|
||||
timeseries: object | null;
|
||||
geojson: object | null;
|
||||
source_hash: string;
|
||||
};
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
detail: data.detail,
|
||||
timeseries: data.timeseries,
|
||||
geojson: data.geojson,
|
||||
sourceHash: data.source_hash,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
module.exports = getDefaultConfig(__dirname);
|
||||
Generated
-9819
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "bincio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-react-native": "~11.0.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-background-fetch": "~14.0.9",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "~15.15.0",
|
||||
"react-native-webview": "13.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bincio mobile app — one-time setup
|
||||
# Run from the mobile/ directory: ./setup.sh
|
||||
# Or from the repo root: bash mobile/setup.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# ── Colours ───────────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; RESET='\033[0m'
|
||||
ok() { echo -e "${GREEN}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
||||
die() { echo -e "${RED}✗${RESET} $*" >&2; exit 1; }
|
||||
step() { echo -e "\n${YELLOW}▸${RESET} $*"; }
|
||||
|
||||
echo ""
|
||||
echo " Bincio mobile setup"
|
||||
echo " ═══════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. Node.js ────────────────────────────────────────────────────────────────
|
||||
step "Checking Node.js..."
|
||||
if ! command -v node &>/dev/null; then
|
||||
die "Node.js not found. Install from https://nodejs.org (v20+ recommended)."
|
||||
fi
|
||||
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
||||
die "Node.js 18+ required (found $(node -v)). Update at https://nodejs.org"
|
||||
fi
|
||||
ok "Node.js $(node -v)"
|
||||
|
||||
# ── 2. npm ────────────────────────────────────────────────────────────────────
|
||||
if ! command -v npm &>/dev/null; then
|
||||
die "npm not found. It ships with Node.js — check your installation."
|
||||
fi
|
||||
ok "npm $(npm -v)"
|
||||
|
||||
# ── 3. Expo CLI (global, optional — we use npx) ───────────────────────────────
|
||||
step "Checking Expo CLI..."
|
||||
if command -v expo &>/dev/null; then
|
||||
ok "Expo CLI $(expo --version) (global)"
|
||||
else
|
||||
warn "Expo CLI not installed globally. Using npx instead (slightly slower)."
|
||||
warn "Install globally with: npm install -g expo-cli"
|
||||
fi
|
||||
|
||||
# ── 4. Platform tools ─────────────────────────────────────────────────────────
|
||||
step "Checking platform tools..."
|
||||
PLATFORM="$(uname -s)"
|
||||
|
||||
if [ "$PLATFORM" = "Darwin" ]; then
|
||||
if command -v xcodebuild &>/dev/null; then
|
||||
ok "Xcode $(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')"
|
||||
else
|
||||
warn "Xcode not found — iOS builds will not work."
|
||||
warn "Install Xcode from the App Store, then: xcode-select --install"
|
||||
fi
|
||||
if command -v xcrun &>/dev/null && xcrun --sdk iphoneos --show-sdk-version &>/dev/null; then
|
||||
ok "iOS SDK available"
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v adb &>/dev/null; then
|
||||
ok "Android SDK / adb found"
|
||||
else
|
||||
warn "adb not found — Android builds require Android Studio."
|
||||
warn "Install from https://developer.android.com/studio"
|
||||
fi
|
||||
|
||||
# ── 5. Install dependencies ───────────────────────────────────────────────────
|
||||
step "Installing npm dependencies..."
|
||||
if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then
|
||||
ok "node_modules already present — running npm install to sync..."
|
||||
fi
|
||||
npm install
|
||||
ok "Dependencies installed"
|
||||
|
||||
# ── 6. expo-env.d.ts (required by expo-router) ────────────────────────────────
|
||||
step "Generating Expo type declarations..."
|
||||
npx expo customize expo-env.d.ts --no-install 2>/dev/null || true
|
||||
if [ ! -f expo-env.d.ts ]; then
|
||||
echo '/// <reference types="expo-router/types" />' > expo-env.d.ts
|
||||
fi
|
||||
ok "expo-env.d.ts ready"
|
||||
|
||||
# ── 7. Summary ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo " ══════════════════════════════════════════"
|
||||
echo " Setup complete! Next steps:"
|
||||
echo ""
|
||||
echo " Start with Expo Go (scan QR on your phone):"
|
||||
echo " npx expo start"
|
||||
echo ""
|
||||
echo " Run on Android emulator:"
|
||||
echo " npx expo run:android"
|
||||
echo ""
|
||||
echo " Run on iOS simulator (macOS only):"
|
||||
echo " npx expo run:ios"
|
||||
echo ""
|
||||
echo " Build APK for Karoo sideload:"
|
||||
echo " npx eas build -p android --profile preview"
|
||||
echo " ══════════════════════════════════════════"
|
||||
echo ""
|
||||
@@ -1,29 +0,0 @@
|
||||
export type PaletteKey = 'auto' | 'default' | 'giro' | 'tour' | 'vuelta';
|
||||
|
||||
export const PALETTES = {
|
||||
default: { accent: '#60a5fa', dim: 'rgba(96,165,250,0.15)', label: 'Default' },
|
||||
giro: { accent: '#f472b6', dim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" },
|
||||
tour: { accent: '#facc15', dim: 'rgba(250,204,21,0.15)', label: 'Tour de France' },
|
||||
vuelta: { accent: '#ef4444', dim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' },
|
||||
} as const satisfies Record<string, { accent: string; dim: string; label: string }>;
|
||||
|
||||
export type Theme = (typeof PALETTES)[keyof typeof PALETTES];
|
||||
|
||||
// Race windows [month 0-indexed, day inclusive] — update each year
|
||||
const RACES: Array<{ key: Exclude<PaletteKey, 'auto' | 'default'>; start: [number, number]; end: [number, number] }> = [
|
||||
{ key: 'giro', start: [4, 8], end: [5, 1] }, // May 8 – Jun 1
|
||||
{ key: 'tour', start: [5, 27], end: [6, 19] }, // Jun 27 – Jul 19
|
||||
{ key: 'vuelta', start: [7, 15], end: [8, 6] }, // Aug 15 – Sep 6
|
||||
];
|
||||
|
||||
export function autoKey(): Exclude<PaletteKey, 'auto'> {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
for (const r of RACES) {
|
||||
const start = new Date(y, r.start[0], r.start[1]);
|
||||
const end = new Date(y, r.end[0], r.end[1] + 1);
|
||||
if (now >= start && now < end) return r.key;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -24,6 +24,8 @@ dependencies = [
|
||||
"rich>=13.0", # pretty console output
|
||||
# Schema validation
|
||||
"jsonschema>=4.23",
|
||||
# Image generation (OG track images)
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -40,6 +42,7 @@ serve = [
|
||||
"uvicorn[standard]>=0.29",
|
||||
"python-multipart>=0.0.9",
|
||||
"bcrypt>=4.1",
|
||||
"PyJWT>=2.8",
|
||||
]
|
||||
strava = [
|
||||
"requests>=2.32",
|
||||
@@ -77,6 +80,7 @@ dev = [
|
||||
"uvicorn[standard]>=0.29",
|
||||
"python-multipart>=0.0.9",
|
||||
"bcrypt>=4.1",
|
||||
"PyJWT>=2.8",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
# Refactoring Plan
|
||||
|
||||
Branch: `refactoring`
|
||||
Approach: test-first — each step starts with tests that prove correctness of the current behaviour, then the refactor makes those tests pass against the new structure.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Extract shared image utilities
|
||||
|
||||
### Problem
|
||||
`_ALLOWED_IMAGE_TYPES`, `_MAX_IMAGE_BYTES`, and `_unique_image_name()` are defined identically in two files:
|
||||
|
||||
| File | Lines |
|
||||
|---|---|
|
||||
| `bincio/edit/server.py` | 46–58 |
|
||||
| `bincio/serve/server.py` | 337–357 |
|
||||
|
||||
Any change (e.g. adding `image/avif`) must be made in both places.
|
||||
|
||||
### Target
|
||||
New module: `bincio/shared/images.py`
|
||||
|
||||
```python
|
||||
# bincio/shared/images.py
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED_IMAGE_TYPES: frozenset[str] = frozenset({
|
||||
"image/jpeg", "image/png", "image/webp", "image/gif"
|
||||
})
|
||||
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
def unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
candidate = filename
|
||||
counter = 1
|
||||
while (directory / candidate).exists():
|
||||
candidate = f"{stem}_{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
```
|
||||
|
||||
### Test plan
|
||||
|
||||
**New file**: `tests/test_shared_images.py`
|
||||
|
||||
Write these tests first (they will fail until the module exists):
|
||||
|
||||
```python
|
||||
# tests/test_shared_images.py
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES, MAX_IMAGE_BYTES, unique_image_name
|
||||
|
||||
def test_constants():
|
||||
assert "image/jpeg" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/png" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/webp" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/gif" in ALLOWED_IMAGE_TYPES
|
||||
assert "image/avif" not in ALLOWED_IMAGE_TYPES # guard against accidental expansion
|
||||
assert MAX_IMAGE_BYTES == 10 * 1024 * 1024
|
||||
|
||||
def test_unique_name_no_collision(tmp_path):
|
||||
assert unique_image_name(tmp_path, "photo.jpg") == "photo.jpg"
|
||||
|
||||
def test_unique_name_single_collision(tmp_path):
|
||||
(tmp_path / "photo.jpg").touch()
|
||||
assert unique_image_name(tmp_path, "photo.jpg") == "photo_1.jpg"
|
||||
|
||||
def test_unique_name_multiple_collisions(tmp_path):
|
||||
(tmp_path / "photo.jpg").touch()
|
||||
(tmp_path / "photo_1.jpg").touch()
|
||||
assert unique_image_name(tmp_path, "photo.jpg") == "photo_2.jpg"
|
||||
|
||||
def test_unique_name_no_suffix(tmp_path):
|
||||
(tmp_path / "photo").touch()
|
||||
assert unique_image_name(tmp_path, "photo") == "photo_1"
|
||||
|
||||
def test_unique_name_preserves_case(tmp_path):
|
||||
assert unique_image_name(tmp_path, "MyPhoto.PNG") == "MyPhoto.PNG"
|
||||
```
|
||||
|
||||
### Implementation steps
|
||||
|
||||
1. Create `bincio/shared/__init__.py` (empty).
|
||||
2. Create `bincio/shared/images.py` with the public constants and function (no leading underscore).
|
||||
3. In `bincio/edit/server.py`: replace lines 46–58 with:
|
||||
```python
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name
|
||||
```
|
||||
4. In `bincio/serve/server.py`: same replacement at lines 337–357.
|
||||
5. Run `pytest tests/test_shared_images.py` — all pass.
|
||||
6. Run full test suite to confirm no regressions.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Extract embedded HTML template from `edit/server.py`
|
||||
|
||||
### Problem
|
||||
`bincio/edit/server.py` contains 285 lines of static HTML/CSS/JS as a Python string literal (`_HTML`, lines 63–347). This:
|
||||
|
||||
- Makes the file 30% larger than it needs to be.
|
||||
- Prevents syntax highlighting and linting of the HTML/JS.
|
||||
- Cannot be tested in isolation (template substitution is done inline by the route handler).
|
||||
|
||||
### Target
|
||||
New file: `bincio/edit/templates/edit.html`
|
||||
|
||||
The template already uses three placeholder tokens:
|
||||
- `__SITE_URL__` — replaced with `site_url` at request time
|
||||
- `__SPORT_OPTIONS__` — replaced with generated `<option>` tags
|
||||
- `__STAT_CHECKBOXES__` — replaced with generated `<label>` tags
|
||||
|
||||
Extract a helper that loads and renders the template:
|
||||
|
||||
```python
|
||||
# bincio/edit/server.py — replaces the _HTML string and inline render
|
||||
from pathlib import Path as _Path
|
||||
_TEMPLATE_PATH = _Path(__file__).parent / "templates" / "edit.html"
|
||||
|
||||
def _render_edit_html(activity_id: str) -> str:
|
||||
template = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
sport_options = "\n".join(
|
||||
f'<option value="{s}">{s.title()}</option>' for s in SPORTS
|
||||
)
|
||||
stat_checkboxes = "\n".join(
|
||||
f'<label class="check-item"><input type="checkbox" data-stat="{k}"> {v}</label>'
|
||||
for k, v in STAT_PANELS.items()
|
||||
)
|
||||
return (
|
||||
template
|
||||
.replace("__SITE_URL__", site_url)
|
||||
.replace("__SPORT_OPTIONS__", sport_options)
|
||||
.replace("__STAT_CHECKBOXES__", stat_checkboxes)
|
||||
)
|
||||
```
|
||||
|
||||
### Test plan
|
||||
|
||||
**Extend** `tests/test_edit_server.py` with a new section:
|
||||
|
||||
```python
|
||||
# tests/test_edit_server.py — new tests for template loading
|
||||
import bincio.edit.server as edit_server
|
||||
|
||||
def test_edit_ui_returns_html(tmp_path):
|
||||
"""GET /edit/<id> returns 200 with an HTML body containing the form."""
|
||||
edit_server.data_dir = tmp_path
|
||||
activities = tmp_path / "activities"
|
||||
activities.mkdir()
|
||||
(activities / "run-001.json").write_text('{"id":"run-001"}')
|
||||
resp = CLIENT.get("/edit/run-001")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/html")
|
||||
assert '<form id="form"' in resp.text
|
||||
|
||||
def test_edit_ui_injects_site_url(tmp_path):
|
||||
"""Template placeholder __SITE_URL__ is replaced with the configured site_url."""
|
||||
edit_server.data_dir = tmp_path
|
||||
edit_server.site_url = "http://localhost:1234"
|
||||
(tmp_path / "activities").mkdir(exist_ok=True)
|
||||
(tmp_path / "activities" / "run-001.json").write_text('{"id":"run-001"}')
|
||||
resp = CLIENT.get("/edit/run-001")
|
||||
assert "http://localhost:1234" in resp.text
|
||||
assert "__SITE_URL__" not in resp.text
|
||||
|
||||
def test_edit_ui_no_unresolved_placeholders(tmp_path):
|
||||
"""No placeholder tokens remain after rendering."""
|
||||
edit_server.data_dir = tmp_path
|
||||
(tmp_path / "activities").mkdir(exist_ok=True)
|
||||
(tmp_path / "activities" / "run-001.json").write_text('{"id":"run-001"}')
|
||||
resp = CLIENT.get("/edit/run-001")
|
||||
for token in ("__SITE_URL__", "__SPORT_OPTIONS__", "__STAT_CHECKBOXES__"):
|
||||
assert token not in resp.text, f"Unresolved placeholder: {token}"
|
||||
|
||||
def test_edit_template_file_exists():
|
||||
"""The template file is present on disk (guards against accidental deletion)."""
|
||||
from pathlib import Path
|
||||
template = Path(edit_server.__file__).parent / "templates" / "edit.html"
|
||||
assert template.exists(), f"Template not found at {template}"
|
||||
```
|
||||
|
||||
### Implementation steps
|
||||
|
||||
1. Create `bincio/edit/templates/` directory.
|
||||
2. Move the HTML content of `_HTML` into `bincio/edit/templates/edit.html` verbatim (keep the `__PLACEHOLDER__` tokens as-is).
|
||||
3. Delete the `_HTML = """..."""` string literal from `server.py` (lines 63–347).
|
||||
4. Add `_render_edit_html()` helper as shown above.
|
||||
5. Update the `/edit/{activity_id}` route handler to call `_render_edit_html(activity_id)` instead of the inline `_HTML.replace(...)` chain.
|
||||
6. Run `pytest tests/test_edit_server.py` — all pass (new + existing).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Split `serve/server.py` into APIRouter modules
|
||||
|
||||
### Problem
|
||||
`bincio/serve/server.py` is 3,230 lines containing ~60 routes across 10 logical domains, all shared dependencies, all Pydantic models, and all background task machinery. It cannot be meaningfully reviewed, tested in isolation, or understood at a glance.
|
||||
|
||||
### Target structure
|
||||
|
||||
```
|
||||
bincio/serve/
|
||||
├── server.py # ~150 lines: app factory, middleware, router registration, startup
|
||||
├── deps.py # module-level globals + shared FastAPI dependency functions
|
||||
├── models.py # all Pydantic request/response models
|
||||
├── tasks.py # background workers: site-rebuild, rebuild-for-handle, jobs registry
|
||||
├── db.py # unchanged
|
||||
└── routers/
|
||||
├── __init__.py
|
||||
├── auth.py # /api/auth/*, /api/register, /api/invites
|
||||
├── me.py # /api/me/*
|
||||
├── admin.py # /api/admin/*
|
||||
├── activities.py # /api/activity/*, /api/activities/*
|
||||
├── uploads.py # /api/upload/*
|
||||
├── segments.py # /api/segments/*
|
||||
├── strava.py # /api/strava/*
|
||||
├── garmin.py # /api/garmin/*
|
||||
├── ideas.py # /api/ideas/*, /api/feedback
|
||||
└── feed.py # /api/feed, /api/stats, /api/me (read-only), /api/wheel/*
|
||||
```
|
||||
|
||||
### `deps.py` — shared state and dependency functions
|
||||
|
||||
All module-level globals that multiple routers need move here. The CLI sets them on this module before uvicorn starts.
|
||||
|
||||
```python
|
||||
# bincio/serve/deps.py
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
|
||||
from fastapi import Cookie, HTTPException
|
||||
from bincio.serve.db import User, get_session, get_user
|
||||
|
||||
# ── Module-level state (set by CLI) ──────────────────────────────────────────
|
||||
data_dir: Path | None = None
|
||||
site_dir: Path | None = None
|
||||
webroot: Path | None = None
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
public_url: str = ""
|
||||
dem_url: str = "https://api.open-elevation.com"
|
||||
sync_secret: str = ""
|
||||
garmin_key: bytes | None = None
|
||||
_db: sqlite3.Connection | None = None
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||
SESSION_COOKIE = "bincio_session"
|
||||
COOKIE_MAX_AGE = 30 * 86400
|
||||
SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
|
||||
|
||||
# ── Dependency functions ───────────────────────────────────────────────────────
|
||||
def get_data_dir() -> Path:
|
||||
if data_dir is None:
|
||||
raise HTTPException(500, "Server not configured")
|
||||
return data_dir
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
global _db
|
||||
if _db is None:
|
||||
from bincio.serve.db import open_db
|
||||
_db = open_db(get_data_dir())
|
||||
return _db
|
||||
|
||||
def get_current_user(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
if not bincio_session:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
sess = get_session(get_db(), bincio_session)
|
||||
if sess is None:
|
||||
raise HTTPException(401, "Invalid or expired session")
|
||||
user = get_user(get_db(), sess.handle)
|
||||
if user is None or user.suspended:
|
||||
raise HTTPException(401, "Account not found or suspended")
|
||||
return user
|
||||
|
||||
def require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
user = get_current_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
```
|
||||
|
||||
### `models.py` — Pydantic models
|
||||
|
||||
All `class *Request(BaseModel)` and `class *Response(BaseModel)` definitions move to `bincio/serve/models.py`. Routers import from there.
|
||||
|
||||
### `tasks.py` — background workers
|
||||
|
||||
Move `_site_rebuild_worker`, `_site_rebuild_event`, `_rebuild_for_handle`, `_active_jobs`, `_jobs_lock`, `_job_start`, `_job_update`, `_job_finish` to `bincio/serve/tasks.py`. These import from `deps.py` for `webroot` and `site_dir`.
|
||||
|
||||
### Router example — `routers/auth.py`
|
||||
|
||||
```python
|
||||
# bincio/serve/routers/auth.py
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.models import LoginRequest, LoginResponse, ...
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/api/auth/login", response_model=LoginResponse)
|
||||
async def login(body: LoginRequest, request: Request, response: Response):
|
||||
db = deps.get_db()
|
||||
...
|
||||
```
|
||||
|
||||
Main `server.py` becomes:
|
||||
|
||||
```python
|
||||
# bincio/serve/server.py (~150 lines)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from bincio.serve.routers import auth, me, admin, activities, uploads, segments, strava, garmin, ideas, feed
|
||||
|
||||
app = FastAPI(title="BincioActivity Serve")
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
app.add_middleware(CORSMiddleware, ...)
|
||||
|
||||
for router in [auth.router, me.router, admin.router, activities.router,
|
||||
uploads.router, segments.router, strava.router, garmin.router,
|
||||
ideas.router, feed.router]:
|
||||
app.include_router(router)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _on_startup() -> None: ...
|
||||
```
|
||||
|
||||
### Test plan
|
||||
|
||||
**Philosophy**: write tests that call routes through the full app (via `TestClient`), so they remain valid before and after the split. Each router gets its own test file.
|
||||
|
||||
#### Before starting the split
|
||||
|
||||
Write `tests/serve/test_auth_router.py` (and equivalents for each router). These tests pass against the current monolith. After the split they must still pass — this is the regression guard.
|
||||
|
||||
```python
|
||||
# tests/serve/test_auth_router.py
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from bincio.serve.server import app
|
||||
import bincio.serve.deps as deps
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path):
|
||||
deps.data_dir = tmp_path
|
||||
deps._db = None
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
def test_login_missing_body(client):
|
||||
r = client.post("/api/auth/login", json={})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
r = client.post("/api/auth/login", json={"handle": "nobody", "password": "x"})
|
||||
assert r.status_code in (401, 404)
|
||||
|
||||
def test_logout_unauthenticated(client):
|
||||
r = client.post("/api/auth/logout")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_register_invite_required(client):
|
||||
r = client.post("/api/register", json={"handle": "alice", "password": "pass1234", "invite": ""})
|
||||
assert r.status_code in (400, 403)
|
||||
```
|
||||
|
||||
Write similar test files for `me`, `admin`, `activities`, `uploads` before touching any production code.
|
||||
|
||||
#### After the split
|
||||
|
||||
Run the full test suite. No test should fail — the tests are route-level and do not import from the monolith's internals.
|
||||
|
||||
#### Additional tests enabled by the split
|
||||
|
||||
Once routers are isolated, add unit tests for dependency functions in `deps.py`:
|
||||
|
||||
```python
|
||||
# tests/serve/test_deps.py
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
import bincio.serve.deps as deps
|
||||
|
||||
def test_get_data_dir_raises_when_unset():
|
||||
deps.data_dir = None
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
deps.get_data_dir()
|
||||
assert exc.value.status_code == 500
|
||||
|
||||
def test_get_current_user_raises_without_cookie(tmp_path):
|
||||
deps.data_dir = tmp_path
|
||||
deps._db = None
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
deps.get_current_user(bincio_session=None)
|
||||
assert exc.value.status_code == 401
|
||||
```
|
||||
|
||||
### Implementation steps (do not start until all pre-split tests are green)
|
||||
|
||||
1. Create `bincio/serve/deps.py` — move globals, constants, `get_db`, `get_current_user`, `require_admin`, `get_data_dir`. Update `serve/cli.py` to set `deps.*` instead of `server.*`.
|
||||
2. Create `bincio/serve/models.py` — move all Pydantic models. Update imports in `server.py`.
|
||||
3. Create `bincio/serve/tasks.py` — move `_site_rebuild_worker`, `_site_rebuild_event`, `_rebuild_for_handle`, jobs registry. Import from `deps`.
|
||||
4. Create `bincio/serve/routers/__init__.py` (empty).
|
||||
5. Extract one router at a time in this order (least-coupled first):
|
||||
- `feed.py` (read-only, minimal deps)
|
||||
- `auth.py` (depends only on db, no user dir operations)
|
||||
- `me.py` (depends on current user + user dir)
|
||||
- `ideas.py`
|
||||
- `segments.py`
|
||||
- `strava.py`
|
||||
- `garmin.py`
|
||||
- `activities.py`
|
||||
- `uploads.py`
|
||||
- `admin.py` (most complex, depends on tasks)
|
||||
6. After each router extraction: run the full test suite before proceeding.
|
||||
7. Once all routers are extracted, reduce `server.py` to the app factory and middleware only.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Narrow broad `except Exception:` catches
|
||||
|
||||
### Problem
|
||||
After the Step 3 split, the router files inherited ~35 bare `except Exception:` clauses from the original monolith. Most were in route handlers where a specific narrow type is knowable and preferable — broad catches hide bugs and let surprising failures silently produce wrong results.
|
||||
|
||||
### Classification rule
|
||||
|
||||
| Situation | Decision |
|
||||
|---|---|
|
||||
| Background thread top-level guard (calls `log.exception`) | **Keep** — last-resort, full traceback essential |
|
||||
| SSE stream generator top-level | **Keep** — must convert any error to a client event |
|
||||
| Per-item batch loop (must not abort on one failure) | **Keep** — `log.warning/error` already present |
|
||||
| Explicitly non-fatal post-upload merge step | **Keep** — `log.warning` present; upload already succeeded |
|
||||
| Route handler: reading/writing JSON files | `(OSError, json.JSONDecodeError)` |
|
||||
| Route handler: datetime parsing | `ValueError` |
|
||||
| Route handler: base64 decoding | `ValueError` |
|
||||
| Route handler: YAML parsing | `(OSError, yaml.YAMLError)` |
|
||||
| Route handler: GeoJSON coord extraction | `(TypeError, IndexError, AttributeError)` |
|
||||
| Startup cleanup (`Path.unlink`) | `OSError` |
|
||||
| JSON line parsing inside SSE batch | `json.JSONDecodeError` |
|
||||
|
||||
### What was changed (28 catches narrowed across 8 files)
|
||||
|
||||
- `server.py` — startup `tmp*.zip` cleanup → `OSError`
|
||||
- `segments.py` — file-scan loops (6 catches) → `(OSError, json.JSONDecodeError, ValueError)` / `ValueError`
|
||||
- `me.py` — credential file reads, manifest write (4 catches) → `(OSError, json.JSONDecodeError)`
|
||||
- `activities.py` — index/cache reads (2 catches) → `(OSError, json.JSONDecodeError)`; YAML enrichment (1) → `(OSError, yaml.YAMLError)` with `import yaml` moved above the `try`
|
||||
- `admin.py` — diag index reads, strava-status loop reads (5 catches) → `(OSError, json.JSONDecodeError)` / `json.JSONDecodeError`
|
||||
- `ideas.py` — idea file reads (3 catches) → `(OSError, json.JSONDecodeError)`
|
||||
- `strava.py` — index parse in reset endpoint → `(OSError, json.JSONDecodeError, ValueError)`
|
||||
- `uploads.py` — GeoJSON coords, base64 decode, cache update (3 catches)
|
||||
|
||||
### What was kept (11 catches, all intentional)
|
||||
|
||||
`tasks.py:97`, `tasks.py:133` — background thread tops with `log.exception`
|
||||
`admin.py:579` — admin strava-sync background thread top with `log.exception`
|
||||
`admin.py:630` — per-activity batch loop in recompute-elevation with `log.warning`
|
||||
`garmin.py:112`, `strava.py:164`, `uploads.py:491` — SSE stream tops
|
||||
`uploads.py:143`, `uploads.py:259` — non-fatal post-upload merge with `log.warning`
|
||||
`uploads.py:245` — extraction failure → 422 (any parser failure must surface as 422)
|
||||
`uploads.py:404` — per-file batch loop in upload event stream
|
||||
|
||||
---
|
||||
|
||||
## Progress tracker
|
||||
|
||||
| # | Step | Status |
|
||||
|---|---|---|
|
||||
| 1 | Extract shared image utilities → `bincio/shared/images.py` | Done |
|
||||
| 2 | Extract HTML template → `bincio/edit/templates/edit.html` | Done |
|
||||
| 3 | Split `serve/server.py` into `deps.py` + `routers/*` | Done |
|
||||
| 4 | Narrow broad `except Exception:` catches | Done |
|
||||
|
||||
> **Note on dependency pinning**: not included. `uv.lock` already pins every dependency (including transitives) to exact versions, which is strictly stronger than switching `>=` to `~=` in `pyproject.toml`. The lockfile is the right mechanism for this concern.
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill Garmin gear for all users who have stored Garmin credentials.
|
||||
|
||||
Usage (on VPS):
|
||||
cd /opt/bincio
|
||||
uv run python3 scripts/backfill_garmin_gear.py --data-dir /var/bincio/data
|
||||
|
||||
# Limit to specific users:
|
||||
uv run python3 scripts/backfill_garmin_gear.py --data-dir /var/bincio/data --users plagzo12
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Backfill Garmin gear for all users")
|
||||
parser.add_argument("--data-dir", required=True, type=Path, help="Root data directory")
|
||||
parser.add_argument("--users", nargs="*", help="Limit to these user handles (default: all)")
|
||||
args = parser.parse_args()
|
||||
|
||||
data_dir: Path = args.data_dir.resolve()
|
||||
if not data_dir.is_dir():
|
||||
sys.exit(f"data-dir not found: {data_dir}")
|
||||
|
||||
from bincio.extract.garmin_api import GarminError, has_credentials
|
||||
from bincio.extract.garmin_sync import import_garmin_gear
|
||||
|
||||
candidates = (
|
||||
[data_dir / h for h in args.users]
|
||||
if args.users
|
||||
else sorted(p for p in data_dir.iterdir() if p.is_dir())
|
||||
)
|
||||
|
||||
garmin_users = [p for p in candidates if has_credentials(p)]
|
||||
if not garmin_users:
|
||||
print("No users with Garmin credentials found.")
|
||||
return
|
||||
|
||||
print(f"Found {len(garmin_users)} Garmin user(s): {[p.name for p in garmin_users]}\n")
|
||||
|
||||
for user_dir in garmin_users:
|
||||
handle = user_dir.name
|
||||
print(f"[{handle}] importing gear...", flush=True)
|
||||
try:
|
||||
result = import_garmin_gear(data_dir, user_dir)
|
||||
print(
|
||||
f"[{handle}] done — "
|
||||
f"gear_added={result['gear_added']}, "
|
||||
f"activities_updated={result['activities_updated']}"
|
||||
)
|
||||
except GarminError as exc:
|
||||
print(f"[{handle}] Garmin error: {exc}")
|
||||
except Exception as exc:
|
||||
print(f"[{handle}] unexpected error: {type(exc).__name__}: {exc}")
|
||||
|
||||
print("\nAll done. Run merge_all or trigger a rebuild to refresh the index shards.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user