dsh-plugin-image-workbench 0.0.0-stage → 1.1.6

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 (186) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +258 -0
  3. package/README.md +260 -2
  4. package/client.js +241 -0
  5. package/cordis.patch.yml +14 -0
  6. package/host.js +692 -0
  7. package/install.mjs +333 -0
  8. package/package.json +58 -4
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -0
  11. package/server/access.py +26 -0
  12. package/server/app.py +1329 -0
  13. package/server/catalog.json +1 -0
  14. package/server/catalog.py +147 -0
  15. package/server/composer.py +391 -0
  16. package/server/drafts.py +158 -0
  17. package/server/history.py +575 -0
  18. package/server/imaging.py +172 -0
  19. package/server/pool.py +1335 -0
  20. package/server/pool_admin.py +921 -0
  21. package/server/pool_service.py +1073 -0
  22. package/server/requirements.txt +6 -0
  23. package/server/smoke_check.py +402 -0
  24. package/server/static/access.html +202 -0
  25. package/server/static/index.html +4725 -0
  26. package/vendor/chatgpt2api/README.md +13 -0
  27. package/vendor/chatgpt2api/SOURCE.json +11 -0
  28. package/vendor/chatgpt2api/runtime/uv.exe +0 -0
  29. package/vendor/chatgpt2api/source/.env.example +82 -0
  30. package/vendor/chatgpt2api/source/.python-version +1 -0
  31. package/vendor/chatgpt2api/source/LICENSE +21 -0
  32. package/vendor/chatgpt2api/source/README.md +391 -0
  33. package/vendor/chatgpt2api/source/VERSION +1 -0
  34. package/vendor/chatgpt2api/source/api/__init__.py +2 -0
  35. package/vendor/chatgpt2api/source/api/accounts.py +537 -0
  36. package/vendor/chatgpt2api/source/api/ai.py +217 -0
  37. package/vendor/chatgpt2api/source/api/app.py +62 -0
  38. package/vendor/chatgpt2api/source/api/errors.py +46 -0
  39. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
  40. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
  41. package/vendor/chatgpt2api/source/api/support.py +134 -0
  42. package/vendor/chatgpt2api/source/api/system.py +365 -0
  43. package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
  44. package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
  45. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
  46. package/vendor/chatgpt2api/source/config.default.json +108 -0
  47. package/vendor/chatgpt2api/source/main.py +9 -0
  48. package/vendor/chatgpt2api/source/pyproject.toml +27 -0
  49. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
  50. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
  51. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
  52. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
  53. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
  54. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
  55. package/vendor/chatgpt2api/source/services/__init__.py +0 -0
  56. package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
  57. package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
  58. package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
  59. package/vendor/chatgpt2api/source/services/config.py +667 -0
  60. package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
  61. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
  62. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
  63. package/vendor/chatgpt2api/source/services/image_service.py +376 -0
  64. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
  65. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
  66. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
  67. package/vendor/chatgpt2api/source/services/log_service.py +337 -0
  68. package/vendor/chatgpt2api/source/services/model_service.py +182 -0
  69. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
  70. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
  71. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
  72. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
  73. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
  74. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
  75. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
  76. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
  77. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
  78. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
  79. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
  80. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
  81. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
  82. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
  83. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
  84. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
  85. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
  86. package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
  87. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
  88. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
  89. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
  90. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
  91. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
  92. package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
  93. package/vendor/chatgpt2api/source/utils/helper.py +469 -0
  94. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
  95. package/vendor/chatgpt2api/source/utils/log.py +110 -0
  96. package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
  97. package/vendor/chatgpt2api/source/utils/pow.py +207 -0
  98. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
  99. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
  100. package/vendor/chatgpt2api/source/uv.lock +904 -0
  101. package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
  102. package/vendor/chatgpt2api/source/web/components.json +24 -0
  103. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
  104. package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
  105. package/vendor/chatgpt2api/source/web/package.json +58 -0
  106. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
  107. package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
  108. package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
  109. package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
  110. package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
  111. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
  112. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
  113. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
  114. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
  115. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
  116. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
  117. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
  118. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
  119. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
  120. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
  121. package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
  122. package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
  123. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
  124. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
  125. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
  126. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
  127. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
  128. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
  129. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
  130. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
  131. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
  133. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
  134. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
  135. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
  136. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
  137. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
  138. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
  139. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
  140. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
  141. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
  142. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
  143. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
  144. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
  145. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
  146. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
  147. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
  148. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
  149. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
  150. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
  151. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
  152. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
  153. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
  154. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
  155. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
  156. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
  157. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
  158. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
  159. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
  160. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
  161. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
  162. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
  163. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
  164. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
  165. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
  166. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
  167. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
  168. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
  169. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
  170. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
  171. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
  172. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
  173. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
  174. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
  175. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
  176. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
  177. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
  178. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
  179. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
  180. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
  181. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
  182. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
  183. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
  184. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
  185. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
  186. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from threading import Lock
