labull-framework 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.
- labull_framework/__init__.py +8 -0
- labull_framework/admin.py +63 -0
- labull_framework/apps.py +33 -0
- labull_framework/auth/__init__.py +4 -0
- labull_framework/auth/backend.py +144 -0
- labull_framework/auth/bootstrap.py +47 -0
- labull_framework/auth/sso.py +259 -0
- labull_framework/auth/views.py +184 -0
- labull_framework/canvas/__init__.py +19 -0
- labull_framework/canvas/data.py +523 -0
- labull_framework/canvas/gitinfo.py +155 -0
- labull_framework/canvas/static/canvas.css +487 -0
- labull_framework/canvas/static/canvas.js +1036 -0
- labull_framework/canvas/static/page.html +52 -0
- labull_framework/canvas/views.py +121 -0
- labull_framework/env.py +187 -0
- labull_framework/jsonio.py +57 -0
- labull_framework/magic_block/__init__.py +2 -0
- labull_framework/magic_block/devkit.py +428 -0
- labull_framework/magic_block/dispatch.py +314 -0
- labull_framework/magic_block/export.py +773 -0
- labull_framework/magic_block/health.py +220 -0
- labull_framework/magic_block/registry.py +157 -0
- labull_framework/magic_block/sdk.py +245 -0
- labull_framework/magic_block/service.py +405 -0
- labull_framework/magic_block/validate.py +558 -0
- labull_framework/magic_block/views.py +407 -0
- labull_framework/management/__init__.py +0 -0
- labull_framework/management/commands/__init__.py +0 -0
- labull_framework/management/commands/labull_contract.py +52 -0
- labull_framework/migrations/0001_initial.py +71 -0
- labull_framework/migrations/__init__.py +0 -0
- labull_framework/models.py +76 -0
- labull_framework/settings.py +223 -0
- labull_framework/urls.py +93 -0
- labull_framework-0.1.0.dist-info/METADATA +140 -0
- labull_framework-0.1.0.dist-info/RECORD +40 -0
- labull_framework-0.1.0.dist-info/WHEEL +5 -0
- labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
- labull_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
"""magic block export:**把库里的块算成一整个开发包**,以及**把改好的目录读回库**。
|
|
2
|
+
|
|
3
|
+
四个函数,三个方向,都是"块作者用自己编辑器改代码"那条路:
|
|
4
|
+
|
|
5
|
+
| 方向 | 函数 | 干什么 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| 出 | `export_package()` | 库 → **一份"相对路径 → 正文"的表**(浏览器把它写进用户选的目录) |
|
|
8
|
+
| 回 | `scan_folders(upload)` | **浏览器扫到的**目录 → 逐块对比(只读,不写库) |
|
|
9
|
+
| 回 | `read_dir(path)` | **服务器上的**目录 → 逐块对比(同一套判据;CLI 或源码就在服务器上时用) |
|
|
10
|
+
| 回 | `write_dir(items, actor)` | 把勾选的块写回库(**走保存那一条路**,一行保存逻辑都不重写) |
|
|
11
|
+
|
|
12
|
+
🔴 **写盘不在后端**:开发目录是用户自己机器上的一个文件夹(浏览器用 File System Access API 选的),
|
|
13
|
+
后端只算"该铺什么"、只认"浏览器扫到了什么" —— 它一个字节都不碰对方的盘。
|
|
14
|
+
所以那两条读口是**两种入口、同一套判据**:配对、比对、能不能存全部只有 `_judge()` 这一处。
|
|
15
|
+
🔴 **落盘布局是契约**(用户按它找文件):`workspace/<可读名>-<块 uuid>/`,三份生成物在顶层。
|
|
16
|
+
🔴 **合并进库的判据只有一处**:`write_dir` 调 `service.save()` —— 校验、体检、编译缓存失效全在那一处。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Iterable
|
|
25
|
+
|
|
26
|
+
from . import devkit, validate
|
|
27
|
+
from .sdk import (
|
|
28
|
+
BLOCK_BACKEND_FILE,
|
|
29
|
+
BLOCK_ENTRY,
|
|
30
|
+
BLOCK_MANIFEST,
|
|
31
|
+
LIB_SDK,
|
|
32
|
+
MAX_BODY_BYTES,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#: 铺出来的开发目录顶层那一层(名字是契约:用户按它找)。
|
|
36
|
+
DEV_BLOCKS_DIR = 'workspace'
|
|
37
|
+
|
|
38
|
+
#: 平台铺下来的生成物:**读回时一律静默跳过**(它们不是块包的一部分,进库只会把库弄脏)。
|
|
39
|
+
#: 🔴 名字的真源在 `devkit`(那几份东西就是它写的):块文件夹里两份 + 每个块一份主题,
|
|
40
|
+
#: 开发目录顶层四份生成物 + 两份前端写的(Page 枚举 / 主题与控件清单)。
|
|
41
|
+
PLATFORM_FILES: tuple[str, ...] = (
|
|
42
|
+
'api.open.d.ts',
|
|
43
|
+
'VERSION.lock',
|
|
44
|
+
devkit.README,
|
|
45
|
+
devkit.GUIDE,
|
|
46
|
+
devkit.BACKEND_LOCK,
|
|
47
|
+
devkit.PAGES_LOCK,
|
|
48
|
+
devkit.THEME,
|
|
49
|
+
devkit.CONTROLS,
|
|
50
|
+
devkit.REQUIREMENTS,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: 从目录里读块时,哪些文件算"能读的文本"(其余一律跳过,不报成"不是文本文件")。
|
|
54
|
+
#: 🔴 浏览器那一侧**照这份名单扫**(导出报文里的 `rules.textExt`)—— 它不自己抄一份。
|
|
55
|
+
TEXT_EXT: tuple[str, ...] = (
|
|
56
|
+
'.html',
|
|
57
|
+
'.htm',
|
|
58
|
+
'.js',
|
|
59
|
+
'.mjs',
|
|
60
|
+
'.cjs',
|
|
61
|
+
'.ts',
|
|
62
|
+
'.css',
|
|
63
|
+
'.json',
|
|
64
|
+
'.yaml',
|
|
65
|
+
'.yml',
|
|
66
|
+
'.md',
|
|
67
|
+
'.txt',
|
|
68
|
+
'.py',
|
|
69
|
+
'.svg',
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
#: 扫描与读文件的几道上限(都是"防一次手滑把整台机器读完",不是安全边界)。
|
|
73
|
+
MAX_DEPTH = 3
|
|
74
|
+
MAX_FILE_BYTES = 512 * 1024
|
|
75
|
+
MAX_BUNDLE_CHARS = 8 * 1024 * 1024
|
|
76
|
+
|
|
77
|
+
#: 爬目录时跳过的目录名(依赖 / 构建产物 / 缓存)。
|
|
78
|
+
SKIP_DIRS: frozenset[str] = frozenset(
|
|
79
|
+
{'.git', '.venv', 'venv', '__pycache__', 'node_modules', 'dist', 'build', '.idea', '.vscode'}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#: 模块名那一段的形状(与清单里 `module` 的规矩同一份)。
|
|
83
|
+
MODULE_SEGMENT_RE = re.compile(validate.MODULE_PATTERN.pattern)
|
|
84
|
+
#: uuid 那一段的形状(文件夹名里带 uuid 时用它认出来)。
|
|
85
|
+
UUID_SEGMENT_RE = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
|
86
|
+
#: 文件夹名**末尾**那一段 uuid(导出铺的是 `<可读名>-<块 uuid>`;末尾这个词是判据,别放宽)。
|
|
87
|
+
TRAILING_ID_RE = re.compile(r'([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$')
|
|
88
|
+
|
|
89
|
+
#: 配对打分:清单命中压过一切,其余按"名字 + uuid / 名字 + 模块 / uuid"递减。
|
|
90
|
+
MANIFEST_MATCH_SCORE = 200
|
|
91
|
+
|
|
92
|
+
# ──────────────────────────── 认块时的打分(越大越可信) ────────────────────────────
|
|
93
|
+
#
|
|
94
|
+
# 🔴 四档名字分**必须**拉开:`_assign()` 要的是"全局择优",而"名字和 uuid 都对上"
|
|
95
|
+
# 与"只有一个同名"之间如果同分,选谁都行 —— 那就会出现"改一个块的文件夹,结果写掉另一个块"。
|
|
96
|
+
#: 文件夹名 = `<可读名>-<块 uuid>`(导出铺的就是这个形状)⇒ **名字与身份同时对上**。
|
|
97
|
+
SCORE_NAME_AND_ID = 160
|
|
98
|
+
#: 文件夹名 = 块的可读名,**且**路径里那一段模块名也对得上(老布局 `<模块>/<可读名>` 的最硬线索)。
|
|
99
|
+
SCORE_NAME_AND_MODULE = 120
|
|
100
|
+
#: 文件夹名 = 块的可读名,库里只有一个块叫这个名字。
|
|
101
|
+
SCORE_NAME = 80
|
|
102
|
+
#: 文件夹名 = 块的可读名,但库里有好几个同名的 ⇒ 可疑,别当成唯一命中。
|
|
103
|
+
SCORE_NAME_AMBIGUOUS = 40
|
|
104
|
+
#: 文件夹名 = 块记录的 uuid(有人直接把 uuid 当文件夹名时的退路)。
|
|
105
|
+
SCORE_ID = 30
|
|
106
|
+
|
|
107
|
+
#: 扫描一个**顶层**目录时直接忽略的文件名(都是"开发目录说明 / 生成物"这一类)。
|
|
108
|
+
#: ⚠️ 只对最外那一层生效:块文件夹**里面**的文件一个都不能跳过。
|
|
109
|
+
_SKIP_ROOT_FILES: frozenset[str] = frozenset(
|
|
110
|
+
{
|
|
111
|
+
devkit.README.lower(),
|
|
112
|
+
devkit.GUIDE.lower(),
|
|
113
|
+
devkit.BACKEND_LOCK.lower(),
|
|
114
|
+
devkit.PAGES_LOCK.lower(),
|
|
115
|
+
devkit.THEME.lower(),
|
|
116
|
+
devkit.CONTROLS.lower(),
|
|
117
|
+
devkit.REQUIREMENTS.lower(),
|
|
118
|
+
'readme.md',
|
|
119
|
+
'readme.lock',
|
|
120
|
+
'.ds_store',
|
|
121
|
+
'thumbs.db',
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _model() -> Any:
|
|
127
|
+
"""块表(用 `apps.get_model` 取:这个包在 app 还没注册完时也可能被 import)。
|
|
128
|
+
|
|
129
|
+
🔴 与 `service.MagicBlockService._model()` **逐字同一份**:这一层原来漏了它,于是
|
|
130
|
+
`read_dir` 一进门就 `NameError`(HTTP 上是 500,报文只肯说"服务端出错")——
|
|
131
|
+
而这几条口(export / read / import)正是控制台那一趟来回的全部。取法要跟着 service 走:
|
|
132
|
+
直接 `from .models import MagicBlock` 会在 app 未就绪时炸。
|
|
133
|
+
"""
|
|
134
|
+
from django.apps import apps
|
|
135
|
+
|
|
136
|
+
return apps.get_model('labull_framework', 'MagicBlock')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _reason(error: BaseException) -> str:
|
|
140
|
+
"""一句能给人看的失败原因(`OSError` 常带 errno,那就连 errno 一起说)。"""
|
|
141
|
+
if isinstance(error, OSError) and error.errno is not None:
|
|
142
|
+
return f'{error.strerror or error.__class__.__name__}(errno {error.errno})'
|
|
143
|
+
return str(error) or error.__class__.__name__
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _failure(kind: str, where: str, message: str) -> dict:
|
|
147
|
+
"""一条"这里没成"的记录。
|
|
148
|
+
|
|
149
|
+
🔴 `kind` 是给界面分组用的字面量(`dir` / `file` / `match`),**与 `message` 一起原样回给前端** ——
|
|
150
|
+
"哪里错了"和"为什么错"要分得开,用户才能一眼看出"是我路径给错了"还是"文件本身不合法"。
|
|
151
|
+
⚠️ 浏览器扫出来的失败也走这一个形状(它报"读不了 / 不是文本 / 太大",后端报"没配上块")。
|
|
152
|
+
"""
|
|
153
|
+
return {'kind': kind, 'where': _posix(where), 'message': message}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _safe_name(raw: str, fallback: str) -> str:
|
|
157
|
+
"""把一段用户文本变成**能当文件夹名**的形式。
|
|
158
|
+
|
|
159
|
+
🔴 **保留中文**(块的文件夹名就是 `<可读名>-<uuid>`,可读名常常是中文),只清掉文件系统不接受的东西:
|
|
160
|
+
Windows 的 `<>:"/\\|?*`、控制字符、结尾的点与空格(`foo.` 在 Windows 上建不出来)、
|
|
161
|
+
以及全空白 / `.` / `..`。清完什么都不剩就退回 `fallback`。
|
|
162
|
+
"""
|
|
163
|
+
text = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '-', str(raw or ''))
|
|
164
|
+
text = re.sub(r'\s+', ' ', text).strip().rstrip('.')
|
|
165
|
+
return text[:80] or fallback
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _same(left: Any, right: Any) -> bool:
|
|
169
|
+
"""两个标识算不算同一个(**大小写敏感**、去首尾空白)。
|
|
170
|
+
|
|
171
|
+
🔴 **故意的**:Windows 上路径不区分大小写,但这里的用途是"文件夹名 ↔ 库里那条记录"的配对 ——
|
|
172
|
+
把 `Orders` 与 `orders` 当成同一个块,会让"改名/改模块"这类**需要人看见**的差异被悄悄吞掉。
|
|
173
|
+
"""
|
|
174
|
+
return str(left or '').strip() == str(right or '').strip()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _posix(path: str) -> str:
|
|
178
|
+
"""回给前端的路径一律用 `/`:它可能被粘进编辑器、也可能写进文档,`\\\\` 只会碍事。"""
|
|
179
|
+
return str(path or '').replace('\\', '/')
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _strip_bom(text: str) -> str:
|
|
183
|
+
"""去掉 UTF-8 BOM。
|
|
184
|
+
|
|
185
|
+
🔴 必须去:Windows 上记事本 / 某些导出工具会加 BOM。带着它的正文与库里的那一份
|
|
186
|
+
**永远不相等** ⇒ 每个块都被判成"改动过","读回改动"这个功能就废了。
|
|
187
|
+
⚠️ 浏览器读文件也要去(`File.text()` 会把 BOM 解成第一个字符),见 `localfs.ts`。
|
|
188
|
+
"""
|
|
189
|
+
return text[1:] if text.startswith('\ufeff') else text
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_text(name: str) -> bool:
|
|
193
|
+
"""这个文件名算不算"能读的文本"(认 `TEXT_EXT` 里的扩展名,大小写不敏感)。"""
|
|
194
|
+
return Path(name).suffix.lower() in TEXT_EXT
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_platform_file(name: str) -> bool:
|
|
198
|
+
"""平台随开发目录铺下来的生成物(`api.open.d.ts` / `*.lock*`):读回时**静默跳过**、不进库。"""
|
|
199
|
+
return name in PLATFORM_FILES
|
|
200
|
+
|
|
201
|
+
# ──────────────────────────── 出:库 → 开发包(后端只算,写盘在浏览器) ────────────────────────────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _block_folder(row: Any) -> str:
|
|
205
|
+
"""一个块的文件夹名:`<可读名>-<块 uuid>`。
|
|
206
|
+
|
|
207
|
+
🔴 uuid 写在名字里是**故意的**:配对时"名字 + 身份"同时对上,就不必再靠"名字像不像"猜 ——
|
|
208
|
+
重名、改名、被挪进别的目录都照样认得出来(见 `_score` 里那条最硬的线索)。
|
|
209
|
+
"""
|
|
210
|
+
short = str(getattr(row, 'id', '') or '')[:8] or 'block'
|
|
211
|
+
name = _safe_name(getattr(row, 'name', '') or short, short)
|
|
212
|
+
return f'{name}-{row.id}'
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def package_files(rows: Iterable[Any], tables: list[dict[str, Any]] | None = None) -> dict[str, str]:
|
|
216
|
+
"""把"库里的行 + 模型形状"算成**一整个开发包**(相对路径 → 正文,一律 `/` 分隔)。
|
|
217
|
+
|
|
218
|
+
🔴 **纯函数**:不碰数据库、不写盘 —— 浏览器拿到它之后按路径写进用户选的目录。
|
|
219
|
+
🔴 一个块一个文件夹 = 一整个文件包,另加两份**平台生成物**:`api.open.d.ts`
|
|
220
|
+
(照这个块自己的后端生成,给编辑器补全用)与 `VERSION.lock`(导出时用的 SDK);两者都不进库。
|
|
221
|
+
🔴 顶层那几份生成物(开发说明 / 精简版说明 / 后端清单)由 `devkit` 写正文,名字也归它
|
|
222
|
+
(见 `devkit` 顶上那一段);`pages.lock.ts` **不在返回值里** —— 它是前端自己写的那一份
|
|
223
|
+
(路由表只有前端有)。
|
|
224
|
+
"""
|
|
225
|
+
from .service import api_dts
|
|
226
|
+
|
|
227
|
+
files: dict[str, str] = {}
|
|
228
|
+
blocks = 0
|
|
229
|
+
for row in rows:
|
|
230
|
+
folder = _block_folder(row)
|
|
231
|
+
package = dict(getattr(row, 'files', None) or {})
|
|
232
|
+
if BLOCK_MANIFEST not in package:
|
|
233
|
+
# 🔴 没有清单就补一份:id 用**这个块自己的 uuid**,绝不编一个看起来合法的 ——
|
|
234
|
+
# 那会让下次"读回改动"报一条用户无法理解的错。
|
|
235
|
+
package[BLOCK_MANIFEST] = _starter_manifest(row)
|
|
236
|
+
for name, body in package.items():
|
|
237
|
+
files[f'{DEV_BLOCKS_DIR}/{folder}/{name}'] = str(body)
|
|
238
|
+
dts = api_dts(validate.routes_of(package.get(BLOCK_BACKEND_FILE) or ''))
|
|
239
|
+
if dts:
|
|
240
|
+
files[f'{DEV_BLOCKS_DIR}/{folder}/api.open.d.ts'] = dts
|
|
241
|
+
files[f'{DEV_BLOCKS_DIR}/{folder}/VERSION.lock'] = f'{LIB_SDK}\n'
|
|
242
|
+
blocks += 1
|
|
243
|
+
files[devkit.BACKEND_LOCK] = devkit.backend_lock_pyi(tables)
|
|
244
|
+
files[devkit.GUIDE] = devkit.dev_guide()
|
|
245
|
+
files[devkit.REQUIREMENTS] = requirements_text()
|
|
246
|
+
files[devkit.README] = devkit.dev_readme(blocks)
|
|
247
|
+
return files
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def requirements_text() -> str:
|
|
251
|
+
"""这个项目的后端依赖清单(`BASE_DIR/requirements.txt` 的**逐字副本** + 一行说明)。
|
|
252
|
+
|
|
253
|
+
🔴 为什么要铺它:块后端跑在**项目的解释器**里,作者要 `import` 什么就得先装什么 ——
|
|
254
|
+
而块包里没有依赖文件、`backend.lock.pyi` 也只列"能 import 的库名"。给他一份可照抄的清单,
|
|
255
|
+
他就不必回来问"你们后端到底装了什么"。
|
|
256
|
+
⚠️ 逐字抄(前面只加一行 `#` 注释):它得能直接 `pip install -r` 用。
|
|
257
|
+
⚠️ 项目里没有这个文件 / 读不出来就**如实写一行**,不编一份假的(那比没有更坏)。
|
|
258
|
+
"""
|
|
259
|
+
from django.conf import settings
|
|
260
|
+
|
|
261
|
+
base = getattr(settings, 'BASE_DIR', None)
|
|
262
|
+
source = (Path(base) / 'requirements.txt') if base else None
|
|
263
|
+
if source is not None and source.is_file():
|
|
264
|
+
try:
|
|
265
|
+
body = source.read_text(encoding='utf-8')
|
|
266
|
+
except OSError as exc:
|
|
267
|
+
return f'# 读不到 {source}:{_reason(exc)}\n'
|
|
268
|
+
return (
|
|
269
|
+
f'# 这个项目的后端依赖清单({source.name} 的逐字副本,导出时抄的)。\n'
|
|
270
|
+
'# 块后端与项目共用同一个解释器:要 import 什么,先确认它在这里面。\n'
|
|
271
|
+
f'{body}'
|
|
272
|
+
)
|
|
273
|
+
return (
|
|
274
|
+
'# 这个项目里没有 requirements.txt(或者 settings 里没有 BASE_DIR)——\n'
|
|
275
|
+
'# 块后端能 import 什么,以**这个项目实际装了的包**为准。\n'
|
|
276
|
+
'# 想知道装了哪些:在项目里跑一句 pip freeze。\n'
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def scan_rules() -> dict:
|
|
281
|
+
"""浏览器"读回改动"时要照的规则(**判据只有这一处**,浏览器一条都不抄)。
|
|
282
|
+
|
|
283
|
+
🔴 浏览器只会干"读文件 / 写文件":哪些扩展名算文本、哪些目录跳过、几层到顶、多大算太大,
|
|
284
|
+
全部照这一份走(它从导出报文里拿,见 `POST /magic-blocks/export` 的 `rules`)。
|
|
285
|
+
抄一份进 JS 的下场是"两边迟早漂",而漂了的表现是"导出认得、读回认不得"。
|
|
286
|
+
"""
|
|
287
|
+
return {
|
|
288
|
+
'entry': BLOCK_ENTRY,
|
|
289
|
+
'blocksDir': DEV_BLOCKS_DIR,
|
|
290
|
+
'textExt': list(TEXT_EXT),
|
|
291
|
+
'skipDirs': sorted(SKIP_DIRS),
|
|
292
|
+
'platformFiles': list(PLATFORM_FILES),
|
|
293
|
+
'rootSkipFiles': sorted(_SKIP_ROOT_FILES),
|
|
294
|
+
'maxDepth': MAX_DEPTH,
|
|
295
|
+
'maxFileBytes': MAX_FILE_BYTES,
|
|
296
|
+
'maxBodyBytes': MAX_BODY_BYTES,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def export_package() -> dict:
|
|
301
|
+
"""`POST /magic-blocks/export` 的全部答案:一整个开发包 + 读回时要照的规则。
|
|
302
|
+
|
|
303
|
+
🔴 它**不写盘**(写盘在浏览器那一侧,目录是用户自己机器上的):
|
|
304
|
+
回话里的 `files` 就是"该铺什么",键是相对路径。
|
|
305
|
+
"""
|
|
306
|
+
from ..canvas.data import shape
|
|
307
|
+
|
|
308
|
+
rows = list(_model().objects.all().order_by('name'))
|
|
309
|
+
tables = (shape() or {}).get('tables') or []
|
|
310
|
+
return {'files': package_files(rows, tables), 'blocks': len(rows), 'rules': scan_rules()}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _starter_manifest(row: Any) -> str:
|
|
314
|
+
"""包里没有清单时补的那一份(四个键全必填)。"""
|
|
315
|
+
name = row.name or str(row.id)[:8]
|
|
316
|
+
return (
|
|
317
|
+
'# 块包必备文件之一:这个块的身份与元信息。四个键全部必填,只有这四个。\n'
|
|
318
|
+
f'id: "{row.id}"\n'
|
|
319
|
+
f'module: "{row.module or "home"}"\n'
|
|
320
|
+
f'name: "{name}"\n'
|
|
321
|
+
f'brief: "{row.brief or name}"\n'
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ──────────────────────────── 回:目录 → 逐块对比(两种入口,一套判据) ────────────────────────────
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _Collect:
|
|
329
|
+
"""一次扫描攒下来的东西。
|
|
330
|
+
|
|
331
|
+
⚠️ 服务器读盘(`read_dir`)与浏览器递上来(`scan_folders`)都用它:前者的失败来自
|
|
332
|
+
`try/except OSError`,后者的失败由浏览器报(它读不了 / 不是文本 / 太大)。
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
def __init__(self) -> None:
|
|
336
|
+
self.folders: list[dict] = []
|
|
337
|
+
self.files = 0
|
|
338
|
+
self.failures: list[dict] = []
|
|
339
|
+
#: 因为深度上限而没往下走 ⇒ 不能说"目录里没有就是删了"。
|
|
340
|
+
self.truncated = False
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def scan_folders(upload: dict, include_missing: bool = False) -> dict:
|
|
344
|
+
"""**浏览器扫到的**那个目录,与库里的块逐块比一遍(只读,不写库)。
|
|
345
|
+
|
|
346
|
+
:param upload: `{'root'?, 'folders': [{'where', 'files'}], 'failures'?, 'truncated'?}`
|
|
347
|
+
—— 形状由 `frontend/magic-block/src/localfs.ts` 的扫描器给出(它照 `scan_rules()` 走)。
|
|
348
|
+
:param include_missing: 默认 `False` —— "没扫到"不等于"要删掉",默认列出来只会误导。
|
|
349
|
+
|
|
350
|
+
🔴 这里**看不到对方的盘**(也不需要):递上来的就是事实。配对 / 比对 / 能不能存仍然只有
|
|
351
|
+
`_judge()` 这一处判据 —— 换一个入口不换规矩。
|
|
352
|
+
"""
|
|
353
|
+
given = upload if isinstance(upload, dict) else {}
|
|
354
|
+
collect = _Collect()
|
|
355
|
+
collect.truncated = given.get('truncated') is True
|
|
356
|
+
for item in given.get('failures') or []:
|
|
357
|
+
if not isinstance(item, dict):
|
|
358
|
+
continue
|
|
359
|
+
collect.failures.append(
|
|
360
|
+
_failure(str(item.get('kind') or 'file'), str(item.get('where') or ''), str(item.get('message') or ''))
|
|
361
|
+
)
|
|
362
|
+
for item in given.get('folders') or []:
|
|
363
|
+
if not isinstance(item, dict):
|
|
364
|
+
continue
|
|
365
|
+
where = _posix(str(item.get('where') or ''))
|
|
366
|
+
raw = item.get('files')
|
|
367
|
+
files = {str(name): str(body) for name, body in raw.items()} if isinstance(raw, dict) else {}
|
|
368
|
+
collect.files += len(files)
|
|
369
|
+
collect.folders.append(
|
|
370
|
+
{'abs': where, 'where': where, 'segments': [seg for seg in where.split('/') if seg], 'files': files}
|
|
371
|
+
)
|
|
372
|
+
return _judge(str(given.get('root') or ''), collect, include_missing)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def read_dir(path: str, include_missing: bool = False) -> dict:
|
|
376
|
+
"""读一个**服务器上的**目录,与库里的块**逐块比一遍**(只读,不写库)。
|
|
377
|
+
|
|
378
|
+
⚠️ 控制台那条路走 `scan_folders()`(开发目录在用户自己机器上);这一条留给
|
|
379
|
+
"源码本来就在服务器上"的用法(CLI / 脚本)。两条入口的判断完全一样。
|
|
380
|
+
:param include_missing: 默认 `False` —— 路径往往只指到一个块或一个模块的文件夹,
|
|
381
|
+
"没扫到"不等于"要删掉",默认列出来只会误导。
|
|
382
|
+
"""
|
|
383
|
+
given = str(path or '').strip()
|
|
384
|
+
collect = _Collect()
|
|
385
|
+
if not given:
|
|
386
|
+
collect.failures.append(_failure('dir', '', '还没有给目录路径'))
|
|
387
|
+
return _result('', collect, [], [])
|
|
388
|
+
if not os.path.isabs(given):
|
|
389
|
+
collect.failures.append(_failure('dir', given, '要一个**绝对路径**(相对路径在服务端没有意义)'))
|
|
390
|
+
return _result(given, collect, [], [])
|
|
391
|
+
root = Path(given).resolve()
|
|
392
|
+
if not root.is_dir():
|
|
393
|
+
collect.failures.append(
|
|
394
|
+
_failure('dir', str(root), '这个目录不存在或读不了(后端跑在容器里时,那个目录要先挂进去)')
|
|
395
|
+
)
|
|
396
|
+
return _result(str(root), collect, [], [])
|
|
397
|
+
|
|
398
|
+
_discover(root, collect, [], 0)
|
|
399
|
+
return _judge(_posix(str(root)), collect, include_missing)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _judge(root: str, collect: _Collect, include_missing: bool) -> dict:
|
|
403
|
+
"""配对 + 逐文件比 + 提前算"能不能存"(**两种读入口共用这一处**)。"""
|
|
404
|
+
pool = list(_model().objects.all())
|
|
405
|
+
matched = _assign(collect.folders, pool)
|
|
406
|
+
used = {pool[index].id: collect.folders[disk]['where'] for disk, index in matched.items()}
|
|
407
|
+
blocks: list[dict] = []
|
|
408
|
+
unmatched: list[str] = []
|
|
409
|
+
for index, disk in enumerate(collect.folders):
|
|
410
|
+
hit = matched.get(index)
|
|
411
|
+
if hit is None:
|
|
412
|
+
unmatched.append(disk['where'] or '(选中的目录本身)')
|
|
413
|
+
collect.failures.append(_failure('match', disk['abs'], _explain(disk, pool, used)))
|
|
414
|
+
continue
|
|
415
|
+
blocks.append(_diff(pool[hit], disk))
|
|
416
|
+
|
|
417
|
+
if include_missing:
|
|
418
|
+
for row in pool:
|
|
419
|
+
if row.id in used:
|
|
420
|
+
continue
|
|
421
|
+
blocks.append(
|
|
422
|
+
{
|
|
423
|
+
'id': row.id,
|
|
424
|
+
'name': row.name,
|
|
425
|
+
'folder': '',
|
|
426
|
+
'where': '',
|
|
427
|
+
'status': 'missing',
|
|
428
|
+
'changedFiles': sorted(row.files or {}),
|
|
429
|
+
'files': {},
|
|
430
|
+
'canApply': False,
|
|
431
|
+
'blockedWhy': '这个块在目录里找不到(要保存它,请把它的文件夹放进目录)',
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# 🔴 每条 diff 上用**保存那套判据**提前算结论("扫描说能存、真存又不过"是同一件事的两个说法)。
|
|
436
|
+
for item in blocks:
|
|
437
|
+
if item.get('canApply', False) and item['files']:
|
|
438
|
+
messages = [message for _stage, message in validate.check_block(item['files'])]
|
|
439
|
+
if messages:
|
|
440
|
+
item['canApply'] = False
|
|
441
|
+
item['blockedWhy'] = '校验没过:' + ';'.join(messages)
|
|
442
|
+
|
|
443
|
+
order = {'modified': 0, 'unchanged': 1, 'missing': 2}
|
|
444
|
+
blocks.sort(key=lambda item: (order.get(item['status'], 9), item['folder']))
|
|
445
|
+
return _result(root, collect, blocks, unmatched)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _result(root: str, collect: _Collect, blocks: list[dict], unmatched: list[str]) -> dict:
|
|
449
|
+
"""扫描结果(形状与前端那份一致)。"""
|
|
450
|
+
return {
|
|
451
|
+
'root': _posix(root),
|
|
452
|
+
'folders': len(collect.folders),
|
|
453
|
+
'files': collect.files,
|
|
454
|
+
'blocks': blocks,
|
|
455
|
+
'unmatched': unmatched,
|
|
456
|
+
'failures': collect.failures,
|
|
457
|
+
# "目录里没有它"这条判据只有在**整棵树都走完了**的情况下才可信。
|
|
458
|
+
'complete': not collect.truncated,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _discover(root: Path, collect: _Collect, segments: list[str], depth: int) -> None:
|
|
463
|
+
"""走一遍目录,找出所有候选块文件夹。
|
|
464
|
+
|
|
465
|
+
🔴 候选判据:这一层有入口 `index.open.html`,或者"到底了"(没有子文件夹)——
|
|
466
|
+
坏包与空文件夹也要交给配对那一步去认,不能静默跳过。
|
|
467
|
+
"""
|
|
468
|
+
try:
|
|
469
|
+
entries = sorted(root.iterdir(), key=lambda item: item.name)
|
|
470
|
+
except OSError as exc:
|
|
471
|
+
collect.failures.append(_failure('dir', str(root), f'读不了这个文件夹:{_reason(exc)}'))
|
|
472
|
+
return
|
|
473
|
+
dirs = [item for item in entries if item.is_dir() and not item.is_symlink()]
|
|
474
|
+
files = [item for item in entries if item.is_file()]
|
|
475
|
+
|
|
476
|
+
if not dirs or any(item.name == BLOCK_ENTRY for item in files):
|
|
477
|
+
collect.folders.append(_disk(root, segments, collect))
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
for item in files:
|
|
481
|
+
if not segments and item.name.lower() in _SKIP_ROOT_FILES:
|
|
482
|
+
continue
|
|
483
|
+
collect.failures.append(
|
|
484
|
+
_failure(
|
|
485
|
+
'file',
|
|
486
|
+
str(item),
|
|
487
|
+
f'这一层直接放着一份文件,但它自己不是块文件夹、也没有 {BLOCK_ENTRY},文件没有归属',
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
for item in dirs:
|
|
491
|
+
if item.name in SKIP_DIRS:
|
|
492
|
+
continue
|
|
493
|
+
if depth >= MAX_DEPTH:
|
|
494
|
+
collect.truncated = True
|
|
495
|
+
collect.failures.append(
|
|
496
|
+
_failure('dir', str(item), f'超过最大层数 {MAX_DEPTH} 层还没见到 {BLOCK_ENTRY},没有继续往下找')
|
|
497
|
+
)
|
|
498
|
+
continue
|
|
499
|
+
_discover(item, collect, [*segments, item.name], depth + 1)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _disk(directory: Path, segments: list[str], collect: _Collect) -> dict:
|
|
503
|
+
"""磁盘上的一个候选块文件夹(读它的文本文件,包内子目录逐层加前缀)。"""
|
|
504
|
+
return {
|
|
505
|
+
'abs': str(directory),
|
|
506
|
+
'where': '/'.join(segments),
|
|
507
|
+
'segments': list(segments),
|
|
508
|
+
'files': _read_files(directory, collect),
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _read_files(directory: Path, collect: _Collect) -> dict[str, str]:
|
|
513
|
+
"""读一个文件夹里的文本文件;子目录照原样拼成相对路径(`js/app.js`)。"""
|
|
514
|
+
files: dict[str, str] = {}
|
|
515
|
+
try:
|
|
516
|
+
entries = sorted(directory.iterdir(), key=lambda item: item.name)
|
|
517
|
+
except OSError as exc:
|
|
518
|
+
collect.failures.append(_failure('dir', str(directory), f'读不了这个文件夹:{_reason(exc)}'))
|
|
519
|
+
return files
|
|
520
|
+
for entry in entries:
|
|
521
|
+
if entry.is_symlink():
|
|
522
|
+
# 🔴 不跟软链接:可能绕成环(扫描永不结束),也可能指到项目外面。
|
|
523
|
+
collect.failures.append(
|
|
524
|
+
_failure('file', str(entry), '这是一个软链接,已跳过(不跟软链接,避免绕环 / 读到项目外面)')
|
|
525
|
+
)
|
|
526
|
+
continue
|
|
527
|
+
if entry.is_dir():
|
|
528
|
+
if entry.name in SKIP_DIRS:
|
|
529
|
+
continue
|
|
530
|
+
for name, body in _read_files(entry, collect).items():
|
|
531
|
+
files[f'{entry.name}/{name}'] = body
|
|
532
|
+
continue
|
|
533
|
+
if not entry.is_file() or _is_platform_file(entry.name):
|
|
534
|
+
# 平台随开发目录铺下来的那几份**静默跳过**:报出来只会给每个块平白添几条失败。
|
|
535
|
+
continue
|
|
536
|
+
if not _is_text(entry.name):
|
|
537
|
+
collect.failures.append(
|
|
538
|
+
_failure('file', str(entry), f'不是文本文件(认得的扩展名:{" ".join(TEXT_EXT)}),已跳过')
|
|
539
|
+
)
|
|
540
|
+
continue
|
|
541
|
+
try:
|
|
542
|
+
size = entry.stat().st_size
|
|
543
|
+
except OSError as exc:
|
|
544
|
+
collect.failures.append(_failure('file', str(entry), f'读不到文件大小:{_reason(exc)}'))
|
|
545
|
+
continue
|
|
546
|
+
if size > MAX_FILE_BYTES:
|
|
547
|
+
collect.failures.append(
|
|
548
|
+
_failure(
|
|
549
|
+
'file',
|
|
550
|
+
str(entry),
|
|
551
|
+
f'文件 {round(size / 1024)} KB 超过上限 {MAX_FILE_BYTES // 1024} KB,已跳过',
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
continue
|
|
555
|
+
try:
|
|
556
|
+
files[entry.name] = _strip_bom(entry.read_text(encoding='utf-8'))
|
|
557
|
+
collect.files += 1
|
|
558
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
559
|
+
collect.failures.append(_failure('file', str(entry), f'读不了这个文件:{_reason(exc)}'))
|
|
560
|
+
return files
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _diff(row: Any, disk: dict) -> dict:
|
|
564
|
+
"""库里那一包与目录里那一包逐文件比。"""
|
|
565
|
+
db_files = dict(row.files or {})
|
|
566
|
+
disk_files = disk['files']
|
|
567
|
+
changed = sorted(name for name, body in disk_files.items() if db_files.get(name) != body)
|
|
568
|
+
removed = sorted(name for name in db_files if name not in disk_files)
|
|
569
|
+
size = sum(len(body) for body in disk_files.values())
|
|
570
|
+
blocked = f'这一包超过上限 {MAX_BUNDLE_CHARS // 1024} KB,没有读全' if size > MAX_BUNDLE_CHARS else ''
|
|
571
|
+
return {
|
|
572
|
+
'id': row.id,
|
|
573
|
+
'name': row.name,
|
|
574
|
+
'module': row.module or _module_segment(disk['segments']),
|
|
575
|
+
'brief': row.brief,
|
|
576
|
+
'folder': _posix(disk['abs']),
|
|
577
|
+
'where': disk['where'],
|
|
578
|
+
'status': 'unchanged' if not changed and not removed else 'modified',
|
|
579
|
+
'changedFiles': changed,
|
|
580
|
+
'files': disk_files,
|
|
581
|
+
'canApply': not blocked,
|
|
582
|
+
'blockedWhy': blocked,
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ──────────────────────────── 配对(全局择优) ────────────────────────────
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _manifest_of(disk: dict) -> dict:
|
|
590
|
+
"""这个文件夹的清单:认块时它说的 `id` 是最高置信依据。
|
|
591
|
+
|
|
592
|
+
🔴 `present` 与 `id` **分开看**:有清单就只认清单(哪怕清单本身写坏了),
|
|
593
|
+
只有没清单才退回名字 / uuid 打分 —— 名字像不等于同一个块。
|
|
594
|
+
"""
|
|
595
|
+
files = disk['files']
|
|
596
|
+
if BLOCK_MANIFEST not in files:
|
|
597
|
+
return {'present': False, 'id': '', 'issues': []}
|
|
598
|
+
manifest, issues = validate.read_manifest(files)
|
|
599
|
+
return {'present': True, 'id': (manifest or {}).get('id', ''), 'issues': issues}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _folder_id(folder: str) -> str:
|
|
603
|
+
"""文件夹名**末尾**那段 uuid(导出铺的是 `<可读名>-<uuid>`;没有就是空串)。"""
|
|
604
|
+
match = TRAILING_ID_RE.search(folder)
|
|
605
|
+
return match.group(1) if match else ''
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _folder_name(folder: str) -> str:
|
|
609
|
+
"""文件夹名去掉末尾那段 uuid 之后剩下的那部分(就是块的可读名)。"""
|
|
610
|
+
match = TRAILING_ID_RE.search(folder)
|
|
611
|
+
return folder[: match.start()].rstrip('-').strip() if match else folder
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _module_segment(segments: list[str]) -> str:
|
|
615
|
+
"""路径里那一段能不能当模块名(`<模块>/<可读名>/` 的倒数第二段)。
|
|
616
|
+
|
|
617
|
+
🔴 `workspace` 要排掉:它是开发目录自己那一层(`workspace/<可读名>-<uuid>/`),
|
|
618
|
+
把它当成模块名会让"老布局那一档打分"凭空高一级(而它一个字都不说明)。
|
|
619
|
+
"""
|
|
620
|
+
if len(segments) < 2:
|
|
621
|
+
return ''
|
|
622
|
+
candidate = segments[-2]
|
|
623
|
+
if candidate == DEV_BLOCKS_DIR:
|
|
624
|
+
return ''
|
|
625
|
+
return candidate if validate.MODULE_PATTERN.match(candidate) else ''
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _score(disk: dict, row: Any, pool: list[Any]) -> int:
|
|
629
|
+
"""一个文件夹与一个块有多对得上(0 = 对不上)。"""
|
|
630
|
+
folder = disk['segments'][-1] if disk['segments'] else ''
|
|
631
|
+
if not folder:
|
|
632
|
+
return 0
|
|
633
|
+
tail = _folder_id(folder)
|
|
634
|
+
if tail and _same(row.id, tail):
|
|
635
|
+
# 🔴 导出铺的就是这个形状:名字与身份**同时**对上,比"名字 + 模块"还硬。
|
|
636
|
+
return SCORE_NAME_AND_ID if _same(row.name, _folder_name(folder)) else SCORE_ID
|
|
637
|
+
module_segment = _module_segment(disk['segments'])
|
|
638
|
+
if _same(row.name, folder):
|
|
639
|
+
if module_segment and _same(row.module, module_segment):
|
|
640
|
+
return SCORE_NAME_AND_MODULE
|
|
641
|
+
same_name = len([other for other in pool if _same(other.name, folder)])
|
|
642
|
+
return SCORE_NAME_AMBIGUOUS if same_name > 1 else SCORE_NAME
|
|
643
|
+
return SCORE_ID if _same(row.id, folder) else 0
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _assign(disks: list[dict], pool: list[Any]) -> dict[int, int]:
|
|
647
|
+
"""每个块文件夹配库里的哪一条(返回 `{文件夹下标: 池下标}`)。
|
|
648
|
+
|
|
649
|
+
🔴 **清单优先**,而且带清单的文件夹**不进**打分段:清单里的 id 不在库里 = 这个块还没建过,
|
|
650
|
+
随便配一个库里的块,下一步就会拿这一包把那条记录覆盖掉。
|
|
651
|
+
🔴 要用分数**全局择优**而不是贪心:贪心看扫描顺序,会出现"uuid 文件夹把别人的块抢走"。
|
|
652
|
+
"""
|
|
653
|
+
manifests = [_manifest_of(disk) for disk in disks]
|
|
654
|
+
candidates: list[tuple[int, int, int]] = []
|
|
655
|
+
for disk_index, manifest in enumerate(manifests):
|
|
656
|
+
if manifest['present']:
|
|
657
|
+
if not manifest['id']:
|
|
658
|
+
continue
|
|
659
|
+
for pool_index, row in enumerate(pool):
|
|
660
|
+
if _same(row.id, manifest['id']):
|
|
661
|
+
candidates.append((MANIFEST_MATCH_SCORE, disk_index, pool_index))
|
|
662
|
+
break
|
|
663
|
+
continue
|
|
664
|
+
for pool_index, row in enumerate(pool):
|
|
665
|
+
score = _score(disks[disk_index], row, pool)
|
|
666
|
+
if score > 0:
|
|
667
|
+
candidates.append((score, disk_index, pool_index))
|
|
668
|
+
|
|
669
|
+
# 分数降序;同分按(文件夹先、块先)—— 稳定定序,结果与扫描顺序无关、可复现。
|
|
670
|
+
candidates.sort(key=lambda item: (-item[0], item[1], item[2]))
|
|
671
|
+
taken_disk: set[int] = set()
|
|
672
|
+
taken_pool: set[int] = set()
|
|
673
|
+
out: dict[int, int] = {}
|
|
674
|
+
for _score_value, disk_index, pool_index in candidates:
|
|
675
|
+
if disk_index in taken_disk or pool_index in taken_pool:
|
|
676
|
+
continue
|
|
677
|
+
taken_disk.add(disk_index)
|
|
678
|
+
taken_pool.add(pool_index)
|
|
679
|
+
out[disk_index] = pool_index
|
|
680
|
+
|
|
681
|
+
# 兜底:只选了一个块的文件夹(路径里没有块名那一段),且它包里**没有清单**。
|
|
682
|
+
if len(disks) == 1 and not out and pool and not disks[0]['segments'] and not manifests[0]['present']:
|
|
683
|
+
out[0] = 0
|
|
684
|
+
return out
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _explain(disk: dict, pool: list[Any], used: dict[str, str]) -> str:
|
|
688
|
+
"""认不出时到底该说什么(原因要能直接照做,不能只说"没匹配上")。"""
|
|
689
|
+
manifest = _manifest_of(disk)
|
|
690
|
+
where = disk['where'] or '(选中的目录本身)'
|
|
691
|
+
if manifest['present']:
|
|
692
|
+
if manifest['issues']:
|
|
693
|
+
return (
|
|
694
|
+
f'「{where}」包里有清单,但清单里的块 id 读不出来:{";".join(manifest["issues"])}。'
|
|
695
|
+
f'认块只认清单里的 id —— 请先把 {BLOCK_MANIFEST} 改对'
|
|
696
|
+
)
|
|
697
|
+
if manifest['id'] in used:
|
|
698
|
+
return f'「{where}」清单里的 uuid={manifest["id"]} 已经被目录里的「{used[manifest["id"]]}」占了'
|
|
699
|
+
return (
|
|
700
|
+
f'「{where}」清单里的 uuid={manifest["id"]} 在库里没有对应的块:这个块可能还没建过,'
|
|
701
|
+
'也可能清单里的 id 被改过'
|
|
702
|
+
)
|
|
703
|
+
folder = disk['segments'][-1] if disk['segments'] else ''
|
|
704
|
+
tail = _folder_id(folder)
|
|
705
|
+
if not folder:
|
|
706
|
+
sample = '、'.join(f'{row.module or "(无模块)"}/{row.name}' for row in pool[:5])
|
|
707
|
+
return (
|
|
708
|
+
f'「{where}」这一层本身就是一份 {BLOCK_ENTRY},但文件夹名与路径里没有任何一段对得上库里的块'
|
|
709
|
+
f'(库里有 {len(pool)} 个块:{sample}…)—— 认不出是哪一块'
|
|
710
|
+
)
|
|
711
|
+
id_match = next((row for row in pool if _same(row.id, folder)), None)
|
|
712
|
+
if id_match is not None and id_match.id in used:
|
|
713
|
+
return f'「{where}」对应的块 uuid={id_match.id} 已经被目录里的「{used[id_match.id]}」占了,同一个块只认一处'
|
|
714
|
+
if tail:
|
|
715
|
+
hit = next((row for row in pool if _same(row.id, tail)), None)
|
|
716
|
+
if hit is not None and hit.id in used:
|
|
717
|
+
return f'「{where}」文件夹名末尾的 uuid={tail} 已经被目录里的「{used[tail]}」占了,同一个块只认一处'
|
|
718
|
+
if hit is None:
|
|
719
|
+
return (
|
|
720
|
+
f'「{where}」文件夹名末尾是一个 uuid({tail}),但库里没有 id 为它的块'
|
|
721
|
+
f'(导出时那种名字是 `<可读名>-<uuid>`;这个块可能已经删了,或者 uuid 被改过)'
|
|
722
|
+
)
|
|
723
|
+
name_matches = [row for row in pool if _same(row.name, folder) or _same(row.name, _folder_name(folder))]
|
|
724
|
+
if name_matches and all(row.id in used for row in name_matches):
|
|
725
|
+
return f'「{where}」的名字「{folder}」对上了 {len(name_matches)} 个块,但它们都已经被别的文件夹占了'
|
|
726
|
+
if UUID_SEGMENT_RE.match(folder):
|
|
727
|
+
return f'「{where}」看起来是一个 uuid,但库里没有 id 为 {folder} 的块(这个块可能还没建过)'
|
|
728
|
+
return (
|
|
729
|
+
f'「{where}」找不到对应的块:库里既没有叫「{folder}」的可读名,也没有这个 uuid —— '
|
|
730
|
+
'文件夹名要么是导出时那种 `<可读名>-<uuid>`,要么直接用它那条记录的 uuid'
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ──────────────────────────── 从目录写回库 ────────────────────────────
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def write_dir(items: list[dict], actor: str = '') -> dict:
|
|
738
|
+
"""把(`scan_folders` / `read_dir` 读回来的)这些块写回库。
|
|
739
|
+
|
|
740
|
+
🔴 走的是**保存那一条路**(`service.save`):同样的校验、同样的体检、同样的"写库之后才换注册表"。
|
|
741
|
+
这里**一行保存逻辑都不重写** —— 重写一份的后果是"扫描说能存、真存又不过"。
|
|
742
|
+
🔴 一个块失败只报它自己(`results` 逐条带原因),不拖累别的块。
|
|
743
|
+
"""
|
|
744
|
+
from .service import BlockNotFound, MagicBlockService, SaveRejected
|
|
745
|
+
|
|
746
|
+
service = MagicBlockService()
|
|
747
|
+
out: dict = {'saved': 0, 'failed': 0, 'results': []}
|
|
748
|
+
for item in items or []:
|
|
749
|
+
block_id = str(item.get('id') or '')
|
|
750
|
+
entry = {'id': block_id, 'name': str(item.get('name') or ''), 'ok': False, 'error': '', 'issues': []}
|
|
751
|
+
try:
|
|
752
|
+
saved = service.save(
|
|
753
|
+
block_id=block_id,
|
|
754
|
+
module=str(item.get('module') or ''),
|
|
755
|
+
name=str(item.get('name') or ''),
|
|
756
|
+
brief=str(item.get('brief') or ''),
|
|
757
|
+
files=item.get('files') or {},
|
|
758
|
+
actor=actor,
|
|
759
|
+
)
|
|
760
|
+
except (SaveRejected, BlockNotFound) as exc:
|
|
761
|
+
issues = list(getattr(exc, 'issues', []) or [])
|
|
762
|
+
entry['issues'] = issues
|
|
763
|
+
entry['error'] = ';'.join(issues) or exc.message
|
|
764
|
+
except Exception as exc: # noqa: BLE001 —— 一个块失败不拖累其它块
|
|
765
|
+
entry['error'] = f'{type(exc).__name__}: {_reason(exc)}'
|
|
766
|
+
else:
|
|
767
|
+
entry.update({'ok': True, 'id': saved.id, 'name': saved.name})
|
|
768
|
+
out['saved'] += 1
|
|
769
|
+
out['results'].append(entry)
|
|
770
|
+
continue
|
|
771
|
+
out['failed'] += 1
|
|
772
|
+
out['results'].append(entry)
|
|
773
|
+
return out
|