openvoiceui 1.0.0
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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- package/tts_providers/supertonic_tts.py +399 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workspace file browser — read-only access to the openclaw workspace directory.
|
|
3
|
+
|
|
4
|
+
GET /api/workspace/browse?path=<relative> → directory listing (files + dirs)
|
|
5
|
+
GET /api/workspace/file?path=<relative> → file content (text, 500 KB limit)
|
|
6
|
+
GET /api/workspace/raw?path=<relative> → raw file (images, PDFs, etc.)
|
|
7
|
+
GET /api/workspace/tree?path=<relative> → dirs only (for sidebar tree)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from flask import Blueprint, jsonify, request, send_file
|
|
13
|
+
from services.paths import RUNTIME_DIR
|
|
14
|
+
|
|
15
|
+
workspace_bp = Blueprint('workspace', __name__)
|
|
16
|
+
|
|
17
|
+
WORKSPACE_DIR = Path(os.getenv('WORKSPACE_DIR', str(RUNTIME_DIR / 'workspace')))
|
|
18
|
+
|
|
19
|
+
MAX_FILE_SIZE = 500 * 1024 # 500 KB preview limit
|
|
20
|
+
|
|
21
|
+
TEXT_EXTENSIONS = {
|
|
22
|
+
'.md', '.txt', '.json', '.csv', '.yaml', '.yml',
|
|
23
|
+
'.html', '.js', '.ts', '.py', '.sh', '.toml', '.log',
|
|
24
|
+
'.env', '.gitignore', '.sql', '.xml', '.ini', '.cfg',
|
|
25
|
+
'.lock', '.rst', '.njk', '.jsx', '.tsx',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp'}
|
|
29
|
+
MEDIA_EXTENSIONS = {'.mp3', '.wav', '.ogg', '.m4a', '.mp4', '.webm', '.mov', '.pdf'}
|
|
30
|
+
RAW_EXTENSIONS = IMAGE_EXTENSIONS | MEDIA_EXTENSIONS
|
|
31
|
+
MAX_RAW_SIZE = 20 * 1024 * 1024 # 20 MB limit for raw serving
|
|
32
|
+
|
|
33
|
+
HIDDEN_PREFIXES = {'.git', '__pycache__', 'node_modules', '.venv', 'venv'}
|
|
34
|
+
|
|
35
|
+
# Workspace-name → writable runtime path mapping
|
|
36
|
+
# workspace/Uploads/foo → RUNTIME_DIR/uploads/foo (writable)
|
|
37
|
+
# workspace/Agent/... → read-only, no mapping
|
|
38
|
+
_WRITABLE_MAP = {
|
|
39
|
+
'Uploads': RUNTIME_DIR / 'uploads',
|
|
40
|
+
'Canvas': RUNTIME_DIR / 'canvas-pages',
|
|
41
|
+
'Music': RUNTIME_DIR / 'music',
|
|
42
|
+
'AI-Music': RUNTIME_DIR / 'generated_music',
|
|
43
|
+
'Transcripts': RUNTIME_DIR / 'transcripts',
|
|
44
|
+
'Voice-Clones': RUNTIME_DIR / 'voice-clones',
|
|
45
|
+
'Icons': RUNTIME_DIR / 'icons',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _writable_path(rel: str):
|
|
50
|
+
"""Map a workspace-relative path to its writable runtime path, or None if read-only."""
|
|
51
|
+
parts = rel.strip('/').split('/', 1)
|
|
52
|
+
if not parts or not parts[0]:
|
|
53
|
+
return None
|
|
54
|
+
prefix = parts[0]
|
|
55
|
+
base = _WRITABLE_MAP.get(prefix)
|
|
56
|
+
if base is None:
|
|
57
|
+
return None
|
|
58
|
+
sub = parts[1] if len(parts) > 1 else ''
|
|
59
|
+
target = (base / sub).resolve()
|
|
60
|
+
# Safety: ensure it stays within the writable base
|
|
61
|
+
try:
|
|
62
|
+
target.relative_to(base.resolve())
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return target
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve(rel: str):
|
|
69
|
+
"""Resolve relative path safely within WORKSPACE_DIR. Returns None on traversal."""
|
|
70
|
+
try:
|
|
71
|
+
p = (WORKSPACE_DIR / rel.lstrip('/')).resolve()
|
|
72
|
+
p.relative_to(WORKSPACE_DIR.resolve()) # raises ValueError if outside
|
|
73
|
+
return p
|
|
74
|
+
except (ValueError, Exception):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _entry(item: Path, count_children: bool = True) -> dict:
|
|
79
|
+
"""Build a single directory entry dict."""
|
|
80
|
+
try:
|
|
81
|
+
st = item.stat()
|
|
82
|
+
except (PermissionError, OSError):
|
|
83
|
+
return {
|
|
84
|
+
'name': item.name,
|
|
85
|
+
'type': 'dir' if item.is_dir() else 'file',
|
|
86
|
+
'size': 0,
|
|
87
|
+
'modified': 0,
|
|
88
|
+
'ext': '',
|
|
89
|
+
}
|
|
90
|
+
e = {
|
|
91
|
+
'name': item.name,
|
|
92
|
+
'type': 'dir' if item.is_dir() else 'file',
|
|
93
|
+
'size': st.st_size if item.is_file() else 0,
|
|
94
|
+
'modified': int(st.st_mtime),
|
|
95
|
+
'ext': item.suffix.lower() if item.is_file() else '',
|
|
96
|
+
}
|
|
97
|
+
if item.is_dir() and count_children:
|
|
98
|
+
try:
|
|
99
|
+
children = [c for c in item.iterdir() if not c.name.startswith('.')]
|
|
100
|
+
e['children'] = len(children)
|
|
101
|
+
except PermissionError:
|
|
102
|
+
e['children'] = 0
|
|
103
|
+
return e
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_hidden(name: str) -> bool:
|
|
107
|
+
return name.startswith('.') or name in HIDDEN_PREFIXES
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@workspace_bp.route('/api/workspace/browse')
|
|
111
|
+
def browse():
|
|
112
|
+
rel = request.args.get('path', '')
|
|
113
|
+
target = _resolve(rel)
|
|
114
|
+
|
|
115
|
+
if target is None:
|
|
116
|
+
return jsonify({'error': 'Invalid path'}), 400
|
|
117
|
+
|
|
118
|
+
if not WORKSPACE_DIR.exists():
|
|
119
|
+
return jsonify({'path': '', 'entries': [], 'unavailable': True})
|
|
120
|
+
|
|
121
|
+
if not target.exists():
|
|
122
|
+
return jsonify({'error': 'Path not found'}), 404
|
|
123
|
+
|
|
124
|
+
if not target.is_dir():
|
|
125
|
+
return jsonify({'error': 'Not a directory'}), 400
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
entries = []
|
|
129
|
+
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
130
|
+
if _is_hidden(item.name):
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
entries.append(_entry(item))
|
|
134
|
+
except (PermissionError, OSError):
|
|
135
|
+
continue
|
|
136
|
+
except PermissionError:
|
|
137
|
+
return jsonify({'error': 'Permission denied'}), 403
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
rel_path = str(target.relative_to(WORKSPACE_DIR.resolve()))
|
|
141
|
+
if rel_path == '.':
|
|
142
|
+
rel_path = ''
|
|
143
|
+
except ValueError:
|
|
144
|
+
rel_path = ''
|
|
145
|
+
|
|
146
|
+
return jsonify({'path': rel_path, 'entries': entries})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@workspace_bp.route('/api/workspace/tree')
|
|
150
|
+
def tree():
|
|
151
|
+
"""Return only directories for the sidebar tree (one level deep)."""
|
|
152
|
+
rel = request.args.get('path', '')
|
|
153
|
+
target = _resolve(rel)
|
|
154
|
+
|
|
155
|
+
if not WORKSPACE_DIR.exists():
|
|
156
|
+
return jsonify({'dirs': []})
|
|
157
|
+
|
|
158
|
+
if target is None or not target.exists() or not target.is_dir():
|
|
159
|
+
return jsonify({'dirs': []})
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
dirs = []
|
|
163
|
+
for item in sorted(target.iterdir(), key=lambda p: p.name.lower()):
|
|
164
|
+
if item.is_dir() and not _is_hidden(item.name):
|
|
165
|
+
try:
|
|
166
|
+
rel_p = str(item.relative_to(WORKSPACE_DIR.resolve()))
|
|
167
|
+
except ValueError:
|
|
168
|
+
continue
|
|
169
|
+
# Check if this dir has subdirectories (for expand arrow)
|
|
170
|
+
try:
|
|
171
|
+
has_subdirs = any(
|
|
172
|
+
c.is_dir() and not _is_hidden(c.name)
|
|
173
|
+
for c in item.iterdir()
|
|
174
|
+
)
|
|
175
|
+
except PermissionError:
|
|
176
|
+
has_subdirs = False
|
|
177
|
+
dirs.append({'name': item.name, 'path': rel_p, 'has_subdirs': has_subdirs})
|
|
178
|
+
return jsonify({'dirs': dirs})
|
|
179
|
+
except Exception:
|
|
180
|
+
return jsonify({'dirs': []})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@workspace_bp.route('/api/workspace/file')
|
|
184
|
+
def read_file():
|
|
185
|
+
rel = request.args.get('path', '')
|
|
186
|
+
target = _resolve(rel)
|
|
187
|
+
|
|
188
|
+
if target is None:
|
|
189
|
+
return jsonify({'error': 'Invalid path'}), 400
|
|
190
|
+
|
|
191
|
+
if not target.exists():
|
|
192
|
+
return jsonify({'error': 'File not found'}), 404
|
|
193
|
+
|
|
194
|
+
if target.is_dir():
|
|
195
|
+
return jsonify({'error': 'Is a directory'}), 400
|
|
196
|
+
|
|
197
|
+
size = target.stat().st_size
|
|
198
|
+
ext = target.suffix.lower()
|
|
199
|
+
|
|
200
|
+
if size > MAX_FILE_SIZE:
|
|
201
|
+
return jsonify({
|
|
202
|
+
'error': f'File too large for preview ({size // 1024} KB). Max is 500 KB.',
|
|
203
|
+
'size': size,
|
|
204
|
+
'too_large': True,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
if ext not in TEXT_EXTENSIONS and ext != '':
|
|
208
|
+
return jsonify({'error': 'Binary file — no preview available', 'binary': True})
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
content = target.read_text(encoding='utf-8', errors='replace')
|
|
212
|
+
return jsonify({
|
|
213
|
+
'path': rel,
|
|
214
|
+
'name': target.name,
|
|
215
|
+
'ext': ext,
|
|
216
|
+
'size': size,
|
|
217
|
+
'content': content,
|
|
218
|
+
})
|
|
219
|
+
except Exception as e:
|
|
220
|
+
return jsonify({'error': str(e)}), 500
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@workspace_bp.route('/api/workspace/raw')
|
|
224
|
+
def raw_file():
|
|
225
|
+
"""Serve a raw file (images, audio, video, PDF) with correct content-type."""
|
|
226
|
+
rel = request.args.get('path', '')
|
|
227
|
+
target = _resolve(rel)
|
|
228
|
+
|
|
229
|
+
if target is None:
|
|
230
|
+
return jsonify({'error': 'Invalid path'}), 400
|
|
231
|
+
|
|
232
|
+
if not target.exists():
|
|
233
|
+
return jsonify({'error': 'File not found'}), 404
|
|
234
|
+
|
|
235
|
+
if target.is_dir():
|
|
236
|
+
return jsonify({'error': 'Is a directory'}), 400
|
|
237
|
+
|
|
238
|
+
size = target.stat().st_size
|
|
239
|
+
if size > MAX_RAW_SIZE:
|
|
240
|
+
return jsonify({'error': f'File too large ({size // (1024*1024)} MB). Max is 20 MB.'}), 413
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
return send_file(str(target), conditional=True)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
return jsonify({'error': str(e)}), 500
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@workspace_bp.route('/api/workspace/mkdir', methods=['POST'])
|
|
249
|
+
def mkdir():
|
|
250
|
+
"""Create a new folder inside a writable workspace area."""
|
|
251
|
+
import re
|
|
252
|
+
data = request.get_json(silent=True) or {}
|
|
253
|
+
rel = data.get('path', '').strip()
|
|
254
|
+
if not rel:
|
|
255
|
+
return jsonify({'error': 'Missing "path" field'}), 400
|
|
256
|
+
|
|
257
|
+
target = _writable_path(rel)
|
|
258
|
+
if target is None:
|
|
259
|
+
return jsonify({'error': 'Cannot create folders here (read-only area)'}), 403
|
|
260
|
+
|
|
261
|
+
# Validate the folder name (last component)
|
|
262
|
+
folder_name = target.name
|
|
263
|
+
if not folder_name or re.search(r'[<>:"|?*\\]', folder_name):
|
|
264
|
+
return jsonify({'error': 'Invalid folder name'}), 400
|
|
265
|
+
if folder_name.startswith('.'):
|
|
266
|
+
return jsonify({'error': 'Folder names cannot start with a dot'}), 400
|
|
267
|
+
|
|
268
|
+
if target.exists():
|
|
269
|
+
return jsonify({'error': 'Already exists'}), 409
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
target.mkdir(parents=True, exist_ok=False)
|
|
273
|
+
return jsonify({'path': rel, 'created': True})
|
|
274
|
+
except PermissionError:
|
|
275
|
+
return jsonify({'error': 'Permission denied'}), 403
|
|
276
|
+
except Exception as e:
|
|
277
|
+
return jsonify({'error': str(e)}), 500
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@workspace_bp.route('/api/workspace/writable')
|
|
281
|
+
def check_writable():
|
|
282
|
+
"""Check if a workspace path is in a writable area."""
|
|
283
|
+
rel = request.args.get('path', '')
|
|
284
|
+
target = _writable_path(rel) if rel else None
|
|
285
|
+
# Root is not writable, but top-level writable dirs are
|
|
286
|
+
if not rel:
|
|
287
|
+
return jsonify({'writable': False, 'writable_areas': list(_WRITABLE_MAP.keys())})
|
|
288
|
+
return jsonify({'writable': target is not None, 'path': rel})
|