diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 392cdec..abd030f 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 3005f838bcd96e2cbc58616aede46e4f39df4523 + ref: 69a9616f6f48f19dc109a6b3eef8fc1000825829 path: .akashic-core - uses: actions/checkout@v4 with: diff --git a/dashboard.py b/dashboard.py index fc996a0..c2c5bbe 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,6 +1,9 @@ from __future__ import annotations +import json import os +from pathlib import Path +from uuid import uuid4 from typing import Any from fastapi import FastAPI, HTTPException @@ -19,12 +22,14 @@ def get_meme_categories() -> dict[str, Any]: catalog._load() result: list[dict[str, Any]] = [] for tag, cat in catalog._categories.items(): - cat_dir = memes_dir / tag + cat_dir = _safe_path(memes_dir, ((tag, "category"),)) count = 0 if cat_dir.is_dir(): count = len([ f for f in cat_dir.iterdir() - if f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} + if not f.is_symlink() + and f.is_file() + and f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} ]) result.append({ "tag": tag, @@ -39,57 +44,124 @@ def get_meme_categories() -> dict[str, Any]: @app.get("/api/dashboard/meme/images/{tag}") def get_meme_images(tag: str) -> dict[str, Any]: - cat_dir = memes_dir / tag + cat_dir = _safe_path(memes_dir, ((tag, "category"),)) images = [] if cat_dir.is_dir(): images = [ f.name for f in cat_dir.iterdir() - if f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} + if not f.is_symlink() + and f.is_file() + and f.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp"} ] images.sort() return {"tag": tag, "images": images} @app.delete("/api/dashboard/meme/categories/{tag}") def delete_meme_category(tag: str) -> dict[str, Any]: - catalog._load() - if tag not in catalog._categories: - raise HTTPException(status_code=404, detail="Category not found") - del catalog._categories[tag] - - manifest_path = memes_dir / "manifest.json" - if manifest_path.exists(): - import json - try: - data = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception: - data = {} - if "categories" in data and tag in data["categories"]: - del data["categories"][tag] - manifest_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - - import shutil - cat_dir = memes_dir / tag - if cat_dir.is_dir(): - shutil.rmtree(cat_dir, ignore_errors=True) - - return {"success": True} + return _remove_category(memes_dir, tag, catalog) @app.delete("/api/dashboard/meme/media/{tag}/{filename}") def delete_meme_media(tag: str, filename: str) -> dict[str, Any]: - safe_tag = os.path.basename(tag) - safe_filename = os.path.basename(filename) - file_path = memes_dir / safe_tag / safe_filename + file_path = _safe_path( + memes_dir, + ((tag, "category"), (filename, "filename")), + ) if not file_path.is_file(): raise HTTPException(status_code=404, detail="Meme image not found") - file_path.unlink() - return {"success": True} + recovery_id = _move_to_recovery(memes_dir, file_path, "image") + return {"success": True, "recovery_id": recovery_id} @app.get("/api/dashboard/meme/media/{tag}/{filename}") def get_meme_media(tag: str, filename: str) -> Any: - # Avoid path traversal attacks - safe_tag = os.path.basename(tag) - safe_filename = os.path.basename(filename) - file_path = memes_dir / safe_tag / safe_filename + file_path = _safe_path( + memes_dir, + ((tag, "category"), (filename, "filename")), + ) if not file_path.is_file(): raise HTTPException(status_code=404, detail="Meme image not found") return FileResponse(file_path) + + +def _safe_segment(value: str, label: str) -> str: + if ( + not value + or value in {".", ".."} + or "/" in value + or "\\" in value + or "\x00" in value + or os.path.basename(value) != value + ): + raise HTTPException(status_code=422, detail=f"Invalid {label}") + return value + + +def _safe_path(root: Path, segments: tuple[tuple[str, str], ...]) -> Path: + base = root.resolve(strict=False) + candidate = base + for value, label in segments: + candidate /= _safe_segment(value, label) + if candidate.is_symlink(): + raise HTTPException(status_code=422, detail=f"Invalid {label}") + if not candidate.resolve(strict=False).is_relative_to(base): + raise HTTPException(status_code=422, detail="Path escapes meme workspace") + return candidate + + +def _move_to_recovery(memes_dir: Path, source: Path, kind: str) -> str: + recovery_id = f"{kind}-{uuid4().hex}" + recovery_dir = _safe_path(memes_dir, ((".trash", "recovery root"),)) / recovery_id + try: + recovery_dir.mkdir(parents=True, exist_ok=False) + source.replace(recovery_dir / source.name) + except OSError as error: + raise HTTPException(status_code=500, detail=f"Failed to preserve deleted {kind}") from error + return recovery_id + + +def _remove_category( + memes_dir: Path, + tag: str, + catalog: MemeCatalog, +) -> dict[str, object]: + manifest_path = _safe_path(memes_dir, (("manifest.json", "manifest"),)) + category_dir = _safe_path(memes_dir, ((tag, "category"),)) + try: + manifest_bytes = manifest_path.read_bytes() + manifest = json.loads(manifest_bytes) + except (OSError, json.JSONDecodeError) as error: + raise HTTPException(status_code=500, detail="Meme manifest is unreadable") from error + categories = manifest.get("categories") if isinstance(manifest, dict) else None + if not isinstance(categories, dict): + raise HTTPException(status_code=500, detail="Meme manifest categories are invalid") + if tag not in categories: + raise HTTPException(status_code=404, detail="Category not found") + + recovery_id = f"category-{uuid4().hex}" + recovery_dir = _safe_path(memes_dir, ((".trash", "recovery root"),)) / recovery_id + moved_category: Path | None = None + try: + recovery_dir.mkdir(parents=True, exist_ok=False) + (recovery_dir / "manifest.json").write_bytes(manifest_bytes) + if category_dir.exists(): + moved_category = recovery_dir / tag + category_dir.replace(moved_category) + del categories[tag] + _write_manifest(manifest_path, manifest) + except OSError as error: + if moved_category is not None and moved_category.exists(): + moved_category.replace(category_dir) + raise HTTPException(status_code=500, detail="Failed to preserve deleted category") from error + catalog._manifest_mtime = -1.0 + return {"success": True, "recovery_id": recovery_id} + + +def _write_manifest(path: Path, manifest: dict[str, object]) -> None: + staging = path.with_name(f".{path.name}.{uuid4().hex}.tmp") + try: + with staging.open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, ensure_ascii=False) + handle.flush() + os.fsync(handle.fileno()) + os.replace(staging, path) + finally: + staging.unlink(missing_ok=True) diff --git a/dashboard_panel.tsx b/dashboard_panel.tsx deleted file mode 100644 index f31d2d2..0000000 --- a/dashboard_panel.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/// -import { useEffect, useState } from "react"; -import { api } from "@akashic/dashboard-ui"; - -// The Akashic Dashboard injects itself globally. -// We declare it here to satisfy TypeScript in our standalone build. -declare global { - interface Window { - AkashicDashboard: any; - } -} - -interface Category { - tag: string; - name: string; - desc: string; - aliases: string[]; - enabled: boolean; - count: number; -} - -function MemeMain() { - const [categories, setCategories] = useState([]); - const [selectedTag, setSelectedTag] = useState(null); - const [images, setImages] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - void api("/api/dashboard/meme/categories").then((data: any) => { - setCategories(data.categories || []); - if (data.categories?.length > 0) { - setSelectedTag(data.categories[0].tag); - } - setLoading(false); - }, (reason: unknown) => { - setError(reason instanceof Error ? reason.message : "分类读取失败"); - setLoading(false); - }); - }, []); - - useEffect(() => { - if (selectedTag) { - void api(`/api/dashboard/meme/images/${selectedTag}`).then((data: any) => { - setImages(data.images || []); - }, (reason: unknown) => setError(reason instanceof Error ? reason.message : "图片读取失败")); - } else { - setImages([]); - } - }, [selectedTag]); - - return ( -
- - -
-
-
-

表情包库

-

- {selectedTag ? categories.find((c) => c.tag === selectedTag)?.name || selectedTag : "选择一个分类"} -

-
- {selectedTag ? categories.find((c) => c.tag === selectedTag)?.desc : ""} -
-
- {images.length} 张图片 -
- -
- {images.map(img => ( -
- -
- -
-
- {img} -
-
- ))} - {loading &&
正在读取表情包分类…
} - {!loading && !error && images.length === 0 && selectedTag && ( -
- 这个分类还没有图片 - 通过 Meme 插件导入图片后会显示在这里。 -
- )} - {error && ( -
- 无法加载表情包 - {error} -
- )} -
-
-
- ); -} - -window.AkashicDashboard.registerPlugin({ - id: "meme", - label: "Meme 表情包", - viewLabel: "表情包", - layout: "workbench", - - async getCount(): Promise { - try { - const data = await api("/api/dashboard/meme/categories"); - let total = 0; - for (const cat of data.categories || []) { - total += cat.count; - } - return total; - } catch { - return null; - } - }, - - async fetchPage() { - return { items: [], total: 0 }; - }, - - Main: MemeMain, -}); diff --git a/plugin.py b/plugin.py index fb579f1..6f73152 100644 --- a/plugin.py +++ b/plugin.py @@ -46,6 +46,12 @@ def decorate_meme_ctx(ctx: AfterReasoningCtx, decorator: MemeDecorator) -> None: inject: tuple[ServiceKey[object], ...] = (CITATION_PROTOCOL_SERVICE,) skill_roots = ("skills",) dashboard_module = "dashboard.py" +web_module = "web_module.js" +web_requires = ("workbench.panels.v1",) +web_provides = () +web_contract_digests = { + "workbench.panels.v1": "724b282c22c4b3f3a36967ab664c4dfd8bce4257665f99459000306938caf527", +} workspace_roots = ("memes",) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 76fd844..a57e091 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone import pytest -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.routing import APIRoute from agent.core.response_parser import ResponseMetadata @@ -118,7 +118,6 @@ def _prompt_ctx() -> PromptRenderCtx: timestamp=datetime.now(timezone.utc), history=[], skill_names=[], - retrieved_memory_block="", disabled_sections=set(), turn_injection_prompt="", ) @@ -205,6 +204,7 @@ async def mount(ctx: Context) -> None: inject=inject, runtime=PluginRuntime( plugin_id="meme", + generation_id=root.generation_id, plugin_dir=Path(__file__).parents[1], data_dir=tmp_path / "plugin-data", workspace=tmp_path, @@ -269,6 +269,7 @@ async def mount(ctx: Context) -> None: inject=inject, runtime=PluginRuntime( plugin_id="meme", + generation_id=root.generation_id, plugin_dir=Path(__file__).parents[1], data_dir=tmp_path / "candidate-data", workspace=candidate_workspace, @@ -315,6 +316,101 @@ async def mount(ctx: Context) -> None: await root.dispose() +def _meme_dashboard_route(tmp_path: Path, path: str, method: str): + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + app = FastAPI() + dashboard_module.register( + app, + DashboardContext( + plugin_id="meme", + plugin_dir=Path(__file__).parents[1], + data_root=tmp_path / "data", + validation=True, + _workspace_roots=(("memes", tmp_path / "memes"),), + ), + ) + return next( + route + for route in app.routes + if isinstance(route, APIRoute) + and route.path == path + and method in route.methods + ) + + +def test_dashboard_rejects_media_path_traversal(tmp_path: Path) -> None: + media_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "GET", + ) + + with pytest.raises(HTTPException, match="Invalid category") as raised: + media_route.endpoint("..", "secret.png") + + assert raised.value.status_code == 422 + + +def test_dashboard_rejects_media_symlink(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + secret = tmp_path / "secret.png" + secret.write_bytes(b"secret") + image.unlink() + image.symlink_to(secret) + media_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "GET", + ) + + with pytest.raises(HTTPException, match="Invalid filename") as raised: + media_route.endpoint("shy", "001.png") + + assert raised.value.status_code == 422 + + +def test_dashboard_delete_keeps_named_recovery_copy(tmp_path: Path) -> None: + image = _write_meme_workspace(tmp_path) + delete_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/media/{tag}/{filename}", + "DELETE", + ) + + result = delete_route.endpoint("shy", "001.png") + + assert not image.exists() + recovery_id = result["recovery_id"] + assert recovery_id.startswith("image-") + recovered = tmp_path / "memes" / ".trash" / recovery_id / "001.png" + assert recovered.read_bytes() == b"\x89PNG\r\n\x1a\n" + + +def test_category_delete_restores_directory_when_manifest_write_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image = _write_meme_workspace(tmp_path) + dashboard_module = importlib.import_module("test_meme_plugin.dashboard") + + def fail_write(*_args) -> None: + raise OSError("disk full") + + monkeypatch.setattr(dashboard_module, "_write_manifest", fail_write) + delete_route = _meme_dashboard_route( + tmp_path, + "/api/dashboard/meme/categories/{tag}", + "DELETE", + ) + + with pytest.raises(HTTPException, match="Failed to preserve deleted category"): + delete_route.endpoint("shy") + + assert image.is_file() + manifest = json.loads((tmp_path / "memes" / "manifest.json").read_text()) + assert "shy" in manifest["categories"] + + @pytest.mark.asyncio async def test_v3_plugin_loads_package_and_dashboard_through_real_manager( tmp_path: Path, diff --git a/dashboard_panel.css b/web_module.css similarity index 92% rename from dashboard_panel.css rename to web_module.css index 4e2fe95..0cd1b74 100644 --- a/dashboard_panel.css +++ b/web_module.css @@ -1,12 +1,3 @@ -.plugin-workbench-pane:has(> .plugin-workbench-root[data-akashic-plugin="meme"]) { - overflow: hidden; -} - -.plugin-workbench-root[data-akashic-plugin="meme"] { - height: 100%; - min-height: 0; -} - .meme-dashboard { display: grid; grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); @@ -90,10 +81,6 @@ text-align: start; } -.meme-category:hover { - background: var(--ak-color-bg-surface-high); -} - .meme-category.is-active { color: var(--ak-color-action-primary); background: var(--ak-color-action-soft); @@ -137,20 +124,14 @@ .meme-delete, .meme-item__delete { display: grid; - width: 40px; - height: 40px; + width: 44px; + height: 44px; place-items: center; border-radius: 6px; color: var(--ak-color-text-muted); font-size: 14px; } -.meme-delete:hover, -.meme-item__delete:hover { - color: var(--ak-color-status-error); - background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); -} - .meme-dashboard button:focus-visible, .meme-dashboard summary:focus-visible { outline: 2px solid var(--ak-color-action-primary); @@ -193,6 +174,9 @@ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); align-content: start; gap: 16px; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; } .meme-item { @@ -234,12 +218,24 @@ overflow: hidden; margin-block-start: 8px; color: var(--ak-color-text-muted); - font-family: var(--mono); + font-family: var(--ak-type-technical); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +@media (hover: hover) { + .meme-category:hover { + background: var(--ak-color-bg-surface-high); + } + + .meme-delete:hover, + .meme-item__delete:hover { + color: var(--ak-color-status-error); + background: color-mix(in oklch, var(--ak-color-status-error) 12%, transparent); + } +} + .meme-state { display: grid; grid-column: 1 / -1; @@ -312,6 +308,7 @@ .meme-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; + overflow: visible; } } diff --git a/web_module.js b/web_module.js new file mode 100644 index 0000000..85adfea --- /dev/null +++ b/web_module.js @@ -0,0 +1,200 @@ +export function activate(ctx) { + return ctx.ui.inject("workbench.panels.v1", (mount) => mount.register({ + id: "meme", + label: "Meme 表情包", + order: 40, + render(host) { + const panel = document.createElement("section"); + panel.className = "meme-dashboard"; + panel.innerHTML = ``; + host.replaceChildren(panel); + const categoriesHost = panel.querySelector("[data-categories]"); + const categoryCount = panel.querySelector("[data-category-count]"); + const title = panel.querySelector("[data-title]"); + const description = panel.querySelector("[data-description]"); + const total = panel.querySelector("[data-total]"); + const status = panel.querySelector("[data-status]"); + const gallery = panel.querySelector("[data-gallery]"); + let categories = []; + let selectedTag = ""; + let catalogRequest = new AbortController(); + let imageRequest = new AbortController(); + let mutationRequest = new AbortController(); + let observer = null; + let objectUrls = []; + let disposed = false; + + const clearImages = () => { + imageRequest.abort(); + imageRequest = new AbortController(); + observer?.disconnect(); + observer = null; + for (const url of objectUrls) URL.revokeObjectURL(url); + objectUrls = []; + gallery.replaceChildren(); + }; + + const loadCategories = async (preferredTag = selectedTag) => { + catalogRequest.abort(); + catalogRequest = new AbortController(); + const activeRequest = catalogRequest; + status.textContent = "正在读取表情包分类…"; + try { + const result = await json(ctx, "/api/dashboard/meme/categories", {}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted) return; + categories = Array.isArray(result.categories) ? result.categories : []; + selectedTag = categories.some((item) => item.tag === preferredTag) + ? preferredTag + : String(categories[0]?.tag ?? ""); + renderCategories(); + if (selectedTag) await loadImages(selectedTag); + else { + clearImages(); + title.textContent = "选择一个分类"; + total.textContent = "0 张图片"; + status.textContent = "还没有表情包分类。"; + } + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + const renderCategories = () => { + categoryCount.textContent = String(categories.length); + categoriesHost.replaceChildren(); + for (const category of categories) { + const row = document.createElement("div"); + row.className = "meme-category-row"; + const choose = document.createElement("button"); + choose.type = "button"; + choose.className = `meme-category${selectedTag === category.tag ? " is-active" : ""}`; + choose.disabled = !category.enabled; + choose.setAttribute("aria-current", selectedTag === category.tag ? "page" : "false"); + choose.innerHTML = `${escapeHtml(category.name || category.tag)}${escapeHtml(category.desc || category.tag)}${Number(category.count || 0)}`; + choose.addEventListener("click", () => { + selectedTag = category.tag; + renderCategories(); + void loadImages(category.tag); + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "meme-delete"; + remove.setAttribute("aria-label", `删除分类 ${category.tag}`); + remove.textContent = "×"; + remove.addEventListener("click", () => void deleteCategory(category)); + row.append(choose, remove); + categoriesHost.append(row); + } + }; + + const loadImages = async (tag) => { + clearImages(); + const activeRequest = imageRequest; + const category = categories.find((item) => item.tag === tag); + title.textContent = category?.name || tag; + description.textContent = category?.desc || ""; + status.textContent = "正在读取图片…"; + try { + const result = await json(ctx, `/api/dashboard/meme/images/${encodeURIComponent(tag)}`, {}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted || tag !== selectedTag) return; + const images = Array.isArray(result.images) ? result.images : []; + total.textContent = `${images.length} 张图片`; + status.textContent = images.length ? "" : "这个分类还没有图片。"; + observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + observer?.unobserve(entry.target); + void loadImage(entry.target, tag, activeRequest.signal); + } + }, {rootMargin: "160px"}); + for (const filename of images) { + const figure = document.createElement("figure"); + figure.className = "meme-item"; + figure.dataset.filename = filename; + figure.innerHTML = `
载入中
${escapeHtml(filename)}
`; + figure.querySelector("button").addEventListener("click", () => void deleteImage(tag, filename)); + gallery.append(figure); + observer.observe(figure); + } + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + const loadImage = async (figure, tag, signal) => { + try { + const response = await ctx.http.request(`/api/dashboard/meme/media/${encodeURIComponent(tag)}/${encodeURIComponent(figure.dataset.filename)}`, {signal}); + if (!response.ok) throw new Error(`图片读取失败: HTTP ${response.status}`); + const url = URL.createObjectURL(await response.blob()); + if (disposed || signal.aborted || tag !== selectedTag) { + URL.revokeObjectURL(url); + return; + } + objectUrls.push(url); + const image = document.createElement("img"); + image.src = url; + image.alt = ""; + figure.querySelector(".meme-item__preview").replaceChildren(image); + } catch (reason) { + if (!signal.aborted) figure.querySelector(".meme-item__preview").textContent = reason instanceof Error ? reason.message : String(reason); + } + }; + + const deleteCategory = async (category) => { + if (!window.confirm(`确定删除分类“${category.tag}”及其中全部图片吗?此操作无法撤销。`)) return; + mutationRequest.abort(); + mutationRequest = new AbortController(); + const activeRequest = mutationRequest; + try { + await json(ctx, `/api/dashboard/meme/categories/${encodeURIComponent(category.tag)}`, {method: "DELETE"}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted) return; + await loadCategories(); + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + const deleteImage = async (tag, filename) => { + if (!window.confirm(`确定删除图片“${filename}”吗?此操作无法撤销。`)) return; + mutationRequest.abort(); + mutationRequest = new AbortController(); + const activeRequest = mutationRequest; + try { + await json(ctx, `/api/dashboard/meme/media/${encodeURIComponent(tag)}/${encodeURIComponent(filename)}`, {method: "DELETE"}, activeRequest.signal); + if (disposed || activeRequest.signal.aborted || tag !== selectedTag) return; + const category = categories.find((item) => item.tag === tag); + if (category) category.count = Math.max(0, Number(category.count) - 1); + renderCategories(); + await loadImages(tag); + } catch (reason) { + if (!activeRequest.signal.aborted) showError(status, reason); + } + }; + + void loadCategories(); + return () => { + disposed = true; + catalogRequest.abort(); + mutationRequest.abort(); + clearImages(); + host.replaceChildren(); + }; + }, + })); +} + +async function json(ctx, path, init, signal) { + const response = await ctx.http.request(path, {...init, signal}); + const body = await response.json(); + if (!response.ok) throw new Error(body?.detail || body?.message || `HTTP ${response.status}`); + return body; +} + +function showError(target, reason) { + target.setAttribute("role", "alert"); + target.textContent = reason instanceof Error ? reason.message : String(reason); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (character) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[character]); +}