pdrive 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pdrive/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .backend.web import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env python
2
+ # pdrive - a web based document browser
3
+ # Copyright (C) 2017 Huy Nguyen
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env python
2
+ # pdrive - a web based document browser
3
+ # Copyright (C) 2017 Huy Nguyen
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ """Move files and folders to the trash of the operating system.
19
+
20
+ Deleting from a file manager should be undoable, so nothing is unlinked here:
21
+ every item is handed to the trash the desktop itself uses. One backend per
22
+ platform, all of them standard library only:
23
+
24
+ * macOS - a rename into the trash of the volume the item lives on: ~/.Trash
25
+ for the boot volume, /Volumes/<volume>/.Trashes/<uid> for anything else.
26
+ * Windows - SHFileOperationW with FOF_ALLOWUNDO, the call Explorer itself
27
+ makes, so the item lands in the Recycle Bin of its own drive and keeps
28
+ working with "Restore".
29
+ * Linux and the BSDs - the freedesktop.org Trash specification that GNOME,
30
+ KDE and XFCE read: a move into $XDG_DATA_HOME/Trash (usually
31
+ ~/.local/share/Trash) plus a .trashinfo record holding the original path.
32
+ Items on another filesystem go to the per user trash at the top of their
33
+ own mount, as the specification asks.
34
+ """
35
+
36
+ import datetime
37
+ import errno
38
+ import os
39
+ import shutil
40
+ import stat
41
+ import sys
42
+ import urllib.parse
43
+
44
+
45
+ def move_to_trash(path):
46
+ """Move `path` to the trash and return where it ended up.
47
+
48
+ The Windows Recycle Bin does not report the name it gave the item, so the
49
+ return value is None there. Raises OSError when the move fails, e.g. when
50
+ the file is gone or the trash folder is not writable.
51
+ """
52
+ path = os.path.abspath(os.path.expanduser(path))
53
+ if not os.path.lexists(path):
54
+ raise FileNotFoundError(
55
+ errno.ENOENT, "no such file or directory", path)
56
+
57
+ if sys.platform == "darwin":
58
+ return _trash_macos(path)
59
+ if os.name == "nt":
60
+ return _trash_windows(path)
61
+ return _trash_freedesktop(path)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Shared helpers
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def _split_name(name):
69
+ """Split a file name into a stem and an extension, dot files included.
70
+
71
+ "notes" -> ("notes", ""), "notes.txt" -> ("notes", ".txt"),
72
+ ".bashrc" -> (".bashrc", "")
73
+ """
74
+ stem, dot, extension = name.rpartition(".")
75
+ if not stem:
76
+ return name, ""
77
+ return stem, dot + extension
78
+
79
+
80
+ def _free_name(directory, name):
81
+ """A variant of `name` that is not taken in `directory` yet.
82
+
83
+ Finder's habit is copied here: "report.pdf", "report 2.pdf", ...
84
+ """
85
+ stem, extension = _split_name(name)
86
+ candidate = name
87
+ counter = 1
88
+ while os.path.lexists(os.path.join(directory, candidate)):
89
+ counter += 1
90
+ candidate = "%s %d%s" % (stem, counter, extension)
91
+ return candidate
92
+
93
+
94
+ def _free_trash_name(files_dir, info_dir, name):
95
+ """A name free in both halves of a freedesktop trash, since the item and
96
+ its .trashinfo record have to share it."""
97
+ stem, extension = _split_name(name)
98
+ candidate = name
99
+ counter = 1
100
+ while (os.path.lexists(os.path.join(files_dir, candidate)) or
101
+ os.path.lexists(os.path.join(info_dir, candidate + ".trashinfo"))):
102
+ counter += 1
103
+ candidate = "%s %d%s" % (stem, counter, extension)
104
+ return candidate
105
+
106
+
107
+ def _move_into(directory, path):
108
+ """Move `path` into `directory`, giving it a free name, and return the new
109
+ path. rename() keeps it atomic; a copy is only made when the trash sits on
110
+ another filesystem."""
111
+ name = _free_name(directory, os.path.basename(path.rstrip("/\\")))
112
+ destination = os.path.join(directory, name)
113
+ try:
114
+ os.rename(path, destination)
115
+ except OSError as error:
116
+ if error.errno != errno.EXDEV:
117
+ raise
118
+ shutil.move(path, destination)
119
+ return destination
120
+
121
+
122
+ def _mount_point(path):
123
+ """The top of the filesystem `path` lives on."""
124
+ path = os.path.dirname(os.path.abspath(path))
125
+ while not os.path.ismount(path):
126
+ parent = os.path.dirname(path)
127
+ if parent == path:
128
+ break
129
+ path = parent
130
+ return path
131
+
132
+
133
+ def _on_same_device(path, other):
134
+ """True when `path` sits on the same filesystem as `other`, so that it can
135
+ be renamed instead of copied."""
136
+ try:
137
+ return os.stat(path).st_dev == os.stat(other).st_dev
138
+ except OSError:
139
+ return False
140
+
141
+
142
+ def _usable_directory(directory):
143
+ """Create `directory` if needed and report whether it can be written to."""
144
+ try:
145
+ os.makedirs(directory, exist_ok=True)
146
+ return os.access(directory, os.W_OK | os.X_OK)
147
+ except OSError:
148
+ return False
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # macOS
153
+ # ---------------------------------------------------------------------------
154
+
155
+ def _trash_macos(path):
156
+ home = os.path.expanduser("~")
157
+ home_trash = os.path.join(home, ".Trash")
158
+
159
+ if _on_same_device(path, home):
160
+ os.makedirs(home_trash, exist_ok=True)
161
+ return _move_into(home_trash, path)
162
+
163
+ # another volume: macOS keeps its items in the volume's own trash so that
164
+ # emptying the Trash does not have to copy gigabytes across drives
165
+ volume_trash = os.path.join(
166
+ _mount_point(path), ".Trashes", str(os.getuid()))
167
+ if _usable_directory(volume_trash):
168
+ return _move_into(volume_trash, path)
169
+
170
+ # a volume the user cannot write a .Trashes folder into: the home trash is
171
+ # a copy across filesystems, which still beats destroying the item
172
+ os.makedirs(home_trash, exist_ok=True)
173
+ return _move_into(home_trash, path)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Windows
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _trash_windows(path):
181
+ import ctypes
182
+ from ctypes import wintypes
183
+
184
+ class SHFILEOPSTRUCTW(ctypes.Structure):
185
+ _fields_ = [
186
+ ("hwnd", wintypes.HWND),
187
+ ("wFunc", wintypes.UINT),
188
+ ("pFrom", wintypes.LPCWSTR),
189
+ ("pTo", wintypes.LPCWSTR),
190
+ ("fFlags", ctypes.c_uint16),
191
+ ("fAnyOperationsAborted", wintypes.BOOL),
192
+ ("hNameMappings", ctypes.c_void_p),
193
+ ("lpszProgressTitle", wintypes.LPCWSTR),
194
+ ]
195
+
196
+ FO_DELETE = 3
197
+ FOF_SILENT = 0x0004
198
+ FOF_NOCONFIRMATION = 0x0010
199
+ FOF_ALLOWUNDO = 0x0040 # the flag that turns a delete into a recycle
200
+ FOF_NOERRORUI = 0x0400
201
+ FOF_NOCONFIRMMKDIR = 0x0200
202
+
203
+ # SHFileOperationW only understands the old MAX_PATH (260) interface, so a
204
+ # long path is handed over through its 8.3 short name when it has one
205
+ if len(path) >= 260:
206
+ buffer = ctypes.create_unicode_buffer(260)
207
+ if ctypes.windll.kernel32.GetShortPathNameW(path, buffer, 260):
208
+ path = buffer.value
209
+
210
+ operation = SHFILEOPSTRUCTW()
211
+ operation.wFunc = FO_DELETE
212
+ # the source list is NUL terminated, and ctypes adds the second NUL
213
+ operation.pFrom = path + "\0"
214
+ operation.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT |
215
+ FOF_NOERRORUI | FOF_NOCONFIRMMKDIR)
216
+
217
+ result = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(operation))
218
+ if result != 0 or operation.fAnyOperationsAborted:
219
+ raise OSError(
220
+ errno.EIO,
221
+ "the Recycle Bin refused the item (SHFileOperationW code %d)"
222
+ % result, path)
223
+ # the Recycle Bin does not tell us the name the item was given
224
+ return None
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Linux, BSD - freedesktop.org Trash specification
229
+ # ---------------------------------------------------------------------------
230
+
231
+ def _freedesktop_trash_dir(path):
232
+ data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(
233
+ os.path.expanduser("~"), ".local", "share")
234
+ home_trash = os.path.join(data_home, "Trash")
235
+
236
+ if _on_same_device(path, data_home):
237
+ return home_trash
238
+
239
+ # The specification prefers a trash on the item's own mount, and only then
240
+ # a copy into the home trash. ".Trash" is shared between users when it is
241
+ # sticky and owned by root, in which case each user gets a subfolder.
242
+ mount = _mount_point(path)
243
+ shared = os.path.join(mount, ".Trash")
244
+ try:
245
+ info = os.stat(shared)
246
+ shared_ok = (stat.S_ISDIR(info.st_mode) and
247
+ info.st_uid == 0 and
248
+ info.st_mode & stat.S_ISVTX)
249
+ except OSError:
250
+ shared_ok = False
251
+
252
+ if shared_ok:
253
+ candidate = os.path.join(shared, str(os.getuid()))
254
+ else:
255
+ candidate = os.path.join(mount, ".Trash-%d" % os.getuid())
256
+
257
+ if _usable_directory(candidate):
258
+ return candidate
259
+ return home_trash
260
+
261
+
262
+ def _trash_freedesktop(path):
263
+ trash = _freedesktop_trash_dir(path)
264
+ files_dir = os.path.join(trash, "files")
265
+ info_dir = os.path.join(trash, "info")
266
+ os.makedirs(files_dir, exist_ok=True)
267
+ os.makedirs(info_dir, exist_ok=True)
268
+
269
+ # written before the move, as the specification requires, so that a crash
270
+ # halfway through leaves a record behind instead of an orphan
271
+ name = _free_trash_name(files_dir, info_dir,
272
+ os.path.basename(path.rstrip("/\\")))
273
+ record = "[Trash Info]\nPath=%s\nDeletionDate=%s\n" % (
274
+ urllib.parse.quote(path, safe="/"),
275
+ datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
276
+ )
277
+ info_path = os.path.join(info_dir, name + ".trashinfo")
278
+ with open(info_path, "w", encoding="utf-8") as handle:
279
+ handle.write(record)
280
+
281
+ try:
282
+ destination = os.path.join(files_dir, name)
283
+ try:
284
+ os.rename(path, destination)
285
+ except OSError as error:
286
+ if error.errno != errno.EXDEV:
287
+ raise
288
+ shutil.move(path, destination)
289
+ except OSError:
290
+ # do not leave a .trashinfo pointing at nothing
291
+ try:
292
+ os.unlink(info_path)
293
+ except OSError:
294
+ pass
295
+ raise
296
+ return destination
pdrive/backend/web.py ADDED
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python
2
+ # pdrive - a web based document browser
3
+ # Copyright (C) 2017 Huy Nguyen
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ import logging
19
+ from flask import cli
20
+ import sys
21
+ import platform
22
+ import os.path as pth
23
+ import os
24
+ import json
25
+ import shutil
26
+
27
+ from argparse import ArgumentParser
28
+ from flask import Flask
29
+ from flask import make_response
30
+ from flask import request
31
+ from flask import render_template
32
+ from flask import send_file
33
+ from flask import send_from_directory
34
+
35
+ try:
36
+ from .trash import move_to_trash
37
+ except ImportError: # running this file as a plain script, not a package
38
+ from trash import move_to_trash
39
+
40
+ frontend_path = pth.join(
41
+ pth.split(pth.split(pth.abspath(__file__))[0])[0], "frontend")
42
+
43
+ print(frontend_path)
44
+
45
+ app = Flask(__name__,
46
+ static_folder=pth.join(frontend_path, "dist"),
47
+ static_url_path="")
48
+
49
+
50
+ basedir = os.path.abspath(os.curdir)
51
+ hostname = platform.node()
52
+
53
+
54
+ @app.route("/api", methods=["GET", "POST"])
55
+ def api():
56
+ global basedir
57
+ payload = request.form.get("payload")
58
+ if payload is None:
59
+ nodes = [process(pth.join(basedir, n)) for n in os.listdir(basedir)]
60
+ data = {
61
+ "basedir": basedir,
62
+ "cwd": basedir,
63
+ "hostname": hostname,
64
+ "nodes": nodes
65
+ }
66
+ return json.dumps(data)
67
+ else:
68
+ payload = json.loads(payload)
69
+ print(payload)
70
+ if payload["command"] == "trash":
71
+ # Deleting is a move to the trash of the host system (the Recycle
72
+ # Bin, ~/.Trash, the freedesktop trash, ...), so an accidental
73
+ # click is always undoable. Every path is reported back: some may
74
+ # have made it while others did not, and the UI says so.
75
+ trashed = []
76
+ failed = []
77
+ for path in payload["paths"]:
78
+ try:
79
+ trashed.append(move_to_trash(path) or path)
80
+ except OSError as error:
81
+ failed.append({"path": path, "error": str(error)})
82
+ data = dict(payload)
83
+ data["trashed"] = trashed
84
+ data["failed"] = failed
85
+ return json.dumps(data)
86
+ elif payload["command"] == "rm":
87
+ # Permanent deletion. The UI only sends this after it has asked the
88
+ # user to confirm, because there is nothing to recover from here.
89
+ deleted = []
90
+ failed = []
91
+ for path in payload["paths"]:
92
+ try:
93
+ if os.path.isdir(path) and not os.path.islink(path):
94
+ shutil.rmtree(path)
95
+ else:
96
+ os.unlink(path)
97
+ deleted.append(path)
98
+ except OSError as error:
99
+ failed.append({"path": path, "error": str(error)})
100
+ data = dict(payload)
101
+ data["deleted"] = deleted
102
+ data["failed"] = failed
103
+ return json.dumps(data)
104
+ elif payload["command"] == "ls":
105
+ path = payload["path"]
106
+ nodes = [process(pth.join(path, n)) for n in os.listdir(path)]
107
+ data = {
108
+ "basedir": basedir,
109
+ "cwd": path,
110
+ "hostname": hostname,
111
+ "nodes": nodes
112
+ }
113
+ return json.dumps(data)
114
+ elif payload["command"] == "mv":
115
+ paths = payload["paths"]
116
+ destination = payload["destination"]
117
+ for p in paths:
118
+ shutil.move(p, destination)
119
+ return json.dumps(payload)
120
+ elif payload["command"] == "cp":
121
+ paths = payload["paths"]
122
+ destination = payload["destination"]
123
+ for p in paths:
124
+ name = os.path.split(p)[1]
125
+ copy = shutil.copytree if os.path.isdir(p) else shutil.copy
126
+ dest = pth.join(destination, name) if os.path.isdir(
127
+ destination) and os.path.isdir(p) else destination
128
+ copy(p, dest)
129
+ return json.dumps(payload)
130
+
131
+ elif payload["command"] == "mkdir":
132
+ path = payload["path"]
133
+ if not os.path.exists(path):
134
+ os.makedirs(path)
135
+ return json.dumps(process(path))
136
+
137
+ return json.dumps(payload)
138
+
139
+
140
+ @app.route("/serve")
141
+ def serve_file():
142
+ fpath = request.args.get("file")
143
+ if not fpath or not os.path.isfile(fpath):
144
+ return make_response("file not found", 404)
145
+ response = send_file(fpath)
146
+ return response
147
+
148
+
149
+ @app.route("/download")
150
+ def download_file():
151
+ fpath = request.args.get("file")
152
+ if not fpath or not os.path.isfile(fpath):
153
+ return make_response("file not found", 404)
154
+ name = os.path.split(fpath)[1]
155
+ response = send_file(fpath)
156
+ response.headers['Content-Disposition'] = "attachment; filename=%s" % name
157
+ return response
158
+
159
+
160
+ @app.route("/upload", methods=["POST"])
161
+ def upload_file():
162
+ fd = request.files['file']
163
+ basedir = request.form["basedir"]
164
+ fpath = os.path.join(basedir, fd.filename)
165
+ fd.save(fpath)
166
+ node = process(fpath)
167
+ return json.dumps(node)
168
+
169
+
170
+ @app.route("/")
171
+ def index_page():
172
+ return send_file(pth.join(frontend_path, "dist", "index.html"))
173
+
174
+
175
+ def process(path):
176
+ import os
177
+ import stat
178
+ import datetime
179
+ import mimetypes
180
+
181
+ def timestamp(seconds):
182
+ # local time, and a format that also sorts correctly as a string
183
+ return datetime.datetime.fromtimestamp(seconds).strftime("%Y-%m-%d %H:%M:%S")
184
+
185
+ name = pth.split(path)[1]
186
+ statinfo = os.stat(path)
187
+
188
+ if stat.S_ISDIR(statinfo.st_mode):
189
+ node_type = "dir"
190
+ else:
191
+ node_type = "file"
192
+
193
+ mimetype = None
194
+ if node_type == "file":
195
+ mimetype, _ = mimetypes.guess_type(path)
196
+
197
+ # st_birthtime is the real creation time (macOS, Windows, some BSDs).
198
+ # Platforms without it (Linux) fall back to st_ctime, the last inode
199
+ # change, which is the closest thing they have.
200
+ created = getattr(statinfo, "st_birthtime", None) or statinfo.st_ctime
201
+
202
+ return {
203
+ "name": name,
204
+ "path": path,
205
+ "type": node_type,
206
+ "mimetype": mimetype,
207
+ "size": statinfo.st_size,
208
+ "ctime": timestamp(created),
209
+ "mtime": timestamp(statinfo.st_mtime)
210
+ }
211
+
212
+
213
+ def print_banner(args):
214
+ hostname = platform.node()
215
+
216
+
217
+
218
+ print("")
219
+ print("\x1b[32m================\x1b[0m")
220
+ print("\x1b[32m- PDrive -\x1b[0m")
221
+ print("\x1b[32m================\x1b[0m")
222
+ print("")
223
+ print(f"\x1b[32mStarting server at http://{args.host}:{args.port}\x1b[0m")
224
+ print("")
225
+ print("\x1b[32mYou can SSH Tunnel to this service in a "
226
+ "separate terminal with:\x1b[0m")
227
+ print("")
228
+ print(f"\x1b[32m ssh -N -L {args.port}:localhost:{args.port} "
229
+ f"{hostname}\x1b[0m")
230
+ print("")
231
+
232
+
233
+ def main():
234
+ p = ArgumentParser()
235
+ p.add_argument("-H", "--host", default="127.0.0.1",
236
+ help="Host address to bind server to (default: 127.0.0.1)")
237
+ p.add_argument("-p", "--port", default=9999,
238
+ help="Host port to bind server to (default: 9999)")
239
+ a = p.parse_args()
240
+
241
+ # If gunicorn is available use it rather than the
242
+ # default dev server
243
+ import importlib
244
+ import importlib.util
245
+ gunicorn_import_available = importlib.util.find_spec("gunicorn")
246
+ if gunicorn_import_available:
247
+ import gunicorn.app.base
248
+ import multiprocessing
249
+
250
+ class Application(gunicorn.app.base.BaseApplication):
251
+
252
+ def __init__(self, app, options=None):
253
+ self.options = options or {}
254
+ self.application = app
255
+ super().__init__()
256
+
257
+ def load_config(self):
258
+ config = {key: value for key, value in self.options.items()
259
+ if key in self.cfg.settings and value is not None}
260
+ for key, value in config.items():
261
+ self.cfg.set(key.lower(), value)
262
+
263
+ def load(self):
264
+ return self.application
265
+
266
+ # start app
267
+ print_banner(a)
268
+
269
+ cpu_count = multiprocessing.cpu_count()
270
+ options = {
271
+ 'bind': '%s:%s' % (a.host, a.port),
272
+ 'workers': min(cpu_count+1, 4),
273
+ 'timeout': 120,
274
+ }
275
+ Application(app, options).run()
276
+ else:
277
+ print_banner(a)
278
+
279
+ print("")
280
+ print("\x1b[33mYou are running PDrive using a built-in minimal web server. For \n"
281
+ "better file upload & download performance, we recommend you install \n"
282
+ "the gunicorn web server python package. PDrive will automatically \n"
283
+ "use it if it is available:\x1b[0m")
284
+ print("")
285
+ print(" pip install gunicorn")
286
+ print("")
287
+
288
+ log = logging.getLogger('werkzeug')
289
+ log.disabled = True
290
+ cli.show_server_banner = lambda *_: None
291
+ app.run(host=a.host, port=a.port)
292
+
293
+
294
+ if __name__ == '__main__':
295
+ main()
@@ -0,0 +1 @@
1
+ *{margin:0;padding:0}body{background:#fff;font-family:-apple-system,BlinkMacSystemFont,SF Pro Text,Segoe UI,system-ui,Roboto,Helvetica Neue,Arial,sans-serif}button{font-family:inherit}.app{height:100%}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.logo-mark{flex-shrink:0;font-size:20px;color:#fff}.header-bar-brand{align-items:baseline;display:flex;gap:10px;min-width:0}.header-bar-brand .logo-icon{align-self:center;border-radius:5px;box-shadow:0 1px 3px #00000047;display:block;flex-shrink:0;height:22px;width:22px}.header-bar-host{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:#ffffffb3;font-size:13px;max-width:clamp(0px,calc(100% - 684px),40vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.header-bar{background:#20a0ff;padding:4px 10px;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;position:relative}.header-bar-actions{display:flex;align-items:center;gap:6px}.header-bar-button-group{display:flex;gap:6px}.header-bar-button{background-color:#285688f1;border:1px solid #28568821;color:#9ab;border-radius:4px;cursor:pointer;padding:5px 10px;line-height:1}.header-bar-button:hover{background-color:#285688aa}.header-bar-button.active{background-color:#28568861;color:#fff}.header-bar-button .material-icons{font-size:20px;line-height:1;vertical-align:middle}.btn{background:#fff;border:1px solid #DCDFE6;border-radius:4px;color:#606266;cursor:pointer;font-family:inherit;font-size:14px;padding:8px 16px;transition:background-color .15s,border-color .15s,color .15s}.btn:hover{background:#ecf5ff;border-color:#c6e2ff;color:#409eff}.btn:focus-visible{outline:2px solid #409EFF;outline-offset:1px}.btn-primary{background:#409eff;border-color:#409eff;color:#fff}.btn-primary:hover{background:#66b1ff;border-color:#66b1ff;color:#fff}.btn-danger{background:#e03131;border-color:#e03131;color:#fff}.btn-danger:hover{background:#f03e3e;border-color:#f03e3e;color:#fff}.btn-danger:focus-visible{outline:2px solid #E03131;outline-offset:1px}.text-input{border:1px solid #DCDFE6;border-radius:4px;box-sizing:border-box;color:#606266;font-family:inherit;font-size:14px;padding:8px 11px;width:100%}.text-input:focus{border-color:#409eff;outline:none}.modal-backdrop{align-items:center;background:#00000080;display:flex;justify-content:center;inset:0;position:fixed;z-index:1000}.modal{background:#fff;border-radius:6px;box-shadow:0 8px 24px #00000040;max-width:calc(100vw - 32px);width:420px}.modal-header{padding:20px 20px 0}.modal-title{font-size:18px;font-weight:600;margin:0}.modal-body{padding:20px}.confirm-message{color:#606266;line-height:1.5;margin:0}.modal-footer{display:flex;gap:8px;justify-content:flex-end;padding:0 20px 20px}.dropdown{display:inline-block;position:relative}.dropdown-menu{background:#fff;border-radius:4px;box-shadow:0 2px 12px #00000026;list-style:none;min-width:170px;padding:6px 0;position:absolute;right:0;top:calc(100% + 4px);z-index:1000}.dropdown-menu button{align-items:center;background:none;border:0;color:#606266;cursor:pointer;display:flex;font-family:inherit;font-size:14px;padding:9px 16px;text-align:left;width:100%}.dropdown-menu button:hover,.dropdown-menu button:focus-visible{background:#ecf5ff;color:#409eff;outline:none}.progress{align-items:center;display:flex;gap:8px;margin-top:8px}.progress-bar{background:#ebeef5;border-radius:3px;flex:1;height:6px;overflow:hidden}.progress-inner{background:#409eff;height:100%;transition:width .2s}.progress-text{color:#606266;font-size:12px;min-width:34px;text-align:right}.upload-overlay{position:fixed;background-color:#000000e6;z-index:999;padding:20px;inset:0}.upload-overlay-message{position:relative;margin:100px auto;width:400px;padding:20px;color:#fff;text-align:center;font-size:24px}.job-progress{position:fixed;bottom:20px;right:20px;width:300px;background-color:#fff;box-shadow:0 2px 6px #999;z-index:999}.job-progress-header{background:#8492a6;padding:10px 20px;color:#fff}.job-progress-task{padding:20px;font-size:12px}.toolbar-view{background:#324057;color:#fff;display:flex;align-items:center;justify-content:space-between}.breadcrumb-view{padding:8px 10px;border-right:1px solid #1F2D3D;flex:1;min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;white-space:nowrap}.breadcrumb-view::-webkit-scrollbar{display:none}.breadcrumb-view .segment{border-radius:3px;cursor:pointer;padding:2px 4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.breadcrumb-view .segment:hover{background:#ffffff1f}.breadcrumb-view .material-icons{vertical-align:middle}.new-button-view{margin-right:10px}.new-button{margin:3px 0 3px 16px;padding:5px 8px 5px 10px}.new-button .material-icons{vertical-align:middle}.new-menu-icon{vertical-align:text-bottom;margin-right:6px}.contextual-menu{position:absolute;top:0;left:0;width:200px;background:#fff;z-index:999;font-size:.9em;box-shadow:0 0 10px #0000004d;border-radius:6px;overflow:hidden}.contextual-menu div{padding:10px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.contextual-menu div:hover{background:#e5e9f2;cursor:pointer}.contextual-menu div:active{background:#1d8ce0;color:#fff}.contextual-menu .contextual-menu-separator{border-top:2px solid #E5E9F2}.contextual-menu .contextual-menu-danger{color:#c92a2a}.contextual-menu .contextual-menu-danger:hover{background:#fff0f0;color:#c92a2a}.grid-view{display:grid;grid-template-columns:repeat(auto-fill,100px);gap:44px;padding:30px;justify-content:start;align-content:start;overflow:hidden}.grid-view .grid-item{display:flex;flex-direction:column;width:100px}.grid-item-preview{text-align:center;height:80px;width:80px;margin:0 auto 4px;vertical-align:middle;transition:background-color .05s ease-in-out}.grid-view .grid-item.selected .grid-item-preview{background:#eaecef;border-radius:6px}.grid-view .grid-item-preview img{box-sizing:border-box;display:block;margin:8px auto 0;border-radius:4px;box-shadow:0 1px 3px #0000002e}.grid-view .grid-item-preview .tile-icon{display:flex;justify-content:center;padding-top:8px}.grid-view .grid-item .filename{border-radius:4px;padding:4px;text-align:center;font-size:.8em;line-height:1.25;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;height:2.5em;overflow:hidden;overflow-wrap:anywhere;transition:background-color .05s ease-in-out}.grid-view .grid-item:hover .filename{background:#f1f3f6}.grid-view .grid-item.selected .filename{background:#0064e1;color:#fff;overflow-x:hidden}table{width:100%;display:table;border-collapse:collapse;border-spacing:0;box-sizing:content-box;font-size:13px}table thead{background:#eff2f7}table tr.selected{background:#0064e1;color:#fff}table tbody tr{transition:background-color .05s ease-in-out}table tbody tr:hover:not(.selected){background:#f1f3f6}table tbody tr:nth-child(2n):not(.selected){background:#f7f8fa}table td{height:34px;padding-left:18px;padding-right:18px;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}table th{border-bottom:1px solid #e0e6ed;white-space:nowrap;overflow:hidden;background-color:#eff2f7;text-align:left;height:34px;padding-left:18px;padding-right:18px;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}table td .row-caret{display:inline-block;height:18px;line-height:18px;margin:0 2px 0 -2px;vertical-align:middle;width:18px}table td button.row-caret{background:none;border:0;border-radius:3px;color:inherit;cursor:pointer;padding:0;text-align:center}table td button.row-caret:hover{background:#0000001a}table td .row-caret .chevron{display:block;font-size:18px;line-height:18px;transition:transform .12s ease-in-out}table td .row-caret.expanded .chevron{transform:rotate(90deg)}table tbody tr.loading-row td{color:#909399;font-style:italic}.file-icon{display:inline-block;line-height:0;vertical-align:middle;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.22))}tr.selected .file-icon{filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}.file-icon svg{display:block}.material-icons{vertical-align:top}.material-icons.md-18{font-size:18px}.material-icons.md-24{font-size:24px}.material-icons.md-36{font-size:36px}.material-icons.md-48{font-size:48px}.material-icons.md-dark{color:#0000004d}.material-icons.md-dark.md-inactive{color:#00000042}.material-icons.md-light{color:#fff}.material-icons.md-light.md-inactive{color:#ffffff4d}@keyframes preview-fade-in{0%{opacity:0}}@keyframes preview-fade-out{to{opacity:0}}@keyframes preview-pop-in{0%{opacity:0;transform:scale(.985)}}@keyframes preview-pop-out{to{opacity:0;transform:scale(.99)}}.preview-backdrop{animation:preview-fade-in .15s ease-out;background:#000000d1}.modal-preview{animation:preview-pop-in .18s cubic-bezier(.2,.8,.3,1);background:transparent;border-radius:0;box-shadow:none;display:flex;flex-direction:column;gap:16px;max-height:calc(100vh - 40px);max-width:calc(100vw - 40px);width:auto}.preview-backdrop.is-closing{animation:preview-fade-out .14s ease-in forwards;pointer-events:none}.preview-backdrop.is-closing .modal-preview{animation:preview-pop-out .14s ease-in forwards}@media(prefers-reduced-motion:reduce){.preview-backdrop,.modal-preview,.preview-backdrop.is-closing,.preview-backdrop.is-closing .modal-preview{animation:none}.preview-backdrop.is-closing{opacity:0}}.preview-content{align-items:center;display:flex;flex:1 1 auto;justify-content:center;min-height:0;min-width:0}.preview-media{border-radius:4px;display:block;max-height:calc(100vh - 120px);max-width:min(1200px,calc(100vw - 40px))}audio.preview-media{max-width:calc(100vw - 40px);width:420px}.preview-frame{background:#fff;border:0;border-radius:4px;height:calc(100vh - 120px);width:min(1000px,calc(100vw - 40px))}.preview-text-panel{background:#fff;border-radius:6px;box-sizing:border-box;max-height:calc(100vh - 120px);overflow:auto;padding:16px 18px;width:min(900px,calc(100vw - 40px))}.preview-text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.6;margin:0;tab-size:4;white-space:pre}.preview-note{color:#8a6d3b;font-size:12px;margin:0 0 12px}.preview-placeholder{color:#8492a6;padding:48px 0;text-align:center}.preview-placeholder .file-icon{display:block;margin:0 auto 10px}.preview-content>.preview-placeholder{color:#ffffff8c;padding:64px 0}.preview-bar{align-items:center;color:#ffffffd9;display:flex;flex-shrink:0;font-size:13px;gap:6px;justify-content:center;min-height:32px}.preview-caption{align-items:baseline;display:flex;gap:8px;justify-content:center;max-width:min(60vw,520px);min-width:0;overflow:hidden}.preview-name{color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.preview-detail{color:#ffffff73;flex-shrink:0;font-size:12px}.preview-bar-divider{background:#fff3;height:16px;margin:0 4px;width:1px}.preview-action{align-items:center;background:none;border:0;border-radius:50%;color:#ffffffbf;cursor:pointer;display:flex;height:32px;justify-content:center;padding:0;width:32px}.preview-action:hover:not(:disabled){background:#ffffff1f;color:#fff}.preview-action:disabled{color:#ffffff38;cursor:default}.selection-surface{min-height:1px}body.drag-selecting{-webkit-user-select:none;user-select:none}.selection-rect{background:#409eff1f;border:1px solid #409EFF;border-radius:2px;pointer-events:none;position:fixed;z-index:900}body.drag-moving{-webkit-user-select:none;user-select:none}.drag-ghost{background:#fff;border:1px solid #C6E2FF;border-radius:4px;box-shadow:0 4px 12px #0000002e;color:#303133;font-size:12px;max-width:260px;overflow:hidden;padding:4px 8px;pointer-events:none;position:fixed;text-overflow:ellipsis;white-space:nowrap;z-index:950}table tbody tr.drop-target td{box-shadow:inset 0 0 0 2px #409eff}.grid-view .grid-item.drop-target{border-radius:4px;outline:2px solid #409EFF;outline-offset:2px}.filename-measurer{left:-10000px;position:absolute;top:0;visibility:hidden}