mirror of
https://github.com/miniflux/v2.git
synced 2025-06-27 16:36:00 +00:00
Refactor entry/feed query builder sorting to match SQL semantic
This commit is contained in:
parent
095bec072c
commit
28775f5e10
15 changed files with 44 additions and 79 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -2,4 +2,5 @@ miniflux-*
|
||||||
miniflux
|
miniflux
|
||||||
*.rpm
|
*.rpm
|
||||||
*.deb
|
*.deb
|
||||||
.idea
|
.idea
|
||||||
|
.vscode
|
|
@ -143,8 +143,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
|
||||||
builder.WithFeedID(feedID)
|
builder.WithFeedID(feedID)
|
||||||
builder.WithCategoryID(categoryID)
|
builder.WithCategoryID(categoryID)
|
||||||
builder.WithStatuses(statuses)
|
builder.WithStatuses(statuses)
|
||||||
builder.WithOrder(order)
|
builder.WithSorting(order, direction)
|
||||||
builder.WithDirection(direction)
|
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(limit)
|
builder.WithLimit(limit)
|
||||||
builder.WithTags(tags)
|
builder.WithTags(tags)
|
||||||
|
|
|
@ -242,8 +242,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||||
builder := h.store.NewEntryQueryBuilder(userID)
|
builder := h.store.NewEntryQueryBuilder(userID)
|
||||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||||
builder.WithLimit(50)
|
builder.WithLimit(50)
|
||||||
builder.WithOrder("id")
|
builder.WithSorting("id", model.DefaultSortingDirection)
|
||||||
builder.WithDirection(model.DefaultSortingDirection)
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case request.HasQueryParam(r, "since_id"):
|
case request.HasQueryParam(r, "since_id"):
|
||||||
|
@ -256,11 +255,11 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
|
||||||
maxID := request.QueryInt64Param(r, "max_id", 0)
|
maxID := request.QueryInt64Param(r, "max_id", 0)
|
||||||
if maxID == 0 {
|
if maxID == 0 {
|
||||||
logger.Debug("[Fever] Fetching most recent items for user #%d", userID)
|
logger.Debug("[Fever] Fetching most recent items for user #%d", userID)
|
||||||
builder.WithDirection("desc")
|
builder.WithSorting("id", "DESC")
|
||||||
} else if maxID > 0 {
|
} else if maxID > 0 {
|
||||||
logger.Debug("[Fever] Fetching items before #%d for user #%d", maxID, userID)
|
logger.Debug("[Fever] Fetching items before #%d for user #%d", maxID, userID)
|
||||||
builder.BeforeEntryID(maxID)
|
builder.BeforeEntryID(maxID)
|
||||||
builder.WithDirection("desc")
|
builder.WithSorting("id", "DESC")
|
||||||
}
|
}
|
||||||
case request.HasQueryParam(r, "with_ids"):
|
case request.HasQueryParam(r, "with_ids"):
|
||||||
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
|
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
|
||||||
|
|
|
@ -790,8 +790,7 @@ func (h *handler) streamItemContents(w http.ResponseWriter, r *http.Request) {
|
||||||
builder := h.store.NewEntryQueryBuilder(userID)
|
builder := h.store.NewEntryQueryBuilder(userID)
|
||||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||||
builder.WithEntryIDs(itemIDs)
|
builder.WithEntryIDs(itemIDs)
|
||||||
builder.WithOrder(model.DefaultSortingOrder)
|
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
|
||||||
builder.WithDirection(requestModifiers.SortDirection)
|
|
||||||
|
|
||||||
entries, err := builder.GetEntries()
|
entries, err := builder.GetEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1144,8 +1143,7 @@ func (h *handler) handleReadingListStream(w http.ResponseWriter, r *http.Request
|
||||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||||
builder.WithLimit(rm.Count)
|
builder.WithLimit(rm.Count)
|
||||||
builder.WithOffset(rm.Offset)
|
builder.WithOffset(rm.Offset)
|
||||||
builder.WithOrder(model.DefaultSortingOrder)
|
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||||
builder.WithDirection(rm.SortDirection)
|
|
||||||
if rm.StartTime > 0 {
|
if rm.StartTime > 0 {
|
||||||
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
||||||
}
|
}
|
||||||
|
@ -1187,8 +1185,7 @@ func (h *handler) handleStarredStream(w http.ResponseWriter, r *http.Request, rm
|
||||||
builder.WithStarred(true)
|
builder.WithStarred(true)
|
||||||
builder.WithLimit(rm.Count)
|
builder.WithLimit(rm.Count)
|
||||||
builder.WithOffset(rm.Offset)
|
builder.WithOffset(rm.Offset)
|
||||||
builder.WithOrder(model.DefaultSortingOrder)
|
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||||
builder.WithDirection(rm.SortDirection)
|
|
||||||
if rm.StartTime > 0 {
|
if rm.StartTime > 0 {
|
||||||
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
||||||
}
|
}
|
||||||
|
@ -1230,8 +1227,7 @@ func (h *handler) handleReadStream(w http.ResponseWriter, r *http.Request, rm Re
|
||||||
builder.WithStatus(model.EntryStatusRead)
|
builder.WithStatus(model.EntryStatusRead)
|
||||||
builder.WithLimit(rm.Count)
|
builder.WithLimit(rm.Count)
|
||||||
builder.WithOffset(rm.Offset)
|
builder.WithOffset(rm.Offset)
|
||||||
builder.WithOrder(model.DefaultSortingOrder)
|
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||||
builder.WithDirection(rm.SortDirection)
|
|
||||||
if rm.StartTime > 0 {
|
if rm.StartTime > 0 {
|
||||||
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
||||||
}
|
}
|
||||||
|
@ -1279,8 +1275,7 @@ func (h *handler) handleFeedStream(w http.ResponseWriter, r *http.Request, rm Re
|
||||||
builder.WithFeedID(feedID)
|
builder.WithFeedID(feedID)
|
||||||
builder.WithLimit(rm.Count)
|
builder.WithLimit(rm.Count)
|
||||||
builder.WithOffset(rm.Offset)
|
builder.WithOffset(rm.Offset)
|
||||||
builder.WithOrder(model.DefaultSortingOrder)
|
builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
|
||||||
builder.WithDirection(rm.SortDirection)
|
|
||||||
if rm.StartTime > 0 {
|
if rm.StartTime > 0 {
|
||||||
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
builder.AfterDate(time.Unix(rm.StartTime, 0))
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,13 +18,12 @@ import (
|
||||||
|
|
||||||
// EntryQueryBuilder builds a SQL query to fetch entries.
|
// EntryQueryBuilder builds a SQL query to fetch entries.
|
||||||
type EntryQueryBuilder struct {
|
type EntryQueryBuilder struct {
|
||||||
store *Storage
|
store *Storage
|
||||||
args []interface{}
|
args []interface{}
|
||||||
conditions []string
|
conditions []string
|
||||||
order string
|
sortExpressions []string
|
||||||
direction string
|
limit int
|
||||||
limit int
|
offset int
|
||||||
offset int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSearchQuery adds full-text search query to the condition.
|
// WithSearchQuery adds full-text search query to the condition.
|
||||||
|
@ -35,8 +34,10 @@ func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder {
|
||||||
e.args = append(e.args, query)
|
e.args = append(e.args, query)
|
||||||
|
|
||||||
// 0.0000001 = 0.1 / (seconds_in_a_day)
|
// 0.0000001 = 0.1 / (seconds_in_a_day)
|
||||||
e.WithOrder(fmt.Sprintf("ts_rank(document_vectors, plainto_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001", nArgs))
|
e.WithSorting(
|
||||||
e.WithDirection("DESC")
|
fmt.Sprintf("ts_rank(document_vectors, plainto_tsquery($%d)) - extract (epoch from now() - published_at)::float * 0.0000001", nArgs),
|
||||||
|
"DESC",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
@ -168,15 +169,9 @@ func (e *EntryQueryBuilder) WithShareCodeNotEmpty() *EntryQueryBuilder {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithOrder set the sorting order.
|
// WithSorting add a sort expression.
|
||||||
func (e *EntryQueryBuilder) WithOrder(order string) *EntryQueryBuilder {
|
func (e *EntryQueryBuilder) WithSorting(column, direction string) *EntryQueryBuilder {
|
||||||
e.order = order
|
e.sortExpressions = append(e.sortExpressions, fmt.Sprintf("%s %s", column, direction))
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithDirection set the sorting direction.
|
|
||||||
func (e *EntryQueryBuilder) WithDirection(direction string) *EntryQueryBuilder {
|
|
||||||
e.direction = direction
|
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -403,12 +398,8 @@ func (e *EntryQueryBuilder) buildCondition() string {
|
||||||
func (e *EntryQueryBuilder) buildSorting() string {
|
func (e *EntryQueryBuilder) buildSorting() string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
if e.order != "" {
|
if len(e.sortExpressions) > 0 {
|
||||||
parts = append(parts, fmt.Sprintf(`ORDER BY %s`, e.order))
|
parts = append(parts, fmt.Sprintf(`ORDER BY %s`, strings.Join(e.sortExpressions, ", ")))
|
||||||
}
|
|
||||||
|
|
||||||
if e.direction != "" {
|
|
||||||
parts = append(parts, e.direction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.limit > 0 {
|
if e.limit > 0 {
|
||||||
|
|
|
@ -135,8 +135,7 @@ func (s *Storage) CountAllFeedsWithErrors() int {
|
||||||
// Feeds returns all feeds that belongs to the given user.
|
// Feeds returns all feeds that belongs to the given user.
|
||||||
func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
|
func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
|
||||||
builder := NewFeedQueryBuilder(s, userID)
|
builder := NewFeedQueryBuilder(s, userID)
|
||||||
builder.WithOrder(model.DefaultFeedSorting)
|
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
|
||||||
builder.WithDirection(model.DefaultFeedSortingDirection)
|
|
||||||
return builder.GetFeeds()
|
return builder.GetFeeds()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,8 +152,7 @@ func getFeedsSorted(builder *FeedQueryBuilder) (model.Feeds, error) {
|
||||||
func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
|
func (s *Storage) FeedsWithCounters(userID int64) (model.Feeds, error) {
|
||||||
builder := NewFeedQueryBuilder(s, userID)
|
builder := NewFeedQueryBuilder(s, userID)
|
||||||
builder.WithCounters()
|
builder.WithCounters()
|
||||||
builder.WithOrder(model.DefaultFeedSorting)
|
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
|
||||||
builder.WithDirection(model.DefaultFeedSortingDirection)
|
|
||||||
return getFeedsSorted(builder)
|
return getFeedsSorted(builder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,8 +169,7 @@ func (s *Storage) FeedsByCategoryWithCounters(userID, categoryID int64) (model.F
|
||||||
builder := NewFeedQueryBuilder(s, userID)
|
builder := NewFeedQueryBuilder(s, userID)
|
||||||
builder.WithCategoryID(categoryID)
|
builder.WithCategoryID(categoryID)
|
||||||
builder.WithCounters()
|
builder.WithCounters()
|
||||||
builder.WithOrder(model.DefaultFeedSorting)
|
builder.WithSorting(model.DefaultFeedSorting, model.DefaultFeedSortingDirection)
|
||||||
builder.WithDirection(model.DefaultFeedSortingDirection)
|
|
||||||
return getFeedsSorted(builder)
|
return getFeedsSorted(builder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,8 +18,7 @@ type FeedQueryBuilder struct {
|
||||||
store *Storage
|
store *Storage
|
||||||
args []interface{}
|
args []interface{}
|
||||||
conditions []string
|
conditions []string
|
||||||
order string
|
sortExpressions []string
|
||||||
direction string
|
|
||||||
limit int
|
limit int
|
||||||
offset int
|
offset int
|
||||||
withCounters bool
|
withCounters bool
|
||||||
|
@ -66,15 +65,9 @@ func (f *FeedQueryBuilder) WithCounters() *FeedQueryBuilder {
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithOrder set the sorting order.
|
// WithSorting add a sort expression.
|
||||||
func (f *FeedQueryBuilder) WithOrder(order string) *FeedQueryBuilder {
|
func (f *FeedQueryBuilder) WithSorting(column, direction string) *FeedQueryBuilder {
|
||||||
f.order = order
|
f.sortExpressions = append(f.sortExpressions, fmt.Sprintf("%s %s", column, direction))
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithDirection set the sorting direction.
|
|
||||||
func (f *FeedQueryBuilder) WithDirection(direction string) *FeedQueryBuilder {
|
|
||||||
f.direction = direction
|
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,12 +94,8 @@ func (f *FeedQueryBuilder) buildCounterCondition() string {
|
||||||
func (f *FeedQueryBuilder) buildSorting() string {
|
func (f *FeedQueryBuilder) buildSorting() string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|
||||||
if f.order != "" {
|
if len(f.sortExpressions) > 0 {
|
||||||
parts = append(parts, fmt.Sprintf(`ORDER BY %s`, f.order))
|
parts = append(parts, fmt.Sprintf(`ORDER BY %s`, strings.Join(f.sortExpressions, ", ")))
|
||||||
}
|
|
||||||
|
|
||||||
if f.direction != "" {
|
|
||||||
parts = append(parts, f.direction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
|
|
|
@ -26,8 +26,7 @@ func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
|
||||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||||
builder.WithStarred(true)
|
builder.WithStarred(true)
|
||||||
builder.WithOrder(user.EntryOrder)
|
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||||
builder.WithDirection(user.EntryDirection)
|
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(user.EntriesPerPage)
|
builder.WithLimit(user.EntriesPerPage)
|
||||||
|
|
||||||
|
|
|
@ -37,8 +37,7 @@ func (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request
|
||||||
offset := request.QueryIntParam(r, "offset", 0)
|
offset := request.QueryIntParam(r, "offset", 0)
|
||||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithCategoryID(category.ID)
|
builder.WithCategoryID(category.ID)
|
||||||
builder.WithOrder(user.EntryOrder)
|
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||||
builder.WithDirection(user.EntryDirection)
|
|
||||||
builder.WithStatus(model.EntryStatusUnread)
|
builder.WithStatus(model.EntryStatusUnread)
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(user.EntriesPerPage)
|
builder.WithLimit(user.EntriesPerPage)
|
||||||
|
|
|
@ -37,8 +37,7 @@ func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Requ
|
||||||
offset := request.QueryIntParam(r, "offset", 0)
|
offset := request.QueryIntParam(r, "offset", 0)
|
||||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithCategoryID(category.ID)
|
builder.WithCategoryID(category.ID)
|
||||||
builder.WithOrder(user.EntryOrder)
|
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||||
builder.WithDirection(user.EntryDirection)
|
|
||||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(user.EntriesPerPage)
|
builder.WithLimit(user.EntriesPerPage)
|
||||||
|
|
|
@ -38,8 +38,7 @@ func (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithFeedID(feed.ID)
|
builder.WithFeedID(feed.ID)
|
||||||
builder.WithStatus(model.EntryStatusUnread)
|
builder.WithStatus(model.EntryStatusUnread)
|
||||||
builder.WithOrder(user.EntryOrder)
|
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||||
builder.WithDirection(user.EntryDirection)
|
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(user.EntriesPerPage)
|
builder.WithLimit(user.EntriesPerPage)
|
||||||
|
|
||||||
|
|
|
@ -38,8 +38,7 @@ func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request)
|
||||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithFeedID(feed.ID)
|
builder.WithFeedID(feed.ID)
|
||||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||||
builder.WithOrder(user.EntryOrder)
|
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||||
builder.WithDirection(user.EntryDirection)
|
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(user.EntriesPerPage)
|
builder.WithLimit(user.EntriesPerPage)
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,8 @@ func (h *handler) showHistoryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
offset := request.QueryIntParam(r, "offset", 0)
|
offset := request.QueryIntParam(r, "offset", 0)
|
||||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithStatus(model.EntryStatusRead)
|
builder.WithStatus(model.EntryStatusRead)
|
||||||
builder.WithOrder("changed_at DESC, published_at DESC")
|
builder.WithSorting("changed_at", "DESC")
|
||||||
|
builder.WithSorting("published_at", "DESC")
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(user.EntriesPerPage)
|
builder.WithLimit(user.EntriesPerPage)
|
||||||
|
|
||||||
|
|
|
@ -22,8 +22,7 @@ func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
builder := h.store.NewEntryQueryBuilder(user.ID)
|
builder := h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithShareCodeNotEmpty()
|
builder.WithShareCodeNotEmpty()
|
||||||
builder.WithOrder(user.EntryOrder)
|
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||||
builder.WithDirection(user.EntryDirection)
|
|
||||||
|
|
||||||
entries, err := builder.GetEntries()
|
entries, err := builder.GetEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -49,8 +49,7 @@ func (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) {
|
||||||
beginSqlFetchUnreadEntries := time.Now()
|
beginSqlFetchUnreadEntries := time.Now()
|
||||||
builder = h.store.NewEntryQueryBuilder(user.ID)
|
builder = h.store.NewEntryQueryBuilder(user.ID)
|
||||||
builder.WithStatus(model.EntryStatusUnread)
|
builder.WithStatus(model.EntryStatusUnread)
|
||||||
builder.WithOrder(user.EntryOrder)
|
builder.WithSorting(user.EntryOrder, user.EntryDirection)
|
||||||
builder.WithDirection(user.EntryDirection)
|
|
||||||
builder.WithOffset(offset)
|
builder.WithOffset(offset)
|
||||||
builder.WithLimit(user.EntriesPerPage)
|
builder.WithLimit(user.EntriesPerPage)
|
||||||
builder.WithGloballyVisible()
|
builder.WithGloballyVisible()
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue