Much work on validating object properties.
This commit is contained in:
parent
d26300f8c4
commit
6093a775f1
|
@ -60,14 +60,14 @@
|
||||||
:minor #{:info}
|
:minor #{:info}
|
||||||
:should #{:info :minor}
|
:should #{:info :minor}
|
||||||
:must #{:info :minor :should}
|
:must #{:info :minor :should}
|
||||||
:critical severity})
|
:critical #{:info :minor :should :must}})
|
||||||
|
|
||||||
(def ^:const validation-fault-context-uri
|
(def ^:const validation-fault-context-uri
|
||||||
"The URI of the context of a validation fault report object shall be this
|
"The URI of the context of a validation fault report object shall be this
|
||||||
literal string."
|
literal string."
|
||||||
"https://simon-brooke.github.io/dog-and-duck/codox/Validation_Faults.html")
|
"https://simon-brooke.github.io/dog-and-duck/codox/Validation_Faults.html")
|
||||||
|
|
||||||
(def ^:const verb-types
|
(def ^:const activity-types
|
||||||
"The set of types we will accept as verbs.
|
"The set of types we will accept as verbs.
|
||||||
|
|
||||||
There's an [explicit set of allowed verb types]
|
There's an [explicit set of allowed verb types]
|
||||||
|
@ -77,3 +77,9 @@
|
||||||
"Offer" "Question" "Reject" "Read" "Remove" "TentativeAccept"
|
"Offer" "Question" "Reject" "Read" "Remove" "TentativeAccept"
|
||||||
"TentativeReject" "Travel" "Undo" "Update" "View"})
|
"TentativeReject" "Travel" "Undo" "Update" "View"})
|
||||||
|
|
||||||
|
(def ^:const noun-types
|
||||||
|
"The set of types we will accept as nouns.
|
||||||
|
|
||||||
|
TODO: incomplete."
|
||||||
|
#{"Image" "Note" "Place"})
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,226 @@
|
||||||
(ns dog-and-duck.quack.picky.objects
|
(ns dog-and-duck.quack.picky.objects
|
||||||
(:require [clojure.data.json :as json]
|
(:require [clojure.data.json :as json]
|
||||||
|
[dog-and-duck.quack.picky.constants :refer [actor-types noun-types]]
|
||||||
[dog-and-duck.quack.picky.control-variables :refer [*reify-refs*]]
|
[dog-and-duck.quack.picky.control-variables :refer [*reify-refs*]]
|
||||||
[dog-and-duck.quack.picky.time :refer [date-time-property-or-fault]]
|
[dog-and-duck.quack.picky.time :refer [date-time-property-or-fault
|
||||||
|
xsd-date-time?
|
||||||
|
xsd-duration?]]
|
||||||
[dog-and-duck.quack.picky.utils :refer [concat-non-empty
|
[dog-and-duck.quack.picky.utils :refer [concat-non-empty
|
||||||
|
has-activity-type?
|
||||||
has-context?
|
has-context?
|
||||||
has-type?
|
has-type?
|
||||||
has-type-or-fault
|
has-type-or-fault
|
||||||
make-fault-object
|
make-fault-object
|
||||||
nil-if-empty]]
|
nil-if-empty
|
||||||
|
object-or-uri?]]
|
||||||
[taoensso.timbre :refer [warn]])
|
[taoensso.timbre :refer [warn]])
|
||||||
(:import [java.io FileNotFoundException]
|
(:import [java.io FileNotFoundException]
|
||||||
[java.net URI URISyntaxException]))
|
[java.net URI URISyntaxException]))
|
||||||
|
|
||||||
|
(def object-expected-properties
|
||||||
|
"Requirements of properties of object, cribbed from
|
||||||
|
https://www.w3.org/TR/activitystreams-vocabulary/#properties
|
||||||
|
|
||||||
|
Note the following sub-key value types:
|
||||||
|
|
||||||
|
* `:collection` opposite of `:functional`: if true, value should be a
|
||||||
|
collection (in the Clojure sense), not a single object;
|
||||||
|
* `:functional` if true, value should be a single object; if false, may
|
||||||
|
be a single object or a sequence of objects, but each must pass
|
||||||
|
validation checks;
|
||||||
|
* `:if-invalid` a sequence of two keywords, first indicating severity,
|
||||||
|
second being a message key;
|
||||||
|
* `:if-missing` a sequence of two keywords, first indicating severity,
|
||||||
|
second being a message key;
|
||||||
|
* `:required` a boolean, or a function of one argument returning a
|
||||||
|
boolean, in which case the function will be applied to the object
|
||||||
|
having the property;
|
||||||
|
* `:verifier` a function of one argument returning a boolean, which will
|
||||||
|
be applied to the value or values of the identified property."
|
||||||
|
{:accuracy {:functional false
|
||||||
|
:if-invalid [:must :invalid-number]
|
||||||
|
:verifier number?}
|
||||||
|
:actor {:functional false
|
||||||
|
:if-invalid [:must :invalid-actor]
|
||||||
|
:if-missing [:must :no-actor]
|
||||||
|
:required has-activity-type?
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:altitude {:functional false
|
||||||
|
:if-invalid [:must :invalid-number]
|
||||||
|
:verifier number?}
|
||||||
|
:anyOf {:collection true
|
||||||
|
:functional false
|
||||||
|
;; a Question should have a `:oneOf` ot `:anyOf`, but at this layer
|
||||||
|
;; that's hard to check.
|
||||||
|
:if-invalid [:must :invalid-option]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:attachment {:functional false
|
||||||
|
:if-invalid [:must :invalid-attachment]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:attributedTo {:functional false
|
||||||
|
:if-invalid [:must :invalid-attribution]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:audience {:functional false
|
||||||
|
:if-invalid [:must :invalid-audience]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:bcc {:functional false
|
||||||
|
:if-invalid [:must :invalid-audience] ;; do we need a separate message for bcc, cc, etc?
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:cc {:functional false
|
||||||
|
:if-invalid [:must :invalid-audience] ;; do we need a separate message for bcc, cc, etc?
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:closed {:functional false
|
||||||
|
:if-invalid [:must :invalid-closed]
|
||||||
|
:verifier (fn [pv] (or (object-or-uri? pv)
|
||||||
|
(xsd-date-time? pv)
|
||||||
|
(#{"true" "false"} pv)))}
|
||||||
|
:content {:functional false
|
||||||
|
:if-invalid [:must :invalid-content]
|
||||||
|
:verifier string?}
|
||||||
|
:context {:functional false
|
||||||
|
:if-invalid [:must :invalid-context]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:current {:functional true
|
||||||
|
:if-missing [:minor :paged-collection-no-current]
|
||||||
|
:if-invalid [:must :paged-collection-invalid-current]
|
||||||
|
:required (fn [x] ;; if an object is a collection which has pages,
|
||||||
|
;; it ought to have a `:current` page. But
|
||||||
|
;; 1. it isn't required to, and
|
||||||
|
;; 2. there's no certain way of telling that it
|
||||||
|
;; does have pages - although if it has a
|
||||||
|
;; `:first`, then it is.
|
||||||
|
(and
|
||||||
|
(or (has-type? x "Collection")
|
||||||
|
(has-type? x "OrderedCollection"))
|
||||||
|
(:first x)))
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv #{"CollectionPage"
|
||||||
|
"OrderedCollectionPage"}))}
|
||||||
|
:duration {:functional false
|
||||||
|
:if-invalid [:must :invalid-duration]
|
||||||
|
:verifier xsd-duration?}
|
||||||
|
:first {:functional true
|
||||||
|
:if-missing [:minor :paged-collection-no-first]
|
||||||
|
:if-invalid [:must :paged-collection-invalid-first]
|
||||||
|
:required (fn [x] ;; if an object is a collection which has pages,
|
||||||
|
;; it ought to have a `:first` page. But
|
||||||
|
;; 1. it isn't required to, and
|
||||||
|
;; 2. there's no certain way of telling that it
|
||||||
|
;; does have pages - although if it has a
|
||||||
|
;; `:last`, then it is.
|
||||||
|
(and
|
||||||
|
(or (has-type? x "Collection")
|
||||||
|
(has-type? x "OrderedCollection"))
|
||||||
|
(:last x)))
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv #{"CollectionPage"
|
||||||
|
"OrderedCollectionPage"}))}
|
||||||
|
:generator {:functional false
|
||||||
|
:if-invalid [:must :invalid-generator]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:icon {:functional false
|
||||||
|
:if-invalid [:must :invalid-icon]
|
||||||
|
;; an icon is also expected to have a 1:1 aspect ratio, but that's
|
||||||
|
;; too much detail at this level of verification
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv "Image"))}
|
||||||
|
:id {:functional true
|
||||||
|
:if-missing [:minor :no-id-transient]
|
||||||
|
:if-invalid [:must :invalid-id]
|
||||||
|
:verifier (fn [pv] (try (uri? (URI. pv))
|
||||||
|
(catch URISyntaxException _ false)))}
|
||||||
|
:image {:functional false
|
||||||
|
:if-invalid [:must :invalid-image]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv "Image"))}
|
||||||
|
:inReplyTo {:functional false
|
||||||
|
:if-invalid [:must :invalid-in-reply-to]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv noun-types))}
|
||||||
|
:instrument {:functional false
|
||||||
|
:if-invalid [:must :invalid-instrument]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:items {:collection true
|
||||||
|
:functional false
|
||||||
|
:if-invalid [:must :invalid-items]
|
||||||
|
:if-missing [:must :no-items-or-pages]
|
||||||
|
:required (fn [x] (or (has-type? x #{"CollectionPage"
|
||||||
|
"OrderedCollectionPage"})
|
||||||
|
(and (has-type? x #{"Collection"
|
||||||
|
"OrderedCollection"})
|
||||||
|
;; if it's a collection and has pages,
|
||||||
|
;; it doesn't need items.
|
||||||
|
(not (:current x))
|
||||||
|
(not (:first x))
|
||||||
|
(not (:last x)))))
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:last {:functional true
|
||||||
|
:if-missing [:minor :paged-collection-no-last]
|
||||||
|
:if-invalid [:must :paged-collection-invalid-last]
|
||||||
|
:required (fn [x] (if (try (uri? (URI. x))
|
||||||
|
(catch URISyntaxException _ false))
|
||||||
|
true
|
||||||
|
;; if an object is a collection which has pages,
|
||||||
|
;; it ought to have a `:last` page. But
|
||||||
|
;; 1. it isn't required to, and
|
||||||
|
;; 2. there's no certain way of telling that it
|
||||||
|
;; does have pages - although if it has a
|
||||||
|
;; `:first`, then it is.
|
||||||
|
(and
|
||||||
|
(has-type? x #{"Collection"
|
||||||
|
"OrderedCollection"})
|
||||||
|
(:first x))))
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv #{"CollectionPage"
|
||||||
|
"OrderedCollectionPage"}))}
|
||||||
|
:location {:functional false
|
||||||
|
:if-invalid [:must :invalid-location]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv #{"Place"}))}
|
||||||
|
:name {:functional false
|
||||||
|
:if-invalid [:must :invalid-name]
|
||||||
|
:verifier string?}
|
||||||
|
:oneOf {:collection true
|
||||||
|
:functional false
|
||||||
|
;; a Question should have a `:oneOf` ot `:anyOf`, but at this layer
|
||||||
|
;; that's hard to check.
|
||||||
|
:if-invalid [:must :invalid-option]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:origin {:functional false
|
||||||
|
:if-invalid :invalid-origin
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:next {:functional true
|
||||||
|
:if-invalid [:must :invalid-next-page]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv #{"CollectionPage"
|
||||||
|
"OrderedCollectionPage"}))}
|
||||||
|
:object {:functional false
|
||||||
|
:if-invalid [:must :invalid-direct-object]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:prev {:functional true
|
||||||
|
:if-invalid [:must :invalid-prior-page]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv #{"CollectionPage"
|
||||||
|
"OrderedCollectionPage"}))}
|
||||||
|
:preview {:functional false
|
||||||
|
:if-invalid [:must :invalid-preview]
|
||||||
|
;; probably likely to be an Image or Video, but that isn't stated.
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:replies {:functional true
|
||||||
|
:if-invalid [:must :invalid-replies]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv #{"Collection"
|
||||||
|
"OrderedCollection"}))}
|
||||||
|
:result {:functional false
|
||||||
|
:if-invalid [:must :invalid-result]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:tag {:functional false
|
||||||
|
:if-invalid [:must :invalid-tag]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:target {:functional false
|
||||||
|
:if-invalid [:must :invalid-target]
|
||||||
|
:verifier object-or-uri?}
|
||||||
|
:to {:functional false
|
||||||
|
:if-invalid [:must :invalid-to]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv actor-types))}
|
||||||
|
:type {:functional false
|
||||||
|
:if-missing [:minor :no-type]
|
||||||
|
:if-invalid [:must :invalid-type]
|
||||||
|
;; strictly, it's an 'anyURI', but realistically these are not checkable.
|
||||||
|
:verifier string?}
|
||||||
|
:url {:functional false
|
||||||
|
:if-invalid [:must :invalid-url-property]
|
||||||
|
:verifier (fn [pv] (object-or-uri? pv "Link"))}})
|
||||||
|
|
||||||
(defn object-faults
|
(defn object-faults
|
||||||
"Return a list of faults found in object `x`, or `nil` if none are.
|
"Return a list of faults found in object `x`, or `nil` if none are.
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
(ns dog-and-duck.quack.picky.time
|
(ns dog-and-duck.quack.picky.time
|
||||||
"Time, gentleman, please! Recognising and validating date time values."
|
"Time, gentleman, please! Recognising and validating date time values."
|
||||||
(:require [dog-and-duck.quack.picky.utils :refer [cond-make-fault-object
|
(:require [dog-and-duck.quack.picky.utils :refer [cond-make-fault-object
|
||||||
make-fault-object]]
|
make-fault-object
|
||||||
|
truthy?]]
|
||||||
[scot.weft.i18n.core :refer [get-message]]
|
[scot.weft.i18n.core :refer [get-message]]
|
||||||
[taoensso.timbre :refer [warn]])
|
[taoensso.timbre :refer [warn]])
|
||||||
(:import [java.time LocalDateTime]
|
(:import [java.time LocalDateTime]
|
||||||
[java.time.format DateTimeFormatter DateTimeParseException]))
|
[java.time.format DateTimeFormatter DateTimeParseException]
|
||||||
|
[javax.xml.datatype DatatypeFactory]))
|
||||||
|
|
||||||
;;; Copyright (C) Simon Brooke, 2023
|
;;; Copyright (C) Simon Brooke, 2023
|
||||||
|
|
||||||
|
@ -33,6 +35,17 @@
|
||||||
(warn (get-message :bad-date-time) ":" value)
|
(warn (get-message :bad-date-time) ":" value)
|
||||||
false)))
|
false)))
|
||||||
|
|
||||||
|
(defn xsd-duration?
|
||||||
|
"Return `true` if `value` matches the pattern for an
|
||||||
|
[xsd:duration](https://www.w3.org/TR/xmlschema11-2/#duration), else `false`"
|
||||||
|
[value]
|
||||||
|
(truthy?
|
||||||
|
(and (string? value)
|
||||||
|
(try (DatatypeFactory/newDuration value)
|
||||||
|
(catch IllegalArgumentException _
|
||||||
|
(warn (get-message :bad-duration) ":" value)
|
||||||
|
false)))))
|
||||||
|
|
||||||
(defn date-time-property-or-fault
|
(defn date-time-property-or-fault
|
||||||
"If the value of this `property` of object `x` is a valid xsd:dateTime
|
"If the value of this `property` of object `x` is a valid xsd:dateTime
|
||||||
value, return a fault object with this `token` and `severity`.
|
value, return a fault object with this `token` and `severity`.
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
actor-types
|
actor-types
|
||||||
context-key severity-filters
|
context-key severity-filters
|
||||||
validation-fault-context-uri
|
validation-fault-context-uri
|
||||||
verb-types]]
|
activity-types]]
|
||||||
[dog-and-duck.utils.process :refer [get-hostname get-pid]]
|
[dog-and-duck.utils.process :refer [get-hostname get-pid]]
|
||||||
[scot.weft.i18n.core :refer [get-message]]
|
[scot.weft.i18n.core :refer [get-message]]
|
||||||
[taoensso.timbre :as log :refer [warn]])
|
[taoensso.timbre :as log :refer [warn]])
|
||||||
|
@ -41,26 +41,39 @@
|
||||||
(if x true false))
|
(if x true false))
|
||||||
|
|
||||||
(defn has-type?
|
(defn has-type?
|
||||||
"Return `true` if object `x` has type `type`, else `false`.
|
"Return `true` if object `x` has a type in `acceptable`, else `false`.
|
||||||
|
|
||||||
The values of `type` fields of ActivityStreams objects may be lists; they
|
The values of `:type` fields of ActivityStreams objects may be lists; they
|
||||||
are considered to have a type if the type token is a member of the list."
|
are considered to have a type if a member of the list is in `acceptable`.
|
||||||
[x type]
|
|
||||||
(assert (map? x) (string? type))
|
`acceptable` may be passed as a string, in which case there is only one
|
||||||
|
acceptable value, or as a set of strings, in which case any member of the
|
||||||
|
set is acceptable."
|
||||||
|
[x acceptable]
|
||||||
|
(assert (map? x) (or (string? acceptable) (set? acceptable)))
|
||||||
(let [tv (:type x)]
|
(let [tv (:type x)]
|
||||||
(cond
|
(truthy?
|
||||||
(coll? tv) (truthy? (not-empty (filter #(= % type) tv)))
|
(cond
|
||||||
:else (= tv type))))
|
(and (string? acceptable) (coll? tv)) (not-empty (filter #(= % acceptable) tv))
|
||||||
|
(and (set? acceptable) (coll? tv)) (not-empty (filter #(acceptable %) tv))
|
||||||
|
(string? acceptable) (= tv acceptable)
|
||||||
|
(set? acceptable) (acceptable tv)))))
|
||||||
|
|
||||||
(defn object-or-uri?
|
(defn object-or-uri?
|
||||||
"Very basic check that `x` is either an object or a URI."
|
"Very basic check that `x` is either an object or a URI."
|
||||||
[x]
|
([x]
|
||||||
(try
|
(try
|
||||||
(cond (string? x) (uri? (URI. x))
|
(cond (string? x) (uri? (URI. x))
|
||||||
(map? x) (if (and (:type x) (:id x)) true false)
|
(map? x) (if (and (:type x) (:id x)) true false)
|
||||||
:else false)
|
:else false)
|
||||||
(catch URISyntaxException _ false)
|
(catch URISyntaxException _ false)
|
||||||
(catch NullPointerException _ false)))
|
(catch NullPointerException _ false)))
|
||||||
|
([x type]
|
||||||
|
(if (object-or-uri? x)
|
||||||
|
(if (map? x)
|
||||||
|
(has-type? x type)
|
||||||
|
true)
|
||||||
|
false)))
|
||||||
|
|
||||||
(defmacro link-or-uri?
|
(defmacro link-or-uri?
|
||||||
"Very basic check that `x` is either a link object or a URI."
|
"Very basic check that `x` is either a link object or a URI."
|
||||||
|
@ -68,11 +81,11 @@
|
||||||
`(if (object-or-uri? ~x) (has-type? ~x "Link") false))
|
`(if (object-or-uri? ~x) (has-type? ~x "Link") false))
|
||||||
|
|
||||||
|
|
||||||
(defn verb-type?
|
(defn activity-type?
|
||||||
"`true` if `x`, a string, represents a recognised ActivityStreams activity
|
"`true` if `x`, a string, represents a recognised ActivityStreams activity
|
||||||
type."
|
type."
|
||||||
[^String x]
|
[^String x]
|
||||||
(if (verb-types x) true false))
|
(if (activity-types x) true false))
|
||||||
|
|
||||||
(defn has-activity-type?
|
(defn has-activity-type?
|
||||||
"Return `true` if the object `x` has a type which is an activity type, else
|
"Return `true` if the object `x` has a type which is an activity type, else
|
||||||
|
@ -80,8 +93,8 @@
|
||||||
[x]
|
[x]
|
||||||
(let [tv (:type x)]
|
(let [tv (:type x)]
|
||||||
(cond
|
(cond
|
||||||
(coll? tv) (truthy? (not-empty (filter verb-type? tv)))
|
(coll? tv) (truthy? (not-empty (filter activity-type? tv)))
|
||||||
:else (verb-type? tv))))
|
:else (activity-type? tv))))
|
||||||
|
|
||||||
(defn has-actor-type?
|
(defn has-actor-type?
|
||||||
"Return `true` if the object `x` has a type which is an actor type, else
|
"Return `true` if the object `x` has a type which is an actor type, else
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
[dog-and-duck.quack.picky.constants :refer [activitystreams-context-uri
|
[dog-and-duck.quack.picky.constants :refer [activitystreams-context-uri
|
||||||
context-key]]
|
context-key]]
|
||||||
[dog-and-duck.quack.picky.utils :refer [actor-type? context?
|
[dog-and-duck.quack.picky.utils :refer [actor-type? context?
|
||||||
verb-type?]]
|
activity-type?]]
|
||||||
[dog-and-duck.quack.quack :refer [actor?
|
[dog-and-duck.quack.quack :refer [actor?
|
||||||
object? ordered-collection-page?
|
object? ordered-collection-page?
|
||||||
persistent-object?]]
|
persistent-object?]]
|
||||||
|
@ -85,16 +85,16 @@
|
||||||
(deftest verb-type-test
|
(deftest verb-type-test
|
||||||
(testing "identification of verb types"
|
(testing "identification of verb types"
|
||||||
(let [expected false
|
(let [expected false
|
||||||
actual (verb-type? nil)]
|
actual (activity-type? nil)]
|
||||||
(is (= actual expected) "nil is not a verb"))
|
(is (= actual expected) "nil is not a verb"))
|
||||||
(let [expected false
|
(let [expected false
|
||||||
actual (verb-type? "Quack")]
|
actual (activity-type? "Quack")]
|
||||||
(is (= actual expected) "Quack is not a verb"))
|
(is (= actual expected) "Quack is not a verb"))
|
||||||
(let [expected true
|
(let [expected true
|
||||||
actual (verb-type? "Create")]
|
actual (activity-type? "Create")]
|
||||||
(is (= actual expected) "Create is a verb"))
|
(is (= actual expected) "Create is a verb"))
|
||||||
(let [expected true
|
(let [expected true
|
||||||
actual (verb-type? "Reject")]
|
actual (activity-type? "Reject")]
|
||||||
(is (= actual expected) "Reject is a verb"))))
|
(is (= actual expected) "Reject is a verb"))))
|
||||||
|
|
||||||
(deftest context-test
|
(deftest context-test
|
||||||
|
|
Loading…
Reference in a new issue