2022-04-15 23:39:04 +00:00
|
|
|
|
// This file Copyright © 2022 Mnemosyne LLC.
|
2022-08-08 18:05:39 +00:00
|
|
|
|
// It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only),
|
2022-04-15 23:39:04 +00:00
|
|
|
|
// or any future license endorsed by Mnemosyne LLC.
|
|
|
|
|
// License text can be found in the licenses/ folder.
|
|
|
|
|
|
2022-07-26 02:45:54 +00:00
|
|
|
|
#include <algorithm> // std::find()
|
2022-09-21 23:34:18 +00:00
|
|
|
|
#include <cctype>
|
2022-07-26 02:45:54 +00:00
|
|
|
|
#include <functional>
|
2022-05-27 01:29:10 +00:00
|
|
|
|
#include <iterator>
|
2022-04-15 23:39:04 +00:00
|
|
|
|
#include <optional>
|
|
|
|
|
#include <set>
|
|
|
|
|
#include <string>
|
|
|
|
|
#include <string_view>
|
2022-08-17 16:08:36 +00:00
|
|
|
|
#include <utility>
|
2022-04-15 23:39:04 +00:00
|
|
|
|
|
|
|
|
|
#include <fmt/format.h>
|
|
|
|
|
|
|
|
|
|
#include "transmission.h"
|
|
|
|
|
|
|
|
|
|
#include "error.h"
|
|
|
|
|
#include "log.h"
|
|
|
|
|
#include "torrent-files.h"
|
|
|
|
|
#include "utils.h"
|
|
|
|
|
|
|
|
|
|
using namespace std::literals;
|
|
|
|
|
|
|
|
|
|
namespace
|
|
|
|
|
{
|
|
|
|
|
|
|
|
|
|
using file_func_t = std::function<void(char const* filename)>;
|
|
|
|
|
|
2022-08-02 17:41:04 +00:00
|
|
|
|
bool isFolder(std::string_view path)
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
2022-08-02 17:41:04 +00:00
|
|
|
|
auto const info = tr_sys_path_get_info(path);
|
|
|
|
|
return info && info->isFolder();
|
2022-04-15 23:39:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
2022-08-02 17:41:04 +00:00
|
|
|
|
bool isEmptyFolder(char const* path)
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
2022-08-02 17:41:04 +00:00
|
|
|
|
if (!isFolder(path))
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (auto const odir = tr_sys_dir_open(path); odir != TR_BAD_SYS_DIR)
|
|
|
|
|
{
|
|
|
|
|
char const* name_cstr = nullptr;
|
|
|
|
|
while ((name_cstr = tr_sys_dir_read_name(odir)) != nullptr)
|
|
|
|
|
{
|
|
|
|
|
auto const name = std::string_view{ name_cstr };
|
|
|
|
|
if (name != "." && name != "..")
|
|
|
|
|
{
|
|
|
|
|
tr_sys_dir_close(odir);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tr_sys_dir_close(odir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void depthFirstWalk(char const* path, file_func_t const& func, std::optional<int> max_depth = {})
|
|
|
|
|
{
|
2022-08-02 17:41:04 +00:00
|
|
|
|
if (isFolder(path) && (!max_depth || *max_depth > 0))
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
|
|
|
|
if (auto const odir = tr_sys_dir_open(path); odir != TR_BAD_SYS_DIR)
|
|
|
|
|
{
|
|
|
|
|
char const* name_cstr = nullptr;
|
|
|
|
|
while ((name_cstr = tr_sys_dir_read_name(odir)) != nullptr)
|
|
|
|
|
{
|
|
|
|
|
auto const name = std::string_view{ name_cstr };
|
|
|
|
|
if (name == "." || name == "..")
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
depthFirstWalk(tr_pathbuf{ path, '/', name }.c_str(), func, max_depth ? *max_depth - 1 : max_depth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tr_sys_dir_close(odir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool isJunkFile(std::string_view filename)
|
|
|
|
|
{
|
|
|
|
|
auto const base = tr_sys_path_basename(filename);
|
|
|
|
|
|
|
|
|
|
#ifdef __APPLE__
|
2022-04-21 14:28:38 +00:00
|
|
|
|
// check for resource forks. <http://web.archive.org/web/20101010051608/http://support.apple.com/kb/TA20578>
|
2022-04-15 23:39:04 +00:00
|
|
|
|
if (tr_strvStartsWith(base, "._"sv))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
auto constexpr Files = std::array<std::string_view, 3>{
|
|
|
|
|
".DS_Store"sv,
|
|
|
|
|
"Thumbs.db"sv,
|
|
|
|
|
"desktop.ini"sv,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return std::find(std::begin(Files), std::end(Files), base) != std::end(Files);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // unnamed namespace
|
|
|
|
|
|
|
|
|
|
///
|
|
|
|
|
|
|
|
|
|
std::optional<tr_torrent_files::FoundFile> tr_torrent_files::find(
|
|
|
|
|
tr_file_index_t file_index,
|
|
|
|
|
std::string_view const* search_paths,
|
|
|
|
|
size_t n_paths) const
|
|
|
|
|
{
|
|
|
|
|
auto filename = tr_pathbuf{};
|
|
|
|
|
auto const& subpath = path(file_index);
|
|
|
|
|
|
|
|
|
|
for (size_t path_idx = 0; path_idx < n_paths; ++path_idx)
|
|
|
|
|
{
|
|
|
|
|
auto const base = search_paths[path_idx];
|
|
|
|
|
|
|
|
|
|
filename.assign(base, '/', subpath);
|
2022-08-02 17:41:04 +00:00
|
|
|
|
if (auto const info = tr_sys_path_get_info(filename); info)
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
2022-08-02 17:41:04 +00:00
|
|
|
|
return FoundFile{ *info, std::move(filename), std::size(base) };
|
2022-04-15 23:39:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
2022-08-02 17:41:04 +00:00
|
|
|
|
filename.assign(base, '/', subpath, PartialFileSuffix);
|
|
|
|
|
if (auto const info = tr_sys_path_get_info(filename); info)
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
2022-08-02 17:41:04 +00:00
|
|
|
|
return FoundFile{ *info, std::move(filename), std::size(base) };
|
2022-04-15 23:39:04 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool tr_torrent_files::hasAnyLocalData(std::string_view const* search_paths, size_t n_paths) const
|
|
|
|
|
{
|
|
|
|
|
for (tr_file_index_t i = 0, n = fileCount(); i < n; ++i)
|
|
|
|
|
{
|
|
|
|
|
if (find(i, search_paths, n_paths))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
|
|
|
|
|
|
|
|
|
bool tr_torrent_files::move(
|
|
|
|
|
std::string_view old_parent_in,
|
|
|
|
|
std::string_view parent_in,
|
|
|
|
|
double volatile* setme_progress,
|
2022-06-01 15:25:06 +00:00
|
|
|
|
std::string_view parent_name,
|
2022-04-15 23:39:04 +00:00
|
|
|
|
tr_error** error) const
|
|
|
|
|
{
|
|
|
|
|
if (setme_progress != nullptr)
|
|
|
|
|
{
|
|
|
|
|
*setme_progress = 0.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto const old_parent = tr_pathbuf{ old_parent_in };
|
|
|
|
|
auto const parent = tr_pathbuf{ parent_in };
|
2022-06-01 15:25:06 +00:00
|
|
|
|
tr_logAddTrace(fmt::format(FMT_STRING("Moving files from '{:s}' to '{:s}'"), old_parent, parent), parent_name);
|
2022-04-15 23:39:04 +00:00
|
|
|
|
|
|
|
|
|
if (tr_sys_path_is_same(old_parent, parent))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!tr_sys_dir_create(parent, TR_SYS_DIR_CREATE_PARENTS, 0777, error))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto const search_paths = std::array<std::string_view, 1>{ old_parent.sv() };
|
|
|
|
|
|
|
|
|
|
auto const total_size = totalSize();
|
|
|
|
|
auto err = bool{};
|
|
|
|
|
auto bytes_moved = uint64_t{};
|
|
|
|
|
|
|
|
|
|
for (tr_file_index_t i = 0, n = fileCount(); i < n; ++i)
|
|
|
|
|
{
|
|
|
|
|
auto const found = find(i, std::data(search_paths), std::size(search_paths));
|
|
|
|
|
if (!found)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto const& old_path = found->filename();
|
|
|
|
|
auto const path = tr_pathbuf{ parent, '/', found->subpath() };
|
2022-06-01 15:25:06 +00:00
|
|
|
|
tr_logAddTrace(fmt::format(FMT_STRING("Found file #{:d} '{:s}'"), i, old_path), parent_name);
|
2022-04-15 23:39:04 +00:00
|
|
|
|
|
|
|
|
|
if (tr_sys_path_is_same(old_path, path))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2022-06-01 15:25:06 +00:00
|
|
|
|
tr_logAddTrace(fmt::format(FMT_STRING("Moving file #{:d} to '{:s}'"), i, old_path, path), parent_name);
|
2022-04-15 23:39:04 +00:00
|
|
|
|
if (!tr_moveFile(old_path, path, error))
|
|
|
|
|
{
|
|
|
|
|
err = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (setme_progress != nullptr && total_size > 0U)
|
|
|
|
|
{
|
|
|
|
|
bytes_moved += fileSize(i);
|
|
|
|
|
*setme_progress = static_cast<double>(bytes_moved) / total_size;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// after moving the files, remove any leftover empty directories
|
|
|
|
|
if (!err)
|
|
|
|
|
{
|
|
|
|
|
auto const remove_empty_directories = [](char const* filename)
|
|
|
|
|
{
|
2022-08-02 17:41:04 +00:00
|
|
|
|
if (isEmptyFolder(filename))
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
|
|
|
|
tr_sys_path_remove(filename, nullptr);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2022-06-01 15:25:06 +00:00
|
|
|
|
remove(old_parent, parent_name, remove_empty_directories);
|
2022-04-15 23:39:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return !err;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* This convoluted code does something (seemingly) simple:
|
|
|
|
|
* remove the torrent's local files.
|
|
|
|
|
*
|
|
|
|
|
* Fun complications:
|
|
|
|
|
* 1. Try to preserve the directory hierarchy in the recycle bin.
|
|
|
|
|
* 2. If there are nontorrent files, don't delete them...
|
|
|
|
|
* 3. ...unless the other files are "junk", such as .DS_Store
|
|
|
|
|
*/
|
|
|
|
|
void tr_torrent_files::remove(std::string_view parent_in, std::string_view tmpdir_prefix, FileFunc const& func) const
|
|
|
|
|
{
|
|
|
|
|
auto const parent = tr_pathbuf{ parent_in };
|
|
|
|
|
|
|
|
|
|
// don't try to delete local data if the directory's gone missing
|
|
|
|
|
if (!tr_sys_path_exists(parent))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// make a tmpdir
|
|
|
|
|
auto tmpdir = tr_pathbuf{ parent, '/', tmpdir_prefix, "__XXXXXX"sv };
|
|
|
|
|
tr_sys_dir_create_temp(std::data(tmpdir));
|
|
|
|
|
|
|
|
|
|
// move the local data to the tmpdir
|
|
|
|
|
auto const search_paths = std::array<std::string_view, 1>{ parent.sv() };
|
|
|
|
|
for (tr_file_index_t idx = 0, n_files = fileCount(); idx < n_files; ++idx)
|
|
|
|
|
{
|
|
|
|
|
if (auto const found = find(idx, std::data(search_paths), std::size(search_paths)); found)
|
|
|
|
|
{
|
|
|
|
|
tr_moveFile(found->filename(), tr_pathbuf{ tmpdir, '/', found->subpath() });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Make a list of the top-level torrent files & folders
|
|
|
|
|
// because we'll need it below in the 'remove junk' phase
|
2022-06-01 15:25:06 +00:00
|
|
|
|
auto const path = tr_pathbuf{ parent, '/', tmpdir_prefix };
|
|
|
|
|
auto top_files = std::set<std::string>{ std::string{ path } };
|
2022-04-15 23:39:04 +00:00
|
|
|
|
depthFirstWalk(
|
|
|
|
|
tmpdir,
|
|
|
|
|
[&parent, &tmpdir, &top_files](char const* filename)
|
|
|
|
|
{
|
|
|
|
|
if (tmpdir != filename)
|
|
|
|
|
{
|
|
|
|
|
top_files.emplace(tr_pathbuf{ parent, '/', tr_sys_path_basename(filename) });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
1);
|
|
|
|
|
|
|
|
|
|
auto const func_wrapper = [&tmpdir, &func](char const* filename)
|
|
|
|
|
{
|
|
|
|
|
if (tmpdir != filename)
|
|
|
|
|
{
|
|
|
|
|
func(filename);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Remove the tmpdir.
|
|
|
|
|
// Since `func` might send files to a recycle bin, try to preserve
|
|
|
|
|
// the folder hierarchy by removing top-level files & folders first.
|
|
|
|
|
// But that can fail -- e.g. `func` might refuse to remove nonempty
|
|
|
|
|
// directories -- so plan B is to remove everything bottom-up.
|
|
|
|
|
depthFirstWalk(tmpdir, func_wrapper, 1);
|
|
|
|
|
depthFirstWalk(tmpdir, func_wrapper);
|
|
|
|
|
tr_sys_path_remove(tmpdir);
|
|
|
|
|
|
|
|
|
|
// OK we've removed the local data.
|
|
|
|
|
// What's left are empty folders, junk, and user-generated files.
|
|
|
|
|
// Remove the first two categories and leave the third alone.
|
|
|
|
|
auto const remove_junk = [](char const* filename)
|
|
|
|
|
{
|
2022-08-02 17:41:04 +00:00
|
|
|
|
if (isEmptyFolder(filename) || isJunkFile(filename))
|
2022-04-15 23:39:04 +00:00
|
|
|
|
{
|
|
|
|
|
tr_sys_path_remove(filename);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
for (auto const& filename : top_files)
|
|
|
|
|
{
|
|
|
|
|
depthFirstWalk(filename.c_str(), remove_junk);
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-05-23 22:53:26 +00:00
|
|
|
|
|
|
|
|
|
namespace
|
|
|
|
|
{
|
|
|
|
|
|
|
|
|
|
// https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
|
|
|
// Do not use the following reserved names for the name of a file:
|
|
|
|
|
// CON, PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8,
|
|
|
|
|
// COM9, LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, and LPT9.
|
|
|
|
|
// Also avoid these names followed immediately by an extension;
|
|
|
|
|
// for example, NUL.txt is not recommended.
|
|
|
|
|
[[nodiscard]] bool isReservedFile(std::string_view in) noexcept
|
|
|
|
|
{
|
|
|
|
|
if (std::empty(in))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Shortcut to avoid extra work below.
|
|
|
|
|
// All the paths below involve filenames that begin with one of these chars
|
|
|
|
|
static auto constexpr ReservedFilesBeginWithOneOf = "ACLNP"sv;
|
|
|
|
|
if (ReservedFilesBeginWithOneOf.find(toupper(in.front())) == std::string_view::npos)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto in_upper = tr_pathbuf{ in };
|
|
|
|
|
std::transform(std::begin(in_upper), std::end(in_upper), std::begin(in_upper), [](auto ch) { return toupper(ch); });
|
|
|
|
|
auto const in_upper_sv = in_upper.sv();
|
|
|
|
|
|
|
|
|
|
static auto constexpr ReservedNames = std::array<std::string_view, 22>{
|
|
|
|
|
"AUX"sv, "CON"sv, "NUL"sv, "PRN"sv, //
|
|
|
|
|
"COM1"sv, "COM2"sv, "COM3"sv, "COM4"sv, "COM5"sv, "COM6"sv, "COM7"sv, "COM8"sv, "COM9"sv, //
|
|
|
|
|
"LPT1"sv, "LPT2"sv, "LPT3"sv, "LPT4"sv, "LPT5"sv, "LPT6"sv, "LPT7"sv, "LPT8"sv, "LPT9"sv, //
|
|
|
|
|
};
|
|
|
|
|
if (std::find(std::begin(ReservedNames), std::end(ReservedNames), in_upper_sv) != std::end(ReservedNames))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static auto constexpr ReservedPrefixes = std::array<std::string_view, 22>{
|
|
|
|
|
"AUX."sv, "CON."sv, "NUL."sv, "PRN."sv, //
|
2022-07-29 01:02:33 +00:00
|
|
|
|
"COM1."sv, "COM2."sv, "COM3."sv, "COM4."sv, "COM5."sv, "COM6."sv, "COM7."sv, "COM8."sv, "COM9."sv, //
|
2022-05-23 22:53:26 +00:00
|
|
|
|
"LPT1."sv, "LPT2."sv, "LPT3."sv, "LPT4."sv, "LPT5."sv, "LPT6."sv, "LPT7."sv, "LPT8."sv, "LPT9."sv, //
|
|
|
|
|
};
|
|
|
|
|
return std::any_of(
|
|
|
|
|
std::begin(ReservedPrefixes),
|
|
|
|
|
std::end(ReservedPrefixes),
|
|
|
|
|
[in_upper_sv](auto const& prefix) { return tr_strvStartsWith(in_upper_sv, prefix); });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// https://docs.microsoft.com/en-us/windows/desktop/FileIO/naming-a-file
|
|
|
|
|
// Use any character in the current code page for a name, including Unicode
|
|
|
|
|
// characters and characters in the extended character set (128–255),
|
|
|
|
|
// except for the following:
|
|
|
|
|
[[nodiscard]] auto constexpr isReservedChar(char ch) noexcept
|
|
|
|
|
{
|
|
|
|
|
switch (ch)
|
|
|
|
|
{
|
|
|
|
|
case '"':
|
|
|
|
|
case '*':
|
|
|
|
|
case '/':
|
|
|
|
|
case ':':
|
|
|
|
|
case '<':
|
|
|
|
|
case '>':
|
|
|
|
|
case '?':
|
|
|
|
|
case '\\':
|
|
|
|
|
case '|':
|
|
|
|
|
return true;
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void appendSanitizedComponent(std::string_view in, tr_pathbuf& out)
|
|
|
|
|
{
|
|
|
|
|
// remove leading and trailing spaces
|
|
|
|
|
in = tr_strvStrip(in);
|
|
|
|
|
|
|
|
|
|
// remove trailing periods
|
|
|
|
|
while (tr_strvEndsWith(in, '.'))
|
|
|
|
|
{
|
|
|
|
|
in.remove_suffix(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isReservedFile(in))
|
|
|
|
|
{
|
|
|
|
|
out.append('_');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// replace reserved characters with an underscore
|
2022-09-07 16:04:28 +00:00
|
|
|
|
static auto constexpr AddChar = [](auto ch)
|
2022-05-23 22:53:26 +00:00
|
|
|
|
{
|
|
|
|
|
return isReservedChar(ch) ? '_' : ch;
|
|
|
|
|
};
|
2022-09-07 16:04:28 +00:00
|
|
|
|
std::transform(std::begin(in), std::end(in), std::back_inserter(out), AddChar);
|
2022-05-23 22:53:26 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
void tr_torrent_files::makeSubpathPortable(std::string_view path, tr_pathbuf& append_me)
|
|
|
|
|
{
|
|
|
|
|
auto segment = std::string_view{};
|
|
|
|
|
while (tr_strvSep(&path, &segment, '/'))
|
|
|
|
|
{
|
|
|
|
|
appendSanitizedComponent(segment, append_me);
|
|
|
|
|
append_me.append('/');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (auto const n = std::size(append_me); n > 0)
|
|
|
|
|
{
|
|
|
|
|
append_me.resize(n - 1); // remove trailing slash
|
|
|
|
|
}
|
|
|
|
|
}
|