Merge branch 'master' into module-typing2
Conflicts: specification/modules/typing_notifications.rst
This commit is contained in:
commit
1520f3647f
15 changed files with 189 additions and 24 deletions
|
@ -267,6 +267,12 @@ paths:
|
||||||
type: string
|
type: string
|
||||||
description: "The user's membership state in this room."
|
description: "The user's membership state in this room."
|
||||||
enum: ["invite", "join", "leave", "ban"]
|
enum: ["invite", "join", "leave", "ban"]
|
||||||
|
invite:
|
||||||
|
type: object
|
||||||
|
title: "InviteEvent"
|
||||||
|
description: "The invite event if ``membership`` is ``invite``"
|
||||||
|
allOf:
|
||||||
|
- "$ref": "v1-event-schema/m.room.member"
|
||||||
messages:
|
messages:
|
||||||
type: object
|
type: object
|
||||||
title: PaginationChunk
|
title: PaginationChunk
|
||||||
|
|
|
@ -32,6 +32,26 @@
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["m.room.member"]
|
"enum": ["m.room.member"]
|
||||||
|
},
|
||||||
|
"invite_room_state": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "A subset of the state of the room at the time of the invite, if ``membership`` is ``invite``",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "StateEvent",
|
||||||
|
"description": "A stripped down state event, with only the ``type``, ``state_key`` and ``content`` keys.",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PullRequest struct {
|
type PullRequest struct {
|
||||||
|
@ -58,16 +59,24 @@ func (u *User) IsTrusted() bool {
|
||||||
return allowedMembers[u.Login]
|
return allowedMembers[u.Login]
|
||||||
}
|
}
|
||||||
|
|
||||||
const pullsPrefix = "https://api.github.com/repos/matrix-org/matrix-doc/pulls"
|
const (
|
||||||
|
pullsPrefix = "https://api.github.com/repos/matrix-org/matrix-doc/pulls"
|
||||||
|
matrixDocCloneURL = "https://github.com/matrix-org/matrix-doc.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func gitClone(url string, shared bool) (string, error) {
|
||||||
|
directory := path.Join("/tmp/matrix-doc", strconv.FormatInt(rand.Int63(), 10))
|
||||||
|
cmd := exec.Command("git", "clone", url, directory)
|
||||||
|
if shared {
|
||||||
|
cmd.Args = append(cmd.Args, "--shared")
|
||||||
|
}
|
||||||
|
|
||||||
func gitClone(url string) (string, error) {
|
|
||||||
dst := path.Join("/tmp/matrix-doc", strconv.FormatInt(rand.Int63(), 10))
|
|
||||||
cmd := exec.Command("git", "clone", url, dst)
|
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("error cloning repo: %v", err)
|
return "", fmt.Errorf("error cloning repo: %v", err)
|
||||||
}
|
}
|
||||||
return dst, nil
|
return directory, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitCheckout(path, sha string) error {
|
func gitCheckout(path, sha string) error {
|
||||||
|
@ -80,6 +89,16 @@ func gitCheckout(path, sha string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func gitFetch(path string) error {
|
||||||
|
cmd := exec.Command("git", "fetch")
|
||||||
|
cmd.Dir = path
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error fetching repo: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func lookupPullRequest(url url.URL, pathPrefix string) (*PullRequest, error) {
|
func lookupPullRequest(url url.URL, pathPrefix string) (*PullRequest, error) {
|
||||||
if !strings.HasPrefix(url.Path, pathPrefix+"/") {
|
if !strings.HasPrefix(url.Path, pathPrefix+"/") {
|
||||||
return nil, fmt.Errorf("invalid path passed: %s expect %s/123", url.Path, pathPrefix)
|
return nil, fmt.Errorf("invalid path passed: %s expect %s/123", url.Path, pathPrefix)
|
||||||
|
@ -119,10 +138,18 @@ func writeError(w http.ResponseWriter, code int, err error) {
|
||||||
io.WriteString(w, fmt.Sprintf("%v\n", err))
|
io.WriteString(w, fmt.Sprintf("%v\n", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
matrixDocCloneURL string
|
||||||
|
}
|
||||||
|
|
||||||
// generateAt generates spec from repo at sha.
|
// generateAt generates spec from repo at sha.
|
||||||
// Returns the path where the generation was done.
|
// Returns the path where the generation was done.
|
||||||
func generateAt(repo, sha string) (dst string, err error) {
|
func (s *server) generateAt(sha string) (dst string, err error) {
|
||||||
dst, err = gitClone(repo)
|
err = gitFetch(s.matrixDocCloneURL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst, err = gitClone(s.matrixDocCloneURL, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -135,12 +162,10 @@ func generateAt(repo, sha string) (dst string, err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveSpec(w http.ResponseWriter, req *http.Request) {
|
func (s *server) serveSpec(w http.ResponseWriter, req *http.Request) {
|
||||||
var cloneURL string
|
|
||||||
var sha string
|
var sha string
|
||||||
|
|
||||||
if strings.ToLower(req.URL.Path) == "/spec/head" {
|
if strings.ToLower(req.URL.Path) == "/spec/head" {
|
||||||
cloneURL = "https://github.com/matrix-org/matrix-doc.git"
|
|
||||||
sha = "HEAD"
|
sha = "HEAD"
|
||||||
} else {
|
} else {
|
||||||
pr, err := lookupPullRequest(*req.URL, "/spec")
|
pr, err := lookupPullRequest(*req.URL, "/spec")
|
||||||
|
@ -155,11 +180,10 @@ func serveSpec(w http.ResponseWriter, req *http.Request) {
|
||||||
writeError(w, 403, err)
|
writeError(w, 403, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cloneURL = pr.Head.Repo.CloneURL
|
|
||||||
sha = pr.Head.SHA
|
sha = pr.Head.SHA
|
||||||
}
|
}
|
||||||
|
|
||||||
dst, err := generateAt(cloneURL, sha)
|
dst, err := s.generateAt(sha)
|
||||||
defer os.RemoveAll(dst)
|
defer os.RemoveAll(dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, 500, err)
|
writeError(w, 500, err)
|
||||||
|
@ -181,7 +205,7 @@ func checkAuth(pr *PullRequest) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveRSTDiff(w http.ResponseWriter, req *http.Request) {
|
func (s *server) serveRSTDiff(w http.ResponseWriter, req *http.Request) {
|
||||||
pr, err := lookupPullRequest(*req.URL, "/diff/rst")
|
pr, err := lookupPullRequest(*req.URL, "/diff/rst")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, 400, err)
|
writeError(w, 400, err)
|
||||||
|
@ -195,14 +219,14 @@ func serveRSTDiff(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
base, err := generateAt(pr.Base.Repo.CloneURL, pr.Base.SHA)
|
base, err := s.generateAt(pr.Base.SHA)
|
||||||
defer os.RemoveAll(base)
|
defer os.RemoveAll(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, 500, err)
|
writeError(w, 500, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
head, err := generateAt(pr.Head.Repo.CloneURL, pr.Head.SHA)
|
head, err := s.generateAt(pr.Head.SHA)
|
||||||
defer os.RemoveAll(head)
|
defer os.RemoveAll(head)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, 500, err)
|
writeError(w, 500, err)
|
||||||
|
@ -219,7 +243,7 @@ func serveRSTDiff(w http.ResponseWriter, req *http.Request) {
|
||||||
w.Write(diff.Bytes())
|
w.Write(diff.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveHTMLDiff(w http.ResponseWriter, req *http.Request) {
|
func (s *server) serveHTMLDiff(w http.ResponseWriter, req *http.Request) {
|
||||||
pr, err := lookupPullRequest(*req.URL, "/diff/html")
|
pr, err := lookupPullRequest(*req.URL, "/diff/html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, 400, err)
|
writeError(w, 400, err)
|
||||||
|
@ -233,14 +257,14 @@ func serveHTMLDiff(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
base, err := generateAt(pr.Base.Repo.CloneURL, pr.Base.SHA)
|
base, err := s.generateAt(pr.Base.SHA)
|
||||||
defer os.RemoveAll(base)
|
defer os.RemoveAll(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, 500, err)
|
writeError(w, 500, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
head, err := generateAt(pr.Head.Repo.CloneURL, pr.Head.SHA)
|
head, err := s.generateAt(pr.Head.SHA)
|
||||||
defer os.RemoveAll(head)
|
defer os.RemoveAll(head)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, 500, err)
|
writeError(w, 500, err)
|
||||||
|
@ -327,9 +351,15 @@ func main() {
|
||||||
"Kegsay": true,
|
"Kegsay": true,
|
||||||
"NegativeMjark": true,
|
"NegativeMjark": true,
|
||||||
}
|
}
|
||||||
http.HandleFunc("/spec/", serveSpec)
|
rand.Seed(time.Now().Unix())
|
||||||
http.HandleFunc("/diff/rst/", serveRSTDiff)
|
masterCloneDir, err := gitClone(matrixDocCloneURL, false)
|
||||||
http.HandleFunc("/diff/html/", serveHTMLDiff)
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
s := server{masterCloneDir}
|
||||||
|
http.HandleFunc("/spec/", s.serveSpec)
|
||||||
|
http.HandleFunc("/diff/rst/", s.serveRSTDiff)
|
||||||
|
http.HandleFunc("/diff/html/", s.serveHTMLDiff)
|
||||||
http.HandleFunc("/healthz", serveText("ok"))
|
http.HandleFunc("/healthz", serveText("ok"))
|
||||||
http.HandleFunc("/", listPulls)
|
http.HandleFunc("/", listPulls)
|
||||||
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
|
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
|
||||||
|
|
|
@ -1,3 +1,93 @@
|
||||||
Feature Profiles
|
Feature Profiles
|
||||||
================
|
================
|
||||||
|
|
||||||
|
.. sect:feature-profiles:
|
||||||
|
|
||||||
|
Matrix supports many different kinds of clients: from embedded IoT devices to
|
||||||
|
desktop clients. Not all clients can provide the same feature sets as other
|
||||||
|
clients e.g. due to lack of physical hardware such as not having a screen.
|
||||||
|
Clients can fall into one of several profiles and each profile contains a set
|
||||||
|
of features that the client MUST support. This section details a set of
|
||||||
|
"feature profiles". Clients are expected to implement a profile in its entirety
|
||||||
|
in order for it to be classified as that profile.
|
||||||
|
|
||||||
|
Summary
|
||||||
|
-------
|
||||||
|
|
||||||
|
===================================== ========== ========== ========== ========== ==========
|
||||||
|
Module / Profile Web Mobile Desktop CLI Embedded
|
||||||
|
===================================== ========== ========== ========== ========== ==========
|
||||||
|
`Instant Messaging`_ Required Required Required Required Optional
|
||||||
|
`Presence`_ Required Required Required Required Optional
|
||||||
|
`Push Notifications`_ Optional Required Optional Optional Optional
|
||||||
|
`Receipts`_ Required Required Required Required Optional
|
||||||
|
`Typing Notifications`_ Required Required Required Required Optional
|
||||||
|
`VoIP`_ Required Required Required Optional Optional
|
||||||
|
`Content Repository`_ Required Required Required Optional Optional
|
||||||
|
`Managing History Visibility`_ Required Required Required Required Optional
|
||||||
|
`End-to-End Encryption`_ Optional Optional Optional Optional Optional
|
||||||
|
===================================== ========== ========== ========== ========== ==========
|
||||||
|
|
||||||
|
*Please see each module for more details on what clients need to implement.*
|
||||||
|
|
||||||
|
.. _End-to-End Encryption: `module:e2e`_
|
||||||
|
.. _Instant Messaging: `module:im`_
|
||||||
|
.. _Presence: `module:presence`_
|
||||||
|
.. _Push Notifications: `module:push`_
|
||||||
|
.. _Receipts: `module:receipts`_
|
||||||
|
.. _Typing Notifications: `module:typing`_
|
||||||
|
.. _VoIP: `module:voip`_
|
||||||
|
.. _Content Repository: `module:content`_
|
||||||
|
.. _Managing History Visibility: `module:history-visibility`_
|
||||||
|
|
||||||
|
Clients
|
||||||
|
-------
|
||||||
|
|
||||||
|
Stand-alone web (``Web``)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This is a web page which heavily uses Matrix for communication. Single-page web
|
||||||
|
apps would be classified as a stand-alone web client, as would multi-page web
|
||||||
|
apps which use Matrix on nearly every page.
|
||||||
|
|
||||||
|
Mobile (``Mobile``)
|
||||||
|
~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This is a Matrix client specifically designed for consumption on mobile devices.
|
||||||
|
This is typically a mobile app but need not be so provided the feature set can
|
||||||
|
be reached (e.g. if a mobile site could display push notifications it could be
|
||||||
|
classified as a mobile client).
|
||||||
|
|
||||||
|
Desktop (``Desktop``)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This is a native GUI application which can run in its own environment outside a
|
||||||
|
browser.
|
||||||
|
|
||||||
|
Command Line Interface (``CLI``)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This is a client which is used via a text-based terminal.
|
||||||
|
|
||||||
|
Embedded (``Embedded``)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
This is a client which is embedded into another application or an embedded
|
||||||
|
device.
|
||||||
|
|
||||||
|
Application
|
||||||
|
+++++++++++
|
||||||
|
|
||||||
|
This is a Matrix client which is embedded in another website, e.g. using
|
||||||
|
iframes. These embedded clients are typically for a single purpose
|
||||||
|
related to the website in question, and are not intended to be fully-fledged
|
||||||
|
communication apps.
|
||||||
|
|
||||||
|
Device
|
||||||
|
++++++
|
||||||
|
|
||||||
|
This is a client which is typically running on an embedded device such as a
|
||||||
|
kettle, fridge or car. These clients tend to perform a few operations and run
|
||||||
|
in a resource constrained environment. Like embedded applications, they are
|
||||||
|
not intended to be fully-fledged communication systems.
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
Content repository
|
Content repository
|
||||||
==================
|
==================
|
||||||
|
|
||||||
|
.. _module:content:
|
||||||
|
|
||||||
HTTP API
|
HTTP API
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
End-to-End Encryption
|
End-to-End Encryption
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
|
.. _module:e2e:
|
||||||
|
|
||||||
.. TODO-doc
|
.. TODO-doc
|
||||||
- Why is this needed.
|
- Why is this needed.
|
||||||
- Overview of process
|
- Overview of process
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
Room History Visibility
|
Room History Visibility
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
|
.. _module:history-visibility:
|
||||||
|
|
||||||
Whether a member of a room can see the events that happened in a room from
|
Whether a member of a room can see the events that happened in a room from
|
||||||
before they joined the room is controlled by the ``history_visibility`` key
|
before they joined the room is controlled by the ``history_visibility`` key
|
||||||
of the ``m.room.history_visibility`` state event. The valid values for
|
of the ``m.room.history_visibility`` state event. The valid values for
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
Instant Messaging
|
Instant Messaging
|
||||||
=================
|
=================
|
||||||
|
|
||||||
|
.. _module:im:
|
||||||
|
|
||||||
Events
|
Events
|
||||||
------
|
------
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
Presence
|
Presence
|
||||||
========
|
========
|
||||||
|
|
||||||
|
.. _module:presence:
|
||||||
|
|
||||||
Each user has the concept of presence information. This encodes the
|
Each user has the concept of presence information. This encodes the
|
||||||
"availability" of that user, suitable for display on other user's clients.
|
"availability" of that user, suitable for display on other user's clients.
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
Push Notifications
|
Push Notifications
|
||||||
==================
|
==================
|
||||||
|
|
||||||
|
.. _module:push:
|
||||||
|
|
||||||
Overview
|
Overview
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
Receipts
|
Receipts
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
.. _module:receipts:
|
||||||
|
|
||||||
Receipts are used to publish which events in a room the user or their devices
|
Receipts are used to publish which events in a room the user or their devices
|
||||||
have interacted with. For example, which events the user has read. For
|
have interacted with. For example, which events the user has read. For
|
||||||
efficiency this is done as "up to" markers, i.e. marking a particular event
|
efficiency this is done as "up to" markers, i.e. marking a particular event
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
Typing Notifications
|
Typing Notifications
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
.. _module:typing:
|
||||||
|
|
||||||
Users may wish to be informed when another user is typing in a room. This can be
|
Users may wish to be informed when another user is typing in a room. This can be
|
||||||
achieved using typing notifications. These are ephemeral events scoped to a
|
achieved using typing notifications. These are ephemeral events scoped to a
|
||||||
``room_id``. This means they do not form part of the `Event Graph`_ but still
|
``room_id``. This means they do not form part of the `Event Graph`_ but still
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
Voice over IP
|
Voice over IP
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
.. _module:voip:
|
||||||
|
|
||||||
Matrix can also be used to set up VoIP calls. This is part of the core
|
Matrix can also be used to set up VoIP calls. This is part of the core
|
||||||
specification, although is at a relatively early stage. Voice (and video) over
|
specification, although is at a relatively early stage. Voice (and video) over
|
||||||
Matrix is built on the WebRTC 1.0 standard. Call events are sent to a room, like
|
Matrix is built on the WebRTC 1.0 standard. Call events are sent to a room, like
|
||||||
|
|
|
@ -2,11 +2,11 @@ targets:
|
||||||
main: # arbitrary name to identify this build target
|
main: # arbitrary name to identify this build target
|
||||||
files: # the sort order of files to cat
|
files: # the sort order of files to cat
|
||||||
- 0-intro.rst
|
- 0-intro.rst
|
||||||
- { 1: 0-feature_profiles.rst }
|
|
||||||
- 1-client_server_api.rst
|
- 1-client_server_api.rst
|
||||||
- { 1: 0-events.rst }
|
- { 1: 0-events.rst }
|
||||||
- { 1: 0-event_signing.rst }
|
- { 1: 0-event_signing.rst }
|
||||||
- 2-modules.rst
|
- 2-modules.rst
|
||||||
|
- { 1: 0-feature_profiles.rst }
|
||||||
- { 1: "group:modules" } # reference a group of files
|
- { 1: "group:modules" } # reference a group of files
|
||||||
- 3-application_service_api.rst
|
- 3-application_service_api.rst
|
||||||
- 4-server_server_api.rst
|
- 4-server_server_api.rst
|
||||||
|
|
|
@ -122,7 +122,7 @@ def main(input_module, file_stream=None, out_dir=None, verbose=False):
|
||||||
|
|
||||||
# check the input files and substitute in sections where required
|
# check the input files and substitute in sections where required
|
||||||
log("Parsing input template: %s" % file_stream.name)
|
log("Parsing input template: %s" % file_stream.name)
|
||||||
temp_str = file_stream.read()
|
temp_str = file_stream.read().decode("utf-8")
|
||||||
# do sanity checking on the template to make sure they aren't reffing things
|
# do sanity checking on the template to make sure they aren't reffing things
|
||||||
# which will never be replaced with a section.
|
# which will never be replaced with a section.
|
||||||
ast = env.parse(temp_str)
|
ast = env.parse(temp_str)
|
||||||
|
@ -140,7 +140,7 @@ def main(input_module, file_stream=None, out_dir=None, verbose=False):
|
||||||
with open(
|
with open(
|
||||||
os.path.join(out_dir, os.path.basename(file_stream.name)), "w"
|
os.path.join(out_dir, os.path.basename(file_stream.name)), "w"
|
||||||
) as f:
|
) as f:
|
||||||
f.write(output)
|
f.write(output.encode("utf-8"))
|
||||||
log("Output file for: %s" % file_stream.name)
|
log("Output file for: %s" % file_stream.name)
|
||||||
check_unaccessed("units", units)
|
check_unaccessed("units", units)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue