feat: web client refresh (#1476)

Give the web client a major overhaul.

User-visible highlights include:

* Mobile is now fully supported.
* Added fullscreen support on mobile.
* Better support for dark mode.
* Added mime icons to the torrent list.
* Improved theme consistency across the app.

Maintainer highlights include:

* Updated code to use ES6 APIs.
* No longer uses jQuery UI.
* No longer uses jQuery.
* Use Webpack to bundle the Javascript, CSS, and assets together -- the entire bundle size is now 68K gzipped.
* Added eslint / prettier / stylelint tooling.
* Uses torrent-get's 'table' mode for more efficient RPC calls.
This commit is contained in:
Charles Kerr 2020-10-23 20:04:25 -05:00 committed by GitHub
parent b28839bd6d
commit cd453764b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
137 changed files with 16708 additions and 11543 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ macosx/en.lproj/*~.nib
node_modules/
po/*.mo
third-party/miniupnp/miniupnpcstrings.h
web/public_html/transmission-app.js.map

View File

@ -17,6 +17,7 @@ include(TrMacros)
tr_auto_option(ENABLE_GTK "Build GTK+ client" AUTO)
tr_auto_option(ENABLE_QT "Build Qt client" AUTO)
tr_auto_option(ENABLE_MAC "Build Mac client" AUTO)
option(ENABLE_WEB "Build Web client" OFF)
option(ENABLE_UTILS "Build utils (create, edit, show)" ON)
option(ENABLE_CLI "Build command-line client" OFF)
option(ENABLE_TESTS "Build unit tests" ON)
@ -622,10 +623,7 @@ if(ENABLE_TESTS)
endif()
function(tr_install_web DST_DIR)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/web DESTINATION ${DST_DIR}
PATTERN *.am EXCLUDE
PATTERN *.in EXCLUDE
PATTERN *.scss EXCLUDE)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/web/public_html DESTINATION ${DST_DIR})
endfunction()
add_subdirectory(libtransmission)
@ -637,7 +635,7 @@ if(ENABLE_GTK AND ENABLE_NLS)
add_subdirectory(po)
endif()
foreach(P daemon cli utils gtk qt mac)
foreach(P cli daemon gtk mac qt utils web)
string(TOUPPER "${P}" P_ID)
if(ENABLE_${P_ID})
if(DEFINED ${P_ID}_PROJECT_DIR)
@ -664,7 +662,11 @@ set(CPACK_SOURCE_PACKAGE_FILE_NAME "${TR_NAME}-${TR_USER_AGENT_PREFIX}")
if(NOT TR_STABLE_RELEASE)
string(APPEND CPACK_SOURCE_PACKAGE_FILE_NAME "-r${TR_VCS_REVISION}")
endif()
list(APPEND CPACK_SOURCE_IGNORE_FILES [.]git)
list(APPEND CPACK_SOURCE_IGNORE_FILES
"${CMAKE_BINARY_DIR}"
"[.]git"
"node_modules"
)
set(CPACK_INSTALL_SCRIPTS "${CMAKE_CURRENT_LIST_DIR}/cmake/CPackSourceFixDirLinks.cmake")
## Code Formatting

View File

@ -62,7 +62,10 @@ fi
cd "${root}/web" || exit 1
if [ -n "$fix" ]; then
cd "${root}/web" && yarn --silent install && yarn --silent 'lint:fix'
elif ! yarn -s install && yarn --silent lint; then
elif ! yarn -s install; then
echo 'JS code could not be checked -- "yarn install" failed'
exitcode=1
elif ! yarn --silent lint; then
echo 'JS code needs formatting'
exitcode=1
fi

View File

@ -53,7 +53,7 @@ png2ico(Transmission.ico
"${ICONS_DIR}/192x192/transmission-qt.png"
"${ICONS_DIR}/256x256/transmission-qt.png")
set(WEBSRCDIR "${CMAKE_INSTALL_PREFIX}/share/transmission/web")
set(WEBSRCDIR "${CMAKE_INSTALL_PREFIX}/share/transmission/public_html")
set(TRQMSRCDIR "${CMAKE_INSTALL_PREFIX}/share/transmission/translations")
set(QTQMSRCDIR "${TR_QT_DIR}/translations")

View File

@ -45,7 +45,7 @@
<Merge Id="VCRedist" SourceFile="$(var.MsvcCrtMsmFile)" DiskId="1" Language="0" />
<Directory Id="$(var.PlatformProgramFilesFolder)" Name="PFiles">
<Directory Id="INSTALLDIR" Name="Transmission">
<Directory Id="WEBINSTALLDIR" Name="web" />
<Directory Id="WEBINSTALLDIR" Name="public_html" />
</Directory>
</Directory>
<Directory Id="ProgramMenuFolder" Name="Programs"/>

View File

@ -493,7 +493,7 @@ char const* tr_getWebClientDir(tr_session const* session)
#ifdef BUILD_MAC_CLIENT /* on Mac, look in the Application Support folder first, then in the app bundle. */
/* Look in the Application Support folder */
s = tr_buildPath(tr_sessionGetConfigDir(session), "web", NULL);
s = tr_buildPath(tr_sessionGetConfigDir(session), "public_html", NULL);
if (!isWebClientDir(s))
{
@ -511,7 +511,7 @@ char const* tr_getWebClientDir(tr_session const* session)
CFRelease(appRef);
/* Fallback to the app bundle */
s = tr_buildPath(appString, "Contents", "Resources", "web", NULL);
s = tr_buildPath(appString, "Contents", "Resources", "public_html", NULL);
if (!isWebClientDir(s))
{
@ -628,7 +628,7 @@ char const* tr_getWebClientDir(tr_session const* session)
/* walk through the candidates & look for a match */
for (tr_list* l = candidates; l != NULL; l = l->next)
{
char* path = tr_buildPath(l->data, "transmission", "web", NULL);
char* path = tr_buildPath(l->data, "transmission", "public_html", NULL);
bool const found = isWebClientDir(path);
if (found)

View File

@ -279,7 +279,8 @@ static char const* mimetype_guess(char const* path)
{ "html", "text/html" },
{ "ico", "image/vnd.microsoft.icon" },
{ "js", "application/javascript" },
{ "png", "image/png" }
{ "png", "image/png" },
{ "svg", "image/svg+xml" }
};
char const* dot = strrchr(path, '.');
@ -1264,5 +1265,11 @@ tr_rpc_server* tr_rpcInit(tr_session* session, tr_variant* settings)
}
}
char const* webClientDir = tr_getWebClientDir(s->session);
if (!tr_str_is_empty(webClientDir))
{
tr_logAddNamedInfo(MY_NAME, _("Serving RPC and Web requests from directory '%s'"), webClientDir);
}
return s;
}

129
web/.eslintrc.js Normal file
View File

@ -0,0 +1,129 @@
module.exports = {
"env": {
"browser": true,
"commonjs": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:sonarjs/recommended",
"plugin:unicorn/recommended"
],
"parser": "@babel/eslint-parser",
"parserOptions": {
"sourceType": "module"
},
"plugins": [
"sonarjs",
"unicorn"
],
"rules": {
"accessor-pairs": "error",
"array-callback-return": "error",
"arrow-spacing": "error",
"block-scoped-var": "error",
"class-methods-use-this": "error",
"consistent-return": "error",
"curly": "error",
"default-case": "error",
"default-case-last": "error",
"default-param-last": "error",
"eqeqeq": "error",
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"init-declarations": "error",
"no-array-constructor": "error",
"no-caller": "error",
"no-confusing-arrow": "error",
"no-constructor-return": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-empty-function": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-floating-decimal": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-loop-func": "error",
"no-loss-of-precision": "error",
"no-multi-str": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-octal": "error",
"no-octal-escape": "error",
"no-promise-executor-return": "error",
"no-proto": "error",
"no-redeclare": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-properties": "error",
"no-return-assign": "error",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": "error",
"no-template-curly-in-string": "error",
"no-this-before-super": "error",
"no-throw-literal": "error",
"no-undef-init": "error",
"no-undefined": "error",
"no-unmodified-loop-condition": "error",
"no-unreachable-loop": "error",
"no-unused-expressions": "error",
"no-unused-labels": "error",
"no-unused-vars": "error",
"no-use-before-define": "error",
"no-useless-backreference": "error",
"no-useless-call": "error",
"no-useless-catch": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-escape": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": "error",
"no-with": "error",
"object-shorthand": "error",
"prefer-arrow-callback": "error",
"prefer-const": "error",
"prefer-destructuring": "error",
"prefer-exponentiation-operator": "error",
"prefer-numeric-literals": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-regex-literals": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"radix": "error",
"require-atomic-updates": "error",
"require-await": "error",
"semi": "error",
"sonarjs/cognitive-complexity": "off",
"sonarjs/no-duplicate-string": "off",
"sort-keys": "error",
"strict": "error",
'unicorn/consistent-function-scoping': 'off',
"unicorn/no-fn-reference-in-iterator": "off",
"unicorn/no-null": "off",
"unicorn/no-reduce": "off",
"unicorn/no-unused-properties": "off",
"unicorn/prevent-abbreviations": "off",
}
};

78
web/CMakeLists.txt Normal file
View File

@ -0,0 +1,78 @@
project(trweb)
set(TRWEB_SRCS
src/about-dialog.js
src/action-manager.js
src/alert-dialog.js
src/context-menu.js
src/file-row.js
src/formatter.js
src/inspector.js
src/main.js
src/move-dialog.js
src/notifications.js
src/open-dialog.js
src/overflow-menu.js
src/prefs-dialog.js
src/prefs.js
src/remote.js
src/remove-dialog.js
src/rename-dialog.js
src/shortcuts-dialog.js
src/statistics-dialog.js
src/torrent.js
src/torrent-row.js
src/transmission.js
src/utils.js
style/transmission-app.scss
)
set(TRWEB_IMGS
style/images/analytics.svg
style/images/application-x-executable.png
style/images/audio-x-generic.png
style/images/blue-turtle.png
style/images/checkered-flag.svg
style/images/chevron-down.svg
style/images/chevron-up.svg
style/images/diagram-3-fill.svg
style/images/files.svg
style/images/folder.png
style/images/font-x-generic.png
style/images/gear-fill.svg
style/images/horizontal-rule.svg
style/images/image-x-generic.png
style/images/lock-fill.svg
style/images/logo.png
style/images/package-x-generic.png
style/images/pause-circle-active.svg
style/images/pause-circle-idle.svg
style/images/play-circle-active.svg
style/images/play-circle-idle.svg
style/images/router.svg
style/images/search.svg
style/images/team.svg
style/images/text-x-generic.png
style/images/three-dots-vertical.svg
style/images/toolbar-close.png
style/images/toolbar-folder.png
style/images/toolbar-info.png
style/images/toolbar-pause.png
style/images/toolbar-start.png
style/images/turtle.png
style/images/up-and-down-arrows.svg
style/images/video-x-generic.png
)
add_custom_target(
trweb ALL
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/package.json" "${CMAKE_CURRENT_BINARY_DIR}/package.json"
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/yarn.lock" "${CMAKE_CURRENT_BINARY_DIR}/yarn.lock"
COMMAND yarn install
COMMAND yarn webpack --config "${CMAKE_CURRENT_SOURCE_DIR}/webpack.config.js" --context "${CMAKE_CURRENT_SOURCE_DIR}"
BYPRODUCTS
public_html/transmission-app.js
public_html/transmission-app.js.LICENSE.txt
DEPENDS ${TRWEB_IMGS}
SOURCES ${TRWEB_SRCS}
)

View File

@ -1,339 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

6
web/babel.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
"plugins": [
"@babel/plugin-proposal-class-properties"
],
"presets": []
};

View File

@ -1,522 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=8,IE=9,IE=10"><!-- ticket #4555 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<link href="./images/favicon.ico" rel="icon" />
<link href="./images/favicon.png" rel="shortcut icon" />
<link rel="apple-touch-icon" href="./images/webclip-icon.png"/>
<script type="text/javascript" src="./javascript/jquery/jquery.min.js"></script>
<script type="text/javascript" src="./javascript/jquery/jquery-migrate.min.js"></script>
<script type="text/javascript" src="./javascript/jquery/jquery-ui.min.js"></script>
<script type="text/javascript" src="./javascript/jquery/jquery.ui-contextmenu.min.js"></script>
<link rel="stylesheet" href="./style/jqueryui/jquery-ui.min.css" type="text/css" media="all" />
<!--
<link media="screen" href="./style/transmission/mobile.css" type= "text/css" rel="stylesheet" />
-->
<link media="only screen and (max-device-width: 480px)" href="./style/transmission/mobile.css" type= "text/css" rel="stylesheet" />
<link media="screen and (min-device-width: 481px)" href="./style/transmission/common.css" type="text/css" rel="stylesheet" />
<!--[if IE 8]>
<link media="screen" href="./style/transmission/common.css" type="text/css" rel="stylesheet" />
<![endif]-->
<script type="text/javascript" src="./javascript/jquery/jquery.transmenu.min.js"></script>
<script type="text/javascript" src="./javascript/polyfill.js"></script>
<script type="text/javascript" src="./javascript/common.js"></script>
<script type="text/javascript" src="./javascript/inspector.js"></script>
<script type="text/javascript" src="./javascript/prefs-dialog.js"></script>
<script type="text/javascript" src="./javascript/remote.js"></script>
<script type="text/javascript" src="./javascript/transmission.js"></script>
<script type="text/javascript" src="./javascript/torrent.js"></script>
<script type="text/javascript" src="./javascript/torrent-row.js"></script>
<script type="text/javascript" src="./javascript/file-row.js"></script>
<script type="text/javascript" src="./javascript/dialog.js"></script>
<script type="text/javascript" src="./javascript/formatter.js"></script>
<script type="text/javascript" src="./javascript/notifications.js"></script>
<script type="text/javascript" src="./javascript/main.js"></script>
<title>Transmission Web Interface</title>
</head>
<body id="transmission_body">
<div id="toolbar">
<div id="toolbar-open" title="Open Torrent"></div>
<div id="toolbar-remove" title="Remove Selected Torrents"></div>
<div class="toolbar-separator"></div>
<div id="toolbar-start" title="Start Selected Torrents"></div>
<div id="toolbar-pause" title="Pause Selected Torrents"></div>
<div class="toolbar-separator"></div>
<div id="toolbar-start-all" title="Start All Torrents"></div>
<div id="toolbar-pause-all" title="Pause All Torrents"></div>
<div id="toolbar-inspector" title="Toggle Inspector"></div>
</div>
<div id="statusbar">
<div id='filter'>
Show
<select id="filter-mode">
<option value="all">All</option>
<option value="active">Active</option>
<option value="downloading">Downloading</option>
<option value="seeding">Seeding</option>
<option value="paused">Paused</option>
<option value="finished">Finished</option>
</select>
<select id="filter-tracker"></select>
<input type="search" id="torrent_search" placeholder="Filter" />
<span id="filter-count">&nbsp;</span>
</div>
<div id='speed-info'>
<div id='speed-dn-container'>
<div id='speed-dn-icon'></div>
<div id='speed-dn-label'></div>
</div>
<div id='speed-up-container'>
<div id='speed-up-icon'></div>
<div id='speed-up-label'></div>
</div>
</div>
</div>
<div class="ui-helper-hidden" id="prefs-dialog">
<ul>
<li id="prefs-tab-general"><a href="#prefs-page-torrents">Torrents</a></li>
<li id="prefs-tab-speed"><a href="#prefs-page-speed">Speed</a></li>
<li id="prefs-tab-peers"><a href="#prefs-page-peers">Peers</a></li>
<li id="prefs-tab-network"><a href="#prefs-page-network">Network</a></li>
<li class="ui-tab-dialog-close"></li>
</ul>
<div>
<div id="prefs-page-torrents">
<div class="prefs-section">
<div class="title">Downloading</div>
<div class="row"><div class="key">Download to:</div><div class="value"><input type="text" id="download-dir"/></div></div>
<div class="checkbox-row"><input type="checkbox" id="start-added-torrents"/><label for="start-added-torrents">Start when added</label></div>
<div class="checkbox-row"><input type="checkbox" id="rename-partial-files"/><label for="rename-partial-files">Append &quot;.part&quot; to incomplete files' names</label></div>
</div>
<div class="prefs-section">
<div class="title">Seeding</div>
<div class="row"><div class="key"><input type="checkbox" id="seedRatioLimited"/><label for="seedRatioLimited">Stop seeding at ratio:</label></div>
<div class="value"><input type="number" min="0" id="seedRatioLimit"/></div></div>
<div class="row"><div class="key"><input type="checkbox" id="idle-seeding-limit-enabled"/><label for="idle-seeding-limit-enabled">Stop seeding if idle for (min):</label></div>
<div class="value"><input type="number" min="1" max="40320" id="idle-seeding-limit"/></div></div>
</div>
</div>
<div id="prefs-page-speed">
<div class="prefs-section">
<div class="title">Speed Limits</div>
<div class="row"><div class="key"><input type="checkbox" id="speed-limit-up-enabled"/><label for="speed-limit-up-enabled">Upload (kB/s):</label></div>
<div class="value"><input type="number" min="0" id="speed-limit-up"/></div></div>
<div class="row"><div class="key"><input type="checkbox" id="speed-limit-down-enabled"/><label for="speed-limit-down-enabled">Download (kB/s):</label></div>
<div class="value"><input type="number" min="0" id="speed-limit-down"/></div></div>
</div>
<div class="prefs-section">
<div class="title"><div id="alternative-speed-limits-title">Alternative Speed Limits</div></div>
<div class="row" id="alternative-speed-limits-desc">Override normal speed limits manually or at scheduled times</div>
<div class="row"><div class="key">Upload (kB/s):</div>
<div class="value"><input type="number" min="0" id="alt-speed-up"/></div></div>
<div class="row"><div class="key">Download (kB/s):</div>
<div class="value"><input type="number" min="0" id="alt-speed-down"/></div></div>
<div class="checkbox-row"><input type="checkbox" id="alt-speed-time-enabled"/><label for="alt-speed-time-enabled">Scheduled Times</label></div>
<div class="row"><div class="key">From:</div>
<div class="value"><select id="alt-speed-time-begin"></select></div></div>
<div class="row"><div class="key">To:</div>
<div class="value"><select id="alt-speed-time-end"></select></div></div>
<div class="row"><div class="key"><label for="alt-speed-time-day">On days:</label></div>
<div class="value"><select id="alt-speed-time-day">
<option value="127">Everyday</option>
<option value="62">Weekdays</option>
<option value="65">Weekends</option>
<option value="1">Sunday</option>
<option value="2">Monday</option>
<option value="4">Tuesday</option>
<option value="8">Wednesday</option>
<option value="16">Thursday</option>
<option value="32">Friday</option>
<option value="64">Saturday</option></select></div></div>
</div>
</div>
<div id="prefs-page-peers">
<div class="prefs-section">
<div class="title">Connections</div>
<div class="row"><div class="key"><label for="peer-limit-per-torrent">Max peers per torrent:</label></div>
<div class="value"><input type="number" min="0" id="peer-limit-per-torrent"/></div></div>
<div class="row"><div class="key"><label for="peer-limit-global">Max peers overall:</label></div>
<div class="value"><input type="number" min="0" id="peer-limit-global"/></div></div>
</div>
<div class="prefs-section">
<div class="title">Options</div>
<div class="row"><div class="key">Encryption mode:</div>
<div class="value"><select id="encryption">
<option value="tolerated">Allow encryption</option>
<option value="preferred">Prefer encryption</option>
<option value="required">Require encryption</option></select></div></div>
<div class="checkbox-row"><input type="checkbox" id="pex-enabled" title="PEX is a tool for exchanging peer lists with the peers you're connected to."/>
<label for="pex-enabled" title="PEX is a tool for exchanging peer lists with the peers you're connected to.">Use PEX to find more peers</label></div>
<div class="checkbox-row"><input type="checkbox" id="dht-enabled" title="DHT is a tool for finding peers without a tracker."/>
<label for="dht-enabled" title="DHT is a tool for finding peers without a tracker.">Use DHT to find more peers</label></div>
<div class="checkbox-row"><input type="checkbox" id="lpd-enabled" title="LPD is a tool for finding peers on your local network."/>
<label for="lpd-enabled" title="LPD is a tool for finding peers on your local network.">Use LPD to find more peers</label></div>
</div>
<div class="prefs-section">
<div class="title">Blocklist</div>
<div class="row"><div class="key"><input type="checkbox" id="blocklist-enabled"/><label for="blocklist-enabled">Enable blocklist:</label></div>
<div class="value"><input type="url" id="blocklist-url"/></div></div>
<div class="row"><div class="key" id="blocklist-info">Blocklist has <span id="blocklist-size">?</span> rules</div>
<div class="value"><input type="button" id="blocklist-update-button" value="Update"/></div></div>
</div>
</div>
<div id="prefs-page-network">
<div class="prefs-section">
<div class="title">Listening Port</div>
<div class="row"><div class="key"><label for="peer-port">Peer listening port:</label></div>
<div class="value"><input type="number" min="0" max="65535" id="peer-port"/></div></div>
<div class="row"><div class="key">&nbsp;</div>
<div class="value"><span id="port-label">Status: Unknown</span></div></div>
<div class="checkbox-row"><input type="checkbox" id="peer-port-random-on-start"/><label for="peer-port-random-on-start">Randomize port on launch</label></div>
<div class="checkbox-row"><input type="checkbox" id="port-forwarding-enabled"/><label for="port-forwarding-enabled">Use port forwarding from my router</label></div>
</div>
<div class="prefs-section">
<div class="title">Options</div>
<div class="checkbox-row"><input type="checkbox" id="utp-enabled" title="uTP is a tool for reducing network congestion."/>
<label for="utp-enabled" title="uTP is a tool for reducing network congestion.">Enable uTP for peer communication</label></div>
</div>
</div>
</div>
</div>
<div class="ui-helper-hidden" id="torrent_inspector">
<div id="inspector-tabs-wrapper">
<div id="inspector-tabs">
<div class="inspector-tab selected" id="inspector-tab-info" title="Info"><a href="#info"></a></div><div class="inspector-tab" id="inspector-tab-peers" title="Peers"><a href="#peers"></a></div><div class="inspector-tab" id="inspector-tab-trackers" title="Trackers"><a href="#trackers"></a></div><div class="inspector-tab" id="inspector-tab-files" title="Files"><a href="#files"></a></div>
</div><!-- inspector-tabs -->
</div><!-- inspector-tabs-wrapper -->
<div id="inspector_header">
<div id="torrent_inspector_name"></div>
</div>
<div class="inspector-page" id="inspector-page-info">
<div class="prefs-section">
<div class="title">Activity</div>
<div class="row"><div class="key">Have:</div><div class="value" id="inspector-info-have">&nbsp;</div></div>
<div class="row"><div class="key">Availability:</div><div class="value" id="inspector-info-availability">&nbsp;</div></div>
<div class="row"><div class="key">Uploaded:</div><div class="value" id="inspector-info-uploaded">&nbsp;</div></div>
<div class="row"><div class="key">Downloaded:</div><div class="value" id="inspector-info-downloaded">&nbsp;</div></div>
<div class="row"><div class="key">State:</div><div class="value" id="inspector-info-state">&nbsp;</div></div>
<div class="row"><div class="key">Running Time:</div><div class="value" id="inspector-info-running-time">&nbsp;</div></div>
<div class="row"><div class="key">Remaining Time:</div><div class="value" id="inspector-info-remaining-time">&nbsp;</div></div>
<div class="row"><div class="key">Last Activity:</div><div class="value" id="inspector-info-last-activity">&nbsp;</div></div>
<div class="row"><div class="key">Error:</div><div class="value" id="inspector-info-error">&nbsp;</div></div>
</div>
<div class="prefs-section">
<div class="title">Details</div>
<div class="row"><div class="key">Size:</div><div class="value" id="inspector-info-size">&nbsp;</div></div>
<div class="row"><div class="key">Location:</div><div class="value" id="inspector-info-location">&nbsp;</div></div>
<div class="row"><div class="key">Hash:</div><div class="value" id="inspector-info-hash">&nbsp;</div></div>
<div class="row"><div class="key">Privacy:</div><div class="value" id="inspector-info-privacy">&nbsp;</div></div>
<div class="row"><div class="key">Origin:</div><div class="value" id="inspector-info-origin">&nbsp;</div></div>
<div class="row"><div class="key">Comment:</div><div class="value" id="inspector-info-comment">&nbsp;</div></div>
</div>
</div><!-- id="inspector_tab_info_container" -->
<div class="inspector-page ui-helper-hidden" id="inspector-page-peers">
<div id="inspector_peers_list">
</div>
</div><!-- id="inspector_tab_peers_container" -->
<div class="inspector-page ui-helper-hidden" id="inspector-page-trackers">
<div id="inspector_trackers_list">
</div>
</div><!-- id="inspector_tab_trackers_container" -->
<div class="inspector-page ui-helper-hidden" id="inspector-page-files">
<ul id="inspector_file_list">
</ul>
</div><!-- id="inspector_tab_files_container" -->
</div>
<div id="torrent_container">
<ul class="torrent_list" id="torrent_list"></ul>
</div>
<div class="dialog_container ui-helper-hidden" id="dialog_container">
<div class="dialog_top_bar"></div>
<div class="dialog_window">
<div class="dialog_logo" id="dialog_logo"></div>
<h2 class="dialog_heading" id="dialog_heading"></h2>
<div class="dialog_message" id="dialog_message"></div>
<a href="#confirm" id="dialog_confirm_button">Confirm</a>
<a href="#cancel" id="dialog_cancel_button">Cancel</a>
</div>
</div>
<div class="ui-helper-hidden" id="about-dialog">
<p id="about-logo"></p>
<p id="about-title">Transmission X</p>
<p id="about-blurb">A fast and easy BitTorrent client</p>
<p id="about-copyright">Copyright (c) The Transmission Project</p>
</div>
<div class="ui-helper-hidden" id="stats-dialog">
<div class="prefs-section">
<div class="title">Current Session</div>
<div class="row"><div class="key">Uploaded:</div><div class="value" id='stats-session-uploaded'>&nbsp;</div></div>
<div class="row"><div class="key">Downloaded:</div><div class="value" id='stats-session-downloaded'>&nbsp;</div></div>
<div class="row"><div class="key">Ratio:</div><div class="value" id='stats-session-ratio'>&nbsp;</div></div>
<div class="row"><div class="key">Running Time:</div><div class="value" id='stats-session-duration'>&nbsp;</div></div>
</div>
<div class="prefs-section">
<div class="title">Total</div>
<div class="row"><div class="key">Started:</div><div class="value" id='stats-total-count'>&nbsp;</div></div>
<div class="row"><div class="key">Uploaded:</div><div class="value" id='stats-total-uploaded'>&nbsp;</div></div>
<div class="row"><div class="key">Downloaded:</div><div class="value" id='stats-total-downloaded'>&nbsp;</div></div>
<div class="row"><div class="key">Ratio:</div><div class="value" id='stats-total-ratio'>&nbsp;</div></div>
<div class="row"><div class="key">Running Time:</div><div class="value" id='stats-total-duration'>&nbsp;</div></div>
</div>
</div>
<div class="ui-helper-hidden" id="hotkeys-dialog">
<table>
<thead>
<tr>
<th>Key</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>/</td>
<td>Show hotkeys dialog</td>
</tr>
<tr>
<td>,</td>
<td>Open preferences</td>
</tr>
<tr>
<td>Enter</td>
<td>Confirm dialog</td>
</tr>
<tr>
<td>ESC</td>
<td>Close/cancel dialog</td>
</tr>
<tr>
<td>a</td>
<td>Select all torrents</td>
</tr>
<tr>
<td>SHIFT + a</td>
<td>Deselect all torrents</td>
</tr>
<tr>
<td>c</td>
<td>Toggle compact view</td>
</tr>
<tr>
<td>d</td>
<td>Delete selected torrents</td>
</tr>
<tr>
<td>Backspace</td>
<td>Delete selected torrents</td>
</tr>
<tr>
<td>DEL</td>
<td>Delete selected torrents</td>
</tr>
<tr>
<td>i</td>
<td>Toggle inspector</td>
</tr>
<tr>
<td>l</td>
<td>Move torrent/Set location</td>
</tr>
<tr>
<td>m</td>
<td>Move torrent/Set location</td>
</tr>
<tr>
<td>o</td>
<td>Add/open a torrent</td>
</tr>
<tr>
<td>u</td>
<td>Add/open a torrent</td>
</tr>
<tr>
<td>p</td>
<td>Pause selected torrents</td>
</tr>
<tr>
<td>r</td>
<td>Resume selected torrents</td>
</tr>
<tr>
<td>t</td>
<td>Toggle turtle mode</td>
</tr>
</tbody>
</table>
</div>
<div class="dialog_container ui-helper-hidden" id="upload_container">
<div class="dialog_top_bar"></div>
<div class="dialog_window">
<div class="dialog_logo" id="upload_dialog_logo"></div>
<h2 class="dialog_heading">Upload Torrent Files</h2>
<form action="#" method="post" id="torrent_upload_form"
enctype="multipart/form-data" target="torrent_upload_frame" autocomplete="off">
<div class="dialog_message">
<label for="torrent_upload_file">Please select a torrent file to upload:</label>
<input type="file" name="torrent_files[]" id="torrent_upload_file" multiple="multiple" />
<label for="torrent_upload_url">Or enter a URL:</label>
<input type="url" id="torrent_upload_url"/>
<label id='add-dialog-folder-label' for="add-dialog-folder-input">Destination folder:</label>
<input type="text" id="add-dialog-folder-input"/>
<input type="checkbox" id="torrent_auto_start" />
<label for="torrent_auto_start" id="auto_start_label">Start when added</label>
</div>
<a href="#upload" id="upload_confirm_button">Upload</a>
<a href="#cancel" id="upload_cancel_button">Cancel</a>
</form>
</div>
</div>
<div class="dialog_container ui-helper-hidden" id="rename_container">
<div class="dialog_top_bar"></div>
<div class="dialog_window">
<div class="dialog_logo"></div>
<h2 class="dialog_heading">Rename torrent</h2>
<div class="dialog_message">
<label for="torrent_rename_name">Enter new name:</label>
<input type="text" id="torrent_rename_name"/>
</div>
<a href="#rename" id="rename_confirm_button">Rename</a>
<a href="#cancel" id="rename_cancel_button">Cancel</a>
</div>
</div>
<div class="dialog_container ui-helper-hidden" id="move_container">
<div class="dialog_top_bar"></div>
<div class="dialog_window">
<div class="dialog_logo" id="move_dialog_logo"></div>
<h2 class="dialog_heading">Set Location</h2>
<form action="#" method="post" id="torrent_move_form"
enctype="multipart/form-data" target="torrent_move_frame">
<div class="dialog_message">
<label for="torrent_path">Location:</label>
<input type="text" id="torrent_path"/>
</div>
<a href="#move" id="move_confirm_button">Apply</a>
<a href="#cancel" id="move_cancel_button">Cancel</a>
</form>
</div>
</div>
<div class="torrent_footer">
<div id="settings_menu" title="Settings Menu">&nbsp;</div>
<div id="prefs-button" title="Edit Preferences…">&nbsp;</div>
<div id="turtle-button" title="Alternative Speed Limits">&nbsp;</div>
<div id="compact-button" title="Compact View">&nbsp;</div>
<ul class="ui-helper-hidden" id="footer_super_menu">
<li id="about-button">About</li>
<li>---</li>
<li id="homepage">Transmission Homepage</li>
<li id="tipjar">Transmission Tip Jar</li>
<li>---</li>
<li id="statistics">Statistics</li>
<li id="hotkeys">Hotkeys</li>
<!--
<li id="toggle_notifications">Notifcations</li>
-->
<li>---</li>
<li>Total Download Rate
<ul id="footer_download_rate_menu">
<li radio-group="download-rate" id="unlimited_download_rate"><span class='ui-icon ui-icon-bullet'></span>Unlimited</li>
<li radio-group="download-rate" id="limited_download_rate">Limit (10 kB/s)</li>
<li>---</li>
<li class='download-speed'>5 kB/s</li>
<li class='download-speed'>10 kB/s</li>
<li class='download-speed'>20 kB/s</li>
<li class='download-speed'>30 kB/s</li>
<li class='download-speed'>40 kB/s</li>
<li class='download-speed'>50 kB/s</li>
<li class='download-speed'>75 kB/s</li>
<li class='download-speed'>100 kB/s</li>
<li class='download-speed'>150 kB/s</li>
<li class='download-speed'>200 kB/s</li>
<li class='download-speed'>250 kB/s</li>
<li class='download-speed'>500 kB/s</li>
<li class='download-speed'>750 kB/s</li>
</ul>
</li>
<li>Total Upload Rate
<ul id="footer_upload_rate_menu">
<li radio-group="upload-rate" id="unlimited_upload_rate"><span class='ui-icon ui-icon-bullet'></span>Unlimited</li>
<li radio-group="upload-rate" id="limited_upload_rate">Limit (10 kB/s)</li>
<li>---</li>
<li class='upload-speed'>5 kB/s</li>
<li class='upload-speed'>10 kB/s</li>
<li class='upload-speed'>20 kB/s</li>
<li class='upload-speed'>30 kB/s</li>
<li class='upload-speed'>40 kB/s</li>
<li class='upload-speed'>50 kB/s</li>
<li class='upload-speed'>75 kB/s</li>
<li class='upload-speed'>100 kB/s</li>
<li class='upload-speed'>150 kB/s</li>
<li class='upload-speed'>200 kB/s</li>
<li class='upload-speed'>250 kB/s</li>
<li class='upload-speed'>500 kB/s</li>
<li class='upload-speed'>750 kB/s</li>
</ul>
</li>
<li>---</li>
<li>Sort Transfers By
<ul id="footer_sort_menu">
<li radio-group="sort-mode" id="sort_by_queue_order">Queue Order</li>
<li radio-group="sort-mode" id="sort_by_activity">Activity</li>
<li radio-group="sort-mode" id="sort_by_age">Age</li>
<li radio-group="sort-mode" id="sort_by_name">Name</li>
<li radio-group="sort-mode" id="sort_by_percent_completed">Progress</li>
<li radio-group="sort-mode" id="sort_by_ratio">Ratio</li>
<li radio-group="sort-mode" id="sort_by_size">Size</li>
<li radio-group="sort-mode" id="sort_by_state">State</li>
<li>---</li>
<li id="reverse_sort_order">Reverse Sort Order</li>
</ul>
</li>
</ul>
</div>
<ul class="ui-helper-hidden" id="torrent_context_menu">
<li data-command="pause_selected">Pause</li>
<li data-command="resume_selected">Resume</li>
<li data-command="resume_now_selected">Resume Now</li>
<li>---</li>
<li data-command="move_top">Move to Top</li>
<li data-command="move_up">Move Up</li>
<li data-command="move_down">Move Down</li>
<li data-command="move_bottom">Move to Bottom</li>
<li>---</li>
<li data-command="remove">Remove From List…</li>
<li data-command="remove_data">Trash Data and Remove From List…</li>
<li>---</li>
<li data-command="verify">Verify Local Data</li>
<li data-command="move">Set Location…</li>
<li data-command="rename">Rename…</li>
<li>---</li>
<li data-command="reannounce">Ask tracker for more peers</li>
<li>---</li>
<li data-command="select_all">Select All</li>
<li data-command="deselect_all">Deselect All</li>
</ul>
<iframe name="torrent_upload_frame" id="torrent_upload_frame" src="about:blank" ></iframe>
</body>
</html>

View File

@ -1,240 +0,0 @@
/**
* Copyright © Dave Perrett, Malcolm Jarvis and Artem Vorotnikov
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
let transmission,
dialog,
isMobileDevice = RegExp('(iPhone|iPod|Android)').test(navigator.userAgent),
scroll_timeout;
// http://forum.jquery.com/topic/combining-ui-dialog-and-tabs
$.fn.tabbedDialog = function (dialog_opts) {
this.tabs({
selected: 0,
});
this.dialog(dialog_opts);
this.find('.ui-tab-dialog-close').append(this.parent().find('.ui-dialog-titlebar-close'));
this.find('.ui-tab-dialog-close').css({
position: 'absolute',
right: '0',
top: '16px',
});
this.find('.ui-tab-dialog-close > a').css({
float: 'none',
padding: '0',
});
const tabul = this.find('ul:first');
this.parent().addClass('ui-tabs').prepend(tabul).draggable('option', 'handle', tabul);
this.siblings('.ui-dialog-titlebar').remove();
tabul.addClass('ui-dialog-titlebar');
};
/**
* Checks to see if the content actually changed before poking the DOM.
*/
function setInnerHTML(e, html) {
if (!e) {
return;
}
/* innerHTML is listed as a string, but the browser seems to change it.
* For example, "&infin;" gets changed to "∞" somewhere down the line.
* So, let's use an arbitrary different field to test our state... */
if (e.currentHTML != html) {
e.currentHTML = html;
e.innerHTML = html;
}
}
function sanitizeText(text) {
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* Many of our text changes are triggered by periodic refreshes
* on torrents whose state hasn't changed since the last update,
* so see if the text actually changed before poking the DOM.
*/
function setTextContent(e, text) {
if (e && e.textContent != text) {
e.textContent = text;
}
}
/*
* Given a numerator and denominator, return a ratio string
*/
Math.ratio = function (numerator, denominator) {
let result = Math.floor((100 * numerator) / denominator) / 100;
// check for special cases
if (result == Number.POSITIVE_INFINITY || result == Number.NEGATIVE_INFINITY) {
result = -2;
} else if (isNaN(result)) {
result = -1;
}
return result;
};
/**
* Round a string of a number to a specified number of decimal places
*/
Number.prototype.toTruncFixed = function (place) {
const ret = Math.floor(this * Math.pow(10, place)) / Math.pow(10, place);
return ret.toFixed(place);
};
Number.prototype.toStringWithCommas = function () {
return this.toString().replace(/\B(?=(?:\d{3})+(?!\d))/g, ',');
};
/*
* Trim whitespace from a string
*/
String.prototype.trim = function () {
return this.replace(/^\s*/, '').replace(/\s*$/, '');
};
/***
**** Preferences
***/
function Prefs() {}
Prefs.prototype = {};
Prefs._RefreshRate = 'refresh_rate';
Prefs._FilterMode = 'filter';
Prefs._FilterAll = 'all';
Prefs._FilterActive = 'active';
Prefs._FilterSeeding = 'seeding';
Prefs._FilterDownloading = 'downloading';
Prefs._FilterPaused = 'paused';
Prefs._FilterFinished = 'finished';
Prefs._SortDirection = 'sort_direction';
Prefs._SortAscending = 'ascending';
Prefs._SortDescending = 'descending';
Prefs._SortMethod = 'sort_method';
Prefs._SortByAge = 'age';
Prefs._SortByActivity = 'activity';
Prefs._SortByName = 'name';
Prefs._SortByQueue = 'queue_order';
Prefs._SortBySize = 'size';
Prefs._SortByProgress = 'percent_completed';
Prefs._SortByRatio = 'ratio';
Prefs._SortByState = 'state';
Prefs._CompactDisplayState = 'compact_display_state';
Prefs._Defaults = {
filter: 'all',
refresh_rate: 5,
sort_direction: 'ascending',
sort_method: 'name',
'turtle-state': false,
compact_display_state: false,
};
/*
* Set a preference option
*/
Prefs.setValue = function (key, val) {
if (!(key in Prefs._Defaults)) {
console.warn("unrecognized preference key '%s'", key);
}
const date = new Date();
date.setFullYear(date.getFullYear() + 1);
document.cookie = key + '=' + val + '; expires=' + date.toGMTString() + '; path=/';
};
/**
* Get a preference option
*
* @param key the preference's key
* @param fallback if the option isn't set, return this instead
*/
Prefs.getValue = function (key, fallback) {
let val;
if (!(key in Prefs._Defaults)) {
console.warn("unrecognized preference key '%s'", key);
}
const lines = document.cookie.split(';');
for (let i = 0, len = lines.length; !val && i < len; ++i) {
const line = lines[i].trim();
const delim = line.indexOf('=');
if (delim === key.length && line.indexOf(key) === 0) {
val = line.substring(delim + 1);
}
}
// FIXME: we support strings and booleans... add number support too?
if (!val) {
val = fallback;
} else if (val === 'true') {
val = true;
} else if (val === 'false') {
val = false;
}
return val;
};
/**
* Get an object with all the Clutch preferences set
*
* @pararm o object to be populated (optional)
*/
Prefs.getClutchPrefs = function (o) {
if (!o) {
o = {};
}
for (const key in Prefs._Defaults) {
o[key] = Prefs.getValue(key, Prefs._Defaults[key]);
}
return o;
};
// forceNumeric() plug-in implementation
jQuery.fn.forceNumeric = function () {
return this.each(function () {
$(this).keydown(function (e) {
const key = e.which || e.keyCode;
return (
(!e.shiftKey &&
!e.altKey &&
!e.ctrlKey &&
// numbers
key >= 48 &&
key <= 57) ||
// Numeric keypad
(key >= 96 && key <= 105) ||
// comma, period and minus, . on keypad
key === 190 ||
key === 188 ||
key === 109 ||
key === 110 ||
// Backspace and Tab and Enter
key === 8 ||
key === 9 ||
key === 13 ||
// Home and End
key === 35 ||
key === 36 ||
// left and right arrows
key === 37 ||
key === 39 ||
// Del and Ins
key === 46 ||
key === 45
);
});
});
};

View File

@ -1,129 +0,0 @@
/**
* Copyright © Dave Perrett and Malcolm Jarvis
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
function Dialog() {
this.initialize();
}
Dialog.prototype = {
/*
* Constructor
*/
initialize: function () {
/*
* Private Interface Variables
*/
this._container = $('#dialog_container');
this._heading = $('#dialog_heading');
this._message = $('#dialog_message');
this._cancel_button = $('#dialog_cancel_button');
this._confirm_button = $('#dialog_confirm_button');
this._callback = null;
// Observe the buttons
this._cancel_button.bind(
'click',
{
dialog: this,
},
this.onCancelClicked
);
this._confirm_button.bind(
'click',
{
dialog: this,
},
this.onConfirmClicked
);
},
/*--------------------------------------------
*
* E V E N T F U N C T I O N S
*
*--------------------------------------------*/
executeCallback: function () {
this._callback();
dialog.hideDialog();
},
hideDialog: function () {
$('body.dialog_showing').removeClass('dialog_showing');
this._container.hide();
transmission.hideMobileAddressbar();
transmission.updateButtonStates();
},
isVisible: function () {
return this._container.is(':visible');
},
onCancelClicked: function (event) {
event.data.dialog.hideDialog();
},
onConfirmClicked: function (event) {
event.data.dialog.executeCallback();
},
/*--------------------------------------------
*
* I N T E R F A C E F U N C T I O N S
*
*--------------------------------------------*/
/*
* Display a confirm dialog
*/
confirm: function (
dialog_heading,
dialog_message,
confirm_button_label,
callback,
cancel_button_label
) {
if (!isMobileDevice) {
$('.dialog_container').hide();
}
setTextContent(this._heading[0], dialog_heading);
setTextContent(this._message[0], dialog_message);
setTextContent(this._cancel_button[0], cancel_button_label || 'Cancel');
setTextContent(this._confirm_button[0], confirm_button_label);
this._confirm_button.show();
this._callback = callback;
$('body').addClass('dialog_showing');
this._container.show();
transmission.updateButtonStates();
if (isMobileDevice) {
transmission.hideMobileAddressbar();
}
},
/*
* Display an alert dialog
*/
alert: function (dialog_heading, dialog_message, cancel_button_label) {
if (!isMobileDevice) {
$('.dialog_container').hide();
}
setTextContent(this._heading[0], dialog_heading);
setTextContent(this._message[0], dialog_message);
// jquery::hide() doesn't work here in Safari for some odd reason
this._confirm_button.css('display', 'none');
setTextContent(this._cancel_button[0], cancel_button_label);
// Just in case
$('#upload_container').hide();
$('#move_container').hide();
$('body').addClass('dialog_showing');
transmission.updateButtonStates();
if (isMobileDevice) {
transmission.hideMobileAddressbar();
}
this._container.show();
},
};

View File

@ -1,212 +0,0 @@
/**
* Copyright © Mnemosyne LLC
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
function FileRow(torrent, depth, name, indices, even) {
const fields = {
have: 0,
indices: [],
isWanted: true,
priorityLow: false,
priorityNormal: false,
priorityHigh: false,
me: this,
size: 0,
torrent: null,
};
const elements = {
priority_low_button: null,
priority_normal_button: null,
priority_high_button: null,
progress: null,
root: null,
};
const initialize = function (torrent, depth, name, indices, even) {
fields.torrent = torrent;
fields.indices = indices;
createRow(torrent, depth, name, even);
};
const refreshWantedHTML = function () {
const e = $(elements.root);
e.toggleClass('skip', !fields.isWanted);
e.toggleClass('complete', isDone());
$(e[0].checkbox).prop('disabled', !isEditable());
$(e[0].checkbox).prop('checked', fields.isWanted);
};
const refreshProgressHTML = function () {
const pct = 100 * (fields.size ? fields.have / fields.size : 1.0);
const c = [
Transmission.fmt.size(fields.have),
' of ',
Transmission.fmt.size(fields.size),
' (',
Transmission.fmt.percentString(pct),
'%)',
].join('');
setTextContent(elements.progress, c);
};
const refreshImpl = function () {
let i,
file,
have = 0,
size = 0,
wanted = false,
low = false,
normal = false,
high = false;
// loop through the file_indices that affect this row
for (i = 0; i < fields.indices.length; ++i) {
file = fields.torrent.getFile(fields.indices[i]);
have += file.bytesCompleted;
size += file.length;
wanted |= file.wanted;
switch (file.priority) {
case -1:
low = true;
break;
case 0:
normal = true;
break;
case 1:
high = true;
break;
}
}
if (fields.have != have || fields.size != size) {
fields.have = have;
fields.size = size;
refreshProgressHTML();
}
if (fields.isWanted !== wanted) {
fields.isWanted = wanted;
refreshWantedHTML();
}
if (fields.priorityLow !== low) {
fields.priorityLow = low;
$(elements.priority_low_button).toggleClass('selected', low);
}
if (fields.priorityNormal !== normal) {
fields.priorityNormal = normal;
$(elements.priority_normal_button).toggleClass('selected', normal);
}
if (fields.priorityHigh !== high) {
fields.priorityHigh = high;
$(elements.priority_high_button).toggleClass('selected', high);
}
};
var isDone = function () {
return fields.have >= fields.size;
};
var isEditable = function () {
return fields.torrent.getFileCount() > 1 && !isDone();
};
var createRow = function (torrent, depth, name, even) {
let e, root, box;
root = document.createElement('li');
root.className = 'inspector_torrent_file_list_entry' + (even ? 'even' : 'odd');
elements.root = root;
e = document.createElement('input');
e.type = 'checkbox';
e.className = 'file_wanted_control';
e.title = 'Download file';
$(e).change(function (ev) {
fireWantedChanged($(ev.currentTarget).prop('checked'));
});
root.checkbox = e;
root.appendChild(e);
e = document.createElement('div');
e.className = 'file-priority-radiobox';
box = e;
e = document.createElement('div');
e.className = 'low';
e.title = 'Low Priority';
$(e).click(function () {
firePriorityChanged(-1);
});
elements.priority_low_button = e;
box.appendChild(e);
e = document.createElement('div');
e.className = 'normal';
e.title = 'Normal Priority';
$(e).click(function () {
firePriorityChanged(0);
});
elements.priority_normal_button = e;
box.appendChild(e);
e = document.createElement('div');
e.title = 'High Priority';
e.className = 'high';
$(e).click(function () {
firePriorityChanged(1);
});
elements.priority_high_button = e;
box.appendChild(e);
root.appendChild(box);
e = document.createElement('div');
e.className = 'inspector_torrent_file_list_entry_name';
setTextContent(e, name);
$(e).click(fireNameClicked);
root.appendChild(e);
e = document.createElement('div');
e.className = 'inspector_torrent_file_list_entry_progress';
root.appendChild(e);
$(e).click(fireNameClicked);
elements.progress = e;
$(root).css('margin-left', '' + depth * 16 + 'px');
refreshImpl();
return root;
};
var fireWantedChanged = function (do_want) {
$(fields.me).trigger('wantedToggled', [fields.indices, do_want]);
};
var firePriorityChanged = function (priority) {
$(fields.me).trigger('priorityToggled', [fields.indices, priority]);
};
var fireNameClicked = function () {
$(fields.me).trigger('nameClicked', [fields.me, fields.indices]);
};
/***
**** PUBLIC
***/
this.getElement = function () {
return elements.root;
};
this.refresh = function () {
refreshImpl();
};
initialize(torrent, depth, name, indices, even);
}

View File

@ -1,301 +0,0 @@
/**
* Copyright © Mnemosyne LLC
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
Transmission.fmt = (function () {
const speed_K = 1000;
const speed_K_str = 'kB/s';
const speed_M_str = 'MB/s';
const speed_G_str = 'GB/s';
const size_K = 1000;
const size_B_str = 'B';
const size_K_str = 'kB';
const size_M_str = 'MB';
const size_G_str = 'GB';
const size_T_str = 'TB';
const mem_K = 1024;
const mem_B_str = 'B';
const mem_K_str = 'KiB';
const mem_M_str = 'MiB';
const mem_G_str = 'GiB';
const mem_T_str = 'TiB';
return {
/*
* Format a percentage to a string
*/
percentString: function (x) {
if (x < 10.0) {
return x.toTruncFixed(2);
} else if (x < 100.0) {
return x.toTruncFixed(1);
} else {
return x.toTruncFixed(0);
}
},
/*
* Format a ratio to a string
*/
ratioString: function (x) {
if (x === -1) {
return 'None';
}
if (x === -2) {
return '&infin;';
}
return this.percentString(x);
},
/**
* Formats the a memory size into a human-readable string
* @param {Number} bytes the filesize in bytes
* @return {String} human-readable string
*/
mem: function (bytes) {
if (bytes < mem_K) {
return [bytes, mem_B_str].join(' ');
}
let convertedSize;
let unit;
if (bytes < Math.pow(mem_K, 2)) {
convertedSize = bytes / mem_K;
unit = mem_K_str;
} else if (bytes < Math.pow(mem_K, 3)) {
convertedSize = bytes / Math.pow(mem_K, 2);
unit = mem_M_str;
} else if (bytes < Math.pow(mem_K, 4)) {
convertedSize = bytes / Math.pow(mem_K, 3);
unit = mem_G_str;
} else {
convertedSize = bytes / Math.pow(mem_K, 4);
unit = mem_T_str;
}
// try to have at least 3 digits and at least 1 decimal
return convertedSize <= 9.995
? [convertedSize.toTruncFixed(2), unit].join(' ')
: [convertedSize.toTruncFixed(1), unit].join(' ');
},
/**
* Formats the a disk capacity or file size into a human-readable string
* @param {Number} bytes the filesize in bytes
* @return {String} human-readable string
*/
size: function (bytes) {
if (bytes < size_K) {
return [bytes, size_B_str].join(' ');
}
let convertedSize;
let unit;
if (bytes < Math.pow(size_K, 2)) {
convertedSize = bytes / size_K;
unit = size_K_str;
} else if (bytes < Math.pow(size_K, 3)) {
convertedSize = bytes / Math.pow(size_K, 2);
unit = size_M_str;
} else if (bytes < Math.pow(size_K, 4)) {
convertedSize = bytes / Math.pow(size_K, 3);
unit = size_G_str;
} else {
convertedSize = bytes / Math.pow(size_K, 4);
unit = size_T_str;
}
// try to have at least 3 digits and at least 1 decimal
return convertedSize <= 9.995
? [convertedSize.toTruncFixed(2), unit].join(' ')
: [convertedSize.toTruncFixed(1), unit].join(' ');
},
speedBps: function (Bps) {
return this.speed(this.toKBps(Bps));
},
toKBps: function (Bps) {
return Math.floor(Bps / speed_K);
},
speed: function (KBps) {
let speed = KBps;
if (speed <= 999.95) {
// 0 KBps to 999 K
return [speed.toTruncFixed(0), speed_K_str].join(' ');
}
speed /= speed_K;
if (speed <= 99.995) {
// 1 M to 99.99 M
return [speed.toTruncFixed(2), speed_M_str].join(' ');
}
if (speed <= 999.95) {
// 100 M to 999.9 M
return [speed.toTruncFixed(1), speed_M_str].join(' ');
}
// insane speeds
speed /= speed_K;
return [speed.toTruncFixed(2), speed_G_str].join(' ');
},
timeInterval: function (seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
seconds = Math.floor(seconds % 60);
const d = days + ' ' + (days > 1 ? 'days' : 'day');
const h = hours + ' ' + (hours > 1 ? 'hours' : 'hour');
const m = minutes + ' ' + (minutes > 1 ? 'minutes' : 'minute');
const s = seconds + ' ' + (seconds > 1 ? 'seconds' : 'second');
if (days) {
if (days >= 4 || !hours) {
return d;
}
return d + ', ' + h;
}
if (hours) {
if (hours >= 4 || !minutes) {
return h;
}
return h + ', ' + m;
}
if (minutes) {
if (minutes >= 4 || !seconds) {
return m;
}
return m + ', ' + s;
}
return s;
},
timestamp: function (seconds) {
if (!seconds) {
return 'N/A';
}
const myDate = new Date(seconds * 1000);
const now = new Date();
let date = '';
let time = '';
const sameYear = now.getFullYear() === myDate.getFullYear();
const sameMonth = now.getMonth() === myDate.getMonth();
const dateDiff = now.getDate() - myDate.getDate();
if (sameYear && sameMonth && Math.abs(dateDiff) <= 1) {
if (dateDiff === 0) {
date = 'Today';
} else if (dateDiff === 1) {
date = 'Yesterday';
} else {
date = 'Tomorrow';
}
} else {
date = myDate.toDateString();
}
let hours = myDate.getHours();
let period = 'AM';
if (hours > 12) {
hours = hours - 12;
period = 'PM';
}
if (hours === 0) {
hours = 12;
}
if (hours < 10) {
hours = '0' + hours;
}
let minutes = myDate.getMinutes();
if (minutes < 10) {
minutes = '0' + minutes;
}
seconds = myDate.getSeconds();
if (seconds < 10) {
seconds = '0' + seconds;
}
time = [hours, minutes, seconds].join(':');
return [date, time, period].join(' ');
},
ngettext: function (msgid, msgid_plural, n) {
// TODO(i18n): http://doc.qt.digia.com/4.6/i18n-plural-rules.html
return n === 1 ? msgid : msgid_plural;
},
countString: function (msgid, msgid_plural, n) {
return [n.toStringWithCommas(), this.ngettext(msgid, msgid_plural, n)].join(' ');
},
peerStatus: function (flagStr) {
const formattedFlags = [];
for (var i = 0, flag; (flag = flagStr[i]); ++i) {
let explanation = null;
switch (flag) {
case 'O':
explanation = 'Optimistic unchoke';
break;
case 'D':
explanation = 'Downloading from this peer';
break;
case 'd':
explanation = "We would download from this peer if they'd let us";
break;
case 'U':
explanation = 'Uploading to peer';
break;
case 'u':
explanation = "We would upload to this peer if they'd ask";
break;
case 'K':
explanation = "Peer has unchoked us, but we're not interested";
break;
case '?':
explanation = "We unchoked this peer, but they're not interested";
break;
case 'E':
explanation = 'Encrypted Connection';
break;
case 'H':
explanation = 'Peer was discovered through Distributed Hash Table (DHT)';
break;
case 'X':
explanation = 'Peer was discovered through Peer Exchange (PEX)';
break;
case 'I':
explanation = 'Peer is an incoming connection';
break;
case 'T':
explanation = 'Peer is connected via uTP';
break;
}
if (!explanation) {
formattedFlags.push(flag);
} else {
formattedFlags.push(
'<span title="' + flag + ': ' + explanation + '">' + flag + '</span>'
);
}
}
return formattedFlags.join('');
},
};
})();

View File

@ -1,958 +0,0 @@
/**
* Copyright © Charles Kerr, Dave Perrett, Malcolm Jarvis and Bruno Bierbaumer
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
function Inspector(controller) {
var data = {
controller: null,
elements: {},
torrents: [],
},
needsExtraInfo = function (torrents) {
return torrents.some((tor) => !tor.hasExtraInfo());
},
refreshTorrents = function (callback) {
let fields,
ids = $.map(data.torrents.slice(0), function (t) {
return t.getId();
});
if (ids && ids.length) {
fields = ['id'].concat(Torrent.Fields.StatsExtra);
if (needsExtraInfo(data.torrents)) {
$.merge(fields, Torrent.Fields.InfoExtra);
}
data.controller.updateTorrents(ids, fields, callback);
}
},
onTabClicked = function (ev) {
const tab = ev.currentTarget;
if (isMobileDevice) {
ev.stopPropagation();
}
// select this tab and deselect the others
$(tab).addClass('selected').siblings().removeClass('selected');
// show this tab and hide the others
$('#' + tab.id.replace('tab', 'page'))
.show()
.siblings('.inspector-page')
.hide();
updateInspector();
},
updateInspector = function () {
let e = data.elements,
torrents = data.torrents,
name;
// update the name, which is shown on all the pages
if (!torrents || !torrents.length) {
name = 'No Selection';
} else if (torrents.length === 1) {
name = torrents[0].getName();
} else {
name = '' + torrents.length + ' Transfers Selected';
}
setTextContent(e.name_lb, name || na);
// update the visible page
if ($(e.info_page).is(':visible')) {
updateInfoPage();
} else if ($(e.peers_page).is(':visible')) {
updatePeersPage();
} else if ($(e.trackers_page).is(':visible')) {
updateTrackersPage();
} else if ($(e.files_page).is(':visible')) {
updateFilesPage();
}
},
/****
***** GENERAL INFO PAGE
****/
updateInfoPage = function () {
let torrents = data.torrents,
e = data.elements,
fmt = Transmission.fmt,
none = 'None',
mixed = 'Mixed',
unknown = 'Unknown',
isMixed,
allPaused,
allFinished,
str,
baseline,
it,
i,
t,
sizeWhenDone = 0,
leftUntilDone = 0,
available = 0,
haveVerified = 0,
haveUnverified = 0,
verifiedPieces = 0,
stateString,
latest,
pieces,
size,
pieceSize,
creator,
mixed_creator,
date,
mixed_date,
v,
u,
f,
d,
now = Date.now();
//
// state_lb
//
if (torrents.length < 1) {
str = none;
} else {
isMixed = false;
allPaused = true;
allFinished = true;
baseline = torrents[0].getStatus();
for (i = 0; (t = torrents[i]); ++i) {
it = t.getStatus();
if (it != baseline) {
isMixed = true;
}
if (!t.isStopped()) {
allPaused = allFinished = false;
}
if (!t.isFinished()) {
allFinished = false;
}
}
if (isMixed) {
str = mixed;
} else if (allFinished) {
str = 'Finished';
} else if (allPaused) {
str = 'Paused';
} else {
str = torrents[0].getStateString();
}
}
setTextContent(e.state_lb, str);
stateString = str;
//
// have_lb
//
if (torrents.length < 1) {
str = none;
} else {
baseline = torrents[0].getStatus();
for (i = 0; (t = torrents[i]); ++i) {
if (!t.needsMetaData()) {
haveUnverified += t.getHaveUnchecked();
v = t.getHaveValid();
haveVerified += v;
if (t.getPieceSize()) {
verifiedPieces += v / t.getPieceSize();
}
sizeWhenDone += t.getSizeWhenDone();
leftUntilDone += t.getLeftUntilDone();
available += t.getHave() + t.getDesiredAvailable();
}
}
d = 100.0 * (sizeWhenDone ? (sizeWhenDone - leftUntilDone) / sizeWhenDone : 1);
str = fmt.percentString(d);
if (!haveUnverified && !leftUntilDone) {
str = fmt.size(haveVerified) + ' (100%)';
} else if (!haveUnverified) {
str = fmt.size(haveVerified) + ' of ' + fmt.size(sizeWhenDone) + ' (' + str + '%)';
} else {
str =
fmt.size(haveVerified) +
' of ' +
fmt.size(sizeWhenDone) +
' (' +
str +
'%), ' +
fmt.size(haveUnverified) +
' Unverified';
}
}
setTextContent(e.have_lb, str);
//
// availability_lb
//
if (torrents.length < 1) {
str = none;
} else if (sizeWhenDone == 0) {
str = none;
} else {
str = '' + fmt.percentString((100.0 * available) / sizeWhenDone) + '%';
}
setTextContent(e.availability_lb, str);
//
// downloaded_lb
//
if (torrents.length < 1) {
str = none;
} else {
d = f = 0;
for (i = 0; (t = torrents[i]); ++i) {
d += t.getDownloadedEver();
f += t.getFailedEver();
}
if (f) {
str = fmt.size(d) + ' (' + fmt.size(f) + ' corrupt)';
} else {
str = fmt.size(d);
}
}
setTextContent(e.downloaded_lb, str);
//
// uploaded_lb
//
if (torrents.length < 1) {
str = none;
} else {
d = u = 0;
if (torrents.length == 1) {
d = torrents[0].getDownloadedEver();
u = torrents[0].getUploadedEver();
if (d == 0) {
d = torrents[0].getHaveValid();
}
} else {
for (i = 0; (t = torrents[i]); ++i) {
d += t.getDownloadedEver();
u += t.getUploadedEver();
}
}
str = fmt.size(u) + ' (Ratio: ' + fmt.ratioString(Math.ratio(u, d)) + ')';
}
setTextContent(e.uploaded_lb, str);
//
// running time
//
if (torrents.length < 1) {
str = none;
} else {
allPaused = true;
baseline = torrents[0].getStartDate();
for (i = 0; (t = torrents[i]); ++i) {
if (baseline != t.getStartDate()) {
baseline = 0;
}
if (!t.isStopped()) {
allPaused = false;
}
}
if (allPaused) {
str = stateString; // paused || finished}
} else if (!baseline) {
str = mixed;
} else {
str = fmt.timeInterval(now / 1000 - baseline);
}
}
setTextContent(e.running_time_lb, str);
//
// remaining time
//
str = '';
if (torrents.length < 1) {
str = none;
} else {
baseline = torrents[0].getETA();
for (i = 0; (t = torrents[i]); ++i) {
if (baseline != t.getETA()) {
str = mixed;
break;
}
}
}
if (!str.length) {
if (baseline < 0) {
str = unknown;
} else {
str = fmt.timeInterval(baseline);
}
}
setTextContent(e.remaining_time_lb, str);
//
// last activity
//
latest = -1;
if (torrents.length < 1) {
str = none;
} else {
for (i = 0; (t = torrents[i]); ++i) {
d = t.getLastActivity();
if (latest < d) {
latest = d;
}
}
d = now / 1000 - latest; // seconds since last activity
if (d < 0) {
str = none;
} else if (d < 5) {
str = 'Active now';
} else {
str = fmt.timeInterval(d) + ' ago';
}
}
setTextContent(e.last_activity_lb, str);
//
// error
//
if (torrents.length < 1) {
str = none;
} else {
str = torrents[0].getErrorString();
for (i = 0; (t = torrents[i]); ++i) {
if (str != t.getErrorString()) {
str = mixed;
break;
}
}
}
setTextContent(e.error_lb, str || none);
//
// size
//
if (torrents.length < 1) {
{
str = none;
}
} else {
pieces = 0;
size = 0;
pieceSize = torrents[0].getPieceSize();
for (i = 0; (t = torrents[i]); ++i) {
pieces += t.getPieceCount();
size += t.getTotalSize();
if (pieceSize != t.getPieceSize()) {
pieceSize = 0;
}
}
if (!size) {
str = none;
} else if (pieceSize > 0) {
str =
fmt.size(size) +
' (' +
pieces.toStringWithCommas() +
' pieces @ ' +
fmt.mem(pieceSize) +
')';
} else {
str = fmt.size(size) + ' (' + pieces.toStringWithCommas() + ' pieces)';
}
}
setTextContent(e.size_lb, str);
//
// hash
//
if (torrents.length < 1) {
str = none;
} else {
str = torrents[0].getHashString();
for (i = 0; (t = torrents[i]); ++i) {
if (str != t.getHashString()) {
str = mixed;
break;
}
}
}
setTextContent(e.hash_lb, str);
//
// privacy
//
if (torrents.length < 1) {
str = none;
} else {
baseline = torrents[0].getPrivateFlag();
str = baseline ? 'Private to this tracker -- DHT and PEX disabled' : 'Public torrent';
for (i = 0; (t = torrents[i]); ++i) {
if (baseline != t.getPrivateFlag()) {
str = mixed;
break;
}
}
}
setTextContent(e.privacy_lb, str);
//
// comment
//
if (torrents.length < 1) {
str = none;
} else {
str = torrents[0].getComment();
for (i = 0; (t = torrents[i]); ++i) {
if (str != t.getComment()) {
str = mixed;
break;
}
}
}
if (!str) {
str = none;
}
if (str.startsWith('https://') || str.startsWith('http://')) {
str = encodeURI(str);
setInnerHTML(e.comment_lb, '<a href="' + str + '" target="_blank" >' + str + '</a>');
} else {
setTextContent(e.comment_lb, str);
}
//
// origin
//
if (torrents.length < 1) {
str = none;
} else {
mixed_creator = false;
mixed_date = false;
creator = torrents[0].getCreator();
date = torrents[0].getDateCreated();
for (i = 0; (t = torrents[i]); ++i) {
if (creator != t.getCreator()) {
mixed_creator = true;
}
if (date != t.getDateCreated()) {
mixed_date = true;
}
}
const empty_creator = !creator || !creator.length;
const empty_date = !date;
if (mixed_creator || mixed_date) {
str = mixed;
} else if (empty_creator && empty_date) {
str = unknown;
} else if (empty_date && !empty_creator) {
str = 'Created by ' + creator;
} else if (empty_creator && !empty_date) {
str = 'Created on ' + new Date(date * 1000).toDateString();
} else {
str = 'Created by ' + creator + ' on ' + new Date(date * 1000).toDateString();
}
}
setTextContent(e.origin_lb, str);
//
// foldername
//
if (torrents.length < 1) {
str = none;
} else {
str = torrents[0].getDownloadDir();
for (i = 0; (t = torrents[i]); ++i) {
if (str != t.getDownloadDir()) {
str = mixed;
break;
}
}
}
setTextContent(e.foldername_lb, str);
},
/****
***** FILES PAGE
****/
changeFileCommand = function (fileIndices, command) {
const torrentId = data.file_torrent.getId();
data.controller.changeFileCommand(torrentId, fileIndices, command);
},
onFileWantedToggled = function (ev, fileIndices, want) {
changeFileCommand(fileIndices, want ? 'files-wanted' : 'files-unwanted');
},
onFilePriorityToggled = function (ev, fileIndices, priority) {
let command;
switch (priority) {
case -1:
command = 'priority-low';
break;
case 1:
command = 'priority-high';
break;
default:
command = 'priority-normal';
break;
}
changeFileCommand(fileIndices, command);
},
onNameClicked = function (ev, fileRow, fileIndices) {
$(fileRow.getElement()).siblings().slideToggle();
},
clearFileList = function () {
$(data.elements.file_list).empty();
delete data.file_torrent;
delete data.file_torrent_n;
delete data.file_rows;
},
createFileTreeModel = function (tor) {
let i,
j,
n,
name,
tokens,
walk,
token,
sub,
leaves = [],
tree = {
children: {},
file_indices: [],
};
n = tor.getFileCount();
for (i = 0; i < n; ++i) {
name = tor.getFile(i).name;
tokens = name.split('/');
walk = tree;
for (j = 0; j < tokens.length; ++j) {
token = tokens[j];
sub = walk.children[token];
if (!sub) {
walk.children[token] = sub = {
name: token,
parent: walk,
children: {},
file_indices: [],
depth: j,
};
}
walk = sub;
}
walk.file_index = i;
delete walk.children;
leaves.push(walk);
}
for (i = 0; i < leaves.length; ++i) {
walk = leaves[i];
j = walk.file_index;
do {
walk.file_indices.push(j);
walk = walk.parent;
} while (walk);
}
return tree;
},
addNodeToView = function (tor, parent, sub, i) {
let row;
row = new FileRow(tor, sub.depth, sub.name, sub.file_indices, i % 2);
data.file_rows.push(row);
parent.appendChild(row.getElement());
$(row).bind('wantedToggled', onFileWantedToggled);
$(row).bind('priorityToggled', onFilePriorityToggled);
$(row).bind('nameClicked', onNameClicked);
},
addSubtreeToView = function (tor, parent, sub, i) {
let key, div;
div = document.createElement('div');
if (sub.parent) {
addNodeToView(tor, div, sub, i++);
}
if (sub.children) {
for (key in sub.children) {
i = addSubtreeToView(tor, div, sub.children[key]);
}
}
parent.appendChild(div);
return i;
},
updateFilesPage = function () {
let i,
n,
tor,
fragment,
tree,
file_list = data.elements.file_list,
torrents = data.torrents;
// only show one torrent at a time
if (torrents.length !== 1) {
clearFileList();
return;
}
tor = torrents[0];
n = tor ? tor.getFileCount() : 0;
if (tor != data.file_torrent || n != data.file_torrent_n) {
// rebuild the file list...
clearFileList();
data.file_torrent = tor;
data.file_torrent_n = n;
data.file_rows = [];
fragment = document.createDocumentFragment();
tree = createFileTreeModel(tor);
addSubtreeToView(tor, fragment, tree, 0);
file_list.appendChild(fragment);
} else {
// ...refresh the already-existing file list
for (i = 0, n = data.file_rows.length; i < n; ++i) {
data.file_rows[i].refresh();
}
}
},
/****
***** PEERS PAGE
****/
updatePeersPage = function () {
let i,
k,
tor,
peers,
peer,
parity,
html = [],
fmt = Transmission.fmt,
peers_list = data.elements.peers_list,
torrents = data.torrents;
for (k = 0; (tor = torrents[k]); ++k) {
peers = tor.getPeers();
html.push('<div class="inspector_group">');
if (torrents.length > 1) {
html.push('<div class="inspector_torrent_label">', sanitizeText(tor.getName()), '</div>');
}
if (!peers || !peers.length) {
html.push('<br></div>'); // firefox won't paint the top border if the div is empty
continue;
}
html.push(
'<table class="peer_list">',
'<tr class="inspector_peer_entry even">',
'<th class="encryptedCol"></th>',
'<th class="upCol">Up</th>',
'<th class="downCol">Down</th>',
'<th class="percentCol">%</th>',
'<th class="statusCol">Status</th>',
'<th class="addressCol">Address</th>',
'<th class="clientCol">Client</th>',
'</tr>'
);
for (i = 0; (peer = peers[i]); ++i) {
parity = i % 2 ? 'odd' : 'even';
html.push(
'<tr class="inspector_peer_entry ',
parity,
'">',
'<td>',
peer.isEncrypted
? '<div class="encrypted-peer-cell" title="Encrypted Connection">'
: '<div class="unencrypted-peer-cell">',
'</div>',
'</td>',
'<td>',
peer.rateToPeer ? fmt.speedBps(peer.rateToPeer) : '',
'</td>',
'<td>',
peer.rateToClient ? fmt.speedBps(peer.rateToClient) : '',
'</td>',
'<td class="percentCol">',
Math.floor(peer.progress * 100),
'%',
'</td>',
'<td>',
fmt.peerStatus(peer.flagStr),
'</td>',
'<td>',
sanitizeText(peer.address),
'</td>',
'<td class="clientCol">',
sanitizeText(peer.clientName),
'</td>',
'</tr>'
);
}
html.push('</table></div>');
}
setInnerHTML(peers_list, html.join(''));
},
/****
***** TRACKERS PAGE
****/
getAnnounceState = function (tracker) {
let timeUntilAnnounce,
s = '';
switch (tracker.announceState) {
case Torrent._TrackerActive:
s = 'Announce in progress';
break;
case Torrent._TrackerWaiting:
timeUntilAnnounce = tracker.nextAnnounceTime - new Date().getTime() / 1000;
if (timeUntilAnnounce < 0) {
timeUntilAnnounce = 0;
}
s = 'Next announce in ' + Transmission.fmt.timeInterval(timeUntilAnnounce);
break;
case Torrent._TrackerQueued:
s = 'Announce is queued';
break;
case Torrent._TrackerInactive:
s = tracker.isBackup ? 'Tracker will be used as a backup' : 'Announce not scheduled';
break;
default:
s = 'unknown announce state: ' + tracker.announceState;
}
return s;
},
lastAnnounceStatus = function (tracker) {
let lastAnnounceLabel = 'Last Announce',
lastAnnounce = ['N/A'],
lastAnnounceTime;
if (tracker.hasAnnounced) {
lastAnnounceTime = Transmission.fmt.timestamp(tracker.lastAnnounceTime);
if (tracker.lastAnnounceSucceeded) {
lastAnnounce = [
lastAnnounceTime,
' (got ',
Transmission.fmt.countString('peer', 'peers', tracker.lastAnnouncePeerCount),
')',
];
} else {
lastAnnounceLabel = 'Announce error';
lastAnnounce = [
tracker.lastAnnounceResult ? tracker.lastAnnounceResult + ' - ' : '',
lastAnnounceTime,
];
}
}
return {
label: lastAnnounceLabel,
value: lastAnnounce.join(''),
};
},
lastScrapeStatus = function (tracker) {
let lastScrapeLabel = 'Last Scrape',
lastScrape = 'N/A',
lastScrapeTime;
if (tracker.hasScraped) {
lastScrapeTime = Transmission.fmt.timestamp(tracker.lastScrapeTime);
if (tracker.lastScrapeSucceeded) {
lastScrape = lastScrapeTime;
} else {
lastScrapeLabel = 'Scrape error';
lastScrape =
(tracker.lastScrapeResult ? tracker.lastScrapeResult + ' - ' : '') + lastScrapeTime;
}
}
return {
label: lastScrapeLabel,
value: lastScrape,
};
},
updateTrackersPage = function () {
let i,
j,
tier,
tracker,
trackers,
tor,
html,
parity,
lastAnnounceStatusHash,
announceState,
lastScrapeStatusHash,
na = 'N/A',
trackers_list = data.elements.trackers_list,
torrents = data.torrents;
// By building up the HTML as as string, then have the browser
// turn this into a DOM tree, this is a fast operation.
html = [];
for (i = 0; (tor = torrents[i]); ++i) {
html.push('<div class="inspector_group">');
if (torrents.length > 1) {
html.push('<div class="inspector_torrent_label">', sanitizeText(tor.getName()), '</div>');
}
tier = -1;
trackers = tor.getTrackers();
for (j = 0; (tracker = trackers[j]); ++j) {
if (tier != tracker.tier) {
if (tier !== -1) {
// close previous tier
html.push('</ul></div>');
}
tier = tracker.tier;
html.push(
'<div class="inspector_group_label">',
'Tier ',
tier + 1,
'</div>',
'<ul class="tier_list">'
);
}
// Display construction
lastAnnounceStatusHash = lastAnnounceStatus(tracker);
announceState = getAnnounceState(tracker);
lastScrapeStatusHash = lastScrapeStatus(tracker);
parity = j % 2 ? 'odd' : 'even';
html.push(
'<li class="inspector_tracker_entry ',
parity,
'"><div class="tracker_host" title="',
sanitizeText(tracker.announce),
'">',
sanitizeText(tracker.host || tracker.announce),
'</div>',
'<div class="tracker_activity">',
'<div>',
lastAnnounceStatusHash['label'],
': ',
sanitizeText(lastAnnounceStatusHash['value']),
'</div>',
'<div>',
announceState,
'</div>',
'<div>',
lastScrapeStatusHash['label'],
': ',
sanitizeText(lastScrapeStatusHash['value']),
'</div>',
'</div><table class="tracker_stats">',
'<tr><th>Seeders:</th><td>',
tracker.seederCount > -1 ? tracker.seederCount : na,
'</td></tr>',
'<tr><th>Leechers:</th><td>',
tracker.leecherCount > -1 ? tracker.leecherCount : na,
'</td></tr>',
'<tr><th>Downloads:</th><td>',
tracker.downloadCount > -1 ? tracker.downloadCount : na,
'</td></tr>',
'</table></li>'
);
}
if (tier !== -1) {
// close last tier
html.push('</ul></div>');
}
html.push('</div>'); // inspector_group
}
setInnerHTML(trackers_list, html.join(''));
},
initialize = function (controller) {
data.controller = controller;
$('.inspector-tab').click(onTabClicked);
data.elements.info_page = $('#inspector-page-info')[0];
data.elements.files_page = $('#inspector-page-files')[0];
data.elements.peers_page = $('#inspector-page-peers')[0];
data.elements.trackers_page = $('#inspector-page-trackers')[0];
data.elements.file_list = $('#inspector_file_list')[0];
data.elements.peers_list = $('#inspector_peers_list')[0];
data.elements.trackers_list = $('#inspector_trackers_list')[0];
data.elements.have_lb = $('#inspector-info-have')[0];
data.elements.availability_lb = $('#inspector-info-availability')[0];
data.elements.downloaded_lb = $('#inspector-info-downloaded')[0];
data.elements.uploaded_lb = $('#inspector-info-uploaded')[0];
data.elements.state_lb = $('#inspector-info-state')[0];
data.elements.running_time_lb = $('#inspector-info-running-time')[0];
data.elements.remaining_time_lb = $('#inspector-info-remaining-time')[0];
data.elements.last_activity_lb = $('#inspector-info-last-activity')[0];
data.elements.error_lb = $('#inspector-info-error')[0];
data.elements.size_lb = $('#inspector-info-size')[0];
data.elements.foldername_lb = $('#inspector-info-location')[0];
data.elements.hash_lb = $('#inspector-info-hash')[0];
data.elements.privacy_lb = $('#inspector-info-privacy')[0];
data.elements.origin_lb = $('#inspector-info-origin')[0];
data.elements.comment_lb = $('#inspector-info-comment')[0];
data.elements.name_lb = $('#torrent_inspector_name')[0];
// force initial 'N/A' updates on all the pages
updateInspector();
updateInfoPage();
updatePeersPage();
updateTrackersPage();
updateFilesPage();
};
/****
***** PUBLIC FUNCTIONS
****/
this.setTorrents = function (torrents) {
const d = data;
// update the inspector when a selected torrent's data changes.
$(d.torrents).unbind('dataChanged.inspector');
$(torrents).bind('dataChanged.inspector', $.proxy(updateInspector, this));
d.torrents = torrents;
// periodically ask for updates to the inspector's torrents
clearTimeout(d.refreshTimeout);
function callback() {
refreshTorrents(rescheduleTimeout);
}
function rescheduleTimeout() {
d.refreshTimeout = setTimeout(callback, 2000);
}
rescheduleTimeout();
refreshTorrents();
// refresh the inspector's UI
updateInspector();
};
initialize(controller);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,99 +0,0 @@
/*
* This file Copyright (C) 2015 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
$.widget('tr.transMenu', $.ui.menu, {
options: {
open: null,
close: null
},
_create: function() {
this.selectImpl = this.options.select;
this.options.select = $.proxy(this._select, this);
this.element.hide();
this._superApply(arguments);
},
_select: function(event, ui) {
if (ui.item.is("[aria-haspopup='true']"))
return;
ui.id = ui.item.attr("id");
ui.group = ui.item.attr("radio-group");
ui.target = $(event.currentTarget);
if (this.selectImpl(event, ui) !== false)
this.close();
},
open: function(event) {
this.element.show();
this.element.css({ position: "absolute", left: 4, top: -this.element.height() - 4 });
$(document).bind("keydown" + this.eventNamespace, $.proxy(function(event) {
if (event.which === $.ui.keyCode.ESCAPE)
this.close();
}, this));
$(document).bind("mousedown" + this.eventNamespace + " touchstart" + this.eventNamespace, $.proxy(function(event) {
if (!$(event.target).closest(".ui-menu-item").length)
this.close();
}, this));
this._trigger("open", event);
},
close: function(event) {
$(document).unbind("keydown" + this.eventNamespace);
$(document).unbind("mousedown" + this.eventNamespace);
$(document).unbind("touchstart" + this.eventNamespace);
this._close(this.element);
this.element.hide();
this._trigger("close", event);
}
});
(function($)
{
function indicatorClass(type)
{
return ['ui-icon', 'ui-icon-' + type];
}
function findIndicator(item, type)
{
return $(item).find('span.' + indicatorClass(type).join('.'));
}
function createIndicator(item, type)
{
$(item).prepend($("<span class='" + indicatorClass(type).join(' ') + "'></span>"));
}
function indicatorType(item)
{
var group = item.attr('radio-group');
return { type: group !== undefined ? 'bullet' : 'check', group: group };
}
$.fn.selectMenuItem = function() {
var t = indicatorType(this);
if (t.type == 'bullet')
this.parent().find('li[radio-group=' + t.group + '] span.' + indicatorClass(t.type).join('.')).remove();
if (findIndicator(this, t.type).length == 0)
createIndicator(this, t.type);
return this;
};
$.fn.deselectMenuItem = function() {
var t = indicatorType(this);
return findIndicator(this, t.type).remove();
};
$.fn.menuItemIsSelected = function() {
return findIndicator(this, 'bullet').length > 0 || findIndicator(this, 'check').length > 0;
};
})(jQuery);

View File

@ -1 +0,0 @@
$.widget("tr.transMenu",$.ui.menu,{options:{open:null,close:null},_create:function(){this.selectImpl=this.options.select,this.options.select=$.proxy(this._select,this),this.element.hide(),this._superApply(arguments)},_select:function(e,t){t.item.is("[aria-haspopup='true']")||(t.id=t.item.attr("id"),t.group=t.item.attr("radio-group"),t.target=$(e.currentTarget),this.selectImpl(e,t)!==!1&&this.close())},open:function(e){this.element.show(),this.element.css({position:"absolute",left:4,top:-this.element.height()-4}),$(document).bind("keydown"+this.eventNamespace,$.proxy(function(e){e.which===$.ui.keyCode.ESCAPE&&this.close()},this)),$(document).bind("mousedown"+this.eventNamespace+" touchstart"+this.eventNamespace,$.proxy(function(e){$(e.target).closest(".ui-menu-item").length||this.close()},this)),this._trigger("open",e)},close:function(e){$(document).unbind("keydown"+this.eventNamespace),$(document).unbind("mousedown"+this.eventNamespace),$(document).unbind("touchstart"+this.eventNamespace),this._close(this.element),this.element.hide(),this._trigger("close",e)}}),function($){function e(e){return["ui-icon","ui-icon-"+e]}function t(t,n){return $(t).find("span."+e(n).join("."))}function n(t,n){$(t).prepend($("<span class='"+e(n).join(" ")+"'></span>"))}function i(e){var t=e.attr("radio-group");return{type:void 0!==t?"bullet":"check",group:t}}$.fn.selectMenuItem=function(){var s=i(this);return"bullet"==s.type&&this.parent().find("li[radio-group="+s.group+"] span."+e(s.type).join(".")).remove(),0==t(this,s.type).length&&n(this,s.type),this},$.fn.deselectMenuItem=function(){var e=i(this);return t(this,e.type).remove()},$.fn.menuItemIsSelected=function(){return t(this,"bullet").length>0||t(this,"check").length>0}}(jQuery);

View File

@ -1,469 +0,0 @@
/*******************************************************************************
* jquery.ui-contextmenu.js plugin.
*
* jQuery plugin that provides a context menu (based on the jQueryUI menu widget).
*
* @see https://github.com/mar10/jquery-ui-contextmenu
*
* Copyright (c) 2013-2015, Martin Wendt (http://wwWendt.de). Licensed MIT.
*/
(function( factory ) {
"use strict";
if ( typeof define === "function" && define.amd ) {
// AMD. Register as an anonymous module.
define([ "jquery", "jquery-ui/menu" ], factory );
} else {
// Browser globals
factory( jQuery );
}
}(function( $ ) {
"use strict";
var supportSelectstart = "onselectstart" in document.createElement("div"),
match = $.ui.menu.version.match(/^(\d)\.(\d+)/),
uiVersion = {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10)
},
isLTE110 = ( uiVersion.major < 2 && uiVersion.minor < 11 );
$.widget("moogle.contextmenu", {
version: "@VERSION",
options: {
addClass: "ui-contextmenu", // Add this class to the outer <ul>
autoFocus: false, // Set keyboard focus to first entry on open
autoTrigger: true, // open menu on browser's `contextmenu` event
delegate: null, // selector
hide: { effect: "fadeOut", duration: "fast" },
ignoreParentSelect: true, // Don't trigger 'select' for sub-menu parents
menu: null, // selector or jQuery pointing to <UL>, or a definition hash
position: null, // popup positon
preventContextMenuForPopup: false, // prevent opening the browser's system
// context menu on menu entries
preventSelect: false, // disable text selection of target
show: { effect: "slideDown", duration: "fast" },
taphold: false, // open menu on taphold events (requires external plugins)
uiMenuOptions: {}, // Additional options, used when UI Menu is created
// Events:
beforeOpen: $.noop, // menu about to open; return `false` to prevent opening
blur: $.noop, // menu option lost focus
close: $.noop, // menu was closed
create: $.noop, // menu was initialized
createMenu: $.noop, // menu was initialized (original UI Menu)
focus: $.noop, // menu option got focus
open: $.noop, // menu was opened
select: $.noop // menu option was selected; return `false` to prevent closing
},
/** Constructor */
_create: function() {
var cssText, eventNames, targetId,
opts = this.options;
this.$headStyle = null;
this.$menu = null;
this.menuIsTemp = false;
this.currentTarget = null;
this.previousFocus = null;
if (opts.preventSelect) {
// Create a global style for all potential menu targets
// If the contextmenu was bound to `document`, we apply the
// selector relative to the <body> tag instead
targetId = ($(this.element).is(document) ? $("body")
: this.element).uniqueId().attr("id");
cssText = "#" + targetId + " " + opts.delegate + " { " +
"-webkit-user-select: none; " +
"-khtml-user-select: none; " +
"-moz-user-select: none; " +
"-ms-user-select: none; " +
"user-select: none; " +
"}";
this.$headStyle = $("<style class='moogle-contextmenu-style' />")
.prop("type", "text/css")
.appendTo("head");
try {
this.$headStyle.html(cssText);
} catch ( e ) {
// issue #47: fix for IE 6-8
this.$headStyle[0].styleSheet.cssText = cssText;
}
// TODO: the selectstart is not supported by FF?
if (supportSelectstart) {
this.element.delegate(opts.delegate, "selectstart" + this.eventNamespace,
function(event) {
event.preventDefault();
});
}
}
this._createUiMenu(opts.menu);
eventNames = "contextmenu" + this.eventNamespace;
if (opts.taphold) {
eventNames += " taphold" + this.eventNamespace;
}
this.element.delegate(opts.delegate, eventNames, $.proxy(this._openMenu, this));
},
/** Destructor, called on $().contextmenu("destroy"). */
_destroy: function() {
this.element.undelegate(this.eventNamespace);
this._createUiMenu(null);
if (this.$headStyle) {
this.$headStyle.remove();
this.$headStyle = null;
}
},
/** (Re)Create jQuery UI Menu. */
_createUiMenu: function(menuDef) {
var ct,
opts = this.options;
// Remove temporary <ul> if any
if (this.isOpen()) {
// #58: 'replaceMenu' in beforeOpen causing select: to lose ui.target
ct = this.currentTarget;
// close without animation, to force async mode
this._closeMenu(true);
this.currentTarget = ct;
}
if (this.menuIsTemp) {
this.$menu.remove(); // this will also destroy ui.menu
} else if (this.$menu) {
this.$menu
.menu("destroy")
.removeClass(this.options.addClass)
.hide();
}
this.$menu = null;
this.menuIsTemp = false;
// If a menu definition array was passed, create a hidden <ul>
// and generate the structure now
if ( !menuDef ) {
return;
} else if ($.isArray(menuDef)) {
this.$menu = $.moogle.contextmenu.createMenuMarkup(menuDef);
this.menuIsTemp = true;
}else if ( typeof menuDef === "string" ) {
this.$menu = $(menuDef);
} else {
this.$menu = menuDef;
}
// Create - but hide - the jQuery UI Menu widget
this.$menu
.hide()
.addClass(opts.addClass)
// Create a menu instance that delegates events to our widget
.menu($.extend(true, {}, opts.uiMenuOptions, {
blur: $.proxy(opts.blur, this),
create: $.proxy(opts.createMenu, this),
focus: $.proxy(opts.focus, this),
select: $.proxy(function(event, ui) {
// User selected a menu entry
var retval,
isParent = $.moogle.contextmenu.isMenu(ui.item),
actionHandler = ui.item.data("actionHandler");
ui.cmd = ui.item.attr("data-command");
ui.target = $(this.currentTarget);
// ignore clicks, if they only open a sub-menu
if ( !isParent || !opts.ignoreParentSelect) {
retval = this._trigger.call(this, "select", event, ui);
if ( actionHandler ) {
retval = actionHandler.call(this, event, ui);
}
if ( retval !== false ) {
this._closeMenu.call(this);
}
event.preventDefault();
}
}, this)
}));
},
/** Open popup (called on 'contextmenu' event). */
_openMenu: function(event, recursive) {
var res, promise,
opts = this.options,
posOption = opts.position,
self = this,
manualTrigger = !!event.isTrigger,
ui = { menu: this.$menu, target: $(event.target),
extraData: event.extraData, originalEvent: event,
result: null };
if ( !opts.autoTrigger && !manualTrigger ) {
// ignore browser's `contextmenu` events
return;
}
// Prevent browser from opening the system context menu
event.preventDefault();
this.currentTarget = event.target;
if ( !recursive ) {
res = this._trigger("beforeOpen", event, ui);
promise = (ui.result && $.isFunction(ui.result.promise)) ? ui.result : null;
ui.result = null;
if ( res === false ) {
this.currentTarget = null;
return false;
} else if ( promise ) {
// Handler returned a Deferred or Promise. Delay menu open until
// the promise is resolved
promise.done(function() {
self._openMenu(event, true);
});
this.currentTarget = null;
return false;
}
ui.menu = this.$menu; // Might have changed in beforeOpen
}
// Register global event handlers that close the dropdown-menu
$(document).bind("keydown" + this.eventNamespace, function(event) {
if ( event.which === $.ui.keyCode.ESCAPE ) {
self._closeMenu();
}
}).bind("mousedown" + this.eventNamespace + " touchstart" + this.eventNamespace,
function(event) {
// Close menu when clicked outside menu
if ( !$(event.target).closest(".ui-menu-item").length ) {
self._closeMenu();
}
});
// required for custom positioning (issue #18 and #13).
if ($.isFunction(posOption)) {
posOption = posOption(event, ui);
}
posOption = $.extend({
my: "left top",
at: "left bottom",
// if called by 'open' method, event does not have pageX/Y
of: (event.pageX === undefined) ? event.target : event,
collision: "fit"
}, posOption);
// Finally display the popup
this.$menu
.show() // required to fix positioning error
.css({
position: "absolute",
left: 0,
top: 0
}).position(posOption)
.hide(); // hide again, so we can apply nice effects
if ( opts.preventContextMenuForPopup ) {
this.$menu.bind("contextmenu" + this.eventNamespace, function(event) {
event.preventDefault();
});
}
this._show(this.$menu, opts.show, function() {
// Set focus to first active menu entry
if ( opts.autoFocus ) {
// var $first = self.$menu.children(".ui-menu-item:enabled:first");
// self.$menu.menu("focus", event, $first).focus();
self.$menu.focus();
self.previousFocus = $(event.target);
}
self._trigger.call(self, "open", event, ui);
});
},
/** Close popup. */
_closeMenu: function(immediately) {
var self = this,
hideOpts = immediately ? false : this.options.hide;
// Note: we don't want to unbind the 'contextmenu' event
$(document)
.unbind("mousedown" + this.eventNamespace)
.unbind("touchstart" + this.eventNamespace)
.unbind("keydown" + this.eventNamespace);
self.currentTarget = null; // issue #44 after hide animation is too late
if ( this.$menu ) { // #88: widget might have been destroyed already
this.$menu
.unbind("contextmenu" + this.eventNamespace);
this._hide(this.$menu, hideOpts, function() {
if ( self.previousFocus ) {
self.previousFocus.focus();
self.previousFocus = null;
}
self._trigger("close");
});
} else {
self._trigger("close");
}
},
/** Handle $().contextmenu("option", key, value) calls. */
_setOption: function(key, value) {
switch (key) {
case "menu":
this.replaceMenu(value);
break;
}
$.Widget.prototype._setOption.apply(this, arguments);
},
/** Return ui-menu entry (<LI> tag). */
_getMenuEntry: function(cmd) {
return this.$menu.find("li[data-command=" + cmd + "]");
},
/** Close context menu. */
close: function() {
if (this.isOpen()) {
this._closeMenu();
}
},
/** Enable or disable the menu command. */
enableEntry: function(cmd, flag) {
this._getMenuEntry(cmd).toggleClass("ui-state-disabled", (flag === false));
},
/** Return Menu element (UL). */
getMenu: function() {
return this.$menu;
},
/** Return true if menu is open. */
isOpen: function() {
// return this.$menu && this.$menu.is(":visible");
return !!this.$menu && !!this.currentTarget;
},
/** Open context menu on a specific target (must match options.delegate)
* Optional `extraData` is passed to event handlers as `ui.extraData`.
*/
open: function(target, extraData) {
// Fake a 'contextmenu' event
extraData = extraData || {};
var e = jQuery.Event("contextmenu", { target: target.get(0), extraData: extraData });
return this.element.trigger(e);
},
/** Replace the menu altogether. */
replaceMenu: function(data) {
this._createUiMenu(data);
},
/** Redefine menu entry (title or all of it). */
setEntry: function(cmd, entry) {
var $ul,
$entryLi = this._getMenuEntry(cmd);
if (typeof entry === "string") {
$.moogle.contextmenu.updateTitle($entryLi, entry);
} else {
$entryLi.empty();
entry.cmd = entry.cmd || cmd;
$.moogle.contextmenu.createEntryMarkup(entry, $entryLi);
if ($.isArray(entry.children)) {
$ul = $("<ul/>").appendTo($entryLi);
$.moogle.contextmenu.createMenuMarkup(entry.children, $ul);
}
this.getMenu().menu("refresh");
}
},
/** Show or hide the menu command. */
showEntry: function(cmd, flag) {
this._getMenuEntry(cmd).toggle(flag !== false);
}
});
/*
* Global functions
*/
$.extend($.moogle.contextmenu, {
/** Convert a menu description into a into a <li> content. */
createEntryMarkup: function(entry, $parentLi) {
var $a = null;
if ( !/[^\-\u2014\u2013\s]/.test( entry.title ) ) {
// hyphen, em dash, en dash: separator as defined by UI Menu 1.10
$parentLi.text(entry.title);
} else {
if ( isLTE110 ) {
// jQuery UI Menu 1.10 or before required an `<a>` tag
$parentLi.attr("data-command", entry.cmd);
$a = $("<a/>", {
html: "" + entry.title,
href: "#"
}).appendTo($parentLi);
if ( entry.uiIcon ) {
$a.append($("<span class='ui-icon' />").addClass(entry.uiIcon));
}
} else {
// jQuery UI Menu 1.11+ preferes to avoid `<a>` tags
$parentLi
.attr("data-command", entry.cmd)
.html("" + entry.title);
if ( $.isFunction(entry.action) ) {
$parentLi.data("actionHandler", entry.action);
}
if ( entry.uiIcon ) {
$parentLi
.append($("<span class='ui-icon' />")
.addClass(entry.uiIcon));
}
}
if ( $.isFunction(entry.action) ) {
$parentLi.data("actionHandler", entry.action);
}
if ( entry.disabled ) {
$parentLi.addClass("ui-state-disabled");
}
if ( entry.addClass ) {
$parentLi.addClass(entry.addClass);
}
if ( $.isPlainObject(entry.data) ) {
$parentLi.data(entry.data);
}
}
},
/** Convert a nested array of command objects into a <ul> structure. */
createMenuMarkup: function(options, $parentUl) {
var i, menu, $ul, $li;
if ( $parentUl == null ) {
$parentUl = $("<ul class='ui-helper-hidden' />").appendTo("body");
}
for (i = 0; i < options.length; i++) {
menu = options[i];
$li = $("<li/>").appendTo($parentUl);
$.moogle.contextmenu.createEntryMarkup(menu, $li);
if ( $.isArray(menu.children) ) {
$ul = $("<ul/>").appendTo($li);
$.moogle.contextmenu.createMenuMarkup(menu.children, $ul);
}
}
return $parentUl;
},
/** Returns true if the menu item has child menu items */
isMenu: function(item) {
if ( isLTE110 ) {
return item.has(">a[aria-haspopup='true']").length > 0;
} else {
return item.is("[aria-haspopup='true']");
}
},
/** Replaces the value of elem's first text node child */
replaceFirstTextNodeChild: function(elem, text) {
elem
.contents()
.filter(function() { return this.nodeType === 3; })
.first()
.replaceWith(text);
},
/** Updates the menu item's title */
updateTitle: function(item, title) {
if ( isLTE110 ) {
$.moogle.contextmenu.replaceFirstTextNodeChild($("a", item), title);
} else {
$.moogle.contextmenu.replaceFirstTextNodeChild(item, title);
}
}
});
}));

File diff suppressed because one or more lines are too long

View File

@ -1,53 +0,0 @@
/**
* Copyright © Dave Perrett, Malcolm Jarvis and Artem Vorotnikov
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
function main() {
// IE specific fixes here
if (jQuery.browser.msie) {
try {
document.execCommand('BackgroundImageCache', false, true);
} catch (err) {
// no-op
}
}
if (jQuery.browser.safari) {
// Move search field's margin down for the styled input
document.getElementById('torrent_search').style['margin-top'] = 3;
}
if (isMobileDevice) {
window.onload = function () {
setTimeout(function () {
window.scrollTo(0, 1);
}, 500);
};
window.onorientationchange = function () {
setTimeout(function () {
window.scrollTo(0, 1);
}, 100);
};
if (window.navigator.standalone) {
// Fix min height for isMobileDevice when run in full screen mode from home screen
// so the footer appears in the right place
document.getElementById('torrent_container').style['min-height'] = '338px';
}
} else {
// Fix for non-Safari-3 browsers: dark borders to replace shadows.
Array.from(document.getElementsByClassName('dialog_window')).forEach(function (e) {
e.style['border'] = '1px solid #777';
});
}
// Initialise the dialog controller
dialog = new Dialog();
// Initialise the main Transmission controller
transmission = new Transmission();
}
document.addEventListener('DOMContentLoaded', main);

View File

@ -1,46 +0,0 @@
const Notifications = {};
$(document).ready(function () {
if (!window.webkitNotifications) {
return;
}
let notificationsEnabled = window.webkitNotifications.checkPermission() === 0;
const toggle = $('#toggle_notifications');
toggle.show();
updateMenuTitle();
$(transmission).bind('downloadComplete seedingComplete', function (event, torrent) {
if (notificationsEnabled) {
let title = (event.type == 'downloadComplete' ? 'Download' : 'Seeding') + ' complete',
content = torrent.getName(),
notification;
notification = window.webkitNotifications.createNotification(
'style/transmission/images/logo.png',
title,
content
);
notification.show();
setTimeout(function () {
notification.cancel();
}, 5000);
}
});
function updateMenuTitle() {
toggle.html((notificationsEnabled ? 'Disable' : 'Enable') + ' Notifications');
}
Notifications.toggle = function () {
if (window.webkitNotifications.checkPermission() !== 0) {
window.webkitNotifications.requestPermission(function () {
notificationsEnabled = window.webkitNotifications.checkPermission() === 0;
updateMenuTitle();
});
} else {
notificationsEnabled = !notificationsEnabled;
updateMenuTitle();
}
};
});

View File

@ -1,87 +0,0 @@
/**
* Copyright © Dave Perrett, Malcolm Jarvis and Artem Vorotnikov
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
if (!Array.from) {
Array.from = (function () {
const toStr = Object.prototype.toString;
const isCallable = function (fn) {
return typeof fn === 'function' || toStr.call(fn) === '[object Function]';
};
const toInteger = function (value) {
const number = Number(value);
if (isNaN(number)) {
return 0;
}
if (number === 0 || !isFinite(number)) {
return number;
}
return (number > 0 ? 1 : -1) * Math.floor(Math.abs(number));
};
const maxSafeInteger = Math.pow(2, 53) - 1;
const toLength = function (value) {
const len = toInteger(value);
return Math.min(Math.max(len, 0), maxSafeInteger);
};
// The length property of the from method is 1.
return function from(arrayLike /*, mapFn, thisArg */) {
// 1. Let C be the this value.
const C = this;
// 2. Let items be ToObject(arrayLike).
const items = Object(arrayLike);
// 3. ReturnIfAbrupt(items).
if (arrayLike == null) {
throw new TypeError('Array.from requires an array-like object - not null or undefined');
}
// 4. If mapfn is undefined, then let mapping be false.
const mapFn = arguments.length > 1 ? arguments[1] : void undefined;
let T;
if (typeof mapFn !== 'undefined') {
// 5. else
// 5. a If IsCallable(mapfn) is false, throw a TypeError exception.
if (!isCallable(mapFn)) {
throw new TypeError('Array.from: when provided, the second argument must be a function');
}
// 5. b. If thisArg was supplied, let T be thisArg; else let T be undefined.
if (arguments.length > 2) {
T = arguments[2];
}
}
// 10. Let lenValue be Get(items, "length").
// 11. Let len be ToLength(lenValue).
const len = toLength(items.length);
// 13. If IsConstructor(C) is true, then
// 13. a. Let A be the result of calling the [[Construct]] internal method of C with an argument list containing the single item len.
// 14. a. Else, Let A be ArrayCreate(len).
const A = isCallable(C) ? Object(new C(len)) : new Array(len);
// 16. Let k be 0.
let k = 0;
// 17. Repeat, while k < len… (also steps a - h)
let kValue;
while (k < len) {
kValue = items[k];
if (mapFn) {
A[k] = typeof T === 'undefined' ? mapFn(kValue, k) : mapFn.call(T, kValue, k);
} else {
A[k] = kValue;
}
k += 1;
}
// 18. Let putStatus be Put(A, "length", len, true).
A.length = len;
// 20. Return A.
return A;
};
})();
}

View File

@ -1,308 +0,0 @@
/**
* Copyright © Charles Kerr, Dave Perrett, Malcolm Jarvis and Bruno Bierbaumer
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
function PrefsDialog(remote) {
const data = {
dialog: null,
remote: null,
elements: {},
// all the RPC session keys that we have gui controls for
keys: [
'alt-speed-down',
'alt-speed-time-begin',
'alt-speed-time-day',
'alt-speed-time-enabled',
'alt-speed-time-end',
'alt-speed-up',
'blocklist-enabled',
'blocklist-size',
'blocklist-url',
'dht-enabled',
'download-dir',
'encryption',
'idle-seeding-limit',
'idle-seeding-limit-enabled',
'lpd-enabled',
'peer-limit-global',
'peer-limit-per-torrent',
'peer-port',
'peer-port-random-on-start',
'pex-enabled',
'port-forwarding-enabled',
'rename-partial-files',
'seedRatioLimit',
'seedRatioLimited',
'speed-limit-down',
'speed-limit-down-enabled',
'speed-limit-up',
'speed-limit-up-enabled',
'start-added-torrents',
'utp-enabled',
],
// map of keys that are enabled only if a 'parent' key is enabled
groups: {
'alt-speed-time-enabled': [
'alt-speed-time-begin',
'alt-speed-time-day',
'alt-speed-time-end',
],
'blocklist-enabled': ['blocklist-url', 'blocklist-update-button'],
'idle-seeding-limit-enabled': ['idle-seeding-limit'],
seedRatioLimited: ['seedRatioLimit'],
'speed-limit-down-enabled': ['speed-limit-down'],
'speed-limit-up-enabled': ['speed-limit-up'],
},
};
const initTimeDropDown = function (e) {
let i, hour, mins, value, content;
for (i = 0; i < 24 * 4; ++i) {
hour = parseInt(i / 4, 10);
mins = (i % 4) * 15;
value = i * 15;
content = hour + ':' + (mins || '00');
e.options[i] = new Option(content, value);
}
};
const onPortChecked = function (response) {
const is_open = response['arguments']['port-is-open'];
const text = 'Port is <b>' + (is_open ? 'Open' : 'Closed') + '</b>';
const e = data.elements.root.find('#port-label');
setInnerHTML(e[0], text);
};
const setGroupEnabled = function (parent_key, enabled) {
let i, key, keys, root;
if (parent_key in data.groups) {
root = data.elements.root;
keys = data.groups[parent_key];
for (i = 0; (key = keys[i]); ++i) {
root.find('#' + key).attr('disabled', !enabled);
}
}
};
const onBlocklistUpdateClicked = function () {
data.remote.updateBlocklist();
setBlocklistButtonEnabled(false);
};
var setBlocklistButtonEnabled = function (b) {
const e = data.elements.blocklist_button;
e.attr('disabled', !b);
e.val(b ? 'Update' : 'Updating...');
};
const getValue = function (e) {
let str;
switch (e[0].type) {
case 'checkbox':
case 'radio':
return e.prop('checked');
case 'text':
case 'url':
case 'email':
case 'number':
case 'search':
case 'select-one':
str = e.val();
if (parseInt(str, 10).toString() === str) {
return parseInt(str, 10);
}
if (parseFloat(str).toString() === str) {
return parseFloat(str);
}
return str;
default:
return null;
}
};
/* this callback is for controls whose changes can be applied
immediately, like checkboxs, radioboxes, and selects */
const onControlChanged = function (ev) {
const o = {};
o[ev.target.id] = getValue($(ev.target));
data.remote.savePrefs(o);
};
/* these two callbacks are for controls whose changes can't be applied
immediately -- like a text entry field -- because it takes many
change events for the user to get to the desired result */
const onControlFocused = function (ev) {
data.oldValue = getValue($(ev.target));
};
const onControlBlurred = function (ev) {
const newValue = getValue($(ev.target));
if (newValue !== data.oldValue) {
const o = {};
o[ev.target.id] = newValue;
data.remote.savePrefs(o);
delete data.oldValue;
}
};
const getDefaultMobileOptions = function () {
return {
width: $(window).width(),
height: $(window).height(),
position: ['left', 'top'],
};
};
const initialize = function (remote) {
let i, key, e, o;
data.remote = remote;
e = $('#prefs-dialog');
data.elements.root = e;
initTimeDropDown(e.find('#alt-speed-time-begin')[0]);
initTimeDropDown(e.find('#alt-speed-time-end')[0]);
o = isMobileDevice
? getDefaultMobileOptions()
: {
width: 350,
height: 400,
};
o.autoOpen = false;
o.show = o.hide = 'fade';
o.close = onDialogClosed;
e.tabbedDialog(o);
e = e.find('#blocklist-update-button');
data.elements.blocklist_button = e;
e.click(onBlocklistUpdateClicked);
// listen for user input
for (i = 0; (key = data.keys[i]); ++i) {
e = data.elements.root.find('#' + key);
switch (e[0].type) {
case 'checkbox':
case 'radio':
case 'select-one':
e.change(onControlChanged);
break;
case 'text':
case 'url':
case 'email':
case 'number':
case 'search':
e.focus(onControlFocused);
e.blur(onControlBlurred);
break;
default:
break;
}
}
};
const getValues = function () {
let i,
key,
val,
o = {},
keys = data.keys,
root = data.elements.root;
for (i = 0; (key = keys[i]); ++i) {
val = getValue(root.find('#' + key));
if (val !== null) {
o[key] = val;
}
}
return o;
};
var onDialogClosed = function () {
transmission.hideMobileAddressbar();
$(data.dialog).trigger('closed', getValues());
};
/****
***** PUBLIC FUNCTIONS
****/
// update the dialog's controls
this.set = function (o) {
let e, i, key, val;
const keys = data.keys;
const root = data.elements.root;
setBlocklistButtonEnabled(true);
for (i = 0; (key = keys[i]); ++i) {
val = o[key];
e = root.find('#' + key);
if (key === 'blocklist-size') {
// special case -- regular text area
e.text('' + val.toStringWithCommas());
} else {
switch (e[0].type) {
case 'checkbox':
case 'radio':
e.prop('checked', val);
setGroupEnabled(key, val);
break;
case 'text':
case 'url':
case 'email':
case 'number':
case 'search':
// don't change the text if the user's editing it.
// it's very annoying when that happens!
if (e[0] !== document.activeElement) {
e.val(val);
}
break;
case 'select-one':
e.val(val);
break;
default:
break;
}
}
}
};
this.show = function () {
transmission.hideMobileAddressbar();
setBlocklistButtonEnabled(true);
data.remote.checkPort(onPortChecked, this);
data.elements.root.dialog('open');
};
this.close = function () {
transmission.hideMobileAddressbar();
data.elements.root.dialog('close');
};
this.shouldAddedTorrentsStart = function () {
return data.elements.root.find('#start-added-torrents')[0].checked;
};
data.dialog = this;
initialize(remote);
}

View File

@ -1,302 +0,0 @@
/**
* Copyright © Charles Kerr, Dave Perrett, Malcolm Jarvis and Bruno Bierbaumer
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
const RPC = {
_DaemonVersion: 'version',
_DownSpeedLimit: 'speed-limit-down',
_DownSpeedLimited: 'speed-limit-down-enabled',
_QueueMoveTop: 'queue-move-top',
_QueueMoveBottom: 'queue-move-bottom',
_QueueMoveUp: 'queue-move-up',
_QueueMoveDown: 'queue-move-down',
_Root: '../rpc',
_TurtleDownSpeedLimit: 'alt-speed-down',
_TurtleState: 'alt-speed-enabled',
_TurtleUpSpeedLimit: 'alt-speed-up',
_UpSpeedLimit: 'speed-limit-up',
_UpSpeedLimited: 'speed-limit-up-enabled',
};
function TransmissionRemote(controller) {
this.initialize(controller);
return this;
}
TransmissionRemote.prototype = {
/*
* Constructor
*/
initialize: function (controller) {
this._controller = controller;
this._error = '';
this._token = '';
},
/*
* Display an error if an ajax request fails, and stop sending requests
* or on a 409, globally set the X-Transmission-Session-Id and resend
*/
ajaxError: function (request, error_string, exception, ajaxObject) {
let token;
const remote = this;
// set the Transmission-Session-Id on a 409
if (
request.status === 409 &&
(token = request.getResponseHeader('X-Transmission-Session-Id'))
) {
remote._token = token;
$.ajax(ajaxObject);
return;
}
remote._error = request.responseText
? request.responseText.trim().replace(/(<([^>]+)>)/gi, '')
: '';
if (!remote._error.length) {
remote._error = 'Server not responding';
}
dialog.confirm(
'Connection Failed',
'Could not connect to the server. You may need to reload the page to reconnect.',
'Details',
function () {
alert(remote._error);
},
'Dismiss'
);
remote._controller.togglePeriodicSessionRefresh(false);
},
appendSessionId: function (XHR) {
if (this._token) {
XHR.setRequestHeader('X-Transmission-Session-Id', this._token);
}
},
sendRequest: function (data, callback, context, async) {
const remote = this;
if (typeof async != 'boolean') {
async = true;
}
var ajaxSettings = {
url: RPC._Root,
type: 'POST',
contentType: 'json',
dataType: 'json',
cache: false,
data: JSON.stringify(data),
beforeSend: function (XHR) {
remote.appendSessionId(XHR);
},
error: function (request, error_string, exception) {
remote.ajaxError(request, error_string, exception, ajaxSettings);
},
success: callback,
context: context,
async: async,
};
$.ajax(ajaxSettings);
},
loadDaemonPrefs: function (callback, context, async) {
const o = {
method: 'session-get',
};
this.sendRequest(o, callback, context, async);
},
checkPort: function (callback, context, async) {
const o = {
method: 'port-test',
};
this.sendRequest(o, callback, context, async);
},
renameTorrent: function (torrentIds, oldpath, newname, callback, context) {
const o = {
method: 'torrent-rename-path',
arguments: {
ids: torrentIds,
path: oldpath,
name: newname,
},
};
this.sendRequest(o, callback, context);
},
loadDaemonStats: function (callback, context, async) {
const o = {
method: 'session-stats',
};
this.sendRequest(o, callback, context, async);
},
updateTorrents: function (torrentIds, fields, callback, context) {
const o = {
method: 'torrent-get',
arguments: {
fields: fields,
},
};
if (torrentIds) {
o['arguments'].ids = torrentIds;
}
this.sendRequest(o, function (response) {
const args = response['arguments'];
callback.call(context, args.torrents, args.removed);
});
},
getFreeSpace: function (dir, callback, context) {
const o = {
method: 'free-space',
arguments: {
path: dir,
},
};
this.sendRequest(o, function (response) {
const args = response['arguments'];
callback.call(context, args.path, args['size-bytes']);
});
},
changeFileCommand: function (torrentId, fileIndices, command) {
const remote = this,
args = {
ids: [torrentId],
};
args[command] = fileIndices;
this.sendRequest(
{
arguments: args,
method: 'torrent-set',
},
function () {
remote._controller.refreshTorrents([torrentId]);
}
);
},
sendTorrentSetRequests: function (method, torrent_ids, args, callback, context) {
if (!args) {
args = {};
}
args['ids'] = torrent_ids;
const o = {
method: method,
arguments: args,
};
this.sendRequest(o, callback, context);
},
sendTorrentActionRequests: function (method, torrent_ids, callback, context) {
this.sendTorrentSetRequests(method, torrent_ids, null, callback, context);
},
startTorrents: function (torrent_ids, noqueue, callback, context) {
const name = noqueue ? 'torrent-start-now' : 'torrent-start';
this.sendTorrentActionRequests(name, torrent_ids, callback, context);
},
stopTorrents: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests('torrent-stop', torrent_ids, callback, context);
},
moveTorrents: function (torrent_ids, new_location, callback, context) {
this.sendTorrentSetRequests(
'torrent-set-location',
torrent_ids,
{
move: true,
location: new_location,
},
callback,
context
);
},
removeTorrents: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests('torrent-remove', torrent_ids, callback, context);
},
removeTorrentsAndData: function (torrents) {
const remote = this;
const o = {
method: 'torrent-remove',
arguments: {
'delete-local-data': true,
ids: [],
},
};
if (torrents) {
for (let i = 0, len = torrents.length; i < len; ++i) {
o.arguments.ids.push(torrents[i].getId());
}
}
this.sendRequest(o, function () {
remote._controller.refreshTorrents();
});
},
verifyTorrents: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests('torrent-verify', torrent_ids, callback, context);
},
reannounceTorrents: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests('torrent-reannounce', torrent_ids, callback, context);
},
addTorrentByUrl: function (url, options) {
const remote = this;
if (url.match(/^[0-9a-f]{40}$/i)) {
url = 'magnet:?xt=urn:btih:' + url;
}
const o = {
method: 'torrent-add',
arguments: {
paused: options.paused,
filename: url,
},
};
this.sendRequest(o, function () {
remote._controller.refreshTorrents();
});
},
savePrefs: function (args) {
const remote = this;
const o = {
method: 'session-set',
arguments: args,
};
this.sendRequest(o, function () {
remote._controller.loadDaemonPrefs();
});
},
updateBlocklist: function () {
const remote = this;
const o = {
method: 'blocklist-update',
};
this.sendRequest(o, function () {
remote._controller.loadDaemonPrefs();
});
},
// Added queue calls
moveTorrentsToTop: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests(RPC._QueueMoveTop, torrent_ids, callback, context);
},
moveTorrentsToBottom: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests(RPC._QueueMoveBottom, torrent_ids, callback, context);
},
moveTorrentsUp: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests(RPC._QueueMoveUp, torrent_ids, callback, context);
},
moveTorrentsDown: function (torrent_ids, callback, context) {
this.sendTorrentActionRequests(RPC._QueueMoveDown, torrent_ids, callback, context);
},
};

View File

@ -1,451 +0,0 @@
/**
* Copyright © Mnemosyne LLC
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
function TorrentRendererHelper() {}
TorrentRendererHelper.getProgressInfo = function (controller, t) {
let pct, extra;
const s = t.getStatus();
const seed_ratio_limit = t.seedRatioLimit(controller);
if (t.needsMetaData()) {
pct = t.getMetadataPercentComplete() * 100;
} else if (!t.isDone()) {
pct = Math.round(t.getPercentDone() * 100);
} else if (seed_ratio_limit > 0 && t.isSeeding()) {
// don't split up the bar if paused or queued
pct = Math.round((t.getUploadRatio() * 100) / seed_ratio_limit);
} else {
pct = 100;
}
if (s === Torrent._StatusStopped) {
extra = 'paused';
} else if (s === Torrent._StatusDownloadWait) {
extra = 'leeching queued';
} else if (t.needsMetaData()) {
extra = 'magnet';
} else if (s === Torrent._StatusDownload) {
extra = 'leeching';
} else if (s === Torrent._StatusSeedWait) {
extra = 'seeding queued';
} else if (s === Torrent._StatusSeed) {
extra = 'seeding';
} else {
extra = '';
}
return {
percent: pct,
complete: ['torrent_progress_bar', 'complete', extra].join(' '),
incomplete: ['torrent_progress_bar', 'incomplete', extra].join(' '),
};
};
TorrentRendererHelper.createProgressbar = function (classes) {
let complete, incomplete, progressbar;
complete = document.createElement('div');
complete.className = 'torrent_progress_bar complete';
incomplete = document.createElement('div');
incomplete.className = 'torrent_progress_bar incomplete';
progressbar = document.createElement('div');
progressbar.className = 'torrent_progress_bar_container ' + classes;
progressbar.appendChild(complete);
progressbar.appendChild(incomplete);
return {
element: progressbar,
complete: complete,
incomplete: incomplete,
};
};
TorrentRendererHelper.renderProgressbar = function (controller, t, progressbar) {
let e, style, width, display;
const info = TorrentRendererHelper.getProgressInfo(controller, t);
// update the complete progressbar
e = progressbar.complete;
style = e.style;
width = '' + info.percent + '%';
display = info.percent > 0 ? 'block' : 'none';
if (style.width !== width || style.display !== display) {
$(e).css({
width: '' + info.percent + '%',
display: display,
});
}
if (e.className !== info.complete) {
e.className = info.complete;
}
// update the incomplete progressbar
e = progressbar.incomplete;
display = info.percent < 100 ? 'block' : 'none';
if (e.style.display !== display) {
e.style.display = display;
}
if (e.className !== info.incomplete) {
e.className = info.incomplete;
}
};
TorrentRendererHelper.formatUL = function (t) {
return '▲' + Transmission.fmt.speedBps(t.getUploadSpeed());
};
TorrentRendererHelper.formatDL = function (t) {
return '▼' + Transmission.fmt.speedBps(t.getDownloadSpeed());
};
TorrentRendererHelper.formatETA = function (t) {
const eta = t.getETA();
if (eta < 0 || eta >= 999 * 60 * 60) {
return '';
}
return 'ETA: ' + Transmission.fmt.timeInterval(eta);
};
/****
*****
*****
****/
function TorrentRendererFull() {}
TorrentRendererFull.prototype = {
createRow: function () {
let root, name, peers, progressbar, details, image, button;
root = document.createElement('li');
root.className = 'torrent';
name = document.createElement('div');
name.className = 'torrent_name';
peers = document.createElement('div');
peers.className = 'torrent_peer_details';
progressbar = TorrentRendererHelper.createProgressbar('full');
details = document.createElement('div');
details.className = 'torrent_progress_details';
image = document.createElement('div');
button = document.createElement('a');
button.appendChild(image);
root.appendChild(name);
root.appendChild(peers);
root.appendChild(button);
root.appendChild(progressbar.element);
root.appendChild(details);
root._name_container = name;
root._peer_details_container = peers;
root._progress_details_container = details;
root._progressbar = progressbar;
root._pause_resume_button_image = image;
root._toggle_running_button = button;
return root;
},
getPeerDetails: function (t) {
let err,
peer_count,
webseed_count,
fmt = Transmission.fmt;
if ((err = t.getErrorMessage())) {
return err;
}
if (t.isDownloading()) {
peer_count = t.getPeersConnected();
webseed_count = t.getWebseedsSendingToUs();
if (webseed_count && peer_count) {
// Downloading from 2 of 3 peer(s) and 2 webseed(s)
return [
'Downloading from',
t.getPeersSendingToUs(),
'of',
fmt.countString('peer', 'peers', peer_count),
'and',
fmt.countString('web seed', 'web seeds', webseed_count),
'',
TorrentRendererHelper.formatDL(t),
TorrentRendererHelper.formatUL(t),
].join(' ');
} else if (webseed_count) {
// Downloading from 2 webseed(s)
return [
'Downloading from',
fmt.countString('web seed', 'web seeds', webseed_count),
'',
TorrentRendererHelper.formatDL(t),
TorrentRendererHelper.formatUL(t),
].join(' ');
} else {
// Downloading from 2 of 3 peer(s)
return [
'Downloading from',
t.getPeersSendingToUs(),
'of',
fmt.countString('peer', 'peers', peer_count),
'',
TorrentRendererHelper.formatDL(t),
TorrentRendererHelper.formatUL(t),
].join(' ');
}
}
if (t.isSeeding()) {
return [
'Seeding to',
t.getPeersGettingFromUs(),
'of',
fmt.countString('peer', 'peers', t.getPeersConnected()),
'-',
TorrentRendererHelper.formatUL(t),
].join(' ');
}
if (t.isChecking()) {
return [
'Verifying local data (',
Transmission.fmt.percentString(100.0 * t.getRecheckProgress()),
'% tested)',
].join('');
}
return t.getStateString();
},
getProgressDetails: function (controller, t) {
if (t.needsMetaData()) {
let MetaDataStatus = 'retrieving';
if (t.isStopped()) {
MetaDataStatus = 'needs';
}
const percent = 100 * t.getMetadataPercentComplete();
return [
'Magnetized transfer - ' + MetaDataStatus + ' metadata (',
Transmission.fmt.percentString(percent),
'%)',
].join('');
}
let c;
const sizeWhenDone = t.getSizeWhenDone();
const totalSize = t.getTotalSize();
const is_done = t.isDone() || t.isSeeding();
if (is_done) {
if (totalSize === sizeWhenDone) {
// seed: '698.05 MiB'
c = [Transmission.fmt.size(totalSize)];
} else {
// partial seed: '127.21 MiB of 698.05 MiB (18.2%)'
c = [
Transmission.fmt.size(sizeWhenDone),
' of ',
Transmission.fmt.size(t.getTotalSize()),
' (',
t.getPercentDoneStr(),
'%)',
];
}
// append UL stats: ', uploaded 8.59 GiB (Ratio: 12.3)'
c.push(
', uploaded ',
Transmission.fmt.size(t.getUploadedEver()),
' (Ratio ',
Transmission.fmt.ratioString(t.getUploadRatio()),
')'
);
} else {
// not done yet
c = [
Transmission.fmt.size(sizeWhenDone - t.getLeftUntilDone()),
' of ',
Transmission.fmt.size(sizeWhenDone),
' (',
t.getPercentDoneStr(),
'%)',
];
}
// maybe append eta
if (!t.isStopped() && (!is_done || t.seedRatioLimit(controller) > 0)) {
c.push(' - ');
const eta = t.getETA();
if (eta < 0 || eta >= 999 * 60 * 60 /* arbitrary */) {
c.push('remaining time unknown');
} else {
c.push(Transmission.fmt.timeInterval(t.getETA()), ' remaining');
}
}
return c.join('');
},
render: function (controller, t, root) {
// name
setTextContent(root._name_container, t.getName());
// progressbar
TorrentRendererHelper.renderProgressbar(controller, t, root._progressbar);
// peer details
const has_error = t.getError() !== Torrent._ErrNone;
let e = root._peer_details_container;
$(e).toggleClass('error', has_error);
setTextContent(e, this.getPeerDetails(t));
// progress details
e = root._progress_details_container;
setTextContent(e, this.getProgressDetails(controller, t));
// pause/resume button
const is_stopped = t.isStopped();
e = root._pause_resume_button_image;
e.alt = is_stopped ? 'Resume' : 'Pause';
e.className = is_stopped ? 'torrent_resume' : 'torrent_pause';
},
};
/****
*****
*****
****/
function TorrentRendererCompact() {}
TorrentRendererCompact.prototype = {
createRow: function () {
let progressbar, details, name, root;
progressbar = TorrentRendererHelper.createProgressbar('compact');
details = document.createElement('div');
details.className = 'torrent_peer_details compact';
name = document.createElement('div');
name.className = 'torrent_name compact';
root = document.createElement('li');
root.appendChild(progressbar.element);
root.appendChild(details);
root.appendChild(name);
root.className = 'torrent compact';
root._progressbar = progressbar;
root._details_container = details;
root._name_container = name;
return root;
},
getPeerDetails: function (t) {
let c;
if ((c = t.getErrorMessage())) {
return c;
}
if (t.isDownloading()) {
const have_dn = t.getDownloadSpeed() > 0;
const have_up = t.getUploadSpeed() > 0;
if (!have_up && !have_dn) {
return 'Idle';
}
let s = '';
if (!isMobileDevice) {
s = TorrentRendererHelper.formatETA(t) + ' ';
}
if (have_dn) {
s += TorrentRendererHelper.formatDL(t);
}
if (have_dn && have_up) {
s += ' ';
}
if (have_up) {
s += TorrentRendererHelper.formatUL(t);
}
return s;
}
if (t.isSeeding()) {
return [
'Ratio: ',
Transmission.fmt.ratioString(t.getUploadRatio()),
', ',
TorrentRendererHelper.formatUL(t),
].join('');
}
return t.getStateString();
},
render: function (controller, t, root) {
// name
const is_stopped = t.isStopped();
let e = root._name_container;
$(e).toggleClass('paused', is_stopped);
setTextContent(e, t.getName());
// peer details
const has_error = t.getError() !== Torrent._ErrNone;
e = root._details_container;
$(e).toggleClass('error', has_error);
setTextContent(e, this.getPeerDetails(t));
// progressbar
TorrentRendererHelper.renderProgressbar(controller, t, root._progressbar);
},
};
/****
*****
*****
****/
function TorrentRow(view, controller, torrent) {
this.initialize(view, controller, torrent);
}
TorrentRow.prototype = {
initialize: function (view, controller, torrent) {
const row = this;
this._view = view;
this._torrent = torrent;
this._element = view.createRow();
this.render(controller);
$(this._torrent).bind('dataChanged.torrentRowListener', function () {
row.render(controller);
});
},
getElement: function () {
return this._element;
},
render: function (controller) {
const tor = this.getTorrent();
if (tor) {
this._view.render(controller, tor, this.getElement());
}
},
isSelected: function () {
return this.getElement().className.indexOf('selected') !== -1;
},
getTorrent: function () {
return this._torrent;
},
getTorrentId: function () {
return this.getTorrent().getId();
},
};

View File

@ -1,614 +0,0 @@
/**
* Copyright © Mnemosyne LLC
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
function Torrent(data) {
this.initialize(data);
}
/***
****
**** Constants
****
***/
// Torrent.fields.status
Torrent._StatusStopped = 0;
Torrent._StatusCheckWait = 1;
Torrent._StatusCheck = 2;
Torrent._StatusDownloadWait = 3;
Torrent._StatusDownload = 4;
Torrent._StatusSeedWait = 5;
Torrent._StatusSeed = 6;
// Torrent.fields.seedRatioMode
Torrent._RatioUseGlobal = 0;
Torrent._RatioUseLocal = 1;
Torrent._RatioUnlimited = 2;
// Torrent.fields.error
Torrent._ErrNone = 0;
Torrent._ErrTrackerWarning = 1;
Torrent._ErrTrackerError = 2;
Torrent._ErrLocalError = 3;
// TrackerStats' announceState
Torrent._TrackerInactive = 0;
Torrent._TrackerWaiting = 1;
Torrent._TrackerQueued = 2;
Torrent._TrackerActive = 3;
Torrent.Fields = {};
// commonly used fields which only need to be loaded once,
// either on startup or when a magnet finishes downloading its metadata
// finishes downloading its metadata
Torrent.Fields.Metadata = ['addedDate', 'name', 'totalSize'];
// commonly used fields which need to be periodically refreshed
Torrent.Fields.Stats = [
'error',
'errorString',
'eta',
'isFinished',
'isStalled',
'leftUntilDone',
'metadataPercentComplete',
'peersConnected',
'peersGettingFromUs',
'peersSendingToUs',
'percentDone',
'queuePosition',
'rateDownload',
'rateUpload',
'recheckProgress',
'seedRatioMode',
'seedRatioLimit',
'sizeWhenDone',
'status',
'trackers',
'downloadDir',
'uploadedEver',
'uploadRatio',
'webseedsSendingToUs',
];
// fields used by the inspector which only need to be loaded once
Torrent.Fields.InfoExtra = [
'comment',
'creator',
'dateCreated',
'files',
'hashString',
'isPrivate',
'pieceCount',
'pieceSize',
];
// fields used in the inspector which need to be periodically refreshed
Torrent.Fields.StatsExtra = [
'activityDate',
'corruptEver',
'desiredAvailable',
'downloadedEver',
'fileStats',
'haveUnchecked',
'haveValid',
'peers',
'startDate',
'trackerStats',
];
/***
****
**** Methods
****
***/
Torrent.prototype = {
initialize: function (data) {
this.fields = {};
this.fieldObservers = {};
this.refresh(data);
},
notifyOnFieldChange: function (field, callback) {
this.fieldObservers[field] = this.fieldObservers[field] || [];
this.fieldObservers[field].push(callback);
},
setField: function (o, name, value) {
let i, observer;
if (o[name] === value) {
return false;
}
if (o == this.fields && this.fieldObservers[name] && this.fieldObservers[name].length) {
for (i = 0; (observer = this.fieldObservers[name][i]); ++i) {
observer.call(this, value, o[name], name);
}
}
o[name] = value;
return true;
},
// fields.files is an array of unions of RPC's "files" and "fileStats" objects.
updateFiles: function (files) {
let changed = false;
const myfiles = this.fields.files || [];
const keys = ['length', 'name', 'bytesCompleted', 'wanted', 'priority'];
let i, f, j, key, myfile;
for (i = 0; (f = files[i]); ++i) {
myfile = myfiles[i] || {};
for (j = 0; (key = keys[j]); ++j) {
if (key in f) {
changed |= this.setField(myfile, key, f[key]);
}
}
myfiles[i] = myfile;
}
this.fields.files = myfiles;
return changed;
},
collateTrackers: function (trackers) {
return trackers.map((t) => t.announce.toLowerCase()).join('\t');
},
refreshFields: function (data) {
let key;
let changed = false;
for (key in data) {
switch (key) {
case 'files':
case 'fileStats': // merge files and fileStats together
changed |= this.updateFiles(data[key]);
break;
case 'trackerStats': // 'trackerStats' is a superset of 'trackers'...
changed |= this.setField(this.fields, 'trackers', data[key]);
break;
case 'trackers': // ...so only save 'trackers' if we don't have it already
if (!(key in this.fields)) {
changed |= this.setField(this.fields, key, data[key]);
}
break;
default:
changed |= this.setField(this.fields, key, data[key]);
}
}
return changed;
},
refresh: function (data) {
if (this.refreshFields(data)) {
$(this).trigger('dataChanged', this);
}
},
/****
*****
****/
// simple accessors
getComment: function () {
return this.fields.comment;
},
getCreator: function () {
return this.fields.creator;
},
getDateAdded: function () {
return this.fields.addedDate;
},
getDateCreated: function () {
return this.fields.dateCreated;
},
getDesiredAvailable: function () {
return this.fields.desiredAvailable;
},
getDownloadDir: function () {
return this.fields.downloadDir;
},
getDownloadSpeed: function () {
return this.fields.rateDownload;
},
getDownloadedEver: function () {
return this.fields.downloadedEver;
},
getError: function () {
return this.fields.error;
},
getErrorString: function () {
return this.fields.errorString;
},
getETA: function () {
return this.fields.eta;
},
getFailedEver: function (i) {
return this.fields.corruptEver;
},
getFile: function (i) {
return this.fields.files[i];
},
getFileCount: function () {
return this.fields.files ? this.fields.files.length : 0;
},
getHashString: function () {
return this.fields.hashString;
},
getHave: function () {
return this.getHaveValid() + this.getHaveUnchecked();
},
getHaveUnchecked: function () {
return this.fields.haveUnchecked;
},
getHaveValid: function () {
return this.fields.haveValid;
},
getId: function () {
return this.fields.id;
},
getLastActivity: function () {
return this.fields.activityDate;
},
getLeftUntilDone: function () {
return this.fields.leftUntilDone;
},
getMetadataPercentComplete: function () {
return this.fields.metadataPercentComplete;
},
getName: function () {
return this.fields.name || 'Unknown';
},
getPeers: function () {
return this.fields.peers;
},
getPeersConnected: function () {
return this.fields.peersConnected;
},
getPeersGettingFromUs: function () {
return this.fields.peersGettingFromUs;
},
getPeersSendingToUs: function () {
return this.fields.peersSendingToUs;
},
getPieceCount: function () {
return this.fields.pieceCount;
},
getPieceSize: function () {
return this.fields.pieceSize;
},
getPrivateFlag: function () {
return this.fields.isPrivate;
},
getQueuePosition: function () {
return this.fields.queuePosition;
},
getRecheckProgress: function () {
return this.fields.recheckProgress;
},
getSeedRatioLimit: function () {
return this.fields.seedRatioLimit;
},
getSeedRatioMode: function () {
return this.fields.seedRatioMode;
},
getSizeWhenDone: function () {
return this.fields.sizeWhenDone;
},
getStartDate: function () {
return this.fields.startDate;
},
getStatus: function () {
return this.fields.status;
},
getTotalSize: function () {
return this.fields.totalSize;
},
getTrackers: function () {
return this.fields.trackers;
},
getUploadSpeed: function () {
return this.fields.rateUpload;
},
getUploadRatio: function () {
return this.fields.uploadRatio;
},
getUploadedEver: function () {
return this.fields.uploadedEver;
},
getWebseedsSendingToUs: function () {
return this.fields.webseedsSendingToUs;
},
isFinished: function () {
return this.fields.isFinished;
},
// derived accessors
hasExtraInfo: function () {
return 'hashString' in this.fields;
},
isSeeding: function () {
return this.getStatus() === Torrent._StatusSeed;
},
isStopped: function () {
return this.getStatus() === Torrent._StatusStopped;
},
isChecking: function () {
return this.getStatus() === Torrent._StatusCheck;
},
isDownloading: function () {
return this.getStatus() === Torrent._StatusDownload;
},
isQueued: function () {
return (
this.getStatus() === Torrent._StatusDownloadWait ||
this.getStatus() === Torrent._StatusSeedWait
);
},
isDone: function () {
return this.getLeftUntilDone() < 1;
},
needsMetaData: function () {
return this.getMetadataPercentComplete() < 1;
},
getActivity: function () {
return this.getDownloadSpeed() + this.getUploadSpeed();
},
getPercentDoneStr: function () {
return Transmission.fmt.percentString(100 * this.getPercentDone());
},
getPercentDone: function () {
return this.fields.percentDone;
},
getStateString: function () {
switch (this.getStatus()) {
case Torrent._StatusStopped:
return this.isFinished() ? 'Seeding complete' : 'Paused';
case Torrent._StatusCheckWait:
return 'Queued for verification';
case Torrent._StatusCheck:
return 'Verifying local data';
case Torrent._StatusDownloadWait:
return 'Queued for download';
case Torrent._StatusDownload:
return 'Downloading';
case Torrent._StatusSeedWait:
return 'Queued for seeding';
case Torrent._StatusSeed:
return 'Seeding';
case null:
case undefined:
return 'Unknown';
default:
return 'Error';
}
},
seedRatioLimit: function (controller) {
switch (this.getSeedRatioMode()) {
case Torrent._RatioUseGlobal:
return controller.seedRatioLimit();
case Torrent._RatioUseLocal:
return this.getSeedRatioLimit();
default:
return -1;
}
},
getErrorMessage: function () {
const str = this.getErrorString();
switch (this.getError()) {
case Torrent._ErrTrackerWarning:
return 'Tracker returned a warning: ' + str;
case Torrent._ErrTrackerError:
return 'Tracker returned an error: ' + str;
case Torrent._ErrLocalError:
return 'Error: ' + str;
default:
return null;
}
},
getCollatedName: function () {
const f = this.fields;
if (!f.collatedName && f.name) {
f.collatedName = f.name.toLowerCase();
}
return f.collatedName || '';
},
getCollatedTrackers: function () {
const f = this.fields;
if (!f.collatedTrackers && f.trackers) {
f.collatedTrackers = this.collateTrackers(f.trackers);
}
return f.collatedTrackers || '';
},
/****
*****
****/
testState: function (state) {
const s = this.getStatus();
switch (state) {
case Prefs._FilterActive:
return (
this.getPeersGettingFromUs() > 0 ||
this.getPeersSendingToUs() > 0 ||
this.getWebseedsSendingToUs() > 0 ||
this.isChecking()
);
case Prefs._FilterSeeding:
return s === Torrent._StatusSeed || s === Torrent._StatusSeedWait;
case Prefs._FilterDownloading:
return s === Torrent._StatusDownload || s === Torrent._StatusDownloadWait;
case Prefs._FilterPaused:
return this.isStopped();
case Prefs._FilterFinished:
return this.isFinished();
default:
return true;
}
},
/**
* @param filter one of Prefs._Filter*
* @param search substring to look for, or null
* @return true if it passes the test, false if it fails
*/
test: function (state, search, tracker) {
// flter by state...
let pass = this.testState(state);
// maybe filter by text...
if (pass && search && search.length) {
pass = this.getCollatedName().indexOf(search.toLowerCase()) !== -1;
}
// maybe filter by tracker...
if (pass && tracker && tracker.length) {
pass = this.getCollatedTrackers().indexOf(tracker) !== -1;
}
return pass;
},
};
/***
****
**** SORTING
****
***/
Torrent.compareById = function (ta, tb) {
return ta.getId() - tb.getId();
};
Torrent.compareByName = function (ta, tb) {
return ta.getCollatedName().localeCompare(tb.getCollatedName()) || Torrent.compareById(ta, tb);
};
Torrent.compareByQueue = function (ta, tb) {
return ta.getQueuePosition() - tb.getQueuePosition();
};
Torrent.compareByAge = function (ta, tb) {
const a = ta.getDateAdded();
const b = tb.getDateAdded();
return b - a || Torrent.compareByQueue(ta, tb);
};
Torrent.compareByState = function (ta, tb) {
const a = ta.getStatus();
const b = tb.getStatus();
return b - a || Torrent.compareByQueue(ta, tb);
};
Torrent.compareByActivity = function (ta, tb) {
const a = ta.getActivity();
const b = tb.getActivity();
return b - a || Torrent.compareByState(ta, tb);
};
Torrent.compareByRatio = function (ta, tb) {
const a = ta.getUploadRatio();
const b = tb.getUploadRatio();
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
return Torrent.compareByState(ta, tb);
};
Torrent.compareByProgress = function (ta, tb) {
const a = ta.getPercentDone();
const b = tb.getPercentDone();
return a - b || Torrent.compareByRatio(ta, tb);
};
Torrent.compareBySize = function (ta, tb) {
const a = ta.getTotalSize();
const b = tb.getTotalSize();
return a - b || Torrent.compareByName(ta, tb);
};
Torrent.compareTorrents = function (a, b, sortMethod, sortDirection) {
let i;
switch (sortMethod) {
case Prefs._SortByActivity:
i = Torrent.compareByActivity(a, b);
break;
case Prefs._SortByAge:
i = Torrent.compareByAge(a, b);
break;
case Prefs._SortByQueue:
i = Torrent.compareByQueue(a, b);
break;
case Prefs._SortByProgress:
i = Torrent.compareByProgress(a, b);
break;
case Prefs._SortBySize:
i = Torrent.compareBySize(a, b);
break;
case Prefs._SortByState:
i = Torrent.compareByState(a, b);
break;
case Prefs._SortByRatio:
i = Torrent.compareByRatio(a, b);
break;
default:
i = Torrent.compareByName(a, b);
break;
}
if (sortDirection === Prefs._SortDescending) {
i = -i;
}
return i;
};
/**
* @param torrents an array of Torrent objects
* @param sortMethod one of Prefs._SortBy*
* @param sortDirection Prefs._SortAscending or Prefs._SortDescending
*/
Torrent.sortTorrents = function (torrents, sortMethod, sortDirection) {
switch (sortMethod) {
case Prefs._SortByActivity:
torrents.sort(this.compareByActivity);
break;
case Prefs._SortByAge:
torrents.sort(this.compareByAge);
break;
case Prefs._SortByQueue:
torrents.sort(this.compareByQueue);
break;
case Prefs._SortByProgress:
torrents.sort(this.compareByProgress);
break;
case Prefs._SortBySize:
torrents.sort(this.compareBySize);
break;
case Prefs._SortByState:
torrents.sort(this.compareByState);
break;
case Prefs._SortByRatio:
torrents.sort(this.compareByRatio);
break;
default:
torrents.sort(this.compareByName);
break;
}
if (sortDirection === Prefs._SortDescending) {
torrents.reverse();
}
return torrents;
};

File diff suppressed because it is too large Load Diff

View File

@ -3,60 +3,51 @@
"main": "index.js",
"repository": "https://github.com/transmission/transmission",
"license": "MIT",
"babel": {
"plugins": [
"@babel/plugin-proposal-class-properties"
],
"presets": []
},
"eslintConfig": {
"env": {
"browser": true,
"es6": true,
"jquery": true
},
"extends": "eslint:recommended",
"parser": "@babel/eslint-parser",
"parserOptions": {
"sourceType": "module"
},
"rules": {
"curly": ["error", "all"],
"no-undef": "off",
"no-unused-vars": "off",
"no-var": "off",
"prefer-const": "off",
"semi": ["error", "always"]
}
},
"prettier": {
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"vueIndentScriptAndStyle": false
},
"scripts": {
"lint": "prettier --loglevel warn --check javascript/*js && eslint javascript/*js",
"lint:fix": "prettier --loglevel warn -w javascript/*js && eslint --fix javascript/*js"
"build": "webpack --config webpack.config.js",
"css": "sass --no-source-map style/",
"css:map": "sass style/",
"lint": "run-p --silent lint:eslint lint:stylelint lint:prettier",
"lint:fix": "run-s lint:eslint:fix lint:stylelint:fix lint:prettier:fix",
"lint:eslint": "eslint src/*.js",
"lint:eslint:fix": "eslint --fix src/*.js",
"lint:prettier": "prettier --loglevel warn --check package.json public_html/index.html style/*scss src/*.js",
"lint:prettier:fix": "prettier --loglevel warn -w package.json public_html/index.html style/*scss src/*.js",
"lint:stylelint": "stylelint style/*scss",
"lint:stylelint:fix": "stylelint --fix style/*scss"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/eslint-parser": "^7.11.5",
"@babel/plugin-proposal-class-properties": "^7.10.4",
"eslint": "^7.9.0",
"prettier": "^2.1.2"
"css-loader": "^4.3.0",
"eslint": "^7.11.0",
"eslint-plugin-sonarjs": "^0.5.0",
"eslint-plugin-unicorn": "^23.0.0",
"file-loader": "^6.1.0",
"img-optimize-loader": "^1.0.7",
"mini-css-extract-plugin": "^0.11.3",
"node-sass": "^4.14.1",
"npm-run-all": "^4.1.5",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"prettier": "^2.1.2",
"sass": "^1.26.11",
"sass-loader": "^10.0.2",
"style-loader": "^1.2.1",
"stylelint": "^13.7.1",
"stylelint-config-prettier": "^8.0.2",
"stylelint-config-primer": "^9.2.1",
"stylelint-config-sass-guidelines": "^7.1.0",
"stylelint-config-standard": "^20.0.0",
"svgo": "^1.3.2",
"svgo-loader": "^2.2.1",
"terser-webpack-plugin": "^4.2.2",
"url-loader": "^4.1.0",
"webpack": "^4.44.2",
"webpack-bundle-analyzer": "^3.9.0",
"webpack-cli": "^3.3.12"
},
"dependencies": {
"lodash.isequal": "^4.5.0"
}
}

3
web/prettier.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
"singleQuote": true,
};

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 683 B

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

106
web/public_html/index.html Executable file
View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=8,IE=9,IE=10" />
<!-- ticket #4555 -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<link href="./images/favicon.ico" rel="icon" />
<link href="./images/favicon.png" rel="shortcut icon" />
<link rel="apple-touch-icon" href="./images/webclip-icon.png" />
<script type="text/javascript" src="./transmission-app.js"></script>
<title>Transmission Web Interface</title>
</head>
<body>
<div class="mainwin">
<header class="mainwin-toolbar" id="mainwin-toolbar">
<button
aria-keyshortcuts="Alt+O"
aria-label="Open torrent"
class="toolbar-button"
data-action="open-torrent"
id="toolbar-open"
></button>
<button
aria-keyshortcuts="Alt+Delete"
aria-label="Remove selected torrents"
class="toolbar-button"
data-action="remove-selected-torrents"
href="#"
id="toolbar-remove"
></button>
<div href="#" class="toolbar-separator"></div>
<button
aria-keyshortcuts="Alt+R"
aria-label="Resume selected torrents"
class="toolbar-button"
data-action="resume-selected-torrents"
id="toolbar-start"
></button>
<button
aria-keyshortcuts="Alt+P"
aria-label="Pause selected torrents"
class="toolbar-button"
data-action="pause-selected-torrents"
id="toolbar-pause"
></button>
<div href="#" class="toolbar-separator"></div>
<button
aria-keyshortcuts="Alt+I"
aria-label="Toggle inspector"
class="toolbar-button"
data-action="show-inspector"
id="toolbar-inspector"
></button>
<div class="toolbar-separator"></div>
<button
aria-keyshortcuts="Alt+M"
aria-label="Show sidebar menu"
class="toolbar-button"
data-action="show-overflow-menu"
id="toolbar-overflow"
></button>
</header>
<!-- class mainwin-toolbar -->
<header class="mainwin-filterbar" id="statusbar">
<span>Show</span>
<select id="filter-mode">
<option value="all">All</option>
<option value="active">Active</option>
<option value="downloading">Downloading</option>
<option value="seeding">Seeding</option>
<option value="paused">Paused</option>
<option value="finished">Finished</option>
</select>
<select id="filter-tracker"></select>
<input type="search" id="torrent-search" placeholder="Filter" />
<span id="filter-count">&nbsp;</span>
<span class="flex"></span>
<div class="speed-dn-icon"></div>
<div id="speed-dn-label"></div>
<div class="speed-up-icon"></div>
<div id="speed-up-label"></div>
</header>
<!-- class mainwin-filterbar -->
<main class="mainwin-workarea">
<div id="torrent-container">
<ul class="torrent-list flex" id="torrent-list"></ul>
</div>
</main>
<!-- class="mainwin-workarea" -->
<iframe
name="torrent-upload-frame"
id="torrent-upload-frame"
src="about:blank"
></iframe>
</div>
<!-- class mainwin -->
</body>
</html>

58
web/src/about-dialog.js Normal file
View File

@ -0,0 +1,58 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { createDialogContainer } from './utils.js';
export class AboutDialog extends EventTarget {
constructor(version_info) {
super();
this.elements = AboutDialog._create(version_info);
this.elements.dismiss.addEventListener('click', () => this._onDismiss());
document.body.append(this.elements.root);
this.elements.dismiss.focus();
}
close() {
this.elements.root.remove();
this.dispatchEvent(new Event('close'));
delete this.elements;
}
_onDismiss() {
this.close();
}
static _create(version_info) {
const elements = createDialogContainer('about-dialog');
elements.root.setAttribute('aria-label', 'About transmission');
elements.heading.textContent = 'Transmission';
elements.dismiss.textContent = 'Close';
let e = document.createElement('div');
e.classList.add('about-dialog-version-number');
e.textContent = version_info.version;
elements.heading.append(e);
e = document.createElement('div');
e.classList.add('about-dialog-version-checksum');
e.textContent = version_info.checksum;
elements.heading.append(e);
e = document.createElement('div');
e.textContent = 'A fast and easy bitTorrent client';
elements.workarea.append(e);
e = document.createElement('div');
e.textContent = 'Copyright © The Transmission Project';
elements.workarea.append(e);
elements.confirm.remove();
delete elements.confirm;
return elements;
}
}

216
web/src/action-manager.js Normal file
View File

@ -0,0 +1,216 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
export class ActionManager extends EventTarget {
constructor() {
super();
this.actions = Object.seal({
'deselect-all': {
enabled: false,
shortcut: 'Control+A',
text: 'Deselect all',
},
'move-bottom': { enabled: false, text: 'Move to the back of the queue' },
'move-down': { enabled: false, text: 'Move down in the queue' },
'move-top': { enabled: false, text: 'Move to the front of the queue' },
'move-up': { enabled: false, text: 'Move up in the queue' },
'open-torrent': {
enabled: true,
shortcut: 'Alt+O',
text: 'Open torrent…',
},
'pause-all-torrents': { enabled: false, text: 'Pause all' },
'pause-selected-torrents': {
enabled: false,
shortcut: 'Alt+U',
text: 'Pause',
},
'reannounce-selected-torrents': {
enabled: false,
text: 'Ask tracker for more peers',
},
'remove-selected-torrents': { enabled: false, text: 'Remove from list…' },
'resume-selected-torrents': {
enabled: false,
shortcut: 'Alt+R',
text: 'Resume',
},
'resume-selected-torrents-now': { enabled: false, text: 'Resume now' },
'select-all': { enabled: false, shortcut: 'Alt+A', text: 'Select all' },
'show-about-dialog': { enabled: true, text: 'About' },
'show-inspector': {
enabled: true,
shortcut: 'Alt+I',
text: 'Torrent Inspector',
},
'show-move-dialog': {
enabled: false,
shortcut: 'Alt+L',
text: 'Set location…',
},
'show-overflow-menu': { enabled: true, text: 'More options…' },
'show-preferences-dialog': {
enabled: true,
shortcut: 'Alt+P',
text: 'Edit preferences',
},
'show-rename-dialog': {
enabled: false,
shortcut: 'Alt+N',
text: 'Rename…',
},
'show-shortcuts-dialog': { enabled: true, text: 'Keyboard shortcuts' },
'show-statistics-dialog': {
enabled: true,
shortcut: 'Alt+S',
text: 'Statistics',
},
'start-all-torrents': { enabled: false, text: 'Start all' },
'toggle-compact-rows': { enabled: true, text: 'Compact rows' },
'trash-selected-torrents': {
enabled: false,
text: 'Trash data and remove from list…',
},
'verify-selected-torrents': {
enabled: false,
shortcut: 'Alt+V',
text: 'Verify local data',
},
});
}
click(name) {
if (this.isEnabled(name)) {
const event_ = new Event('click');
event_.action = name;
this.dispatchEvent(event_);
}
}
getActionForShortcut(shortcut) {
for (const [name, properties] of Object.entries(this.actions)) {
if (shortcut === properties.shortcut) {
return name;
}
}
return null;
}
// return a map of shortcuts to action names
allShortcuts() {
return new Map(
Object.entries(this.actions)
.filter(([, properties]) => properties.shortcut)
.map(([name, properties]) => [properties.shortcut, name])
);
}
isEnabled(name) {
return this._getAction(name).enabled;
}
text(name) {
return this._getAction(name).text;
}
keyshortcuts(name) {
return this._getAction(name).shortcut;
}
update(event_) {
const counts = ActionManager._recount(event_.selected, event_.nonselected);
this._updateStates(counts);
}
_getAction(name) {
const action = this.actions[name];
if (!action) {
throw new Error(`no such action: ${name}`);
}
return action;
}
static _recount(selected, nonselected) {
const test = (tor) => tor.isStopped();
const total = selected.length + nonselected.length;
const selected_paused = selected.filter(test).length;
const selected_active = selected.length - selected_paused;
const nonselected_paused = nonselected.filter(test).length;
const nonselected_active = nonselected.length - nonselected_paused;
const paused = selected_paused + nonselected_paused;
const active = selected_active + nonselected_active;
const selected_queued = selected.filter((tor) => tor.isQueued()).length;
return {
active,
nonselected_active,
nonselected_paused,
paused,
selected: selected.length,
selected_active,
selected_paused,
selected_queued,
total,
};
}
_updateStates(counts) {
const set_enabled = (enabled, actions) => {
for (const action of actions) {
this._updateActionState(action, enabled);
}
};
set_enabled(counts.selected_paused > 0, ['resume-selected-torrents']);
set_enabled(counts.paused > 0, ['start-all-torrents']);
set_enabled(counts.active > 0, ['pause-all-torrents']);
set_enabled(counts.selected_paused > 0 || counts.selected_queued > 0, [
'resume-selected-torrents-now',
]);
set_enabled(counts.selected_active > 0, [
'pause-selected-torrents',
'reannounce-selected-torrents',
]);
set_enabled(counts.selected > 0, [
'deselect-all',
'move-bottom',
'move-down',
'move-top',
'move-up',
'show-inspector',
'show-move-dialog',
'remove-selected-torrents',
'trash-selected-torrents',
'verify-selected-torrents',
]);
set_enabled(counts.selected === 1, ['show-rename-dialog']);
set_enabled(counts.selected < counts.total, ['select-all']);
}
_updateActionState(name, enabled) {
const action = this.actions[name];
if (!action) {
throw new Error(`no such action: ${name}`);
}
if (action.enabled !== enabled) {
action.enabled = enabled;
const event = new Event('change');
event.action = name;
event.enabled = enabled;
this.dispatchEvent(event);
}
}
}

46
web/src/alert-dialog.js Normal file
View File

@ -0,0 +1,46 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { createDialogContainer } from './utils.js';
export class AlertDialog extends EventTarget {
constructor(options) {
super();
// options: heading, message
this.elements = AlertDialog._create(options);
this.elements.dismiss.addEventListener('click', () => this._onDismiss());
this.options = options;
document.body.append(this.elements.root);
this.elements.dismiss.focus();
}
close() {
if (!this.closed) {
this.elements.root.remove();
this.dispatchEvent(new Event('close'));
for (const key of Object.keys(this)) {
delete this[key];
}
this.closed = true;
}
}
_onDismiss() {
this.close();
}
static _create(options) {
const { heading, message } = options;
const elements = createDialogContainer('confirm-dialog');
elements.confirm.remove();
delete elements.confirm;
elements.heading.textContent = heading;
elements.workarea.textContent = message;
return elements;
}
}

102
web/src/context-menu.js Normal file
View File

@ -0,0 +1,102 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { setEnabled } from './utils.js';
export class ContextMenu extends EventTarget {
constructor(action_manager) {
super();
this.action_listener = this._update.bind(this);
this.action_manager = action_manager;
this.action_manager.addEventListener('change', this.action_listener);
Object.assign(this, this._create());
this.show();
}
show() {
for (const [action, item] of Object.entries(this.actions)) {
setEnabled(item, this.action_manager.isEnabled(action));
}
document.body.append(this.root);
}
close() {
if (!this.closed) {
this.action_manager.removeEventListener('change', this.action_listener);
this.root.remove();
this.dispatchEvent(new Event('close'));
for (const key of Object.keys(this)) {
delete this[key];
}
this.closed = true;
}
}
_update(event_) {
const e = this.actions[event_.action];
if (e) {
setEnabled(e, event_.enabled);
}
}
_create() {
const root = document.createElement('div');
root.role = 'menu';
root.classList.add('context-menu', 'popup');
const actions = {};
const add_item = (action) => {
const item = document.createElement('div');
const text = this.action_manager.text(action);
item.role = 'menuitem';
item.classList.add('context-menuitem');
item.dataset.action = action;
item.textContent = text;
const keyshortcuts = this.action_manager.keyshortcuts(action);
if (keyshortcuts) {
item.setAttribute('aria-keyshortcuts', keyshortcuts);
}
item.addEventListener('click', () => {
this.action_manager.click(action);
this.close();
});
actions[action] = item;
root.append(item);
};
const add_separator = () => {
const item = document.createElement('div');
item.classList.add('context-menu-separator');
root.append(item);
};
add_item('resume-selected-torrents');
add_item('resume-selected-torrents-now');
add_item('pause-selected-torrents');
add_separator();
add_item('move-top');
add_item('move-up');
add_item('move-down');
add_item('move-bottom');
add_separator();
add_item('remove-selected-torrents');
add_item('trash-selected-torrents');
add_separator();
add_item('verify-selected-torrents');
add_item('show-move-dialog');
add_item('show-rename-dialog');
add_separator();
add_item('reannounce-selected-torrents');
add_separator();
add_item('select-all');
add_item('deselect-all');
return { actions, root };
}
}

197
web/src/file-row.js Normal file
View File

@ -0,0 +1,197 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { Formatter } from './formatter.js';
import { makeUUID, setChecked, setEnabled, setTextContent } from './utils.js';
export class FileRow extends EventTarget {
isDone() {
return this.fields.have >= this.fields.size;
}
isEditable() {
return this.fields.torrent.getFileCount() > 1 && !this.isDone();
}
refreshWantedHTML() {
const e = this.elements.root;
e.classList.toggle('skip', !this.fields.isWanted);
e.classList.toggle('complete', this.isDone());
setEnabled(e.checkbox, this.isEditable());
e.checkbox.checked = this.fields.isWanted;
}
refreshProgressHTML() {
const { size, have } = this.fields;
const pct = 100 * (size ? have / size : 1);
const fmt = Formatter;
const c = `${fmt.size(have)} of ${fmt.size(size)} (${fmt.percentString(
pct
)}%)`;
setTextContent(this.elements.progress, c);
}
refresh() {
let have = 0;
let high = false;
let low = false;
let normal = false;
let size = 0;
let wanted = false;
// loop through the file_indices that affect this row
const files = this.fields.torrent.getFiles();
for (const index of this.fields.indices) {
const file = files[index];
have += file.bytesCompleted;
size += file.length;
wanted |= file.wanted;
switch (file.priority) {
case -1:
low = true;
break;
case 1:
high = true;
break;
default:
normal = true;
break;
}
}
setChecked(this.elements.priority_low_button, low);
setChecked(this.elements.priority_normal_button, normal);
setChecked(this.elements.priority_high_button, high);
if (this.fields.have !== have || this.fields.size !== size) {
this.fields.have = have;
this.fields.size = size;
this.refreshProgressHTML();
}
if (this.fields.isWanted !== wanted) {
this.fields.isWanted = wanted;
this.refreshWantedHTML();
}
}
fireWantedChanged(wanted) {
const e = new Event('wantedToggled');
e.indices = [...this.fields.indices];
e.wanted = wanted;
this.dispatchEvent(e);
}
firePriorityChanged(priority) {
const e = new Event('priorityToggled');
e.indices = [...this.fields.indices];
e.priority = priority;
this.dispatchEvent(e);
}
createRow(torrent, depth, name, even) {
const root = document.createElement('li');
root.classList.add(
'inspector-torrent-file-list-entry',
even ? 'even' : 'odd'
);
this.elements.root = root;
let e = document.createElement('input');
const check_id = makeUUID();
e.type = 'checkbox';
e.className = 'file-wanted-control';
e.title = 'Download file';
e.id = check_id;
e.addEventListener('change', (event_) =>
this.fireWantedChanged(event_.target.checked)
);
root.checkbox = e;
root.append(e);
e = document.createElement('label');
e.className = 'inspector-torrent-file-list-entry-name';
e.setAttribute('for', check_id);
setTextContent(e, name);
root.append(e);
e = document.createElement('div');
e.className = 'inspector-torrent-file-list-entry-progress';
root.append(e);
this.elements.progress = e;
e = document.createElement('div');
e.className = 'file-priority-radiobox';
const box = e;
const priority_click_listener = (event_) =>
this.firePriorityChanged(event_.target.value);
e = document.createElement('input');
e.type = 'radio';
e.value = -1;
e.className = 'low';
e.title = 'Low Priority';
e.addEventListener('click', priority_click_listener);
this.elements.priority_low_button = e;
box.append(e);
e = document.createElement('input');
e.type = 'radio';
e.value = 0;
e.className = 'normal';
e.title = 'Normal Priority';
e.addEventListener('click', priority_click_listener);
this.elements.priority_normal_button = e;
box.append(e);
e = document.createElement('input');
e.type = 'radio';
e.value = 1;
e.title = 'High Priority';
e.className = 'high';
e.addEventListener('click', priority_click_listener);
this.elements.priority_high_button = e;
box.append(e);
root.append(box);
root.style.paddingLeft = `${depth * 20}px`;
this.refresh();
}
/// PUBLIC
getElement() {
return this.elements.root;
}
constructor(torrent, depth, name, indices, even) {
super();
this.fields = {
have: 0,
indices,
isWanted: true,
// priorityHigh: false,
// priorityLow: false,
// priorityNormal: false,
size: 0,
torrent,
};
this.elements = {
priority_high_button: null,
priority_low_button: null,
priority_normal_button: null,
progress: null,
root: null,
};
this.createRow(torrent, depth, name, even);
}
}

184
web/src/formatter.js Normal file
View File

@ -0,0 +1,184 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
const plural_rules = new Intl.PluralRules();
const current_locale = plural_rules.resolvedOptions().locale;
const number_format = new Intl.NumberFormat(current_locale);
const kilo = 1000;
const mem_formatters = [
new Intl.NumberFormat(current_locale, { style: 'unit', unit: 'byte' }),
new Intl.NumberFormat(current_locale, { style: 'unit', unit: 'kilobyte' }),
new Intl.NumberFormat(current_locale, { style: 'unit', unit: 'megabyte' }),
new Intl.NumberFormat(current_locale, { style: 'unit', unit: 'gigabyte' }),
new Intl.NumberFormat(current_locale, { style: 'unit', unit: 'terabyte' }),
new Intl.NumberFormat(current_locale, { style: 'unit', unit: 'petabyte' }),
];
const fmt_kBps = new Intl.NumberFormat(current_locale, {
style: 'unit',
unit: 'kilobyte-per-second',
});
const fmt_MBps = new Intl.NumberFormat(current_locale, {
style: 'unit',
unit: 'megabyte-per-second',
});
export class Formatter {
static countString(msgid, msgid_plural, n) {
return `${this.number(n)} ${this.ngettext(msgid, msgid_plural, n)}`;
}
// Formats the a memory size into a human-readable string
// @param {Number} bytes the filesize in bytes
// @return {String} human-readable string
static mem(bytes) {
if (bytes < 0) {
return 'Unknown';
}
if (bytes === 0) {
return 'None';
}
let size = bytes;
for (const nf of mem_formatters) {
if (size < kilo) {
return nf.format(Math.floor(size));
}
size /= kilo;
}
return 'E2BIG';
}
static ngettext(msgid, msgid_plural, n) {
return plural_rules.select(n) === 'one' ? msgid : msgid_plural;
}
// format a percentage to a string
static percentString(x) {
const decimal_places = x < 100 ? 1 : 0;
return this._toTruncFixed(x, decimal_places);
}
/*
* Format a ratio to a string
*/
static ratioString(x) {
if (x === -1) {
return 'None';
}
if (x === -2) {
return '&infin;';
}
return this.percentString(x);
}
/**
* Formats the a disk capacity or file size into a human-readable string
* @param {Number} bytes the filesize in bytes
* @return {String} human-readable string
*/
static size(bytes) {
return this.mem(bytes);
}
static speed(KBps) {
return KBps < 999.95 ? fmt_kBps.format(KBps) : fmt_MBps.format(KBps / 1000);
}
static speedBps(Bps) {
return this.speed(this.toKBps(Bps));
}
static timeInterval(seconds) {
const days = Math.floor(seconds / 86400);
if (days) {
return this.countString('day', 'days', days);
}
const hours = Math.floor((seconds % 86400) / 3600);
if (hours) {
return this.countString('hour', 'hours', hours);
}
const minutes = Math.floor((seconds % 3600) / 60);
if (minutes) {
return this.countString('minute', 'minutes', minutes);
}
seconds = Math.floor(seconds % 60);
return this.countString('second', 'seconds', seconds);
}
static timestamp(seconds) {
if (!seconds) {
return 'N/A';
}
const myDate = new Date(seconds * 1000);
const now = new Date();
let date = '';
let time = '';
const sameYear = now.getFullYear() === myDate.getFullYear();
const sameMonth = now.getMonth() === myDate.getMonth();
const dateDiff = now.getDate() - myDate.getDate();
if (sameYear && sameMonth && Math.abs(dateDiff) <= 1) {
if (dateDiff === 0) {
date = 'Today';
} else if (dateDiff === 1) {
date = 'Yesterday';
} else {
date = 'Tomorrow';
}
} else {
date = myDate.toDateString();
}
let hours = myDate.getHours();
let period = 'AM';
if (hours > 12) {
hours = hours - 12;
period = 'PM';
}
if (hours === 0) {
hours = 12;
}
if (hours < 10) {
hours = `0${hours}`;
}
let minutes = myDate.getMinutes();
if (minutes < 10) {
minutes = `0${minutes}`;
}
seconds = myDate.getSeconds();
if (seconds < 10) {
seconds = `0${seconds}`;
}
time = [hours, minutes, seconds].join(':');
return [date, time, period].join(' ');
}
static toKBps(Bps) {
return Math.floor(Bps / kilo);
}
static number(number) {
return number_format.format(number);
}
/** Round a string of a number to a specified number of decimal places */
static _toTruncFixed(number, places) {
const returnValue = Math.floor(number * 10 ** places) / 10 ** places;
return returnValue.toFixed(places);
}
}

927
web/src/inspector.js Normal file
View File

@ -0,0 +1,927 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { FileRow } from './file-row.js';
import { Formatter } from './formatter.js';
import { Torrent } from './torrent.js';
import {
OutsideClickListener,
Utils,
createTabsContainer,
setTextContent,
} from './utils.js';
const peer_column_classes = [
'encryption',
'speed-up',
'speed-down',
'percent-done',
'status',
'peer-address',
'peer-app-name',
];
export class Inspector extends EventTarget {
constructor(controller) {
super();
this.closed = false;
this.controller = controller;
this.elements = this._create();
this.current_page = this.elements.info.root;
this.interval = setInterval(this._refreshTorrents.bind(this), 3000);
this.name = 'inspector';
this.selection_listener = (event_) => this._setTorrents(event_.selected);
this.torrent_listener = () => this._updateCurrentPage();
this.torrents = [];
this.file_torrent = null;
this.file_torrent_n = null;
this.file_rows = null;
this.outside = new OutsideClickListener(this.elements.root);
this.outside.addEventListener('click', () => this.close());
Object.seal(this);
controller.addEventListener(
'torrent-selection-changed',
this.selection_listener
);
this._setTorrents(this.controller.getSelectedTorrents());
document.body.append(this.elements.root);
}
close() {
if (!this.closed) {
this.outside.stop();
clearInterval(this.interval);
this._setTorrents([]);
this.elements.root.remove();
this.controller.removeEventListener(
'torrent-selection-changed',
this.selection_listener
);
this.dispatchEvent(new Event('close'));
for (const property of Object.keys(this)) {
this[property] = null;
}
this.closed = true;
}
}
static _createInfoPage() {
const root = document.createElement('div');
root.classList.add('inspector-info-page');
const elements = { root };
const append_section_title = (text) => {
const label = document.createElement('div');
label.textContent = text;
label.classList.add('section-label');
root.append(label);
};
const append_row = (text) => {
const lhs = document.createElement('label');
setTextContent(lhs, text);
root.append(lhs);
const rhs = document.createElement('label');
root.append(rhs);
return rhs;
};
append_section_title('Activity');
let rows = [
['have', 'Have:'],
['availability', 'Availability:'],
['uploaded', 'Uploaded:'],
['downloaded', 'Downloaded:'],
['state', 'State:'],
['running_time', 'Running time:'],
['remaining_time', 'Remaining:'],
['last_activity', 'Last activity:'],
['error', 'Error:'],
];
for (const [name, text] of rows) {
elements[name] = append_row(text);
}
append_section_title('Details');
rows = [
['size', 'Size:'],
['location', 'Location:'],
['hash', 'Hash:'],
['privacy', 'Privacy:'],
['origin', 'Origin:'],
['comment', 'Comment:'],
];
for (const [name, text] of rows) {
elements[name] = append_row(text);
}
return elements;
}
static _createListPage(list_type, list_id) {
const root = document.createElement('div');
const list = document.createElement(list_type);
list.id = list_id;
root.append(list);
return { list, root };
}
static _createTiersPage() {
return Inspector._createListPage('div', 'inspector-tiers-list');
}
static _createFilesPage() {
return Inspector._createListPage('ul', 'inspector-file-list');
}
static _createPeersPage() {
const table = document.createElement('table');
table.classList.add('peer-list');
const thead = document.createElement('thead');
const tr = document.createElement('tr');
const names = ['', 'Up', 'Down', 'Done', 'Status', 'Address', 'Client'];
names.forEach((name, index) => {
const th = document.createElement('th');
const classname = peer_column_classes[index];
if (classname === 'encryption') {
th.dataset.encrypted = true;
}
th.classList.add(classname);
setTextContent(th, name);
tr.append(th);
});
const tbody = document.createElement('tbody');
thead.append(tr);
table.append(thead);
table.append(tbody);
return {
root: table,
tbody,
};
}
_create() {
const pages = {
files: Inspector._createFilesPage(),
info: Inspector._createInfoPage(),
peers: Inspector._createPeersPage(),
tiers: Inspector._createTiersPage(),
};
const on_activated = (page) => {
this.current_page = page;
this._updateCurrentPage();
};
const elements = createTabsContainer(
'inspector',
[
['inspector-tab-info', pages.info.root],
['inspector-tab-peers', pages.peers.root],
['inspector-tab-tiers', pages.tiers.root],
['inspector-tab-files', pages.files.root],
],
on_activated.bind(this)
);
return { ...elements, ...pages };
}
_setTorrents(torrents) {
// update the inspector when a selected torrent's data changes.
const key = 'dataChanged';
const callback = this.torrent_listener;
this.torrents.forEach((t) => t.removeEventListener(key, callback));
this.torrents = [...torrents];
this.torrents.forEach((t) => t.addEventListener(key, callback));
this._refreshTorrents();
this._updateCurrentPage();
}
static _needsExtraInfo(torrents) {
return torrents.some((tor) => !tor.hasExtraInfo());
}
_refreshTorrents() {
const { controller, torrents } = this;
const ids = torrents.map((t) => t.getId());
if (ids && ids.length > 0) {
const fields = ['id', ...Torrent.Fields.StatsExtra];
if (Inspector._needsExtraInfo(torrents)) {
fields.push(...Torrent.Fields.InfoExtra);
}
controller.updateTorrents(ids, fields);
}
}
_updateCurrentPage() {
const { elements } = this;
switch (this.current_page) {
case elements.files.root:
this._updateFiles();
break;
case elements.info.root:
this._updateInfo();
break;
case elements.peers.root:
this._updatePeers();
break;
case elements.tiers.root:
this._updateTiers();
break;
default:
console.warn('unexpected page');
console.log(this.current_page);
}
}
_updateInfo() {
const none = 'None';
const mixed = 'Mixed';
const unknown = 'Unknown';
const fmt = Formatter;
const now = Date.now();
const { torrents } = this;
const e = this.elements;
const sizeWhenDone = torrents.reduce(
(accumulator, t) => accumulator + t.getSizeWhenDone(),
0
);
// state
let string = null;
if (torrents.length === 0) {
string = none;
} else if (torrents.every((t) => t.isFinished())) {
string = 'Finished';
} else if (torrents.every((t) => t.isStopped())) {
string = 'Paused';
} else {
const get = (t) => t.getStateString();
const first = get(torrents[0]);
string = torrents.every((t) => get(t) === first) ? first : mixed;
}
setTextContent(e.info.state, string);
const stateString = string;
// have
if (torrents.length === 0) {
string = none;
} else {
const verified = torrents.reduce(
(accumulator, t) => accumulator + t.getHaveValid(),
0
);
const unverified = torrents.reduce(
(accumulator, t) => accumulator + t.getHaveUnchecked(),
0
);
const leftUntilDone = torrents.reduce(
(accumulator, t) => accumulator + t.getLeftUntilDone(),
0
);
const d =
100 *
(sizeWhenDone ? (sizeWhenDone - leftUntilDone) / sizeWhenDone : 1);
string = fmt.percentString(d);
if (!unverified && !leftUntilDone) {
string = `${fmt.size(verified)} (100%)`;
} else if (!unverified) {
string = `${fmt.size(verified)} of ${fmt.size(
sizeWhenDone
)} (${string}%)`;
} else {
string = `${fmt.size(verified)} of ${fmt.size(
sizeWhenDone
)} (${string}%), ${fmt.size(unverified)} Unverified`;
}
}
setTextContent(e.info.have, string);
// availability
if (torrents.length === 0) {
string = none;
} else if (sizeWhenDone === 0) {
string = none;
} else {
const available = torrents.reduce(
(accumulator, t) => t.getHave() + t.getDesiredAvailable(),
0
);
string = `${fmt.percentString((100 * available) / sizeWhenDone)}%`;
}
setTextContent(e.info.availability, string);
// downloaded
if (torrents.length === 0) {
string = none;
} else {
const d = torrents.reduce(
(accumulator, t) => accumulator + t.getDownloadedEver(),
0
);
const f = torrents.reduce(
(accumulator, t) => accumulator + t.getFailedEver(),
0
);
string = f ? `${fmt.size(d)} (${fmt.size(f)} corrupt)` : fmt.size(d);
}
setTextContent(e.info.downloaded, string);
// uploaded
if (torrents.length === 0) {
string = none;
} else {
const u = torrents.reduce(
(accumulator, t) => accumulator + t.getUploadedEver(),
0
);
const d =
torrents.reduce(
(accumulator, t) => accumulator + t.getDownloadedEver(),
0
) ||
torrents.reduce((accumulator, t) => accumulator + t.getHaveValid(), 0);
string = `${fmt.size(u)} (Ratio: ${fmt.ratioString(Utils.ratio(u, d))})`;
}
setTextContent(e.info.uploaded, string);
// running time
if (torrents.length === 0) {
string = none;
} else if (torrents.every((t) => t.isStopped())) {
string = stateString; // paused || finished}
} else {
const get = (t) => t.getStartDate();
const first = get(torrents[0]);
string = !torrents.every((t) => get(t) === first)
? mixed
: fmt.timeInterval(now / 1000 - first);
}
setTextContent(e.info.running_time, string);
// remaining time
if (torrents.length === 0) {
string = none;
} else {
const get = (t) => t.getETA();
const first = get(torrents[0]);
if (!torrents.every((t) => get(t) === first)) {
string = mixed;
} else if (first < 0) {
string = unknown;
} else {
string = fmt.timeInterval(first);
}
}
setTextContent(e.info.remaining_time, string);
// last active at
if (torrents.length === 0) {
string = none;
} else {
const latest = torrents.reduce(
(accumulator, t) => Math.max(accumulator, t.getLastActivity()),
-1
);
const now_seconds = Math.floor(now / 1000);
if (0 < latest && latest <= now_seconds) {
const idle_secs = now_seconds - latest;
string =
idle_secs < 5 ? 'Active now' : `${fmt.timeInterval(idle_secs)} ago`;
} else {
string = none;
}
}
setTextContent(e.info.last_activity, string);
// error
if (torrents.length === 0) {
string = none;
} else {
const get = (t) => t.getErrorString();
const first = get(torrents[0]);
string = torrents.every((t) => get(t) === first) ? first : mixed;
}
setTextContent(e.info.error, string || none);
// size
if (torrents.length === 0) {
string = none;
} else {
const size = torrents.reduce(
(accumulator, t) => accumulator + t.getTotalSize(),
0
);
if (!size) {
string = 'None';
} else {
const get = (t) => t.getPieceSize();
const pieceCount = torrents.reduce(
(accumulator, t) => accumulator + t.getPieceCount(),
0
);
const pieceString = fmt.number(pieceCount);
const pieceSize = get(torrents[0]);
string = torrents.every((t) => get(t) === pieceSize)
? `${fmt.size(size)} (${pieceString} pieces @ ${fmt.mem(pieceSize)})`
: `${fmt.size(size)} (${pieceString} pieces)`;
}
}
setTextContent(e.info.size, string);
// hash
if (torrents.length === 0) {
string = none;
} else {
const get = (t) => t.getHashString();
const first = get(torrents[0]);
string = torrents.every((t) => get(t) === first) ? first : mixed;
}
setTextContent(e.info.hash, string);
// privacy
if (torrents.length === 0) {
string = none;
} else {
const get = (t) => t.getPrivateFlag();
const first = get(torrents[0]);
if (!torrents.every((t) => get(t) === first)) {
string = mixed;
} else if (first) {
string = 'Private to this tracker -- DHT and PEX disabled';
} else {
string = 'Public torrent';
}
}
setTextContent(e.info.privacy, string);
// comment
if (torrents.length === 0) {
string = none;
} else {
const get = (t) => t.getComment();
const first = get(torrents[0]);
string = torrents.every((t) => get(t) === first) ? first : mixed;
}
string = string || none;
if (string.startsWith('https://') || string.startsWith('http://')) {
string = encodeURI(string);
Utils.setInnerHTML(
e.info.comment,
`<a href="${string}" target="_blank" >${string}</a>`
);
} else {
setTextContent(e.info.comment, string);
}
// origin
if (torrents.length === 0) {
string = none;
} else {
let get = (t) => t.getCreator();
const creator = get(torrents[0]);
const mixed_creator = !torrents.every((t) => get(t) === creator);
get = (t) => t.getDateCreated();
const date = get(torrents[0]);
const mixed_date = !torrents.every((t) => get(t) === date);
const empty_creator = !creator || !creator.length;
const empty_date = !date;
if (mixed_creator || mixed_date) {
string = mixed;
} else if (empty_creator && empty_date) {
string = unknown;
} else if (empty_date && !empty_creator) {
string = `Created by ${creator}`;
} else if (empty_creator && !empty_date) {
string = `Created on ${new Date(date * 1000).toDateString()}`;
} else {
string = `Created by ${creator} on ${new Date(
date * 1000
).toDateString()}`;
}
}
setTextContent(e.info.origin, string);
// location
if (torrents.length === 0) {
string = none;
} else {
const get = (t) => t.getDownloadDir();
const first = get(torrents[0]);
string = torrents.every((t) => get(t) === first) ? first : mixed;
}
setTextContent(e.info.location, string);
}
/// PEERS PAGE
static _peerStatusTitle(flag_string) {
const texts = Object.seal({
'?': "We unchoked this peer, but they're not interested",
D: 'Downloading from this peer',
E: 'Encrypted Connection',
H: 'Peer was discovered through Distributed Hash Table (DHT)',
I: 'Peer is an incoming connection',
K: "Peer has unchoked us, but we're not interested",
O: 'Optimistic unchoke',
T: 'Peer is connected via uTP',
U: 'Uploading to peer',
X: 'Peer was discovered through Peer Exchange (PEX)',
d: "We would download from this peer if they'd let us",
u: "We would upload to this peer if they'd ask",
});
return [...flag_string]
.filter((ch) => texts[ch])
.map((ch) => `${ch}: ${texts[ch]}`)
.join('\n');
}
_updatePeers() {
const fmt = Formatter;
const { torrents } = this;
const { tbody } = this.elements.peers;
const cell_setters = [
(peer, td) => {
td.dataset.encrypted = peer.isEncrypted;
},
(peer, td) =>
setTextContent(
td,
peer.rateToPeer ? fmt.speedBps(peer.rateToPeer) : ''
),
(peer, td) =>
setTextContent(
td,
peer.rateToClient ? fmt.speedBps(peer.rateToClient) : ''
),
(peer, td) => setTextContent(td, `${Math.floor(peer.progress * 100)}%`),
(peer, td) => {
setTextContent(td, peer.flagStr);
td.setAttribute('title', Inspector._peerStatusTitle(peer.flagStr));
},
(peer, td) => setTextContent(td, peer.address),
(peer, td) => setTextContent(td, peer.clientName),
];
const rows = [];
for (const tor of torrents) {
// torrent name
const tortr = document.createElement('tr');
tortr.classList.add('torrent-row');
const tortd = document.createElement('td');
tortd.setAttribute('colspan', cell_setters.length);
setTextContent(tortd, tor.getName());
tortr.append(tortd);
rows.push(tortr);
// peers
for (const peer of tor.getPeers()) {
const tr = document.createElement('tr');
tr.classList.add('peer-row');
cell_setters.forEach((setter, index) => {
const td = document.createElement('td');
td.classList.add(peer_column_classes[index]);
setter(peer, td);
tr.append(td);
});
rows.push(tr);
}
// TODO: modify instead of rebuilding wholesale?
while (tbody.firstChild) {
tbody.firstChild.remove();
}
tbody.append(...rows);
}
}
/// TRACKERS PAGE
static getAnnounceState(tracker) {
switch (tracker.announceState) {
case Torrent._TrackerActive:
return 'Announce in progress';
case Torrent._TrackerWaiting: {
const timeUntilAnnounce = Math.max(
0,
tracker.nextAnnounceTime - new Date().getTime() / 1000
);
return `Next announce in ${Formatter.timeInterval(timeUntilAnnounce)}`;
}
case Torrent._TrackerQueued:
return 'Announce is queued';
case Torrent._TrackerInactive:
return tracker.isBackup
? 'Tracker will be used as a backup'
: 'Announce not scheduled';
default:
return `unknown announce state: ${tracker.announceState}`;
}
}
static lastAnnounceStatus(tracker) {
let lastAnnounceLabel = 'Last Announce';
let lastAnnounce = ['N/A'];
if (tracker.hasAnnounced) {
const lastAnnounceTime = Formatter.timestamp(tracker.lastAnnounceTime);
if (tracker.lastAnnounceSucceeded) {
lastAnnounce = [
lastAnnounceTime,
' (got ',
Formatter.countString('peer', 'peers', tracker.lastAnnouncePeerCount),
')',
];
} else {
lastAnnounceLabel = 'Announce error';
lastAnnounce = [
tracker.lastAnnounceResult ? `${tracker.lastAnnounceResult} - ` : '',
lastAnnounceTime,
];
}
}
return {
label: lastAnnounceLabel,
value: lastAnnounce.join(''),
};
}
static lastScrapeStatus(tracker) {
let lastScrapeLabel = 'Last Scrape';
let lastScrape = 'N/A';
if (tracker.hasScraped) {
const lastScrapeTime = Formatter.timestamp(tracker.lastScrapeTime);
if (tracker.lastScrapeSucceeded) {
lastScrape = lastScrapeTime;
} else {
lastScrapeLabel = 'Scrape error';
lastScrape =
(tracker.lastScrapeResult ? `${tracker.lastScrapeResult} - ` : '') +
lastScrapeTime;
}
}
return {
label: lastScrapeLabel,
value: lastScrape,
};
}
_updateTiers() {
const na = 'N/A';
const { list } = this.elements.tiers;
const { torrents } = this;
const rows = [];
for (const tor of torrents) {
const group = document.createElement('div');
group.classList.add('inspector-group');
rows.push(group);
// if >1 torrent to be shown, give a title
if (torrents.length > 1) {
const title = document.createElement('div');
title.classList.add('tier-list-torrent');
setTextContent(title, tor.getName());
rows.push(title);
}
tor.getTrackers().forEach((tracker, index) => {
const announceState = Inspector.getAnnounceState(tracker);
const lastAnnounceStatusHash = Inspector.lastAnnounceStatus(tracker);
const lastScrapeStatusHash = Inspector.lastScrapeStatus(tracker);
const tier_div = document.createElement('div');
tier_div.classList.add('tier-list-row', index % 2 ? 'odd' : 'even');
let element = document.createElement('div');
element.classList.add('tier-list-tracker');
setTextContent(
element,
`${tracker.domain || tracker.host || tracker.announce} - tier ${
tracker.tier + 1
}`
);
element.setAttribute('title', tracker.announce);
tier_div.append(element);
element = document.createElement('div');
element.classList.add('tier-announce');
setTextContent(
element,
`${lastAnnounceStatusHash.label}: ${lastAnnounceStatusHash.value}`
);
tier_div.append(element);
element = document.createElement('div');
element.classList.add('tier-seeders');
setTextContent(
element,
`Seeders: ${tracker.seederCount > -1 ? tracker.seederCount : na}`
);
tier_div.append(element);
element = document.createElement('div');
element.classList.add('tier-state');
setTextContent(element, announceState);
tier_div.append(element);
element = document.createElement('div');
element.classList.add('tier-leechers');
setTextContent(
element,
`Leechers: ${tracker.leecherCount > -1 ? tracker.leecherCount : na}`
);
tier_div.append(element);
element = document.createElement('div');
element.classList.add('tier-scrape');
setTextContent(
element,
`${lastScrapeStatusHash.label}: ${lastScrapeStatusHash.value}`
);
tier_div.append(element);
element = document.createElement('div');
element.classList.add('tier-downloads');
setTextContent(
element,
`Downloads: ${
tracker.downloadCount > -1 ? tracker.downloadCount : na
}`
);
tier_div.append(element);
rows.push(tier_div);
});
}
// TODO: modify instead of rebuilding wholesale?
while (list.firstChild) {
list.firstChild.remove();
}
list.append(...rows);
}
/// FILES PAGE
_changeFileCommand(fileIndices, command) {
const { controller, file_torrent } = this;
const torrentId = file_torrent.getId();
controller.changeFileCommand(torrentId, fileIndices, command);
}
_onFileWantedToggled(event_) {
const { indices, wanted } = event_;
this._changeFileCommand(
indices,
wanted ? 'files-wanted' : 'files-unwanted'
);
}
_onFilePriorityToggled(event_) {
const { indices, priority } = event_;
let command = null;
switch (priority) {
case -1:
command = 'priority-low';
break;
case 1:
command = 'priority-high';
break;
default:
command = 'priority-normal';
break;
}
this._changeFileCommand(indices, command);
}
_clearFileList() {
const { list } = this.elements.files;
while (list.firstChild) {
list.firstChild.remove();
}
this.file_torrent = null;
this.file_torrent_n = null;
this.file_rows = null;
}
static createFileTreeModel(tor) {
const leaves = [];
const tree = {
children: {},
file_indices: [],
};
tor.getFiles().forEach((file, index) => {
const { name } = file;
const tokens = name.split('/');
let walk = tree;
for (const [index_, token] of tokens.entries()) {
let sub = walk.children[token];
if (!sub) {
walk.children[token] = sub = {
children: {},
depth: index_,
file_indices: [],
name: token,
parent: walk,
};
}
walk = sub;
}
walk.file_index = index;
delete walk.children;
leaves.push(walk);
});
for (const leaf of leaves) {
const { file_index } = leaf;
let walk = leaf;
do {
walk.file_indices.push(file_index);
walk = walk.parent;
} while (walk);
}
return tree;
}
addNodeToView(tor, parent, sub, index) {
const row = new FileRow(
tor,
sub.depth,
sub.name,
sub.file_indices,
index % 2
);
row.addEventListener('wantedToggled', this._onFileWantedToggled.bind(this));
row.addEventListener(
'priorityToggled',
this._onFilePriorityToggled.bind(this)
);
this.file_rows.push(row);
parent.append(row.getElement());
}
addSubtreeToView(tor, parent, sub, index) {
if (sub.parent) {
this.addNodeToView(tor, parent, sub, index++);
}
if (sub.children) {
for (const value of Object.values(sub.children)) {
index = this.addSubtreeToView(tor, parent, value, index);
}
}
return index;
}
_updateFiles() {
const { list } = this.elements.files;
const { torrents } = this;
// only show one torrent at a time
if (torrents.length !== 1) {
this._clearFileList();
return;
}
const [tor] = torrents;
const n = tor.getFiles().length;
if (tor !== this.file_torrent || n !== this.file_torrent_n) {
// rebuild the file list...
this._clearFileList();
this.file_torrent = tor;
this.file_torrent_n = n;
this.file_rows = [];
const fragment = document.createDocumentFragment();
const tree = Inspector.createFileTreeModel(tor);
this.addSubtreeToView(tor, fragment, tree, 0);
list.append(fragment);
} else {
// ...refresh the already-existing file list
this.file_rows.forEach((row) => row.refresh());
}
}
}

29
web/src/main.js Normal file
View File

@ -0,0 +1,29 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { ActionManager } from './action-manager.js';
import { Notifications } from './notifications.js';
import { Prefs } from './prefs.js';
import { Transmission } from './transmission.js';
import { debounce } from './utils.js';
import '../style/transmission-app.scss';
function main() {
const action_manager = new ActionManager();
const prefs = new Prefs();
const notifications = new Notifications(prefs);
const transmission = new Transmission(action_manager, notifications, prefs);
const scroll_soon = debounce(() =>
transmission.elements.torrent_list.scrollTo(0, 1)
);
window.addEventListener('load', scroll_soon);
window.onorientationchange = scroll_soon;
}
document.addEventListener('DOMContentLoaded', main);

84
web/src/move-dialog.js Normal file
View File

@ -0,0 +1,84 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
let default_path = '';
import { createDialogContainer } from './utils.js';
export class MoveDialog extends EventTarget {
constructor(controller, remote) {
super();
this.controller = controller;
this.remote = remote;
this.elements = {};
this.torrents = [];
this.show();
}
show() {
const torrents = this.controller.getSelectedTorrents();
if (torrents.length === 0) {
return;
}
default_path = default_path || torrents[0].getDownloadDir();
this.torrents = torrents;
this.elements = MoveDialog._create();
this.elements.confirm.addEventListener('click', () => this._onConfirm());
this.elements.dismiss.addEventListener('click', () => this._onDismiss());
this.elements.entry.value = default_path;
document.body.append(this.elements.root);
this.elements.entry.focus();
}
close() {
this.elements.root.remove();
this.dispatchEvent(new Event('close'));
delete this.controller;
delete this.remote;
delete this.elements;
delete this.torrents;
}
_onDismiss() {
this.close();
}
_onConfirm() {
const ids = this.torrents.map((tor) => tor.getId());
const path = this.elements.entry.value.trim();
default_path = path;
this.remote.moveTorrents(ids, path);
this.close();
}
static _create() {
const elements = createDialogContainer('move-dialog');
elements.root.setAttribute('aria-label', 'Move Torrent');
elements.heading.textContent = 'Set Torrent Location';
confirm.textContent = 'Apply';
const label = document.createElement('label');
label.setAttribute('for', 'torrent-path');
label.textContent = 'Location:';
elements.workarea.append(label);
const entry = document.createElement('input');
entry.setAttribute('type', 'text');
entry.id = 'torrent-path';
elements.entry = entry;
elements.workarea.append(entry);
return elements;
}
}

60
web/src/notifications.js Normal file
View File

@ -0,0 +1,60 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { setTextContent } from './utils.js';
export class Notifications {
constructor(prefs) {
this._prefs = prefs;
this._elements = {
toggle: document.querySelector('#toggle-notifications'),
};
}
_setEnabled(enabled) {
this.prefs.notifications_enabled = enabled;
setTextContent(
this._toggle,
`${enabled ? 'Disable' : 'Enable'} Notifications`
);
}
_requestPermission() {
Notification.requestPermission().then((s) =>
this._setEnabled(s === 'granted')
);
}
toggle() {
if (this._enabled) {
this._setEnabled(false);
} else if (Notification.permission === 'granted') {
this._setEnabled(true);
} else if (Notification.permission !== 'denied') {
this._requestPermission();
}
}
/*
// TODO:
// $(transmission).bind('downloadComplete seedingComplete', (event, torrent) => {
// if (notificationsEnabled) {
const title = `${event.type === 'downloadComplete' ? 'Download' : 'Seeding'} complete`;
const content = torrent.getName();
const notification = window.webkitNotifications.createNotification(
'style/transmission/images/logo.png',
title,
content
);
notification.show();
setTimeout(() => {
notification.cancel();
}, 5000);
}
});
*/
}

194
web/src/open-dialog.js Normal file
View File

@ -0,0 +1,194 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { AlertDialog } from './alert-dialog.js';
import { Formatter } from './formatter.js';
import { createDialogContainer, makeUUID } from './utils.js';
export class OpenDialog extends EventTarget {
constructor(controller, remote) {
super();
this.controller = controller;
this.remote = remote;
this.elements = this._create();
this.elements.dismiss.addEventListener('click', () => this._onDismiss());
this.elements.confirm.addEventListener('click', () => this._onConfirm());
this._updateFreeSpaceInAddDialog();
document.body.append(this.elements.root);
this.elements.url_input.focus();
}
close() {
if (!this.closed) {
clearInterval(this.interval);
this.elements.root.remove();
this.dispatchEvent(new Event('close'));
for (const key of Object.keys(this)) {
delete this[key];
}
this.closed = true;
}
}
_onDismiss() {
this.close();
}
_updateFreeSpaceInAddDialog() {
const path = this.elements.folder_input.value;
this.remote.getFreeSpace(path, (dir, bytes) => {
const string = bytes > 0 ? `${Formatter.size(bytes)} Free` : '';
this.elements.freespace.textContent = string;
});
}
_onConfirm() {
const { remote } = this;
const { file_input, folder_input, start_input, url_input } = this.elements;
const paused = !start_input.checked;
const destination = folder_input.value.trim();
for (const file of file_input.files) {
const reader = new FileReader();
reader.addEventListener('load', (e) => {
const contents = e.target.result;
const key = 'base64,';
const index = contents.indexOf(key);
if (index === -1) {
return;
}
const o = {
arguments: {
'download-dir': destination,
metainfo: contents.slice(Math.max(0, index + key.length)),
paused,
},
method: 'torrent-add',
};
console.log(o);
remote.sendRequest(o, (response) => {
if (response.result !== 'success') {
alert(`Error adding "${file.name}": ${response.result}`);
this.controller.setCurrentPopup(
new AlertDialog({
heading: `Error adding "${file.name}"`,
message: response.result,
})
);
}
});
});
reader.readAsDataURL(file);
}
let url = url_input.value.trim();
if (url.length > 0) {
if (url.match(/^[\da-f]{40}$/i)) {
url = `magnet:?xt=urn:btih:${url}`;
}
const o = {
arguments: {
'download-dir': destination,
filename: url,
paused,
},
method: 'torrent-add',
};
console.log(o);
remote.sendRequest(o, (payload, response) => {
if (response.result !== 'success') {
this.controller.setCurrentPopup(
new AlertDialog({
heading: `Error adding "${url}"`,
message: response.result,
})
);
}
});
}
this._onDismiss();
}
_create() {
const elements = createDialogContainer();
const { confirm, root, heading, workarea } = elements;
root.classList.add('open-torrent');
heading.textContent = 'Add Torrents';
confirm.textContent = 'Add';
let input_id = makeUUID();
let label = document.createElement('label');
label.setAttribute('for', input_id);
label.textContent = 'Please select torrent files to add:';
workarea.append(label);
let input = document.createElement('input');
input.type = 'file';
input.name = 'torrent-files[]';
input.id = input_id;
input.multiple = 'multiple';
workarea.append(input);
elements.file_input = input;
input_id = makeUUID();
label = document.createElement('label');
label.setAttribute('for', input_id);
label.textContent = 'Or enter a URL:';
workarea.append(label);
input = document.createElement('input');
input.type = 'url';
input.id = input_id;
workarea.append(input);
elements.url_input = input;
input_id = makeUUID();
label = document.createElement('label');
label.id = 'add-dialog-folder-label';
label.for = input_id;
label.textContent = 'Destination folder:';
workarea.append(label);
const freespace = document.createElement('span');
freespace.id = 'free-space-text';
label.append(freespace);
workarea.append(label);
elements.freespace = freespace;
input = document.createElement('input');
input.type = 'text';
input.id = 'add-dialog-folder-input';
input.addEventListener('change', () => this._updateFreeSpaceInAddDialog());
input.value = this.controller.session_properties['download-dir'];
workarea.append(input);
elements.folder_input = input;
const checkarea = document.createElement('div');
workarea.append(checkarea);
const check = document.createElement('input');
check.type = 'checkbox';
check.id = 'auto-start-check';
check.checked = this.controller.shouldAddedTorrentsStart();
checkarea.append(check);
elements.start_input = check;
label = document.createElement('label');
label.id = 'auto-start-label';
label.setAttribute('for', check.id);
label.textContent = 'Start when added';
checkarea.append(label);
return elements;
}
}

466
web/src/overflow-menu.js Normal file
View File

@ -0,0 +1,466 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { Formatter } from './formatter.js';
import { Prefs } from './prefs.js';
import { RPC } from './remote.js';
import { OutsideClickListener, setEnabled } from './utils.js';
export class OverflowMenu extends EventTarget {
constructor(session_manager, prefs, remote, action_manager) {
super();
this.action_listener = this._onActionChange.bind(this);
this.action_manager = action_manager;
this.action_manager.addEventListener('change', this.action_listener);
this.prefs_listener = this._onPrefsChange.bind(this);
this.prefs = prefs;
this.prefs.addEventListener('change', this.prefs_listener);
this.closed = false;
this.remote = remote;
this.name = 'overflow-menu';
this.session_listener = this._onSessionChange.bind(this);
this.session_manager = session_manager;
this.session_manager.addEventListener(
'session-change',
this.session_listener
);
const { session_properties } = session_manager;
Object.assign(this, this._create(session_properties));
this.outside = new OutsideClickListener(this.root);
this.outside.addEventListener('click', () => this.close());
Object.seal(this);
this.show();
}
show() {
document.body.append(this.root);
}
close() {
if (!this.closed) {
this.outside.stop();
this.session_manager.removeEventListener(
'session-change',
this.session_listener
);
this.action_manager.removeEventListener('change', this.action_listener);
this.prefs.removeEventListener('change', this.prefs_listener);
this.root.remove();
this.dispatchEvent(new Event('close'));
for (const key of Object.keys(this)) {
this[key] = null;
}
this.closed = true;
}
}
_onSessionChange(event_) {
const { alt_speed_check } = this.elements;
const { session_properties } = event_;
alt_speed_check.checked = session_properties[RPC._TurtleState];
}
_onPrefsChange(event_) {
switch (event_.key) {
case Prefs.SortDirection:
case Prefs.SortMode:
this.root.querySelector(`[data-pref="${event_.key}"]`).value =
event_.value;
break;
default:
break;
}
}
_onActionChange(event_) {
const element = this.actions[event_.action];
if (element) {
this._updateElement(element);
}
}
_updateElement(element) {
if (element.dataset.action) {
const { action } = element.dataset;
const shortcuts = this.action_manager.keyshortcuts(action);
if (shortcuts) {
element.setAttribute('aria-keyshortcuts', shortcuts);
}
setEnabled(element, this.action_manager.isEnabled(action));
}
}
_onClick(event_) {
const { action, pref } = event_.target.dataset;
if (action) {
this.action_manager.click(action);
return;
}
if (pref) {
this.prefs[pref] = event_.target.value;
return;
}
console.log('unhandled');
console.log(event_);
console.trace();
}
_create(session_properties) {
const actions = {};
const on_click = this._onClick.bind(this);
const elements = {};
const make_section = (classname, title) => {
const section = document.createElement('fieldset');
section.classList.add('section', classname);
const legend = document.createElement('legend');
legend.classList.add('title');
legend.textContent = title;
section.append(legend);
return section;
};
const make_button = (parent, text, action) => {
const e = document.createElement('button');
e.textContent = text;
e.addEventListener('click', on_click);
parent.append(e);
if (action) {
e.dataset.action = action;
}
return e;
};
const root = document.createElement('div');
root.classList.add('overflow-menu', 'popup');
let section = make_section('display', 'Display');
root.append(section);
let options = document.createElement('div');
options.id = 'display-options';
section.append(options);
// sort mode
let div = document.createElement('div');
options.append(div);
let label = document.createElement('label');
label.id = 'display-sort-mode-label';
label.textContent = 'Sort by';
div.append(label);
let select = document.createElement('select');
select.id = 'display-sort-mode-select';
select.dataset.pref = Prefs.SortMode;
div.append(select);
const sort_modes = [
[Prefs.SortByActivity, 'Activity'],
[Prefs.SortByAge, 'Age'],
[Prefs.SortByName, 'Name'],
[Prefs.SortByProgress, 'Progress'],
[Prefs.SortByQueue, 'Queue order'],
[Prefs.SortByRatio, 'Ratio'],
[Prefs.SortBySize, 'Size'],
[Prefs.SortByState, 'State'],
];
for (const [value, text] of sort_modes) {
const option = document.createElement('option');
option.value = value;
option.textContent = text;
select.append(option);
}
label.setAttribute('for', select.id);
select.value = this.prefs.sort_mode;
select.addEventListener('change', (event_) => {
this.prefs.sort_mode = event_.target.value;
});
// sort direction
div = document.createElement('div');
options.append(div);
let check = document.createElement('input');
check.id = 'display-sort-reverse-check';
check.dataset.pref = Prefs.SortDirection;
check.type = 'checkbox';
div.append(check);
label = document.createElement('label');
label.id = 'display-sort-reverse-label';
label.setAttribute('for', check.id);
label.textContent = 'Reverse sort';
div.append(label);
check.checked = this.prefs.sort_direction !== Prefs.SortAscending;
check.addEventListener('input', (event_) => {
this.prefs.sort_direction = event_.target.checked
? Prefs.SortDescending
: Prefs.SortAscending;
});
// compact
div = document.createElement('div');
options.append(div);
const action = 'toggle-compact-rows';
check = document.createElement('input');
check.id = 'display-compact-check';
check.dataset.action = action;
check.type = 'checkbox';
div.append(check);
label = document.createElement('label');
label.id = 'display-compact-label';
label.for = check.id;
label.setAttribute('for', check.id);
label.textContent = this.action_manager.text(action);
div.append(label);
check.checked = this.prefs.display_mode === Prefs.DisplayCompact;
check.addEventListener('input', (event_) => {
const { checked } = event_.target;
this.prefs.display_mode = checked
? Prefs.DisplayCompact
: Prefs.DisplayFull;
});
// fullscreen
div = document.createElement('div');
options.append(div);
check = document.createElement('input');
check.id = 'display-fullscreen-check';
check.type = 'checkbox';
const is_fullscreen = () => document.fullscreenElement !== null;
check.checked = is_fullscreen();
check.addEventListener('input', () => {
if (is_fullscreen()) {
document.exitFullscreen();
} else {
document.body.requestFullscreen();
}
});
document.addEventListener('fullscreenchange', () => {
check.checked = is_fullscreen();
});
div.append(check);
label = document.createElement('label');
label.id = 'display-fullscreen-label';
label.for = check.id;
label.setAttribute('for', check.id);
label.textContent = 'Fullscreen';
div.append(label);
section = make_section('speed', 'Speed Limit');
root.append(section);
options = document.createElement('div');
options.id = 'speed-options';
section.append(options);
// speed up
div = document.createElement('div');
div.classList.add('speed-up');
options.append(div);
label = document.createElement('label');
label.id = 'speed-up-label';
label.textContent = 'Upload:';
div.append(label);
const unlimited = 'Unlimited';
select = document.createElement('select');
select.id = 'speed-up-select';
div.append(select);
const speeds = ['10', '100', '200', '500', '750', unlimited];
for (const speed of [
...new Set(speeds)
.add(`${session_properties[RPC._UpSpeedLimit]}`)
.values(),
].sort()) {
const option = document.createElement('option');
option.value = speed;
option.textContent =
speed === unlimited ? unlimited : Formatter.speed(speed);
select.append(option);
}
label.setAttribute('for', select.id);
select.value = session_properties[RPC._UpSpeedLimited]
? `${session_properties[RPC._UpSpeedLimit]}`
: unlimited;
select.addEventListener('change', (event_) => {
const { value } = event_.target;
console.log(event_);
if (event_.target.value === unlimited) {
this.remote.savePrefs({ [RPC._UpSpeedLimited]: false });
} else {
this.remote.savePrefs({
[RPC._UpSpeedLimited]: true,
[RPC._UpSpeedLimit]: Number.parseInt(value, 10),
});
}
});
// speed down
div = document.createElement('div');
div.classList.add('speed-down');
options.append(div);
label = document.createElement('label');
label.id = 'speed-down-label';
label.textContent = 'Download:';
div.append(label);
select = document.createElement('select');
select.id = 'speed-down-select';
div.append(select);
for (const speed of [
...new Set(speeds)
.add(`${session_properties[RPC._DownSpeedLimit]}`)
.values(),
].sort()) {
const option = document.createElement('option');
option.value = speed;
option.textContent = speed;
select.append(option);
}
label.setAttribute('for', select.id);
select.value = session_properties[RPC._DownSpeedLimited]
? `${session_properties[RPC._DownSpeedLimit]}`
: unlimited;
select.addEventListener('change', (event_) => {
const { value } = event_.target;
console.log(event_);
if (event_.target.value === unlimited) {
this.remote.savePrefs({ [RPC._DownSpeedLimited]: false });
} else {
this.remote.savePrefs({
[RPC._DownSpeedLimited]: true,
[RPC._DownSpeedLimit]: Number.parseInt(value, 10),
});
}
});
// alt speed
div = document.createElement('div');
div.classList.add('alt-speed');
options.append(div);
check = document.createElement('input');
check.id = 'alt-speed-check';
check.type = 'checkbox';
check.checked = session_properties[RPC._TurtleState];
check.addEventListener('change', (event_) => {
this.remote.savePrefs({
[RPC._TurtleState]: event_.target.checked,
});
});
div.append(check);
elements.alt_speed_check = check;
label = document.createElement('label');
label.id = 'alt-speed-image';
label.setAttribute('for', check.id);
div.append(label);
label = document.createElement('label');
label.id = 'alt-speed-label';
label.setAttribute('for', check.id);
label.textContent = 'Use Temp limits';
div.append(label);
label = document.createElement('label');
label.id = 'alt-speed-values-label';
label.setAttribute('for', check.id);
const up = Formatter.speed(session_properties[RPC._TurtleUpSpeedLimit]);
const dn = Formatter.speed(session_properties[RPC._TurtleDownSpeedLimit]);
label.textContent = `(${up} up, ${dn} down)`;
div.append(label);
section = make_section('actions', 'Actions');
root.append(section);
for (const action_name of [
'show-preferences-dialog',
'pause-all-torrents',
'start-all-torrents',
]) {
const text = this.action_manager.text(action_name);
actions[action_name] = make_button(section, text, action_name);
}
section = make_section('info', 'Info');
root.append(section);
options = document.createElement('div');
section.append(options);
for (const action_name of [
'show-about-dialog',
'show-shortcuts-dialog',
'show-statistics-dialog',
]) {
const text = this.action_manager.text(action_name);
actions[action_name] = make_button(options, text, action_name);
}
section = make_section('links', 'Links');
root.append(section);
options = document.createElement('div');
section.append(options);
let e = document.createElement('a');
e.href = 'https://transmissionbt.com/';
e.tabindex = '0';
e.textContent = 'Homepage';
options.append(e);
e = document.createElement('a');
e.href = 'https://transmissionbt.com/donate/';
e.tabindex = '0';
e.textContent = 'Tip Jar';
options.append(e);
e = document.createElement('a');
e.href = 'https://github.com/transmission/transmission/';
e.tabindex = '0';
e.textContent = 'Source Code';
options.append(e);
Object.values(actions).forEach(this._updateElement.bind(this));
return { actions, elements, root };
}
}

710
web/src/prefs-dialog.js Normal file
View File

@ -0,0 +1,710 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { Formatter } from './formatter.js';
import {
OutsideClickListener,
createTabsContainer,
makeUUID,
setEnabled,
setTextContent,
} from './utils.js';
export class PrefsDialog extends EventTarget {
static _initTimeDropDown(e) {
for (let index = 0; index < 24 * 4; ++index) {
const hour = Number.parseInt(index / 4, 10);
const mins = (index % 4) * 15;
const value = index * 15;
const content = `${hour}:${mins || '00'}`;
e.options[index] = new Option(content, value);
}
}
static _initDayDropDown(e) {
const options = [
['Everyday', '127'],
['Weekdays', '62'],
['Weekends', '65'],
['Sunday', '1'],
['Monday', '2'],
['Tuesday', '4'],
['Wednesday', '8'],
['Thursday', '16'],
['Friday', '32'],
['Saturday', '64'],
];
for (let index = 0; options[index]; ++index) {
const [text, value] = options[index];
e.options[index] = new Option(text, value);
}
}
_checkPort() {
const element = this.elements.network.port_status_label;
element.removeAttribute('data-open');
setTextContent(element, 'Checking...');
this.remote.checkPort(this._onPortChecked, this);
}
_onPortChecked(response) {
const element = this.elements.network.port_status_label;
const is_open = response.arguments['port-is-open'];
element.dataset.open = is_open;
setTextContent(element, is_open ? 'Open' : 'Closed');
}
_setBlocklistButtonEnabled(b) {
const e = this.elements.peers.blocklist_update_button;
setEnabled(e, b);
e.value = b ? 'Update' : 'Updating...';
}
static _getValue(e) {
switch (e.type) {
case 'checkbox':
case 'radio':
return e.checked;
case 'number':
case 'text':
case 'url': {
const string = e.value;
if (Number.parseInt(string, 10).toString() === string) {
return Number.parseInt(string, 10);
}
if (Number.parseFloat(string).toString() === string) {
return Number.parseFloat(string);
}
return string;
}
default:
return null;
}
}
// this callback is for controls whose changes can be applied
// immediately, like checkboxs, radioboxes, and selects
_onControlChanged(event_) {
const { key } = event_.target.dataset;
this.remote.savePrefs({
[key]: PrefsDialog._getValue(event_.target),
});
if (key === 'peer-port' || key === 'port-forwarding-enabled') {
this._checkPort();
}
}
_onDialogClosed() {
this.dispatchEvent(new Event('closed'));
}
// update the dialog's controls
_update(o) {
if (!o) {
return;
}
this._setBlocklistButtonEnabled(true);
for (const [key, value] of Object.entries(o)) {
for (const element of this.elements.root.querySelectorAll(
`[data-key="${key}"]`
)) {
if (key === 'blocklist-size') {
const n = Formatter.number(value);
element.innerHTML = `Blocklist has <span class="blocklist-size-number">${n}</span> rules`;
setTextContent(this.elements.peers.blocklist_update_button, 'Update');
} else {
switch (element.type) {
case 'checkbox':
case 'radio':
if (element.checked !== value) {
element.checked = value;
element.dispatchEvent(new Event('change'));
}
break;
case 'text':
case 'url':
case 'email':
case 'number':
case 'search':
// don't change the text if the user's editing it.
// it's very annoying when that happens!
if (
// eslint-disable-next-line eqeqeq
element.value != value &&
element !== document.activeElement
) {
element.value = value;
element.dispatchEvent(new Event('change'));
}
break;
case 'select-one':
if (element.value !== value) {
element.value = value;
element.dispatchEvent(new Event('change'));
}
break;
default:
break;
}
}
}
}
}
shouldAddedTorrentsStart() {
return this.data.elements.root.find('#start-added-torrents')[0].checked;
}
static _createCheckAndLabel(id, text) {
const root = document.createElement('div');
root.id = id;
const check = document.createElement('input');
check.id = makeUUID();
check.type = 'checkbox';
root.append(check);
const label = document.createElement('label');
label.textContent = text;
label.setAttribute('for', check.id);
root.append(label);
return { check, label, root };
}
static _enableIfChecked(element, check) {
const callback = () => {
if (element.tagName === 'INPUT') {
setEnabled(element, check.checked);
} else {
element.classList.toggle('disabled', !check.checked);
}
};
check.addEventListener('change', callback);
callback();
}
static _createTorrentsPage() {
const root = document.createElement('div');
root.classList.add('prefs-torrents-page');
let label = document.createElement('div');
label.textContent = 'Downloading';
label.classList.add('section-label');
root.append(label);
label = document.createElement('label');
label.textContent = 'Download to:';
root.append(label);
let input = document.createElement('input');
input.type = 'text';
input.id = makeUUID();
input.dataset.key = 'download-dir';
label.setAttribute('for', input.id);
root.append(input);
const download_dir = input;
let cal = PrefsDialog._createCheckAndLabel(
'autostart-div',
'Start when added'
);
cal.check.dataset.key = 'start-added-torrents';
root.append(cal.root);
const autostart_check = cal.check;
cal = PrefsDialog._createCheckAndLabel(
'suffix-div',
`Append "part" to incomplete files' names`
);
cal.check.dataset.key = 'rename-partial-files';
root.append(cal.root);
const suffix_check = cal.check;
label = document.createElement('div');
label.textContent = 'Seeding';
label.classList.add('section-label');
root.append(label);
cal = PrefsDialog._createCheckAndLabel(
'stop-ratio-div',
'Stop seeding at ratio:'
);
cal.check.dataset.key = 'seedRatioLimited';
root.append(cal.root);
const stop_ratio_check = cal.check;
input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'seedRatioLimit';
root.append(input);
PrefsDialog._enableIfChecked(input, cal.check);
const stop_ratio_input = input;
cal = PrefsDialog._createCheckAndLabel(
'stop-idle-div',
'Stop seeding if idle for N mins:'
);
cal.check.dataset.key = 'idle-seeding-limit-enabled';
root.append(cal.root);
const stop_idle_check = cal.check;
input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'idle-seeding-limit';
root.append(input);
PrefsDialog._enableIfChecked(input, cal.check);
const stop_idle_input = input;
return {
autostart_check,
download_dir,
root,
stop_idle_check,
stop_idle_input,
stop_ratio_check,
stop_ratio_input,
suffix_check,
};
}
static _createSpeedPage() {
const root = document.createElement('div');
root.classList.add('prefs-speed-page');
let label = document.createElement('div');
label.textContent = 'Speed Limits';
label.classList.add('section-label');
root.append(label);
let cal = PrefsDialog._createCheckAndLabel(
'upload-speed-div',
'Upload (kB/s):'
);
cal.check.dataset.key = 'speed-limit-up-enabled';
root.append(cal.root);
const upload_speed_check = cal.check;
let input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'speed-limit-up';
root.append(input);
PrefsDialog._enableIfChecked(input, cal.check);
const upload_speed_input = input;
cal = PrefsDialog._createCheckAndLabel(
'download-speed-div',
'Download (kB/s):'
);
cal.check.dataset.key = 'speed-limit-down-enabled';
root.append(cal.root);
const download_speed_check = cal.check;
input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'speed-limit-down';
root.append(input);
PrefsDialog._enableIfChecked(input, cal.check);
const download_speed_input = input;
label = document.createElement('div');
label.textContent = 'Alternative Speed Limits';
label.classList.add('section-label', 'alt-speed-section-label');
root.append(label);
label = document.createElement('div');
label.textContent =
'Override normal speed limits manually or at scheduled times';
label.classList.add('alt-speed-label');
root.append(label);
label = document.createElement('label');
label.textContent = 'Upload (kB/s):';
root.append(label);
input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'alt-speed-up';
input.id = makeUUID();
label.setAttribute('for', input.id);
root.append(input);
const alt_upload_speed_input = input;
label = document.createElement('label');
label.textContent = 'Download (kB/s):';
root.append(label);
input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'alt-speed-down';
input.id = makeUUID();
label.setAttribute('for', input.id);
root.append(input);
const alt_download_speed_input = input;
cal = PrefsDialog._createCheckAndLabel('alt-times-div', 'Scheduled times');
cal.check.dataset.key = 'alt-speed-time-enabled';
root.append(cal.root);
const alt_times_check = cal.check;
label = document.createElement('label');
label.textContent = 'From:';
PrefsDialog._enableIfChecked(label, cal.check);
root.append(label);
let select = document.createElement('select');
select.id = makeUUID();
select.dataset.key = 'alt-speed-time-begin';
PrefsDialog._initTimeDropDown(select);
label.setAttribute('for', select.id);
root.append(select);
PrefsDialog._enableIfChecked(select, cal.check);
const alt_from_select = select;
label = document.createElement('label');
label.textContent = 'To:';
PrefsDialog._enableIfChecked(label, cal.check);
root.append(label);
select = document.createElement('select');
select.id = makeUUID();
select.dataset.key = 'alt-speed-time-end';
PrefsDialog._initTimeDropDown(select);
label.setAttribute('for', select.id);
root.append(select);
PrefsDialog._enableIfChecked(select, cal.check);
const alt_to_select = select;
label = document.createElement('label');
label.textContent = 'On days:';
PrefsDialog._enableIfChecked(label, cal.check);
root.append(label);
select = document.createElement('select');
select.id = makeUUID();
select.dataset.key = 'alt-speed-time-day';
PrefsDialog._initDayDropDown(select);
label.setAttribute('for', select.id);
root.append(select);
PrefsDialog._enableIfChecked(select, cal.check);
const alt_days_select = select;
return {
alt_days_select,
alt_download_speed_input,
alt_from_select,
alt_times_check,
alt_to_select,
alt_upload_speed_input,
download_speed_check,
download_speed_input,
root,
upload_speed_check,
upload_speed_input,
};
}
static _createPeersPage() {
const root = document.createElement('div');
root.classList.add('prefs-peers-page');
let label = document.createElement('div');
label.textContent = 'Connections';
label.classList.add('section-label');
root.append(label);
let cal = PrefsDialog._createCheckAndLabel(
'max-peers-per-torrent-div',
'Max peers per torrent:'
);
root.append(cal.root);
const max_peers_per_torrent_check = cal.check;
let input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'peer-limit-per-torrent';
root.append(input);
PrefsDialog._enableIfChecked(input, cal.check);
const max_peers_per_torrent_input = input;
cal = PrefsDialog._createCheckAndLabel(
'max-peers-overall-div',
'Max peers overall:'
);
root.append(cal.root);
const max_peers_overall_check = cal.check;
input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'peer-limit-global';
root.append(input);
PrefsDialog._enableIfChecked(input, cal.check);
const max_peers_overall_input = input;
label = document.createElement('div');
label.textContent = 'Options';
label.classList.add('section-label');
root.append(label);
label = document.createElement('label');
label.textContent = 'Encryption mode:';
root.append(label);
const select = document.createElement('select');
select.id = makeUUID();
select.dataset.key = 'encryption';
select.options[0] = new Option('Prefer encryption', 'preferred');
select.options[1] = new Option('Allow encryption', 'tolerated');
select.options[2] = new Option('Require encryption', 'required');
root.append(select);
const encryption_select = select;
cal = PrefsDialog._createCheckAndLabel(
'use-pex-div',
'Use PEX to find more peers'
);
cal.check.title =
"PEX is a tool for exchanging peer lists with the peers you're connected to.";
cal.check.dataset.key = 'pex-enabled';
cal.label.title = cal.check.title;
root.append(cal.root);
const pex_check = cal.check;
cal = PrefsDialog._createCheckAndLabel(
'use-dht-div',
'Use DHT to find more peers'
);
cal.check.title = 'DHT is a tool for finding peers without a tracker.';
cal.check.dataset.key = 'dht-enabled';
cal.label.title = cal.check.title;
root.append(cal.root);
const dht_check = cal.check;
cal = PrefsDialog._createCheckAndLabel(
'use-lpd-div',
'Use LPD to find more peers'
);
cal.check.title = 'LPD is a tool for finding peers on your local network.';
cal.check.dataset.key = 'lpd-enabled';
cal.label.title = cal.check.title;
root.append(cal.root);
const lpd_check = cal.check;
label = document.createElement('div');
label.textContent = 'Blocklist';
label.classList.add('section-label');
root.append(label);
cal = PrefsDialog._createCheckAndLabel(
'blocklist-enabled-div',
'Enable blocklist:'
);
cal.check.dataset.key = 'blocklist-enabled';
root.append(cal.root);
const blocklist_enabled_check = cal.check;
input = document.createElement('input');
input.type = 'url';
input.value = 'http://www.example.com/blocklist';
input.dataset.key = 'blocklist-url';
root.append(input);
PrefsDialog._enableIfChecked(input, cal.check);
const blocklist_url_input = input;
label = document.createElement('label');
label.textContent = 'Blocklist has {n} rules';
label.dataset.key = 'blocklist-size';
label.classList.add('blocklist-size-label');
PrefsDialog._enableIfChecked(label, cal.check);
root.append(label);
const button = document.createElement('button');
button.classList.add('blocklist-update-button');
button.textContent = 'Update';
root.append(button);
PrefsDialog._enableIfChecked(button, cal.check);
const blocklist_update_button = button;
return {
blocklist_enabled_check,
blocklist_update_button,
blocklist_url_input,
dht_check,
encryption_select,
lpd_check,
max_peers_overall_check,
max_peers_overall_input,
max_peers_per_torrent_check,
max_peers_per_torrent_input,
pex_check,
root,
};
}
static _createNetworkPage() {
const root = document.createElement('div');
root.classList.add('prefs-network-page');
let label = document.createElement('div');
label.textContent = 'Listening Port';
label.classList.add('section-label');
root.append(label);
label = document.createElement('label');
label.textContent = 'Peer listening port:';
root.append(label);
const input = document.createElement('input');
input.type = 'number';
input.dataset.key = 'peer-port';
input.id = makeUUID();
label.setAttribute('for', input.id);
root.append(input);
const port_input = input;
const port_status_div = document.createElement('div');
port_status_div.classList.add('port-status');
label = document.createElement('label');
label.textContent = 'Port is';
port_status_div.append(label);
const port_status_label = document.createElement('label');
port_status_label.textContent = '?';
port_status_label.classList.add('port-status-label');
port_status_div.append(port_status_label);
root.append(port_status_div);
let cal = PrefsDialog._createCheckAndLabel(
'randomize-port',
'Randomize port on launch'
);
cal.check.dataset.key = 'peer-port-random-on-start';
root.append(cal.root);
const random_port_check = cal.check;
cal = PrefsDialog._createCheckAndLabel(
'port-forwarding',
'Use port forwarding from my router'
);
cal.check.dataset.key = 'port-forwarding-enabled';
root.append(cal.root);
const port_forwarding_check = cal.check;
label = document.createElement('div');
label.textContent = 'Options';
label.classList.add('section-label');
root.append(label);
cal = PrefsDialog._createCheckAndLabel(
'utp-enabled',
'Enable uTP for peer communication'
);
cal.check.dataset.key = 'utp-enabled';
root.append(cal.root);
const utp_check = cal.check;
return {
port_forwarding_check,
port_input,
port_status_label,
random_port_check,
root,
utp_check,
};
}
static _create() {
const pages = {
network: PrefsDialog._createNetworkPage(),
peers: PrefsDialog._createPeersPage(),
speed: PrefsDialog._createSpeedPage(),
torrents: PrefsDialog._createTorrentsPage(),
};
const elements = createTabsContainer('prefs-dialog', [
['prefs-tab-torrent', pages.torrents.root],
['prefs-tab-speed', pages.speed.root],
['prefs-tab-peers', pages.peers.root],
['prefs-tab-network', pages.network.root],
]);
return { ...elements, ...pages };
}
constructor(session_manager, remote) {
super();
this.closed = false;
this.session_manager = session_manager;
this.remote = remote;
this.update_soon = () =>
this._update(this.session_manager.session_properties);
this.elements = PrefsDialog._create();
this.elements.peers.blocklist_update_button.addEventListener(
'click',
(event_) => {
setTextContent(event_.target, 'Updating blocklist...');
this.remote.updateBlocklist();
this._setBlocklistButtonEnabled(false);
}
);
this.outside = new OutsideClickListener(this.elements.root);
this.outside.addEventListener('click', () => this.close());
Object.seal(this);
// listen for user input
const on_change = this._onControlChanged.bind(this);
const walk = (o) => {
for (const element of Object.values(o)) {
if (element.tagName === 'INPUT') {
switch (element.type) {
case 'checkbox':
case 'radio':
case 'number':
case 'text':
case 'url':
element.addEventListener('change', on_change);
break;
default:
console.trace(`unhandled input: ${element.type}`);
break;
}
}
}
};
walk(this.elements.network);
walk(this.elements.peers);
walk(this.elements.speed);
walk(this.elements.torrents);
this.session_manager.addEventListener('session-change', this.update_soon);
this.update_soon();
document.body.append(this.elements.root);
}
close() {
if (!this.closed) {
this.outside.stop();
this.session_manager.removeEventListener(
'session-change',
this.update_soon
);
this.elements.root.remove();
dispatchEvent(new Event('close'));
for (const key of Object.keys(this)) {
this[key] = null;
}
this.closed = true;
}
}
}

128
web/src/prefs.js Normal file
View File

@ -0,0 +1,128 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { debounce } from './utils.js';
export class Prefs extends EventTarget {
constructor() {
super();
this._cache = {};
this.dispatchPrefsChange = debounce((key, old_value, value) => {
const event = new Event('change');
Object.assign(event, { key, old_value, value });
this.dispatchEvent(event);
});
for (const [key, default_value] of Object.entries(Prefs._Defaults)) {
// populate the cache...
this._set(key, Prefs._getCookie(key, default_value));
// add property getter/setters...
Object.defineProperty(this, key.replaceAll('-', '_'), {
get: () => this._get(key),
set: (value) => {
this._set(key, value);
},
});
}
Object.seal(this);
}
entries() {
return Object.entries(this._cache);
}
keys() {
return Object.keys(this._cache);
}
_get(key) {
const { _cache } = this;
if (!Object.prototype.hasOwnProperty.call(_cache, key)) {
throw new Error(key);
}
return _cache[key];
}
_set(key, value) {
const { _cache } = this;
const old_value = _cache[key];
if (old_value !== value) {
_cache[key] = value;
Prefs._setCookie(key, value);
this.dispatchPrefsChange(key, old_value, value);
}
}
static _setCookie(key, value) {
const date = new Date();
date.setFullYear(date.getFullYear() + 1);
document.cookie = `${key}=${value}; SameSite=Strict; expires=${date.toGMTString()}; path=/`;
}
static _getCookie(key, fallback) {
const value = Prefs._readCookie(key);
if (value === null) {
return fallback;
}
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
if (value.match(/^\d+$/)) {
return Number.parseInt(value, 10);
}
return value;
}
static _readCookie(key) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${key}=`);
return parts.length === 2 ? parts.pop().split(';').shift() : null;
}
}
Prefs.AltSpeedEnabled = 'alt-speed-enabled';
Prefs.DisplayCompact = 'compact';
Prefs.DisplayFull = 'full';
Prefs.DisplayMode = 'display-mode';
Prefs.FilterActive = 'active';
Prefs.FilterAll = 'all';
Prefs.FilterDownloading = 'downloading';
Prefs.FilterFinished = 'finished';
Prefs.FilterMode = 'filter-mode';
Prefs.FilterPaused = 'paused';
Prefs.FilterSeeding = 'seeding';
Prefs.NotificationsEnabled = 'notifications-enabled';
Prefs.RefreshRate = 'refresh-rate-sec';
Prefs.SortAscending = 'ascending';
Prefs.SortByActivity = 'activity';
Prefs.SortByAge = 'age';
Prefs.SortByName = 'name';
Prefs.SortByProgress = 'progress';
Prefs.SortByQueue = 'queue';
Prefs.SortByRatio = 'ratio';
Prefs.SortBySize = 'size';
Prefs.SortByState = 'state';
Prefs.SortDescending = 'descending';
Prefs.SortDirection = 'sort-direction';
Prefs.SortMode = 'sort-mode';
Prefs._Defaults = {
[Prefs.AltSpeedEnabled]: false,
[Prefs.DisplayMode]: Prefs.DisplayFull,
[Prefs.FilterMode]: Prefs.FilterAll,
[Prefs.NotificationsEnabled]: false,
[Prefs.RefreshRate]: 5,
[Prefs.SortDirection]: Prefs.SortAscending,
[Prefs.SortMode]: Prefs.SortByName,
};

314
web/src/remote.js Normal file
View File

@ -0,0 +1,314 @@
/**
* Copyright © Charles Kerr, Dave Perrett, Malcolm Jarvis and Bruno Bierbaumer
*
* This file is licensed under the GPLv2.
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
*/
import { AlertDialog } from './alert-dialog.js';
export const RPC = {
_DaemonVersion: 'version',
_DownSpeedLimit: 'speed-limit-down',
_DownSpeedLimited: 'speed-limit-down-enabled',
_QueueMoveBottom: 'queue-move-bottom',
_QueueMoveDown: 'queue-move-down',
_QueueMoveTop: 'queue-move-top',
_QueueMoveUp: 'queue-move-up',
_Root: '../rpc',
_TurtleDownSpeedLimit: 'alt-speed-down',
_TurtleState: 'alt-speed-enabled',
_TurtleUpSpeedLimit: 'alt-speed-up',
_UpSpeedLimit: 'speed-limit-up',
_UpSpeedLimited: 'speed-limit-up-enabled',
};
export class Remote {
// TODO: decouple from controller
constructor(controller) {
this._controller = controller;
this._error = '';
this._session_id = '';
}
sendRequest(data, callback, context) {
const headers = new Headers();
headers.append('cache-control', 'no-cache');
headers.append('content-type', 'application/json');
headers.append('pragma', 'no-cache');
if (this._session_id) {
headers.append(Remote._SessionHeader, this._session_id);
}
let response_argument = null;
fetch(RPC._Root, {
body: JSON.stringify(data),
headers,
method: 'POST',
})
.then((response) => {
response_argument = response;
if (response.status === 409) {
const error = new Error(Remote._SessionHeader);
error.header = response.headers.get(Remote._SessionHeader);
throw error;
}
return response.json();
})
.then((payload) => {
if (callback) {
callback.call(context, payload, response_argument);
}
})
.catch((error) => {
if (error.message === Remote._SessionHeader) {
// copy the session header and try again
this._session_id = error.header;
this.sendRequest(data, callback, context);
return;
}
console.trace(error);
this._controller.togglePeriodicSessionRefresh(false);
this._controller.setCurrentPopup(
new AlertDialog({
heading: 'Connection failed',
message:
'Could not connect to the server. You may need to reload the page to reconnect.',
})
);
});
}
// TODO: return a Promise
loadDaemonPrefs(callback, context) {
const o = {
method: 'session-get',
};
this.sendRequest(o, callback, context);
}
checkPort(callback, context) {
const o = {
method: 'port-test',
};
this.sendRequest(o, callback, context);
}
renameTorrent(torrentIds, oldpath, newname, callback, context) {
const o = {
arguments: {
ids: torrentIds,
name: newname,
path: oldpath,
},
method: 'torrent-rename-path',
};
this.sendRequest(o, callback, context);
}
loadDaemonStats(callback, context) {
const o = {
method: 'session-stats',
};
this.sendRequest(o, callback, context);
}
updateTorrents(torrentIds, fields, callback, context) {
const o = {
arguments: {
fields,
format: 'table',
},
method: 'torrent-get',
};
if (torrentIds) {
o.arguments.ids = torrentIds;
}
this.sendRequest(o, (response) => {
const arguments_ = response['arguments'];
callback.call(context, arguments_.torrents, arguments_.removed);
});
}
getFreeSpace(dir, callback, context) {
const o = {
arguments: {
path: dir,
},
method: 'free-space',
};
this.sendRequest(o, (response) => {
const arguments_ = response['arguments'];
callback.call(context, arguments_.path, arguments_['size-bytes']);
});
}
changeFileCommand(torrentId, fileIndices, command) {
const arguments_ = {
ids: [torrentId],
};
arguments_[command] = fileIndices;
this.sendRequest(
{
arguments: arguments_,
method: 'torrent-set',
},
() => {
this._controller.refreshTorrents([torrentId]);
}
);
}
sendTorrentSetRequests(method, torrent_ids, arguments_, callback, context) {
if (!arguments_) {
arguments_ = {};
}
arguments_['ids'] = torrent_ids;
const o = {
arguments: arguments_,
method,
};
this.sendRequest(o, callback, context);
}
sendTorrentActionRequests(method, torrent_ids, callback, context) {
this.sendTorrentSetRequests(method, torrent_ids, null, callback, context);
}
startTorrents(torrent_ids, noqueue, callback, context) {
const name = noqueue ? 'torrent-start-now' : 'torrent-start';
this.sendTorrentActionRequests(name, torrent_ids, callback, context);
}
stopTorrents(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
'torrent-stop',
torrent_ids,
callback,
context
);
}
moveTorrents(torrent_ids, new_location, callback, context) {
this.sendTorrentSetRequests(
'torrent-set-location',
torrent_ids,
{
location: new_location,
move: true,
},
callback,
context
);
}
removeTorrents(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
'torrent-remove',
torrent_ids,
callback,
context
);
}
removeTorrentsAndData(torrents) {
const o = {
arguments: {
'delete-local-data': true,
ids: [],
},
method: 'torrent-remove',
};
if (torrents) {
for (let index = 0, length_ = torrents.length; index < length_; ++index) {
o.arguments.ids.push(torrents[index].getId());
}
}
this.sendRequest(o, () => {
this._controller.refreshTorrents();
});
}
verifyTorrents(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
'torrent-verify',
torrent_ids,
callback,
context
);
}
reannounceTorrents(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
'torrent-reannounce',
torrent_ids,
callback,
context
);
}
addTorrentByUrl(url, options) {
if (url.match(/^[\da-f]{40}$/i)) {
url = `magnet:?xt=urn:btih:${url}`;
}
const o = {
arguments: {
filename: url,
paused: options.paused,
},
method: 'torrent-add',
};
this.sendRequest(o, () => {
this._controller.refreshTorrents();
});
}
savePrefs(arguments_) {
const o = {
arguments: arguments_,
method: 'session-set',
};
this.sendRequest(o, () => {
this._controller.loadDaemonPrefs();
});
}
updateBlocklist() {
const o = {
method: 'blocklist-update',
};
this.sendRequest(o, () => {
this._controller.loadDaemonPrefs();
});
}
// Added queue calls
moveTorrentsToTop(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
RPC._QueueMoveTop,
torrent_ids,
callback,
context
);
}
moveTorrentsToBottom(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
RPC._QueueMoveBottom,
torrent_ids,
callback,
context
);
}
moveTorrentsUp(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
RPC._QueueMoveUp,
torrent_ids,
callback,
context
);
}
moveTorrentsDown(torrent_ids, callback, context) {
this.sendTorrentActionRequests(
RPC._QueueMoveDown,
torrent_ids,
callback,
context
);
}
}
Remote._SessionHeader = 'X-Transmission-Session-Id';

86
web/src/remove-dialog.js Normal file
View File

@ -0,0 +1,86 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { createDialogContainer } from './utils.js';
export class RemoveDialog extends EventTarget {
constructor(options) {
super();
// options: remote, torrents, trash
this.options = options;
this.elements = RemoveDialog._create(options);
this.elements.dismiss.addEventListener('click', () => this._onDismiss());
this.elements.confirm.addEventListener('click', () => this._onConfirm());
document.body.append(this.elements.root);
this.elements.dismiss.focus();
}
close() {
if (!this.closed) {
this.elements.root.remove();
this.dispatchEvent(new Event('close'));
for (const key of Object.keys(this)) {
delete this[key];
}
this.closed = true;
}
}
_onDismiss() {
this.close();
}
_onConfirm() {
const { torrents } = this.options;
if (torrents.length > 0) {
if (this.options.trash) {
this.options.remote.removeTorrentsAndData(torrents);
} else {
this.options.remote.removeTorrents(torrents);
}
}
this.close();
}
static _create(options) {
const { trash } = options;
const { heading, message } = RemoveDialog._createMessage(options);
const elements = createDialogContainer('remove-dialog');
elements.heading.textContent = heading;
elements.message.textContent = message;
elements.confirm.textContent = trash ? 'Trash' : 'Remove';
return elements;
}
static _createMessage(options) {
let heading = null;
let message = null;
const { torrents } = options;
const [torrent] = torrents;
if (options.trash && torrents.length === 1) {
heading = `Remove ${torrent.getName()} and delete data?`;
message =
'All data downloaded for this torrent will be deleted. Are you sure you want to remove it?';
} else if (options.trash) {
heading = `Remove ${torrents.length} transfers and delete data?`;
message =
'All data downloaded for these torrents will be deleted. Are you sure you want to remove them?';
} else if (torrents.length === 1) {
heading = `Remove ${torrent.getName()}?`;
message =
'Once removed, continuing the transfer will require the torrent file. Are you sure you want to remove it?';
} else {
heading = `Remove ${torrents.length} transfers?`;
message =
'Once removed, continuing the transfers will require the torrent files. Are you sure you want to remove them?';
}
return { heading, message };
}
}

86
web/src/rename-dialog.js Normal file
View File

@ -0,0 +1,86 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { createDialogContainer } from './utils.js';
export class RenameDialog extends EventTarget {
constructor(controller, remote) {
super();
this.controller = controller;
this.remote = remote;
this.elements = {};
this.torrents = [];
this.show();
}
show() {
const torrents = this.controller.getSelectedTorrents();
if (torrents.length !== 1) {
console.trace();
return;
}
this.torrents = torrents;
this.elements = RenameDialog._create();
this.elements.entry.value = torrents[0].getName();
document.body.append(this.elements.root);
this.elements.entry.focus();
}
close() {
this.elements.root.remove();
this.dispatchEvent(new Event('close'));
delete this.controller;
delete this.remote;
delete this.elements;
delete this.torrents;
}
_onDismiss() {
this.close();
}
_onConfirm() {
const [tor] = this.torrents;
const old_name = tor.getName();
const new_name = this.elements.entry.value;
this.remote.renameTorrent([tor.getId()], old_name, new_name, (response) => {
if (response.result === 'success') {
tor.refresh(response.arguments);
}
});
this.close();
}
static _create() {
const elements = createDialogContainer('rename-dialog');
elements.root.setAttribute('aria-label', 'Rename Torrent');
elements.heading.textContent = 'Enter new name:';
elements.confirm.textContent = 'Rename';
elements.dismiss.addEventListener('click', () => this._onDismiss());
elements.confirm.addEventListener('click', () => this._onConfirm());
const label = document.createElement('label');
label.setAttribute('for', 'torrent-rename-name');
label.textContent = 'Enter new name:';
elements.workarea.append(label);
const entry = document.createElement('input');
entry.setAttribute('type', 'text');
entry.id = 'torrent-rename-name';
elements.entry = entry;
elements.workarea.append(entry);
return elements;
}
}

View File

@ -0,0 +1,82 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { createDialogContainer } from './utils.js';
export class ShortcutsDialog extends EventTarget {
constructor(action_manager) {
super();
this.elements = ShortcutsDialog._create(action_manager);
this.elements.dismiss.addEventListener('click', () => this._onDismiss());
document.body.append(this.elements.root);
this.elements.dismiss.focus();
}
close() {
this.elements.root.remove();
this.dispatchEvent(new Event('close'));
delete this.elements;
}
_onDismiss() {
this.close();
}
static _create(action_manager) {
const elements = createDialogContainer('shortcuts-dialog');
elements.root.setAttribute('aria-label', 'Keyboard Shortcuts');
const table = document.createElement('table');
const thead = document.createElement('thead');
table.append(thead);
let tr = document.createElement('tr');
thead.append(tr);
let th = document.createElement('th');
th.textContent = 'Key';
tr.append(th);
th = document.createElement('th');
th.textContent = 'Action';
tr.append(th);
const tbody = document.createElement('tbody');
table.append(tbody);
const o = {};
for (const [shortcut, name] of action_manager.allShortcuts().entries()) {
const tokens = shortcut.split('+');
const sortKey = [tokens.pop(), ...tokens].join('+');
o[sortKey] = { name, shortcut };
}
for (const [, values] of Object.entries(o).sort()) {
const { name, shortcut } = values;
tr = document.createElement('tr');
tbody.append(tr);
let td = document.createElement('td');
td.textContent = shortcut.replaceAll('+', ' + ');
tr.append(td);
td = document.createElement('td');
td.textContent = action_manager.text(name);
tr.append(td);
}
elements.heading.textContent = 'Transmission';
elements.dismiss.textContent = 'Close';
elements.heading.textContent = 'Keyboard shortcuts';
elements.message.append(table);
elements.confirm.remove();
delete elements.confirm;
return elements;
}
}

View File

@ -0,0 +1,103 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { Formatter } from './formatter.js';
import {
Utils,
setTextContent,
createDialogContainer,
createInfoSection,
} from './utils.js';
export class StatisticsDialog extends EventTarget {
constructor(remote) {
super();
this.remote = remote;
const updateDaemon = () =>
this.remote.loadDaemonStats((data) => this._update(data.arguments));
const delay_msec = 5000;
this.interval = setInterval(updateDaemon, delay_msec);
updateDaemon();
this.elements = StatisticsDialog._create();
this.elements.dismiss.addEventListener('click', () => this._onDismiss());
document.body.append(this.elements.root);
this.elements.dismiss.focus();
}
close() {
if (!this.closed) {
clearInterval(this.interval);
this.elements.root.remove();
for (const key of Object.keys(this)) {
delete this[key];
}
this.closed = true;
}
}
_onDismiss() {
this.close();
}
_update(stats) {
console.log(stats);
const fmt = Formatter;
let s = stats['current-stats'];
let ratio = Utils.ratio(s.uploadedBytes, s.downloadedBytes);
setTextContent(this.elements.session.up, fmt.size(s.uploadedBytes));
setTextContent(this.elements.session.down, fmt.size(s.downloadedBytes));
setTextContent(this.elements.session.ratio, fmt.ratioString(ratio));
setTextContent(
this.elements.session.time,
fmt.timeInterval(s.secondsActive)
);
s = stats['cumulative-stats'];
ratio = Utils.ratio(s.uploadedBytes, s.downloadedBytes);
setTextContent(this.elements.total.up, fmt.size(s.uploadedBytes));
setTextContent(this.elements.total.down, fmt.size(s.downloadedBytes));
setTextContent(this.elements.total.ratio, fmt.ratioString(ratio));
setTextContent(this.elements.total.time, fmt.timeInterval(s.secondsActive));
}
static _create() {
const elements = createDialogContainer('statistics-dialog');
const { workarea } = elements;
elements.confirm.remove();
elements.dismiss.textContent = 'Close';
delete elements.confirm;
const heading_text = 'Statistics';
elements.root.setAttribute('aria-label', heading_text);
elements.heading.textContent = heading_text;
const labels = ['Uploaded:', 'Downloaded:', 'Ratio:', 'Running time:'];
let section = createInfoSection('Current session', labels);
const [sup, sdown, sratio, stime] = section.children;
const session = (elements.session = {});
session.up = sup;
session.down = sdown;
session.ratio = sratio;
session.time = stime;
workarea.append(section.root);
section = createInfoSection('Total', labels);
const [tup, tdown, tratio, ttime] = section.children;
const total = (elements.total = {});
total.up = tup;
total.down = tdown;
total.ratio = tratio;
total.time = ttime;
workarea.append(section.root);
return elements;
}
}

408
web/src/torrent-row.js Normal file
View File

@ -0,0 +1,408 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { Formatter } from './formatter.js';
import { Torrent } from './torrent.js';
import { setTextContent } from './utils.js';
class TorrentRendererHelper {
static getProgressInfo(controller, t) {
const status = t.getStatus();
const classList = ['torrent-progress-bar'];
let percent = null;
if (status === Torrent._StatusStopped) {
classList.push('paused');
}
if (t.needsMetaData()) {
classList.push('magnet');
percent = Math.round(t.getMetadataPercentComplete() * 100);
} else if (status === Torrent._StatusCheck) {
classList.push('verify');
percent = Math.round(t.getRecheckProgress() * 100);
} else if (t.getLeftUntilDone() > 0) {
classList.push('leech');
percent = Math.round(t.getPercentDone() * 100);
} else {
classList.push('seed');
const seed_ratio_limit = t.seedRatioLimit(controller);
percent =
seed_ratio_limit > 0
? (t.getUploadRatio() * 100) / seed_ratio_limit
: 100;
}
if (t.isQueued()) {
classList.push('queued');
}
return {
classList,
percent,
};
}
static renderProgressbar(controller, t, progressbar) {
const info = TorrentRendererHelper.getProgressInfo(controller, t);
progressbar.className = info.classList.join(' ');
progressbar.style['background-size'] = `${info.percent}% 100%, 100% 100%`;
}
static formatUL(t) {
return `${Formatter.speedBps(t.getUploadSpeed())}`;
}
static formatDL(t) {
return `${Formatter.speedBps(t.getDownloadSpeed())}`;
}
static formatETA(t) {
const eta = t.getETA();
if (eta < 0 || eta >= 999 * 60 * 60) {
return '';
}
return `ETA: ${Formatter.timeInterval(eta)}`;
}
}
///
export class TorrentRendererFull {
static getPeerDetails(t) {
const fmt = Formatter;
const error = t.getErrorMessage();
if (error) {
return error;
}
if (t.isDownloading()) {
const peer_count = t.getPeersConnected();
const webseed_count = t.getWebseedsSendingToUs();
if (webseed_count && peer_count) {
// Downloading from 2 of 3 peer(s) and 2 webseed(s)
return [
'Downloading from',
t.getPeersSendingToUs(),
'of',
fmt.countString('peer', 'peers', peer_count),
'and',
fmt.countString('web seed', 'web seeds', webseed_count),
'',
TorrentRendererHelper.formatDL(t),
TorrentRendererHelper.formatUL(t),
].join(' ');
}
if (webseed_count) {
// Downloading from 2 webseed(s)
return [
'Downloading from',
fmt.countString('web seed', 'web seeds', webseed_count),
'',
TorrentRendererHelper.formatDL(t),
TorrentRendererHelper.formatUL(t),
].join(' ');
}
// Downloading from 2 of 3 peer(s)
return [
'Downloading from',
t.getPeersSendingToUs(),
'of',
fmt.countString('peer', 'peers', peer_count),
'',
TorrentRendererHelper.formatDL(t),
TorrentRendererHelper.formatUL(t),
].join(' ');
}
if (t.isSeeding()) {
return [
'Seeding to',
t.getPeersGettingFromUs(),
'of',
fmt.countString('peer', 'peers', t.getPeersConnected()),
'-',
TorrentRendererHelper.formatUL(t),
].join(' ');
}
if (t.isChecking()) {
return [
'Verifying local data (',
Formatter.percentString(100 * t.getRecheckProgress()),
'% tested)',
].join('');
}
return t.getStateString();
}
static getProgressDetails(controller, t) {
if (t.needsMetaData()) {
let MetaDataStatus = 'retrieving';
if (t.isStopped()) {
MetaDataStatus = 'needs';
}
const percent = 100 * t.getMetadataPercentComplete();
return [
`Magnetized transfer - ${MetaDataStatus} metadata (`,
Formatter.percentString(percent),
'%)',
].join('');
}
const sizeWhenDone = t.getSizeWhenDone();
const totalSize = t.getTotalSize();
const is_done = t.isDone() || t.isSeeding();
const c = [];
if (is_done) {
if (totalSize === sizeWhenDone) {
// seed: '698.05 MiB'
c.push(Formatter.size(totalSize));
} else {
// partial seed: '127.21 MiB of 698.05 MiB (18.2%)'
c.push(
Formatter.size(sizeWhenDone),
' of ',
Formatter.size(t.getTotalSize()),
' (',
t.getPercentDoneStr(),
'%)'
);
}
// append UL stats: ', uploaded 8.59 GiB (Ratio: 12.3)'
c.push(
', uploaded ',
Formatter.size(t.getUploadedEver()),
' (Ratio ',
Formatter.ratioString(t.getUploadRatio()),
')'
);
} else {
// not done yet
c.push(
Formatter.size(sizeWhenDone - t.getLeftUntilDone()),
' of ',
Formatter.size(sizeWhenDone),
' (',
t.getPercentDoneStr(),
'%)'
);
}
// maybe append eta
if (!t.isStopped() && (!is_done || t.seedRatioLimit(controller) > 0)) {
c.push(' - ');
const eta = t.getETA();
if (eta < 0 || eta >= 999 * 60 * 60 /* arbitrary */) {
c.push('remaining time unknown');
} else {
c.push(Formatter.timeInterval(t.getETA()), ' remaining');
}
}
return c.join('');
}
// eslint-disable-next-line class-methods-use-this
render(controller, t, root) {
const is_stopped = t.isStopped();
// name
let e = root._name_container;
setTextContent(e, t.getName());
e.classList.toggle('paused', is_stopped);
// progressbar
TorrentRendererHelper.renderProgressbar(controller, t, root._progressbar);
root._progressbar.classList.add('full');
// peer details
const has_error = t.getError() !== Torrent._ErrNone;
e = root._peer_details_container;
e.classList.toggle('error', has_error);
setTextContent(e, TorrentRendererFull.getPeerDetails(t));
// progress details
e = root._progress_details_container;
setTextContent(e, TorrentRendererFull.getProgressDetails(controller, t));
// pause/resume button
e = root._toggle_running_button;
e.alt = is_stopped ? 'Resume' : 'Pause';
e.dataset.action = is_stopped ? 'resume' : 'pause';
}
// eslint-disable-next-line class-methods-use-this
createRow(torrent) {
const root = document.createElement('li');
root.className = 'torrent';
const icon = document.createElement('div');
icon.classList.add('icon');
icon.dataset.iconMimeType = torrent
.getPrimaryMimeType()
.split('/', 1)
.pop();
icon.dataset.iconMultifile = torrent.getFileCount() > 1 ? 'true' : 'false';
const name = document.createElement('div');
name.className = 'torrent-name';
const peers = document.createElement('div');
peers.className = 'torrent-peer-details';
const progress = document.createElement('div');
progress.classList.add('torrent-progress');
const progressbar = document.createElement('div');
progressbar.classList.add('torrent-progress-bar', 'full');
progress.append(progressbar);
const button = document.createElement('a');
button.className = 'torrent-pauseresume-button';
progress.append(button);
const details = document.createElement('div');
details.className = 'torrent-progress-details';
root.append(icon);
root.append(name);
root.append(peers);
root.append(progress);
root.append(details);
root._icon = icon;
root._name_container = name;
root._peer_details_container = peers;
root._progress_details_container = details;
root._progressbar = progressbar;
root._toggle_running_button = button;
return root;
}
}
///
export class TorrentRendererCompact {
static getPeerDetails(t) {
const errorMessage = t.getErrorMessage();
if (errorMessage) {
return errorMessage;
}
if (t.isDownloading()) {
const have_dn = t.getDownloadSpeed() > 0;
const have_up = t.getUploadSpeed() > 0;
if (!have_up && !have_dn) {
return 'Idle';
}
const s = [`${TorrentRendererHelper.formatETA(t)} `];
if (have_dn) {
s.push(TorrentRendererHelper.formatDL(t));
}
if (have_up) {
s.push(TorrentRendererHelper.formatUL(t));
}
return s.join(' ');
}
if (t.isSeeding()) {
return `Ratio: ${Formatter.ratioString(
t.getUploadRatio()
)}, ${TorrentRendererHelper.formatUL(t)}`;
}
return t.getStateString();
}
// eslint-disable-next-line class-methods-use-this
render(controller, t, root) {
// name
let e = root._name_container;
e.classList.toggle('paused', t.isStopped());
setTextContent(e, t.getName());
// peer details
const has_error = t.getError() !== Torrent._ErrNone;
e = root._details_container;
e.classList.toggle('error', has_error);
setTextContent(e, TorrentRendererCompact.getPeerDetails(t));
// progressbar
TorrentRendererHelper.renderProgressbar(controller, t, root._progressbar);
root._progressbar.classList.add('compact');
}
// eslint-disable-next-line class-methods-use-this
createRow(torrent) {
const progressbar = document.createElement('div');
progressbar.classList.add('torrent-progress-bar', 'compact');
const icon = document.createElement('div');
icon.classList.add('icon');
icon.dataset.iconMimeType = torrent
.getPrimaryMimeType()
.split('/', 1)
.pop();
icon.dataset.iconMultifile = torrent.getFileCount() > 1 ? 'true' : 'false';
const details = document.createElement('div');
details.className = 'torrent-peer-details compact';
const name = document.createElement('div');
name.className = 'torrent-name compact';
const root = document.createElement('li');
root.append(progressbar);
root.append(details);
root.append(name);
root.append(icon);
root.className = 'torrent compact';
root._progressbar = progressbar;
root._details_container = details;
root._name_container = name;
return root;
}
}
///
export class TorrentRow {
constructor(view, controller, torrent) {
this._view = view;
this._torrent = torrent;
this._element = view.createRow(torrent);
const update = () => this.render(controller);
this._torrent.addEventListener('dataChanged', update);
update();
}
getElement() {
return this._element;
}
render(controller) {
const tor = this.getTorrent();
if (tor) {
this._view.render(controller, tor, this.getElement());
}
}
isSelected() {
return this.getElement().classList.contains('selected');
}
getTorrent() {
return this._torrent;
}
getTorrentId() {
return this.getTorrent().getId();
}
}

668
web/src/torrent.js Normal file
View File

@ -0,0 +1,668 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import { Formatter } from './formatter.js';
import { Prefs } from './prefs.js';
import { deepEqual } from './utils.js';
/// DOMAINS
// example: "tracker.ubuntu.com" returns "ubuntu.com"
function getDomainName(host) {
const dot = host.indexOf('.');
if (dot !== host.lastIndexOf('.')) {
host = host.slice(dot + 1);
}
return host;
}
// example: "ubuntu.com" returns "Ubuntu"
function getReadableDomain(name) {
if (name.length > 0) {
name = name.charAt(0).toUpperCase() + name.slice(1);
}
const dot = name.indexOf('.');
if (dot !== -1) {
name = name.slice(0, dot);
}
return name;
}
// key: url string
// val: { domain, readable_domain }
const announce_to_domain_cache = {};
function getAnnounceDomain(announce) {
if (announce_to_domain_cache[announce]) {
return announce_to_domain_cache[announce];
}
const url = new URL(announce);
const domain = getDomainName(url.host);
const name = getReadableDomain(domain);
const o = { domain, name, url };
announce_to_domain_cache[announce] = o;
return o;
}
///
export class Torrent extends EventTarget {
constructor(data) {
super();
this.fieldObservers = {};
this.fields = {};
this.refresh(data);
}
notifyOnFieldChange(field, callback) {
this.fieldObservers[field] = this.fieldObservers[field] || [];
this.fieldObservers[field].push(callback);
}
setField(o, name, value) {
const old_value = o[name];
if (deepEqual(old_value, value)) {
return false;
}
const observers = this.fieldObservers[name];
if (o === this.fields && observers && observers.length > 0) {
for (const observer of observers) {
observer.call(this, value, old_value, name);
}
}
o[name] = value;
return true;
}
// fields.files is an array of unions of RPC's "files" and "fileStats" objects.
updateFiles(files) {
let changed = false;
const myfiles = this.fields.files || [];
const keys = ['length', 'name', 'bytesCompleted', 'wanted', 'priority'];
for (const [index, f] of files.entries()) {
const myfile = myfiles[index] || {};
for (const key of keys) {
if (key in f) {
changed |= this.setField(myfile, key, f[key]);
}
}
myfiles[index] = myfile;
}
this.fields.files = myfiles;
return changed;
}
static collateTrackers(trackers) {
return trackers.map((t) => t.announce.toLowerCase()).join('\t');
}
refreshFields(data) {
let changed = false;
for (const [key, value] of Object.entries(data)) {
switch (key) {
case 'files':
case 'fileStats': // merge files and fileStats together
changed |= this.updateFiles(value);
break;
case 'trackerStats': // 'trackerStats' is a superset of 'trackers'...
changed |= this.setField(this.fields, 'trackers', value);
break;
case 'trackers': // ...so only save 'trackers' if we don't have it already
if (!(key in this.fields)) {
changed |= this.setField(this.fields, key, value);
}
break;
default:
changed |= this.setField(this.fields, key, value);
}
}
return changed;
}
refresh(data) {
if (this.refreshFields(data)) {
this.dispatchEvent(new Event('dataChanged'));
}
}
///
// simple accessors
getComment() {
return this.fields.comment;
}
getCreator() {
return this.fields.creator;
}
getDateAdded() {
return this.fields.addedDate;
}
getDateCreated() {
return this.fields.dateCreated;
}
getDesiredAvailable() {
return this.fields.desiredAvailable;
}
getDownloadDir() {
return this.fields.downloadDir;
}
getDownloadSpeed() {
return this.fields.rateDownload;
}
getDownloadedEver() {
return this.fields.downloadedEver;
}
getError() {
return this.fields.error;
}
getErrorString() {
return this.fields.errorString;
}
getETA() {
return this.fields.eta;
}
getFailedEver() {
return this.fields.corruptEver;
}
getFiles() {
return this.fields.files || [];
}
getFile(index) {
return this.fields.files[index];
}
getFileCount() {
return this.fields['file-count'];
}
getHashString() {
return this.fields.hashString;
}
getHave() {
return this.getHaveValid() + this.getHaveUnchecked();
}
getHaveUnchecked() {
return this.fields.haveUnchecked;
}
getHaveValid() {
return this.fields.haveValid;
}
getId() {
return this.fields.id;
}
getLastActivity() {
return this.fields.activityDate;
}
getLeftUntilDone() {
return this.fields.leftUntilDone;
}
getMetadataPercentComplete() {
return this.fields.metadataPercentComplete;
}
getName() {
return this.fields.name || 'Unknown';
}
getPeers() {
return this.fields.peers || [];
}
getPeersConnected() {
return this.fields.peersConnected;
}
getPeersGettingFromUs() {
return this.fields.peersGettingFromUs;
}
getPeersSendingToUs() {
return this.fields.peersSendingToUs;
}
getPieceCount() {
return this.fields.pieceCount;
}
getPieceSize() {
return this.fields.pieceSize;
}
getPrimaryMimeType() {
return this.fields['primary-mime-type'];
}
getPrivateFlag() {
return this.fields.isPrivate;
}
getQueuePosition() {
return this.fields.queuePosition;
}
getRecheckProgress() {
return this.fields.recheckProgress;
}
getSeedRatioLimit() {
return this.fields.seedRatioLimit;
}
getSeedRatioMode() {
return this.fields.seedRatioMode;
}
getSizeWhenDone() {
return this.fields.sizeWhenDone;
}
getStartDate() {
return this.fields.startDate;
}
getStatus() {
return this.fields.status;
}
getTotalSize() {
return this.fields.totalSize;
}
getTrackers() {
const trackers = this.fields.trackers || [];
for (const tracker of trackers) {
if (tracker.announce && !tracker.domain) {
Object.assign(tracker, getAnnounceDomain(tracker.announce));
}
}
return this.fields.trackers;
}
getUploadSpeed() {
return this.fields.rateUpload;
}
getUploadRatio() {
return this.fields.uploadRatio;
}
getUploadedEver() {
return this.fields.uploadedEver;
}
getWebseedsSendingToUs() {
return this.fields.webseedsSendingToUs;
}
isFinished() {
return this.fields.isFinished;
}
// derived accessors
hasExtraInfo() {
return 'hashString' in this.fields;
}
isSeeding() {
return this.getStatus() === Torrent._StatusSeed;
}
isStopped() {
return this.getStatus() === Torrent._StatusStopped;
}
isChecking() {
return this.getStatus() === Torrent._StatusCheck;
}
isDownloading() {
return this.getStatus() === Torrent._StatusDownload;
}
isQueued() {
return (
this.getStatus() === Torrent._StatusDownloadWait ||
this.getStatus() === Torrent._StatusSeedWait
);
}
isDone() {
return this.getLeftUntilDone() < 1;
}
needsMetaData() {
return this.getMetadataPercentComplete() < 1;
}
getActivity() {
return this.getDownloadSpeed() + this.getUploadSpeed();
}
getPercentDoneStr() {
return Formatter.percentString(100 * this.getPercentDone());
}
getPercentDone() {
return this.fields.percentDone;
}
getStateString() {
switch (this.getStatus()) {
case Torrent._StatusStopped:
return this.isFinished() ? 'Seeding complete' : 'Paused';
case Torrent._StatusCheckWait:
return 'Queued for verification';
case Torrent._StatusCheck:
return 'Verifying local data';
case Torrent._StatusDownloadWait:
return 'Queued for download';
case Torrent._StatusDownload:
return 'Downloading';
case Torrent._StatusSeedWait:
return 'Queued for seeding';
case Torrent._StatusSeed:
return 'Seeding';
case null:
return 'Unknown';
default:
return 'Error';
}
}
seedRatioLimit(controller) {
switch (this.getSeedRatioMode()) {
case Torrent._RatioUseGlobal:
return controller.seedRatioLimit();
case Torrent._RatioUseLocal:
return this.getSeedRatioLimit();
default:
return -1;
}
}
getErrorMessage() {
const string = this.getErrorString();
switch (this.getError()) {
case Torrent._ErrTrackerWarning:
return `Tracker returned a warning: ${string}`;
case Torrent._ErrTrackerError:
return `Tracker returned an error: ${string}`;
case Torrent._ErrLocalError:
return `Error: ${string}`;
default:
return null;
}
}
getCollatedName() {
const f = this.fields;
if (!f.collatedName && f.name) {
f.collatedName = f.name.toLowerCase();
}
return f.collatedName || '';
}
getCollatedTrackers() {
const f = this.fields;
if (!f.collatedTrackers && f.trackers) {
f.collatedTrackers = Torrent.collateTrackers(f.trackers);
}
return f.collatedTrackers || '';
}
/****
*****
****/
testState(state) {
const s = this.getStatus();
switch (state) {
case Prefs.FilterActive:
return (
this.getPeersGettingFromUs() > 0 ||
this.getPeersSendingToUs() > 0 ||
this.getWebseedsSendingToUs() > 0 ||
this.isChecking()
);
case Prefs.FilterSeeding:
return s === Torrent._StatusSeed || s === Torrent._StatusSeedWait;
case Prefs.FilterDownloading:
return (
s === Torrent._StatusDownload || s === Torrent._StatusDownloadWait
);
case Prefs.FilterPaused:
return this.isStopped();
case Prefs.FilterFinished:
return this.isFinished();
default:
return true;
}
}
/**
* @param filter one of Prefs.Filter*
* @param search substring to look for, or null
* @return true if it passes the test, false if it fails
*/
test(state, search, tracker) {
// flter by state...
let pass = this.testState(state);
// maybe filter by text...
if (pass && search && search.length > 0) {
pass = this.getCollatedName().includes(search.toLowerCase());
}
// maybe filter by tracker...
if (pass && tracker && tracker.length > 0) {
pass = this.getCollatedTrackers().includes(tracker);
}
return pass;
}
static compareById(ta, tb) {
return ta.getId() - tb.getId();
}
static compareByName(ta, tb) {
return (
ta.getCollatedName().localeCompare(tb.getCollatedName()) ||
Torrent.compareById(ta, tb)
);
}
static compareByQueue(ta, tb) {
return ta.getQueuePosition() - tb.getQueuePosition();
}
static compareByAge(ta, tb) {
const a = ta.getDateAdded();
const b = tb.getDateAdded();
return b - a || Torrent.compareByQueue(ta, tb);
}
static compareByState(ta, tb) {
const a = ta.getStatus();
const b = tb.getStatus();
return b - a || Torrent.compareByQueue(ta, tb);
}
static compareByActivity(ta, tb) {
const a = ta.getActivity();
const b = tb.getActivity();
return b - a || Torrent.compareByState(ta, tb);
}
static compareByRatio(ta, tb) {
const a = ta.getUploadRatio();
const b = tb.getUploadRatio();
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
return Torrent.compareByState(ta, tb);
}
static compareByProgress(ta, tb) {
const a = ta.getPercentDone();
const b = tb.getPercentDone();
return a - b || Torrent.compareByRatio(ta, tb);
}
static compareBySize(ta, tb) {
const a = ta.getTotalSize();
const b = tb.getTotalSize();
return a - b || Torrent.compareByName(ta, tb);
}
static compareTorrents(a, b, sortMode, sortDirection) {
let index = 0;
switch (sortMode) {
case Prefs.SortByActivity:
index = Torrent.compareByActivity(a, b);
break;
case Prefs.SortByAge:
index = Torrent.compareByAge(a, b);
break;
case Prefs.SortByQueue:
index = Torrent.compareByQueue(a, b);
break;
case Prefs.SortByProgress:
index = Torrent.compareByProgress(a, b);
break;
case Prefs.SortBySize:
index = Torrent.compareBySize(a, b);
break;
case Prefs.SortByState:
index = Torrent.compareByState(a, b);
break;
case Prefs.SortByRatio:
index = Torrent.compareByRatio(a, b);
break;
case Prefs.SortByName:
index = Torrent.compareByName(a, b);
break;
default:
console.log(`Unrecognized sort mode: ${sortMode}`);
index = Torrent.compareByName(a, b);
break;
}
if (sortDirection === Prefs.SortDescending) {
index = -index;
}
return index;
}
/**
* @param torrents an array of Torrent objects
* @param sortMode one of Prefs.SortBy*
* @param sortDirection Prefs.SortAscending or Prefs.SortDescending
*/
static sortTorrents(torrents, sortMode, sortDirection) {
switch (sortMode) {
case Prefs.SortByActivity:
torrents.sort(this.compareByActivity);
break;
case Prefs.SortByAge:
torrents.sort(this.compareByAge);
break;
case Prefs.SortByName:
torrents.sort(this.compareByName);
break;
case Prefs.SortByProgress:
torrents.sort(this.compareByProgress);
break;
case Prefs.SortByQueue:
torrents.sort(this.compareByQueue);
break;
case Prefs.SortByRatio:
torrents.sort(this.compareByRatio);
break;
case Prefs.SortBySize:
torrents.sort(this.compareBySize);
break;
case Prefs.SortByState:
torrents.sort(this.compareByState);
break;
default:
console.log(`Unrecognized sort mode: ${sortMode}`);
torrents.sort(this.compareByName);
break;
}
if (sortDirection === Prefs.SortDescending) {
torrents.reverse();
}
return torrents;
}
}
// Torrent.fields.status
Torrent._StatusStopped = 0;
Torrent._StatusCheckWait = 1;
Torrent._StatusCheck = 2;
Torrent._StatusDownloadWait = 3;
Torrent._StatusDownload = 4;
Torrent._StatusSeedWait = 5;
Torrent._StatusSeed = 6;
// Torrent.fields.seedRatioMode
Torrent._RatioUseGlobal = 0;
Torrent._RatioUseLocal = 1;
Torrent._RatioUnlimited = 2;
// Torrent.fields.error
Torrent._ErrNone = 0;
Torrent._ErrTrackerWarning = 1;
Torrent._ErrTrackerError = 2;
Torrent._ErrLocalError = 3;
// TrackerStats' announceState
Torrent._TrackerInactive = 0;
Torrent._TrackerWaiting = 1;
Torrent._TrackerQueued = 2;
Torrent._TrackerActive = 3;
Torrent.Fields = {};
// commonly used fields which only need to be loaded once,
// either on startup or when a magnet finishes downloading its metadata
// finishes downloading its metadata
Torrent.Fields.Metadata = [
'addedDate',
'file-count',
'name',
'primary-mime-type',
'totalSize',
];
// commonly used fields which need to be periodically refreshed
Torrent.Fields.Stats = [
'error',
'errorString',
'eta',
'isFinished',
'isStalled',
'leftUntilDone',
'metadataPercentComplete',
'peersConnected',
'peersGettingFromUs',
'peersSendingToUs',
'percentDone',
'queuePosition',
'rateDownload',
'rateUpload',
'recheckProgress',
'seedRatioMode',
'seedRatioLimit',
'sizeWhenDone',
'status',
'trackers',
'downloadDir',
'uploadedEver',
'uploadRatio',
'webseedsSendingToUs',
];
// fields used by the inspector which only need to be loaded once
Torrent.Fields.InfoExtra = [
'comment',
'creator',
'dateCreated',
'files',
'hashString',
'isPrivate',
'pieceCount',
'pieceSize',
];
// fields used in the inspector which need to be periodically refreshed
Torrent.Fields.StatsExtra = [
'activityDate',
'corruptEver',
'desiredAvailable',
'downloadedEver',
'fileStats',
'haveUnchecked',
'haveValid',
'peers',
'startDate',
'trackerStats',
];

1105
web/src/transmission.js Normal file

File diff suppressed because it is too large Load Diff

278
web/src/utils.js Normal file
View File

@ -0,0 +1,278 @@
/*
* This file Copyright (C) 2020 Mnemosyne LLC
*
* It may be used under the GNU GPL versions 2 or 3
* or any future license endorsed by Mnemosyne LLC.
*/
import isEqual from 'lodash.isequal';
export class Utils {
/**
* Checks to see if the content actually changed before poking the DOM.
*/
static setInnerHTML(e, html) {
if (!e) {
return;
}
/* innerHTML is listed as a string, but the browser seems to change it.
* For example, "&infin;" gets changed to "∞" somewhere down the line.
* So, let's use an arbitrary different field to test our state... */
if (e.currentHTML !== html) {
e.currentHTML = html;
e.innerHTML = html;
}
}
/** Given a numerator and denominator, return a ratio string */
static ratio(numerator, denominator) {
let result = Math.floor((100 * numerator) / denominator) / 100;
// check for special cases
if (
result === Number.POSITIVE_INFINITY ||
result === Number.NEGATIVE_INFINITY
) {
result = -2;
} else if (Number.isNaN(result)) {
result = -1;
}
return result;
}
}
export function createTabsContainer(id, tabs, callback) {
const root = document.createElement('div');
root.id = id;
root.classList.add('tabs-container');
const buttons = document.createElement('div');
buttons.classList.add('tabs-buttons');
root.append(buttons);
const pages = document.createElement('div');
pages.classList.add('tabs-pages');
root.append(pages);
const button_array = [];
for (const [button_id, page] of tabs) {
const button = document.createElement('button');
button.id = button_id;
button.classList.add('tabs-button');
button.setAttribute('type', 'button');
buttons.append(button);
button_array.push(button);
page.classList.add('hidden', 'tabs-page');
pages.append(page);
button.addEventListener('click', () => {
for (const element of buttons.children) {
element.classList.toggle('selected', element === button);
}
for (const element of pages.children) {
element.classList.toggle('hidden', element !== page);
}
if (callback) {
callback(page);
}
});
}
button_array[0].classList.add('selected');
pages.children[0].classList.remove('hidden');
return {
buttons: button_array,
root,
};
}
export function createDialogContainer(id) {
const root = document.createElement('dialog');
root.classList.add('dialog-container', 'popup', id);
root.open = true;
root.setAttribute('role', 'dialog');
const win = document.createElement('div');
win.classList.add('dialog-window');
root.append(win);
const logo = document.createElement('div');
logo.classList.add('dialog-logo');
win.append(logo);
const heading = document.createElement('div');
heading.classList.add('dialog-heading');
win.append(heading);
const message = document.createElement('div');
message.classList.add('dialog-message');
win.append(message);
const workarea = document.createElement('div');
workarea.classList.add('dialog-workarea');
win.append(workarea);
const buttons = document.createElement('div');
buttons.classList.add('dialog-buttons');
win.append(buttons);
const bbegin = document.createElement('span');
bbegin.classList.add('dialog-buttons-begin');
buttons.append(bbegin);
const dismiss = document.createElement('button');
dismiss.classList.add('dialog-dismiss-button');
dismiss.textContent = 'Cancel';
buttons.append(dismiss);
const confirm = document.createElement('button');
confirm.textContent = 'OK';
buttons.append(confirm);
const bend = document.createElement('span');
bend.classList.add('dialog-buttons-end');
buttons.append(bend);
return {
confirm,
dismiss,
heading,
message,
root,
workarea,
};
}
export function makeUUID() {
// source: https://stackoverflow.com/a/2117523/6568470
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
);
}
export function createSection(title) {
const root = document.createElement('fieldset');
root.classList.add('section');
const legend = document.createElement('legend');
legend.classList.add('title');
legend.textContent = title;
root.append(legend);
const content = document.createElement('div');
content.classList.add('content');
root.append(content);
return { content, root };
}
export function createInfoSection(title, labels) {
const children = [];
const { root, content } = createSection(title);
for (const label_text of labels) {
const label_element = document.createElement('label');
label_element.textContent = label_text;
content.append(label_element);
const item = document.createElement('div');
item.id = makeUUID();
content.append(item);
label_element.setAttribute('for', item.id);
children.push(item);
}
return { children, root };
}
export function debounce(callback, wait = 100) {
let timeout = null;
return (...arguments_) => {
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
callback(arguments_);
}, wait);
}
};
}
export function deepEqual(a, b) {
return isEqual(a, b);
}
function setOrDeleteAttribute(element, attribute, b) {
if (b) {
element.setAttribute(attribute, true);
} else {
element.removeAttribute(attribute);
}
}
export function setEnabled(element, b) {
setOrDeleteAttribute(element, 'disabled', !b);
}
export function setChecked(element, b) {
setOrDeleteAttribute(element, 'checked', b);
}
function getBestMenuPos(r, bounds) {
let { x, y } = r;
const { width, height } = r;
if (x > bounds.x + bounds.width - width && x - width >= bounds.x) {
x -= width;
} else {
x = Math.min(x, bounds.x + bounds.width - width);
}
if (y > bounds.y + bounds.height - height && y - height >= bounds.y) {
y -= height;
} else {
y = Math.min(y, bounds.y + bounds.height - height);
}
return new DOMRect(x, y, width, height);
}
export function movePopup(popup, x, y, boundingElement) {
const initial_pos = new DOMRect(x, y, popup.clientWidth, popup.clientHeight);
const clamped_pos = getBestMenuPos(
initial_pos,
boundingElement.getBoundingClientRect()
);
popup.style.left = `${clamped_pos.left}px`;
popup.style.top = `${clamped_pos.top}px`;
}
export class OutsideClickListener extends EventTarget {
constructor(element) {
super();
this.listener = (event_) => {
if (!element.contains(event_.target)) {
this.dispatchEvent(new MouseEvent(event_.type, event_));
event_.preventDefault();
}
};
Object.seal(this);
this.start();
}
start() {
setTimeout(() => document.addEventListener('click', this.listener), 0);
}
stop() {
document.removeEventListener('click', this.listener);
}
}
export function setTextContent(e, text) {
if (e.textContent !== text) {
e.textContent = text;
}
}

View File

@ -0,0 +1,69 @@
# GOT BETTER ICONS? PULL REQUESTS WELCOMED
## Material Icons
https://github.com/google/material-design-icons/
https://material.io/resources/icons/
* analytics.svg
* horizontal-rule.svg
* pause-circle-active.svg (pause-circle-filled.svg)
* pause-circle-idle.svg (pause-circle-filled.svg)
* play-circle-active.svg (play-circle-filled.svg)
* play-circle-idle.svg (play-circle-filled.svg)
* router.svg
## Bootstrap Icons
https://github.com/twbs/icons
https://icons.getbootstrap.com/icons/
license: MIT
* chevron-down.svg
* chevron-up.svg
* files.svg
* gear-fill.svg
* lock-fill.svg
* search.svg
* three-dots-vertical.svg
## Adwaita Icons
https://gitlab.gnome.org/GNOME/adwaita-icon-theme
license: CC-BY-SA 3.0
* audio-x-generic.png
* folder.png
* font-x-generic.png
* image-x-generic.png
* package-x-generic.png
* text-x-generic.png
* video-x-generic.png
## SVG Repo
https://www.svgrepo.com/
license: CC0
* checkered-flag.svg
* cloud-networking.svg
* globa-server.svg
* team.svg
* top-speed.svg (license: MIT)
* up-and-down-arrows.svg
## Custom Icons
license: MIT
* blue-turtle.png
* logo.png
* toolbar-close.png
* toolbar-folder.png
* toolbar-info.png
* toolbar-pause.png
* toolbar-start.png
* turtle.png

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-5h2v5zm4 0h-2v-3h2v3zm0-5h-2v-2h2v2zm4 5h-2V7h2v10z"/></svg>

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 227 B

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="453.405px" height="453.405px" viewBox="0 0 453.405 453.405" style="enable-background:new 0 0 453.405 453.405;"
xml:space="preserve">
<g>
<g>
<path d="M382.08,60.394c-26.324-4.534-53.444-0.845-79.764,1.751c-26.223,2.587-53.604,5.753-79.585-0.397
c-30.592-7.241-49.945-27.294-64.216-54.464c-3.935,10.646-7.869,21.291-11.803,31.938
c-25.74,69.646-51.479,139.292-77.218,208.938L0,436.203l26.838,9.919l62.541-169.227c11.607,12.383,25.937,21.375,44.333,25.729
c25.979,6.146,53.363,2.986,79.584,0.398c26.318-2.601,53.441-6.287,79.765-1.752c33.826,5.826,55.682,26.086,71.323,55.871
c29.677-80.291,59.348-160.583,89.021-240.876C437.761,86.479,415.911,66.222,382.08,60.394z M385.379,203.349
c-13.234-11.169-27.441-18.638-44.57-21.931c-5.715,15.458-11.428,30.916-17.141,46.374c17.131,3.295,31.335,10.764,44.572,21.932
c-5.239,14.176-10.479,28.353-15.717,42.526c-13.234-11.168-27.443-18.642-44.573-21.93c5.239-14.177,10.479-28.353,15.718-42.528
c-17.442-2.813-34.473-2.797-52.072-1.72c-5.238,14.176-10.479,28.353-15.717,42.528c-18.21,1.471-36.358,3.56-54.567,5.028
c5.238-14.178,10.478-28.353,15.716-42.526c-17.599,1.078-34.631,1.096-52.073-1.719c-5.239,14.176-10.478,28.352-15.717,42.526
c-17.128-3.29-31.341-10.763-44.572-21.933c5.238-14.174,10.478-28.351,15.716-42.525c13.236,11.17,27.442,18.64,44.573,21.932
c5.712-15.458,11.427-30.918,17.139-46.376c-17.13-3.285-31.338-10.766-44.573-21.93c5.714-15.46,11.427-30.92,17.14-46.378
c13.236,11.173,27.442,18.635,44.572,21.933c5.239-14.176,10.478-28.351,15.717-42.525c17.442,2.813,34.476,2.797,52.073,1.717
c-5.238,14.175-10.478,28.351-15.717,42.526c18.209-1.471,36.357-3.558,54.567-5.028c5.238-14.175,10.479-28.351,15.717-42.527
c17.601-1.078,34.629-1.095,52.072,1.719c-5.239,14.176-10.478,28.351-15.717,42.528c17.131,3.294,31.335,10.761,44.573,21.93
C396.806,172.431,391.095,187.891,385.379,203.349z"/>
<path d="M234.167,184.726c-5.713,15.459-11.426,30.917-17.14,46.376c18.21-1.472,36.359-3.56,54.568-5.03
c5.713-15.457,11.426-30.916,17.139-46.374C270.523,181.169,252.376,183.257,234.167,184.726z"/>
<path d="M234.167,184.726c5.714-15.458,11.427-30.918,17.14-46.375c-17.604,1.075-34.629,1.093-52.075-1.718
c-5.713,15.458-11.426,30.917-17.139,46.375C199.536,185.824,216.566,185.807,234.167,184.726z"/>
<path d="M305.873,133.323c-5.713,15.458-11.426,30.916-17.139,46.375c17.601-1.075,34.629-1.093,52.073,1.72
c5.712-15.458,11.426-30.917,17.138-46.375C340.503,132.229,323.474,132.243,305.873,133.323z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,3 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-chevron-down" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@ -0,0 +1,3 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-chevron-up" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -0,0 +1,3 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-diagram-3-fill" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M6 3.5A1.5 1.5 0 0 1 7.5 2h1A1.5 1.5 0 0 1 10 3.5v1A1.5 1.5 0 0 1 8.5 6v1H14a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 2 7h5.5V6A1.5 1.5 0 0 1 6 4.5v-1zm-6 8A1.5 1.5 0 0 1 1.5 10h1A1.5 1.5 0 0 1 4 11.5v1A1.5 1.5 0 0 1 2.5 14h-1A1.5 1.5 0 0 1 0 12.5v-1zm6 0A1.5 1.5 0 0 1 7.5 10h1a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 8.5 14h-1A1.5 1.5 0 0 1 6 12.5v-1zm6 0a1.5 1.5 0 0 1 1.5-1.5h1a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5v-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 683 B

View File

@ -0,0 +1,4 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-files" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M4 2h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm0 1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4z"/>
<path d="M6 0h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2v-1a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1H4a2 2 0 0 1 2-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

BIN
web/style/images/folder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

View File

@ -0,0 +1,3 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-gear-fill" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 0 0-5.86 2.929 2.929 0 0 0 0 5.858z"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" fill-rule="evenodd" height="24" width="24"/><rect fill-rule="evenodd" height="2" width="16" x="4" y="11"/></g></svg>

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-lock-fill" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 9a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V9z"/>
<path fill-rule="evenodd" d="M4.5 4a3.5 3.5 0 1 1 7 0v3h-1V4a2.5 2.5 0 0 0-5 0v3h-1V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#800" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#999" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#080" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#999" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M20.2 5.9l.8-.8C19.6 3.7 17.8 3 16 3s-3.6.7-5 2.1l.8.8C13 4.8 14.5 4.2 16 4.2s3 .6 4.2 1.7zm-.9.8c-.9-.9-2.1-1.4-3.3-1.4s-2.4.5-3.3 1.4l.8.8c.7-.7 1.6-1 2.5-1 .9 0 1.8.3 2.5 1l.8-.8zM19 13h-2V9h-2v4H5c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zM8 18H6v-2h2v2zm3.5 0h-2v-2h2v2zm3.5 0h-2v-2h2v2z"/></svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@ -0,0 +1,4 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/>
<path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

18
web/style/images/team.svg Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="122.699px" height="122.699px" viewBox="0 0 122.699 122.699" style="enable-background:new 0 0 122.699 122.699;"
xml:space="preserve">
<g>
<circle cx="19.5" cy="12.2" r="12.1"/>
<path d="M6,66.699h1.2v24c0,3.301,2.7,6,6,6h12.6c3.3,0,6-2.699,6-6V89.3c-1.1-2.101-1.8-4.5-1.8-7v-31.4c0-6.1,3.7-11.4,9-13.7
v-2.4c0-3.3-2.7-6-6-6H6c-3.3,0-6,2.7-6,6v25.9C0,64,2.6,66.699,6,66.699z"/>
<circle cx="103.3" cy="12.2" r="12.1"/>
<path d="M83.699,34.7v2.4c5.301,2.3,9,7.6,9,13.7v31.3c0,2.5-0.6,4.9-1.799,7v1.4c0,3.3,2.699,6,6,6h12.6c3.3,0,6-2.7,6-6v-24
h1.199c3.301,0,6-2.7,6-6V34.7c0-3.3-2.699-6-6-6h-27C86.4,28.7,83.699,31.399,83.699,34.7z"/>
<path d="M39.1,50.899L39.1,50.899v9.8v21.6c0,3.3,2.7,6,6,6h2.3v28.3c0,3.3,2.7,6,6,6h16.1c3.3,0,6-2.7,6-6v-28.4h2.3
c3.3,0,6-2.699,6-6V60.7v-9.8l0,0c0-3.3-2.7-6-6-6H45.1C41.7,44.899,39.1,47.6,39.1,50.899z"/>
<circle cx="61.4" cy="26" r="13.9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

View File

@ -0,0 +1,3 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-three-dots-vertical" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 677 B

After

Width:  |  Height:  |  Size: 677 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 183 B

After

Width:  |  Height:  |  Size: 183 B

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="71.753px" height="71.753px" viewBox="0 0 71.753 71.753" style="enable-background:new 0 0 71.753 71.753;"
xml:space="preserve">
<g>
<path d="M39.798,20.736H28.172v20.738L11.625,41.47V20.736H0L19.899,0.839L39.798,20.736z M51.855,70.914l19.897-19.896H60.129
V30.282l-16.547-0.004v20.74H31.957L51.855,70.914z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 B

Some files were not shown because too many files have changed in this diff Show More