Compare commits
434 Commits
claude/vea
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
076cd214fe | |
|
|
8a4a1c1af7 | |
|
|
655f553bdb | |
|
|
911c9dd3d0 | |
|
|
3aab4ab51a | |
|
|
5bd55e0322 | |
|
|
a74bb408ee | |
|
|
5d368b849a | |
|
|
9657cff19a | |
|
|
e1bff274c7 | |
|
|
756ecb3ec0 | |
|
|
0d1f44cb2a | |
|
|
03a9056d8f | |
|
|
73f68f61c1 | |
|
|
9f9711cdfe | |
|
|
8f68b3f396 | |
|
|
988dfa0b3a | |
|
|
01a851ca72 | |
|
|
65a498dd50 | |
|
|
d9804c3e00 | |
|
|
83f353aee9 | |
|
|
bb0055293b | |
|
|
b947e892d0 | |
|
|
d2fcd2efa5 | |
|
|
8ae1ee0645 | |
|
|
bfead87452 | |
|
|
8f429ac643 | |
|
|
90d57f4b38 | |
|
|
07c872eb2b | |
|
|
e1afa039cb | |
|
|
ee8c759f0b | |
|
|
b866ba3f58 | |
|
|
b0bbfa677a | |
|
|
82f03d94b6 | |
|
|
59ef240a4d | |
|
|
421c45e60b | |
|
|
837339b894 | |
|
|
7f687236d4 | |
|
|
fa0c9a8584 | |
|
|
71639a3dc5 | |
|
|
8d10e053d4 | |
|
|
a728e50796 | |
|
|
9015bad65c | |
|
|
3459158b99 | |
|
|
3abe8d650a | |
|
|
a2063a27a8 | |
|
|
cf1273c1d9 | |
|
|
8d0814e460 | |
|
|
2690b7153e | |
|
|
c55ee0b742 | |
|
|
c72cfdf50d | |
|
|
c6b3c7cddc | |
|
|
12952516cf | |
|
|
9946ca9031 | |
|
|
a73761f4c5 | |
|
|
ac9811826e | |
|
|
0369b61001 | |
|
|
59da1d9514 | |
|
|
d374c8e6f9 | |
|
|
aa8c5974ae | |
|
|
0dba563a03 | |
|
|
dab2ac473f | |
|
|
57b6d530f3 | |
|
|
ba5c09f962 | |
|
|
eed762097a | |
|
|
f6baa7d472 | |
|
|
07d39b4290 | |
|
|
0efae572cd | |
|
|
9e880e98a5 | |
|
|
e25ca7ab9a | |
|
|
18ee92f227 | |
|
|
96452f0b71 | |
|
|
3f8ce93636 | |
|
|
626e1e267e | |
|
|
f18813f3ff | |
|
|
f7ac69d01a | |
|
|
98b3613247 | |
|
|
18a0df505b | |
|
|
7b5b8ecad2 | |
|
|
4b18d08993 | |
|
|
7c9b9e3352 | |
|
|
08890895de | |
|
|
330b94c3c4 | |
|
|
7691f282cf | |
|
|
6cee626383 | |
|
|
0d043e80e4 | |
|
|
9390199ce0 | |
|
|
f8eacca7c8 | |
|
|
7a182817ee | |
|
|
aaa2428f6c | |
|
|
0f3a8a7e39 | |
|
|
f4bbe80144 | |
|
|
02d46944ac | |
|
|
0e17556a16 | |
|
|
84dc57c562 | |
|
|
82dae774f1 | |
|
|
63b7f32379 | |
|
|
81791fd346 | |
|
|
b6af132845 | |
|
|
ab927aa772 | |
|
|
764e4efa18 | |
|
|
7208694960 | |
|
|
54fbc79a52 | |
|
|
767ce3bc7e | |
|
|
adf031128e | |
|
|
6e1b15e344 | |
|
|
822fde2594 | |
|
|
f3c6312a9e | |
|
|
bf175dde6d | |
|
|
5edc7b5160 | |
|
|
9d18ba236f | |
|
|
1e1e733c36 | |
|
|
5236e0c306 | |
|
|
80bb9aadc1 | |
|
|
86ec62d10b | |
|
|
37a34cc5cf | |
|
|
aac4dd845e | |
|
|
7f53be4105 | |
|
|
7b7c850dd8 | |
|
|
99d025399c | |
|
|
647f1f4def | |
|
|
a61e594952 | |
|
|
9cad4f388c | |
|
|
40f2900e9d | |
|
|
4614e18e7a | |
|
|
1a4f03bbc8 | |
|
|
7c11c2dc29 | |
|
|
20d8d23474 | |
|
|
2cc7c0b6f0 | |
|
|
5b7924b1f6 | |
|
|
a32d8d6492 | |
|
|
9aa0687288 | |
|
|
422d504a07 | |
|
|
d4ab438073 | |
|
|
78f79accb5 | |
|
|
59a5a2c1e7 | |
|
|
434b5d9fe3 | |
|
|
55ca06ca3d | |
|
|
02e7a134cc | |
|
|
4b92f23329 | |
|
|
ee8dbf82ba | |
|
|
8c72e83a4d | |
|
|
ac5e94252b | |
|
|
ca4e747bed | |
|
|
cf456fb223 | |
|
|
8a255cbff6 | |
|
|
c4819631e2 | |
|
|
13f1a13367 | |
|
|
4737114b12 | |
|
|
5555950f03 | |
|
|
ffcf92936a | |
|
|
c5d615843d | |
|
|
5ccbd0d74e | |
|
|
8bec654595 | |
|
|
ea4bb09679 | |
|
|
a8bce2ef0b | |
|
|
f851e6f959 | |
|
|
e13d3f537c | |
|
|
9cc47a3da4 | |
|
|
d054fb7a5b | |
|
|
be5858aafe | |
|
|
340be305f8 | |
|
|
460e8f3ab1 | |
|
|
652f61da50 | |
|
|
b42a7fdcc4 | |
|
|
1d6d332c18 | |
|
|
85b820fa7c | |
|
|
35bcb7ed26 | |
|
|
a8a53c9581 | |
|
|
77416d32e4 | |
|
|
a21822fcec | |
|
|
a881246e80 | |
|
|
3af5eb8208 | |
|
|
8381f7543f | |
|
|
5e00fcbbb8 | |
|
|
acdc2a07f2 | |
|
|
0eb39fae9a | |
|
|
f0eff52949 | |
|
|
b3bcab1f72 | |
|
|
f33cde82e2 | |
|
|
3291556821 | |
|
|
5d7812a093 | |
|
|
1071755625 | |
|
|
2e0e8e9582 | |
|
|
b86710903b | |
|
|
9da51f5096 | |
|
|
fc831719cd | |
|
|
dbdf4a04cb | |
|
|
869bec38b5 | |
|
|
5f4a21a4c5 | |
|
|
eea2ecbc72 | |
|
|
f308fd830a | |
|
|
95084087a5 | |
|
|
48f7dafce3 | |
|
|
2bbe77c141 | |
|
|
d8ad9d64f5 | |
|
|
56a516045b | |
|
|
dc4c3a6a17 | |
|
|
37a174b99e | |
|
|
eac1fa2726 | |
|
|
8ff4696786 | |
|
|
f819b685d8 | |
|
|
89ee1ef507 | |
|
|
e5055d720c | |
|
|
5807f2e7b4 | |
|
|
5dcc501d54 | |
|
|
3312716afd | |
|
|
56b1ba47ec | |
|
|
6f3dab53cb | |
|
|
ea0c9fbe94 | |
|
|
1cf10b563a | |
|
|
adc8070ff9 | |
|
|
53d1fa0735 | |
|
|
5b3ee89edd | |
|
|
5579daa452 | |
|
|
e25f997281 | |
|
|
9c3c1047ac | |
|
|
809ac118f9 | |
|
|
3bd75acabf | |
|
|
3a84c491ae | |
|
|
070ce19286 | |
|
|
a88aef0e47 | |
|
|
05b10e34d8 | |
|
|
28af57bc93 | |
|
|
46b07a019c | |
|
|
154a96674d | |
|
|
b8894ac1c1 | |
|
|
a610ccbbac | |
|
|
190fab8e6d | |
|
|
4afcfa4441 | |
|
|
a562f69f02 | |
|
|
da3bf0e7cd | |
|
|
e271f75cad | |
|
|
3462e24ec7 | |
|
|
a221a15ec3 | |
|
|
a1621fec37 | |
|
|
dc935f263c | |
|
|
cec74717fc | |
|
|
5cdc785b49 | |
|
|
0a879e56a8 | |
|
|
4316554b27 | |
|
|
328de89c0b | |
|
|
0e0ececa2d | |
|
|
25a294d7bc | |
|
|
a6a073329f | |
|
|
e16d120236 | |
|
|
6d505e360b | |
|
|
8502c541a6 | |
|
|
12f290905a | |
|
|
1e82c62ff7 | |
|
|
f5e7e9ddde | |
|
|
2e02fe11ac | |
|
|
da76d393d9 | |
|
|
5c960fccd5 | |
|
|
711545e9f2 | |
|
|
1aa9493bc3 | |
|
|
d5bcbe39b4 | |
|
|
51e5c86f69 | |
|
|
9eca20fe43 | |
|
|
aa144b9a39 | |
|
|
bf2a65dcaf | |
|
|
732cd115a5 | |
|
|
cb0d24dae1 | |
|
|
c9c2c58c16 | |
|
|
e1512b6b53 | |
|
|
df6a56b195 | |
|
|
9e181bfc55 | |
|
|
00bbdbf95b | |
|
|
0bd7f956f5 | |
|
|
4390af4773 | |
|
|
2d334e56c7 | |
|
|
7c021dd663 | |
|
|
e948b191b0 | |
|
|
2ef898e89d | |
|
|
8febfac742 | |
|
|
212d891fa1 | |
|
|
1832d0d3ee | |
|
|
bb4f19da27 | |
|
|
ab8189e927 | |
|
|
8c34676946 | |
|
|
e4b0a487fc | |
|
|
8839c296a2 | |
|
|
27bb80d11a | |
|
|
43d0203358 | |
|
|
80c21e6f40 | |
|
|
565bf97294 | |
|
|
4a558fc57a | |
|
|
74510bb00a | |
|
|
2858b8b827 | |
|
|
b8b376c53a | |
|
|
aef584c9fa | |
|
|
cf22f08974 | |
|
|
e197b1912f | |
|
|
0c5a0a99ec | |
|
|
9454cd3ec5 | |
|
|
4bd6a6c4f7 | |
|
|
42795518e9 | |
|
|
26c067cc38 | |
|
|
6387d8138a | |
|
|
8ff97a61de | |
|
|
89ed627800 | |
|
|
c2e1b078ce | |
|
|
627cd0a6f4 | |
|
|
a2be36b5fe | |
|
|
c2d1e48c8c | |
|
|
ef256273e0 | |
|
|
ed4ae0cd43 | |
|
|
a52ee1593a | |
|
|
9cddc137c5 | |
|
|
2c0608e47b | |
|
|
7158334699 | |
|
|
604e5850bc | |
|
|
1ca5367f27 | |
|
|
05c9c07e19 | |
|
|
fb4bca34dd | |
|
|
1b47932916 | |
|
|
67bc3ff4f1 | |
|
|
24188480c4 | |
|
|
45e05a5d27 | |
|
|
5855ccc1d4 | |
|
|
aac0322975 | |
|
|
98741d8171 | |
|
|
cf1f7c3309 | |
|
|
3647551a79 | |
|
|
d76c009808 | |
|
|
43bbeed1c8 | |
|
|
f90868c595 | |
|
|
43d6e14289 | |
|
|
a35518a099 | |
|
|
8e09b69fb3 | |
|
|
380e0afb86 | |
|
|
bcade97fa4 | |
|
|
b107685063 | |
|
|
4e5751ebfe | |
|
|
cae89caef2 | |
|
|
9e234911f2 | |
|
|
c3c648f060 | |
|
|
413006e9ba | |
|
|
171d14d7b8 | |
|
|
6bbb700f36 | |
|
|
a763fed573 | |
|
|
58d882d36d | |
|
|
184384b68c | |
|
|
2fc11630b4 | |
|
|
9a184fdfab | |
|
|
62979ff342 | |
|
|
33b9aa6292 | |
|
|
bec991288b | |
|
|
25665f887f | |
|
|
a3370a9a49 | |
|
|
2f68a3fae4 | |
|
|
0a7750ee3d | |
|
|
fd2f005a3b | |
|
|
af2482aab2 | |
|
|
e22e169fb9 | |
|
|
6836903c5f | |
|
|
72445c4d2f | |
|
|
e39885682c | |
|
|
d604d8d443 | |
|
|
c9f8b87263 | |
|
|
984a2633cc | |
|
|
88832a3e8b | |
|
|
4d404e376a | |
|
|
808ef2534e | |
|
|
3271a1e1af | |
|
|
67aca34124 | |
|
|
58bc03d712 | |
|
|
5c05a1a289 | |
|
|
8dada8b298 | |
|
|
c0392e42ac | |
|
|
cdb1db855b | |
|
|
dd32e3e496 | |
|
|
3233dff545 | |
|
|
dab6ac620d | |
|
|
2cb0fdcded | |
|
|
58b2aaa74e | |
|
|
ceb2b4f161 | |
|
|
5257922f3b | |
|
|
b04d4d269c | |
|
|
220af19a39 | |
|
|
b18e051ab3 | |
|
|
d2319e1257 | |
|
|
2f4e3ecb91 | |
|
|
cfac0773d7 | |
|
|
782c17c01d | |
|
|
e81ccb3486 | |
|
|
a4a0af91ff | |
|
|
774d884f5c | |
|
|
17bef4f599 | |
|
|
730932be13 | |
|
|
2e8bd6724b | |
|
|
82ad23c135 | |
|
|
6076102d21 | |
|
|
4fc4125546 | |
|
|
0f50dc047d | |
|
|
738bcd0c77 | |
|
|
9858792123 | |
|
|
1d7d67541f | |
|
|
5ddc9d8ff0 | |
|
|
c7e7f8dca3 | |
|
|
3d6608cac7 | |
|
|
fd10300597 | |
|
|
ebb89ba4f3 | |
|
|
e1c9ab5939 | |
|
|
fb6f16adde | |
|
|
9bf19e4dc5 | |
|
|
5bbc5aa1ea | |
|
|
833e8e817c | |
|
|
2488478f69 | |
|
|
78ca1904b5 | |
|
|
451bd5a8d6 | |
|
|
2602f723c3 | |
|
|
152bbd2ac4 | |
|
|
3347180f31 | |
|
|
43e910025a | |
|
|
8532016a2d | |
|
|
e257823cef | |
|
|
14446e3c41 | |
|
|
d2a3186b3e | |
|
|
057b2e5439 | |
|
|
00b42234e9 | |
|
|
21194e61b0 | |
|
|
a2156e7231 | |
|
|
b52a451db4 | |
|
|
c6c57d9d15 | |
|
|
fb9119c98d | |
|
|
132f973486 | |
|
|
4125fd47c3 | |
|
|
b56a74d6a7 | |
|
|
8a4f5cbe11 | |
|
|
15d8ac5f49 | |
|
|
47b2d1043b | |
|
|
5fefa88577 | |
|
|
5fa6d66c41 |
|
|
@ -0,0 +1,186 @@
|
|||
---
|
||||
name: api-v2-routes
|
||||
description: Use when adding or changing a resource on the Huma-backed /api/v2 API (new endpoints, porting a v1 resource, editing pkg/routes/api/v2/). Covers per-operation Huma handlers, the shared envelopes, error/auth bridging, REST verb conventions, and what's automatic.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Adding /api/v2 routes for a CRUDable resource
|
||||
|
||||
`/api/v2` is served by [Huma v2](https://github.com/danielgtaylor/huma) mounted on an Echo group via the vendored `pkg/modules/humaecho5` adapter. Unlike v1's generic `WebHandler`, each operation is a typed Huma handler registered explicitly. The handlers are thin: they pull auth off the context, call the same `pkg/web/handler.Do*` functions v1 uses, and translate domain errors into RFC 9457 responses.
|
||||
|
||||
**Reference implementation:** `pkg/routes/api/v2/labels.go` is the canonical example — copy its shape. Shared envelopes live in `pkg/routes/api/v2/types.go`; the auth/error bridge in `pkg/routes/api/v2/errors.go`; config in `pkg/routes/api/v2/huma.go`.
|
||||
|
||||
## Prerequisite: the model must be CRUDable
|
||||
|
||||
v2 handlers call `handler.DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete`, which invoke the model's `Can*` methods. If the model isn't already a working v1 resource, do the model work first — invoke the **`crudable`** skill. Permissions are enforced at the model level; **never** re-check them in a v2 handler.
|
||||
|
||||
**Every exposed model field needs a `doc:` tag.** v2's schema is reflected from struct tags at runtime; Huma cannot read the Go doc comments swaggo uses for v1. A field without `doc:"..."` ships with no description in the spec. Add the tag alongside the existing comment (keep both — swaggo still reads the comment for v1, and they should stay in sync):
|
||||
|
||||
```go
|
||||
// The title of the label. You'll see this one on tasks associated with it.
|
||||
Title string `json:"title" minLength:"1" maxLength:"250" doc:"The title of the label. You'll see this one on tasks associated with it."`
|
||||
```
|
||||
|
||||
These model edits are safe for v1 — swaggo, XORM, and govalidator all ignore the `doc` tag. (Huma *does* read validation tags like `minLength`/`maxLength`/`enum`/`format`, so those carry over without a `doc` tag.) As with operations, a `doc` tag earns its place when it says something the field name and type don't: a format hint ("hex, 6 chars"), a read-only note ("set by the server; ignored on write"), units, or allowed values. "The label description." on a `Description` field is filler. See `pkg/models/label.go` for the reference.
|
||||
|
||||
**Mark server-controlled fields `readOnly:"true"`.** Because the same model struct is the request body *and* the response, fields the client can never set — `id`, `created`, `updated`, `created_by`, and similar server-derived relations/IDs — should carry `readOnly:"true"`. Huma reflects this into the OpenAPI schema (`readOnly: true`), so docs and client generators present the field as response-only and drop it from request examples:
|
||||
|
||||
```go
|
||||
ID int64 `json:"id" readOnly:"true" doc:"The unique, numeric id of this label."`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this label."`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was created. You cannot change this value."`
|
||||
```
|
||||
|
||||
The tag is **documentation only** — Huma does *not* reject these fields if a client sends them on create/update. Actual immutability still comes from the model layer (XORM-managed `created`/`updated`, `created_by` being `xorm:"-"` and set server-side). It's also harmless on v1 (swaggo/XORM/govalidator ignore it). Don't bother tagging fields that are already `json:"-"` (absent from the schema entirely), and skip it on response-only structs like the error model — there it's cosmetic since they never appear as a request body. See `pkg/models/label.go` and `pkg/user/user.go`.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create `pkg/routes/api/v2/<resource>.go`
|
||||
|
||||
Define the list-response body, a `Register<Resource>Routes(api huma.API)` function, and one handler per operation. Mirror `labels.go` exactly:
|
||||
|
||||
```go
|
||||
// Element type matches what models.<Model>.ReadAll returns; extra fields
|
||||
// tagged json:"-" keep the wire shape identical to the plain model.
|
||||
type fooListBody struct {
|
||||
Body Paginated[*models.Foo]
|
||||
}
|
||||
|
||||
func RegisterFooRoutes(api huma.API) {
|
||||
tags := []string{"foos"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "foos-list",
|
||||
Summary: "List foos",
|
||||
Description: "Returns the foos the authenticated user has access to, paginated.",
|
||||
Method: http.MethodGet, Path: "/foos", Tags: tags,
|
||||
}, foosList)
|
||||
Register(api, huma.Operation{OperationID: "foos-read", Summary: "Get a foo", Description: "...", Method: http.MethodGet, Path: "/foos/{id}", Tags: tags}, foosRead)
|
||||
Register(api, huma.Operation{OperationID: "foos-create", Summary: "Create a foo", Description: "...", Method: http.MethodPost, Path: "/foos", Tags: tags}, foosCreate)
|
||||
Register(api, huma.Operation{OperationID: "foos-update", Summary: "Update a foo", Description: "...", Method: http.MethodPut, Path: "/foos/{id}", Tags: tags}, foosUpdate)
|
||||
Register(api, huma.Operation{OperationID: "foos-delete", Summary: "Delete a foo", Description: "...", Method: http.MethodDelete, Path: "/foos/{id}", Tags: tags}, foosDelete)
|
||||
}
|
||||
```
|
||||
|
||||
Use the package's `Register` wrapper, **not** `huma.Register` directly — it sets `DefaultStatus` from the verb (POST → 201, DELETE → 204). Don't spell out `DefaultStatus` unless you need a non-default code. Don't set `Security:` per operation — it's applied globally in `NewAPI`.
|
||||
|
||||
**Every operation needs a `Summary` and `Description`.** v2's OpenAPI spec is generated from these `Operation` fields at runtime — unlike v1's swaggo, Huma cannot read Go doc comments, so anything you don't put in the `Operation` (or in a `doc:` tag, see below) is simply absent from the spec and the docs UI. An operation without them ships undocumented.
|
||||
|
||||
**Make the description document the non-obvious — don't restate the verb+noun.** "Deletes a label" adds nothing over `DELETE /labels/{id}`. Spend the description on what a consumer *can't* infer from the method/path/schema: permission scope ("only the owner may delete it"; "returns only labels you can see, not a global list"), full-replace vs partial (PUT replaces, PATCH merges), read-only/conditional behavior (ETag → `If-None-Match` → 304), side effects (create sets ownership), non-obvious status codes. If the honest description is just the verb+noun, a short summary alone is fine — don't pad. See `labels.go` for the calibration.
|
||||
|
||||
### 2. Write the handlers
|
||||
|
||||
Every handler: pull auth with `authFromCtx(ctx)`, call the matching `handler.Do*`, wrap returned errors in `translateDomainError`. Use the shared envelopes from `types.go` (`singleBody`, `singleReadBody`, `emptyBody`, `ListParams`, `Paginated`/`NewPaginated`).
|
||||
|
||||
- **List** takes `*ListParams` (gives you `page`/`per_page`/`q` for free, already `doc:`-tagged in `types.go` — no need to re-document them) and returns `*fooListBody`. **You must type-assert the `DoReadAll` result to the concrete slice** — `result` is `any`, and a blind cast or a generic wrapper silently serialises `[]` (the "generic-any silent-empty trap"). Return a hard error on mismatch:
|
||||
```go
|
||||
items, ok := result.([]*models.Foo)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("foos.ReadAll returned unexpected type %T", result)
|
||||
}
|
||||
return &fooListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
|
||||
```
|
||||
- **Extra query params go *directly* on the handler's input struct — not in a shared/embedded helper.** Beyond `ListParams`, if an operation needs its own query params (`expand`, `order_by`, `include_public`, …), declare each as a direct field with its own `query:"…"` tag on that operation's input struct, then bind it onto the model. A shared or embedded struct of query fields silently **fails to bind** under Huma when combined with other query params/embeds — the field arrives empty (hit while implementing Project's `expand`). Flatten them into the input struct.
|
||||
- **Read** embeds `conditional.Params` in its input. To surface the caller's permission, define a small per-resource response struct that **embeds the model by value** and adds the permission: `type fooReadBody struct { models.Foo; MaxPermission models.Permission \`json:"max_permission" readOnly:"true" doc:"..."\` }`. Go and Huma both promote the embedded model's fields, so the wire shape is flat (model fields + `max_permission`) with no custom marshaler and nothing added to the shared model struct. Capture `DoReadOne`'s returned max permission (it is `0`/`1`/`2` on success — **never discard it as `_`**), build the body, and `return conditionalReadResponse(&in.Params, body, foo.Updated, maxPermission)`. The shared helper (in `types.go`) folds the permission into the ETag (so a share/role change invalidates the cache), applies the conditional precondition (304/412), and returns `*singleReadBody[fooReadBody]`. See `labels.go`/`project_views.go`. (A generic `struct{ T; ... }` is impossible — Go forbids embedding a type parameter — so the per-resource struct is the price of a flat shape without a marshaler.)
|
||||
- **Create / Update** return `*singleBody[Model]` and set the model's `ID` from the path (URL wins over body). **Update's request body must be the same `fooReadBody` the read returns, not the bare model** — AutoPatch's GET→PUT round trip echoes the read body (max_permission included) into the PUT, and because `max_permission` is a declared `readOnly` property of `fooReadBody`'s schema, Huma accepts and ignores it on write rather than rejecting it. Take `&in.Body.Foo` (the embedded model — value-embedded, so never nil) and ignore the embedded `MaxPermission`. Create stays a bare `Body Model` (AutoPatch only round-trips into PUT).
|
||||
- **Delete** returns `*emptyBody`.
|
||||
|
||||
### 3. Self-register the resource
|
||||
|
||||
Resources self-register — **you do not edit `pkg/routes/routes.go`**. In your resource file, add an `init()` that hands your registrar to `AddRouteRegistrar`:
|
||||
|
||||
```go
|
||||
func init() { AddRouteRegistrar(RegisterFooRoutes) }
|
||||
|
||||
func RegisterFooRoutes(api huma.API) { ... }
|
||||
```
|
||||
|
||||
`registerAPIRoutesV2` in `routes.go` calls `apiv2.RegisterAll(api)`, which runs every registered registrar (in init/filename order — route order is irrelevant) and then `EnableAutoPatch`. New resources touch zero shared lines, so they never conflict on `routes.go`.
|
||||
|
||||
Notes:
|
||||
|
||||
- **Give each registrar a DISTINCT name.** They share package `apiv2`, so two resources both exporting `RegisterAvatarRoutes` collide and won't compile — that actually happened and the upload one had to be renamed (`RegisterAvatarRoutes` for the binary endpoint vs `RegisterAvatarUploadRoutes` for the upload). Name yours after the specific resource.
|
||||
- **Config-gated resources check the flag inside the registrar.** `RegisterAll` runs at request-router-setup time, after config is loaded, so a `RegisterFooRoutes` may early-return (or skip individual `Register` calls) based on `config.FooEnabled.GetBool()`. Don't try to gate at `init()` time — config isn't loaded yet.
|
||||
- **AutoPatch is automatic.** `RegisterAll` calls `EnableAutoPatch` after all registrars — don't call it yourself, and don't register a manual PATCH (see "What's automatic").
|
||||
|
||||
## REST verb conventions (v2 inverts v1)
|
||||
|
||||
| Operation | v1 | v2 |
|
||||
|---|---|---|
|
||||
| create | PUT | **POST** |
|
||||
| update | POST | **PUT** (and PATCH) |
|
||||
| read / read-all / delete | GET / GET / DELETE | same |
|
||||
|
||||
## Non-CRUDable / custom routes
|
||||
|
||||
Not everything is plain CRUD — bulk operations, custom actions (`POST /tasks/{id}/duplicate`), sub-resource toggles, RPC-ish endpoints. These still go through Huma and reuse most of the machinery, but two responsibilities move **into your handler** because there's no `handler.Do*` doing them for you:
|
||||
|
||||
1. **Permission enforcement is now yours.** This is the one place the "never check permissions in the handler" rule inverts. With no generic `Do*` to call the model's `Can*`, the handler must do it explicitly — load the relevant entity and call its permission method, then refuse on denial. Mirror the v1 custom-handler shape (`pkg/routes/api/v1/task_attachment.go`):
|
||||
```go
|
||||
func tasksDuplicate(ctx context.Context, in *struct{ ID int64 `path:"id"` }) (*singleBody[models.Task], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
t := &models.Task{ID: in.ID}
|
||||
can, err := t.CanUpdate(s, a) // or whichever Can* gates this action
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if !can {
|
||||
return nil, huma.Error403Forbidden("forbidden")
|
||||
}
|
||||
// ... do the work against s ...
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.Task]{Body: t}, nil
|
||||
}
|
||||
```
|
||||
2. **Session / transaction management is now yours.** The `Do*` helpers open and commit their own `xorm.Session`; custom handlers open one with `db.NewSession()`, `defer s.Close()`, and `Commit`/`Rollback` explicitly for anything that writes.
|
||||
|
||||
Otherwise the same rules apply: register with the `Register` wrapper, pull auth via `authFromCtx`, route every error through `translateDomainError`, and reuse the `types.go` envelopes — or define a small body struct when none fits (don't bend a custom response into `singleBody` if it's awkward).
|
||||
|
||||
**Verb choice:** pick by semantics, not the CRUD table. Non-idempotent actions are `POST`. AutoPatch only synthesises PATCH for GET+PUT *pairs*, so standalone custom routes are never touched.
|
||||
|
||||
**Token permissions still automatic, but mind the derived name:** `collectRoutesForAPITokens` keys a route off its prefix-stripped path, so `POST /api/v2/tasks/{id}/duplicate` lands under the `tasks` group as a `duplicate` permission. Single-segment custom paths fall into the `other` group. Name the path so the derived `(group, permission)` reads sensibly — that string is what users grant tokens against.
|
||||
|
||||
## What's automatic — do NOT hand-roll
|
||||
|
||||
- **PATCH** — `EnableAutoPatch` synthesises a JSON-Merge-Patch PATCH for every GET+PUT pair. `RegisterAll` invokes it after all registrars, so it's automatic — don't call `EnableAutoPatch` and don't register PATCH yourself.
|
||||
- **API token permissions** — `collectRoutesForAPITokens` walks the Echo router after registration, so your new routes land in the v2 token table automatically under the same `(group, permission)` keys as their v1 names. PATCH is intentionally not stored; `CanDoAPIRoute` accepts it as an alias for the stored PUT (see `pkg/models/api_routes.go`).
|
||||
- **Security schemes** — `JWTKeyAuth` + `APITokenAuth` are declared globally in `NewAPI`. For a public endpoint, set `Security: []map[string][]string{}` on that operation and add its path to `unauthenticatedAPIPaths` in `routes.go`.
|
||||
- **Error shape** — `translateDomainError` maps any `web.HTTPErrorProcessor` (e.g. `ErrFooDoesNotExist`) onto Huma's status error, producing RFC 9457 `application/problem+json`. Errors without HTTP semantics become 500.
|
||||
- **OpenAPI spec / Scalar docs / `$schema` URLs** — handled in `huma.go`. Leave `Servers` alone (the relative entry must stay at index 0).
|
||||
|
||||
## Anti-patterns (these get flagged)
|
||||
|
||||
- Re-checking permissions in the handler instead of trusting `handler.Do*` → the model's `Can*`.
|
||||
- Blind `result.([]*models.Foo)` without the `ok` check, or returning the `any` straight into the envelope — silent empty lists.
|
||||
- `huma.Register` instead of the package `Register` wrapper (loses the verb-based status).
|
||||
- Per-operation `Security:` lines (now global) or registering a manual PATCH (AutoPatch does it).
|
||||
- Returning a raw model error instead of routing it through `translateDomainError` → leaks a 500 instead of the right code.
|
||||
- Unquoted ETag in the response header.
|
||||
- Operations without `Summary`/`Description`, or model fields without `doc:` tags — they ship undocumented because Huma can't read Go comments.
|
||||
- Server-controlled fields (`id`, `created`, `updated`, `created_by`) on a shared input/output model left without `readOnly:"true"` — the docs then present them as writable request fields.
|
||||
|
||||
## Tests (mandatory)
|
||||
|
||||
Mirror the v1 webtest shape so v2 parity is readable side-by-side. Use the `webHandlerTestV2` harness in `pkg/webtests/integrations.go` — it takes the same `urlParams` map as v1's `webHandlerTest`. See `pkg/webtests/huma_label_test.go`:
|
||||
|
||||
- One `Test<Resource>` covering list/read/create/update/delete, positive + negative (forbidden, nonexistent), mirroring the v1 model test.
|
||||
- v2-only behaviour (ETag/304, PATCH merge-patch) goes in separate top-level `Test<Resource>_*` funcs using the `humaRequest`/`humaTokenFor` helpers in `pkg/webtests/huma_helpers_test.go`.
|
||||
- The RFC 9457 error-body shape is asserted **once** globally in `TestHuma_ErrorShapeIsRFC9457` — don't re-assert the full problem+json shape per resource, just the status code.
|
||||
|
||||
Run with `mage test:filter Test<Resource>` while iterating. **Caveat:** `mage test:filter` injects `-short`, which makes `pkg/webtests` skip entirely (the suite short-circuits in short mode), so it silently reports success without running your webtest. To actually exercise a single webtest, run it directly: `go test -run '<Name>' ./pkg/webtests/`. Save output to a file per the project test-output rule.
|
||||
|
||||
## Related
|
||||
|
||||
- `crudable` skill — the model-layer prerequisite
|
||||
- `pkg/routes/api/v2/labels.go` — reference resource
|
||||
- `pkg/routes/api/v2/{types,errors,huma}.go` — shared envelopes, bridge, config
|
||||
- `pkg/web/handler/core.go` — the `Do*` functions handlers call
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
---
|
||||
name: veans
|
||||
description: Use when tracking tasks in Vikunja via the veans CLI in a repo that has a .veans.yml — claiming/creating/updating tasks, the claim→work→in-review workflow, writing HTML task descriptions and comments, and reading veans JSON output. Replaces TodoWrite for task tracking in these repos.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Tracking work in Vikunja via veans
|
||||
|
||||
`veans` is a CLI that wraps Vikunja's REST API with an agent-friendly surface (the tool lives in `veans/` in this monorepo). In any repo that has a `.veans.yml` at its root, **track your work in Vikunja with `veans` instead of `TodoWrite`** — tasks then stay visible across sessions and to the humans collaborating on the project.
|
||||
|
||||
If there is no `.veans.yml` reachable upward from the working directory, this skill does not apply — fall back to `TodoWrite`.
|
||||
|
||||
## First: prime yourself with the repo's config
|
||||
|
||||
Run this once at the start to get the repo-specific project, view, bucket IDs, and your bot identity:
|
||||
|
||||
```sh
|
||||
veans prime
|
||||
```
|
||||
|
||||
`veans prime` emits the canonical agent prompt with this repo's concrete values filled in (project ID, Kanban view ID, bucket IDs, bot username, label namespace). It exits silently with status 0 when no `.veans.yml` is found, so it's always safe to run. Treat its output as authoritative — the rest of this skill is the version-independent summary.
|
||||
|
||||
## Workflow: claim → work → in-review → human closes
|
||||
|
||||
**Before you start work:**
|
||||
- Find something ready: `veans list --ready` (Todo + not blocked).
|
||||
- If a task exists, claim it: `veans claim <id>` — assigns you the bot, moves it to In Progress, and tags it with the current git branch.
|
||||
- Otherwise create and start in one step: `veans create "<short title>" -s in-progress -d "<p>HTML description</p>"`.
|
||||
|
||||
**While you work:**
|
||||
- Keep the description in sync. Append a step list, or check items off with surgical replaces:
|
||||
```sh
|
||||
veans update <id> --description-append '<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>step 1</p></li></ul>'
|
||||
veans update <id> --description-replace-old 'data-checked="false"><p>step 1</p>' --description-replace-new 'data-checked="true"><p>step 1</p>'
|
||||
```
|
||||
- Comment on significant decisions or course-changes: `veans update <id> --comment '<p>Discovered Y; pivoting to Z because …</p>'`.
|
||||
- For work that could be assigned separately, create real subtasks with `--parent <id>`. For incremental checklists, use task-list items in the description instead.
|
||||
|
||||
**After you finish work:**
|
||||
- Move to `in-review` with a summary comment. **Never move a task to `completed` yourself** — a human or the merge hook closes it once the PR lands.
|
||||
```sh
|
||||
veans update <id> -s in-review --comment '<h3>Summary of changes</h3><ul><li>first thing</li><li>second thing</li></ul>'
|
||||
```
|
||||
- If you abandon the work, scrap it with a reason: `veans update <id> -s scrapped --reason "obsolete: <why>"`.
|
||||
|
||||
**Commit messages:** include the task identifier on a `Refs:` line so the merge hook can auto-close on merge:
|
||||
|
||||
```
|
||||
fix: handle empty project identifiers
|
||||
|
||||
Refs: PROJ-12
|
||||
```
|
||||
|
||||
## Status model
|
||||
|
||||
| Status | Bucket | Done | Who moves it there |
|
||||
| ------------- | ----------- | ---- | ---------------------------------------- |
|
||||
| `todo` | Todo | no | created here by default |
|
||||
| `in-progress` | In Progress | no | `veans claim` or `update -s in-progress` |
|
||||
| `in-review` | In Review | no | you, when work is finished |
|
||||
| `completed` | Done | yes | **humans / merge hook only** |
|
||||
| `scrapped` | Scrapped | yes | you, with `--reason` |
|
||||
|
||||
## Descriptions and comments are HTML — not markdown
|
||||
|
||||
Vikunja renders these fields through the TipTap editor, which stores HTML. Markdown saves as literal text and looks broken in the UI. Write HTML directly. **Titles, however, are plaintext** — no tags, no markdown (they leak into list views and notifications as escaped entities).
|
||||
|
||||
Canonical TipTap shapes that render cleanly:
|
||||
|
||||
```html
|
||||
<h2>Summary</h2>
|
||||
<p>Short paragraph.</p>
|
||||
|
||||
<h3>Steps</h3>
|
||||
<ul data-type="taskList">
|
||||
<li data-type="taskItem" data-checked="false"><p>find the bug</p></li>
|
||||
<li data-type="taskItem" data-checked="true"><p>write the test</p></li>
|
||||
</ul>
|
||||
|
||||
<ul><li>plain bullet</li></ul>
|
||||
<p>Inline <code>code</code>, <strong>bold</strong>, <a href="https://example.com">link</a>.</p>
|
||||
<pre><code class="language-go">if err != nil { return err }</code></pre>
|
||||
<blockquote><p>A quote.</p></blockquote>
|
||||
```
|
||||
|
||||
Rules that bite:
|
||||
- Interactive checkboxes **require** `<ul data-type="taskList">` + `<li data-type="taskItem" data-checked="true|false">`. Plain `<ul><li>` renders as static bullets.
|
||||
- Inner text of a task item must sit inside `<p>` — the editor expects block content in the `<li>`.
|
||||
- Don't add `data-task-id` attributes; the editor auto-fills them on first save.
|
||||
- Escape literal `<`, `>`, `&` as `<`, `>`, `&` (including inside `<pre><code>`).
|
||||
- `--description-replace-old` matches raw HTML byte-for-byte. Make the `old` string unique by including surrounding tags, or it errors (same semantics as the Edit tool).
|
||||
|
||||
## Output: always JSON
|
||||
|
||||
Every `list`, `show`, `create`, `update`, `claim`, and `api` call emits JSON on stdout — no `--json` flag, no human-formatted variant. `list` returns an array; the others return a single task. Parse it.
|
||||
|
||||
Errors land on **stderr** as `{"code":"...","error":"..."}` with a non-zero exit. Branch on the stable `code`: `NOT_FOUND`, `CONFLICT`, `VALIDATION_ERROR`, `AUTH_ERROR`, `RATE_LIMITED`, `NOT_CONFIGURED`, `BOT_USERS_UNAVAILABLE`, `UNKNOWN`.
|
||||
|
||||
Useful task fields: `id` (numeric, internal — pass to `api`), `index` (per-project number behind `PROJ-NN`), `title`, `description` (HTML), `done`, `priority`, `buckets[]` (current bucket per view — match `project_view_id` to yours from `.veans.yml`), `assignees[]`, `labels[]`.
|
||||
|
||||
Task IDs accept `PROJ-NN`, `#NN` (when the project has no identifier), or a bare integer.
|
||||
|
||||
## Common commands
|
||||
|
||||
```sh
|
||||
veans list # all tasks, tree view
|
||||
veans list --ready # Todo + not blocked
|
||||
veans list --mine # assigned to you
|
||||
veans list --branch # tagged with the current git branch
|
||||
veans list --filter "priority > 3" # raw Vikunja filter expression
|
||||
veans show <id> # full task detail
|
||||
|
||||
veans create "title" -s in-progress -d "<p>HTML body</p>"
|
||||
veans create "title" --label bug --priority 4 --parent <id>
|
||||
veans create "title" --blocked-by <id>
|
||||
|
||||
veans update <id> -s in-review --comment '<p>Summary…</p>'
|
||||
veans update <id> --label-add bug --label-remove flaky
|
||||
veans update <id> --description-append '<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>new step</p></li></ul>'
|
||||
veans update <id> -s scrapped --reason "obsolete: replaced by PROJ-9"
|
||||
veans update <id> --if-unchanged-since <ts> # optimistic concurrency; CONFLICT if changed
|
||||
|
||||
veans claim <id> # assign yourself + In Progress + branch label
|
||||
veans api GET /tasks/123 # raw REST escape hatch for endpoints not wrapped here
|
||||
```
|
||||
|
||||
Labels live under the `veans:` namespace (auto-prepended, so `--label bug` becomes `veans:bug`); branch labels are `veans:branch:<branch>`, which `veans claim` adds automatically.
|
||||
|
||||
## Setup is a human task
|
||||
|
||||
`veans init` (bootstrap a repo: pick project + view, create the bot, mint a token, write `.veans.yml`, install hooks) and `veans login` (rotate the bot token) are run by a human, not an agent. If `veans` reports `NOT_CONFIGURED`, ask the human to run `veans init` rather than attempting it yourself.
|
||||
3
.envrc
3
.envrc
|
|
@ -1,3 +0,0 @@
|
|||
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
|
||||
|
||||
use devenv
|
||||
|
|
@ -79,7 +79,7 @@ runs:
|
|||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download Mage binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ runs:
|
|||
|
||||
- name: Download frontend dist (vikunja only)
|
||||
if: inputs.project == 'vikunja'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
|
@ -110,7 +110,7 @@ runs:
|
|||
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
||||
|
||||
- name: Setup xgo cache
|
||||
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
|
||||
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0
|
||||
with:
|
||||
path: /home/runner/.xgo-cache
|
||||
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
|
||||
|
|
@ -133,7 +133,7 @@ runs:
|
|||
cd build && mage release:build "$PROJECT"
|
||||
|
||||
- name: GPG setup
|
||||
uses: kolaente/action-gpg@main
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
|
@ -164,7 +164,7 @@ runs:
|
|||
done
|
||||
|
||||
- name: Upload zips to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
|
|
@ -176,14 +176,14 @@ runs:
|
|||
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
|
||||
|
||||
- name: Store binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_BINARIES_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/binaries/*
|
||||
|
||||
- name: Store binary packages
|
||||
if: github.ref_type == 'tag'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_ZIPS_NAME }}
|
||||
path: ./${{ env.DIST_PREFIX }}/zip/*
|
||||
|
|
|
|||
|
|
@ -91,12 +91,12 @@ runs:
|
|||
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download project binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: ${{ env.BINARIES_ARTIFACT_NAME }}
|
||||
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
|
||||
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ runs:
|
|||
|
||||
- name: GPG setup for archlinux signing
|
||||
if: inputs.packager == 'archlinux'
|
||||
uses: kolaente/action-gpg@main
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||
|
|
@ -163,7 +163,7 @@ runs:
|
|||
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
|
||||
|
||||
- name: Create package
|
||||
uses: kolaente/action-gh-nfpm@master
|
||||
uses: kolaente/action-gh-nfpm@08460c16ce3baaa48eaf94d51eea0e653b15d955 # master
|
||||
with:
|
||||
packager: ${{ inputs.packager }}
|
||||
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
|
||||
|
|
@ -186,7 +186,7 @@ runs:
|
|||
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
|
||||
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||
|
|
@ -198,7 +198,7 @@ runs:
|
|||
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
|
||||
|
||||
- name: Store OS package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ runs:
|
|||
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
||||
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
||||
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
package_json_file: frontend/package.json
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: 'pnpm'
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (for prompt template)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/workflows/auto-label.prompt.md
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
|
||||
- name: Render system prompt from live labels
|
||||
id: render
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
|
||||
with:
|
||||
|
|
@ -122,7 +122,7 @@ jobs:
|
|||
|
||||
- name: Classify with AI
|
||||
id: classify
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4.1-mini
|
||||
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
|
||||
|
|
@ -132,7 +132,7 @@ jobs:
|
|||
prompt-file: ${{ steps.prep.outputs.prompt_path }}
|
||||
|
||||
- name: Apply labels
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
AI_RESPONSE: ${{ steps.classify.outputs.response }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: push source files
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
command: 'push'
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: pull translations
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
command: 'download'
|
||||
command_args: '--export-only-approved --skip-untranslated-strings'
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Ensure file permissions
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
git commit -m "chore(i18n): update translations via Crowdin"
|
||||
- name: Push changes
|
||||
if: steps.check_changes.outputs.changes_exist != '0'
|
||||
uses: ad-m/github-push-action@master
|
||||
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Create Diff
|
||||
uses: e18e/action-dependency-diff@v1
|
||||
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1
|
||||
with:
|
||||
working-directory: ${{ matrix.directory }}
|
||||
|
||||
|
|
@ -33,11 +33,11 @@ jobs:
|
|||
directory: [frontend, desktop]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check provenance downgrades
|
||||
uses: danielroe/provenance-action@main
|
||||
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main
|
||||
with:
|
||||
workspace-path: ${{ matrix.directory }}
|
||||
fail-on-provenance-change: true
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ jobs:
|
|||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
||||
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Find closing PR or commit
|
||||
id: find-closer
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
|
||||
- name: Comment on issue
|
||||
if: steps.find-closer.outputs.closed_by_code == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
docker-images: false
|
||||
swap-storage: false
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# For pull_request_target, we need to explicitly fetch the PR ref from forks
|
||||
# since the PR's commit SHA is not reachable in the base repository.
|
||||
|
|
@ -34,27 +34,27 @@ jobs:
|
|||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
with:
|
||||
version: latest
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=sha,format=long
|
||||
- name: Build and push PR image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
build-args: |
|
||||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
name: prepare-build-mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache build mage
|
||||
id: cache-build-mage
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
|
||||
path: |
|
||||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
export PATH=$PATH:$GOPATH/bin
|
||||
mage -compile ./build-mage-static
|
||||
- name: Store build mage binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: ./build/build-mage-static
|
||||
|
|
@ -43,14 +43,14 @@ jobs:
|
|||
steps:
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
- name: Docker meta version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
vikunja/vikunja
|
||||
|
|
@ -70,7 +70,7 @@ jobs:
|
|||
type=raw,value=latest
|
||||
- name: Build and push unstable
|
||||
if: ${{ github.ref_type != 'tag' }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -81,7 +81,7 @@ jobs:
|
|||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||
- name: Build and push version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
push: true
|
||||
|
|
@ -93,10 +93,10 @@ jobs:
|
|||
binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
with:
|
||||
project: vikunja
|
||||
|
|
@ -112,10 +112,10 @@ jobs:
|
|||
veans-binaries:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-binaries
|
||||
with:
|
||||
project: veans
|
||||
|
|
@ -147,10 +147,10 @@ jobs:
|
|||
pkg: armv7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
with:
|
||||
project: vikunja
|
||||
|
|
@ -186,10 +186,10 @@ jobs:
|
|||
pkg: armv7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- uses: ./.github/actions/release-os-package
|
||||
with:
|
||||
project: veans
|
||||
|
|
@ -235,19 +235,19 @@ jobs:
|
|||
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
|
||||
RELEASE_VERSION: unstable
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Download build mage binary
|
||||
# Statically compiled in test.yml's build-mage job so it runs inside
|
||||
# ubuntu/fedora/archlinux containers without a Go toolchain.
|
||||
if: matrix.format != 'apk'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: build_mage_bin
|
||||
path: build
|
||||
|
||||
- name: Download all server OS packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
|
|
@ -257,14 +257,14 @@ jobs:
|
|||
# Merged into the same incoming dir so reprepro / createrepo_c /
|
||||
# repo-add / the apk loop pick them up alongside vikunja's packages
|
||||
# — same suite, same arch fan-out, no extra source entry for users.
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
path: dist/repo-work/incoming
|
||||
|
||||
- name: Download desktop packages (Linux)
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
path: dist/repo-work/incoming-desktop
|
||||
|
|
@ -309,7 +309,7 @@ jobs:
|
|||
|
||||
- name: GPG setup
|
||||
if: matrix.format != 'apk'
|
||||
uses: kolaente/action-gpg@main
|
||||
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||
with:
|
||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||
|
|
@ -384,7 +384,7 @@ jobs:
|
|||
find dist/repo-output -type d -empty -delete 2>/dev/null || true
|
||||
|
||||
- name: Upload to R2
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -398,12 +398,12 @@ jobs:
|
|||
config-yaml:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: generate
|
||||
|
|
@ -411,7 +411,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static generate:config-yaml 1
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -431,16 +431,16 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
package_json_file: desktop/package.json
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
cache: pnpm
|
||||
|
|
@ -451,7 +451,7 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
|
||||
- name: get frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: frontend/dist
|
||||
|
|
@ -461,7 +461,7 @@ jobs:
|
|||
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
|
||||
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
|
||||
- name: Upload to S3
|
||||
uses: kolaente/s3-action@main
|
||||
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||
with:
|
||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
|
@ -473,7 +473,7 @@ jobs:
|
|||
strip-path-prefix: desktop/dist/
|
||||
exclude: "desktop/dist/*.blockmap"
|
||||
- name: Store Desktop Package
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_${{ matrix.os }}
|
||||
path: |
|
||||
|
|
@ -486,16 +486,16 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
persist-credentials: true
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: generate
|
||||
|
|
@ -520,7 +520,7 @@ jobs:
|
|||
git commit -am "[skip ci] Updated swagger docs"
|
||||
- name: Push changes
|
||||
if: steps.check_changes.outputs.changes_exist != '0'
|
||||
uses: ad-m/github-push-action@master
|
||||
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||
with:
|
||||
ssh: true
|
||||
branch: ${{ github.ref }}
|
||||
|
|
@ -539,44 +539,44 @@ jobs:
|
|||
contents: write
|
||||
steps:
|
||||
- name: Download Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin_packages
|
||||
|
||||
- name: Download OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: vikunja_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Veans Binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: veans_bin_packages
|
||||
|
||||
- name: Download Veans OS Packages
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
pattern: veans_os_package_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Desktop Package Linux
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_ubuntu-latest
|
||||
|
||||
- name: Download Desktop Package MacOS
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_macos-latest
|
||||
|
||||
- name: Download Desktop Package Windows
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_desktop_packages_windows-latest
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
draft: true
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
with:
|
||||
only-labels: 'waiting for reply'
|
||||
days-before-issue-stale: 30
|
||||
|
|
@ -24,6 +24,7 @@ jobs:
|
|||
questions. If you're still seeing this on a recent version, just
|
||||
drop a comment with the requested info and we'll reopen. Thanks
|
||||
for the report!
|
||||
days-before-pr-stale: -1
|
||||
stale-pr-label: 'waiting for reply'
|
||||
days-before-pr-stale: 30
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 100
|
||||
|
|
|
|||
|
|
@ -8,26 +8,26 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
name: prepare-mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Cache Mage
|
||||
id: cache-mage
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
|
||||
path: |
|
||||
./mage-static
|
||||
- name: Compile Mage
|
||||
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
|
||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
|
||||
with:
|
||||
version: latest
|
||||
args: -compile ./mage-static
|
||||
- name: Store Mage Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
path: ./mage-static
|
||||
|
|
@ -36,16 +36,16 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Build
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
chmod +x ./mage-static
|
||||
./mage-static build
|
||||
- name: Store Vikunja Binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
path: ./vikunja
|
||||
|
|
@ -65,8 +65,8 @@ jobs:
|
|||
api-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: prepare frontend files
|
||||
|
|
@ -74,19 +74,19 @@ jobs:
|
|||
mkdir -p frontend/dist
|
||||
touch frontend/dist/index.html
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.10.1
|
||||
|
||||
veans-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||
with:
|
||||
version: v2.10.1
|
||||
working-directory: veans
|
||||
|
|
@ -94,8 +94,8 @@ jobs:
|
|||
veans-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
|
|
@ -115,9 +115,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Check
|
||||
|
|
@ -152,7 +152,7 @@ jobs:
|
|||
ports:
|
||||
- 3306:3306
|
||||
migration-smoke-db-postgres:
|
||||
image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
|
||||
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20
|
||||
env:
|
||||
POSTGRES_PASSWORD: vikunjatest
|
||||
POSTGRES_DB: vikunjatest
|
||||
|
|
@ -164,7 +164,7 @@ jobs:
|
|||
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip
|
||||
unzip vikunja-latest.zip vikunja-unstable-linux-amd64
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: run migration
|
||||
|
|
@ -254,13 +254,13 @@ jobs:
|
|||
ports:
|
||||
- 389:389
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Configure Postgres for faster tests
|
||||
|
|
@ -300,13 +300,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -321,13 +321,13 @@ jobs:
|
|||
needs:
|
||||
- mage
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test
|
||||
|
|
@ -351,13 +351,13 @@ jobs:
|
|||
ports:
|
||||
- 9000:9000
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Mage Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: mage_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: test S3 file storage integration
|
||||
|
|
@ -382,7 +382,7 @@ jobs:
|
|||
frontend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
|
|
@ -391,7 +391,7 @@ jobs:
|
|||
frontend-stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Lint styles
|
||||
working-directory: frontend
|
||||
|
|
@ -400,7 +400,7 @@ jobs:
|
|||
frontend-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Typecheck
|
||||
continue-on-error: true
|
||||
|
|
@ -410,7 +410,7 @@ jobs:
|
|||
test-frontend-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Run unit tests
|
||||
working-directory: frontend
|
||||
|
|
@ -419,11 +419,11 @@ jobs:
|
|||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
- name: Git describe
|
||||
id: ghd
|
||||
uses: proudust/gh-describe@v2
|
||||
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||
- name: Inject frontend version
|
||||
working-directory: frontend
|
||||
run: |
|
||||
|
|
@ -432,7 +432,7 @@ jobs:
|
|||
working-directory: frontend
|
||||
run: pnpm build
|
||||
- name: Store Frontend
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
|
@ -442,13 +442,13 @@ jobs:
|
|||
needs:
|
||||
- api-build
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Install mage
|
||||
|
|
@ -501,7 +501,7 @@ jobs:
|
|||
(cd veans && mage test:e2e)
|
||||
- name: Upload API log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: veans-e2e-vikunja-log
|
||||
path: /tmp/vikunja.log
|
||||
|
|
@ -523,19 +523,19 @@ jobs:
|
|||
ports:
|
||||
- 5556:5556
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy@sha256:4698a73749c5848d3f5fcd42a2174d172fcad2b2283e087843b115424303a565
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-jammy@sha256:7b86926fff94374389e8e1f4fdc5c76d050d4a06a7886bb537bf412b20e2b71e
|
||||
options: --user 1001
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download Vikunja Binary
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: vikunja_bin
|
||||
- uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
install-e2e-binaries: false # Playwright browsers already in container
|
||||
- name: Download Frontend
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: frontend_dist
|
||||
path: ./frontend/dist
|
||||
|
|
@ -570,14 +570,14 @@ jobs:
|
|||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
|
||||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.shard }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-test-results-${{ matrix.shard }}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ docs/resources/
|
|||
pkg/static/templates_vfsdata.go
|
||||
files/
|
||||
!pkg/files/
|
||||
!pkg/web/files/
|
||||
vikunja-dump*
|
||||
vendor/
|
||||
os-packages/
|
||||
|
|
|
|||
|
|
@ -145,6 +145,13 @@ linters:
|
|||
- revive
|
||||
path: pkg/utils/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- revive
|
||||
path: pkg/routes/api/shared/*
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- contextcheck
|
||||
path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context
|
||||
- linters:
|
||||
- revive
|
||||
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -11,12 +11,24 @@ The project consists of:
|
|||
- `desktop/` – Electron wrapper application
|
||||
- `docs/` – Documentation website
|
||||
|
||||
## API Version Policy — new work goes to /api/v2
|
||||
|
||||
**`/api/v1` is effectively deprecated and frozen.** It still runs and is fully supported for existing clients, but it should not grow.
|
||||
|
||||
- **Every new route goes on `/api/v2`** (the Huma-backed API in `pkg/routes/api/v2/`). This includes new CRUDable entities, new custom/non-CRUD endpoints, and new actions on existing resources.
|
||||
- **Before adding any v2 route, invoke the `api-v2-routes` skill** — it covers both CRUD and non-CRUD shapes.
|
||||
- **Touch `/api/v1` only to:** fix a bug, or port an existing resource to v2. Do not add net-new functionality there.
|
||||
- Models in `pkg/models/` are shared by both APIs — a new entity still gets its model + `Can*` methods (invoke `crudable`); only the HTTP surface differs (v2, not v1).
|
||||
|
||||
If a task says "add an endpoint for X" without naming a version, it means v2.
|
||||
|
||||
## Skills
|
||||
|
||||
Before writing code in these areas, invoke the matching skill with the `Skill` tool. They are short checklists derived from recurring review feedback — loading them up front avoids rework.
|
||||
|
||||
- Adding or modifying a model in `pkg/models/` (new CRUD, new or changed `Can*` methods, anything touching permissions): invoke `crudable`.
|
||||
- Creating or editing any file under `pkg/migration/`: invoke `migration`.
|
||||
- Adding **any** new API route (new entity, custom action, or porting from v1) — all new routes go on the Huma-backed `/api/v2`, editing `pkg/routes/api/v2/`: invoke `api-v2-routes`. See the API Version Policy above.
|
||||
|
||||
## Plans and Worktrees
|
||||
|
||||
|
|
@ -172,11 +184,10 @@ Modern Vue 3 composition API application with TypeScript:
|
|||
### Adding New Features
|
||||
|
||||
**Backend Changes:**
|
||||
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required
|
||||
2. Add database migration if needed: `mage dev:make-migration <StructName>`
|
||||
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required (invoke the `crudable` skill)
|
||||
2. Add database migration if needed: `mage dev:make-migration <StructName>` (invoke the `migration` skill)
|
||||
3. Create/update services in `pkg/services/` for complex business logic
|
||||
4. Add API routes in `pkg/routes/api/v1/` following existing patterns
|
||||
5. Update Swagger annotations
|
||||
4. Add API routes on **`/api/v2`** in `pkg/routes/api/v2/` — invoke the `api-v2-routes` skill. Do **not** add new routes to `/api/v1`; it is frozen (see API Version Policy above)
|
||||
|
||||
**Frontend Changes:**
|
||||
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
|
||||
|
|
@ -192,10 +203,11 @@ Modern Vue 3 composition API application with TypeScript:
|
|||
4. Update TypeScript interfaces in frontend `src/modelTypes/`
|
||||
|
||||
### API Development
|
||||
- All API endpoints follow RESTful conventions under `/api/v1/`
|
||||
- Use generic web handlers in `pkg/web/handler/` for standard CRUD operations
|
||||
- Implement proper permissions checking using the Permissions interface
|
||||
- Add Swagger annotations for automatic documentation generation
|
||||
- **New endpoints go on `/api/v2`** (Huma-backed, `pkg/routes/api/v2/`). `/api/v1` is frozen — see the API Version Policy near the top. Invoke the `api-v2-routes` skill before writing v2 routes.
|
||||
- v2 verb conventions differ from v1: POST creates, PUT/PATCH update (v1 used PUT to create, POST to update).
|
||||
- Both versions reuse the generic `pkg/web/handler/` `Do*` functions for standard CRUD, which enforce permissions via the model's `Can*` methods.
|
||||
- Implement permission checks at the model level via the Permissions interface — never in the route handler (the exception: non-CRUD v2 actions must call `Can*` explicitly; the skill covers this).
|
||||
- v2 generates its OpenAPI spec from Go types automatically — no Swagger annotations. v1's swaggo annotations stay as-is but no new ones are needed.
|
||||
|
||||
### Testing
|
||||
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
|
||||
|
|
@ -250,6 +262,8 @@ In the frontend, all translation strings live in `frontend/src/i18n/lang`. For t
|
|||
You only need to adjust the `en.json` file with the source string. The actual translation happens elsewhere.
|
||||
After adjusting the source string, you need to call the respective translation library with the key. Both are similar, check the existing code to figure it out.
|
||||
|
||||
**Do not add a new language from scratch or translate strings into other languages yourself.** Translations are managed through a dedicated workflow. If you are asked to add a new language, translate existing strings, or update translations for non-English locales, point the user to the translation guide instead: https://vikunja.io/docs/translations/
|
||||
|
||||
## Key Files and Conventions
|
||||
|
||||
**Configuration:**
|
||||
|
|
@ -261,12 +275,13 @@ After adjusting the source string, you need to call the respective translation l
|
|||
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
|
||||
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
|
||||
- Follow existing patterns for consistency
|
||||
- **Comments: document the *why*, not the *what* — default to no comment.** Don't write comments that restate the code, a function/struct/field name, or a signature; they're noise the reader skips past (a comment that takes longer to read than the code it describes should be deleted). Only comment a genuinely non-obvious *why* — a gotcha, an invariant, a rejected alternative, a cross-file constraint — in one tight line. Be aggressive about cutting on the first pass, not just when asked.
|
||||
- Before creating a new file, function, or helper, search the codebase (`grep` / `rg`) for existing code that does the same thing. Prefer extending an existing helper over duplicating it. If logic overlaps an existing function significantly, reuse it.
|
||||
|
||||
**Naming Conventions:**
|
||||
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
|
||||
- Vue: PascalCase for components, camelCase for composables
|
||||
- API endpoints: kebab-case in URLs, camelCase in JSON
|
||||
- API endpoints: kebab-case in URLs, snake_case in JSON
|
||||
|
||||
**Permissions and Permissions:**
|
||||
- Always implement Permissions interface for new models
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
|
||||
FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
|
||||
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ COPY frontend/ ./
|
|||
ARG RELEASE_VERSION=dev
|
||||
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder
|
||||
|
||||
RUN go install github.com/magefile/mage@latest && \
|
||||
mv /go/bin/mage /usr/local/go/bin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
module code.vikunja.io/build
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.4
|
||||
|
||||
require github.com/magefile/mage v1.17.2
|
||||
|
|
|
|||
|
|
@ -849,6 +849,11 @@
|
|||
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
|
||||
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
|
||||
},
|
||||
{
|
||||
"key": "groupsyncuseserviceaccount",
|
||||
"default_value": "false",
|
||||
"comment": "If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
|
||||
},
|
||||
{
|
||||
"key": "avatarsyncattribute",
|
||||
"default_value": "",
|
||||
|
|
@ -997,6 +1002,37 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "audit",
|
||||
"comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
|
||||
"children": [
|
||||
{
|
||||
"key": "enabled",
|
||||
"default_value": "false",
|
||||
"comment": "Whether to enable audit logging."
|
||||
},
|
||||
{
|
||||
"key": "logfile",
|
||||
"default_value": "",
|
||||
"comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
|
||||
},
|
||||
{
|
||||
"key": "rotation",
|
||||
"children": [
|
||||
{
|
||||
"key": "maxsizemb",
|
||||
"default_value": "100",
|
||||
"comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
|
||||
},
|
||||
{
|
||||
"key": "maxage",
|
||||
"default_value": "30",
|
||||
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "outgoingrequests",
|
||||
"children": [
|
||||
|
|
|
|||
|
|
@ -100,10 +100,15 @@ app.on('second-instance', (_event, argv) => {
|
|||
return
|
||||
}
|
||||
|
||||
// Focus the main window
|
||||
// Reveal the main window. It may be hidden in the tray (not just minimized),
|
||||
// so show() is required — focus() alone won't surface a hidden window, which
|
||||
// made the app look dead when relaunched while running in the tray.
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else if (serverPort) {
|
||||
createMainWindow()
|
||||
}
|
||||
|
||||
// Find the deep link URL in argv
|
||||
|
|
@ -236,6 +241,11 @@ function createMainWindow() {
|
|||
mainWindow = new BrowserWindow({
|
||||
width: 1680,
|
||||
height: 960,
|
||||
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
|
||||
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
|
||||
// an installed .desktop file. icon.png lives at the app root because
|
||||
// build/ is electron-builder's buildResources dir and isn't packaged.
|
||||
icon: path.join(__dirname, 'icon.png'),
|
||||
webPreferences: {
|
||||
...BASE_WEB_PREFERENCES,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
|
|
@ -543,3 +553,14 @@ app.on('window-all-closed', () => {
|
|||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
|
||||
// handler the app ignores SIGTERM because the tray and express server keep the
|
||||
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
|
||||
// the hide-to-tray close handler doesn't swallow the quit.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"main": "main.js",
|
||||
"repository": "https://code.vikunja.io/desktop",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"author": {
|
||||
"email": "maintainers@vikunja.io",
|
||||
"name": "Vikunja Team"
|
||||
|
|
@ -61,9 +61,9 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.10.2",
|
||||
"electron-builder": "26.8.1",
|
||||
"unzipper": "0.12.3"
|
||||
"electron": "40.10.5",
|
||||
"electron-builder": "26.15.3",
|
||||
"unzipper": "0.12.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "5.2.1"
|
||||
|
|
@ -73,12 +73,16 @@
|
|||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"picomatch": ">=4.0.4",
|
||||
"tmp": ">=0.2.6",
|
||||
"ip-address": ">=10.1.1"
|
||||
"minimatch": "10.2.5",
|
||||
"tar": "7.5.17",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"picomatch": "4.0.4",
|
||||
"tmp": "0.2.7",
|
||||
"ip-address": "10.2.0",
|
||||
"form-data": "4.0.6",
|
||||
"js-yaml": "5.2.0",
|
||||
"undici@6": "6.27.0",
|
||||
"undici@7": "7.28.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
85
devenv.lock
85
devenv.lock
|
|
@ -3,10 +3,11 @@
|
|||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1773012232,
|
||||
"lastModified": 1782492839,
|
||||
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
|
||||
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -16,71 +17,16 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772893680,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772749504,
|
||||
"lastModified": 1782132010,
|
||||
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
|
||||
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -93,11 +39,11 @@
|
|||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1769922788,
|
||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||
"lastModified": 1781607440,
|
||||
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -109,10 +55,11 @@
|
|||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1772773019,
|
||||
"lastModified": 1782467914,
|
||||
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
|
||||
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -125,15 +72,11 @@
|
|||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
24.13.0
|
||||
24.18.0
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@
|
|||
// It has to be the full url, including the last /api/v1 part and port.
|
||||
// You can change this if your api is not reachable on the same port as the frontend.
|
||||
window.API_URL = '/api/v1'
|
||||
window.ALLOW_ICON_CHANGES = true
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
|
|
@ -51,111 +51,111 @@
|
|||
"story:preview": "histoire preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.7.4",
|
||||
"@fortawesome/fontawesome-svg-core": "7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.1.0",
|
||||
"@fortawesome/vue-fontawesome": "3.1.3",
|
||||
"@intlify/unplugin-vue-i18n": "11.0.3",
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@fortawesome/fontawesome-svg-core": "7.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "7.3.0",
|
||||
"@fortawesome/vue-fontawesome": "3.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "11.2.4",
|
||||
"@kyvg/vue3-notification": "3.4.2",
|
||||
"@sentry/vue": "10.36.0",
|
||||
"@tiptap/core": "3.17.0",
|
||||
"@tiptap/extension-blockquote": "3.17.0",
|
||||
"@tiptap/extension-code-block-lowlight": "3.17.0",
|
||||
"@tiptap/extension-hard-break": "3.17.0",
|
||||
"@tiptap/extension-image": "3.17.0",
|
||||
"@tiptap/extension-link": "3.17.0",
|
||||
"@tiptap/extension-list": "3.17.0",
|
||||
"@tiptap/extension-mention": "3.17.0",
|
||||
"@tiptap/extension-table": "3.17.0",
|
||||
"@tiptap/extension-typography": "3.17.0",
|
||||
"@tiptap/extension-underline": "3.17.0",
|
||||
"@tiptap/extensions": "3.17.0",
|
||||
"@tiptap/pm": "3.17.0",
|
||||
"@tiptap/starter-kit": "3.17.0",
|
||||
"@tiptap/suggestion": "3.17.0",
|
||||
"@tiptap/vue-3": "3.17.0",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@vueuse/router": "14.1.0",
|
||||
"axios": "1.16.0",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"@tiptap/core": "3.27.1",
|
||||
"@tiptap/extension-blockquote": "3.27.1",
|
||||
"@tiptap/extension-code-block-lowlight": "3.27.1",
|
||||
"@tiptap/extension-hard-break": "3.27.1",
|
||||
"@tiptap/extension-image": "3.27.1",
|
||||
"@tiptap/extension-link": "3.27.1",
|
||||
"@tiptap/extension-list": "3.27.1",
|
||||
"@tiptap/extension-mention": "3.27.1",
|
||||
"@tiptap/extension-table": "3.27.1",
|
||||
"@tiptap/extension-typography": "3.27.1",
|
||||
"@tiptap/extension-underline": "3.27.1",
|
||||
"@tiptap/extensions": "3.27.1",
|
||||
"@tiptap/pm": "3.27.1",
|
||||
"@tiptap/starter-kit": "3.27.1",
|
||||
"@tiptap/suggestion": "3.27.1",
|
||||
"@tiptap/vue-3": "3.27.1",
|
||||
"@vueuse/core": "14.3.0",
|
||||
"@vueuse/router": "14.3.0",
|
||||
"axios": "1.18.1",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"change-case": "5.4.4",
|
||||
"dayjs": "1.11.19",
|
||||
"dompurify": "3.4.0",
|
||||
"dayjs": "1.11.21",
|
||||
"dompurify": "3.4.11",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"floating-vue": "5.2.2",
|
||||
"is-touch-device": "1.0.1",
|
||||
"klona": "2.0.6",
|
||||
"lowlight": "3.3.0",
|
||||
"marked": "17.0.1",
|
||||
"nanoid": "5.1.6",
|
||||
"marked": "17.0.6",
|
||||
"nanoid": "5.1.16",
|
||||
"pinia": "3.0.4",
|
||||
"register-service-worker": "1.7.2",
|
||||
"sortablejs": "1.15.6",
|
||||
"ufo": "1.6.3",
|
||||
"vue": "3.5.27",
|
||||
"sortablejs": "1.15.7",
|
||||
"ufo": "1.6.4",
|
||||
"vue": "3.5.39",
|
||||
"vue-advanced-cropper": "2.8.9",
|
||||
"vue-flatpickr-component": "11.0.5",
|
||||
"vue-i18n": "11.2.8",
|
||||
"vue-i18n": "11.4.6",
|
||||
"vue-router": "4.6.4",
|
||||
"vuemoji-picker": "0.3.2",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "10.4.0",
|
||||
"@faker-js/faker": "10.5.0",
|
||||
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
||||
"@histoire/plugin-vue": "1.0.0-beta.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@sentry/vite-plugin": "3.6.1",
|
||||
"@tailwindcss/vite": "4.3.0",
|
||||
"@tailwindcss/vite": "4.3.1",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/codemirror": "5.60.17",
|
||||
"@types/is-touch-device": "1.0.3",
|
||||
"@types/node": "24.12.4",
|
||||
"@types/node": "24.13.2",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/test-utils": "2.4.10",
|
||||
"@vue/eslint-config-typescript": "14.9.0",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"@vueuse/shared": "14.3.0",
|
||||
"autoprefixer": "10.5.0",
|
||||
"browserslist": "4.28.2",
|
||||
"caniuse-lite": "1.0.30001793",
|
||||
"autoprefixer": "10.5.2",
|
||||
"browserslist": "4.28.4",
|
||||
"caniuse-lite": "1.0.30001799",
|
||||
"csstype": "3.2.3",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild": "0.28.1",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-depend": "1.5.0",
|
||||
"eslint-plugin-vue": "10.9.1",
|
||||
"happy-dom": "20.9.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.6",
|
||||
"histoire": "1.0.0-beta.1",
|
||||
"otplib": "12.0.1",
|
||||
"postcss": "8.5.15",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-html": "1.8.1",
|
||||
"postcss-preset-env": "11.3.0",
|
||||
"rollup": "4.60.4",
|
||||
"postcss-preset-env": "11.3.1",
|
||||
"rollup": "4.62.2",
|
||||
"rollup-plugin-visualizer": "6.0.11",
|
||||
"sass-embedded": "1.100.0",
|
||||
"stylelint": "17.12.0",
|
||||
"stylelint": "17.13.0",
|
||||
"stylelint-config-property-sort-order-smacss": "10.0.0",
|
||||
"stylelint-config-recommended-vue": "1.6.1",
|
||||
"stylelint-config-standard-scss": "17.0.0",
|
||||
"stylelint-use-logical": "2.1.3",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tailwindcss": "4.3.1",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.3.3",
|
||||
"vite": "7.3.6",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-vue-devtools": "8.1.2",
|
||||
"vite-plugin-vue-devtools": "8.1.4",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.7",
|
||||
"vue-tsc": "3.3.3",
|
||||
"vitest": "4.1.9",
|
||||
"vue-tsc": "3.3.5",
|
||||
"wait-on": "9.0.10",
|
||||
"workbox-cli": "7.4.1",
|
||||
"ws": "8.21.0"
|
||||
|
|
@ -169,14 +169,20 @@
|
|||
"vue-demi"
|
||||
],
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.3",
|
||||
"minimatch": "10.2.5",
|
||||
"rollup": "$rollup",
|
||||
"basic-ftp": ">=5.2.2",
|
||||
"serialize-javascript": "^7.0.5",
|
||||
"flatted": "^3.4.1",
|
||||
"ip-address": ">=10.1.1",
|
||||
"postcss": ">=8.5.10",
|
||||
"tmp": ">=0.2.6"
|
||||
"basic-ftp": "6.0.1",
|
||||
"serialize-javascript": "7.0.6",
|
||||
"flatted": "3.4.2",
|
||||
"ip-address": "10.2.0",
|
||||
"postcss": "8.5.15",
|
||||
"tmp": "0.2.7",
|
||||
"esbuild": "0.28.1",
|
||||
"form-data": "4.0.6",
|
||||
"markdown-it": "14.2.0",
|
||||
"launch-editor": "2.14.1",
|
||||
"@babel/core": "8.0.1",
|
||||
"js-yaml@4": "5.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -61,6 +61,7 @@ import {useAuthStore} from '@/stores/auth'
|
|||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
|
||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||
|
|
@ -107,6 +108,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
|||
|
||||
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
||||
useColorScheme()
|
||||
useTimeTrackingFavicon()
|
||||
</script>
|
||||
|
||||
<style src="@/styles/tailwind.css" />
|
||||
|
|
|
|||
|
|
@ -36,4 +36,18 @@ describe('DatepickerWithRange predefined ranges', () => {
|
|||
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
||||
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
||||
})
|
||||
|
||||
// A cleared range (the Custom option) comes back as null via v-model; the
|
||||
// modelValue watcher must coerce it, not call null.toISOString().
|
||||
it('accepts a null modelValue without crashing', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await wrapper.setProps({modelValue: {dateFrom: 'now/w', dateTo: 'now/w+1w'}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('now/w')
|
||||
|
||||
await wrapper.setProps({modelValue: {dateFrom: null, dateTo: null}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('')
|
||||
expect((wrapper.vm as any).to).toBe('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -114,16 +114,17 @@ import DatemathHelp from '@/components/date/DatemathHelp.vue'
|
|||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||
|
||||
const props = defineProps<{
|
||||
// null for a side that's been cleared (the Custom option) — emitted, so accepted too.
|
||||
modelValue: {
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string,
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null,
|
||||
},
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: {
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
|
|
@ -149,8 +150,8 @@ const to = ref('')
|
|||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : newValue.dateTo.toISOString()
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '')
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.toISOString() ?? '')
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const dateFrom = parseDateOrString(from.value, false)
|
||||
|
|
@ -208,14 +209,22 @@ const customRangeActive = computed<boolean>(() => {
|
|||
})
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
if (from.value !== '' && to.value !== '') {
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
if (from.value === '' || to.value === '') {
|
||||
return t('task.show.select')
|
||||
}
|
||||
|
||||
return t('task.show.select')
|
||||
// Show the preset's name when the range matches one, rather than the raw datemath.
|
||||
const preset = Object.entries(DATE_RANGES).find(
|
||||
([, range]) => from.value === range[0] && to.value === range[1],
|
||||
)
|
||||
if (preset) {
|
||||
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||
}
|
||||
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@
|
|||
<div class="gantt-chart-wrapper">
|
||||
<GanttTimelineHeader
|
||||
:timeline-data="timelineData"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
/>
|
||||
|
||||
<GanttVerticalGridLines
|
||||
:timeline-data="timelineData"
|
||||
:total-width="totalWidth"
|
||||
:height="ganttRows.length * 40"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
/>
|
||||
|
||||
<GanttChartBody
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
:total-width="totalWidth"
|
||||
:date-from-date="dateFromDate"
|
||||
:date-to-date="dateToDate"
|
||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
||||
:day-width-pixels="dayWidthPixels"
|
||||
:is-dragging="isDragging"
|
||||
:is-resizing="isResizing"
|
||||
:drag-state="dragState"
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
|
||||
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
||||
|
|
@ -126,7 +126,9 @@ const emit = defineEmits<{
|
|||
(e: 'update:task', task: ITaskPartialWithId): void
|
||||
}>()
|
||||
|
||||
const DAY_WIDTH_PIXELS = 30
|
||||
const DAY_WIDTH_PIXELS_MIN = 30
|
||||
const dayWidthPixels = ref(0)
|
||||
let resizeObserver: ResizeObserver
|
||||
|
||||
const {tasks, filters} = toRefs(props)
|
||||
|
||||
|
|
@ -158,7 +160,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
|
|||
|
||||
const totalWidth = computed(() => {
|
||||
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||
return dateDiff * DAY_WIDTH_PIXELS
|
||||
return dateDiff * dayWidthPixels.value
|
||||
})
|
||||
|
||||
const timelineData = computed(() => {
|
||||
|
|
@ -297,6 +299,55 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
|||
}
|
||||
}
|
||||
|
||||
function updateDayWidthPixels() {
|
||||
const node = ganttContainer.value
|
||||
if (!node) return
|
||||
|
||||
const rect = node.getBoundingClientRect()
|
||||
const styles = window.getComputedStyle(node)
|
||||
|
||||
const marginLeft = parseFloat(styles.marginLeft) || 0
|
||||
const marginRight = parseFloat(styles.marginRight) || 0
|
||||
|
||||
// max width without overflow
|
||||
const maxWidth = rect.width - marginLeft - marginRight
|
||||
|
||||
const dayCount = Math.ceil(
|
||||
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
|
||||
)
|
||||
|
||||
dayWidthPixels.value = Math.max(
|
||||
maxWidth / dayCount,
|
||||
DAY_WIDTH_PIXELS_MIN,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
|
||||
if (ganttContainer.value) {
|
||||
resizeObserver = new ResizeObserver(updateDayWidthPixels)
|
||||
resizeObserver.observe(ganttContainer.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', updateDayWidthPixels)
|
||||
})
|
||||
|
||||
watch(
|
||||
[dateFromDate, dateToDate],
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateDayWidthPixels()
|
||||
},
|
||||
{flush: 'post'},
|
||||
)
|
||||
|
||||
// Build the task tree when tasks change
|
||||
watch(
|
||||
[tasks, filters],
|
||||
|
|
@ -351,7 +402,7 @@ const ROW_HEIGHT = 40
|
|||
const barPositions = computed(() => {
|
||||
const positions = new Map<number, GanttBarPosition>()
|
||||
const ds = dragState.value
|
||||
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
|
||||
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0
|
||||
|
||||
ganttBars.value.forEach((rowBars, rowIndex) => {
|
||||
for (const bar of rowBars) {
|
||||
|
|
@ -386,7 +437,7 @@ function computeBarX(date: Date): number {
|
|||
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
return diff * dayWidthPixels.value
|
||||
}
|
||||
|
||||
function computeBarWidth(bar: GanttBarModel): number {
|
||||
|
|
@ -394,7 +445,7 @@ function computeBarWidth(bar: GanttBarModel): number {
|
|||
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
|
||||
MILLISECONDS_A_DAY,
|
||||
)
|
||||
return diff * DAY_WIDTH_PIXELS
|
||||
return diff * dayWidthPixels.value
|
||||
}
|
||||
|
||||
// Compute relation arrows
|
||||
|
|
@ -590,7 +641,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
|
|||
if (!dragState.value || !isDragging.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
|
||||
if (days !== dragState.value.currentDays) {
|
||||
dragState.value.currentDays = days
|
||||
|
|
@ -652,7 +703,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
|
|||
if (!dragState.value || !isResizing.value) return
|
||||
|
||||
const diff = e.clientX - dragState.value.startX
|
||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
||||
const days = Math.round(diff / dayWidthPixels.value)
|
||||
|
||||
if (edge === 'start') {
|
||||
const newStart = new Date(dragState.value.originalStart)
|
||||
|
|
@ -730,7 +781,7 @@ function focusTaskBar(rowId: string) {
|
|||
setTimeout(() => {
|
||||
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
||||
if (taskBarElement) {
|
||||
taskBarElement.focus()
|
||||
taskBarElement.focus({preventScroll: true})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,15 @@
|
|||
</ProjectSettingsDropdown>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pageTitle"
|
||||
class="project-title-wrapper"
|
||||
>
|
||||
<span class="project-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<TimerBadge />
|
||||
<OpenQuickActions />
|
||||
<Notifications />
|
||||
<Dropdown>
|
||||
|
|
@ -121,13 +129,17 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
||||
import { PRO_FEATURE } from '@/constants/proFeatures'
|
||||
|
||||
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
||||
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||
import Notifications from '@/components/notifications/Notifications.vue'
|
||||
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
|
|
@ -151,12 +163,20 @@ const background = computed(() => baseStore.background)
|
|||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
// Standalone pages (no project) surface their route's title in the header.
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const pageTitle = computed(() => {
|
||||
const title = route.meta.title as string | undefined
|
||||
return title ? t(title) : ''
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { computed } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useColorScheme } from '@/composables/useColorScheme'
|
||||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
|
|
@ -13,9 +14,10 @@ const now = useNow({
|
|||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const { isDark } = useColorScheme()
|
||||
|
||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES
|
||||
const Logo = computed(() => configStore.allowIconChanges
|
||||
&& authStore.settings.frontendSettings.allowIconChanges
|
||||
&& now.value.getMonth() === 5
|
||||
? LogoFullPride
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@
|
|||
{{ $t('team.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li v-if="timeTrackingEnabled">
|
||||
<RouterLink :to="{ name: 'time-tracking'}">
|
||||
<span class="menu-item-icon icon">
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</span>
|
||||
{{ $t('timeTracking.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
|
|
@ -133,12 +141,17 @@ import Loading from '@/components/misc/Loading.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||
|
||||
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
:disabled="disabled || undefined"
|
||||
@click.stop="toggleDatePopup"
|
||||
>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i>
|
||||
<template v-else>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
</template>
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
|
|
@ -16,6 +19,7 @@
|
|||
>
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
:show-shortcuts="showShortcuts"
|
||||
@update:modelValue="updateData"
|
||||
/>
|
||||
|
||||
|
|
@ -48,12 +52,17 @@ const props = withDefaults(defineProps<{
|
|||
modelValue: Date | null | string,
|
||||
chooseDateLabel?: string,
|
||||
disabled?: boolean,
|
||||
showShortcuts?: boolean,
|
||||
// When the value is null, show this (italic) instead of chooseDateLabel.
|
||||
emptyLabel?: string,
|
||||
}>(), {
|
||||
chooseDateLabel: () => {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.datepicker.chooseDate')
|
||||
},
|
||||
disabled: false,
|
||||
showShortcuts: true,
|
||||
emptyLabel: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -1,66 +1,68 @@
|
|||
<template>
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<template v-if="showShortcuts">
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
|
|
@ -87,9 +89,12 @@ import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
|||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: Date | null | string
|
||||
}>()
|
||||
showShortcuts?: boolean
|
||||
}>(), {
|
||||
showShortcuts: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [Date | null],
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ async function addImage(event: Event) {
|
|||
return
|
||||
}
|
||||
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
||||
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
|
||||
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setImage({src: url}).run()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
|
|||
|
||||
import EmojiList from './EmojiList.vue'
|
||||
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
||||
import {getPopupContainer} from '../popupContainer'
|
||||
|
||||
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
|
|||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '4700'
|
||||
popupElement.appendChild(component.element!)
|
||||
document.body.appendChild(popupElement)
|
||||
getPopupContainer(props.editor).appendChild(popupElement)
|
||||
|
||||
const rect = props.clientRect()
|
||||
if (!rect) {
|
||||
|
|
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
|
|||
cleanupFloating = null
|
||||
}
|
||||
if (popupElement) {
|
||||
document.body.removeChild(popupElement)
|
||||
popupElement.remove()
|
||||
popupElement = null
|
||||
}
|
||||
component?.destroy()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
|
|||
|
||||
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
||||
const previousUrl = editor?.getAttributes('link').href || ''
|
||||
const url = await inputPrompt(pos, previousUrl)
|
||||
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAlignLeft,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faAnglesUp,
|
||||
faArchive,
|
||||
|
|
@ -121,6 +122,7 @@ library.add(faCode)
|
|||
library.add(faQuoteRight)
|
||||
library.add(faListUl)
|
||||
library.add(faAlignLeft)
|
||||
library.add(faAngleLeft)
|
||||
library.add(faAngleRight)
|
||||
library.add(faArchive)
|
||||
library.add(faArrowLeft)
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const props = withDefaults(defineProps<{
|
|||
enabled?: boolean,
|
||||
overflow?: boolean,
|
||||
wide?: boolean,
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling',
|
||||
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
|
||||
}>(), {
|
||||
enabled: true,
|
||||
overflow: false,
|
||||
|
|
@ -211,7 +211,13 @@ $modal-width: 1024px;
|
|||
// Reset UA dialog styles
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
// The scrim lives on the dialog element, not on ::backdrop: Chromium
|
||||
// intermittently stops painting a styled ::backdrop (e.g. after the
|
||||
// dialog's subtree re-renders, or while display is transitioned) even
|
||||
// though getComputedStyle still reports the color. The dialog fills the
|
||||
// viewport anyway, and its opacity transition fades the scrim with it —
|
||||
// same as the old div-based .modal-mask.
|
||||
background: rgba(0, 0, 0, .8);
|
||||
color: #ffffff;
|
||||
// Fill viewport
|
||||
position: fixed;
|
||||
|
|
@ -221,10 +227,12 @@ $modal-width: 1024px;
|
|||
max-inline-size: 100%;
|
||||
max-block-size: 100%;
|
||||
|
||||
// Transitions
|
||||
// Transitions. No display/allow-discrete transition needed: the close
|
||||
// fade runs while the dialog is still [open] (data-closing + timer in
|
||||
// closeDialog), and transitioning display triggers the Chromium paint
|
||||
// bug above.
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
transition: opacity 150ms ease;
|
||||
|
||||
&[open]:not([data-closing]) {
|
||||
opacity: 1;
|
||||
|
|
@ -236,16 +244,11 @@ $modal-width: 1024px;
|
|||
|
||||
&::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
transition: background-color 150ms ease,
|
||||
display 150ms ease allow-discrete;
|
||||
}
|
||||
|
||||
&[open]:not([data-closing])::backdrop {
|
||||
background-color: rgba(0, 0, 0, .8);
|
||||
|
||||
@starting-style {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
// in quick-add mode the Electron window itself is the overlay — no scrim
|
||||
&:has(.is-quick-add-mode) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,13 +264,20 @@ $modal-width: 1024px;
|
|||
}
|
||||
|
||||
.default .modal-content,
|
||||
.hint-modal .modal-content {
|
||||
.hint-modal .modal-content,
|
||||
.top .modal-content {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
// fine to use top/left since we're only using this to position it centered
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
// Cap centered content to the viewport and scroll inside it. Without this a
|
||||
// taller-than-viewport modal centres its top edge above the viewport, where
|
||||
// the container's overflow can't scroll to it (the .top variant overrides
|
||||
// both values below).
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: auto;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translate(50%, -50%);
|
||||
|
|
@ -277,6 +287,9 @@ $modal-width: 1024px;
|
|||
margin: 0;
|
||||
position: static;
|
||||
transform: none;
|
||||
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
|
@ -289,11 +302,31 @@ $modal-width: 1024px;
|
|||
}
|
||||
}
|
||||
|
||||
// anchored below the top edge instead of centered, used for QuickActions
|
||||
.top .modal-content {
|
||||
inset-block-start: 3rem;
|
||||
transform: translate(-50%, 0);
|
||||
max-block-size: calc(100dvh - 6rem);
|
||||
overflow: auto;
|
||||
|
||||
[dir="rtl"] & {
|
||||
transform: translate(50%, 0);
|
||||
}
|
||||
|
||||
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||
@media screen and (max-width: $tablet) {
|
||||
transform: none;
|
||||
max-block-size: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Default width for centered modals. Scoped with :not(.is-wide) so the
|
||||
// `wide` prop can still expand the modal (the .is-wide rule below would
|
||||
// otherwise be outranked by .default .modal-content's specificity).
|
||||
.default .modal-content:not(.is-wide),
|
||||
.hint-modal .modal-content:not(.is-wide) {
|
||||
.hint-modal .modal-content:not(.is-wide),
|
||||
.top .modal-content:not(.is-wide) {
|
||||
inline-size: calc(100% - 2rem);
|
||||
max-inline-size: 640px;
|
||||
|
||||
|
|
@ -403,6 +436,7 @@ $modal-width: 1024px;
|
|||
block-size: auto;
|
||||
max-inline-size: none;
|
||||
max-block-size: none;
|
||||
background: transparent;
|
||||
|
||||
&::backdrop {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -1,66 +1,96 @@
|
|||
<template>
|
||||
<Notifications
|
||||
position="bottom left"
|
||||
:max="2"
|
||||
:ignore-duplicates="true"
|
||||
class="global-notification"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template #body="{ item, close }">
|
||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||
<div
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
>
|
||||
<Teleport :to="teleportTarget">
|
||||
<Notifications
|
||||
position="bottom left"
|
||||
:max="2"
|
||||
:ignore-duplicates="true"
|
||||
class="global-notification"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template #body="{ item, close }">
|
||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||
<div
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<template v-if="Array.isArray(item.text)">
|
||||
<template
|
||||
v-for="(t, k) in item.text"
|
||||
:key="k"
|
||||
>
|
||||
{{ t }}<br>
|
||||
<div
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<template v-if="Array.isArray(item.text)">
|
||||
<template
|
||||
v-for="(t, k) in item.text"
|
||||
:key="k"
|
||||
>
|
||||
{{ t }}<br>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.text }}
|
||||
</template>
|
||||
<span
|
||||
v-if="item.duplicates > 0"
|
||||
class="tw:text-xs tw:font-bold tw:ml-1"
|
||||
<template v-else>
|
||||
{{ item.text }}
|
||||
</template>
|
||||
<span
|
||||
v-if="item.duplicates > 0"
|
||||
class="tw:text-xs tw:font-bold tw:ml-1"
|
||||
>
|
||||
×{{ item.duplicates + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
||||
>
|
||||
×{{ item.duplicates + 1 }}
|
||||
</span>
|
||||
<XButton
|
||||
v-for="(action, i) in item.data.actions"
|
||||
:key="'action_' + i"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
variant="secondary"
|
||||
@click="action.callback"
|
||||
>
|
||||
{{ action.title }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
||||
>
|
||||
<XButton
|
||||
v-for="(action, i) in item.data.actions"
|
||||
:key="'action_' + i"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
variant="secondary"
|
||||
@click="action.callback"
|
||||
>
|
||||
{{ action.title }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Notifications>
|
||||
</template>
|
||||
</Notifications>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
|
||||
const teleportTarget = ref<string | HTMLElement>('body')
|
||||
let observer: MutationObserver | null = null
|
||||
|
||||
function syncTeleportTarget() {
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||
teleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncTeleportTarget()
|
||||
observer = new MutationObserver(syncTeleportTarget)
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['open'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-notification {
|
||||
z-index: 9999;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
<template #default>
|
||||
<Card :has-content="false">
|
||||
<div class="gantt-options">
|
||||
<FormField :label="$t('project.gantt.range')">
|
||||
<FormField :label="$t('misc.dateRange')">
|
||||
<Foo
|
||||
id="range"
|
||||
ref="flatPickerEl"
|
||||
v-model="flatPickerDateRange"
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
:placeholder="$t('project.gantt.range')"
|
||||
:placeholder="$t('misc.dateRange')"
|
||||
/>
|
||||
</FormField>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
@click.stop="showSetLimitInput = true"
|
||||
>
|
||||
{{
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('misc.notSet')})
|
||||
}}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<Modal
|
||||
:enabled="active"
|
||||
:overflow="isNewTaskCommand"
|
||||
variant="top"
|
||||
@close="closeQuickActions"
|
||||
>
|
||||
<div
|
||||
|
|
@ -704,15 +705,16 @@ function reset() {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.quick-actions {
|
||||
// global Bulma .card styles are gone (ported into Card.vue, scoped),
|
||||
// so this bare .card div needs its own card visuals
|
||||
background-color: var(--white);
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
justify-content: flex-start !important;
|
||||
|
||||
// FIXME: changed position should be an option of the modal
|
||||
:deep(.modal-content) {
|
||||
inset-block-start: 3rem;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
&.is-quick-add-mode {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
rows="1"
|
||||
@keydown="resetEmptyTitleError"
|
||||
@keydown.enter="handleEnter"
|
||||
@keydown.esc="blurTaskInput"
|
||||
/>
|
||||
<QuickAddMagic
|
||||
:highlight-hint-icon="taskAddHovered"
|
||||
|
|
@ -282,6 +283,10 @@ function focusTaskInput() {
|
|||
newTaskInput.value?.focus()
|
||||
}
|
||||
|
||||
function blurTaskInput() {
|
||||
newTaskInput.value?.blur()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusTaskInput,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@
|
|||
</XButton>
|
||||
|
||||
<!-- Dropzone -->
|
||||
<Teleport to="body">
|
||||
<Teleport :to="dropzoneTeleportTarget">
|
||||
<div
|
||||
v-if="editEnabled"
|
||||
:class="{hidden: !showDropzone}"
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed, watch} from 'vue'
|
||||
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
|
||||
import {useDropZone} from '@vueuse/core'
|
||||
|
||||
import User from '@/components/misc/User.vue'
|
||||
|
|
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
|
|||
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
||||
)
|
||||
|
||||
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
|
||||
// the browser's top layer, so the full-screen dropzone overlay teleported to
|
||||
// <body> would paint behind it regardless of z-index. Teleport it into the
|
||||
// topmost open dialog instead, mirroring Notification.vue.
|
||||
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
function syncDropzoneTeleportTarget() {
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncDropzoneTeleportTarget()
|
||||
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
|
||||
dialogObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['open'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
watch(() => props.editEnabled, enabled => {
|
||||
if (!enabled) {
|
||||
resetDragState()
|
||||
|
|
@ -478,7 +506,7 @@ defineExpose({
|
|||
inset-inline-start: 0;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 4001; // modal z-index is 4000
|
||||
z-index: 4001; // above app chrome when teleported to body (no modal open)
|
||||
text-align: center;
|
||||
|
||||
&.hidden {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
||||
id="showRelatedTasksFormButton"
|
||||
v-tooltip="$t('task.relation.add')"
|
||||
class="is-pulled-right add-task-relation-button d-print-none"
|
||||
class="is-pulled-end add-task-relation-button d-print-none"
|
||||
:class="{'is-active': showNewRelationForm}"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
|
|
|
|||
|
|
@ -326,9 +326,17 @@ const isOverdue = computed(() => (
|
|||
let oldTask
|
||||
|
||||
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
||||
const updateFunc = async () => {
|
||||
oldTask = {...task.value}
|
||||
const newTask = await taskStore.update(task.value)
|
||||
oldTask = {...task.value}
|
||||
|
||||
// Fire the request immediately and with the intended done value snapshotted, so a re-render or
|
||||
// teardown during the animation delay can neither drop the save nor make it send a stale state.
|
||||
const updatePromise = taskStore.update({
|
||||
...task.value,
|
||||
done: checked,
|
||||
})
|
||||
|
||||
const finish = async () => {
|
||||
const newTask = await updatePromise
|
||||
task.value = newTask
|
||||
|
||||
updateDueDate()
|
||||
|
|
@ -354,9 +362,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
|||
}
|
||||
|
||||
if (checked) {
|
||||
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
|
||||
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done
|
||||
} else {
|
||||
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="task-time-tracking">
|
||||
<XButton
|
||||
v-if="entries.length > 0"
|
||||
v-tooltip="$t('timeTracking.logTime')"
|
||||
v-cy="'addTaskTimeEntry'"
|
||||
class="is-pulled-right d-print-none"
|
||||
:class="{'is-active': showForm}"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
:shadow="false"
|
||||
@click="showForm = !showForm"
|
||||
/>
|
||||
<h3 class="title is-5">
|
||||
{{ $t('timeTracking.title') }}
|
||||
</h3>
|
||||
<TimeEntryForm
|
||||
v-if="formVisible"
|
||||
:task-id="taskId"
|
||||
:entry="editingEntry"
|
||||
:recent-entries="entries"
|
||||
@saved="onSaved"
|
||||
@cancel="editingEntry = null"
|
||||
/>
|
||||
<TimeEntryList
|
||||
class="mbs-4"
|
||||
:entries="entries"
|
||||
:card="false"
|
||||
:empty-text="$t('timeTracking.list.emptyTask')"
|
||||
hide-label-column
|
||||
@edit="editingEntry = $event"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
|
||||
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
|
||||
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
|
||||
|
||||
import {useTimeEntryService} from '@/services/timeEntry'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const entries = ref<ITimeEntry[]>([])
|
||||
const editingEntry = ref<ITimeEntry | null>(null)
|
||||
const showForm = ref(false)
|
||||
|
||||
// Like related tasks: the form is implicit when empty, otherwise behind the +.
|
||||
const formVisible = computed(() => entries.value.length === 0 || showForm.value || editingEntry.value !== null)
|
||||
|
||||
async function load() {
|
||||
const {items} = await useTimeEntryService().getAll({
|
||||
filter: `task_id = ${props.taskId}`,
|
||||
perPage: 250,
|
||||
})
|
||||
entries.value = items
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
editingEntry.value = null
|
||||
showForm.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onDelete(id: number) {
|
||||
await timeTrackingStore.removeEntry(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
watch(() => props.taskId, load, {immediate: true})
|
||||
// The header badge can start/stop the timer without going through this form;
|
||||
// reload so the row reflects the stop (its new end time).
|
||||
watch(() => timeTrackingStore.activeTimer, load)
|
||||
</script>
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
<template>
|
||||
<form
|
||||
ref="formEl"
|
||||
v-cy="'timeEntryForm'"
|
||||
class="time-entry-form"
|
||||
@submit.prevent="saveEntry"
|
||||
>
|
||||
<div
|
||||
v-if="taskId === undefined"
|
||||
class="field-columns"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.project') }}</label>
|
||||
<ProjectSearch v-model="selectedProject" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
||||
<Multiselect
|
||||
v-model="selectedTask"
|
||||
:placeholder="$t('timeTracking.form.taskSearch')"
|
||||
:loading="taskService.loading"
|
||||
:search-results="foundTasks"
|
||||
label="title"
|
||||
@search="findTasks"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
{{ option.title }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.comment.comment') }}</label>
|
||||
<input
|
||||
v-model="comment"
|
||||
v-cy="'timeEntryComment'"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('timeTracking.form.commentPlaceholder')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped from-to-row">
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.from') }}</label>
|
||||
<Datepicker
|
||||
v-model="from"
|
||||
:show-shortcuts="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.to') }}</label>
|
||||
<Datepicker
|
||||
v-model="to"
|
||||
:show-shortcuts="false"
|
||||
:empty-label="$t('misc.notSet')"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<BaseButton
|
||||
v-tooltip="$t('timeTracking.form.smartFill')"
|
||||
v-cy="'smartFill'"
|
||||
class="smart-fill"
|
||||
:aria-label="$t('timeTracking.form.smartFill')"
|
||||
@click="smartFill"
|
||||
>
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field form-actions">
|
||||
<template v-if="isEditing">
|
||||
<XButton
|
||||
v-cy="'updateTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.update') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
variant="secondary"
|
||||
:disabled="isSaving"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</XButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<XButton
|
||||
v-cy="'saveTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.save') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-cy="'startTimer'"
|
||||
variant="secondary"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="startTimer"
|
||||
>
|
||||
{{ $t('timeTracking.form.startTimer') }}
|
||||
</XButton>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive, watch, nextTick} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Multiselect from '@/components/input/Multiselect.vue'
|
||||
import Datepicker from '@/components/input/Datepicker.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {smartFillStart} from '@/helpers/time/smartFillStart'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// When set, the entry is locked to this task and the project/task pickers are hidden.
|
||||
taskId?: number
|
||||
// When set, the form edits this entry (Update + Cancel) instead of creating.
|
||||
entry?: ITimeEntry | null
|
||||
// Entries the smart-clock looks at to continue from the last one's end.
|
||||
recentEntries?: ITimeEntry[]
|
||||
}>(), {
|
||||
taskId: undefined,
|
||||
entry: undefined,
|
||||
recentEntries: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const authStore = useAuthStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const isEditing = computed(() => props.entry != null)
|
||||
|
||||
const formEl = ref<HTMLFormElement | null>(null)
|
||||
const selectedProject = ref<IProject | null>(null)
|
||||
const selectedTask = ref<ITask | null>(null)
|
||||
const from = ref<Date | null>(new Date())
|
||||
const to = ref<Date | null>(null)
|
||||
const comment = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// Task and project are mutually exclusive (XOR) — selecting one clears the other,
|
||||
// so applyTarget never picks a stale target the user has since changed.
|
||||
watch(selectedTask, task => {
|
||||
if (task !== null) {
|
||||
selectedProject.value = null
|
||||
}
|
||||
})
|
||||
watch(selectedProject, project => {
|
||||
if (project !== null) {
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const foundTasks = ref<ITask[]>([])
|
||||
async function findTasks(query: string) {
|
||||
if (query === '') {
|
||||
foundTasks.value = []
|
||||
return
|
||||
}
|
||||
const result = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
||||
foundTasks.value = selectedProject.value === null
|
||||
? result
|
||||
: result.filter(task => task.projectId === selectedProject.value?.id)
|
||||
}
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
// In edit mode the entry already has a valid container; an update that sends
|
||||
// neither keeps it, so don't block submit if the prefill lookup failed.
|
||||
isEditing.value || props.taskId !== undefined || selectedTask.value !== null || selectedProject.value !== null,
|
||||
)
|
||||
|
||||
function smartFill() {
|
||||
from.value = smartFillStart(
|
||||
props.recentEntries,
|
||||
authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
|
||||
new Date(),
|
||||
)
|
||||
to.value = new Date()
|
||||
}
|
||||
|
||||
// Whichever of task / project is set lands on the payload (XOR — enforced by canSubmit).
|
||||
function applyTarget(payload: Partial<ITimeEntry>) {
|
||||
if (props.taskId !== undefined) {
|
||||
payload.taskId = props.taskId
|
||||
} else if (selectedTask.value !== null) {
|
||||
payload.taskId = selectedTask.value.id
|
||||
} else if (selectedProject.value !== null) {
|
||||
payload.projectId = selectedProject.value.id
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(includeEnd: boolean): Partial<ITimeEntry> {
|
||||
const payload: Partial<ITimeEntry> = {
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? new Date(),
|
||||
}
|
||||
applyTarget(payload)
|
||||
// Saving a manual entry always has an end (an empty "To" means "until now");
|
||||
// only the Start-timer path omits it to create a running timer.
|
||||
if (includeEnd) {
|
||||
payload.endTime = to.value ?? new Date()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function reset() {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = null
|
||||
comment.value = ''
|
||||
from.value = new Date()
|
||||
to.value = null
|
||||
}
|
||||
|
||||
// Prefill from the entry being edited; a null entry returns the form to create mode.
|
||||
watch(() => props.entry, async entry => {
|
||||
if (entry == null) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
comment.value = entry.comment
|
||||
from.value = entry.startTime
|
||||
to.value = entry.endTime
|
||||
// Bring the form into view — the edit button may be far down the list.
|
||||
await nextTick()
|
||||
formEl.value?.scrollIntoView({behavior: 'smooth', block: 'center'})
|
||||
if (props.taskId !== undefined) {
|
||||
return
|
||||
}
|
||||
if (entry.taskId > 0) {
|
||||
selectedProject.value = null
|
||||
try {
|
||||
selectedTask.value = await taskService.get(new TaskModel({id: entry.taskId})) as ITask
|
||||
} catch {
|
||||
selectedTask.value = null
|
||||
}
|
||||
} else if (entry.projectId > 0) {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = (projectStore.projects[entry.projectId] as IProject) ?? null
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
async function submit(includeEnd: boolean) {
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload = buildPayload(includeEnd)
|
||||
// A started timer begins now (click time), not when the form first loaded.
|
||||
if (!includeEnd) {
|
||||
payload.startTime = new Date()
|
||||
}
|
||||
await timeTrackingStore.createEntry(payload)
|
||||
reset()
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdate() {
|
||||
const entry = props.entry
|
||||
if (!canSubmit.value || entry == null) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: Partial<ITimeEntry> & {id: number} = {
|
||||
id: entry.id,
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? entry.startTime,
|
||||
// A running entry stays running (null); a completed one can't be reopened,
|
||||
// so keep its end if "To" was cleared (the API rejects clearing it).
|
||||
endTime: entry.endTime === null ? to.value : (to.value ?? entry.endTime),
|
||||
taskId: 0,
|
||||
projectId: 0,
|
||||
}
|
||||
applyTarget(payload)
|
||||
await timeTrackingStore.updateEntry(payload)
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEntry = () => (isEditing.value ? submitUpdate() : submit(true))
|
||||
const startTimer = () => submit(false)
|
||||
function cancelEdit() {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.field-columns {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
> .field {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.from-to-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.smart-fill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 2.5em;
|
||||
inline-size: 2.5em;
|
||||
border-radius: $radius;
|
||||
color: var(--primary);
|
||||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
<template>
|
||||
<p
|
||||
v-if="rows.length === 0"
|
||||
class="has-text-centered has-text-grey is-italic"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
<component
|
||||
:is="card ? Card : 'div'"
|
||||
v-else
|
||||
v-bind="card ? {padding: false, hasContent: false} : {}"
|
||||
>
|
||||
<div class="has-horizontal-overflow">
|
||||
<table class="table has-actions is-hoverable is-fullwidth mbe-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="!hideLabelColumn">
|
||||
{{ $t('task.attributes.project') }}
|
||||
</th>
|
||||
<th v-if="!hideLabelColumn">
|
||||
{{ $t('timeTracking.form.task') }}
|
||||
</th>
|
||||
<th>{{ $t('task.comment.comment') }}</th>
|
||||
<th class="nowrap">
|
||||
{{ $t('timeTracking.list.time') }}
|
||||
</th>
|
||||
<th class="nowrap has-text-right">
|
||||
{{ $t('timeTracking.list.duration') }}
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
:key="row.entry.id"
|
||||
v-cy="'timeEntry'"
|
||||
>
|
||||
<td v-if="!hideLabelColumn">
|
||||
<template
|
||||
v-for="(project, i) in row.projectChain"
|
||||
:key="project.id"
|
||||
>
|
||||
<RouterLink :to="{ name: 'project.index', params: { projectId: project.id } }">
|
||||
{{ project.title }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="i < row.projectChain.length - 1"
|
||||
class="has-text-grey"
|
||||
> > </span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="!hideLabelColumn">
|
||||
<RouterLink
|
||||
v-if="row.entry.taskId > 0"
|
||||
:to="{ name: 'task.detail', params: { id: row.entry.taskId } }"
|
||||
>
|
||||
{{ row.taskIdentifier }}{{ row.taskTitle ? ` - ${row.taskTitle}` : '' }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="has-text-grey">
|
||||
{{ row.entry.comment }}
|
||||
</td>
|
||||
<td class="nowrap has-text-grey">
|
||||
{{ timeRange(row.entry) }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right has-text-weight-semibold">
|
||||
{{ row.seconds === null ? '' : formatDuration(row.seconds) }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right">
|
||||
<template v-if="row.entry.userId === currentUserId">
|
||||
<BaseButton
|
||||
v-tooltip="$t('menu.edit')"
|
||||
v-cy="'editTimeEntry'"
|
||||
class="entry-action"
|
||||
:aria-label="$t('menu.edit')"
|
||||
@click="emit('edit', row.entry)"
|
||||
>
|
||||
<Icon icon="pen" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('misc.delete')"
|
||||
v-cy="'deleteTimeEntry'"
|
||||
class="entry-action entry-delete"
|
||||
:aria-label="$t('misc.delete')"
|
||||
@click="emit('delete', row.entry.id)"
|
||||
>
|
||||
<Icon icon="trash-alt" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td
|
||||
:colspan="hideLabelColumn ? 2 : 4"
|
||||
class="has-text-weight-bold"
|
||||
>
|
||||
{{ $t('timeTracking.list.total') }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right has-text-weight-bold">
|
||||
{{ formatDuration(totalSeconds) }}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
entries: ITimeEntry[]
|
||||
// Drop the project + task columns when every entry belongs to the same task
|
||||
// (e.g. the task-detail page).
|
||||
hideLabelColumn?: boolean
|
||||
// Wrap the table in a Card box; set false to render it inline (no card background).
|
||||
card?: boolean
|
||||
// Override the empty-state message (defaults to the per-day wording).
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
hideLabelColumn: false,
|
||||
card: true,
|
||||
emptyText: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: number]
|
||||
edit: [entry: ITimeEntry]
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const {store: timeFormat} = useTimeFormat()
|
||||
|
||||
// Only the author can update/delete (enforced server-side); shared lists include
|
||||
// others' entries, so hide the controls on rows the current user doesn't own.
|
||||
const authStore = useAuthStore()
|
||||
const currentUserId = computed(() => authStore.info?.id)
|
||||
|
||||
// Task entries carry only a task id; resolve the full task lazily (for its
|
||||
// title, identifier, and parent project) and cache it.
|
||||
const taskService = new TaskService()
|
||||
const tasks = ref<Record<number, ITask>>({})
|
||||
const inFlight = new Set<number>()
|
||||
async function ensureTask(taskId: number) {
|
||||
if (taskId === 0 || tasks.value[taskId] !== undefined || inFlight.has(taskId)) {
|
||||
return
|
||||
}
|
||||
inFlight.add(taskId)
|
||||
try {
|
||||
tasks.value[taskId] = await taskService.get(new TaskModel({id: taskId}))
|
||||
} catch {
|
||||
// Leave unresolved — the row falls back to #<id>.
|
||||
} finally {
|
||||
inFlight.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.entries, entries => {
|
||||
entries.forEach(entry => ensureTask(entry.taskId))
|
||||
}, {immediate: true})
|
||||
|
||||
function entrySeconds(entry: ITimeEntry): number {
|
||||
const end = entry.endTime ?? new Date()
|
||||
return Math.floor((end.getTime() - entry.startTime.getTime()) / 1000)
|
||||
}
|
||||
|
||||
const rows = computed(() => props.entries.map(entry => {
|
||||
const task = entry.taskId > 0 ? tasks.value[entry.taskId] : undefined
|
||||
const projectId = task?.projectId ?? (entry.projectId > 0 ? entry.projectId : 0)
|
||||
const project = projectId > 0 ? projectStore.projects[projectId] as IProject | undefined : undefined
|
||||
const ancestors = project ? projectStore.getAncestors(project) : []
|
||||
|
||||
return {
|
||||
entry,
|
||||
// Full ancestor chain (root → leaf), each link-able.
|
||||
projectChain: ancestors.map(p => ({id: p.id, title: getProjectTitle(p)})),
|
||||
taskIdentifier: task ? (task.identifier || `#${task.index}`) : (entry.taskId > 0 ? `#${entry.taskId}` : ''),
|
||||
taskTitle: task?.title ?? '',
|
||||
// A running entry (no end) has no settled duration — leave it blank.
|
||||
seconds: entry.endTime !== null ? entrySeconds(entry) : null,
|
||||
}
|
||||
}))
|
||||
|
||||
const totalSeconds = computed(() => rows.value.reduce((sum, row) => sum + (row.seconds ?? 0), 0))
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return formatDate(date, timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'hh:mm A')
|
||||
}
|
||||
|
||||
function timeRange(entry: ITimeEntry): string {
|
||||
const start = formatTime(entry.startTime)
|
||||
if (entry.endTime === null) {
|
||||
return `${start} – …`
|
||||
}
|
||||
return `${start} – ${formatTime(entry.endTime)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-action {
|
||||
color: var(--grey-400);
|
||||
transition: color $transition;
|
||||
|
||||
& + & {
|
||||
margin-inline-start: .5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-delete:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="timeTrackingStore.hasActiveTimer"
|
||||
v-cy="'timerBadge'"
|
||||
class="timer-badge"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'time-tracking' }"
|
||||
class="timer-badge__elapsed"
|
||||
:title="$t('timeTracking.title')"
|
||||
>
|
||||
{{ elapsed }}
|
||||
</RouterLink>
|
||||
<BaseButton
|
||||
v-tooltip="$t('timeTracking.stop')"
|
||||
v-cy="'stopTimer'"
|
||||
class="timer-badge__stop"
|
||||
:aria-label="$t('timeTracking.stop')"
|
||||
@click="stop"
|
||||
>
|
||||
<Icon icon="stop" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const now = ref(new Date())
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const elapsed = computed(() => {
|
||||
const timer = timeTrackingStore.activeTimer
|
||||
if (timer === null) {
|
||||
return ''
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor((now.value.getTime() - timer.startTime.getTime()) / 1000))
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const mmss = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
|
||||
return hours >= 1 ? `${hours}:${mmss}` : mmss
|
||||
})
|
||||
|
||||
const isStopping = ref(false)
|
||||
async function stop() {
|
||||
if (isStopping.value) {
|
||||
return
|
||||
}
|
||||
isStopping.value = true
|
||||
try {
|
||||
await timeTrackingStore.stopTimer()
|
||||
} finally {
|
||||
isStopping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The badge lives in the always-mounted header, so it owns the app-wide timer
|
||||
// sync. Subscribing is harmless when the feature is off (no events are emitted);
|
||||
// only the hydrate hits the gated endpoint, so guard that.
|
||||
timeTrackingStore.subscribeToTimerEvents()
|
||||
if (configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) {
|
||||
timeTrackingStore.hydrateActiveTimer()
|
||||
}
|
||||
interval = setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
timeTrackingStore.unsubscribeFromTimerEvents()
|
||||
if (interval !== undefined) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timer-badge__elapsed {
|
||||
padding-inline: .75rem .25rem;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-badge__stop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: .5rem;
|
||||
color: var(--grey-400);
|
||||
transition: color $transition;
|
||||
|
||||
&:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ref } from 'vue'
|
||||
import { getCurrentInstance, ref } from 'vue'
|
||||
import { createGlobalState, useIntervalFn } from '@vueuse/core'
|
||||
import { onBeforeRouteUpdate } from 'vue-router'
|
||||
|
||||
|
|
@ -18,10 +18,14 @@ export const useGlobalNow = createGlobalState(() => {
|
|||
|
||||
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
|
||||
|
||||
// ensure the now value is refreshed when the route changes
|
||||
onBeforeRouteUpdate(() => {
|
||||
update()
|
||||
})
|
||||
// Now that this state can be initialised from a plain helper (formatDateSince), the
|
||||
// first caller is not guaranteed to be a component — guard the route hook accordingly.
|
||||
if (getCurrentInstance()) {
|
||||
// ensure the now value is refreshed when the route changes
|
||||
onBeforeRouteUpdate(() => {
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
now,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {buildStoredQuery} from './useTaskList'
|
||||
|
||||
describe('buildStoredQuery', () => {
|
||||
it('includes sort when set', () => {
|
||||
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'due_date:asc'})
|
||||
})
|
||||
|
||||
it('includes filter and search when set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
|
||||
.toEqual({filter: 'done = false', s: 'foo'})
|
||||
})
|
||||
|
||||
it('omits page when it equals the default of 1', () => {
|
||||
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'id:desc'})
|
||||
})
|
||||
|
||||
it('includes page when greater than 1', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
|
||||
.toEqual({page: '3'})
|
||||
})
|
||||
|
||||
it('returns an empty object when nothing is set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
|
||||
it('skips empty strings', () => {
|
||||
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRouter, isNavigationFailure} from 'vue-router'
|
||||
import type {LocationQueryRaw} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService, {
|
||||
|
|
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useViewFiltersStore} from '@/stores/viewFilters'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
export type Order = 'asc' | 'desc' | 'none'
|
||||
|
|
@ -59,6 +62,22 @@ const SORT_BY_DEFAULT: SortBy = {
|
|||
id: 'desc',
|
||||
}
|
||||
|
||||
interface TaskListQueryState {
|
||||
sort: string | undefined
|
||||
filter: string | undefined
|
||||
s: string | undefined
|
||||
page: number
|
||||
}
|
||||
|
||||
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
|
||||
const query: LocationQueryRaw = {}
|
||||
if (state.sort) query.sort = state.sort
|
||||
if (state.filter) query.filter = state.filter
|
||||
if (state.s) query.s = state.s
|
||||
if (state.page > 1) query.page = String(state.page)
|
||||
return query
|
||||
}
|
||||
|
||||
// This makes sure an id sort order is always sorted last.
|
||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||
// precedence over everything else, making any other sort columns pretty useless.
|
||||
|
|
@ -94,6 +113,9 @@ export function useTaskList(
|
|||
const projectId = computed(() => projectIdGetter())
|
||||
const projectViewId = computed(() => projectViewIdGetter())
|
||||
|
||||
const router = useRouter()
|
||||
const viewFiltersStore = useViewFiltersStore()
|
||||
|
||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
|
@ -119,6 +141,55 @@ export function useTaskList(
|
|||
},
|
||||
})
|
||||
|
||||
// Mirror the URL query bits this composable owns into the store so
|
||||
// in-project tab switches and sidebar re-visits can restore them.
|
||||
//
|
||||
// `ProjectList`/`ProjectTable` are reused across project switches (no
|
||||
// `:key` on them in ProjectView.vue), so setup runs only once. We track
|
||||
// the last viewId we synced — on every viewId transition, if the URL has
|
||||
// none of our params and the store has an entry, restore it via
|
||||
// `router.replace` and skip writing back the empty state we'd otherwise
|
||||
// clobber the saved entry with.
|
||||
let lastSyncedViewId: number | undefined
|
||||
watch(
|
||||
[projectViewId, sortQuery, filter, s, page],
|
||||
([viewId, sortValue, filterValue, sValue, pageValue]) => {
|
||||
const viewIdChanged = viewId !== lastSyncedViewId
|
||||
lastSyncedViewId = viewId
|
||||
|
||||
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
|
||||
// the default so it neither blocks restoration nor wipes stored state.
|
||||
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
|
||||
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
|
||||
if (viewIdChanged && urlIsEmpty) {
|
||||
const storedQuery = viewFiltersStore.getViewQuery(viewId)
|
||||
if (Object.keys(storedQuery).length > 0) {
|
||||
// Merge so unrelated query params on the route survive the restore.
|
||||
// Swallow navigation failures (e.g. aborted/duplicated) so the
|
||||
// ignored promise can't surface as an unhandled rejection.
|
||||
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
|
||||
.catch(failure => {
|
||||
if (!isNavigationFailure(failure)) throw failure
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const query = buildStoredQuery({
|
||||
sort: sortValue as string | undefined,
|
||||
filter: filterValue as string | undefined,
|
||||
s: sValue as string | undefined,
|
||||
page: currentPage,
|
||||
})
|
||||
if (Object.keys(query).length > 0) {
|
||||
viewFiltersStore.setViewQuery(viewId, query)
|
||||
} else {
|
||||
viewFiltersStore.clearViewQuery(viewId)
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import {watch} from 'vue'
|
||||
import {createSharedComposable, tryOnMounted} from '@vueuse/core'
|
||||
import {storeToRefs} from 'pinia'
|
||||
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {getFullBaseUrl} from '@/helpers/getFullBaseUrl'
|
||||
|
||||
const TRACKING_FAVICON = `${getFullBaseUrl()}images/icons/favicon-tracking-32x32.png`
|
||||
|
||||
function getFaviconLink(): HTMLLinkElement | null {
|
||||
return document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||
}
|
||||
|
||||
// Swaps in a favicon with a small red dot in the lower left corner while a timer
|
||||
// is running, so an active time tracking session is visible even when the tab
|
||||
// isn't focused.
|
||||
export const useTimeTrackingFavicon = createSharedComposable(() => {
|
||||
const {hasActiveTimer} = storeToRefs(useTimeTrackingStore())
|
||||
|
||||
const originalHref = getFaviconLink()?.getAttribute('href') ?? '/favicon.ico'
|
||||
|
||||
function update(active: boolean) {
|
||||
const link = getFaviconLink()
|
||||
if (link === null) {
|
||||
return
|
||||
}
|
||||
link.href = active ? TRACKING_FAVICON : originalHref
|
||||
}
|
||||
|
||||
watch(hasActiveTimer, update, {flush: 'post'})
|
||||
tryOnMounted(() => update(hasActiveTimer.value))
|
||||
})
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Licensed "pro" features the server may advertise via /info's enabled_pro_features.
|
||||
// Use these instead of bare strings when calling configStore.isProFeatureEnabled.
|
||||
export const PRO_FEATURE = {
|
||||
ADMIN_PANEL: 'admin_panel',
|
||||
TIME_TRACKING: 'time_tracking',
|
||||
} as const
|
||||
|
||||
export type ProFeature = typeof PRO_FEATURE[keyof typeof PRO_FEATURE]
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Hash-fragment prefix used to carry a post-login destination in the URL.
|
||||
*
|
||||
* Unlike the localStorage redirect, this lives in the address bar so the URL
|
||||
* stays copyable between browsers (needed for native OAuth clients that open
|
||||
* /oauth/authorize, see #2654). It uses the hash – not a query param – so the
|
||||
* embedded OAuth parameters never reach server or proxy access logs.
|
||||
*
|
||||
* Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach
|
||||
* special-cases.
|
||||
*/
|
||||
export const REDIRECT_HASH_PREFIX = '#redirect='
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
||||
|
||||
import {refreshToken, removeToken} from './auth'
|
||||
|
||||
// Count how many times the refresh endpoint is actually POSTed. The whole point
|
||||
// of the in-flight dedup is that concurrent refreshToken() calls share a single
|
||||
// underlying POST, independent of the Web Locks API.
|
||||
let postCallCount = 0
|
||||
let resolvePost: ((value: unknown) => void) | null = null
|
||||
|
||||
vi.mock('@/helpers/fetcher', () => ({
|
||||
HTTPFactory: () => ({
|
||||
post: vi.fn(() => {
|
||||
postCallCount++
|
||||
return new Promise((resolve) => {
|
||||
resolvePost = resolve
|
||||
})
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/desktopAuth', () => ({
|
||||
isDesktopApp: () => false,
|
||||
refreshDesktopToken: vi.fn(),
|
||||
}))
|
||||
|
||||
const FAKE_TOKEN = 'header.payload.signature'
|
||||
|
||||
function settlePost() {
|
||||
resolvePost?.({data: {token: FAKE_TOKEN}})
|
||||
}
|
||||
|
||||
describe('refreshToken in-flight dedup', () => {
|
||||
const originalLocks = navigator.locks
|
||||
|
||||
beforeEach(() => {
|
||||
postCallCount = 0
|
||||
resolvePost = null
|
||||
removeToken()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: originalLocks,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
|
||||
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
|
||||
// undefined, so without this the test would silently fall through to
|
||||
// the insecure-HTTP branch and never exercise navigator.locks.request.
|
||||
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: {request: requestSpy},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const p1 = refreshToken(true)
|
||||
const p2 = refreshToken(true)
|
||||
|
||||
// Both calls share one underlying request.
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
settlePost()
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
// The Web Locks branch actually ran...
|
||||
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
|
||||
// ...and the in-flight dedup still collapsed both calls into one POST.
|
||||
expect(postCallCount).toBe(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
|
||||
// Simulate an insecure HTTP context where navigator.locks is undefined.
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const p1 = refreshToken(true)
|
||||
const p2 = refreshToken(true)
|
||||
const p3 = refreshToken(true)
|
||||
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
settlePost()
|
||||
await Promise.all([p1, p2, p3])
|
||||
|
||||
expect(postCallCount).toBe(1)
|
||||
})
|
||||
|
||||
it('allows a fresh refresh after the previous one settled', async () => {
|
||||
const p1 = refreshToken(true)
|
||||
settlePost()
|
||||
await p1
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
// The in-flight promise was reset, so a later refresh runs anew.
|
||||
const p2 = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
settlePost()
|
||||
await p2
|
||||
})
|
||||
|
||||
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
|
||||
const p1 = refreshToken(true)
|
||||
expect(postCallCount).toBe(1)
|
||||
|
||||
// User logs out while the refresh POST is still in flight.
|
||||
removeToken()
|
||||
|
||||
// The in-flight POST resolves afterwards — it must not undo the logout.
|
||||
settlePost()
|
||||
await p1
|
||||
|
||||
expect(localStorage.getItem('token')).toBeNull()
|
||||
})
|
||||
|
||||
it('an older refresh settling does not clobber a newer in-flight one', async () => {
|
||||
// Refresh A starts and stays in flight.
|
||||
const pA = refreshToken(true)
|
||||
expect(postCallCount).toBe(1)
|
||||
const resolveA = resolvePost
|
||||
|
||||
// User logs out, which drops the in-flight reference to A.
|
||||
removeToken()
|
||||
|
||||
// Refresh B starts; it must claim the in-flight slot.
|
||||
const pB = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
const resolveB = resolvePost
|
||||
|
||||
// A settles after B started. Its cleanup must NOT null the in-flight
|
||||
// slot, since that slot now belongs to B. Without the `=== p` guard,
|
||||
// A's .finally would clobber B and let a concurrent caller fire a
|
||||
// second parallel POST.
|
||||
resolveA?.({data: {token: FAKE_TOKEN}})
|
||||
await pA
|
||||
|
||||
// A concurrent caller while B is still in flight must dedup to B —
|
||||
// no third POST.
|
||||
const pB2 = refreshToken(true)
|
||||
expect(postCallCount).toBe(2)
|
||||
|
||||
resolveB?.({data: {token: FAKE_TOKEN}})
|
||||
await Promise.all([pB, pB2])
|
||||
})
|
||||
})
|
||||
|
|
@ -33,18 +33,53 @@ export const removeToken = () => {
|
|||
savedToken = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('desktopOAuthRefreshToken')
|
||||
|
||||
// Bump the epoch and drop the in-flight refresh so a refresh that started
|
||||
// before this logout can't re-persist a token after we cleared it.
|
||||
authEpoch++
|
||||
inFlightRefresh = null
|
||||
}
|
||||
|
||||
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
|
||||
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
|
||||
// without this guard, refreshes firing close together each spend the single-use
|
||||
// cookie and all but one get a 401.
|
||||
let inFlightRefresh: Promise<void> | null = null
|
||||
|
||||
// Incremented on every removeToken()/logout. A refresh captures the epoch when
|
||||
// it starts and only persists its result if the epoch is unchanged, so a
|
||||
// refresh that resolves after a logout can't undo it.
|
||||
let authEpoch = 0
|
||||
|
||||
/**
|
||||
* Refreshes an auth token while ensuring it is updated everywhere.
|
||||
* The refresh token is sent automatically as an HttpOnly cookie.
|
||||
* The server rotates the cookie on every call.
|
||||
*
|
||||
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
|
||||
* performs the actual refresh; other tabs waiting for the lock detect that
|
||||
* the token in localStorage was already updated and adopt it directly.
|
||||
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the
|
||||
* Web Locks API inside adds cross-tab coordination only in secure contexts.
|
||||
*/
|
||||
export async function refreshToken(persist: boolean): Promise<void> {
|
||||
if (inFlightRefresh) {
|
||||
return inFlightRefresh
|
||||
}
|
||||
const p = doRefresh(persist)
|
||||
inFlightRefresh = p
|
||||
// Only clear if it still points to this promise — a logout (or a newer
|
||||
// refresh started after it) may have replaced inFlightRefresh meanwhile.
|
||||
p.finally(() => {
|
||||
if (inFlightRefresh === p) {
|
||||
inFlightRefresh = null
|
||||
}
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
async function doRefresh(persist: boolean): Promise<void> {
|
||||
// Snapshot the epoch so we can tell if a logout happened while we awaited.
|
||||
const epochAtStart = authEpoch
|
||||
const loggedOutSinceStart = () => authEpoch !== epochAtStart
|
||||
|
||||
// In desktop mode, refresh via IPC to the Electron main process
|
||||
if (isDesktopApp()) {
|
||||
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
|
||||
|
|
@ -53,6 +88,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
}
|
||||
try {
|
||||
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
saveToken(tokens.access_token, persist)
|
||||
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
|
||||
} catch (e) {
|
||||
|
|
@ -65,7 +103,13 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
// if another tab refreshed while we were queued.
|
||||
const tokenBeforeLock = localStorage.getItem('token')
|
||||
|
||||
const doRefresh = async () => {
|
||||
const refreshUnderLock = async () => {
|
||||
// A logout may have happened while we waited for the lock — don't
|
||||
// re-adopt or re-fetch a token after the user signed out.
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the token in localStorage changed while waiting for the lock,
|
||||
// another tab already refreshed. Just adopt the new token.
|
||||
const currentToken = localStorage.getItem('token')
|
||||
|
|
@ -78,6 +122,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
const HTTP = HTTPFactory()
|
||||
try {
|
||||
const response = await HTTP.post('user/token/refresh')
|
||||
if (loggedOutSinceStart()) {
|
||||
return
|
||||
}
|
||||
saveToken(response.data.token, persist)
|
||||
} catch (e) {
|
||||
throw new Error('Error renewing token: ', {cause: e})
|
||||
|
|
@ -85,10 +132,10 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
|||
}
|
||||
|
||||
if (navigator.locks) {
|
||||
await navigator.locks.request('vikunja-token-refresh', doRefresh)
|
||||
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock)
|
||||
} else {
|
||||
// Fallback for environments without Web Locks (e.g. insecure HTTP)
|
||||
await doRefresh()
|
||||
await refreshUnderLock()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,5 +10,9 @@ export function getProjectTitle(project: IProject) {
|
|||
return i18n.global.t('project.inboxTitle')
|
||||
}
|
||||
|
||||
if (project.title === 'My Open Tasks') {
|
||||
return i18n.global.t('project.myOpenTasksFilterTitle')
|
||||
}
|
||||
|
||||
return project.title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId'
|
|||
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
|
||||
import {nextTick} from 'vue'
|
||||
import {eventToShortcutString} from '@/helpers/shortcut'
|
||||
import type {Editor} from '@tiptap/core'
|
||||
import {getPopupContainer} from '@/components/input/editor/popupContainer'
|
||||
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
|
||||
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const id = 'link-input-' + createRandomID()
|
||||
// Append inside the open task <dialog> (top-layer) when present, otherwise
|
||||
// document.body. A body-level popup is painted behind a showModal() dialog
|
||||
// and unfocusable through its focus trap, breaking the link prompt in the
|
||||
// Kanban task popup (#2940).
|
||||
const container = getPopupContainer(editor)
|
||||
|
||||
// Create popup element
|
||||
const popupElement = document.createElement('div')
|
||||
|
|
@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
inputElement.value = oldValue
|
||||
wrapperDiv.appendChild(inputElement)
|
||||
popupElement.appendChild(wrapperDiv)
|
||||
document.body.appendChild(popupElement)
|
||||
container.appendChild(popupElement)
|
||||
|
||||
// Create a local mutable copy of the position for scroll tracking
|
||||
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
|
||||
|
|
@ -82,15 +89,41 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
|
||||
nextTick(() => document.getElementById(id)?.focus())
|
||||
|
||||
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
|
||||
// dialogs close themselves on Escape ("cancel"); swallow that while the
|
||||
// prompt is open so Escape only dismisses the prompt, not the task dialog.
|
||||
const dialog = container.closest('dialog') as HTMLDialogElement | null
|
||||
const handleDialogCancel = (event: Event) => event.preventDefault()
|
||||
dialog?.addEventListener('cancel', handleDialogCancel)
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popupElement.contains(event.target as Node)) {
|
||||
resolve('')
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
if (document.body.contains(popupElement)) {
|
||||
document.body.removeChild(popupElement)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
dialog?.removeEventListener('cancel', handleDialogCancel)
|
||||
if (container.contains(popupElement)) {
|
||||
container.removeChild(popupElement)
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById(id)?.addEventListener('keydown', event => {
|
||||
const shortcutString = eventToShortcutString(event)
|
||||
|
||||
if (shortcutString === 'Escape') {
|
||||
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resolve('')
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
if (shortcutString !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
|
@ -105,15 +138,6 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
|||
cleanup()
|
||||
})
|
||||
|
||||
// Close on click outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!popupElement.contains(event.target as Node)) {
|
||||
resolve('')
|
||||
cleanup()
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
|
||||
// Add slight delay to prevent immediate closing
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ export const redirectToProvider = (provider: IProvider) => {
|
|||
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
|
||||
}
|
||||
|
||||
export const redirectToProviderOnLogout = (provider: IProvider) => {
|
||||
export const redirectToProviderOnLogout = (provider: IProvider): boolean => {
|
||||
if (provider.logoutUrl.length > 0) {
|
||||
window.location.href = `${provider.logoutUrl}`
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {i18n} from '@/i18n'
|
|||
import {createSharedComposable} from '@vueuse/core'
|
||||
import {computed, toValue, type MaybeRefOrGetter} from 'vue'
|
||||
import {useDateDisplay} from '@/composables/useDateDisplay'
|
||||
import {useGlobalNow} from '@/composables/useGlobalNow'
|
||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
|
||||
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat'
|
||||
|
|
@ -49,8 +50,13 @@ export const formatDateSince = (date: Date | string | null) => {
|
|||
|
||||
const locale = DAYJS_LOCALE_MAPPING[i18n.global.locale.value.toLowerCase()] ?? 'en'
|
||||
|
||||
// Computing the relative string against the shared, ticking `now` (instead of fromNow's
|
||||
// internal Date.now()) makes every reactive caller re-render on the 60s tick, so open views
|
||||
// don't keep showing a stale "x minutes ago".
|
||||
const {now} = useGlobalNow()
|
||||
|
||||
return date
|
||||
? dayjs(date).locale(locale).fromNow()
|
||||
? dayjs(date).locale(locale).from(now.value)
|
||||
: ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {smartFillStart} from './smartFillStart'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
function entry(startTime: Date, endTime: Date | null): ITimeEntry {
|
||||
return {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
taskId: 0,
|
||||
projectId: 0,
|
||||
startTime,
|
||||
endTime,
|
||||
comment: '',
|
||||
created: startTime,
|
||||
updated: startTime,
|
||||
maxPermission: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('smartFillStart', () => {
|
||||
const now = new Date('2026-06-07T15:30:00')
|
||||
|
||||
it('continues from the latest entry end time', () => {
|
||||
const entries = [
|
||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||
entry(new Date('2026-06-07T11:00:00'), new Date('2026-06-07T12:30:00')),
|
||||
]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T12:30:00'))
|
||||
})
|
||||
|
||||
it('ignores still-running entries (no end) when picking the latest end', () => {
|
||||
const entries = [
|
||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||
entry(new Date('2026-06-07T13:00:00'), null),
|
||||
]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T10:00:00'))
|
||||
})
|
||||
|
||||
it('falls back to the default start time on the current day when there are no entries', () => {
|
||||
expect(smartFillStart([], '08:15', now)).toEqual(new Date('2026-06-07T08:15:00'))
|
||||
})
|
||||
|
||||
it('falls back to 09:00 when no default is configured', () => {
|
||||
expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00'))
|
||||
})
|
||||
|
||||
it('caps the default start at now when it would be in the future (before 09:00)', () => {
|
||||
const beforeNine = new Date('2026-06-07T07:30:00')
|
||||
expect(smartFillStart([], '09:00', beforeNine)).toEqual(beforeNine)
|
||||
})
|
||||
|
||||
it('caps a future last-entry end at now', () => {
|
||||
const entries = [entry(new Date('2026-06-07T16:00:00'), new Date('2026-06-07T17:00:00'))]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(now)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
// The smart-clock start time: continue from the most recent entry's end so
|
||||
// consecutive entries don't overlap or leave gaps; with no completed entry to
|
||||
// continue from, fall back to the user's configured default start (HH:MM) on
|
||||
// the given day.
|
||||
export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date {
|
||||
// The filled range ends at now, so a start after now would be inverted (and
|
||||
// rejected on save). Cap at now — e.g. the 09:00 fallback before 9am.
|
||||
const cap = (start: Date) => (start.getTime() > now.getTime() ? new Date(now) : start)
|
||||
|
||||
const lastEnd = recentEntries
|
||||
.map(entry => entry.endTime)
|
||||
.filter((end): end is Date => end !== null)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
if (lastEnd !== undefined) {
|
||||
return cap(new Date(lastEnd))
|
||||
}
|
||||
|
||||
const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number)
|
||||
const start = new Date(now)
|
||||
start.setHours(hours || 0, minutes || 0, 0, 0)
|
||||
return cap(start)
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ export const SUPPORTED_LOCALES = {
|
|||
'ja-JP': '日本語',
|
||||
'hu-HU': 'Magyar',
|
||||
'ar-SA': 'اَلْعَرَبِيَّةُ',
|
||||
'fa-IR': 'فارسی',
|
||||
'sl-SI': 'Slovenščina',
|
||||
'pt-BR': 'Português Brasileiro',
|
||||
'hr-HR': 'Hrvatski',
|
||||
|
|
@ -52,7 +53,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
|
|||
|
||||
export type ISOLanguage = string
|
||||
|
||||
const RTL_LANGUAGES = ['ar-SA', 'he-IL'] as const
|
||||
const RTL_LANGUAGES = ['ar-SA', 'he-IL', 'fa-IR'] as const
|
||||
|
||||
export function isRTLLanguage(locale: SupportedLocale): boolean {
|
||||
return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number])
|
||||
|
|
|
|||
|
|
@ -284,8 +284,7 @@
|
|||
"default": "افتراضي",
|
||||
"month": "شهر",
|
||||
"day": "يوم",
|
||||
"hour": "ساعة",
|
||||
"range": "نطاق التاريخ"
|
||||
"hour": "ساعة"
|
||||
},
|
||||
"table": {
|
||||
"title": "جدول",
|
||||
|
|
@ -294,7 +293,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "الحد: {limit}",
|
||||
"noLimit": "غير محدد",
|
||||
"doneBucket": "حافظة المهام المكتملة",
|
||||
"doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.",
|
||||
"doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",
|
||||
|
|
|
|||
|
|
@ -314,8 +314,7 @@
|
|||
"default": "По подразбиране",
|
||||
"month": "Месец",
|
||||
"day": "Ден",
|
||||
"hour": "Час",
|
||||
"range": "Времеви диапазон"
|
||||
"hour": "Час"
|
||||
},
|
||||
"table": {
|
||||
"title": "Таблица",
|
||||
|
|
@ -324,7 +323,6 @@
|
|||
"kanban": {
|
||||
"title": "Канбан",
|
||||
"limit": "Лимит: {limit}",
|
||||
"noLimit": "Не е зададен",
|
||||
"doneBucket": "Колона за завършени",
|
||||
"doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.",
|
||||
"doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",
|
||||
|
|
|
|||
|
|
@ -383,7 +383,6 @@
|
|||
"month": "Měsíc",
|
||||
"day": "Den",
|
||||
"hour": "Hodina",
|
||||
"range": "Časové období",
|
||||
"chartLabel": "Projektový Ganttův diagram",
|
||||
"taskBarsForRow": "Chlívky pro řádek {rowId}",
|
||||
"taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.",
|
||||
|
|
@ -412,7 +411,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nenastaveno",
|
||||
"doneBucket": "Sloupec \"Hotovo\"",
|
||||
"doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.",
|
||||
"doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||
},
|
||||
"timeFormat": "Zeitformat",
|
||||
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12 Stunden (AM/PM)",
|
||||
"24h": "24 Stunden (HH:mm)"
|
||||
|
|
@ -348,6 +349,7 @@
|
|||
"shared": "Geteilte Projekte",
|
||||
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
|
||||
"inboxTitle": "Eingang",
|
||||
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
|
||||
"favorite": "Dieses Projekt als Favorit markieren",
|
||||
"unfavorite": "Dieses Projekt von Favoriten entfernen",
|
||||
"openSettingsMenu": "Projekteinstellungen öffnen",
|
||||
|
|
@ -392,6 +394,7 @@
|
|||
"title": "Dupliziere dieses Projekt",
|
||||
"label": "Duplizieren",
|
||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -470,7 +473,6 @@
|
|||
"month": "Monat",
|
||||
"day": "Tag",
|
||||
"hour": "Stunde",
|
||||
"range": "Zeitraum",
|
||||
"chartLabel": "Projekt Gantt-Diagramm",
|
||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||
|
|
@ -499,7 +501,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nicht gesetzt",
|
||||
"doneBucket": "Erledigt Spalte",
|
||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||
|
|
@ -783,7 +784,10 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach"
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -993,6 +997,7 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1462,6 +1467,32 @@
|
|||
"frontendVersion": "Frontend-Version: {version}",
|
||||
"apiVersion": "API-Version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Zeiterfassung",
|
||||
"stop": "Timer stoppen",
|
||||
"logTime": "Zeit buchen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"form": {
|
||||
"task": "Aufgabe",
|
||||
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||
"save": "Speichern",
|
||||
"startTimer": "Timer starten",
|
||||
"update": "Eintrag aktualisieren",
|
||||
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||
"total": "Gesamt",
|
||||
"time": "Uhrzeit",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Bereich wählen",
|
||||
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "Sekunde|Sekunden",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||
},
|
||||
"timeFormat": "Zeitformat",
|
||||
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12 Stunden (AM/PM)",
|
||||
"24h": "24 Stunden (HH:mm)"
|
||||
|
|
@ -348,6 +349,7 @@
|
|||
"shared": "Geteilte Projekte",
|
||||
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
|
||||
"inboxTitle": "Eingang",
|
||||
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
|
||||
"favorite": "Dieses Projekt als Favorit markieren",
|
||||
"unfavorite": "Dieses Projekt von Favoriten entfernen",
|
||||
"openSettingsMenu": "Projekteinstellungen öffnen",
|
||||
|
|
@ -392,6 +394,7 @@
|
|||
"title": "Dupliziere dieses Projekt",
|
||||
"label": "Duplizieren",
|
||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -470,7 +473,6 @@
|
|||
"month": "Monat",
|
||||
"day": "Tag",
|
||||
"hour": "Stunde",
|
||||
"range": "Zeitraum",
|
||||
"chartLabel": "Projekt Gantt-Diagramm",
|
||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||
|
|
@ -499,7 +501,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nicht gesetzt",
|
||||
"doneBucket": "Erledigt Spalte",
|
||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||
|
|
@ -783,7 +784,10 @@
|
|||
"closeDialog": "Dialog schließen",
|
||||
"closeQuickActions": "Schnellaktionen schließen",
|
||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||
"sortBy": "Sortieren nach"
|
||||
"sortBy": "Sortieren nach",
|
||||
"dateRange": "Zeitraum",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"user": "Benutzer:in"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projektfarbe",
|
||||
|
|
@ -993,6 +997,7 @@
|
|||
"repeatAfter": "Wiederholung setzen",
|
||||
"percentDone": "Fortschritt einstellen",
|
||||
"attachments": "Anhänge hinzufügen",
|
||||
"timeTracking": "Zeit erfassen",
|
||||
"relatedTasks": "Beziehung hinzufügen",
|
||||
"moveProject": "Verschieben",
|
||||
"duplicate": "Duplizieren",
|
||||
|
|
@ -1462,6 +1467,32 @@
|
|||
"frontendVersion": "Frontend-Version: {version}",
|
||||
"apiVersion": "API-Version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Zeiterfassung",
|
||||
"stop": "Timer stoppen",
|
||||
"logTime": "Zeit buchen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"form": {
|
||||
"task": "Aufgabe",
|
||||
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||
"save": "Speichern",
|
||||
"startTimer": "Timer starten",
|
||||
"update": "Eintrag aktualisieren",
|
||||
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||
"total": "Gesamt",
|
||||
"time": "Uhrzeit",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Bereich wählen",
|
||||
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "Sekunde|Sekunden",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ"
|
||||
},
|
||||
"timeFormat": "Μορφή ώρας",
|
||||
"timeTrackingDefaultStart": "Ώρα έναρξης παρακολούθησης χρόνου με έξυπνο-γέμισμα",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12 ώρες (ΠΜ/ΜΜ)",
|
||||
"24h": "24 ώρες (ΩΩ:ΛΛ)"
|
||||
|
|
@ -392,6 +393,7 @@
|
|||
"title": "Αντιγραφή του έργου",
|
||||
"label": "Αντιγραφή",
|
||||
"text": "Επιλέξτε ένα γονικό έργο που θα περιλαμβάνει το αντίγραφο του έργου:",
|
||||
"shares": "Αντιγραφή διαμοιρασμών (χρήστες, ομάδες και σύνδεσμοι διαμοιρασμού) στο αντίγραφο",
|
||||
"success": "Το έργο αντιγράφηκε με επιτυχία."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -470,7 +472,6 @@
|
|||
"month": "Μήνας",
|
||||
"day": "Ημέρα",
|
||||
"hour": "Ώρα",
|
||||
"range": "Εύρος Ημερομηνιών",
|
||||
"chartLabel": "Γράφημα Gantt Έργου",
|
||||
"taskBarsForRow": "Μπάρες εργασίας για τη γραμμή {rowId}",
|
||||
"taskBarLabel": "Εργασία: {task}. Από {startDate} έως {endDate}. {dateType}. Κάντε κλικ για επεξεργασία, σύρετε για μετακίνηση.",
|
||||
|
|
@ -499,7 +500,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Όριο: {limit}",
|
||||
"noLimit": "Δεν έχει οριστεί",
|
||||
"doneBucket": "Κάδος για ολοκληρωμένα",
|
||||
"doneBucketHint": "Όλες οι εργασίες που μετακινούνται σε αυτόν τον κάδο θα σημειώνονται αυτόματα ως ολοκληρωμένες.",
|
||||
"doneBucketHintExtended": "Όλες οι εργασίες που μετακινούνται στον κάδο των ολοκληρωμένων θα σημειώνονται αυτόματα ως ολοκληρωμένες. Όλες οι εργασίες που σημειώνονται ως ολοκληρωμένες από αλλού επίσης θα μετακινηθούν.",
|
||||
|
|
@ -783,7 +783,10 @@
|
|||
"closeDialog": "Κλείσμο του διαλόγου",
|
||||
"closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών",
|
||||
"skipToContent": "Μετάβαση στο κύριο περιεχόμενο",
|
||||
"sortBy": "Ταξινόμηση ανά"
|
||||
"sortBy": "Ταξινόμηση ανά",
|
||||
"dateRange": "Εύρος ημερομηνιών",
|
||||
"notSet": "Μη ορισμένο",
|
||||
"user": "Χρήστης"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Χρώμα έργου",
|
||||
|
|
@ -993,6 +996,7 @@
|
|||
"repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος",
|
||||
"percentDone": "Ορισμός Προόδου",
|
||||
"attachments": "Προσθήκη Συνημμένων",
|
||||
"timeTracking": "Χρόνος ίχνους",
|
||||
"relatedTasks": "Προσθήκη Συσχέτισης",
|
||||
"moveProject": "Μετακίνηση",
|
||||
"duplicate": "Αντιγραφή",
|
||||
|
|
@ -1462,6 +1466,32 @@
|
|||
"frontendVersion": "Έκδοση frontend: {version}",
|
||||
"apiVersion": "Έκδοση API: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Ιχνηλάτηση χρόνου",
|
||||
"stop": "Διακοπή χρονομέτρου",
|
||||
"logTime": "Καταγραφή χρόνου",
|
||||
"editEntry": "Επεξεργασία εγγραφής",
|
||||
"form": {
|
||||
"task": "Εργασία",
|
||||
"taskSearch": "Αναζήτηση για μια εργασία…",
|
||||
"commentPlaceholder": "Σε τι δουλέψατε;",
|
||||
"save": "Αποθήκευση εγγραφής",
|
||||
"startTimer": "Έναρξη χρονοµέτρου",
|
||||
"update": "Ενημέρωση εγγραφής",
|
||||
"smartFill": "Συμπλήρωση από την τελευταία καταχώριση"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Δεν καταγράφηκε ακόμη χρόνος για αυτήν την εργασία.",
|
||||
"emptyFiltered": "Δεν καταγράφηκε χρόνος με βάση τα επιλεγμένα φίλτρα.",
|
||||
"total": "Σύνολο",
|
||||
"time": "Ώρα",
|
||||
"duration": "Διάρκεια"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Επιλέξτε ένα εύρος",
|
||||
"userSearch": "Αναζήτηση για ένα χρήστη…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "δευτερόλεπτο|δευτερόλεπτα",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy\/mm\/dd": "YYYY\/MM\/DD"
|
||||
},
|
||||
"timeFormat": "Time format",
|
||||
"timeTrackingDefaultStart": "Time tracking smart-fill start time",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12-hour (AM/PM)",
|
||||
"24h": "24-hour (HH:mm)"
|
||||
|
|
@ -348,6 +349,7 @@
|
|||
"shared": "Shared Projects",
|
||||
"noDescriptionAvailable": "No project description is available.",
|
||||
"inboxTitle": "Inbox",
|
||||
"myOpenTasksFilterTitle": "My Open Tasks",
|
||||
"favorite": "Mark this project as favorite",
|
||||
"unfavorite": "Remove this project from favorites",
|
||||
"openSettingsMenu": "Open project settings menu",
|
||||
|
|
@ -392,6 +394,7 @@
|
|||
"title": "Duplicate this project",
|
||||
"label": "Duplicate",
|
||||
"text": "Select a parent project which should hold the duplicated project:",
|
||||
"shares": "Copy shares (users, teams and link shares) to the duplicate",
|
||||
"success": "The project was successfully duplicated."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -470,7 +473,6 @@
|
|||
"month": "Month",
|
||||
"day": "Day",
|
||||
"hour": "Hour",
|
||||
"range": "Date Range",
|
||||
"chartLabel": "Project Gantt Chart",
|
||||
"taskBarsForRow": "Task bars for row {rowId}",
|
||||
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
|
||||
|
|
@ -499,7 +501,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Not Set",
|
||||
"doneBucket": "Done bucket",
|
||||
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
|
||||
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
|
||||
|
|
@ -783,7 +784,10 @@
|
|||
"closeDialog": "Close dialog",
|
||||
"closeQuickActions": "Close quick actions",
|
||||
"skipToContent": "Skip to main content",
|
||||
"sortBy": "Sort by"
|
||||
"sortBy": "Sort by",
|
||||
"dateRange": "Date range",
|
||||
"notSet": "Not set",
|
||||
"user": "User"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Project color",
|
||||
|
|
@ -993,6 +997,7 @@
|
|||
"repeatAfter": "Set Repeating Interval",
|
||||
"percentDone": "Set Progress",
|
||||
"attachments": "Add Attachments",
|
||||
"timeTracking": "Track time",
|
||||
"relatedTasks": "Add Relation",
|
||||
"moveProject": "Move",
|
||||
"duplicate": "Duplicate",
|
||||
|
|
@ -1462,6 +1467,32 @@
|
|||
"frontendVersion": "Frontend version: {version}",
|
||||
"apiVersion": "API version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Time tracking",
|
||||
"stop": "Stop timer",
|
||||
"logTime": "Log time",
|
||||
"editEntry": "Edit entry",
|
||||
"form": {
|
||||
"task": "Task",
|
||||
"taskSearch": "Search for a task…",
|
||||
"commentPlaceholder": "What did you work on?",
|
||||
"save": "Save entry",
|
||||
"startTimer": "Start timer",
|
||||
"update": "Update entry",
|
||||
"smartFill": "Fill from last entry"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "No time tracked for this task yet.",
|
||||
"emptyFiltered": "No time tracked for the selected filters.",
|
||||
"total": "Total",
|
||||
"time": "Time",
|
||||
"duration": "Duration"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Select a range",
|
||||
"userSearch": "Search for a user…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "second|seconds",
|
||||
|
|
|
|||
|
|
@ -251,8 +251,7 @@
|
|||
"default": "Predeterminado",
|
||||
"month": "Mes",
|
||||
"day": "Día",
|
||||
"hour": "Hora",
|
||||
"range": "Rango de fechas"
|
||||
"hour": "Hora"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabla",
|
||||
|
|
@ -261,7 +260,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Límite: {limit}",
|
||||
"noLimit": "No Establecido",
|
||||
"doneBucket": "Contenedor completado",
|
||||
"doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.",
|
||||
"doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -347,8 +347,7 @@
|
|||
"default": "Oletus",
|
||||
"month": "Kuukausi",
|
||||
"day": "Päivä",
|
||||
"hour": "Tunti",
|
||||
"range": "Ajanjakso"
|
||||
"hour": "Tunti"
|
||||
},
|
||||
"table": {
|
||||
"title": "Taulukko",
|
||||
|
|
@ -357,7 +356,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Raja: {limit}",
|
||||
"noLimit": "Ei Asetettu",
|
||||
"doneBucket": "Valmiit sarake",
|
||||
"doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.",
|
||||
"doneBucketHintExtended": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi. Muualla valmiiksi merkityt tehtävät siirretään myös.",
|
||||
|
|
|
|||
|
|
@ -346,7 +346,6 @@
|
|||
"month": "Mois",
|
||||
"day": "Jour",
|
||||
"hour": "Heure",
|
||||
"range": "Intervalle",
|
||||
"chartLabel": "Diagramme de Gantt du projet",
|
||||
"taskBarsForRow": "Barres de tâches pour la ligne {rowId}",
|
||||
"taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.",
|
||||
|
|
@ -370,7 +369,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite : {limit}",
|
||||
"noLimit": "Non défini",
|
||||
"doneBucket": "Colonne des tâches terminées",
|
||||
"doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.",
|
||||
"doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.",
|
||||
|
|
|
|||
|
|
@ -318,8 +318,7 @@
|
|||
"default": "ברירת מחדל",
|
||||
"month": "חודש",
|
||||
"day": "יום",
|
||||
"hour": "שעה",
|
||||
"range": "טווח תאריכים"
|
||||
"hour": "שעה"
|
||||
},
|
||||
"table": {
|
||||
"title": "טבלה",
|
||||
|
|
@ -328,7 +327,6 @@
|
|||
"kanban": {
|
||||
"title": "קאנבאן",
|
||||
"limit": "הגבלה: {limit}",
|
||||
"noLimit": "לא נקבע",
|
||||
"doneBucket": "דלי גמורים",
|
||||
"doneBucketHint": "דלי גמורים נשמר בהצלחה.",
|
||||
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",
|
||||
|
|
|
|||
|
|
@ -289,16 +289,14 @@
|
|||
"default": "Zadano",
|
||||
"month": "Mjesec",
|
||||
"day": "Dan",
|
||||
"hour": "Sat",
|
||||
"range": "Raspon datuma"
|
||||
"hour": "Sat"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tablica",
|
||||
"columns": "Stupci"
|
||||
},
|
||||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"noLimit": "Nije postavljeno"
|
||||
"title": "Kanban"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
|
|||
|
|
@ -290,8 +290,7 @@
|
|||
"default": "Alapértelmezett",
|
||||
"month": "Hónap",
|
||||
"day": "Nap",
|
||||
"hour": "Óra",
|
||||
"range": "Időintervallum"
|
||||
"hour": "Óra"
|
||||
},
|
||||
"table": {
|
||||
"title": "Táblázat",
|
||||
|
|
@ -300,7 +299,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Korlát: {limit}",
|
||||
"noLimit": "Nincs beállítva",
|
||||
"doneBucket": "Kész vödör",
|
||||
"doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.",
|
||||
"doneBucketHintExtended": "A kész csoportba áthelyezett összes feladat automatikusan készként lesz megjelölve. A máshonnan elvégzettként megjelölt összes feladat is átkerül.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Mese",
|
||||
"day": "Giorno",
|
||||
"hour": "Ora",
|
||||
"range": "Intervallo di date",
|
||||
"chartLabel": "Progetto diagramma di Gantt",
|
||||
"taskBarsForRow": "Barre delle attività per riga {rowId}",
|
||||
"taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Non Impostato",
|
||||
"doneBucket": "Colonna attività completate",
|
||||
"doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
|
||||
"doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.",
|
||||
|
|
|
|||
|
|
@ -470,7 +470,6 @@
|
|||
"month": "月",
|
||||
"day": "日",
|
||||
"hour": "時間",
|
||||
"range": "期間",
|
||||
"chartLabel": "プロジェクトガントチャート",
|
||||
"taskBarsForRow": "行 {rowId} のタスクバー",
|
||||
"taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。",
|
||||
|
|
@ -499,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "カンバン",
|
||||
"limit": "上限: {limit}",
|
||||
"noLimit": "未設定",
|
||||
"doneBucket": "バケットを完了",
|
||||
"doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。",
|
||||
"doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",
|
||||
|
|
|
|||
|
|
@ -323,8 +323,7 @@
|
|||
"default": "기본값",
|
||||
"month": "월",
|
||||
"day": "일",
|
||||
"hour": "시",
|
||||
"range": "날짜 범위"
|
||||
"hour": "시"
|
||||
},
|
||||
"table": {
|
||||
"title": "테이블",
|
||||
|
|
@ -333,7 +332,6 @@
|
|||
"kanban": {
|
||||
"title": "칸반",
|
||||
"limit": "제한: {limit}",
|
||||
"noLimit": "설정 안함",
|
||||
"doneBucket": "완료 버킷",
|
||||
"doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.",
|
||||
"doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.",
|
||||
|
|
|
|||
|
|
@ -320,8 +320,7 @@
|
|||
"default": "Numatytasis",
|
||||
"month": "Mėnuo",
|
||||
"day": "Diena",
|
||||
"hour": "Valanda",
|
||||
"range": "Datos intervalas"
|
||||
"hour": "Valanda"
|
||||
},
|
||||
"table": {
|
||||
"title": "Lentelė",
|
||||
|
|
@ -330,7 +329,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanbanas",
|
||||
"limit": "Limitas: {limit}",
|
||||
"noLimit": "Nenustatytas",
|
||||
"doneBucket": "Atliktųjų telkinys",
|
||||
"doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.",
|
||||
"doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.",
|
||||
|
|
|
|||
|
|
@ -470,7 +470,6 @@
|
|||
"month": "Maand",
|
||||
"day": "Dag",
|
||||
"hour": "Uur",
|
||||
"range": "Datumbereik",
|
||||
"chartLabel": "Project Gantt-diagram",
|
||||
"taskBarsForRow": "Taakbalken voor rij {rowId}",
|
||||
"taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.",
|
||||
|
|
@ -499,7 +498,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limiet: {limit}",
|
||||
"noLimit": "Niet ingesteld",
|
||||
"doneBucket": "Categorie 'voltooid'",
|
||||
"doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.",
|
||||
"doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.",
|
||||
|
|
|
|||
|
|
@ -353,7 +353,6 @@
|
|||
"month": "Måned",
|
||||
"day": "Dag",
|
||||
"hour": "Time",
|
||||
"range": "Datointervall",
|
||||
"chartLabel": "Gantt-kart for prosjekt",
|
||||
"taskBarsForRow": "Oppgavelinjer for rad {rowId}",
|
||||
"taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.",
|
||||
|
|
@ -377,7 +376,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Begrens: {limit}",
|
||||
"noLimit": "Ikke angitt",
|
||||
"doneBucket": "Ferdigkurv",
|
||||
"doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.",
|
||||
"doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.",
|
||||
|
|
|
|||
|
|
@ -300,8 +300,7 @@
|
|||
"default": "Domyślnie",
|
||||
"month": "Miesiąc",
|
||||
"day": "Dzień",
|
||||
"hour": "Godzina",
|
||||
"range": "Zakres dat"
|
||||
"hour": "Godzina"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -310,7 +309,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Nie ustawiony",
|
||||
"doneBucket": "Zakończone zadania",
|
||||
"doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.",
|
||||
"doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.",
|
||||
|
|
|
|||
|
|
@ -286,8 +286,7 @@
|
|||
"default": "Padrão",
|
||||
"month": "Mês",
|
||||
"day": "Dia",
|
||||
"hour": "Hora",
|
||||
"range": "Período"
|
||||
"hour": "Hora"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -296,7 +295,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Não definido",
|
||||
"doneBucket": "Bucket concluído",
|
||||
"doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.",
|
||||
"doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Mês",
|
||||
"day": "Dia",
|
||||
"hour": "Hora",
|
||||
"range": "Intervalo de Datas",
|
||||
"chartLabel": "Gráfico de Gantt do projeto",
|
||||
"taskBarsForRow": "Barras de tarefas para a linha {rowId}",
|
||||
"taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limite: {limit}",
|
||||
"noLimit": "Não Definido",
|
||||
"doneBucket": "Conjunto concluído",
|
||||
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.",
|
||||
"doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,32 @@
|
|||
},
|
||||
"home": {
|
||||
"welcomeNight": "Доброй ночи, {username}!",
|
||||
"welcomeNightOwl": "Привет, ночная сова {username}",
|
||||
"welcomeNightBurning": "Работаешь допоздна, {username}?",
|
||||
"welcomeNightQuiet": "Тихие часы, {username}",
|
||||
"welcomeNightLate": "Поздно, {username}",
|
||||
"welcomeMorning": "Доброе утро, {username}!",
|
||||
"welcomeMorningHey": "Привет, {username}, готов?",
|
||||
"welcomeMorningFresh": "Свежий старт, {username}",
|
||||
"welcomeMorningCoffee": "Кофе и задачи, {username}?",
|
||||
"welcomeMorningRise": "Проснись и планируй, {username}",
|
||||
"welcomeMorningBack": "С возвращением, {username}",
|
||||
"welcomeMondayFresh": "Свежая неделя, {username}",
|
||||
"welcomeTuesday": "Счастливого вторника, {username}",
|
||||
"welcomeWednesdayMid": "Уже середина недели, {username}",
|
||||
"welcomeThursday": "Почти готово, {username}",
|
||||
"welcomeFridayPush": "Пятница, {username}?",
|
||||
"welcomeSaturday": "Режим выходных, {username}",
|
||||
"welcomeSundaySession": "Воскресный сеанс, {username}?",
|
||||
"welcomeDay": "Привет, {username}!",
|
||||
"welcomeDayFocus": "Давайте сосредоточимся, {username}",
|
||||
"welcomeDayKeepGoing": "Так держать, {username}",
|
||||
"welcomeDayWhatsNext": "Что дальше, {username}?",
|
||||
"welcomeDayGood": "Добрый день, {username}",
|
||||
"welcomeEvening": "Добрый вечер, {username}!",
|
||||
"welcomeEveningWind": "Заканчиваешь, {username}?",
|
||||
"welcomeEveningReturns": "{username} возвращается",
|
||||
"welcomeEveningOneMore": "Ещё одна вещь, {username}?",
|
||||
"lastViewed": "Последние просмотренные",
|
||||
"addToHomeScreen": "Добавьте это приложение на домашний экран для быстрого доступа и удобной работы.",
|
||||
"goToOverview": "Перейти к обзору",
|
||||
|
|
@ -57,6 +80,11 @@
|
|||
"openIdTotpSubmit": "Продолжить",
|
||||
"oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}",
|
||||
"oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.",
|
||||
"desktopTryDemo": "Попробовать демо-версию",
|
||||
"desktopCustomServer": "Пользовательский URL сервера",
|
||||
"desktopCustomServerDescription": "Введите URL сервера Vikunja, чтобы начать.",
|
||||
"desktopWaitingForAuth": "Ожидание аутентификации…",
|
||||
"desktopOAuthError": "Ошибка аутентификации: {error}",
|
||||
"logout": "Выйти",
|
||||
"emailInvalid": "Введите корректный email адрес.",
|
||||
"usernameRequired": "Введите имя пользователя.",
|
||||
|
|
@ -75,6 +103,19 @@
|
|||
"registrationFailed": "Произошла ошибка при регистрации. Проверьте введённые данные и повторите попытку."
|
||||
},
|
||||
"settings": {
|
||||
"bots": {
|
||||
"title": "Боты",
|
||||
"description": "Боты — это пользователи, которые принадлежат вам и которые имеют доступ только к API. Их можно добавить в проекты, назначить задачи, и аутентификация выполняется с помощью токенов API. Боты не могут использовать обычный интерфейс.",
|
||||
"namePlaceholder": "Мой помощник",
|
||||
"create": "Создать бота",
|
||||
"enable": "Включить",
|
||||
"badge": "Бот",
|
||||
"delete": {
|
||||
"header": "Удалить бота",
|
||||
"text1": "Удалить бота «{username}»?",
|
||||
"text2": "Это необратимо. Любые токены API, принадлежащие этому боту, будут аннулированы."
|
||||
}
|
||||
},
|
||||
"title": "Настройки",
|
||||
"newPasswordTitle": "Изменить пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
|
|
@ -100,6 +141,11 @@
|
|||
"weekStart": "Первый день недели",
|
||||
"weekStartSunday": "Воскресенье",
|
||||
"weekStartMonday": "Понедельник",
|
||||
"weekStartTuesday": "Вторник",
|
||||
"weekStartWednesday": "Среда",
|
||||
"weekStartThursday": "Четверг",
|
||||
"weekStartFriday": "Пятница",
|
||||
"weekStartSaturday": "Суббота",
|
||||
"language": "Язык",
|
||||
"defaultProject": "Проект по умолчанию",
|
||||
"defaultView": "Представление по умолчанию",
|
||||
|
|
@ -133,7 +179,13 @@
|
|||
"taskAndNotifications": "Проекты и задачи",
|
||||
"privacy": "Конфиденциальность",
|
||||
"localization": "Локализация",
|
||||
"appearance": "Внешний вид и поведение"
|
||||
"appearance": "Внешний вид и поведение",
|
||||
"desktop": "Настольное приложение"
|
||||
},
|
||||
"desktop": {
|
||||
"quickEntryShortcut": "Ярлык быстрого входа",
|
||||
"shortcutRecorderPlaceholder": "Нажмите, чтобы задать ярлык",
|
||||
"shortcutRecorderRecording": "Нажмите комбинацию клавиш…"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Двухфакторная аутентификация",
|
||||
|
|
@ -163,6 +215,13 @@
|
|||
"usernameIs": "Имя пользователя для CalDAV: {0}",
|
||||
"apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}."
|
||||
},
|
||||
"feeds": {
|
||||
"title": "Atom-лента",
|
||||
"howTo": "Вы можете подписаться на уведомления Vikunja в любом приложении для чтения новостей, поддерживающем Atom-ленты. Используйте следующий URL:",
|
||||
"usernameIs": "Имя пользователя для доступа к ленте: {0}",
|
||||
"apiTokenHint": "Для аутентификации используйте токен API с разрешением {scope}. Создайте его на странице {link}.",
|
||||
"tokenTitle": "Atom-лента"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Аватар",
|
||||
"initials": "Инициалы",
|
||||
|
|
@ -285,6 +344,7 @@
|
|||
"shared": "Общие проекты",
|
||||
"noDescriptionAvailable": "Описание проекта отсутствует.",
|
||||
"inboxTitle": "Входящие",
|
||||
"myOpenTasksFilterTitle": "Мои открытые задачи",
|
||||
"favorite": "Отметить проект как избранный",
|
||||
"unfavorite": "Удалить проект из избранного",
|
||||
"openSettingsMenu": "Открыть настройки проекта",
|
||||
|
|
@ -329,6 +389,7 @@
|
|||
"title": "Создание копии проекта",
|
||||
"label": "Создать копию",
|
||||
"text": "Выберите родительский проект, в который поместить копию проекта:",
|
||||
"shares": "Скопировать настройки доступа (пользователей, групп и ссылок для обмена)",
|
||||
"success": "Копия проекта создана."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -407,7 +468,6 @@
|
|||
"month": "Месяц",
|
||||
"day": "День",
|
||||
"hour": "Час",
|
||||
"range": "Диапазон",
|
||||
"chartLabel": "Диаграмма Ганта",
|
||||
"taskBarsForRow": "Задачи в строке {rowId}",
|
||||
"taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.",
|
||||
|
|
@ -426,7 +486,8 @@
|
|||
"partialDatesStart": "Только дата начала (без окончания)",
|
||||
"partialDatesEnd": "Только дата окончания (без начала)",
|
||||
"expandGroup": "Развернуть группу: {task}",
|
||||
"collapseGroup": "Свернуть группу: {task}"
|
||||
"collapseGroup": "Свернуть группу: {task}",
|
||||
"toggleRelationArrows": "Переключить стрелки связи"
|
||||
},
|
||||
"table": {
|
||||
"title": "Таблица",
|
||||
|
|
@ -435,7 +496,6 @@
|
|||
"kanban": {
|
||||
"title": "Канбан",
|
||||
"limit": "Лимит: {limit}",
|
||||
"noLimit": "не установлен",
|
||||
"doneBucket": "Колонка завершённых",
|
||||
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
|
||||
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
|
||||
|
|
@ -456,7 +516,8 @@
|
|||
"bucketTitleSavedSuccess": "Название колонки сохранено.",
|
||||
"bucketLimitSavedSuccess": "Лимит колонки сохранён.",
|
||||
"collapse": "Свернуть эту колонку",
|
||||
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи."
|
||||
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи.",
|
||||
"bucketOptions": "Настройки колонки"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
@ -679,7 +740,9 @@
|
|||
"upcoming": "Предстоящие задачи",
|
||||
"settings": "Настройки",
|
||||
"imprint": "Отпечаток",
|
||||
"privacy": "Политика конфиденциальности"
|
||||
"privacy": "Политика конфиденциальности",
|
||||
"closeSidebar": "Закрыть боковую панель",
|
||||
"home": "Главная страница Vikunja"
|
||||
},
|
||||
"misc": {
|
||||
"loading": "Загрузка…",
|
||||
|
|
@ -711,9 +774,17 @@
|
|||
"createdBy": "Создатель {0}",
|
||||
"actions": "Действия",
|
||||
"cannotBeUndone": "Это действие отменить нельзя!",
|
||||
"avatarOfUser": "Изображение профиля {user}"
|
||||
"avatarOfUser": "Изображение профиля {user}",
|
||||
"closeBanner": "Закрыть баннер",
|
||||
"closeDialog": "Закрыть диалог",
|
||||
"closeQuickActions": "Закрыть быстрые действия",
|
||||
"skipToContent": "Перейти к основному содержимому",
|
||||
"dateRange": "Диапазон",
|
||||
"notSet": "Не задано",
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Цвет проекта",
|
||||
"resetColor": "Сбросить цвет",
|
||||
"datepicker": {
|
||||
"today": "Сегодня",
|
||||
|
|
@ -786,6 +857,7 @@
|
|||
"date": "Дата",
|
||||
"ranges": {
|
||||
"today": "Сегодня",
|
||||
"tomorrow": "Завтра",
|
||||
"thisWeek": "Эта неделя",
|
||||
"restOfThisWeek": "Остаток этой недели",
|
||||
"nextWeek": "Следующая неделя",
|
||||
|
|
@ -893,6 +965,8 @@
|
|||
"belongsToProject": "Задача принадлежит проекту «{project}»",
|
||||
"back": "Вернуться к проекту",
|
||||
"due": "Истекает {at}",
|
||||
"closeTaskDetail": "Закрыть детали задачи",
|
||||
"title": "Детали задачи",
|
||||
"scrollToBottom": "Прокрутить до конца страницы",
|
||||
"organization": "Организация",
|
||||
"management": "Управление",
|
||||
|
|
@ -986,7 +1060,10 @@
|
|||
"addedSuccess": "Комментарий добавлен.",
|
||||
"permalink": "Скопировать постоянную ссылку на комментарий",
|
||||
"sortNewestFirst": "Сначала новые",
|
||||
"sortOldestFirst": "Сначала старые"
|
||||
"sortOldestFirst": "Сначала старые",
|
||||
"reply": "Ответить",
|
||||
"jumpToOriginal": "Перейти к исходному комментарию",
|
||||
"deletedComment": "удалённый комментарий"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "Пользователи не найдены"
|
||||
|
|
@ -1250,9 +1327,11 @@
|
|||
"none": "Уведомлений нет. Хорошего дня!",
|
||||
"explainer": "Здесь появятся уведомления, когда что-нибудь произойдёт с проектами или задачами, на которые вы подписаны.",
|
||||
"markAllRead": "Отметить всё как прочитанное",
|
||||
"markAllReadSuccess": "Все уведомления отмечены как прочитанные."
|
||||
"markAllReadSuccess": "Все уведомления отмечены как прочитанные.",
|
||||
"subscribeFeed": "Подписаться на уведомления через Atom-ленту"
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "Сначала войдите в главное окно Vikunja.",
|
||||
"commands": "Команды",
|
||||
"placeholder": "Введите команду или поисковый запрос…",
|
||||
"hint": "Используйте {project}, чтобы ограничить поиск проектом. Комбинируйте {project} и {label} (метки) с поисковым запросом для поиска задачи с этими метками или на этом проекте. Используйте {assignee} для поиска команд.",
|
||||
|
|
@ -1379,5 +1458,66 @@
|
|||
"weeks": "неделя|недели|недель",
|
||||
"years": "год|года|лет"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Администрирование",
|
||||
"labels": {
|
||||
"users": "Пользователи",
|
||||
"tasks": "Задачи"
|
||||
},
|
||||
"overview": {
|
||||
"shares": "Общий доступ",
|
||||
"linkSharesShort": "ссылка",
|
||||
"teamSharesShort": "группа",
|
||||
"userSharesShort": "пользователь",
|
||||
"version": "Версия",
|
||||
"license": "Лицензия",
|
||||
"licenseValidUntil": "Истекает",
|
||||
"licenseExpiresIn": "через {days} дней",
|
||||
"licenseLastVerified": "Последняя проверка",
|
||||
"licenseNever": "никогда",
|
||||
"licenseLastCheckFailed": "последняя проверка не удалась",
|
||||
"licenseFeatures": "Возможности",
|
||||
"licenseInstance": "ID экземпляра",
|
||||
"licenseManage": "Управление"
|
||||
},
|
||||
"searchUsersPlaceholder": "Поиск по имени пользователя или электронной почте…",
|
||||
"users": {
|
||||
"status": "Статус",
|
||||
"details": "Детали",
|
||||
"detailsTitle": "Пользователь: {username}",
|
||||
"issuer": "Издатель",
|
||||
"issuerLocal": "Локальный",
|
||||
"issuerUrl": "URL издателя",
|
||||
"subject": "Тема",
|
||||
"statusActive": "Активен",
|
||||
"statusEmailConfirmation": "Нужно подтвердить почту",
|
||||
"statusDisabled": "Отключен",
|
||||
"statusLocked": "Заблокирован",
|
||||
"isAdminLabel": "Администратор",
|
||||
"addUser": "Добавить пользователя",
|
||||
"createTitle": "Создать пользователя",
|
||||
"nameLabel": "Имя",
|
||||
"skipEmailConfirm": "Пропустить подтверждение по электронной почте",
|
||||
"createSubmit": "Создать пользователя",
|
||||
"saveButton": "Сохранить изменения",
|
||||
"createdSuccess": "Пользователь {username} создан.",
|
||||
"updatedSuccess": "Пользователь {username} обновлён.",
|
||||
"deletedSuccess": "Пользователь {username} удалён.",
|
||||
"deleteScheduledSuccess": "Пользователь {username} получит подтверждение по электронной почте для запланированного удаления.",
|
||||
"confirmDeleteTitle": "Удалить пользователя?",
|
||||
"confirmDeleteIntro": "Как следует удалить пользователя {username}?",
|
||||
"deleteModeScheduled": "Запланировать удаление",
|
||||
"deleteModeScheduledHelp": "Запланированное удаление отправляет пользователю письмо с подтверждением, как если бы пользователь сам запросил удаление аккаунта.",
|
||||
"deleteModeNow": "Удалить сейчас",
|
||||
"deleteModeNowHelp": "Удаление сейчас удаляет пользователя и все его данные сразу. Это не может быть отменено."
|
||||
},
|
||||
"projects": {
|
||||
"ownerLabel": "Владелец",
|
||||
"reassignOwner": "Переназначить владельца",
|
||||
"reassignTitle": "Переназначить {title}",
|
||||
"reassignedSuccess": "Владелец проекта переназначен.",
|
||||
"newOwnerLabel": "Новый владелец"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -314,8 +314,7 @@
|
|||
"default": "Privzeto",
|
||||
"month": "Mesec",
|
||||
"day": "Dan",
|
||||
"hour": "Ura",
|
||||
"range": "Datumski obseg"
|
||||
"hour": "Ura"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabela",
|
||||
|
|
@ -324,7 +323,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Omejitev: {limit}",
|
||||
"noLimit": "Ni nastavljeno",
|
||||
"doneBucket": "Vedro končanih nalog",
|
||||
"doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.",
|
||||
"doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Månad",
|
||||
"day": "Dag",
|
||||
"hour": "Timme",
|
||||
"range": "Datumintervall",
|
||||
"chartLabel": "Projektets Gantt-schema",
|
||||
"taskBarsForRow": "Uppgiftsstaplar för rad {rowId}",
|
||||
"taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Gräns: {limit}",
|
||||
"noLimit": "Ej inställt",
|
||||
"doneBucket": "Färdigkolumn",
|
||||
"doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.",
|
||||
"doneBucketHintExtended": "Alla uppgifter som flyttas till färdigkolumnen markeras som färdiga automatiskt. Alla uppgifter som markerats som färdiga från andra håll kommer också att flyttas.",
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@
|
|||
"month": "Ay",
|
||||
"day": "Gün",
|
||||
"hour": "Saat",
|
||||
"range": "Tarih Aralığı",
|
||||
"chartLabel": "Proje Gantt Şeması",
|
||||
"taskBarsForRow": "{rowId} satırı için görev çubukları",
|
||||
"taskBarLabel": "Görev: {task}. {startDate} tarihinden {endDate} tarihine. {dateType}. Düzenlemek için tıklayın, taşımak için sürükleyin.",
|
||||
|
|
@ -386,7 +385,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Sınır: {limit}",
|
||||
"noLimit": "Belirlenmedi",
|
||||
"doneBucket": "Tamamlananlar kutusu",
|
||||
"doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.",
|
||||
"doneBucketHintExtended": "Tamamlananlar kutusuna taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir. Başka bir yerden tamamlandı olarak işaretlenen görevler de buraya taşınır.",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy/mm/dd": "YYYY/MM/DD"
|
||||
},
|
||||
"timeFormat": "Формат часу",
|
||||
"timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12-годинний (AM/PM)",
|
||||
"24h": "24-годинний (HH:mm)"
|
||||
|
|
@ -392,6 +393,7 @@
|
|||
"title": "Дублювати цей проєкт",
|
||||
"label": "Дублювати",
|
||||
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
|
||||
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
|
||||
"success": "Проєкт дубльовано."
|
||||
},
|
||||
"edit": {
|
||||
|
|
@ -470,7 +472,6 @@
|
|||
"month": "Місяць",
|
||||
"day": "День",
|
||||
"hour": "Година",
|
||||
"range": "Проміжок днів",
|
||||
"chartLabel": "Діаграма Ганта",
|
||||
"taskBarsForRow": "Смуги завдань для рядка {rowId}",
|
||||
"taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.",
|
||||
|
|
@ -499,7 +500,6 @@
|
|||
"kanban": {
|
||||
"title": "Дошка",
|
||||
"limit": "Межа: {limit}",
|
||||
"noLimit": "Немає",
|
||||
"doneBucket": "Колонка «Виконано»",
|
||||
"doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.",
|
||||
"doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.",
|
||||
|
|
@ -783,7 +783,10 @@
|
|||
"closeDialog": "Закрити діалог",
|
||||
"closeQuickActions": "Закрити швидкі дії",
|
||||
"skipToContent": "Перейти до основного вмісту",
|
||||
"sortBy": "Сортувати за"
|
||||
"sortBy": "Сортувати за",
|
||||
"dateRange": "Діапазон дат",
|
||||
"notSet": "Не встановлено",
|
||||
"user": "Користувач"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Колір проєкту",
|
||||
|
|
@ -986,13 +989,14 @@
|
|||
"assign": "Доручити",
|
||||
"label": "Позначки",
|
||||
"priority": "Встановити пріоритет",
|
||||
"dueDate": "Встановити термін",
|
||||
"dueDate": "Встановити термін виконання",
|
||||
"startDate": "Почати",
|
||||
"endDate": "Встановити дату завершення",
|
||||
"reminders": "Нагадування",
|
||||
"repeatAfter": "Повторювати",
|
||||
"percentDone": "Встановити прогрес",
|
||||
"attachments": "Вкласти",
|
||||
"timeTracking": "Відстежити час",
|
||||
"relatedTasks": "Пов'язати",
|
||||
"moveProject": "Перемістити",
|
||||
"duplicate": "Дублювати",
|
||||
|
|
@ -1059,7 +1063,7 @@
|
|||
"edited": "змінено: {date}",
|
||||
"creating": "Створюю коментар…",
|
||||
"placeholder": "Введіть коментар, натисніть '/' для додаткових опцій…",
|
||||
"comment": "Залишити",
|
||||
"comment": "Зберегти коментар",
|
||||
"delete": "Видалити коментар",
|
||||
"deleteText1": "Справді впровадити?",
|
||||
"deleteSuccess": "Коментар успішно видалено.",
|
||||
|
|
@ -1148,11 +1152,11 @@
|
|||
"repeat": {
|
||||
"everyDay": "Щодня",
|
||||
"everyWeek": "Щотижня",
|
||||
"every30d": "Щомісяця",
|
||||
"every30d": "Кожні 30 днів",
|
||||
"mode": "Спосіб",
|
||||
"monthly": "Щомісяця",
|
||||
"fromCurrentDate": "Щодень закінчення",
|
||||
"each": "Що",
|
||||
"fromCurrentDate": "З дня закінчення",
|
||||
"each": "Кожен",
|
||||
"specifyAmount": "Вкажіть величину…",
|
||||
"hours": "Години",
|
||||
"days": "День",
|
||||
|
|
@ -1218,8 +1222,8 @@
|
|||
"success": "Вживача успішно видалено зі спільноти."
|
||||
},
|
||||
"leave": {
|
||||
"title": "Покинути спільноту",
|
||||
"text1": "Справді покинути?",
|
||||
"title": "Залишити спільноту",
|
||||
"text1": "Ви впевнені, що хочете залишити цю спільноту?",
|
||||
"text2": "Ви втратите доступ до всіх проєктів, до яких має доступ ця команда. Якщо передумаєте, вам знадобиться адміністратор команди, щоб додати вас знову.",
|
||||
"success": "Ви покинули спільноту."
|
||||
}
|
||||
|
|
@ -1462,6 +1466,32 @@
|
|||
"frontendVersion": "Версія інтерфейсу: {version}",
|
||||
"apiVersion": "API версія: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Відстеження часу",
|
||||
"stop": "Зупинити таймер",
|
||||
"logTime": "Записати час",
|
||||
"editEntry": "Редагувати запис",
|
||||
"form": {
|
||||
"task": "Завдання",
|
||||
"taskSearch": "Знайти завдання…",
|
||||
"commentPlaceholder": "Над чим ви працювали?",
|
||||
"save": "Зберегти запис",
|
||||
"startTimer": "Запустити таймер",
|
||||
"update": "Оновити запис",
|
||||
"smartFill": "Заповнити з останнього запису"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "Для цього завдання ще немає записів обліку часу.",
|
||||
"emptyFiltered": "Немає записів обліку часу для вибраних фільтрів.",
|
||||
"total": "Загалом",
|
||||
"time": "Час",
|
||||
"duration": "Тривалість"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Обрати діапазон",
|
||||
"userSearch": "Знайти користувача…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "секунда|секунд(и)",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue