diff --git a/.changes/capture.added.md b/.changes/capture.added.md new file mode 100644 index 0000000..ab93a63 --- /dev/null +++ b/.changes/capture.added.md @@ -0,0 +1 @@ +提供可独立读取 Capture 图的 Python 分发;共享参与协议移除分发格式白名单。Mac 收入使用标准图与 Storage,不依赖 Python Peer 或 Capture 专用表。 diff --git a/app/business/extension/state.py b/app/business/extension/state.py index 4ccf4df..8f37aa7 100644 --- a/app/business/extension/state.py +++ b/app/business/extension/state.py @@ -33,7 +33,7 @@ class ExtensionParticipation(pydantic.BaseModel): name: str peer_id: uuid.UUID - distribution: typing.Literal["python", "module_federation"] | None + distribution: str | None version: str enabled: bool config_schema: dict[str, typing.Any] | None = None @@ -85,7 +85,7 @@ async def select_participation( self, peer_id: uuid.UUID, name: str, - distribution: typing.Literal["python", "module_federation"], + distribution: str, version: str, ) -> ExtensionParticipation: ... @@ -128,10 +128,7 @@ def _participation( return ExtensionParticipation( name=model.name, peer_id=model.peer_id, - distribution=typing.cast( - typing.Literal["python", "module_federation"] | None, - model.distribution, - ), + distribution=model.distribution, version=model.version, enabled=model.enabled, config_schema=( @@ -254,7 +251,7 @@ async def select_participation( self, peer_id: uuid.UUID, name: str, - distribution: typing.Literal["python", "module_federation"], + distribution: str, version: str, ) -> ExtensionParticipation: async with extension_uow() as repository: diff --git a/app/database_contract/constants.py b/app/database_contract/constants.py index 984ed84..c5dd790 100644 --- a/app/database_contract/constants.py +++ b/app/database_contract/constants.py @@ -4,7 +4,7 @@ CONTRACT_FORMAT = 1 -CONTRACT_REVISION = "peer-jwt-signing-defaults-v1" +CONTRACT_REVISION = "extension-distribution-open-v1" PROTOCOL_SCHEMA = "inkcre" INTERNAL_SCHEMA = "inkcre_internal" diff --git a/app/schemas/extension/main.py b/app/schemas/extension/main.py index 724324b..0d6322e 100644 --- a/app/schemas/extension/main.py +++ b/app/schemas/extension/main.py @@ -8,6 +8,7 @@ ExtensionName: typing.TypeAlias = str +NativeDistribution = typing.Annotated[str, pydantic.Field(min_length=1)] EXTENSION_NAME_PATTERN = ( r"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?/" r"[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$" @@ -32,7 +33,7 @@ class SelectExtensionCommand(pydantic.BaseModel): action: typing.Literal["select"] extension: ExtensionName - distribution: typing.Literal["python", "module_federation"] + distribution: NativeDistribution version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) @@ -77,11 +78,11 @@ class ExtensionPeerStatusRow(pydantic.BaseModel): name: ExtensionName peer_id: uuid.UUID - distribution: typing.Literal["python", "module_federation"] | None + distribution: NativeDistribution | None version: str | None enabled: bool config_schema: dict[str, typing.Any] | None - supported_distributions: tuple[typing.Literal["python", "module_federation"], ...] + supported_distributions: tuple[NativeDistribution, ...] loaded_version: str | None running: bool restart_required: bool @@ -93,14 +94,14 @@ class ExtensionPeerStatus(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid", frozen=True) peer_id: uuid.UUID - supported_distributions: tuple[typing.Literal["python", "module_federation"], ...] + supported_distributions: tuple[NativeDistribution, ...] extensions: tuple[ExtensionPeerStatusRow, ...] class SelectExtensionInput(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - distribution: typing.Literal["python", "module_federation"] + distribution: NativeDistribution version: str = pydantic.Field(pattern=EXTENSION_SEMVER_PATTERN) @@ -168,10 +169,6 @@ class ExtensionPeerParticipationModel(sqlmodel.SQLModel, table=True): f"version ~ '{EXTENSION_SEMVER_PATTERN}'", name="extension_peer_participations_version_canonical", ), - sqlalchemy.CheckConstraint( - "distribution IS NULL OR distribution IN ('python', 'module_federation')", - name="extension_peer_participations_distribution_valid", - ), sqlalchemy.CheckConstraint( "config_schema IS NULL OR jsonb_typeof(config_schema) = 'object'", name="extension_peer_participations_config_schema_object", diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index 8a5ef9c..f889824 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -12,6 +12,8 @@ This directory owns expensive internal design truth for logical units delivered | [agent-query-sink.md](agent-query-sink.md) | Asynchronous Agent-composed query lifecycle, result delivery, and setup contract | | [mail-extension.md](mail-extension.md) | Mail identity, MIME materialization, collection, graph, and failure boundaries | | [memos-extension.md](memos-extension.md) | Memos adapter identity, graph grammar, persistence, and failure boundaries | +| [capture-extension.md](capture-extension.md) | Capture submission identity, original materials, shared graph, and atomic recovery | +| [local-files-extension.md](local-files-extension.md) | 本地文件资料、原件状态与正文的读取分发 | | [rss-extension.md](rss-extension.md) | RSS adapter identity, collection lifecycle, reconciliation, and materialization | | [twitter-extension.md](twitter-extension.md) | Twitter 共同身份、Bookmark 过渡、事务合并和按需媒体读取 | | [security-model.md](security-model.md) | Core-py realization of the shared security boundary model | diff --git a/docs/30-unit-tdd/capture-extension.md b/docs/30-unit-tdd/capture-extension.md new file mode 100644 index 0000000..64a04e5 --- /dev/null +++ b/docs/30-unit-tdd/capture-extension.md @@ -0,0 +1,11 @@ +# Capture Extension 与数据库 schema + +`inkcre/capture` 拥有跨平台材料、图、收入和恢复合同,见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。原生分发在本 Peer 内完成收入;Python 分发提供自身支持的读取能力,不成为其他分发的写入执行者。 + +Capture 不增加专用共享表、HTTP API 或 SQL RPC。Mac 将草稿、待办和确认结果保存在 SQLite;收入使用标准 Block/Relation/Storage,并通过根 metadata 的提交身份和材料摘要恢复。Python 分发只读取同一图合同,不参与 Mac 的收入事务。 + +数据库的 Extension participation 分发标识是开放字符串;宿主只在加载边界验证自己支持的格式。未发布候选 dcf44bc9af73 仅移除原 python/module_federation 白名单,不再创建 Capture 表。 + +Python 分发保留 Capture/Attachment resolver,图片通过标准 storage 读取;不公告采集执行服务。共享图结构未改变,不需要 Python Peer 或 PostgREST 在线才能从 Mac 收入。 + +迁移回退会恢复旧分发约束;信息库已经保存其他原生分发时,数据库会拒绝恢复该约束。回退应保留参与记录并处理部署版本,不得通过删除记录或把分发标识改写为 Python 来强行完成。 diff --git a/docs/30-unit-tdd/local-files-extension.md b/docs/30-unit-tdd/local-files-extension.md new file mode 100644 index 0000000..081071d --- /dev/null +++ b/docs/30-unit-tdd/local-files-extension.md @@ -0,0 +1,5 @@ +# 本地文件读取分发 + +`extensions/local_files` 交付 `inkcre/local-files` 的 Python 读取能力,解释 `extensions.local_files.file.v1` 与 `body` 关系。源合同在 Hub 的 `20-product-tdd/local-files-contract.md`。目录监视、文件提取及共享写入由具备文件权限的执行分发完成;Python 分发不提供收入 API,也不注册目录 Source 占位类。 + +`LocalFileResolver` 读取元数据并校验唯一的 `core.text.v1` 正文,允许首次失败时尚无正文。原件失效或当前提取失败仍返回最后成功正文,同时保留状态和提取范围。位置只作为文字解释,不访问本机路径,不尝试远端取文件,也不物化其他副本。此功能复用已有 Source/Job/图模型,没有新增迁移。 diff --git a/docs/_shared b/docs/_shared index 7449135..311c69b 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 7449135fefdd6056fecba387e59e3005aed5eafd +Subproject commit 311c69bdada4fb291cc7fae0ded5caefa4fdc867 diff --git a/extensions/capture/.changes/capture.added.md b/extensions/capture/.changes/capture.added.md new file mode 100644 index 0000000..a6b6744 --- /dev/null +++ b/extensions/capture/.changes/capture.added.md @@ -0,0 +1 @@ +提供可选的 Capture 图读取与内容解释,区分原材料、出处与附注;不执行采集提交,也不是 Mac 写入的前置条件。 diff --git a/extensions/capture/CHANGELOG.md b/extensions/capture/CHANGELOG.md new file mode 100644 index 0000000..5a759af --- /dev/null +++ b/extensions/capture/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +此文件记录 Capture Python 分发的独立版本。发布条目由项目内变更片段生成。 + + diff --git a/extensions/capture/LICENSE b/extensions/capture/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/extensions/capture/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/capture/__init__.py b/extensions/capture/__init__.py new file mode 100644 index 0000000..2f2b156 --- /dev/null +++ b/extensions/capture/__init__.py @@ -0,0 +1,15 @@ +"""Optional Capture graph interpretation for a Python Peer.""" + +import pydantic + +from app.business.extension import ExtensionBase + + +class CaptureConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + +class Extension(ExtensionBase[CaptureConfig], ext_id="capture", config_cls=CaptureConfig): + @classmethod + def _init_resolvers(cls) -> None: + from . import resolver as resolver_module # noqa: F401 diff --git a/extensions/capture/pyproject.toml b/extensions/capture/pyproject.toml new file mode 100644 index 0000000..54f1e08 --- /dev/null +++ b/extensions/capture/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=80,<81"] +build-backend = "setuptools.build_meta" + +[project] +name = "inkcre-ext-capture" +version = "0.1.0" +description = "Read shared Capture graphs in Python peers" +requires-python = ">=3.12,<3.13" +license = "MIT" +license-files = ["LICENSE"] +dependencies = [ + "pydantic>=2.11.7,<3.0.0", +] + +[project.entry-points."inkcre.core.extensions"] +capture = "extensions.capture:Extension" + +[tool.inkcre-extension] +name = "inkcre/capture" +nickname = "随手收集" +host-sdk = "core-py" +host-sdk-version = ">=0.3.3 <0.4.0" + +[tool.setuptools] +packages = ["extensions.capture"] + +[tool.setuptools.package-dir] +"extensions.capture" = "." diff --git a/extensions/capture/resolver.py b/extensions/capture/resolver.py new file mode 100644 index 0000000..c7dae35 --- /dev/null +++ b/extensions/capture/resolver.py @@ -0,0 +1,142 @@ +"""Interpret source roles from the shared graph without depending on the capture Peer.""" + +import re + +from app.business.info_base.resolver import Resolver, ResolverManager, TextProjectionContext +from app.business.info_base.resolver.label import format_label +from app.business.info_base.services import BlockService + +from .schema import ( + ATTACHMENT_RESOLVER, + CAPTURE_RESOLVER, + AttachmentMetadata, + CaptureMetadata, + SolvedAttachment, + SolvedCapture, + SolvedMaterial, +) + + +class AttachmentResolver(Resolver[SolvedAttachment, str], rso_type=ATTACHMENT_RESOLVER): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> SolvedAttachment: + metadata = AttachmentMetadata.model_validate_json( + await self.get_raw_content(refresh=refresh) + ) + relations = await self.get_relations( + include_in=False, include_out=True, refresh=refresh + ) + links = [relation for relation in relations if relation.content == "content"] + if len(links) != 1: + raise ValueError("采集附件必须包含一条 content 关系") + block = await BlockService.get(links[0].to_) + if block is None or block.resolver != "core.image.v1": + raise ValueError("采集附件的图片内容不存在或类型不符") + return SolvedAttachment(metadata=metadata, content_block_id=links[0].to_) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + block = await BlockService.get(solved.content_block_id) + if block is None: + raise ValueError("采集图片已不存在") + content = await ResolverManager.get(block).get_text( + context=context, refresh=refresh, materialize_missing=materialize_missing + ) + return "\n".join(filter(None, (solved.metadata.filename, content))) + + async def get_label(self, *, refresh: bool = False) -> str: + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + return format_label("采集图片", solved.metadata.filename or solved.metadata.mime_type) + + +class CaptureResolver(Resolver[SolvedCapture, str], rso_type=CAPTURE_RESOLVER): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> SolvedCapture: + metadata = CaptureMetadata.model_validate_json( + await self.get_raw_content(refresh=refresh) + ) + relations = await self.get_relations( + include_in=False, include_out=True, refresh=refresh + ) + material_ids: dict[int, int] = {} + annotations: list[int] = [] + for relation in relations: + match = re.fullmatch(r"material:(0|[1-9][0-9]*)", relation.content) + if match: + index = int(match[1]) + if index in material_ids: + raise ValueError("采集材料存在重复序号") + material_ids[index] = relation.to_ + elif relation.content.startswith("material:"): + raise ValueError("采集材料序号无效") + elif relation.content == "annotation": + annotations.append(relation.to_) + if sorted(material_ids) != list(range(len(material_ids))) or len(annotations) > 1: + raise ValueError("采集材料顺序或附注关系无效") + materials = [] + for index in range(len(material_ids)): + block = await BlockService.get(material_ids[index]) + if block is None or block.resolver not in ("core.text.v1", ATTACHMENT_RESOLVER): + raise ValueError("采集材料已不存在或类型不符") + materials.append( + SolvedMaterial(block_id=material_ids[index], resolver=block.resolver) + ) + if annotations: + block = await BlockService.get(annotations[0]) + if block is None or block.resolver != "core.text.v1": + raise ValueError("采集附注已不存在或类型不符") + return SolvedCapture( + metadata=metadata, + materials=tuple(materials), + annotation_block_id=annotations[0] if annotations else None, + ) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + solved = await self.get_solved_content(refresh=refresh, materialize_missing=False) + origin = solved.metadata.origin + parts = [f"采集时间:{solved.metadata.captured_at.isoformat()}"] + if origin: + parts.extend( + f"{label}:{value}" + for label, value in ( + ("来源应用", origin.application_id), + ("来源标题", origin.title), + ("来源地址", origin.url), + ) + if value + ) + items = [(item.block_id, "材料") for item in solved.materials] + if solved.annotation_block_id is not None: + items.append((solved.annotation_block_id, "用户附注")) + for block_id, label in items: + block = await BlockService.get(block_id) + if block is None: + raise ValueError("采集内容已不存在") + text = await ResolverManager.get(block).get_text( + context=context, refresh=refresh, materialize_missing=materialize_missing + ) + parts.append(f"{label}:\n{text}") + return "\n\n".join(parts) + + async def get_label(self, *, refresh: bool = False) -> str: + metadata = CaptureMetadata.model_validate_json( + await self.get_raw_content(refresh=refresh) + ) + return format_label( + "随手收集", + (metadata.origin.title if metadata.origin else None) or metadata.entry, + ) diff --git a/extensions/capture/schema.py b/extensions/capture/schema.py new file mode 100644 index 0000000..dc800d6 --- /dev/null +++ b/extensions/capture/schema.py @@ -0,0 +1,56 @@ +"""Capture v1 persisted metadata interpreted by the optional Python reader.""" + +from datetime import UTC +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator + + +CAPTURE_RESOLVER = "extensions.capture.capture.v1" +ATTACHMENT_RESOLVER = "extensions.capture.attachment.v1" +ImageMIME = Literal["image/png", "image/jpeg", "image/gif", "image/webp"] + + +class ContractModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class CaptureOrigin(ContractModel): + application_id: str | None = Field(default=None, max_length=1024) + title: str | None = Field(default=None, max_length=4096) + url: str | None = Field(default=None, max_length=8192) + + +class CaptureMetadata(ContractModel): + submission_id: UUID + payload_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + captured_at: AwareDatetime + entry: Literal["selection", "paste", "screenshot", "manual"] + origin: CaptureOrigin | None = None + + @field_validator("captured_at") + @classmethod + def utc_time(cls, value: AwareDatetime) -> AwareDatetime: + return value.astimezone(UTC) + + +class AttachmentMetadata(ContractModel): + mime_type: ImageMIME + filename: str | None = Field(default=None, max_length=512) + + +class SolvedMaterial(ContractModel): + block_id: int + resolver: str + + +class SolvedAttachment(ContractModel): + metadata: AttachmentMetadata + content_block_id: int + + +class SolvedCapture(ContractModel): + metadata: CaptureMetadata + materials: tuple[SolvedMaterial, ...] + annotation_block_id: int | None diff --git a/extensions/local_files/.changes/local-files.added.md b/extensions/local_files/.changes/local-files.added.md new file mode 100644 index 0000000..cfc650a --- /dev/null +++ b/extensions/local_files/.changes/local-files.added.md @@ -0,0 +1 @@ +读取本地文件资料的正文、原件位置及失效状态,不接管目录执行能力。 diff --git a/extensions/local_files/CHANGELOG.md b/extensions/local_files/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/extensions/local_files/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/extensions/local_files/LICENSE b/extensions/local_files/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/extensions/local_files/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/local_files/__init__.py b/extensions/local_files/__init__.py new file mode 100644 index 0000000..35d9126 --- /dev/null +++ b/extensions/local_files/__init__.py @@ -0,0 +1,17 @@ +"""Optional local-file graph interpretation for a Python Peer.""" + +import pydantic + +from app.business.extension import ExtensionBase + + +class LocalFilesConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + +class Extension( + ExtensionBase[LocalFilesConfig], ext_id="local-files", config_cls=LocalFilesConfig +): + @classmethod + def _init_resolvers(cls) -> None: + from . import resolver as resolver_module # noqa: F401 diff --git a/extensions/local_files/pyproject.toml b/extensions/local_files/pyproject.toml new file mode 100644 index 0000000..eb29868 --- /dev/null +++ b/extensions/local_files/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=80,<81"] +build-backend = "setuptools.build_meta" + +[project] +name = "inkcre-ext-local-files" +version = "0.1.0" +description = "Read shared local file bodies and locations in Python peers" +requires-python = ">=3.12,<3.13" +license = "MIT" +license-files = ["LICENSE"] +dependencies = [ + "pydantic>=2.11.7,<3.0.0", +] + +[project.entry-points."inkcre.core.extensions"] +local_files = "extensions.local_files:Extension" + +[tool.inkcre-extension] +name = "inkcre/local-files" +nickname = "本地文件" +host-sdk = "core-py" +host-sdk-version = ">=0.3.3 <0.4.0" + +[tool.setuptools] +packages = ["extensions.local_files"] + +[tool.setuptools.package-dir] +"extensions.local_files" = "." diff --git a/extensions/local_files/resolver.py b/extensions/local_files/resolver.py new file mode 100644 index 0000000..e946734 --- /dev/null +++ b/extensions/local_files/resolver.py @@ -0,0 +1,53 @@ +"""Read persisted text without accessing the original Peer or executing a Source.""" + +from pathlib import PurePosixPath + +from app.business.info_base.resolver import Resolver, TextProjectionContext +from app.business.info_base.resolver.label import format_label +from app.business.info_base.services import BlockService + +from .schema import FILE_RESOLVER, FileMetadata, SolvedFile, file_location + + +class LocalFileResolver(Resolver[SolvedFile, str], rso_type=FILE_RESOLVER): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> SolvedFile: + metadata = FileMetadata.model_validate_json(await self.get_raw_content(refresh=refresh)) + relations = await self.get_relations( + include_in=False, include_out=True, refresh=refresh + ) + bodies = [item for item in relations if item.content == "body"] + if len(bodies) > 1 or (metadata.revision is not None and not bodies): + raise ValueError("文件资料的正文关系无效") + if not bodies: + return SolvedFile(metadata=metadata, body_id=None, text=None) + block = await BlockService.get(bodies[0].to_) + if block is None or block.resolver != "core.text.v1" or block.storage is not None: + raise ValueError("文件正文不存在或类型不符") + return SolvedFile(metadata=metadata, body_id=block.id, text=block.content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + solved = await self._get_solved_content(refresh=refresh, materialize_missing=False) + metadata = solved.metadata + parts = [f"原件位置:{metadata.location}"] + if not metadata.available: + parts.append("原件已失效;保留最后收入的正文。") + if metadata.issue: + parts.append(f"当前提取失败:{metadata.issue}。正文对应上次成功修订。") + if metadata.revision and metadata.revision.scope == "main_document": + parts.append("DOCX 主文档文字,不含脚注、批注。") + if solved.text is not None: + parts.append(solved.text) + return "\n\n".join(parts) + + async def get_label(self, *, refresh: bool = False) -> str: + metadata = FileMetadata.model_validate_json(await self.get_raw_content(refresh=refresh)) + _, path = file_location(metadata.location) + return format_label("本地文件", PurePosixPath(path).name) diff --git a/extensions/local_files/schema.py b/extensions/local_files/schema.py new file mode 100644 index 0000000..73f4261 --- /dev/null +++ b/extensions/local_files/schema.py @@ -0,0 +1,66 @@ +"""Local-files v1 graph metadata; original bytes remain on the owning Peer.""" + +from typing import Literal +from urllib.parse import unquote, urlsplit +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator + +FILE_RESOLVER = "extensions.local_files.file.v1" + + +def file_location(value: str) -> tuple[UUID, str]: + location = urlsplit(value) + if ( + location.scheme != "inkcre-peer-file" + or location.username is not None + or location.password is not None + or location.port is not None + or "?" in value + or "#" in value + ): + raise ValueError("Invalid Peer file location") + peer = UUID(location.hostname or "") + path = unquote(location.path, errors="strict") + if not path.startswith("/") or "\0" in path: + raise ValueError("Peer file location requires an absolute path") + return peer, path + + +class MetadataModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class FileIdentity(MetadataModel): + volume: str + number: int = Field(ge=0) + created: AwareDatetime + + +class FileRevision(MetadataModel): + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + extractor: str + extracted_at: AwareDatetime + scope: Literal["text", "text_layer", "main_document"] + + +class FileMetadata(MetadataModel): + location: str + format: Literal["pdf", "docx", "markdown", "org", "html", "text"] + identity: FileIdentity | None = None + available: bool + observed_at: AwareDatetime + revision: FileRevision | None = None + issue: str | None = None + + @field_validator("location") + @classmethod + def valid_location(cls, value: str) -> str: + file_location(value) + return value + + +class SolvedFile(MetadataModel): + metadata: FileMetadata + body_id: int | None + text: str | None diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index 181e3f6..7b0dece 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -24,6 +24,7 @@ "d41cc84db0c5_add_job_abort_request.py": "fc1fe04942e95c7b8c5f43dbcf369bb88479cb83f8e7dc58528f08caf7a536d2", "d4e6f8a0b2c3_merge_extension_setup_main.py": "2073996504ae5d5cb5d8cc0d468d3c9e93870a44bd5f454986662c22da48fba8", "d9f4e2a1b7c3_adopt_peer_database_protocol.py": "9a163533b5a0619e51bfb94ba7ad0c5c3d168fba5808b08f67531fd8f7e5f263", + "dcf44bc9af73_open_extension_distributions.py": "acc2572716c00c5ed113c5b62eb9cf52bfa0be7efba576e78350026fd6609f35", "e1f4a5b6c7d8_migrate_memos_attachment_v2.py": "5f3691149e06b28378f79eed7eaacd1c415ee6c92fb83b3b5745a429346d5e01", "e5a01f9e69ef_init.py": "a10de6c0818abc593951a34e51a974ffcd999f60ba5afc441f87ac6cef8d869e", "e6a1b2c3d4f5_extension_peer_participation_v2.py": "bf71d955b1e3e27015f412815d020666157eda6af78ed0472ca2b7d38ada872f", diff --git a/migrations/versions/dcf44bc9af73_open_extension_distributions.py b/migrations/versions/dcf44bc9af73_open_extension_distributions.py new file mode 100644 index 0000000..30dde6e --- /dev/null +++ b/migrations/versions/dcf44bc9af73_open_extension_distributions.py @@ -0,0 +1,25 @@ +"""Allow Peer-owned distribution formats. + +Revision ID: dcf44bc9af73 +Revises: 09a921a95314 +""" + +from alembic import op + +revision = 'dcf44bc9af73' +down_revision = '09a921a95314' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_constraint('extension_peer_participations_distribution_valid', + 'extension_peer_participations', schema='inkcre', type_='check') + op.execute("UPDATE inkcre_internal.contract_state SET contract_revision='extension-distribution-open-v1',updated_at=CURRENT_TIMESTAMP WHERE singleton") + + +def downgrade() -> None: + op.create_check_constraint('extension_peer_participations_distribution_valid', + 'extension_peer_participations', + "distribution IS NULL OR distribution IN ('python','module_federation')", schema='inkcre') + op.execute("UPDATE inkcre_internal.contract_state SET contract_revision='peer-jwt-signing-defaults-v1',updated_at=CURRENT_TIMESTAMP WHERE singleton") diff --git a/tests/extensions/local_files/test_reader.py b/tests/extensions/local_files/test_reader.py new file mode 100644 index 0000000..44bc848 --- /dev/null +++ b/tests/extensions/local_files/test_reader.py @@ -0,0 +1,66 @@ +"""Readers consume stored bodies; the source Peer and original file are unnecessary.""" + +import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest + +from extensions.local_files.schema import FileMetadata, file_location +from extensions.local_files.resolver import LocalFileResolver +from app.business.info_base.services import BlockService + + +def test_location_retains_machine_path_without_remote_file_access(): + peer, path = file_location( + "inkcre-peer-file://a143b185-f43d-47d1-8ef7-1d3857284b01/Users/a/%E4%B8%AD%E6%96%87%20%23%3F.md" + ) + assert peer == UUID("a143b185-f43d-47d1-8ef7-1d3857284b01") + assert path == "/Users/a/中文 #?.md" + for value in [ + "file:///etc/passwd", + "inkcre-peer-file://unknown/a", + "inkcre-peer-file://a143b185-f43d-47d1-8ef7-1d3857284b01/a#", + ]: + with pytest.raises(ValueError): + file_location(value) + + +def test_failed_current_file_keeps_successful_body_readable(monkeypatch, async_runner): + metadata = FileMetadata( + location="inkcre-peer-file://a143b185-f43d-47d1-8ef7-1d3857284b01/Users/a/note.docx", + format="docx", + available=False, + observed_at=datetime.datetime.now(datetime.UTC), + revision={ + "sha256": "a" * 64, + "extractor": "mac-native-text.v1", + "extracted_at": datetime.datetime.now(datetime.UTC), + "scope": "main_document", + }, + issue="当前原件损坏", + ) + reader = object.__new__(LocalFileResolver) + monkeypatch.setattr( + reader, "get_raw_content", AsyncMock(return_value=metadata.model_dump_json()) + ) + monkeypatch.setattr( + reader, + "get_relations", + AsyncMock(return_value=(SimpleNamespace(content="body", to_=20),)), + ) + monkeypatch.setattr( + BlockService, + "get", + AsyncMock( + return_value=SimpleNamespace( + id=20, resolver="core.text.v1", content="上次成功正文", storage=None + ) + ), + ) + text = async_runner.run(reader.get_text(materialize_missing=False)) + assert "上次成功正文" in text and "原件已失效" in text and "不含脚注" in text + monkeypatch.setattr(reader, "get_relations", AsyncMock(return_value=())) + with pytest.raises(ValueError, match="正文关系"): + async_runner.run(reader.get_text()) diff --git a/tests/migrations/test_extension_peer_enabled_rpc.py b/tests/migrations/test_extension_peer_enabled_rpc.py index a43fbbf..1966033 100644 --- a/tests/migrations/test_extension_peer_enabled_rpc.py +++ b/tests/migrations/test_extension_peer_enabled_rpc.py @@ -378,6 +378,29 @@ async def verify_shared_state(): asyncio.run(engine.dispose()) +def test_peer_can_persist_its_own_distribution_format(setup_domain_engine, monkeypatch): + peer_id = uuid.uuid4() + name = f"inkcre/native-{peer_id.hex}" + with sqlmodel.Session(setup_domain_engine) as session: + session.add(PeerModel(id=peer_id, name="native-peer")) + session.add(ExtensionModel(name=name)) + session.commit() + engine = create_async_engine(setup_domain_engine.url) + monkeypatch.setattr(extension_uow, "AsyncSessionFactory", async_sessionmaker(engine)) + + async def scenario(): + store = ExtensionStateService() + await store.select_participation(peer_id, name, "kotlin_apk", "0.1.0") + await store.set_participation_enabled(peer_id, name, True) + row = await store.get_participation(peer_id, name) + assert row is not None and row.distribution == "kotlin_apk" and row.enabled + + try: + asyncio.run(scenario()) + finally: + asyncio.run(engine.dispose()) + + def test_selection_change_clears_schema_and_rejects_late_old_callback( setup_domain_engine, monkeypatch ): diff --git a/tests/test_extension_peer_status.py b/tests/test_extension_peer_status.py index d6d51fb..870d464 100644 --- a/tests/test_extension_peer_status.py +++ b/tests/test_extension_peer_status.py @@ -5,6 +5,17 @@ from app.business.extension.main import ExtensionHost from app.business.extension.state import ExtensionStore, InstalledExtension +from app.schemas.extension.main import ExtensionPeerStatus, SelectExtensionCommand + + +def test_shared_protocol_preserves_a_distribution_unknown_to_python(): + command = SelectExtensionCommand( + action="select", extension="inkcre/capture", distribution="kotlin_apk", version="0.1.0" + ) + status = ExtensionPeerStatus( + peer_id=uuid.uuid4(), supported_distributions=(command.distribution,), extensions=() + ) + assert status.supported_distributions == ("kotlin_apk",) class EmptyParticipationStore: