Adding 'move' button to web UI, closes #1579

This adds "move file" capability to the web UI's directory display. The
support and test framework is heavily based on the similar "rename file"
feature. Unit tests and documentation are included. Multiple in-progress
versions of this patch may be found in ticket 1579. This version
includes arbitrary URI target support and is compatible with the change
from tahoe_css to tahoe.css.
This commit is contained in:
Marcus Wanner 2011-11-10 03:00:11 -05:00 committed by Brian Warner
parent 8aa690b64e
commit b29d0920d3
6 changed files with 304 additions and 5 deletions

View File

@ -29,8 +29,9 @@ The Tahoe REST-ful Web API
6. `Attaching An Existing File Or Directory (by URI)`_
7. `Unlinking A Child`_
8. `Renaming A Child`_
9. `Other Utilities`_
10. `Debugging and Testing Features`_
9. `Moving A Child`_
10. `Other Utilities`_
11. `Debugging and Testing Features`_
7. `Other Useful Pages`_
8. `Static Files in /public_html`_
@ -1277,6 +1278,21 @@ Renaming A Child
This operation will replace any existing child of the new name, making it
behave like the UNIX "``mv -f``" command.
Moving A Child
----------------
``POST /uri/$DIRCAP/[SUBDIRS../]?t=rename&from_name=OLD&to_dir=TARGET[&to_name=NEW]``
This instructs the node to move a child of the given directory to a
different directory, both of which must be mutable. The child can also be
renamed in the process. The to_dir parameter can be either the name of a
subdirectory of the dircap from which the child is being moved (multiple
levels of descent are supported) or the writecap of an unrelated directory.
This operation will replace any existing child of the new name, making it
behave like the UNIX "``mv -f``" command. The original child is not
unlinked until it is linked into the target directory.
Other Utilities
---------------
@ -1298,6 +1314,8 @@ Other Utilities
functionality described above, with the provided $CHILDNAME present in the
'from_name' field of that form. I.e. this presents a form offering to
rename $CHILDNAME, requesting the new name, and submitting POST rename.
This same URL format can also be used with "move-form" with the expected
results.
``GET /uri/$DIRCAP/[SUBDIRS../]CHILDNAME?t=uri``

View File

@ -245,6 +245,7 @@ class WebMixin(object):
self._sub_uri = sub_uri
foo.set_uri(u"sub", sub_uri, sub_uri)
sub = self.s.create_node_from_uri(sub_uri)
self._sub_node = sub
_ign, n, blocking_uri = self.makefile(1)
foo.set_uri(u"blockingfile", blocking_uri, blocking_uri)
@ -254,7 +255,7 @@ class WebMixin(object):
# still think of it as an umlaut
foo.set_uri(unicode_filename, self._bar_txt_uri, self._bar_txt_uri)
_ign, n, baz_file = self.makefile(2)
self.SUBBAZ_CONTENTS, n, baz_file = self.makefile(2)
self._baz_file_uri = baz_file
sub.set_uri(u"baz.txt", baz_file, baz_file)
@ -309,6 +310,9 @@ class WebMixin(object):
def failUnlessIsBazDotTxt(self, res):
self.failUnlessReallyEqual(res, self.BAZ_CONTENTS, res)
def failUnlessIsSubBazDotTxt(self, res):
self.failUnlessReallyEqual(res, self.SUBBAZ_CONTENTS, res)
def failUnlessIsBarJSON(self, res):
data = simplejson.loads(res)
self.failUnless(isinstance(data, list))
@ -1258,7 +1262,7 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
r'\s+<td align="right">%d</td>' % len(self.BAR_CONTENTS),
])
self.failUnless(re.search(get_bar, res), res)
for label in ['unlink', 'rename']:
for label in ['unlink', 'rename', 'move']:
for line in res.split("\n"):
# find the line that contains the relevant button for bar.txt
if ("form action" in line and
@ -3242,6 +3246,151 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
d.addCallback(self.failUnlessIsFooJSON)
return d
def test_POST_move_file(self):
""""""
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_dir="sub")
d.addCallback(lambda res:
self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
d.addCallback(lambda res:
self.failUnlessNodeHasChild(self._sub_node, u"bar.txt"))
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
return d
def test_POST_move_file_new_name(self):
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_name="wibble.txt", to_dir="sub")
d.addCallback(lambda res:
self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
d.addCallback(lambda res:
self.failIfNodeHasChild(self._sub_node, u"bar.txt"))
d.addCallback(lambda res:
self.failUnlessNodeHasChild(self._sub_node, u"wibble.txt"))
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
return d
def test_POST_move_file_replace(self):
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_name="baz.txt", to_dir="sub")
d.addCallback(lambda res:
self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
return d
def test_POST_move_file_no_replace(self):
d = self.POST(self.public_url + "/foo", t="move", replace="false",
from_name="bar.txt", to_name="baz.txt", to_dir="sub")
d.addBoth(self.shouldFail, error.Error,
"POST_move_file_no_replace",
"409 Conflict",
"There was already a child by that name, and you asked me "
"to not replace it")
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt"))
d.addCallback(self.failUnlessIsSubBazDotTxt)
return d
def test_POST_move_file_slash_fail(self):
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_name="slash/fail.txt", to_dir="sub")
d.addBoth(self.shouldFail, error.Error,
"test_POST_rename_file_slash_fail",
"400 Bad Request",
"to_name= may not contain a slash",
)
d.addCallback(lambda res:
self.failUnlessNodeHasChild(self._foo_node, u"bar.txt"))
d.addCallback(lambda res:
self.failIfNodeHasChild(self._sub_node, u"slash/fail.txt"))
return d
def test_POST_move_file_no_target(self):
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_name="baz.txt")
d.addBoth(self.shouldFail, error.Error,
"POST_move_file_no_target",
"400 Bad Request",
"move requires from_name and to_dir")
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt"))
d.addCallback(self.failUnlessIsBazDotTxt)
return d
def test_POST_move_file_multi_level(self):
d = self.POST(self.public_url + "/foo/sub/level2?t=mkdir", "")
d.addCallback(lambda res: self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_dir="sub/level2"))
d.addCallback(lambda res: self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
d.addCallback(lambda res: self.failIfNodeHasChild(self._sub_node, u"bar.txt"))
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
return d
def test_POST_move_file_to_uri(self):
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_dir=self._sub_uri)
d.addCallback(lambda res:
self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
return d
def test_POST_move_file_to_nonexist_dir(self):
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_dir="notchucktesta")
d.addBoth(self.shouldFail, error.Error,
"POST_move_file_to_nonexist_dir",
"404 Not Found",
"No such child: notchucktesta")
return d
def test_POST_move_file_into_file(self):
d = self.POST(self.public_url + "/foo", t="move",
from_name="bar.txt", to_dir="baz.txt")
d.addBoth(self.shouldFail, error.Error,
"POST_move_file_into_file",
"410 Gone",
"to_dir is not a usable directory")
d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt"))
d.addCallback(self.failUnlessIsBazDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
return d
def test_POST_move_file_to_bad_uri(self):
d = self.POST(self.public_url + "/foo", t="move", from_name="bar.txt",
to_dir="URI:DIR2:mn5jlyjnrjeuydyswlzyui72i:rmneifcj6k6sycjljjhj3f6majsq2zqffydnnul5hfa4j577arma")
d.addBoth(self.shouldFail, error.Error,
"POST_move_file_to_bad_uri",
"410 Gone",
"to_dir is not a usable directory")
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
d.addCallback(self.failUnlessIsBarDotTxt)
d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
d.addCallback(self.failUnlessIsBarJSON)
return d
def shouldRedirect(self, res, target=None, statuscode=None, which=""):
""" If target is not None then the redirection has to go to target. If
statuscode is not None then the redirection has to be accomplished with
@ -3299,6 +3448,15 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
d.addCallback(_check)
return d
def test_GET_move_form(self):
d = self.GET(self.public_url + "/foo?t=move-form&name=bar.txt",
followRedirect=True)
def _check(res):
self.failUnless('name="when_done" value="."' in res, res)
self.failUnless(re.search(r'name="from_name" value="bar\.txt"', res))
d.addCallback(_check)
return d
def log(self, res, msg):
#print "MSG: %s RES: %s" % (msg, res)
log.msg(msg)

View File

@ -934,6 +934,13 @@ def is_literal_file_uri(s):
s.startswith(ALLEGED_READONLY_PREFIX + 'URI:LIT:') or
s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:LIT:'))
def is_writeable_directory_uri(s):
if not isinstance(s, str):
return False
return (s.startswith('URI:DIR2:') or
s.startswith(ALLEGED_READONLY_PREFIX + 'URI:DIR2:') or
s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:DIR2:'))
def has_uri_prefix(s):
if not isinstance(s, str):
return False

View File

@ -13,7 +13,7 @@ from nevow.inevow import IRequest
from foolscap.api import fireEventually
from allmydata.util import base32, time_format
from allmydata.uri import from_string_dirnode
from allmydata.uri import from_string_dirnode, is_writeable_directory_uri
from allmydata.interfaces import IDirectoryNode, IFileNode, IFilesystemNode, \
IImmutableFileNode, IMutableFileNode, ExistingChildError, \
NoSuchChildError, EmptyPathnameComponentError, SDMF_VERSION, MDMF_VERSION
@ -169,6 +169,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin):
return DirectoryReadonlyURI(ctx, self.node)
if t == 'rename-form':
return RenameForm(self.node)
if t == 'move-form':
return MoveForm(self.node)
raise WebError("GET directory: bad t=%s" % t)
@ -213,6 +215,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin):
d = self._POST_unlink(req)
elif t == "rename":
d = self._POST_rename(req)
elif t == "move":
d = self._POST_move(req)
elif t == "check":
d = self._POST_check(req)
elif t == "start-deep-check":
@ -418,6 +422,52 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin):
d.addCallback(lambda res: "thing renamed")
return d
def _POST_move(self, req):
charset = get_arg(req, "_charset", "utf-8")
from_name = get_arg(req, "from_name")
if from_name is not None:
from_name = from_name.strip()
from_name = from_name.decode(charset)
assert isinstance(from_name, unicode)
to_name = get_arg(req, "to_name")
if to_name is not None:
to_name = to_name.strip()
to_name = to_name.decode(charset)
assert isinstance(to_name, unicode)
if not to_name:
to_name = from_name
to_dir = get_arg(req, "to_dir")
if to_dir is not None:
to_dir = to_dir.strip()
to_dir = to_dir.decode(charset)
assert isinstance(to_dir, unicode)
if not from_name or not to_dir:
raise WebError("move requires from_name and to_dir")
replace = boolean_of_arg(get_arg(req, "replace", "true"))
# allow from_name to contain slashes, so they can fix names that
# were accidentally created with them. But disallow them in to_name
# (if it's specified), to discourage the practice.
if to_name and "/" in to_name:
raise WebError("to_name= may not contain a slash", http.BAD_REQUEST)
d = self.node.has_child(to_dir.split('/')[0])
def get_target_node(isname):
if isname or not is_writeable_directory_uri(str(to_dir)):
return self.node.get_child_at_path(to_dir)
else:
return self.client.create_node_from_uri(str(to_dir))
d.addCallback(get_target_node)
def is_target_node_usable(target_node):
if not IDirectoryNode.providedBy(target_node):
raise WebError("to_dir is not a usable directory", http.GONE)
return target_node
d.addCallback(is_target_node_usable)
d.addCallback(lambda new_parent: self.node.move_child_to(
from_name, new_parent, to_name, replace))
d.addCallback(lambda res: "thing moved")
return d
def _maybe_literal(self, res, Results_Class):
if res:
return Results_Class(self.client, res)
@ -662,6 +712,7 @@ class DirectoryAsHTML(rend.Page):
if self.node.is_unknown() or self.node.is_readonly():
unlink = "-"
rename = "-"
move = "-"
else:
# this creates a button which will cause our _POST_unlink method
# to be invoked, which unlinks the file and then redirects the
@ -680,8 +731,16 @@ class DirectoryAsHTML(rend.Page):
T.input(type='submit', value='rename', name="rename"),
]
move = T.form(action=here, method="get")[
T.input(type='hidden', name='t', value='move-form'),
T.input(type='hidden', name='name', value=name),
T.input(type='hidden', name='when_done', value="."),
T.input(type='submit', value='move', name="move"),
]
ctx.fillSlots("unlink", unlink)
ctx.fillSlots("rename", rename)
ctx.fillSlots("move", move)
times = []
linkcrtime = metadata.get('tahoe', {}).get("linkcrtime")
@ -943,6 +1002,32 @@ class RenameForm(rend.Page):
ctx.tag.attributes['value'] = name
return ctx.tag
class MoveForm(rend.Page):
addSlash = True
docFactory = getxmlfile("move-form.xhtml")
def render_title(self, ctx, data):
return ctx.tag["Directory SI=%s" % abbreviated_dirnode(self.original)]
def render_header(self, ctx, data):
header = ["Move "
"from directory SI=%s" % abbreviated_dirnode(self.original),
]
if self.original.is_readonly():
header.append(" (readonly!)")
header.append(":")
return ctx.tag[header]
def render_when_done(self, ctx, data):
return T.input(type="hidden", name="when_done", value=".")
def render_get_name(self, ctx, data):
req = IRequest(ctx)
name = get_arg(req, "name", "")
ctx.tag.attributes['value'] = name
return ctx.tag
class ManifestResults(rend.Page, ReloadMixin):
docFactory = getxmlfile("manifest.xhtml")

View File

@ -33,6 +33,7 @@
<td><n:slot name="times"/></td>
<td><n:slot name="unlink"/></td>
<td><n:slot name="rename"/></td>
<td><n:slot name="move"/></td>
<td><n:slot name="info"/></td>
</tr>

View File

@ -0,0 +1,30 @@
<html xmlns:n="http://nevow.com/ns/nevow/0.1">
<head>
<title n:render="title"></title>
<link href="/tahoe.css" rel="stylesheet" type="text/css"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>
<h2 n:render="header" />
<div class="freeform-form">
<form action="." method="post" enctype="multipart/form-data">
<fieldset>
<legend class="freeform-form-label">Rename child</legend>
<input type="hidden" name="t" value="move" />
<input n:render="when_done" />
Move child:
<input type="text" name="from_name" readonly="true" n:render="get_name" />
to
<input type="text" name="to_dir" /><br />
New name?
<input type="text" name="to_name" />
<input type="submit" value="move" />
</fieldset>
</form>
</div>
</body></html>