11
+ from urllib.parse import quote, urlparse
12
+
13
+ from curl_cffi import requests
14
+ from fastapi import HTTPException
15
+ from PIL import Image
16
+
17
+ from services.config import DATA_DIR, config
18
+
19
+ IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
20
+ IMAGE_INDEX_LOCK = Lock()
21
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
22
+
23
+
24
+ class ImageStorageError(RuntimeError):
25
+ pass
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StoredImage:
30
+ rel: str
31
+ url: str
32
+ storage: str
33
+ size: int
34
+
35
+
36
+ def _clean(value: object) -> str:
37
+ return str(value or "").strip()
38
+
39
+
40
+ def _now_iso() -> str:
41
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
42
+
43
+
44
+ def _safe_relative_path(path: str) -> str:
45
+ value = str(path or "").strip().replace("\\", "/").lstrip("/")
46
+ if not value:
47
+ raise HTTPException(status_code=404, detail="image not found")
48
+ parts = Path(value).parts
49
+ if any(part in {"", ".", ".."} for part in parts):
50
+ raise HTTPException(status_code=404, detail="image not found")
51
+ return Path(*parts).as_posix()
52
+
53
+
54
+ def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
55
+ try:
56
+ with Image.open(io.BytesIO(payload)) as image:
57
+ return image.size
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def _is_image_rel(path: str) -> bool:
63
+ try:
64
+ safe_rel = _safe_relative_path(path)
65
+ except HTTPException:
66
+ return False
67
+ return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
68
+
69
+
70
+ def _local_image_path(relative_path: str) -> Path:
71
+ rel = _safe_relative_path(relative_path)
72
+ root = config.images_dir.resolve()
73
+ path = (root / rel).resolve()
74
+ try:
75
+ path.relative_to(root)
76
+ except ValueError as exc:
77
+ raise HTTPException(status_code=404, detail="image not found") from exc
78
+ return path
79
+
80
+
81
+ def _read_json_object(path: Path) -> dict[str, object]:
82
+ if not path.exists():
83
+ return {}
84
+ try:
85
+ data = json.loads(path.read_text(encoding="utf-8"))
86
+ except Exception:
87
+ return {}
88
+ return data if isinstance(data, dict) else {}
89
+
90
+
91
+ def _write_json_object(path: Path, data: dict[str, object]) -> None:
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
94
+ tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
95
+ tmp_path.replace(path)
96
+
97
+
98
+ class WebDAVClient:
99
+ def __init__(self, settings: dict[str, object]):
100
+ self.url = _clean(settings.get("webdav_url")).rstrip("/")
101
+ self.username = _clean(settings.get("webdav_username"))
102
+ self.password = _clean(settings.get("webdav_password"))
103
+ self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
104
+ self.session = requests.Session()
105
+
106
+ def _auth_kwargs(self) -> dict[str, object]:
107
+ return {"auth": (self.username, self.password)} if self.username or self.password else {}
108
+
109
+ def _request(self, method: str, url: str, **kwargs):
110
+ response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
111
+ if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
112
+ raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
113
+ return response
114
+
115
+ def remote_url(self, rel: str = "") -> str:
116
+ parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
117
+ encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
118
+ return f"{self.url}/{encoded}" if encoded else self.url
119
+
120
+ def ensure_dirs(self, rel: str) -> None:
121
+ parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
122
+ current = self.url
123
+ for item in "/".join(parts).split("/"):
124
+ if not item:
125
+ continue
126
+ current = f"{current}/{quote(item, safe='')}"
127
+ response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
128
+ if response.status_code in {201, 405}:
129
+ continue
130
+ if response.status_code >= 400:
131
+ raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
132
+
133
+ def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
134
+ self.ensure_dirs(rel)
135
+ url = self.remote_url(rel)
136
+ self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
137
+ return url
138
+
139
+ def get(self, rel: str) -> bytes:
140
+ response = self._request("GET", self.remote_url(rel))
141
+ return bytes(response.content)
142
+
143
+ def delete(self, rel: str) -> bool:
144
+ response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
145
+ if response.status_code in {200, 202, 204, 404}:
146
+ return response.status_code != 404
147
+ raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
148
+
149
+ def test(self) -> dict[str, object]:
150
+ if not self.url:
151
+ return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
152
+ if urlparse(self.url).scheme not in {"http", "https"}:
153
+ return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
154
+ test_rel = ".chatgpt2api_webdav_test.txt"
155
+ try:
156
+ self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
157
+ self.delete(test_rel)
158
+ return {"ok": True, "status": 200, "error": None}
159
+ except ImageStorageError as exc:
160
+ return {"ok": False, "status": 0, "error": str(exc)}
161
+ except Exception as exc:
162
+ return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
163
+ finally:
164
+ self.session.close()
165
+
166
+
167
+ class ImageStorageService:
168
+ def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
169
+ self.index_file = index_file
170
+ self._index_lock = IMAGE_INDEX_LOCK
171
+
172
+ def settings(self) -> dict[str, object]:
173
+ return config.get_image_storage_settings()
174
+
175
+ def mode(self) -> str:
176
+ return _clean(self.settings().get("mode")) or "local"
177
+
178
+ def _load_index(self) -> dict[str, dict[str, object]]:
179
+ raw = _read_json_object(self.index_file)
180
+ items = raw.get("items")
181
+ if not isinstance(items, dict):
182
+ return {}
183
+ return {str(key): value for key, value in items.items() if isinstance(value, dict)}
184
+
185
+ def _load_clean_index(self) -> dict[str, dict[str, object]]:
186
+ items = self._load_index()
187
+ return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
188
+
189
+ def _save_index(self, items: dict[str, dict[str, object]]) -> None:
190
+ _write_json_object(self.index_file, {"items": items})
191
+
192
+ def _public_url(self, rel: str, base_url: str | None = None) -> str:
193
+ settings = self.settings()
194
+ public_base_url = _clean(settings.get("public_base_url"))
195
+ if public_base_url:
196
+ return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
197
+ return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
198
+
199
+ def make_relative_path(self, image_data: bytes) -> str:
200
+ file_hash = hashlib.md5(image_data).hexdigest()
201
+ filename = f"{int(time.time())}_{file_hash}.png"
202
+ relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
203
+ return f"{relative_dir.as_posix()}/{filename}"
204
+
205
+ def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
206
+ config.cleanup_old_images()
207
+ rel = self.make_relative_path(image_data)
208
+ mode = self.mode()
209
+ if mode not in {"local", "webdav", "both"}:
210
+ mode = "local"
211
+ stored_local = False
212
+ stored_webdav = False
213
+ remote_url = ""
214
+
215
+ if mode in {"local", "both"}:
216
+ path = _local_image_path(rel)
217
+ path.parent.mkdir(parents=True, exist_ok=True)
218
+ path.write_bytes(image_data)
219
+ stored_local = True
220
+
221
+ if mode in {"webdav", "both"}:
222
+ remote_url = WebDAVClient(self.settings()).put(rel, image_data)
223
+ stored_webdav = True
224
+
225
+ dimensions = _image_dimensions(image_data)
226
+ item = {
227
+ "rel": rel,
228
+ "path": rel,
229
+ "name": Path(rel).name,
230
+ "date": "-".join(rel.split("/")[:3]),
231
+ "size": len(image_data),
232
+ "created_at": _now_iso(),
233
+ "storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
234
+ "local": stored_local,
235
+ "webdav": stored_webdav,
236
+ "remote_url": remote_url,
237
+ }
238
+ if dimensions:
239
+ item["width"], item["height"] = dimensions
240
+ with self._index_lock:
241
+ items = self._load_clean_index()
242
+ items[rel] = item
243
+ self._save_index(items)
244
+ return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
245
+
246
+ def get_bytes(self, rel: str) -> bytes:
247
+ safe_rel = _safe_relative_path(rel)
248
+ if not _is_image_rel(safe_rel):
249
+ raise HTTPException(status_code=404, detail="image not found")
250
+ path = _local_image_path(safe_rel)
251
+ if path.is_file():
252
+ return path.read_bytes()
253
+ item = self._load_clean_index().get(safe_rel, {})
254
+ if item.get("webdav"):
255
+ return WebDAVClient(self.settings()).get(safe_rel)
256
+ raise HTTPException(status_code=404, detail="image not found")
257
+
258
+ def exists(self, rel: str) -> bool:
259
+ safe_rel = _safe_relative_path(rel)
260
+ if not _is_image_rel(safe_rel):
261
+ return False
262
+ if _local_image_path(safe_rel).is_file():
263
+ return True
264
+ item = self._load_clean_index().get(safe_rel, {})
265
+ return bool(item.get("webdav"))
266
+
267
+ def has_local(self, rel: str) -> bool:
268
+ safe_rel = _safe_relative_path(rel)
269
+ return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
270
+
271
+ def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
272
+ with self._index_lock:
273
+ indexed = self._load_clean_index()
274
+ root = config.images_dir
275
+ changed = False
276
+ for path in root.rglob("*"):
277
+ if not path.is_file() or not _is_image_rel(path.name):
278
+ continue
279
+ rel = path.relative_to(root).as_posix()
280
+ if rel in indexed:
281
+ continue
282
+ dimensions = None
283
+ try:
284
+ dimensions = _image_dimensions(path.read_bytes())
285
+ except Exception:
286
+ dimensions = None
287
+ indexed[rel] = {
288
+ "rel": rel,
289
+ "path": rel,
290
+ "name": path.name,
291
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
292
+ "size": path.stat().st_size,
293
+ "created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
294
+ "storage": "local",
295
+ "local": True,
296
+ "webdav": False,
297
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
298
+ }
299
+ changed = True
300
+
301
+ items: list[dict[str, object]] = []
302
+ for rel, item in list(indexed.items()):
303
+ if not _is_image_rel(rel):
304
+ indexed.pop(rel, None)
305
+ changed = True
306
+ continue
307
+ local = _local_image_path(rel).is_file()
308
+ webdav = bool(item.get("webdav"))
309
+ if not local and not webdav:
310
+ indexed.pop(rel, None)
311
+ changed = True
312
+ continue
313
+ storage = "both" if local and webdav else ("webdav" if webdav else "local")
314
+ if item.get("local") != local or item.get("storage") != storage:
315
+ item = {
316
+ **item,
317
+ "local": local,
318
+ "storage": storage,
319
+ }
320
+ indexed[rel] = item
321
+ changed = True
322
+ day = str(item.get("date") or "")
323
+ if start_date and day < start_date:
324
+ continue
325
+ if end_date and day > end_date:
326
+ continue
327
+ items.append({
328
+ **item,
329
+ "rel": rel,
330
+ "path": rel,
331
+ "url": self._public_url(rel, base_url),
332
+ })
333
+ if changed:
334
+ self._save_index(indexed)
335
+ items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
336
+ return items
337
+
338
+ def delete(self, rel: str) -> bool:
339
+ safe_rel = _safe_relative_path(rel)
340
+ removed = False
341
+ path = _local_image_path(safe_rel)
342
+ if path.is_file():
343
+ path.unlink()
344
+ removed = True
345
+ with self._index_lock:
346
+ items = self._load_clean_index()
347
+ item = items.get(safe_rel, {})
348
+ if item.get("webdav"):
349
+ try:
350
+ removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
351
+ except ImageStorageError:
352
+ if not removed:
353
+ raise
354
+ if safe_rel in items:
355
+ items.pop(safe_rel, None)
356
+ self._save_index(items)
357
+ return removed
358
+
359
+ def sync_all(self) -> dict[str, int]:
360
+ settings = self.settings()
361
+ if self.mode() not in {"webdav", "both"}:
362
+ raise ImageStorageError("WebDAV 图片存储未启用")
363
+ uploaded = 0
364
+ skipped = 0
365
+ failed = 0
366
+ with self._index_lock:
367
+ items = self._load_clean_index()
368
+ client = WebDAVClient(settings)
369
+ for path in sorted(config.images_dir.rglob("*")):
370
+ if not path.is_file() or not _is_image_rel(path.name):
371
+ continue
372
+ rel = path.relative_to(config.images_dir).as_posix()
373
+ item = items.get(rel, {})
374
+ if item.get("webdav"):
375
+ skipped += 1
376
+ continue
377
+ try:
378
+ payload = path.read_bytes()
379
+ remote_url = client.put(rel, payload)
380
+ dimensions = _image_dimensions(payload)
381
+ items[rel] = {
382
+ **item,
383
+ "rel": rel,
384
+ "path": rel,
385
+ "name": path.name,
386
+ "date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
387
+ "size": len(payload),
388
+ "created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
389
+ "storage": "both",
390
+ "local": True,
391
+ "webdav": True,
392
+ "remote_url": remote_url,
393
+ **({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
394
+ }
395
+ uploaded += 1
396
+ except Exception:
397
+ failed += 1
398
+ self._save_index(items)
399
+ return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
400
+
401
+ def test_webdav(self) -> dict[str, object]:
402
+ return WebDAVClient(self.settings()).test()
403
+
404
+
405
+ image_storage_service = ImageStorageService()
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from services.config import DATA_DIR
7
+
8
+ TAGS_FILE = DATA_DIR / "image_tags.json"
9
+
10
+
11
+ def _ensure_file() -> None:
12
+ TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
13
+ if not TAGS_FILE.exists():
14
+ TAGS_FILE.write_text("{}", encoding="utf-8")
15
+
16
+
17
+ def load_tags() -> dict[str, list[str]]:
18
+ _ensure_file()
19
+ try:
20
+ data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
21
+ except Exception:
22
+ return {}
23
+ return data if isinstance(data, dict) else {}
24
+
25
+
26
+ def save_tags(data: dict[str, list[str]]) -> None:
27
+ _ensure_file()
28
+ TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
29
+
30
+
31
+ def get_tags(image_rel: str) -> list[str]:
32
+ return load_tags().get(image_rel, [])
33
+
34
+
35
+ def set_tags(image_rel: str, tags: list[str]) -> list[str]:
36
+ data = load_tags()
37
+ cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
38
+ if cleaned:
39
+ data[image_rel] = cleaned
40
+ else:
41
+ data.pop(image_rel, None)
42
+ save_tags(data)
43
+ return cleaned
44
+
45
+
46
+ def remove_tags(image_rel: str) -> None:
47
+ data = load_tags()
48
+ if data.pop(image_rel, None) is not None:
49
+ save_tags(data)
50
+
51
+
52
+ def delete_tag(tag: str) -> int:
53
+ """从所有图片中删除指定标签,返回受影响的图片数。"""
54
+ data = load_tags()
55
+ count = 0
56
+ for rel in list(data):
57
+ if tag in data[rel]:
58
+ data[rel] = [t for t in data[rel] if t != tag]
59
+ if not data[rel]:
60
+ del data[rel]
61
+ count += 1
62
+ if count > 0:
63
+ save_tags(data)
64
+ return count
65
+
66
+
67
+ def get_all_tags() -> list[str]:
68
+ data = load_tags()
69
+ seen: set[str] = set()
70
+ result: list[str] = []
71
+ for tags in data.values():
72
+ for t in tags:
73
+ if t not in seen:
74
+ seen.add(t)
75
+ result.append(t)
76
+ return result