mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
3 Commits
eca443be35
...
05bc55a488
Author | SHA1 | Date | |
---|---|---|---|
|
05bc55a488 | ||
|
a9364e3d9e | ||
|
ec65847d0c |
BIN
.github/assets/screenshot_browser_plugin.png
vendored
Normal file
BIN
.github/assets/screenshot_browser_plugin.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
16
README.md
16
README.md
@ -294,6 +294,22 @@ Preview:
|
|||||||
</details>
|
</details>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
### Browser Plugin (Chrome & Firefox)
|
||||||
|
The [browser-wakatime](https://github.com/wakatime/browser-wakatime) plugin enables you to track your web surfing in WakaTime (and Wakapi, of course). Visited websites will appear as "files" in the summary. Follow these instructions to get started:
|
||||||
|
|
||||||
|
1. Install the browser extension from the official store ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/wakatimes), [Chrome](https://chrome.google.com/webstore/detail/wakatime/jnbbnacmeggbgdjgaoojpmhdlkkpblgi?hl=de))
|
||||||
|
2. Open the extension settings dialog
|
||||||
|
3. Configure it like so (see screenshot below):
|
||||||
|
* API Key: Your personal API key (get it at [wakapi.dev](https://wakapi.dev))
|
||||||
|
* Logging Type: _Only the domain_
|
||||||
|
* API URL: `https://wakapi.dev/api/compat/wakatime/v1` (alternatively, replace _wakapi.dev_ with your self-hosted instance hostname)
|
||||||
|
4. Save
|
||||||
|
5. Start browsing!
|
||||||
|
|
||||||
|
![](.github/assets/screenshot_browser_plugin.png)
|
||||||
|
|
||||||
|
Note: the plugin will only sync heartbeats once in a while, so it might take some time for them to appear on Wakapi. To "force" it to sync, simply bring up the plugin main dialog.
|
||||||
|
|
||||||
## 📦 Data Export
|
## 📦 Data Export
|
||||||
You can export your coding activity from Wakapi to CSV in the form of raw heartbeats. While there is no way to accomplish this directly through the web UI, we provide an easy-to-use Python [script](scripts/download_heartbeats.py) instead.
|
You can export your coding activity from Wakapi to CSV in the form of raw heartbeats. While there is no way to accomplish this directly through the web UI, we provide an easy-to-use Python [script](scripts/download_heartbeats.py) instead.
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
107
helpers/interval.go
Normal file
107
helpers/interval.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseInterval(interval string) (*models.IntervalKey, error) {
|
||||||
|
for _, i := range models.AllIntervals {
|
||||||
|
if i.HasAlias(interval) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("not a valid interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustParseInterval(interval string) *models.IntervalKey {
|
||||||
|
key, _ := ParseInterval(interval)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
||||||
|
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
||||||
|
return from, to
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
||||||
|
parsed, err := ParseInterval(interval)
|
||||||
|
if err != nil {
|
||||||
|
return err, time.Time{}, time.Time{}
|
||||||
|
}
|
||||||
|
return ResolveIntervalTZ(parsed, tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||||
|
now := time.Now().In(tz)
|
||||||
|
to = now
|
||||||
|
|
||||||
|
switch interval {
|
||||||
|
case models.IntervalToday:
|
||||||
|
from = utils.BeginOfToday(tz)
|
||||||
|
case models.IntervalYesterday:
|
||||||
|
from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
|
||||||
|
to = utils.BeginOfToday(tz)
|
||||||
|
case models.IntervalPastDay:
|
||||||
|
from = now.Add(-24 * time.Hour)
|
||||||
|
case models.IntervalThisWeek:
|
||||||
|
from = utils.BeginOfThisWeek(tz)
|
||||||
|
case models.IntervalLastWeek:
|
||||||
|
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
|
||||||
|
to = utils.BeginOfThisWeek(tz)
|
||||||
|
case models.IntervalThisMonth:
|
||||||
|
from = utils.BeginOfThisMonth(tz)
|
||||||
|
case models.IntervalLastMonth:
|
||||||
|
from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
|
||||||
|
to = utils.BeginOfThisMonth(tz)
|
||||||
|
case models.IntervalThisYear:
|
||||||
|
from = utils.BeginOfThisYear(tz)
|
||||||
|
case models.IntervalPast7Days:
|
||||||
|
from = now.AddDate(0, 0, -7)
|
||||||
|
case models.IntervalPast7DaysYesterday:
|
||||||
|
from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||||
|
to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
|
||||||
|
case models.IntervalPast14Days:
|
||||||
|
from = now.AddDate(0, 0, -14)
|
||||||
|
case models.IntervalPast30Days:
|
||||||
|
from = now.AddDate(0, 0, -30)
|
||||||
|
case models.IntervalPast6Months:
|
||||||
|
from = now.AddDate(0, -6, 0)
|
||||||
|
case models.IntervalPast12Months:
|
||||||
|
from = now.AddDate(0, -12, 0)
|
||||||
|
case models.IntervalAny:
|
||||||
|
from = time.Time{}
|
||||||
|
default:
|
||||||
|
err = errors.New("invalid interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
return err, from, to
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveClosestRange returns the interval label (e.g. "last_7_days") of the maximum allowed range when having opted to share this many days or an error for days == 0.
|
||||||
|
func ResolveMaximumRange(days int) (error, *models.IntervalKey) {
|
||||||
|
if days == 0 {
|
||||||
|
return errors.New("no matching interval"), nil
|
||||||
|
}
|
||||||
|
if days < 0 {
|
||||||
|
return nil, models.IntervalAny
|
||||||
|
}
|
||||||
|
if days < 7 {
|
||||||
|
return nil, models.IntervalPastDay
|
||||||
|
}
|
||||||
|
if days < 14 {
|
||||||
|
return nil, models.IntervalPast7Days
|
||||||
|
}
|
||||||
|
if days < 30 {
|
||||||
|
return nil, models.IntervalPast14Days
|
||||||
|
}
|
||||||
|
if days < 181 { // 3*31 + 2*30 + 1*28
|
||||||
|
return nil, models.IntervalPast30Days
|
||||||
|
}
|
||||||
|
if days < 365 { // 7*31 + 4*30 + 1*28
|
||||||
|
return nil, models.IntervalPast6Months
|
||||||
|
}
|
||||||
|
return nil, models.IntervalPast12Months
|
||||||
|
}
|
27
helpers/interval_test.go
Normal file
27
helpers/interval_test.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveMaximumRange_Default(t *testing.T) {
|
||||||
|
for i := 1; i <= 366; i++ {
|
||||||
|
err1, maximumInterval := ResolveMaximumRange(i)
|
||||||
|
err2, from, to := ResolveIntervalTZ(maximumInterval, time.UTC)
|
||||||
|
|
||||||
|
assert.Nil(t, err1)
|
||||||
|
assert.Nil(t, err2)
|
||||||
|
assert.LessOrEqual(t, to.Sub(from), time.Duration(i*24)*time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMaximumRange_EdgeCases(t *testing.T) {
|
||||||
|
err, _ := ResolveMaximumRange(0)
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
|
||||||
|
_, maximumInterval := ResolveMaximumRange(-1)
|
||||||
|
assert.Equal(t, models.IntervalAny, maximumInterval)
|
||||||
|
}
|
@ -3,7 +3,6 @@ package helpers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -82,69 +81,3 @@ func extractUser(r *http.Request) *models.User {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseInterval(interval string) (*models.IntervalKey, error) {
|
|
||||||
for _, i := range models.AllIntervals {
|
|
||||||
if i.HasAlias(interval) {
|
|
||||||
return i, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.New("not a valid interval")
|
|
||||||
}
|
|
||||||
|
|
||||||
func MustResolveIntervalRawTZ(interval string, tz *time.Location) (from, to time.Time) {
|
|
||||||
_, from, to = ResolveIntervalRawTZ(interval, tz)
|
|
||||||
return from, to
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from, to time.Time) {
|
|
||||||
parsed, err := ParseInterval(interval)
|
|
||||||
if err != nil {
|
|
||||||
return err, time.Time{}, time.Time{}
|
|
||||||
}
|
|
||||||
return ResolveIntervalTZ(parsed, tz)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
|
||||||
now := time.Now().In(tz)
|
|
||||||
to = now
|
|
||||||
|
|
||||||
switch interval {
|
|
||||||
case models.IntervalToday:
|
|
||||||
from = utils.BeginOfToday(tz)
|
|
||||||
case models.IntervalYesterday:
|
|
||||||
from = utils.BeginOfToday(tz).Add(-24 * time.Hour)
|
|
||||||
to = utils.BeginOfToday(tz)
|
|
||||||
case models.IntervalThisWeek:
|
|
||||||
from = utils.BeginOfThisWeek(tz)
|
|
||||||
case models.IntervalLastWeek:
|
|
||||||
from = utils.BeginOfThisWeek(tz).AddDate(0, 0, -7)
|
|
||||||
to = utils.BeginOfThisWeek(tz)
|
|
||||||
case models.IntervalThisMonth:
|
|
||||||
from = utils.BeginOfThisMonth(tz)
|
|
||||||
case models.IntervalLastMonth:
|
|
||||||
from = utils.BeginOfThisMonth(tz).AddDate(0, -1, 0)
|
|
||||||
to = utils.BeginOfThisMonth(tz)
|
|
||||||
case models.IntervalThisYear:
|
|
||||||
from = utils.BeginOfThisYear(tz)
|
|
||||||
case models.IntervalPast7Days:
|
|
||||||
from = now.AddDate(0, 0, -7)
|
|
||||||
case models.IntervalPast7DaysYesterday:
|
|
||||||
from = utils.BeginOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
|
||||||
to = utils.BeginOfToday(tz).AddDate(0, 0, -1)
|
|
||||||
case models.IntervalPast14Days:
|
|
||||||
from = now.AddDate(0, 0, -14)
|
|
||||||
case models.IntervalPast30Days:
|
|
||||||
from = now.AddDate(0, 0, -30)
|
|
||||||
case models.IntervalPast6Months:
|
|
||||||
from = now.AddDate(0, -6, 0)
|
|
||||||
case models.IntervalPast12Months:
|
|
||||||
from = now.AddDate(0, -12, 0)
|
|
||||||
case models.IntervalAny:
|
|
||||||
from = time.Time{}
|
|
||||||
default:
|
|
||||||
err = errors.New("invalid interval")
|
|
||||||
}
|
|
||||||
|
|
||||||
return err, from, to
|
|
||||||
}
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/muety/wakapi/helpers"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
@ -18,9 +19,16 @@ type StatsData struct {
|
|||||||
UserId string `json:"user_id"`
|
UserId string `json:"user_id"`
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
|
Status string `json:"status"`
|
||||||
TotalSeconds float64 `json:"total_seconds"`
|
TotalSeconds float64 `json:"total_seconds"`
|
||||||
DailyAverage float64 `json:"daily_average"`
|
DailyAverage float64 `json:"daily_average"`
|
||||||
DaysIncludingHolidays int `json:"days_including_holidays"`
|
DaysIncludingHolidays int `json:"days_including_holidays"`
|
||||||
|
Range string `json:"range"`
|
||||||
|
HumanReadableRange string `json:"human_readable_range"`
|
||||||
|
HumanReadableTotal string `json:"human_readable_total"`
|
||||||
|
HumanReadableDailyAverage string `json:"human_readable_daily_average"`
|
||||||
|
IsCodingActivityVisible bool `json:"is_coding_activity_visible"`
|
||||||
|
IsOtherUsageVisible bool `json:"is_other_usage_visible"`
|
||||||
Editors []*SummariesEntry `json:"editors"`
|
Editors []*SummariesEntry `json:"editors"`
|
||||||
Languages []*SummariesEntry `json:"languages"`
|
Languages []*SummariesEntry `json:"languages"`
|
||||||
Machines []*SummariesEntry `json:"machines"`
|
Machines []*SummariesEntry `json:"machines"`
|
||||||
@ -38,11 +46,16 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
|
|||||||
UserId: summary.UserID,
|
UserId: summary.UserID,
|
||||||
Start: summary.FromTime.T(),
|
Start: summary.FromTime.T(),
|
||||||
End: summary.ToTime.T(),
|
End: summary.ToTime.T(),
|
||||||
|
Status: "ok",
|
||||||
TotalSeconds: totalTime.Seconds(),
|
TotalSeconds: totalTime.Seconds(),
|
||||||
DailyAverage: totalTime.Seconds() / float64(numDays),
|
|
||||||
DaysIncludingHolidays: numDays,
|
DaysIncludingHolidays: numDays,
|
||||||
|
HumanReadableTotal: helpers.FmtWakatimeDuration(totalTime),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if numDays > 0 {
|
||||||
|
data.DailyAverage = totalTime.Seconds() / float64(numDays)
|
||||||
|
data.HumanReadableDailyAverage = helpers.FmtWakatimeDuration(totalTime / time.Duration(numDays))
|
||||||
|
}
|
||||||
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
|
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
|
||||||
data.DailyAverage = 0
|
data.DailyAverage = 0
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,33 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
// Support Wakapi and WakaTime range / interval identifiers
|
// Support Wakapi and WakaTime range / interval identifiers
|
||||||
// See https://wakatime.com/developers/#summaries
|
// See https://wakatime.com/developers/#summaries
|
||||||
var (
|
var (
|
||||||
IntervalToday = &IntervalKey{"today", "Today"}
|
IntervalToday = &IntervalKey{"today", "Today"}
|
||||||
IntervalYesterday = &IntervalKey{"day", "yesterday", "Yesterday"}
|
IntervalYesterday = &IntervalKey{"day", "yesterday", "Yesterday"}
|
||||||
|
IntervalPastDay = &IntervalKey{"24_hours", "last_24_hours", "last_day", "Last 24 Hours"} // non-official one
|
||||||
IntervalThisWeek = &IntervalKey{"week", "This Week"}
|
IntervalThisWeek = &IntervalKey{"week", "This Week"}
|
||||||
IntervalLastWeek = &IntervalKey{"Last Week"}
|
IntervalLastWeek = &IntervalKey{"last_week", "Last Week"}
|
||||||
IntervalThisMonth = &IntervalKey{"month", "This Month"}
|
IntervalThisMonth = &IntervalKey{"month", "This Month"}
|
||||||
IntervalLastMonth = &IntervalKey{"Last Month"}
|
IntervalLastMonth = &IntervalKey{"last_month", "Last Month"}
|
||||||
IntervalThisYear = &IntervalKey{"year"}
|
IntervalThisYear = &IntervalKey{"year", "This Year"}
|
||||||
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
|
IntervalPast7Days = &IntervalKey{"7_days", "last_7_days", "Last 7 Days"}
|
||||||
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
||||||
IntervalPast14Days = &IntervalKey{"Last 14 Days"}
|
IntervalPast14Days = &IntervalKey{"14_days", "last_14_days", "Last 14 Days"}
|
||||||
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
||||||
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months"}
|
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months", "Last 6 Months"}
|
||||||
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year"}
|
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year", "Last 12 Months"}
|
||||||
IntervalAny = &IntervalKey{"any", "all_time"}
|
IntervalAny = &IntervalKey{"any", "all_time", "All Time"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllIntervals = []*IntervalKey{
|
var AllIntervals = []*IntervalKey{
|
||||||
IntervalToday,
|
IntervalToday,
|
||||||
IntervalYesterday,
|
IntervalYesterday,
|
||||||
|
IntervalPastDay,
|
||||||
IntervalThisWeek,
|
IntervalThisWeek,
|
||||||
IntervalLastWeek,
|
IntervalLastWeek,
|
||||||
IntervalThisMonth,
|
IntervalThisMonth,
|
||||||
@ -46,3 +52,12 @@ func (k *IntervalKey) HasAlias(s string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k *IntervalKey) GetHumanReadable() string {
|
||||||
|
for _, s := range *k {
|
||||||
|
if unicode.IsUpper(rune(s[0])) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
@ -163,6 +163,10 @@ func (u *User) MinDataAge() time.Time {
|
|||||||
return time.Now().AddDate(0, -retentionMonths, 0)
|
return time.Now().AddDate(0, -retentionMonths, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) AnyDataShared() bool {
|
||||||
|
return u.ShareDataMaxDays != 0 && (u.ShareEditors || u.ShareLanguages || u.ShareProjects || u.ShareOSs || u.ShareMachines || u.ShareLabels)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return ValidatePassword(c.PasswordNew) &&
|
return ValidatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
c.PasswordNew == c.PasswordRepeat
|
||||||
|
@ -76,8 +76,14 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if no range was requested, get the maximum allowed range given the users max shared days, otherwise default to past 7 days (which will fail in the next step, because user didn't allow any sharing)
|
||||||
|
// this "floors" the user's maximum shared date to the supported range buckets (e.g. if user opted to share 12 days, we'll still fallback to "last_7_days") for consistency with wakatime
|
||||||
if rangeParam == "" {
|
if rangeParam == "" {
|
||||||
rangeParam = (*models.IntervalPast7Days)[0]
|
if _, userRange := helpers.ResolveMaximumRange(requestedUser.ShareDataMaxDays); userRange != nil {
|
||||||
|
rangeParam = (*userRange)[1]
|
||||||
|
} else {
|
||||||
|
rangeParam = (*models.IntervalPast7Days)[1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
err, rangeFrom, rangeTo := helpers.ResolveIntervalRawTZ(rangeParam, requestedUser.TZ())
|
||||||
@ -103,6 +109,10 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stats := v1.NewStatsFrom(summary, &models.Filters{})
|
stats := v1.NewStatsFrom(summary, &models.Filters{})
|
||||||
|
stats.Data.Range = rangeParam
|
||||||
|
stats.Data.HumanReadableRange = helpers.MustParseInterval(rangeParam).GetHumanReadable()
|
||||||
|
stats.Data.IsCodingActivityVisible = requestedUser.ShareDataMaxDays != 0
|
||||||
|
stats.Data.IsOtherUsageVisible = requestedUser.AnyDataShared()
|
||||||
|
|
||||||
// post filter stats according to user's given sharing permissions
|
// post filter stats according to user's given sharing permissions
|
||||||
if !requestedUser.ShareEditors {
|
if !requestedUser.ShareEditors {
|
||||||
|
Loading…
Reference in New Issue
Block a user