knowflow 0.1.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.
- knowflow/__init__.py +4 -0
- knowflow/__main__.py +3 -0
- knowflow/adapters/knowledge/__init__.py +42 -0
- knowflow/adapters/knowledge/local.py +43 -0
- knowflow/adapters/knowledge/obsidian.py +64 -0
- knowflow/adapters/storage/__init__.py +62 -0
- knowflow/adapters/storage/git.py +155 -0
- knowflow/adapters/storage/local.py +110 -0
- knowflow/adapters/storage/quark.py +225 -0
- knowflow/adapters/storage/s3.py +118 -0
- knowflow/adapters/storage/webdav.py +196 -0
- knowflow/cli/__init__.py +265 -0
- knowflow/cli/template_manager.py +100 -0
- knowflow/core/__init__.py +10 -0
- knowflow/core/knowledge.py +85 -0
- knowflow/core/pipeline.py +119 -0
- knowflow/core/storage.py +167 -0
- knowflow/dashboard/__init__.py +355 -0
- knowflow/templates/daily-journal/plugin.yaml +21 -0
- knowflow/templates/football-prediction/plugin.yaml +22 -0
- knowflow/templates/project-wiki/plugin.yaml +20 -0
- knowflow/templates/research-notes/plugin.yaml +26 -0
- knowflow/templates/stock-analysis/plugin.yaml +22 -0
- knowflow/templates/web-monitor/plugin.yaml +20 -0
- knowflow/templates/worldcup-tracker/plugin.yaml +18 -0
- knowflow/transforms/__init__.py +6 -0
- knowflow/transforms/archive.py +87 -0
- knowflow/transforms/markdown_export.py +110 -0
- knowflow/transforms/profile_index.py +89 -0
- knowflow-0.1.0.dist-info/METADATA +264 -0
- knowflow-0.1.0.dist-info/RECORD +35 -0
- knowflow-0.1.0.dist-info/WHEEL +5 -0
- knowflow-0.1.0.dist-info/entry_points.txt +2 -0
- knowflow-0.1.0.dist-info/licenses/LICENSE +21 -0
- knowflow-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
knowflow/adapters/storage/quark.py — 夸克网盘 storage backend
|
|
5
|
+
==============================================================
|
|
6
|
+
Requires: kuake_cli installed + cookie configured.
|
|
7
|
+
|
|
8
|
+
Config:
|
|
9
|
+
path: str (default: /KnowFlow) — 夸克网盘内的根目录
|
|
10
|
+
temp_dir: str (default: /tmp/knowflow-quark) — 本地缓存目录
|
|
11
|
+
|
|
12
|
+
由于夸克网盘没有文件系统操作(没有 stat/mtime),
|
|
13
|
+
本适配器用本地 temp_dir 做缓存,同步到夸克。
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from knowflow.core.storage import (
|
|
21
|
+
StorageAdapter, StorageItem, StorageError, StorageNotFoundError
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
KUAKE_HOME = os.path.expanduser("~/.config/kuake")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class QuarkStorage(StorageAdapter):
|
|
29
|
+
"""
|
|
30
|
+
夸克网盘存储后端
|
|
31
|
+
|
|
32
|
+
工作方式:
|
|
33
|
+
- 读:先从夸克下载到本地缓存 → 读缓存
|
|
34
|
+
- 写:先写本地缓存 → 再上传到夸克
|
|
35
|
+
- 列表:调 kuake list
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, config: dict = None):
|
|
39
|
+
super().__init__(config)
|
|
40
|
+
cfg = config or {}
|
|
41
|
+
self.remote_root = cfg.get("path", "/KnowFlow").rstrip("/")
|
|
42
|
+
self.temp_dir = Path(cfg.get("temp_dir", "/tmp/knowflow-quark"))
|
|
43
|
+
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
self._check_kuake()
|
|
45
|
+
|
|
46
|
+
def _check_kuake(self):
|
|
47
|
+
"""验证 kuake 可用"""
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
["kuake", "user"],
|
|
51
|
+
capture_output=True, text=True, timeout=15,
|
|
52
|
+
cwd=KUAKE_HOME,
|
|
53
|
+
)
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
raise StorageError(
|
|
56
|
+
f"夸克网盘连接失败。请检查 cookie 是否过期。\n"
|
|
57
|
+
f" {result.stderr.strip()}"
|
|
58
|
+
)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
raise StorageError(
|
|
61
|
+
"未找到 kuake 命令。请安装: https://github.com/kuake-cli/kuake"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _kuake(self, *args, timeout=60) -> str:
|
|
65
|
+
"""执行 kuake 命令"""
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["kuake", *args],
|
|
69
|
+
capture_output=True, text=True, timeout=timeout,
|
|
70
|
+
cwd=KUAKE_HOME,
|
|
71
|
+
)
|
|
72
|
+
if result.returncode != 0:
|
|
73
|
+
raise StorageError(
|
|
74
|
+
f"kuake {' '.join(args)} 失败: {result.stderr.strip()}"
|
|
75
|
+
)
|
|
76
|
+
return result.stdout.strip()
|
|
77
|
+
except subprocess.TimeoutExpired:
|
|
78
|
+
raise StorageError(f"kuake 命令超时 ({timeout}s)")
|
|
79
|
+
|
|
80
|
+
def _local_path(self, path: str) -> Path:
|
|
81
|
+
"""远程路径 → 本地缓存路径"""
|
|
82
|
+
clean = path.lstrip("/")
|
|
83
|
+
return self.temp_dir / clean
|
|
84
|
+
|
|
85
|
+
def _remote_path(self, path: str) -> str:
|
|
86
|
+
"""相对路径 → 夸克完整路径"""
|
|
87
|
+
return f"{self.remote_root}/{path.lstrip('/')}"
|
|
88
|
+
|
|
89
|
+
def _ensure_remote_dirs(self, path: str):
|
|
90
|
+
"""确保夸克远程目录存在"""
|
|
91
|
+
parent = Path(path).parent.as_posix().strip("/")
|
|
92
|
+
# 文件在根目录(如 hello.txt),无需创建目录
|
|
93
|
+
if not parent or parent == ".":
|
|
94
|
+
return
|
|
95
|
+
parts = parent.split("/")
|
|
96
|
+
current = self.remote_root
|
|
97
|
+
for part in parts:
|
|
98
|
+
current = f"{current}/{part}"
|
|
99
|
+
exists = False
|
|
100
|
+
try:
|
|
101
|
+
self._kuake("list", current, timeout=15)
|
|
102
|
+
exists = True
|
|
103
|
+
except StorageError:
|
|
104
|
+
pass
|
|
105
|
+
if not exists:
|
|
106
|
+
parent = current.rsplit("/", 1)[0] if "/" in current else ""
|
|
107
|
+
self._kuake("create", part, parent, timeout=15)
|
|
108
|
+
|
|
109
|
+
def read(self, path: str) -> str:
|
|
110
|
+
local = self._local_path(path)
|
|
111
|
+
if not local.exists():
|
|
112
|
+
self._download(path)
|
|
113
|
+
return local.read_text(encoding="utf-8")
|
|
114
|
+
|
|
115
|
+
def read_binary(self, path: str) -> bytes:
|
|
116
|
+
local = self._local_path(path)
|
|
117
|
+
if not local.exists():
|
|
118
|
+
self._download(path)
|
|
119
|
+
return local.read_bytes()
|
|
120
|
+
|
|
121
|
+
def write(self, path: str, content: str) -> str:
|
|
122
|
+
local = self._local_path(path)
|
|
123
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
local.write_text(content, encoding="utf-8")
|
|
125
|
+
self._upload(path)
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
def write_binary(self, path: str, content: bytes) -> str:
|
|
129
|
+
local = self._local_path(path)
|
|
130
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
local.write_bytes(content)
|
|
132
|
+
self._upload(path)
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
def delete(self, path: str) -> bool:
|
|
136
|
+
remote = self._remote_path(path)
|
|
137
|
+
local = self._local_path(path)
|
|
138
|
+
try:
|
|
139
|
+
self._kuake("delete", remote)
|
|
140
|
+
except StorageError:
|
|
141
|
+
pass
|
|
142
|
+
if local.exists():
|
|
143
|
+
local.unlink()
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
def exists(self, path: str) -> bool:
|
|
147
|
+
"""检查文件是否存在(先查本地缓存,再查夸克远程)"""
|
|
148
|
+
# 1. 查本地缓存
|
|
149
|
+
local = self._local_path(path)
|
|
150
|
+
if local.exists():
|
|
151
|
+
return True
|
|
152
|
+
# 2. 查夸克远程(用 list 查父目录)
|
|
153
|
+
parent_dir = Path(path).parent.as_posix().strip("/")
|
|
154
|
+
try:
|
|
155
|
+
items = self.list(parent_dir)
|
|
156
|
+
target = Path(path).name
|
|
157
|
+
return any(i.path == target for i in items)
|
|
158
|
+
except StorageError:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def list(self, prefix: str = "") -> list[StorageItem]:
|
|
162
|
+
remote = self._remote_path(prefix) if prefix else self.remote_root
|
|
163
|
+
try:
|
|
164
|
+
output = self._kuake("list", remote, timeout=30)
|
|
165
|
+
except StorageError:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(output)
|
|
170
|
+
except json.JSONDecodeError:
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
items = []
|
|
174
|
+
for entry in data.get("data", {}).get("list", []):
|
|
175
|
+
items.append(StorageItem(
|
|
176
|
+
path=entry.get("file_name", ""),
|
|
177
|
+
size=entry.get("size", 0),
|
|
178
|
+
modified_at=0, # 夸克API不提供mtime
|
|
179
|
+
is_dir=entry.get("dir", False),
|
|
180
|
+
etag=entry.get("fid", ""),
|
|
181
|
+
))
|
|
182
|
+
return items
|
|
183
|
+
|
|
184
|
+
def walk(self, prefix: str = "") -> list[StorageItem]:
|
|
185
|
+
"""递归列出所有文件(夸克API不支持递归,只能逐层遍历)"""
|
|
186
|
+
items = []
|
|
187
|
+
queue = [prefix]
|
|
188
|
+
seen = set()
|
|
189
|
+
while queue:
|
|
190
|
+
current = queue.pop(0)
|
|
191
|
+
if current in seen:
|
|
192
|
+
continue
|
|
193
|
+
seen.add(current)
|
|
194
|
+
try:
|
|
195
|
+
children = self.list(current)
|
|
196
|
+
except StorageError:
|
|
197
|
+
continue
|
|
198
|
+
for child in children:
|
|
199
|
+
full_path = f"{current}/{child.path}".strip("/")
|
|
200
|
+
if child.is_dir:
|
|
201
|
+
queue.append(full_path)
|
|
202
|
+
else:
|
|
203
|
+
items.append(StorageItem(
|
|
204
|
+
path=full_path,
|
|
205
|
+
size=child.size,
|
|
206
|
+
modified_at=child.modified_at,
|
|
207
|
+
etag=child.etag,
|
|
208
|
+
))
|
|
209
|
+
return items
|
|
210
|
+
|
|
211
|
+
def _download(self, path: str):
|
|
212
|
+
"""从夸克下载文件到本地缓存"""
|
|
213
|
+
remote = self._remote_path(path)
|
|
214
|
+
local = self._local_path(path)
|
|
215
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
self._kuake("download", remote, str(local), timeout=120)
|
|
217
|
+
|
|
218
|
+
def _upload(self, path: str):
|
|
219
|
+
"""从本地缓存上传到夸克"""
|
|
220
|
+
local = self._local_path(path)
|
|
221
|
+
if not local.exists():
|
|
222
|
+
raise StorageNotFoundError(f"本地缓存不存在: {path}")
|
|
223
|
+
self._ensure_remote_dirs(path)
|
|
224
|
+
remote = self._remote_path(path)
|
|
225
|
+
self._kuake("upload", str(local), remote, timeout=120)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
knowflow/adapters/storage/s3.py — S3-compatible storage
|
|
5
|
+
=========================================================
|
|
6
|
+
Requires: pip install knowflow[s3] (installs boto3)
|
|
7
|
+
|
|
8
|
+
Config:
|
|
9
|
+
bucket: str
|
|
10
|
+
prefix: str (optional, default: "")
|
|
11
|
+
region: str (optional, default: us-east-1)
|
|
12
|
+
endpoint_url: str (optional, for MinIO/R2)
|
|
13
|
+
"""
|
|
14
|
+
from knowflow.core.storage import (
|
|
15
|
+
StorageAdapter, StorageItem, StorageError, StorageNotFoundError
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class S3Storage(StorageAdapter):
|
|
20
|
+
"""
|
|
21
|
+
S3-compatible object storage backend.
|
|
22
|
+
|
|
23
|
+
Works with: AWS S3, MinIO, Cloudflare R2, DigitalOcean Spaces, etc.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: dict = None):
|
|
27
|
+
super().__init__(config)
|
|
28
|
+
self._import_check()
|
|
29
|
+
import boto3
|
|
30
|
+
self.bucket = config.get("bucket", "knowflow")
|
|
31
|
+
self.prefix = config.get("prefix", "").strip("/")
|
|
32
|
+
kwargs = {}
|
|
33
|
+
if config.get("endpoint_url"):
|
|
34
|
+
kwargs["endpoint_url"] = config["endpoint_url"]
|
|
35
|
+
if config.get("region"):
|
|
36
|
+
kwargs["region_name"] = config["region"]
|
|
37
|
+
self.client = boto3.client("s3", **kwargs)
|
|
38
|
+
# Ensure bucket exists
|
|
39
|
+
try:
|
|
40
|
+
self.client.head_bucket(Bucket=self.bucket)
|
|
41
|
+
except Exception:
|
|
42
|
+
self.client.create_bucket(Bucket=self.bucket)
|
|
43
|
+
|
|
44
|
+
def _import_check(self):
|
|
45
|
+
try:
|
|
46
|
+
import boto3 # noqa
|
|
47
|
+
except ImportError:
|
|
48
|
+
raise StorageError(
|
|
49
|
+
"S3 support requires boto3. Install: pip install knowflow[s3]"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _key(self, path: str) -> str:
|
|
53
|
+
return f"{self.prefix}/{path}".strip("/")
|
|
54
|
+
|
|
55
|
+
def read(self, path: str) -> str:
|
|
56
|
+
try:
|
|
57
|
+
obj = self.client.get_object(Bucket=self.bucket, Key=self._key(path))
|
|
58
|
+
return obj["Body"].read().decode("utf-8")
|
|
59
|
+
except self.client.exceptions.NoSuchKey:
|
|
60
|
+
raise StorageNotFoundError(f"Not found: {path}")
|
|
61
|
+
|
|
62
|
+
def read_binary(self, path: str) -> bytes:
|
|
63
|
+
try:
|
|
64
|
+
obj = self.client.get_object(Bucket=self.bucket, Key=self._key(path))
|
|
65
|
+
return obj["Body"].read()
|
|
66
|
+
except self.client.exceptions.NoSuchKey:
|
|
67
|
+
raise StorageNotFoundError(f"Not found: {path}")
|
|
68
|
+
|
|
69
|
+
def write(self, path: str, content: str) -> str:
|
|
70
|
+
self.client.put_object(Bucket=self.bucket, Key=self._key(path), Body=content.encode())
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
def write_binary(self, path: str, content: bytes) -> str:
|
|
74
|
+
self.client.put_object(Bucket=self.bucket, Key=self._key(path), Body=content)
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
def delete(self, path: str) -> bool:
|
|
78
|
+
try:
|
|
79
|
+
self.client.delete_object(Bucket=self.bucket, Key=self._key(path))
|
|
80
|
+
return True
|
|
81
|
+
except Exception:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def exists(self, path: str) -> bool:
|
|
85
|
+
try:
|
|
86
|
+
self.client.head_object(Bucket=self.bucket, Key=self._key(path))
|
|
87
|
+
return True
|
|
88
|
+
except Exception:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def list(self, prefix: str = "") -> list[StorageItem]:
|
|
92
|
+
key_prefix = self._key(prefix)
|
|
93
|
+
response = self.client.list_objects_v2(
|
|
94
|
+
Bucket=self.bucket, Prefix=key_prefix, Delimiter="/"
|
|
95
|
+
)
|
|
96
|
+
items = []
|
|
97
|
+
for obj in response.get("Contents", []):
|
|
98
|
+
name = obj["Key"][len(key_prefix):].lstrip("/")
|
|
99
|
+
if name:
|
|
100
|
+
items.append(StorageItem(
|
|
101
|
+
path=f"{prefix}/{name}".strip("/"),
|
|
102
|
+
size=obj["Size"],
|
|
103
|
+
modified_at=obj["LastModified"].timestamp(),
|
|
104
|
+
))
|
|
105
|
+
return items
|
|
106
|
+
|
|
107
|
+
def walk(self, prefix: str = "") -> list[StorageItem]:
|
|
108
|
+
key_prefix = self._key(prefix)
|
|
109
|
+
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=key_prefix)
|
|
110
|
+
items = []
|
|
111
|
+
for obj in response.get("Contents", []):
|
|
112
|
+
rel = obj["Key"][len(self.prefix):].lstrip("/") if self.prefix else obj["Key"]
|
|
113
|
+
items.append(StorageItem(
|
|
114
|
+
path=rel,
|
|
115
|
+
size=obj["Size"],
|
|
116
|
+
modified_at=obj["LastModified"].timestamp(),
|
|
117
|
+
))
|
|
118
|
+
return items
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
knowflow/adapters/storage/webdav.py — WebDAV storage backend
|
|
5
|
+
=============================================================
|
|
6
|
+
WebDAV protocol — works with: NextCloud, Synology NAS, ownCloud, etc.
|
|
7
|
+
|
|
8
|
+
Config:
|
|
9
|
+
base_url: str — WebDAV endpoint (e.g. https://nextcloud.example.com/remote.php/dav/files/user)
|
|
10
|
+
username: str — WebDAV username
|
|
11
|
+
password: str — WebDAV password or app token
|
|
12
|
+
path: str — Root path within WebDAV (default: /KnowFlow)
|
|
13
|
+
|
|
14
|
+
Requires: requests (already installed in the environment)
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
from xml.etree import ElementTree as ET
|
|
21
|
+
from knowflow.core.storage import (
|
|
22
|
+
StorageAdapter, StorageItem, StorageError, StorageNotFoundError
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
WEBDAV_NS = {
|
|
27
|
+
"d": "DAV:",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WebDAVStorage(StorageAdapter):
|
|
32
|
+
"""
|
|
33
|
+
WebDAV 存储后端
|
|
34
|
+
|
|
35
|
+
兼容:
|
|
36
|
+
- NextCloud
|
|
37
|
+
- Synology Drive
|
|
38
|
+
- ownCloud
|
|
39
|
+
- Apache mod_dav
|
|
40
|
+
- 任何标准 WebDAV 服务
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: dict = None):
|
|
44
|
+
super().__init__(config)
|
|
45
|
+
cfg = config or {}
|
|
46
|
+
self.base_url = cfg["base_url"].rstrip("/")
|
|
47
|
+
self.auth = (cfg.get("username", ""), cfg.get("password", ""))
|
|
48
|
+
self.root = cfg.get("path", "/KnowFlow").rstrip("/")
|
|
49
|
+
self._session = None
|
|
50
|
+
self._check()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def _http(self):
|
|
54
|
+
"""Lazy-import requests to avoid hard dependency"""
|
|
55
|
+
if self._session is None:
|
|
56
|
+
import requests
|
|
57
|
+
self._session = requests.Session()
|
|
58
|
+
self._session.auth = self.auth
|
|
59
|
+
self._session.headers.update({
|
|
60
|
+
"User-Agent": "KnowFlow/0.1.0",
|
|
61
|
+
})
|
|
62
|
+
return self._session
|
|
63
|
+
|
|
64
|
+
def _check(self):
|
|
65
|
+
"""验证连接"""
|
|
66
|
+
try:
|
|
67
|
+
resp = self._http.request("PROPFIND", f"{self.base_url}{self.root}",
|
|
68
|
+
headers={"Depth": "0"}, timeout=15)
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise StorageError(
|
|
72
|
+
f"WebDAV 连接失败: {e}\n"
|
|
73
|
+
f" URL: {self.base_url}{self.root}\n"
|
|
74
|
+
f" 请检查 base_url/username/password 是否正确"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _url(self, path: str) -> str:
|
|
78
|
+
"""相对路径 → 完整 URL"""
|
|
79
|
+
clean = path.lstrip("/")
|
|
80
|
+
full = f"{self.root}/{clean}"
|
|
81
|
+
return f"{self.base_url}{full}"
|
|
82
|
+
|
|
83
|
+
def _parse_propfind(self, xml: str) -> list[StorageItem]:
|
|
84
|
+
"""解析 PROPFIND 响应 XML → StorageItem 列表"""
|
|
85
|
+
items = []
|
|
86
|
+
root = ET.fromstring(xml)
|
|
87
|
+
for resp in root.findall("d:response", WEBDAV_NS):
|
|
88
|
+
href = resp.findtext("d:href", "", WEBDAV_NS)
|
|
89
|
+
if not href:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# 取相对路径
|
|
93
|
+
rel = href.replace(self.base_url, "").replace(self.root, "").strip("/")
|
|
94
|
+
if not rel:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
prop = resp.find("d:propstat/d:prop", WEBDAV_NS)
|
|
98
|
+
if prop is None:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
is_dir = prop.find("d:resourcetype/d:collection", WEBDAV_NS) is not None
|
|
102
|
+
size_text = prop.findtext("d:getcontentlength", "0", WEBDAV_NS)
|
|
103
|
+
size = int(size_text) if size_text.isdigit() else 0
|
|
104
|
+
mtime_text = prop.findtext("d:getlastmodified", "", WEBDAV_NS)
|
|
105
|
+
mtime = 0
|
|
106
|
+
if mtime_text:
|
|
107
|
+
import email.utils
|
|
108
|
+
try:
|
|
109
|
+
mtime = email.utils.parsedate_to_datetime(mtime_text).timestamp()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
items.append(StorageItem(
|
|
114
|
+
path=rel,
|
|
115
|
+
size=size,
|
|
116
|
+
modified_at=mtime,
|
|
117
|
+
is_dir=is_dir,
|
|
118
|
+
))
|
|
119
|
+
return items
|
|
120
|
+
|
|
121
|
+
def read(self, path: str) -> str:
|
|
122
|
+
resp = self._http.get(self._url(path), timeout=30)
|
|
123
|
+
if resp.status_code == 404:
|
|
124
|
+
raise StorageNotFoundError(f"Not found: {path}")
|
|
125
|
+
resp.raise_for_status()
|
|
126
|
+
return resp.text
|
|
127
|
+
|
|
128
|
+
def read_binary(self, path: str) -> bytes:
|
|
129
|
+
resp = self._http.get(self._url(path), timeout=30)
|
|
130
|
+
if resp.status_code == 404:
|
|
131
|
+
raise StorageNotFoundError(f"Not found: {path}")
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
return resp.content
|
|
134
|
+
|
|
135
|
+
def write(self, path: str, content: str) -> str:
|
|
136
|
+
self._ensure_parent(path)
|
|
137
|
+
resp = self._http.put(self._url(path), data=content.encode("utf-8"), timeout=60)
|
|
138
|
+
resp.raise_for_status()
|
|
139
|
+
return ""
|
|
140
|
+
|
|
141
|
+
def write_binary(self, path: str, content: bytes) -> str:
|
|
142
|
+
self._ensure_parent(path)
|
|
143
|
+
resp = self._http.put(self._url(path), data=content, timeout=60)
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
def delete(self, path: str) -> bool:
|
|
148
|
+
resp = self._http.request("DELETE", self._url(path), timeout=30)
|
|
149
|
+
if resp.status_code == 404:
|
|
150
|
+
return False
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
def exists(self, path: str) -> bool:
|
|
155
|
+
resp = self._http.request("PROPFIND", self._url(path),
|
|
156
|
+
headers={"Depth": "0"}, timeout=15)
|
|
157
|
+
return resp.status_code == 207 # Multi-Status = found
|
|
158
|
+
|
|
159
|
+
def list(self, prefix: str = "") -> list[StorageItem]:
|
|
160
|
+
url = self._url(prefix) if prefix else f"{self.base_url}{self.root}"
|
|
161
|
+
resp = self._http.request("PROPFIND", url,
|
|
162
|
+
headers={"Depth": "1"}, timeout=30)
|
|
163
|
+
if resp.status_code != 207:
|
|
164
|
+
return []
|
|
165
|
+
all_items = self._parse_propfind(resp.text)
|
|
166
|
+
# 排除自身
|
|
167
|
+
root_name = prefix.rstrip("/").split("/")[-1] if prefix else ""
|
|
168
|
+
return [i for i in all_items if i.path != root_name and i.path]
|
|
169
|
+
|
|
170
|
+
def walk(self, prefix: str = "") -> list[StorageItem]:
|
|
171
|
+
"""递归列出所有文件"""
|
|
172
|
+
url = self._url(prefix) if prefix else f"{self.base_url}{self.root}"
|
|
173
|
+
resp = self._http.request("PROPFIND", url,
|
|
174
|
+
headers={"Depth": "infinity"}, timeout=60)
|
|
175
|
+
if resp.status_code != 207:
|
|
176
|
+
return []
|
|
177
|
+
all_items = self._parse_propfind(resp.text)
|
|
178
|
+
root_name = prefix.rstrip("/").split("/")[-1] if prefix else ""
|
|
179
|
+
return [i for i in all_items if i.path != root_name and i.path and not i.is_dir]
|
|
180
|
+
|
|
181
|
+
def _ensure_parent(self, path: str):
|
|
182
|
+
"""确保父目录存在(通过 PROPFIND + MKCOL)"""
|
|
183
|
+
parent = Path(path).parent.as_posix().strip("/")
|
|
184
|
+
if not parent or parent == ".":
|
|
185
|
+
return
|
|
186
|
+
parts = parent.split("/")
|
|
187
|
+
current = f"{self.base_url}{self.root}"
|
|
188
|
+
for part in parts:
|
|
189
|
+
current = f"{current}/{quote(part, safe='')}"
|
|
190
|
+
resp = self._http.request("PROPFIND", current,
|
|
191
|
+
headers={"Depth": "0"}, timeout=15)
|
|
192
|
+
if resp.status_code != 207:
|
|
193
|
+
# 目录不存在,创建
|
|
194
|
+
resp = self._http.request("MKCOL", current, timeout=15)
|
|
195
|
+
if resp.status_code not in (201, 405): # 201 Created, 405 Already exists
|
|
196
|
+
resp.raise_for_status()
|