mirror of
https://github.com/go-gitea/gitea
synced 2024-09-01 12:05:19 +02:00
Compare commits
9 Commits
b528700922
...
6273407a6f
Author | SHA1 | Date | |
---|---|---|---|
|
6273407a6f | ||
|
e5e40787dc | ||
|
1f924d845e | ||
|
f46e7eae93 | ||
|
06c67c6d5a | ||
|
fcac28c878 | ||
|
bc0977f1c9 | ||
|
7207d93f01 | ||
|
e5ea1e8ae3 |
@ -27,20 +27,20 @@ func DefaultLocker() Locker {
|
||||
|
||||
// Lock tries to acquire a lock for the given key, it uses the default locker.
|
||||
// Read the documentation of Locker.Lock for more information about the behavior.
|
||||
func Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
|
||||
func Lock(ctx context.Context, key string) (ReleaseFunc, error) {
|
||||
return DefaultLocker().Lock(ctx, key)
|
||||
}
|
||||
|
||||
// TryLock tries to acquire a lock for the given key, it uses the default locker.
|
||||
// Read the documentation of Locker.TryLock for more information about the behavior.
|
||||
func TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
|
||||
func TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
|
||||
return DefaultLocker().TryLock(ctx, key)
|
||||
}
|
||||
|
||||
// LockAndDo tries to acquire a lock for the given key and then calls the given function.
|
||||
// It uses the default locker, and it will return an error if failed to acquire the lock.
|
||||
func LockAndDo(ctx context.Context, key string, f func(context.Context) error) error {
|
||||
ctx, release, err := Lock(ctx, key)
|
||||
release, err := Lock(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -52,7 +52,7 @@ func LockAndDo(ctx context.Context, key string, f func(context.Context) error) e
|
||||
// TryLockAndDo tries to acquire a lock for the given key and then calls the given function.
|
||||
// It uses the default locker, and it will return false if failed to acquire the lock.
|
||||
func TryLockAndDo(ctx context.Context, key string, f func(context.Context) error) (bool, error) {
|
||||
ok, ctx, release, err := TryLock(ctx, key)
|
||||
ok, release, err := TryLock(ctx, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
@ -5,56 +5,34 @@ package globallock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Locker interface {
|
||||
// Lock tries to acquire a lock for the given key, it blocks until the lock is acquired or the context is canceled.
|
||||
//
|
||||
// Lock returns a new context which should be used in the following code.
|
||||
// The new context will be canceled when the lock is released or lost - yes, it's possible to lose a lock.
|
||||
// For example, it lost the connection to the redis server while holding the lock.
|
||||
// If it fails to acquire the lock, the returned context will be the same as the input context.
|
||||
//
|
||||
// Lock returns a ReleaseFunc to release the lock, it cannot be nil.
|
||||
// It's always safe to call this function even if it fails to acquire the lock, and it will do nothing in that case.
|
||||
// And it's also safe to call it multiple times, but it will only release the lock once.
|
||||
// That's why it's called ReleaseFunc, not UnlockFunc.
|
||||
// But be aware that it's not safe to not call it at all; it could lead to a memory leak.
|
||||
// So a recommended pattern is to use defer to call it:
|
||||
// ctx, release, err := locker.Lock(ctx, "key")
|
||||
// release, err := locker.Lock(ctx, "key")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer release()
|
||||
// The ReleaseFunc will return the original context which was used to acquire the lock.
|
||||
// It's useful when you want to continue to do something after releasing the lock.
|
||||
// At that time, the ctx will be canceled, and you can use the returned context by the ReleaseFunc to continue:
|
||||
// ctx, release, err := locker.Lock(ctx, "key")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer release()
|
||||
// doSomething(ctx)
|
||||
// ctx = release()
|
||||
// doSomethingElse(ctx)
|
||||
// Please ignore it and use `defer release()` instead if you don't need this, to avoid forgetting to release the lock.
|
||||
//
|
||||
// Lock returns an error if failed to acquire the lock.
|
||||
// Be aware that even the context is not canceled, it's still possible to fail to acquire the lock.
|
||||
// For example, redis is down, or it reached the maximum number of tries.
|
||||
Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error)
|
||||
Lock(ctx context.Context, key string) (ReleaseFunc, error)
|
||||
|
||||
// TryLock tries to acquire a lock for the given key, it returns immediately.
|
||||
// It follows the same pattern as Lock, but it doesn't block.
|
||||
// And if it fails to acquire the lock because it's already locked, not other reasons like redis is down,
|
||||
// it will return false without any error.
|
||||
TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error)
|
||||
TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error)
|
||||
}
|
||||
|
||||
// ReleaseFunc is a function that releases a lock.
|
||||
// It returns the original context which was used to acquire the lock.
|
||||
type ReleaseFunc func() context.Context
|
||||
|
||||
// ErrLockReleased is used as context cause when a lock is released
|
||||
var ErrLockReleased = fmt.Errorf("lock released")
|
||||
type ReleaseFunc func()
|
||||
|
@ -47,27 +47,24 @@ func TestLocker(t *testing.T) {
|
||||
func testLocker(t *testing.T, locker Locker) {
|
||||
t.Run("lock", func(t *testing.T) {
|
||||
parentCtx := context.Background()
|
||||
ctx, release, err := locker.Lock(parentCtx, "test")
|
||||
release, err := locker.Lock(parentCtx, "test")
|
||||
defer release()
|
||||
|
||||
assert.NotEqual(t, parentCtx, ctx) // new context should be returned
|
||||
assert.NoError(t, err)
|
||||
|
||||
func() {
|
||||
parentCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
ctx, release, err := locker.Lock(parentCtx, "test")
|
||||
release, err := locker.Lock(ctx, "test")
|
||||
defer release()
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, parentCtx, ctx) // should return the same context
|
||||
}()
|
||||
|
||||
release()
|
||||
assert.Error(t, ctx.Err())
|
||||
|
||||
func() {
|
||||
_, release, err := locker.Lock(context.Background(), "test")
|
||||
release, err := locker.Lock(context.Background(), "test")
|
||||
defer release()
|
||||
|
||||
assert.NoError(t, err)
|
||||
@ -76,29 +73,26 @@ func testLocker(t *testing.T, locker Locker) {
|
||||
|
||||
t.Run("try lock", func(t *testing.T) {
|
||||
parentCtx := context.Background()
|
||||
ok, ctx, release, err := locker.TryLock(parentCtx, "test")
|
||||
ok, release, err := locker.TryLock(parentCtx, "test")
|
||||
defer release()
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.NotEqual(t, parentCtx, ctx) // new context should be returned
|
||||
assert.NoError(t, err)
|
||||
|
||||
func() {
|
||||
parentCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
ok, ctx, release, err := locker.TryLock(parentCtx, "test")
|
||||
ok, release, err := locker.TryLock(ctx, "test")
|
||||
defer release()
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, parentCtx, ctx) // should return the same context
|
||||
}()
|
||||
|
||||
release()
|
||||
assert.Error(t, ctx.Err())
|
||||
|
||||
func() {
|
||||
ok, _, release, _ := locker.TryLock(context.Background(), "test")
|
||||
ok, release, _ := locker.TryLock(context.Background(), "test")
|
||||
defer release()
|
||||
|
||||
assert.True(t, ok)
|
||||
@ -107,7 +101,7 @@ func testLocker(t *testing.T, locker Locker) {
|
||||
|
||||
t.Run("wait and acquired", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, release, err := locker.Lock(ctx, "test")
|
||||
release, err := locker.Lock(ctx, "test")
|
||||
require.NoError(t, err)
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
@ -115,7 +109,7 @@ func testLocker(t *testing.T, locker Locker) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
_, release, err := locker.Lock(context.Background(), "test") // should be blocked for seconds
|
||||
release, err := locker.Lock(context.Background(), "test") // should be blocked for seconds
|
||||
defer release()
|
||||
assert.Greater(t, time.Since(started), time.Second)
|
||||
assert.NoError(t, err)
|
||||
@ -127,34 +121,15 @@ func testLocker(t *testing.T, locker Locker) {
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("continue after release", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ctxBeforeLock := ctx
|
||||
ctx, release, err := locker.Lock(ctx, "test")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, ctx.Err())
|
||||
assert.NotEqual(t, ctxBeforeLock, ctx)
|
||||
|
||||
ctxBeforeRelease := ctx
|
||||
ctx = release()
|
||||
|
||||
assert.NoError(t, ctx.Err())
|
||||
assert.Error(t, ctxBeforeRelease.Err())
|
||||
|
||||
// so it can continue with ctx to do more work
|
||||
})
|
||||
|
||||
t.Run("multiple release", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, release1, err := locker.Lock(ctx, "test")
|
||||
release1, err := locker.Lock(ctx, "test")
|
||||
require.NoError(t, err)
|
||||
|
||||
release1()
|
||||
|
||||
_, release2, err := locker.Lock(ctx, "test")
|
||||
release2, err := locker.Lock(ctx, "test")
|
||||
defer release2()
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -163,7 +138,7 @@ func testLocker(t *testing.T, locker Locker) {
|
||||
// and it shouldn't affect the other lock
|
||||
release1()
|
||||
|
||||
ok, _, release3, err := locker.TryLock(ctx, "test")
|
||||
ok, release3, err := locker.TryLock(ctx, "test")
|
||||
defer release3()
|
||||
require.NoError(t, err)
|
||||
// It should be able to acquire the lock;
|
||||
@ -184,28 +159,23 @@ func testRedisLocker(t *testing.T, locker *redisLocker) {
|
||||
// Otherwise, it will affect other tests.
|
||||
t.Run("close", func(t *testing.T) {
|
||||
assert.NoError(t, locker.Close())
|
||||
_, _, err := locker.Lock(context.Background(), "test")
|
||||
_, err := locker.Lock(context.Background(), "test")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}()
|
||||
|
||||
t.Run("failed extend", func(t *testing.T) {
|
||||
ctx, release, err := locker.Lock(context.Background(), "test")
|
||||
release, err := locker.Lock(context.Background(), "test")
|
||||
defer release()
|
||||
require.NoError(t, err)
|
||||
|
||||
// It simulates that there are some problems with extending like network issues or redis server down.
|
||||
v, ok := locker.mutexM.Load("test")
|
||||
require.True(t, ok)
|
||||
m := v.(*redisMutex)
|
||||
_, _ = m.mutex.Unlock() // release it to make it impossible to extend
|
||||
m := v.(*redsync.Mutex)
|
||||
_, _ = m.Unlock() // release it to make it impossible to extend
|
||||
|
||||
select {
|
||||
case <-time.After(redisLockExpiry + time.Second):
|
||||
t.Errorf("lock should be expired")
|
||||
case <-ctx.Done():
|
||||
var errTaken *redsync.ErrTaken
|
||||
assert.ErrorAs(t, context.Cause(ctx), &errTaken)
|
||||
}
|
||||
// In current design, callers can't know the lock can't be extended.
|
||||
// Just keep this case to improve the test coverage.
|
||||
})
|
||||
}
|
||||
|
@ -19,18 +19,13 @@ func NewMemoryLocker() Locker {
|
||||
return &memoryLocker{}
|
||||
}
|
||||
|
||||
func (l *memoryLocker) Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
|
||||
originalCtx := ctx
|
||||
|
||||
func (l *memoryLocker) Lock(ctx context.Context, key string) (ReleaseFunc, error) {
|
||||
if l.tryLock(key) {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
releaseOnce := sync.Once{}
|
||||
return ctx, func() context.Context {
|
||||
return func() {
|
||||
releaseOnce.Do(func() {
|
||||
l.locks.Delete(key)
|
||||
cancel(ErrLockReleased)
|
||||
})
|
||||
return originalCtx
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -39,39 +34,31 @@ func (l *memoryLocker) Lock(ctx context.Context, key string) (context.Context, R
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx, func() context.Context { return originalCtx }, ctx.Err()
|
||||
return func() {}, ctx.Err()
|
||||
case <-ticker.C:
|
||||
if l.tryLock(key) {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
releaseOnce := sync.Once{}
|
||||
return ctx, func() context.Context {
|
||||
return func() {
|
||||
releaseOnce.Do(func() {
|
||||
l.locks.Delete(key)
|
||||
cancel(ErrLockReleased)
|
||||
})
|
||||
return originalCtx
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *memoryLocker) TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
|
||||
originalCtx := ctx
|
||||
|
||||
func (l *memoryLocker) TryLock(_ context.Context, key string) (bool, ReleaseFunc, error) {
|
||||
if l.tryLock(key) {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
releaseOnce := sync.Once{}
|
||||
return true, ctx, func() context.Context {
|
||||
return true, func() {
|
||||
releaseOnce.Do(func() {
|
||||
cancel(ErrLockReleased)
|
||||
l.locks.Delete(key)
|
||||
})
|
||||
return originalCtx
|
||||
}, nil
|
||||
}
|
||||
|
||||
return false, ctx, func() context.Context { return originalCtx }, nil
|
||||
return false, func() {}, nil
|
||||
}
|
||||
|
||||
func (l *memoryLocker) tryLock(key string) bool {
|
||||
|
@ -48,21 +48,21 @@ func NewRedisLocker(connection string) Locker {
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *redisLocker) Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
|
||||
func (l *redisLocker) Lock(ctx context.Context, key string) (ReleaseFunc, error) {
|
||||
return l.lock(ctx, key, 0)
|
||||
}
|
||||
|
||||
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
|
||||
ctx, f, err := l.lock(ctx, key, 1)
|
||||
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
|
||||
f, err := l.lock(ctx, key, 1)
|
||||
|
||||
var (
|
||||
errTaken *redsync.ErrTaken
|
||||
errNodeTaken *redsync.ErrNodeTaken
|
||||
)
|
||||
if errors.As(err, &errTaken) || errors.As(err, &errNodeTaken) {
|
||||
return false, ctx, f, nil
|
||||
return false, f, nil
|
||||
}
|
||||
return err == nil, ctx, f, err
|
||||
return err == nil, f, err
|
||||
}
|
||||
|
||||
// Close closes the locker.
|
||||
@ -76,18 +76,11 @@ func (l *redisLocker) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type redisMutex struct {
|
||||
mutex *redsync.Mutex
|
||||
cancel context.CancelCauseFunc
|
||||
}
|
||||
|
||||
func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.Context, ReleaseFunc, error) {
|
||||
func (l *redisLocker) lock(ctx context.Context, key string, tries int) (ReleaseFunc, error) {
|
||||
if l.closed.Load() {
|
||||
return ctx, func() context.Context { return ctx }, fmt.Errorf("locker is closed")
|
||||
return func() {}, fmt.Errorf("locker is closed")
|
||||
}
|
||||
|
||||
originalCtx := ctx
|
||||
|
||||
options := []redsync.Option{
|
||||
redsync.WithExpiry(redisLockExpiry),
|
||||
}
|
||||
@ -96,18 +89,13 @@ func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.
|
||||
}
|
||||
mutex := l.rs.NewMutex(redisLockKeyPrefix+key, options...)
|
||||
if err := mutex.LockContext(ctx); err != nil {
|
||||
return ctx, func() context.Context { return originalCtx }, err
|
||||
return func() {}, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
|
||||
l.mutexM.Store(key, &redisMutex{
|
||||
mutex: mutex,
|
||||
cancel: cancel,
|
||||
})
|
||||
l.mutexM.Store(key, mutex)
|
||||
|
||||
releaseOnce := sync.Once{}
|
||||
return ctx, func() context.Context {
|
||||
return func() {
|
||||
releaseOnce.Do(func() {
|
||||
l.mutexM.Delete(key)
|
||||
|
||||
@ -115,10 +103,7 @@ func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.
|
||||
// if it failed to unlock, it will be released automatically after the lock expires.
|
||||
// Do not call mutex.UnlockContext(ctx) here, or it will fail to release when ctx has timed out.
|
||||
_, _ = mutex.Unlock()
|
||||
|
||||
cancel(ErrLockReleased)
|
||||
})
|
||||
return originalCtx
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -128,16 +113,15 @@ func (l *redisLocker) startExtend() {
|
||||
return
|
||||
}
|
||||
|
||||
toExtend := make([]*redisMutex, 0)
|
||||
toExtend := make([]*redsync.Mutex, 0)
|
||||
l.mutexM.Range(func(_, value any) bool {
|
||||
m := value.(*redisMutex)
|
||||
m := value.(*redsync.Mutex)
|
||||
|
||||
// Extend the lock if it is not expired.
|
||||
// Although the mutex will be removed from the map before it is released,
|
||||
// it still can be expired because of a failed extension.
|
||||
// If it happens, the cancel function should have been called,
|
||||
// so it does not need to be extended anymore.
|
||||
if time.Now().After(m.mutex.Until()) {
|
||||
// If it happens, it does not need to be extended anymore.
|
||||
if time.Now().After(m.Until()) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -145,9 +129,8 @@ func (l *redisLocker) startExtend() {
|
||||
return true
|
||||
})
|
||||
for _, v := range toExtend {
|
||||
if ok, err := v.mutex.Extend(); !ok {
|
||||
v.cancel(err)
|
||||
}
|
||||
// If it failed to extend, it will be released automatically after the lock expires.
|
||||
_, _ = v.Extend()
|
||||
}
|
||||
|
||||
time.AfterFunc(redisLockExpiry/2, l.startExtend)
|
||||
|
@ -628,6 +628,7 @@ org_still_own_repo=組織はまだ1つ以上のリポジトリを所有してい
|
||||
org_still_own_packages=組織はまだ1つ以上のパッケージを所有しています。 先にそれらを削除してください。
|
||||
|
||||
target_branch_not_exist=ターゲットのブランチが存在していません。
|
||||
target_ref_not_exist=ターゲットの ref が存在しません %s
|
||||
|
||||
admin_cannot_delete_self=あなたが管理者である場合、自分自身を削除することはできません。最初に管理者権限を削除してください。
|
||||
|
||||
@ -1273,6 +1274,7 @@ commit_graph.color=カラー
|
||||
commit.contained_in=このコミットが含まれているのは:
|
||||
commit.contained_in_default_branch=このコミットはデフォルトブランチに含まれています
|
||||
commit.load_referencing_branches_and_tags=このコミットを参照しているブランチやタグを取得
|
||||
commit.load_tags_failed=内部エラーによりタグの読み込みに失敗しました
|
||||
blame=Blame
|
||||
download_file=ファイルをダウンロード
|
||||
normal_view=通常表示
|
||||
@ -2185,6 +2187,7 @@ settings.transfer_in_progress=現在進行中の移転があります。この
|
||||
settings.transfer_notices_1=- 個人ユーザーに移転すると、あなたはリポジトリへのアクセス権を失います。
|
||||
settings.transfer_notices_2=- あなたが所有(または共同で所有)している組織に移転すると、リポジトリへのアクセス権は維持されます。
|
||||
settings.transfer_notices_3=- プライベートリポジトリを個人ユーザーに移転した場合は、最低限そのユーザーが読み取り権限を持つよう設定されます (必要に応じて権限が変更されます)。
|
||||
settings.transfer_notices_4=- リポジトリが組織に属しており、それを他の組織や個人に移転した場合、そのリポジトリのイシューと組織のプロジェクトボードとのリンクが失われます。
|
||||
settings.transfer_owner=新しいオーナー
|
||||
settings.transfer_perform=転送を実行
|
||||
settings.transfer_started=このリポジトリは転送のためにマークされており、「%s」からの確認を待っています
|
||||
@ -3699,6 +3702,11 @@ workflow.disable_success=ワークフロー '%s' が無効になりました。
|
||||
workflow.enable=ワークフローを有効にする
|
||||
workflow.enable_success=ワークフロー '%s' が有効になりました。
|
||||
workflow.disabled=ワークフローは無効です。
|
||||
workflow.run=ワークフローを実行
|
||||
workflow.not_found=ワークフロー '%s' が見つかりません。
|
||||
workflow.run_success=ワークフロー '%s' は正常に実行されました。
|
||||
workflow.from_ref=使用するワークフローの取得元
|
||||
workflow.has_workflow_dispatch=このワークフローには workflow_dispatch イベントトリガーがあります。
|
||||
|
||||
need_approval_desc=フォークプルリクエストのワークフローを実行するには承認が必要です。
|
||||
|
||||
|
4130
package-lock.json
generated
4130
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@ -17,12 +17,12 @@
|
||||
"add-asset-webpack-plugin": "3.0.0",
|
||||
"ansi_up": "6.0.2",
|
||||
"asciinema-player": "3.8.0",
|
||||
"chart.js": "4.4.3",
|
||||
"chart.js": "4.4.4",
|
||||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"clippie": "4.1.3",
|
||||
"css-loader": "7.1.2",
|
||||
"dayjs": "1.11.12",
|
||||
"dayjs": "1.11.13",
|
||||
"dropzone": "6.0.0-beta.2",
|
||||
"easymde": "2.18.0",
|
||||
"esbuild-loader": "4.2.2",
|
||||
@ -34,17 +34,17 @@
|
||||
"katex": "0.16.11",
|
||||
"license-checker-webpack-plugin": "0.2.1",
|
||||
"mermaid": "11.0.2",
|
||||
"mini-css-extract-plugin": "2.9.0",
|
||||
"mini-css-extract-plugin": "2.9.1",
|
||||
"minimatch": "10.0.1",
|
||||
"monaco-editor": "0.50.0",
|
||||
"monaco-editor": "0.51.0",
|
||||
"monaco-editor-webpack-plugin": "7.1.0",
|
||||
"pdfobject": "2.3.0",
|
||||
"postcss": "8.4.40",
|
||||
"postcss": "8.4.41",
|
||||
"postcss-loader": "8.1.1",
|
||||
"postcss-nesting": "12.1.5",
|
||||
"postcss-nesting": "13.0.0",
|
||||
"sortablejs": "1.15.2",
|
||||
"swagger-ui-dist": "5.17.14",
|
||||
"tailwindcss": "3.4.7",
|
||||
"tailwindcss": "3.4.10",
|
||||
"temporal-polyfill": "0.2.5",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
@ -54,20 +54,20 @@
|
||||
"typescript": "5.5.4",
|
||||
"uint8-to-base64": "0.2.0",
|
||||
"vanilla-colorful": "0.7.2",
|
||||
"vue": "3.4.35",
|
||||
"vue": "3.4.38",
|
||||
"vue-bar-graph": "2.1.0",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-loader": "17.4.2",
|
||||
"webpack": "5.93.0",
|
||||
"webpack": "5.94.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"wrap-ansi": "9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.3.0",
|
||||
"@playwright/test": "1.45.3",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.4.0",
|
||||
"@playwright/test": "1.46.1",
|
||||
"@stoplight/spectral-cli": "6.11.1",
|
||||
"@stylistic/eslint-plugin-js": "2.6.1",
|
||||
"@stylistic/stylelint-plugin": "3.0.0",
|
||||
"@stylistic/eslint-plugin-js": "2.6.5",
|
||||
"@stylistic/stylelint-plugin": "3.0.1",
|
||||
"@types/dropzone": "5.7.8",
|
||||
"@types/jquery": "3.5.30",
|
||||
"@types/katex": "0.16.7",
|
||||
@ -78,11 +78,11 @@
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/toastify-js": "1.12.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.0.0",
|
||||
"@typescript-eslint/parser": "8.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.3.0",
|
||||
"@typescript-eslint/parser": "8.3.0",
|
||||
"@vitejs/plugin-vue": "5.1.2",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-import-resolver-typescript": "3.6.1",
|
||||
"eslint-import-resolver-typescript": "3.6.3",
|
||||
"eslint-plugin-array-func": "4.0.0",
|
||||
"eslint-plugin-deprecation": "3.0.0",
|
||||
"eslint-plugin-github": "5.0.1",
|
||||
@ -91,14 +91,14 @@
|
||||
"eslint-plugin-no-use-extend-native": "0.5.0",
|
||||
"eslint-plugin-playwright": "1.6.2",
|
||||
"eslint-plugin-regexp": "2.6.0",
|
||||
"eslint-plugin-sonarjs": "1.0.4",
|
||||
"eslint-plugin-sonarjs": "2.0.1",
|
||||
"eslint-plugin-unicorn": "55.0.0",
|
||||
"eslint-plugin-vitest": "0.4.1",
|
||||
"eslint-plugin-vitest-globals": "1.5.0",
|
||||
"eslint-plugin-vue": "9.27.0",
|
||||
"eslint-plugin-vue-scoped-css": "2.8.1",
|
||||
"eslint-plugin-wc": "2.1.0",
|
||||
"happy-dom": "14.12.3",
|
||||
"eslint-plugin-wc": "2.1.1",
|
||||
"happy-dom": "15.3.1",
|
||||
"markdownlint-cli": "0.41.0",
|
||||
"nolyfill": "1.0.39",
|
||||
"postcss-html": "1.7.0",
|
||||
@ -107,8 +107,8 @@
|
||||
"stylelint-declaration-strict-value": "1.10.6",
|
||||
"stylelint-value-no-unknown-custom-properties": "6.0.1",
|
||||
"svgo": "3.3.2",
|
||||
"type-fest": "4.23.0",
|
||||
"updates": "16.3.7",
|
||||
"type-fest": "4.26.0",
|
||||
"updates": "16.4.0",
|
||||
"vite-string-plugin": "1.3.4",
|
||||
"vitest": "2.0.5"
|
||||
},
|
||||
@ -131,6 +131,10 @@
|
||||
"object.values": "npm:@nolyfill/object.values@^1",
|
||||
"safe-regex-test": "npm:@nolyfill/safe-regex-test@^1",
|
||||
"string.prototype.includes": "npm:@nolyfill/string.prototype.includes@^1",
|
||||
"is-core-module": "npm:@nolyfill/is-core-module@^1"
|
||||
"is-core-module": "npm:@nolyfill/is-core-module@^1",
|
||||
"array.prototype.findlast": "npm:@nolyfill/array.prototype.findlast@^1",
|
||||
"array.prototype.tosorted": "npm:@nolyfill/array.prototype.tosorted@^1",
|
||||
"string.prototype.matchall": "npm:@nolyfill/string.prototype.matchall@^1",
|
||||
"string.prototype.repeat": "npm:@nolyfill/string.prototype.repeat@^1"
|
||||
}
|
||||
}
|
||||
|
10
poetry.lock
generated
10
poetry.lock
generated
@ -42,13 +42,13 @@ six = ">=1.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "djlint"
|
||||
version = "1.34.1"
|
||||
version = "1.34.2"
|
||||
description = "HTML Template Linter and Formatter"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0,<4.0.0"
|
||||
python-versions = "<4.0.0,>=3.8.0"
|
||||
files = [
|
||||
{file = "djlint-1.34.1-py3-none-any.whl", hash = "sha256:96ff1c464fb6f061130ebc88663a2ea524d7ec51f4b56221a2b3f0320a3cfce8"},
|
||||
{file = "djlint-1.34.1.tar.gz", hash = "sha256:db93fa008d19eaadb0454edf1704931d14469d48508daba2df9941111f408346"},
|
||||
{file = "djlint-1.34.2-py3-none-any.whl", hash = "sha256:4825389e395eb77371857c77f547fa5ebd1a644b1bc4fe9fed19d49a2786b9e5"},
|
||||
{file = "djlint-1.34.2.tar.gz", hash = "sha256:db9b2e59203a452b83532499bc243c749279090b905cc1f657973f78e7a31ddd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -357,4 +357,4 @@ dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "cd2ff218e9f27a464dfbc8ec2387824a90f4360e04c3f2e58cc375796b7df33a"
|
||||
content-hash = "af89bce0c442463621b6e536f9b94c31e188e1662c2caa84372c0858a2ee7d5c"
|
||||
|
@ -5,7 +5,7 @@ package-mode = false
|
||||
python = "^3.10"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
djlint = "1.34.1"
|
||||
djlint = "1.34.2"
|
||||
yamllint = "1.35.1"
|
||||
|
||||
[tool.djlint]
|
||||
|
@ -883,10 +883,15 @@ func registerRoutes(m *web.Router) {
|
||||
m.Post("/teams/{team}/action/repo/{action}", org.TeamsRepoAction)
|
||||
}, context.OrgAssignment(true, false, true))
|
||||
|
||||
// require admin permission
|
||||
m.Group("/{org}", func() {
|
||||
m.Get("/teams/-/search", org.SearchTeam)
|
||||
}, context.OrgAssignment(true, false, false, true))
|
||||
|
||||
// require owner permission
|
||||
m.Group("/{org}", func() {
|
||||
m.Get("/teams/new", org.NewTeam)
|
||||
m.Post("/teams/new", web.Bind(forms.CreateTeamForm{}), org.NewTeamPost)
|
||||
m.Get("/teams/-/search", org.SearchTeam)
|
||||
m.Get("/teams/{team}/edit", org.EditTeam)
|
||||
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
|
||||
m.Post("/teams/{team}/delete", org.DeleteTeam)
|
||||
|
@ -29,7 +29,7 @@
|
||||
"sourceMap": true,
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"./types.d.ts",
|
||||
"./web_src/js/globals.d.ts",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
31
types.d.ts
vendored
31
types.d.ts
vendored
@ -1,31 +0,0 @@
|
||||
declare module '*.svg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.css' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare let __webpack_public_path__: string;
|
||||
|
||||
interface Window {
|
||||
config: import('./web_src/js/types.ts').Config;
|
||||
$: typeof import('@types/jquery'),
|
||||
jQuery: typeof import('@types/jquery'),
|
||||
htmx: typeof import('htmx.org'),
|
||||
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
|
||||
_inited: boolean,
|
||||
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
|
||||
},
|
||||
}
|
||||
|
||||
declare module 'htmx.org/dist/htmx.esm.js' {
|
||||
const value = await import('htmx.org');
|
||||
export default value;
|
||||
}
|
||||
|
||||
interface Element {
|
||||
_tippy: import('tippy.js').Instance;
|
||||
}
|
@ -252,6 +252,12 @@ const sfc = {
|
||||
// initial states for job steps
|
||||
this.currentJobStepsStates[i] = {cursor: null, expanded: false};
|
||||
}
|
||||
|
||||
// expands the currently running job step if its state wasn't 'running' before
|
||||
if (this.currentJob.steps[i].status === 'running' && this.currentJobStepsStates[i].cursor === null) {
|
||||
this.currentJobStepsStates[i].cursor = 0;
|
||||
this.currentJobStepsStates[i].expanded = true;
|
||||
}
|
||||
}
|
||||
// append logs to the UI
|
||||
for (const logs of job.logs.stepsLog) {
|
||||
|
61
web_src/js/globals.d.ts
vendored
Normal file
61
web_src/js/globals.d.ts
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
declare module '*.svg' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.css' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare let __webpack_public_path__: string;
|
||||
|
||||
declare module 'htmx.org/dist/htmx.esm.js' {
|
||||
const value = await import('htmx.org');
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module 'uint8-to-base64' {
|
||||
export function encode(arrayBuffer: ArrayBuffer): string;
|
||||
export function decode(base64str: string): ArrayBuffer;
|
||||
}
|
||||
|
||||
declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' {
|
||||
const value = await import('swagger-ui-dist');
|
||||
export default value.SwaggerUIBundle;
|
||||
}
|
||||
|
||||
interface JQuery {
|
||||
api: any, // fomantic
|
||||
areYouSure: any, // jquery.are-you-sure
|
||||
dimmer: any, // fomantic
|
||||
dropdown: any; // fomantic
|
||||
modal: any; // fomantic
|
||||
tab: any; // fomantic
|
||||
transition: any, // fomantic
|
||||
}
|
||||
|
||||
interface JQueryStatic {
|
||||
api: any, // fomantic
|
||||
}
|
||||
|
||||
interface Element {
|
||||
_tippy: import('tippy.js').Instance;
|
||||
}
|
||||
|
||||
type Writable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
|
||||
interface Window {
|
||||
config: import('./web_src/js/types.ts').Config;
|
||||
$: typeof import('@types/jquery'),
|
||||
jQuery: typeof import('@types/jquery'),
|
||||
htmx: Omit<typeof import('htmx.org/dist/htmx.esm.js').default, 'config'> & {
|
||||
config?: Writable<typeof import('htmx.org').default.config>,
|
||||
},
|
||||
ui?: any,
|
||||
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
|
||||
_inited: boolean,
|
||||
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
|
||||
},
|
||||
__webpack_public_path__: string;
|
||||
}
|
@ -1,20 +1,21 @@
|
||||
import {showErrorToast} from './modules/toast.ts';
|
||||
import 'idiomorph/dist/idiomorph-ext.js'; // https://github.com/bigskysoftware/idiomorph#htmx
|
||||
import type {HtmxResponseInfo} from 'htmx.org';
|
||||
|
||||
// https://github.com/bigskysoftware/idiomorph#htmx
|
||||
import 'idiomorph/dist/idiomorph-ext.js';
|
||||
type HtmxEvent = Event & {detail: HtmxResponseInfo};
|
||||
|
||||
// https://htmx.org/reference/#config
|
||||
window.htmx.config.requestClass = 'is-loading';
|
||||
window.htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
||||
// https://htmx.org/events/#htmx:sendError
|
||||
document.body.addEventListener('htmx:sendError', (event) => {
|
||||
document.body.addEventListener('htmx:sendError', (event: HtmxEvent) => {
|
||||
// TODO: add translations
|
||||
showErrorToast(`Network error when calling ${event.detail.requestConfig.path}`);
|
||||
});
|
||||
|
||||
// https://htmx.org/events/#htmx:responseError
|
||||
document.body.addEventListener('htmx:responseError', (event) => {
|
||||
document.body.addEventListener('htmx:responseError', (event: HtmxEvent) => {
|
||||
// TODO: add translations
|
||||
showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}`);
|
||||
});
|
||||
|
@ -98,12 +98,12 @@ initGiteaFomantic();
|
||||
initDirAuto();
|
||||
initSubmitEventPolyfill();
|
||||
|
||||
function callInitFunctions(functions) {
|
||||
function callInitFunctions(functions: (() => any)[]) {
|
||||
// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
|
||||
// It is a quick check, no side effect so no need to do slow URL parsing.
|
||||
const initStart = performance.now();
|
||||
if (window.location.search.includes('_ui_performance_trace=1')) {
|
||||
let results = [];
|
||||
let results: {name: string, dur: number}[] = [];
|
||||
for (const func of functions) {
|
||||
const start = performance.now();
|
||||
func();
|
||||
|
@ -1,12 +1,12 @@
|
||||
import {AnsiUp} from 'ansi_up';
|
||||
|
||||
const replacements = [
|
||||
const replacements: Array<[RegExp, string]> = [
|
||||
[/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op
|
||||
[/\x1b\[\d?[JK]/g, '\r'], // Erase display/line, treat them as a Carriage Return
|
||||
];
|
||||
|
||||
// render ANSI to HTML
|
||||
export function renderAnsi(line) {
|
||||
export function renderAnsi(line: string): string {
|
||||
// create a fresh ansi_up instance because otherwise previous renders can influence
|
||||
// the output of future renders, because ansi_up is stateful and remembers things like
|
||||
// unclosed opening tags for colors.
|
||||
|
@ -8,7 +8,7 @@ window.addEventListener('load', async () => {
|
||||
|
||||
// Make the page's protocol be at the top of the schemes list
|
||||
const proto = window.location.protocol.slice(0, -1);
|
||||
spec.schemes.sort((a, b) => {
|
||||
spec.schemes.sort((a: string, b: string) => {
|
||||
if (a === proto) return -1;
|
||||
if (b === proto) return 1;
|
||||
return 0;
|
||||
|
@ -17,7 +17,7 @@ test('svgParseOuterInner', () => {
|
||||
test('SvgIcon', () => {
|
||||
const root = document.createElement('div');
|
||||
createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root);
|
||||
const node = root.firstChild;
|
||||
const node = root.firstChild as Element;
|
||||
expect(node.nodeName).toEqual('svg');
|
||||
expect(node.getAttribute('width')).toEqual('24');
|
||||
expect(node.getAttribute('height')).toEqual('24');
|
||||
|
@ -29,3 +29,10 @@ export type RequestData = string | FormData | URLSearchParams;
|
||||
export type RequestOpts = {
|
||||
data?: RequestData,
|
||||
} & RequestInit;
|
||||
|
||||
export type IssueData = {
|
||||
owner: string,
|
||||
repo: string,
|
||||
type: string,
|
||||
index: string,
|
||||
}
|
||||
|
@ -95,23 +95,20 @@ test('toAbsoluteUrl', () => {
|
||||
});
|
||||
|
||||
test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
|
||||
// TextEncoder is Node.js API while Uint8Array is jsdom API and their outputs are not
|
||||
// structurally comparable, so we convert to array to compare. The conversion can be
|
||||
// removed once https://github.com/jsdom/jsdom/issues/2524 is resolved.
|
||||
const encoder = new TextEncoder();
|
||||
const uint8array = encoder.encode.bind(encoder);
|
||||
|
||||
expect(encodeURLEncodedBase64(uint8array('AA?'))).toEqual('QUE_'); // standard base64: "QUE/"
|
||||
expect(encodeURLEncodedBase64(uint8array('AA~'))).toEqual('QUF-'); // standard base64: "QUF+"
|
||||
|
||||
expect(Array.from(decodeURLEncodedBase64('QUE/'))).toEqual(Array.from(uint8array('AA?')));
|
||||
expect(Array.from(decodeURLEncodedBase64('QUF+'))).toEqual(Array.from(uint8array('AA~')));
|
||||
expect(Array.from(decodeURLEncodedBase64('QUE_'))).toEqual(Array.from(uint8array('AA?')));
|
||||
expect(Array.from(decodeURLEncodedBase64('QUF-'))).toEqual(Array.from(uint8array('AA~')));
|
||||
expect(new Uint8Array(decodeURLEncodedBase64('QUE/'))).toEqual(uint8array('AA?'));
|
||||
expect(new Uint8Array(decodeURLEncodedBase64('QUF+'))).toEqual(uint8array('AA~'));
|
||||
expect(new Uint8Array(decodeURLEncodedBase64('QUE_'))).toEqual(uint8array('AA?'));
|
||||
expect(new Uint8Array(decodeURLEncodedBase64('QUF-'))).toEqual(uint8array('AA~'));
|
||||
|
||||
expect(encodeURLEncodedBase64(uint8array('a'))).toEqual('YQ'); // standard base64: "YQ=="
|
||||
expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a')));
|
||||
expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a')));
|
||||
expect(new Uint8Array(decodeURLEncodedBase64('YQ'))).toEqual(uint8array('a'));
|
||||
expect(new Uint8Array(decodeURLEncodedBase64('YQ=='))).toEqual(uint8array('a'));
|
||||
});
|
||||
|
||||
test('file detection', () => {
|
||||
|
@ -1,13 +1,14 @@
|
||||
import {encode, decode} from 'uint8-to-base64';
|
||||
import type {IssueData} from './types.ts';
|
||||
|
||||
// transform /path/to/file.ext to file.ext
|
||||
export function basename(path) {
|
||||
export function basename(path: string): string {
|
||||
const lastSlashIndex = path.lastIndexOf('/');
|
||||
return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1);
|
||||
}
|
||||
|
||||
// transform /path/to/file.ext to .ext
|
||||
export function extname(path) {
|
||||
export function extname(path: string): string {
|
||||
const lastSlashIndex = path.lastIndexOf('/');
|
||||
const lastPointIndex = path.lastIndexOf('.');
|
||||
if (lastSlashIndex > lastPointIndex) return '';
|
||||
@ -15,54 +16,54 @@ export function extname(path) {
|
||||
}
|
||||
|
||||
// test whether a variable is an object
|
||||
export function isObject(obj) {
|
||||
export function isObject(obj: any): boolean {
|
||||
return Object.prototype.toString.call(obj) === '[object Object]';
|
||||
}
|
||||
|
||||
// returns whether a dark theme is enabled
|
||||
export function isDarkTheme() {
|
||||
export function isDarkTheme(): boolean {
|
||||
const style = window.getComputedStyle(document.documentElement);
|
||||
return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
// strip <tags> from a string
|
||||
export function stripTags(text) {
|
||||
export function stripTags(text: string): string {
|
||||
return text.replace(/<[^>]*>?/g, '');
|
||||
}
|
||||
|
||||
export function parseIssueHref(href) {
|
||||
export function parseIssueHref(href: string): IssueData {
|
||||
const path = (href || '').replace(/[#?].*$/, '');
|
||||
const [_, owner, repo, type, index] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
|
||||
return {owner, repo, type, index};
|
||||
}
|
||||
|
||||
// parse a URL, either relative '/path' or absolute 'https://localhost/path'
|
||||
export function parseUrl(str) {
|
||||
export function parseUrl(str: string): URL {
|
||||
return new URL(str, str.startsWith('http') ? undefined : window.location.origin);
|
||||
}
|
||||
|
||||
// return current locale chosen by user
|
||||
export function getCurrentLocale() {
|
||||
export function getCurrentLocale(): string {
|
||||
return document.documentElement.lang;
|
||||
}
|
||||
|
||||
// given a month (0-11), returns it in the documents language
|
||||
export function translateMonth(month) {
|
||||
export function translateMonth(month: number) {
|
||||
return new Date(Date.UTC(2022, month, 12)).toLocaleString(getCurrentLocale(), {month: 'short', timeZone: 'UTC'});
|
||||
}
|
||||
|
||||
// given a weekday (0-6, Sunday to Saturday), returns it in the documents language
|
||||
export function translateDay(day) {
|
||||
export function translateDay(day: number) {
|
||||
return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short', timeZone: 'UTC'});
|
||||
}
|
||||
|
||||
// convert a Blob to a DataURI
|
||||
export function blobToDataURI(blob) {
|
||||
export function blobToDataURI(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', (e) => {
|
||||
resolve(e.target.result);
|
||||
resolve(e.target.result as string);
|
||||
});
|
||||
reader.addEventListener('error', () => {
|
||||
reject(new Error('FileReader failed'));
|
||||
@ -75,7 +76,7 @@ export function blobToDataURI(blob) {
|
||||
}
|
||||
|
||||
// convert image Blob to another mime-type format.
|
||||
export function convertImage(blob, mime) {
|
||||
export function convertImage(blob: Blob, mime: string): Promise<Blob> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const img = new Image();
|
||||
@ -104,7 +105,7 @@ export function convertImage(blob, mime) {
|
||||
});
|
||||
}
|
||||
|
||||
export function toAbsoluteUrl(url) {
|
||||
export function toAbsoluteUrl(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
@ -118,15 +119,15 @@ export function toAbsoluteUrl(url) {
|
||||
}
|
||||
|
||||
// Encode an ArrayBuffer into a URLEncoded base64 string.
|
||||
export function encodeURLEncodedBase64(arrayBuffer) {
|
||||
export function encodeURLEncodedBase64(arrayBuffer: ArrayBuffer): string {
|
||||
return encode(arrayBuffer)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
// Decode a URLEncoded base64 to an ArrayBuffer string.
|
||||
export function decodeURLEncodedBase64(base64url) {
|
||||
// Decode a URLEncoded base64 to an ArrayBuffer.
|
||||
export function decodeURLEncodedBase64(base64url: string): ArrayBuffer {
|
||||
return decode(base64url
|
||||
.replace(/_/g, '/')
|
||||
.replace(/-/g, '+'));
|
||||
@ -135,20 +136,22 @@ export function decodeURLEncodedBase64(base64url) {
|
||||
const domParser = new DOMParser();
|
||||
const xmlSerializer = new XMLSerializer();
|
||||
|
||||
export function parseDom(text, contentType) {
|
||||
export function parseDom(text: string, contentType: DOMParserSupportedType): Document {
|
||||
return domParser.parseFromString(text, contentType);
|
||||
}
|
||||
|
||||
export function serializeXml(node) {
|
||||
export function serializeXml(node: Element | Node): string {
|
||||
return xmlSerializer.serializeToString(node);
|
||||
}
|
||||
|
||||
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function isImageFile({name, type}) {
|
||||
export function isImageFile({name, type}: {name: string, type?: string}): boolean {
|
||||
return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/');
|
||||
}
|
||||
|
||||
export function isVideoFile({name, type}) {
|
||||
export function isVideoFile({name, type}: {name: string, type?: string}): boolean {
|
||||
return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/');
|
||||
}
|
||||
|
@ -3,23 +3,23 @@ import type {ColorInput} from 'tinycolor2';
|
||||
|
||||
// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
|
||||
// Keep this in sync with modules/util/color.go
|
||||
function getRelativeLuminance(color: ColorInput) {
|
||||
function getRelativeLuminance(color: ColorInput): number {
|
||||
const {r, g, b} = tinycolor(color).toRgb();
|
||||
return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
|
||||
}
|
||||
|
||||
function useLightText(backgroundColor: ColorInput) {
|
||||
function useLightText(backgroundColor: ColorInput): boolean {
|
||||
return getRelativeLuminance(backgroundColor) < 0.453;
|
||||
}
|
||||
|
||||
// Given a background color, returns a black or white foreground color that the highest
|
||||
// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
|
||||
// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
|
||||
export function contrastColor(backgroundColor: ColorInput) {
|
||||
export function contrastColor(backgroundColor: ColorInput): string {
|
||||
return useLightText(backgroundColor) ? '#fff' : '#000';
|
||||
}
|
||||
|
||||
function resolveColors(obj: Record<string, string>) {
|
||||
function resolveColors(obj: Record<string, string>): Record<string, string> {
|
||||
const styles = window.getComputedStyle(document.documentElement);
|
||||
const getColor = (name: string) => styles.getPropertyValue(name).trim();
|
||||
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)]));
|
||||
|
@ -266,10 +266,8 @@ export function initSubmitEventPolyfill() {
|
||||
/**
|
||||
* Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
|
||||
* Note: This function doesn't account for all possible visibility scenarios.
|
||||
* @param {HTMLElement} element The element to check.
|
||||
* @returns {boolean} True if the element is visible.
|
||||
*/
|
||||
export function isElemVisible(element: HTMLElement) {
|
||||
export function isElemVisible(element: HTMLElement): boolean {
|
||||
if (!element) return false;
|
||||
|
||||
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
|
@ -1,6 +1,11 @@
|
||||
export async function pngChunks(blob) {
|
||||
type PngChunk = {
|
||||
name: string,
|
||||
data: Uint8Array,
|
||||
}
|
||||
|
||||
export async function pngChunks(blob: Blob): Promise<PngChunk[]> {
|
||||
const uint8arr = new Uint8Array(await blob.arrayBuffer());
|
||||
const chunks = [];
|
||||
const chunks: PngChunk[] = [];
|
||||
if (uint8arr.length < 12) return chunks;
|
||||
const view = new DataView(uint8arr.buffer);
|
||||
if (view.getBigUint64(0) !== 9894494448401390090n) return chunks;
|
||||
@ -19,9 +24,14 @@ export async function pngChunks(blob) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
type ImageInfo = {
|
||||
width?: number,
|
||||
dppx?: number,
|
||||
}
|
||||
|
||||
// decode a image and try to obtain width and dppx. It will never throw but instead
|
||||
// return default values.
|
||||
export async function imageInfo(blob) {
|
||||
export async function imageInfo(blob: Blob): Promise<ImageInfo> {
|
||||
let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens
|
||||
|
||||
if (blob.type === 'image/png') { // only png is supported currently
|
||||
|
@ -2,17 +2,17 @@ import emojis from '../../../assets/emoji.json';
|
||||
|
||||
const maxMatches = 6;
|
||||
|
||||
function sortAndReduce(map) {
|
||||
function sortAndReduce(map: Map<string, number>) {
|
||||
const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
|
||||
return Array.from(sortedMap.keys()).slice(0, maxMatches);
|
||||
}
|
||||
|
||||
export function matchEmoji(queryText) {
|
||||
export function matchEmoji(queryText: string): string[] {
|
||||
const query = queryText.toLowerCase().replaceAll('_', ' ');
|
||||
if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]);
|
||||
|
||||
// results is a map of weights, lower is better
|
||||
const results = new Map();
|
||||
const results = new Map<string, number>();
|
||||
for (const {aliases} of emojis) {
|
||||
const mainAlias = aliases[0];
|
||||
for (const [aliasIndex, alias] of aliases.entries()) {
|
||||
@ -27,7 +27,7 @@ export function matchEmoji(queryText) {
|
||||
return sortAndReduce(results);
|
||||
}
|
||||
|
||||
export function matchMention(queryText) {
|
||||
export function matchMention(queryText: string): string[] {
|
||||
const query = queryText.toLowerCase();
|
||||
|
||||
// results is a map of weights, lower is better
|
||||
|
@ -1,16 +1,17 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
import type {ConfigType} from 'dayjs';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
/**
|
||||
* Returns an array of millisecond-timestamps of start-of-week days (Sundays)
|
||||
*
|
||||
* @param startConfig The start date. Can take any type that `Date` accepts.
|
||||
* @param endConfig The end date. Can take any type that `Date` accepts.
|
||||
* @param startDate The start date. Can take any type that dayjs accepts.
|
||||
* @param endDate The end date. Can take any type that dayjs accepts.
|
||||
*/
|
||||
export function startDaysBetween(startDate, endDate) {
|
||||
export function startDaysBetween(startDate: ConfigType, endDate: ConfigType): number[] {
|
||||
const start = dayjs.utc(startDate);
|
||||
const end = dayjs.utc(endDate);
|
||||
|
||||
@ -21,7 +22,7 @@ export function startDaysBetween(startDate, endDate) {
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
|
||||
const startDays = [];
|
||||
const startDays: number[] = [];
|
||||
while (current.isBefore(end)) {
|
||||
startDays.push(current.valueOf());
|
||||
current = current.add(1, 'week');
|
||||
@ -30,7 +31,7 @@ export function startDaysBetween(startDate, endDate) {
|
||||
return startDays;
|
||||
}
|
||||
|
||||
export function firstStartDateAfterDate(inputDate) {
|
||||
export function firstStartDateAfterDate(inputDate: Date): number {
|
||||
if (!(inputDate instanceof Date)) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
@ -41,7 +42,14 @@ export function firstStartDateAfterDate(inputDate) {
|
||||
return resultDate.valueOf();
|
||||
}
|
||||
|
||||
export function fillEmptyStartDaysWithZeroes(startDays, data) {
|
||||
type DayData = {
|
||||
week: number,
|
||||
additions: number,
|
||||
deletions: number,
|
||||
commits: number,
|
||||
}
|
||||
|
||||
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData): DayData[] {
|
||||
const result = {};
|
||||
|
||||
for (const startDay of startDays) {
|
||||
@ -51,11 +59,11 @@ export function fillEmptyStartDaysWithZeroes(startDays, data) {
|
||||
return Object.values(result);
|
||||
}
|
||||
|
||||
let dateFormat;
|
||||
let dateFormat: Intl.DateTimeFormat;
|
||||
|
||||
// format a Date object to document's locale, but with 24h format from user's current locale because this
|
||||
// option is a personal preference of the user, not something that the document's locale should dictate.
|
||||
export function formatDatetime(date) {
|
||||
export function formatDatetime(date: Date | number): string {
|
||||
if (!dateFormat) {
|
||||
// TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support
|
||||
dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||
|
@ -1,12 +1,12 @@
|
||||
export function pathEscapeSegments(s) {
|
||||
export function pathEscapeSegments(s: string): string {
|
||||
return s.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function stripSlash(url) {
|
||||
function stripSlash(url: string): string {
|
||||
return url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
export function isUrl(url) {
|
||||
export function isUrl(url: string): boolean {
|
||||
try {
|
||||
return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
|
||||
} catch {
|
||||
|
@ -1,10 +1,16 @@
|
||||
window.__webpack_public_path__ = '';
|
||||
|
||||
window.config = {
|
||||
appUrl: 'http://localhost:3000/',
|
||||
appSubUrl: '',
|
||||
assetVersionEncoded: '',
|
||||
assetUrlPrefix: '',
|
||||
runModeIsProd: true,
|
||||
customEmojis: {},
|
||||
csrfToken: 'test-csrf-token-123456',
|
||||
pageData: {},
|
||||
i18n: {},
|
||||
appSubUrl: '',
|
||||
notificationSettings: {},
|
||||
enableTimeTracking: true,
|
||||
mentionValues: [
|
||||
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
|
||||
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
|
||||
@ -14,4 +20,6 @@ window.config = {
|
||||
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
|
||||
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
|
||||
],
|
||||
mermaidMaxSourceCharacters: 5000,
|
||||
i18n: {},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user