mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 00:39:13 +00:00
feat(filters): support partial update (#409)
This commit is contained in:
parent
fa20978d58
commit
3a334121f7
4 changed files with 349 additions and 8 deletions
|
@ -568,6 +568,198 @@ func (r *FilterRepo) Update(ctx context.Context, filter domain.Filter) (*domain.
|
|||
return &filter, nil
|
||||
}
|
||||
|
||||
func (r *FilterRepo) UpdatePartial(ctx context.Context, filter domain.FilterUpdate) error {
|
||||
var err error
|
||||
|
||||
q := r.db.squirrel.Update("filter")
|
||||
|
||||
if filter.Name != nil {
|
||||
q = q.Set("name", filter.Name)
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
q = q.Set("enabled", filter.Enabled)
|
||||
}
|
||||
if filter.MinSize != nil {
|
||||
q = q.Set("min_size", filter.MinSize)
|
||||
}
|
||||
if filter.MaxSize != nil {
|
||||
q = q.Set("max_size", filter.MaxSize)
|
||||
}
|
||||
if filter.Delay != nil {
|
||||
q = q.Set("delay", filter.Delay)
|
||||
}
|
||||
if filter.Priority != nil {
|
||||
q = q.Set("priority", filter.Priority)
|
||||
}
|
||||
if filter.MaxDownloads != nil {
|
||||
q = q.Set("max_downloads", filter.MaxDownloads)
|
||||
}
|
||||
if filter.MaxDownloadsUnit != nil {
|
||||
q = q.Set("max_downloads_unit", filter.MaxDownloadsUnit)
|
||||
}
|
||||
if filter.UseRegex != nil {
|
||||
q = q.Set("use_regex", filter.UseRegex)
|
||||
}
|
||||
if filter.MatchReleases != nil {
|
||||
q = q.Set("match_releases", filter.MatchReleases)
|
||||
}
|
||||
if filter.ExceptReleases != nil {
|
||||
q = q.Set("except_releases", filter.ExceptReleases)
|
||||
}
|
||||
if filter.MatchReleaseGroups != nil {
|
||||
q = q.Set("match_release_groups", filter.MatchReleaseGroups)
|
||||
}
|
||||
if filter.ExceptReleaseGroups != nil {
|
||||
q = q.Set("except_release_groups", filter.ExceptReleaseGroups)
|
||||
}
|
||||
if filter.Scene != nil {
|
||||
q = q.Set("scene", filter.Scene)
|
||||
}
|
||||
if filter.Freeleech != nil {
|
||||
q = q.Set("freeleech", filter.Freeleech)
|
||||
}
|
||||
if filter.FreeleechPercent != nil {
|
||||
q = q.Set("freeleech_percent", filter.FreeleechPercent)
|
||||
}
|
||||
if filter.Shows != nil {
|
||||
q = q.Set("shows", filter.Shows)
|
||||
}
|
||||
if filter.Seasons != nil {
|
||||
q = q.Set("seasons", filter.Seasons)
|
||||
}
|
||||
if filter.Episodes != nil {
|
||||
q = q.Set("episodes", filter.Episodes)
|
||||
}
|
||||
if filter.Resolutions != nil {
|
||||
q = q.Set("resolutions", pq.Array(filter.Resolutions))
|
||||
}
|
||||
if filter.Codecs != nil {
|
||||
q = q.Set("codecs", pq.Array(filter.Codecs))
|
||||
}
|
||||
if filter.Sources != nil {
|
||||
q = q.Set("sources", pq.Array(filter.Sources))
|
||||
}
|
||||
if filter.Containers != nil {
|
||||
q = q.Set("containers", pq.Array(filter.Containers))
|
||||
}
|
||||
if filter.MatchHDR != nil {
|
||||
q = q.Set("match_hdr", pq.Array(filter.MatchHDR))
|
||||
}
|
||||
if filter.ExceptHDR != nil {
|
||||
q = q.Set("except_hdr", pq.Array(filter.ExceptHDR))
|
||||
}
|
||||
if filter.MatchOther != nil {
|
||||
q = q.Set("match_other", pq.Array(filter.MatchOther))
|
||||
}
|
||||
if filter.ExceptOther != nil {
|
||||
q = q.Set("except_other", pq.Array(filter.ExceptOther))
|
||||
}
|
||||
if filter.Years != nil {
|
||||
q = q.Set("years", filter.Years)
|
||||
}
|
||||
if filter.MatchCategories != nil {
|
||||
q = q.Set("match_categories", filter.MatchCategories)
|
||||
}
|
||||
if filter.ExceptCategories != nil {
|
||||
q = q.Set("except_categories", filter.ExceptCategories)
|
||||
}
|
||||
if filter.MatchUploaders != nil {
|
||||
q = q.Set("match_uploaders", filter.MatchUploaders)
|
||||
}
|
||||
if filter.ExceptUploaders != nil {
|
||||
q = q.Set("except_uploaders", filter.ExceptUploaders)
|
||||
}
|
||||
if filter.Tags != nil {
|
||||
q = q.Set("tags", filter.Tags)
|
||||
}
|
||||
if filter.ExceptTags != nil {
|
||||
q = q.Set("except_tags", filter.ExceptTags)
|
||||
}
|
||||
if filter.Artists != nil {
|
||||
q = q.Set("artists", filter.Artists)
|
||||
}
|
||||
if filter.Albums != nil {
|
||||
q = q.Set("albums", filter.Albums)
|
||||
}
|
||||
if filter.MatchReleaseTypes != nil {
|
||||
q = q.Set("release_types_match", pq.Array(filter.MatchReleaseTypes))
|
||||
}
|
||||
if filter.Formats != nil {
|
||||
q = q.Set("formats", pq.Array(filter.Formats))
|
||||
}
|
||||
if filter.Quality != nil {
|
||||
q = q.Set("quality", pq.Array(filter.Quality))
|
||||
}
|
||||
if filter.Media != nil {
|
||||
q = q.Set("media", pq.Array(filter.Media))
|
||||
}
|
||||
if filter.LogScore != nil {
|
||||
q = q.Set("log_score", filter.LogScore)
|
||||
}
|
||||
if filter.Log != nil {
|
||||
q = q.Set("has_log", filter.Log)
|
||||
}
|
||||
if filter.Cue != nil {
|
||||
q = q.Set("has_cue", filter.Cue)
|
||||
}
|
||||
if filter.PerfectFlac != nil {
|
||||
q = q.Set("perfect_flac", filter.PerfectFlac)
|
||||
}
|
||||
if filter.Origins != nil {
|
||||
q = q.Set("origins", pq.Array(filter.Origins))
|
||||
}
|
||||
if filter.ExceptOrigins != nil {
|
||||
q = q.Set("except_origins", pq.Array(filter.ExceptOrigins))
|
||||
}
|
||||
if filter.ExternalScriptEnabled != nil {
|
||||
q = q.Set("external_script_enabled", filter.ExternalScriptEnabled)
|
||||
}
|
||||
if filter.ExternalScriptCmd != nil {
|
||||
q = q.Set("external_script_cmd", filter.ExternalScriptCmd)
|
||||
}
|
||||
if filter.ExternalScriptArgs != nil {
|
||||
q = q.Set("external_script_args", filter.ExternalScriptArgs)
|
||||
}
|
||||
if filter.ExternalScriptExpectStatus != nil {
|
||||
q = q.Set("external_script_expect_status", filter.ExternalScriptExpectStatus)
|
||||
}
|
||||
if filter.ExternalWebhookEnabled != nil {
|
||||
q = q.Set("external_webhook_enabled", filter.ExternalWebhookEnabled)
|
||||
}
|
||||
if filter.ExternalWebhookHost != nil {
|
||||
q = q.Set("external_webhook_host", filter.ExternalWebhookHost)
|
||||
}
|
||||
if filter.ExternalWebhookData != nil {
|
||||
q = q.Set("external_webhook_data", filter.ExternalWebhookData)
|
||||
}
|
||||
if filter.ExternalWebhookExpectStatus != nil {
|
||||
q = q.Set("external_webhook_expect_status", filter.ExternalWebhookExpectStatus)
|
||||
}
|
||||
|
||||
q = q.Where("id = ?", filter.ID)
|
||||
|
||||
query, args, err := q.ToSql()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error building query")
|
||||
}
|
||||
|
||||
result, err := r.db.handler.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error executing query")
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error executing query")
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return errors.New("no rows affected")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FilterRepo) ToggleEnabled(ctx context.Context, filterID int, enabled bool) error {
|
||||
var err error
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ type FilterRepo interface {
|
|||
ListFilters(ctx context.Context) ([]Filter, error)
|
||||
Store(ctx context.Context, filter Filter) (*Filter, error)
|
||||
Update(ctx context.Context, filter Filter) (*Filter, error)
|
||||
UpdatePartial(ctx context.Context, filter FilterUpdate) error
|
||||
ToggleEnabled(ctx context.Context, filterID int, enabled bool) error
|
||||
Delete(ctx context.Context, filterID int) error
|
||||
StoreIndexerConnection(ctx context.Context, filterID int, indexerID int) error
|
||||
|
@ -116,6 +117,70 @@ type Filter struct {
|
|||
Downloads *FilterDownloads `json:"-"`
|
||||
}
|
||||
|
||||
type FilterUpdate struct {
|
||||
ID int `json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
MinSize *string `json:"min_size,omitempty"`
|
||||
MaxSize *string `json:"max_size,omitempty"`
|
||||
Delay *int `json:"delay,omitempty"`
|
||||
Priority *int32 `json:"priority,omitempty"`
|
||||
MaxDownloads *int `json:"max_downloads,omitempty"`
|
||||
MaxDownloadsUnit *FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
|
||||
MatchReleases *string `json:"match_releases,omitempty"`
|
||||
ExceptReleases *string `json:"except_releases,omitempty"`
|
||||
UseRegex *bool `json:"use_regex,omitempty"`
|
||||
MatchReleaseGroups *string `json:"match_release_groups,omitempty"`
|
||||
ExceptReleaseGroups *string `json:"except_release_groups,omitempty"`
|
||||
Scene *bool `json:"scene,omitempty"`
|
||||
Origins *[]string `json:"origins,omitempty"`
|
||||
ExceptOrigins *[]string `json:"except_origins,omitempty"`
|
||||
Bonus *[]string `json:"bonus,omitempty"`
|
||||
Freeleech *bool `json:"freeleech,omitempty"`
|
||||
FreeleechPercent *string `json:"freeleech_percent,omitempty"`
|
||||
Shows *string `json:"shows,omitempty"`
|
||||
Seasons *string `json:"seasons,omitempty"`
|
||||
Episodes *string `json:"episodes,omitempty"`
|
||||
Resolutions *[]string `json:"resolutions,omitempty"` // SD, 480i, 480p, 576p, 720p, 810p, 1080i, 1080p.
|
||||
Codecs *[]string `json:"codecs,omitempty"` // XviD, DivX, x264, h.264 (or h264), mpeg2 (or mpeg-2), VC-1 (or VC1), WMV, Remux, h.264 Remux (or h264 Remux), VC-1 Remux (or VC1 Remux).
|
||||
Sources *[]string `json:"sources,omitempty"` // DSR, PDTV, HDTV, HR.PDTV, HR.HDTV, DVDRip, DVDScr, BDr, BD5, BD9, BDRip, BRRip, DVDR, MDVDR, HDDVD, HDDVDRip, BluRay, WEB-DL, TVRip, CAM, R5, TELESYNC, TS, TELECINE, TC. TELESYNC and TS are synonyms (you don't need both). Same for TELECINE and TC
|
||||
Containers *[]string `json:"containers,omitempty"`
|
||||
MatchHDR *[]string `json:"match_hdr,omitempty"`
|
||||
ExceptHDR *[]string `json:"except_hdr,omitempty"`
|
||||
MatchOther *[]string `json:"match_other,omitempty"`
|
||||
ExceptOther *[]string `json:"except_other,omitempty"`
|
||||
Years *string `json:"years,omitempty"`
|
||||
Artists *string `json:"artists,omitempty"`
|
||||
Albums *string `json:"albums,omitempty"`
|
||||
MatchReleaseTypes *[]string `json:"match_release_types,omitempty"` // Album,Single,EP
|
||||
ExceptReleaseTypes *string `json:"except_release_types,omitempty"`
|
||||
Formats *[]string `json:"formats,omitempty"` // MP3, FLAC, Ogg, AAC, AC3, DTS
|
||||
Quality *[]string `json:"quality,omitempty"` // 192, 320, APS (VBR), V2 (VBR), V1 (VBR), APX (VBR), V0 (VBR), q8.x (VBR), Lossless, 24bit Lossless, Other
|
||||
Media *[]string `json:"media,omitempty"` // CD, DVD, Vinyl, Soundboard, SACD, DAT, Cassette, WEB, Other
|
||||
PerfectFlac *bool `json:"perfect_flac,omitempty"`
|
||||
Cue *bool `json:"cue,omitempty"`
|
||||
Log *bool `json:"log,omitempty"`
|
||||
LogScore *int `json:"log_score,omitempty"`
|
||||
MatchCategories *string `json:"match_categories,omitempty"`
|
||||
ExceptCategories *string `json:"except_categories,omitempty"`
|
||||
MatchUploaders *string `json:"match_uploaders,omitempty"`
|
||||
ExceptUploaders *string `json:"except_uploaders,omitempty"`
|
||||
Tags *string `json:"tags,omitempty"`
|
||||
ExceptTags *string `json:"except_tags,omitempty"`
|
||||
TagsAny *string `json:"tags_any,omitempty"`
|
||||
ExceptTagsAny *string `json:"except_tags_any,omitempty"`
|
||||
ExternalScriptEnabled *bool `json:"external_script_enabled,omitempty"`
|
||||
ExternalScriptCmd *string `json:"external_script_cmd,omitempty"`
|
||||
ExternalScriptArgs *string `json:"external_script_args,omitempty"`
|
||||
ExternalScriptExpectStatus *int `json:"external_script_expect_status,omitempty"`
|
||||
ExternalWebhookEnabled *bool `json:"external_webhook_enabled,omitempty"`
|
||||
ExternalWebhookHost *string `json:"external_webhook_host,omitempty"`
|
||||
ExternalWebhookData *string `json:"external_webhook_data,omitempty"`
|
||||
ExternalWebhookExpectStatus *int `json:"external_webhook_expect_status,omitempty"`
|
||||
Actions []*Action `json:"actions,omitempty"`
|
||||
Indexers []Indexer `json:"indexers,omitempty"`
|
||||
}
|
||||
|
||||
func (f Filter) CheckFilter(r *Release) ([]string, bool) {
|
||||
// reset rejections first to clean previous checks
|
||||
r.resetRejections()
|
||||
|
|
|
@ -27,6 +27,7 @@ type Service interface {
|
|||
ListFilters(ctx context.Context) ([]domain.Filter, error)
|
||||
Store(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
|
||||
Update(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
|
||||
UpdatePartial(ctx context.Context, filter domain.FilterUpdate) error
|
||||
Duplicate(ctx context.Context, filterID int) (*domain.Filter, error)
|
||||
ToggleEnabled(ctx context.Context, filterID int, enabled bool) error
|
||||
Delete(ctx context.Context, filterID int) error
|
||||
|
@ -153,6 +154,33 @@ func (s *service) Update(ctx context.Context, filter domain.Filter) (*domain.Fil
|
|||
return f, nil
|
||||
}
|
||||
|
||||
func (s *service) UpdatePartial(ctx context.Context, filter domain.FilterUpdate) error {
|
||||
|
||||
// update
|
||||
if err := s.repo.UpdatePartial(ctx, filter); err != nil {
|
||||
s.log.Error().Err(err).Msgf("could not update partial filter: %v", filter.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
if filter.Indexers != nil {
|
||||
// take care of connected indexers
|
||||
if err := s.repo.StoreIndexerConnections(ctx, filter.ID, filter.Indexers); err != nil {
|
||||
s.log.Error().Err(err).Msgf("could not store filter indexer connections: %v", filter.Name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if filter.Actions != nil {
|
||||
// take care of filter actions
|
||||
if _, err := s.actionRepo.StoreFilterActions(ctx, filter.Actions, int64(filter.ID)); err != nil {
|
||||
s.log.Error().Err(err).Msgf("could not store filter actions: %v", filter.ID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Duplicate(ctx context.Context, filterID int) (*domain.Filter, error) {
|
||||
// find filter
|
||||
baseFilter, err := s.repo.FindByID(ctx, filterID)
|
||||
|
|
|
@ -17,6 +17,7 @@ type filterService interface {
|
|||
Store(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
|
||||
Delete(ctx context.Context, filterID int) error
|
||||
Update(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
|
||||
UpdatePartial(ctx context.Context, filter domain.FilterUpdate) error
|
||||
Duplicate(ctx context.Context, filterID int) (*domain.Filter, error)
|
||||
ToggleEnabled(ctx context.Context, filterID int, enabled bool) error
|
||||
}
|
||||
|
@ -39,6 +40,7 @@ func (h filterHandler) Routes(r chi.Router) {
|
|||
r.Get("/{filterID}/duplicate", h.duplicate)
|
||||
r.Post("/", h.store)
|
||||
r.Put("/{filterID}", h.update)
|
||||
r.Patch("/{filterID}", h.updatePartial)
|
||||
r.Put("/{filterID}/enabled", h.toggleEnabled)
|
||||
r.Delete("/{filterID}", h.delete)
|
||||
}
|
||||
|
@ -49,6 +51,8 @@ func (h filterHandler) getFilters(w http.ResponseWriter, r *http.Request) {
|
|||
trackers, err := h.service.ListFilters(ctx)
|
||||
if err != nil {
|
||||
//
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.encoder.StatusResponse(ctx, w, trackers, http.StatusOK)
|
||||
|
@ -60,7 +64,11 @@ func (h filterHandler) getByID(w http.ResponseWriter, r *http.Request) {
|
|||
filterID = chi.URLParam(r, "filterID")
|
||||
)
|
||||
|
||||
id, _ := strconv.Atoi(filterID)
|
||||
id, err := strconv.Atoi(filterID)
|
||||
if err != nil {
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter, err := h.service.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
|
@ -77,7 +85,11 @@ func (h filterHandler) duplicate(w http.ResponseWriter, r *http.Request) {
|
|||
filterID = chi.URLParam(r, "filterID")
|
||||
)
|
||||
|
||||
id, _ := strconv.Atoi(filterID)
|
||||
id, err := strconv.Atoi(filterID)
|
||||
if err != nil {
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter, err := h.service.Duplicate(ctx, id)
|
||||
if err != nil {
|
||||
|
@ -96,16 +108,18 @@ func (h filterHandler) store(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter, err := h.service.Store(ctx, data)
|
||||
if err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.encoder.StatusResponse(ctx, w, filter, http.StatusCreated)
|
||||
h.encoder.StatusCreatedData(w, filter)
|
||||
}
|
||||
|
||||
func (h filterHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -116,18 +130,50 @@ func (h filterHandler) update(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter, err := h.service.Update(ctx, data)
|
||||
if err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.encoder.StatusResponse(ctx, w, filter, http.StatusOK)
|
||||
}
|
||||
|
||||
func (h filterHandler) updatePartial(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
data domain.FilterUpdate
|
||||
filterID = chi.URLParam(r, "filterID")
|
||||
)
|
||||
|
||||
// set id from param and convert to int
|
||||
id, err := strconv.Atoi(filterID)
|
||||
if err != nil {
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
data.ID = id
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.UpdatePartial(ctx, data); err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.encoder.NoContent(w)
|
||||
}
|
||||
|
||||
func (h filterHandler) toggleEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
|
@ -137,20 +183,25 @@ func (h filterHandler) toggleEnabled(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
)
|
||||
|
||||
id, _ := strconv.Atoi(filterID)
|
||||
id, err := strconv.Atoi(filterID)
|
||||
if err != nil {
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.ToggleEnabled(ctx, id, data.Enabled)
|
||||
if err != nil {
|
||||
if err := h.service.ToggleEnabled(ctx, id, data.Enabled); err != nil {
|
||||
// encode error
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent)
|
||||
h.encoder.NoContent(w)
|
||||
}
|
||||
|
||||
func (h filterHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -159,10 +210,15 @@ func (h filterHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|||
filterID = chi.URLParam(r, "filterID")
|
||||
)
|
||||
|
||||
id, _ := strconv.Atoi(filterID)
|
||||
id, err := strconv.Atoi(filterID)
|
||||
if err != nil {
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(ctx, id); err != nil {
|
||||
// return err
|
||||
h.encoder.Error(w, err)
|
||||
}
|
||||
|
||||
h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue