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.
Files changed (35) hide show
  1. knowflow/__init__.py +4 -0
  2. knowflow/__main__.py +3 -0
  3. knowflow/adapters/knowledge/__init__.py +42 -0
  4. knowflow/adapters/knowledge/local.py +43 -0
  5. knowflow/adapters/knowledge/obsidian.py +64 -0
  6. knowflow/adapters/storage/__init__.py +62 -0
  7. knowflow/adapters/storage/git.py +155 -0
  8. knowflow/adapters/storage/local.py +110 -0
  9. knowflow/adapters/storage/quark.py +225 -0
  10. knowflow/adapters/storage/s3.py +118 -0
  11. knowflow/adapters/storage/webdav.py +196 -0
  12. knowflow/cli/__init__.py +265 -0
  13. knowflow/cli/template_manager.py +100 -0
  14. knowflow/core/__init__.py +10 -0
  15. knowflow/core/knowledge.py +85 -0
  16. knowflow/core/pipeline.py +119 -0
  17. knowflow/core/storage.py +167 -0
  18. knowflow/dashboard/__init__.py +355 -0
  19. knowflow/templates/daily-journal/plugin.yaml +21 -0
  20. knowflow/templates/football-prediction/plugin.yaml +22 -0
  21. knowflow/templates/project-wiki/plugin.yaml +20 -0
  22. knowflow/templates/research-notes/plugin.yaml +26 -0
  23. knowflow/templates/stock-analysis/plugin.yaml +22 -0
  24. knowflow/templates/web-monitor/plugin.yaml +20 -0
  25. knowflow/templates/worldcup-tracker/plugin.yaml +18 -0
  26. knowflow/transforms/__init__.py +6 -0
  27. knowflow/transforms/archive.py +87 -0
  28. knowflow/transforms/markdown_export.py +110 -0
  29. knowflow/transforms/profile_index.py +89 -0
  30. knowflow-0.1.0.dist-info/METADATA +264 -0
  31. knowflow-0.1.0.dist-info/RECORD +35 -0
  32. knowflow-0.1.0.dist-info/WHEEL +5 -0
  33. knowflow-0.1.0.dist-info/entry_points.txt +2 -0
  34. knowflow-0.1.0.dist-info/licenses/LICENSE +21 -0
  35. 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()