diff --git a/backend/src/module/api/plugins.py b/backend/src/module/api/plugins.py index 5119982f..83d71f0e 100644 --- a/backend/src/module/api/plugins.py +++ b/backend/src/module/api/plugins.py @@ -64,6 +64,8 @@ class CatalogEntry(BaseModel): kind: str = "plugin" extension_points: list[str] = [] description: str = "" + # 插件依赖的 ab_sdk 版本范围(清单中的 sdk) + sdk: str = "" min_ab_version: str = "0.0.0" authors: list[str] = [] permissions: list[str] = [] @@ -71,6 +73,8 @@ class CatalogEntry(BaseModel): # 源码位置:作者仓库与固定的 commit(插件市场的索引条目) repo: str = "" commit: str = "" + # 插件在仓库中的子目录,"." 为仓库根 + path: str = "." readme: str = "" installed_version: str | None diff --git a/backend/src/test/test_plugin_catalog.py b/backend/src/test/test_plugin_catalog.py index 2748432a..b9dc5bd1 100644 --- a/backend/src/test/test_plugin_catalog.py +++ b/backend/src/test/test_plugin_catalog.py @@ -370,6 +370,7 @@ class TestPluginsApi: assert response.status_code == 200 [item] = response.json() assert item["id"] == PLUGIN_ID and item["installed_version"] is None + assert item["path"] == "." def test_catalog_unreachable_returns_502(self, authed_client, ctx, monkeypatch): from module.api import plugins as plugins_api diff --git a/webui/src/components/layout/ab-mobile-nav.vue b/webui/src/components/layout/ab-mobile-nav.vue index 67376cf3..ca066a66 100644 --- a/webui/src/components/layout/ab-mobile-nav.vue +++ b/webui/src/components/layout/ab-mobile-nav.vue @@ -1,5 +1,6 @@ @@ -500,38 +390,14 @@ onMounted(load); font-variant-numeric: tabular-nums; } -.catalog { - display: flex; - flex-direction: column; - gap: 12px; - padding-top: 16px; - border-top: 1px solid var(--color-border); -} - -.catalog__list { - display: flex; - flex-direction: column; - gap: 8px; - margin: 0; - padding: 0; - list-style: none; -} - -.catalog__entry { +.plugins__market { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 12px; - padding: 12px; - border-radius: var(--radius-md); - background: var(--color-surface-2); -} - -.catalog__action { - display: flex; - flex-shrink: 0; - align-items: center; - gap: 8px; + gap: var(--layout-gap); + margin: 0; + padding-top: var(--layout-padding); + border-top: 1px solid var(--color-border); } diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 669672d4..7e0b00b3 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -190,7 +190,7 @@ }, "plugins_set": { "title": "Plugins", - "empty": "No plugins found. Install one from the plugin catalog below. Or put a plugin directory (named after the plugin id) into config/plugins/local/, or install it with pip, then restart.", + "empty": "No plugins found. Install one from the plugin market, or put a plugin directory (named after the plugin id) into config/plugins/local/ or install it with pip, then restart.", "allow_unsigned": "Allow unsigned plugins", "allow_unsigned_hint": "Plugins from the local directory or pip are unsigned and only load when this is on. Plugins run inside AutoBangumi with full privileges — only enable plugins you trust.", "enabled": "Enabled", @@ -219,20 +219,6 @@ "uninstall_confirm_body": "The plugin files are deleted. Its options stay in config.json and apply again if you reinstall it.", "uninstall_success": "Plugin uninstalled", "uninstall_failed": "Failed to uninstall the plugin", - "catalog_title": "Plugin catalog", - "catalog_hint": "AutoBangumi signs the catalog and every plugin package, and checks the signature and sha256 before install. Catalog plugins count as signed and do not need \"Allow unsigned plugins\".", - "catalog_browse": "Browse catalog", - "catalog_refresh": "Refresh", - "catalog_failed": "Catalog unavailable.", - "catalog_failed_hint": "Check the network, then select Refresh.", - "catalog_empty": "The catalog has no plugins yet.", - "extension_points": "Extension points: {list}", - "install": "Install", - "update": "Update", - "installed": "Installed", - "installed_version": "Installed v{version}", - "install_success": "Plugin installed and enabled", - "install_failed": "Failed to install the plugin", "retry": "Retry", "enabled_for": "Enable {name}", "unsaved": "Unsaved changes", @@ -258,7 +244,8 @@ }, "permission_labels": { "fs_write": "Write files", - "network": "Network access" + "network": "Network access", + "filesystem": "File system access" }, "disable": "Disable", "disable_confirm_title": "Disable {name}?", @@ -270,7 +257,9 @@ "remove_row_confirm_body": "The values in this row are deleted when you save the options.", "field_required": "Required", "field_min": "Must be {n} or more", - "field_max": "Must be {n} or less" + "field_max": "Must be {n} or less", + "market_hint": "Install and update plugins in the plugin market.", + "open_market": "Open plugin market" } }, "downloader": { @@ -641,7 +630,8 @@ "logout": "Logout", "player": "Player", "rss": "RSS", - "title": "Menu" + "title": "Menu", + "market": "Plugin market" }, "common": { "cancel": "Cancel", @@ -854,5 +844,67 @@ "body": "{torrent_name} (rename method: {strategy}). Detail: {reason}. The files keep their original names. Fix the rename setting. AutoBangumi retries on the next run." } } + }, + "market": { + "title": "Plugin market", + "catalog_verified": "Catalog signature verified", + "updated": "Updated {time}", + "refresh": "Refresh", + "search": "Search by name, id, author or description", + "state_filter": "Install state", + "state_all": "All states", + "state": { + "installed": "Installed", + "update": "Update available", + "available": "Not installed" + }, + "update_from": "Update available · {version} installed", + "categories": "Categories", + "category_all": "All", + "category": { + "source": "Sources", + "notify": "Notifications", + "organize": "Organizing", + "interface": "UI & API", + "other": "Other" + }, + "list_label": "Plugins", + "version": "Version", + "version_sdk": "{version} · SDK {sdk}", + "authors": "Authors", + "source": "Source", + "review": "Review", + "reviewed": "The catalog maintainers reviewed this commit.", + "extension_points": "Extension points", + "permissions": "Declared permissions", + "permission_added": "new in this version", + "permission_removed": "no longer declared", + "frontend": "Web UI", + "yes": "Yes", + "no": "No", + "none": "None", + "install": "Install", + "update": "Update", + "install_enable": "Install and enable", + "back": "Back", + "confirm_install": "Install {name}", + "confirm_update": "Update {name}", + "warning_lead": "Note", + "warning": "The plugin runs inside the AutoBangumi process and can read and change your settings and downloaders. The permissions are only what the author declares; AutoBangumi cannot enforce them.", + "ack_permissions": "I understand the new permissions", + "install_success": "{name} installed and enabled", + "update_success": "{name} updated to {version}", + "install_failed": "Could not install {name}", + "update_failed": "Could not update {name}", + "load_failed": "Could not load the plugin catalog", + "load_failed_hint": "Check the network or proxy settings, then try again.", + "retry": "Retry", + "stale_lead": "Catalog unavailable", + "stale": "Showing the catalog from {time}. You can install and update again once the connection is back.", + "no_match": "No plugins match “{query}”", + "clear_search": "Clear search", + "no_filter_match": "No plugins match these filters", + "show_all": "Show all plugins", + "empty": "The catalog has no plugins yet" } } diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index c4f5bf61..c1bcba9d 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -190,7 +190,7 @@ }, "plugins_set": { "title": "插件", - "empty": "未发现插件。从下方的插件目录安装;或把插件目录放进 config/plugins/local/(目录名与插件 id 相同)、用 pip 安装,重启后即可在此出现。", + "empty": "未发现插件。可以在插件市场中安装;或把插件目录放进 config/plugins/local/(目录名与插件 id 相同)、用 pip 安装,然后重启。", "allow_unsigned": "允许未签名插件", "allow_unsigned_hint": "本地目录与 pip 安装的插件未经签名,开启后才会加载。插件与 AutoBangumi 同进程运行,拥有完整权限,只启用你信任的插件。", "enabled": "启用", @@ -219,20 +219,6 @@ "uninstall_confirm_body": "插件文件会被删除。它的配置保留在 config.json 中,重新安装后继续生效。", "uninstall_success": "插件已卸载", "uninstall_failed": "卸载插件失败", - "catalog_title": "插件目录", - "catalog_hint": "目录与插件包由 AutoBangumi 签名,安装前校验签名与 sha256。从目录安装的插件视为已签名,不需要开启「允许未签名插件」。", - "catalog_browse": "浏览目录", - "catalog_refresh": "刷新", - "catalog_failed": "目录不可用。", - "catalog_failed_hint": "检查网络后点击「刷新」重试。", - "catalog_empty": "目录中暂时没有插件。", - "extension_points": "扩展点:{list}", - "install": "安装", - "update": "更新", - "installed": "已安装", - "installed_version": "已安装 v{version}", - "install_success": "插件已安装并启用", - "install_failed": "安装插件失败", "retry": "重试", "enabled_for": "启用 {name}", "unsaved": "有未保存的修改", @@ -258,7 +244,8 @@ }, "permission_labels": { "fs_write": "写入文件", - "network": "访问网络" + "network": "访问网络", + "filesystem": "访问文件系统" }, "disable": "停用", "disable_confirm_title": "停用 {name}?", @@ -270,7 +257,9 @@ "remove_row_confirm_body": "保存插件选项后,该行的内容会被删除。", "field_required": "必填", "field_min": "不能小于 {n}", - "field_max": "不能大于 {n}" + "field_max": "不能大于 {n}", + "market_hint": "在插件市场中安装和更新插件。", + "open_market": "打开插件市场" } }, "downloader": { @@ -641,7 +630,8 @@ "logout": "退出", "player": "播放器", "rss": "RSS 管理", - "title": "菜单" + "title": "菜单", + "market": "插件市场" }, "common": { "cancel": "取消", @@ -854,5 +844,67 @@ "body": "{torrent_name}(重命名方式:{strategy})。详情:{reason}。文件保留原名。请修正重命名设置,下次运行时会自动重试。" } } + }, + "market": { + "title": "插件市场", + "catalog_verified": "目录签名有效", + "updated": "{time}更新", + "refresh": "刷新", + "search": "搜索名称、id、作者或描述", + "state_filter": "安装状态", + "state_all": "全部状态", + "state": { + "installed": "已安装", + "update": "可更新", + "available": "未安装" + }, + "update_from": "可更新 · 已装 {version}", + "categories": "分类", + "category_all": "全部", + "category": { + "source": "来源", + "notify": "通知", + "organize": "整理", + "interface": "界面与接口", + "other": "其他" + }, + "list_label": "插件列表", + "version": "版本", + "version_sdk": "{version} · SDK {sdk}", + "authors": "作者", + "source": "源码", + "review": "审查", + "reviewed": "目录维护者审查过这个 commit。", + "extension_points": "扩展点", + "permissions": "权限声明", + "permission_added": "新版本新增", + "permission_removed": "新版本不再声明", + "frontend": "前端", + "yes": "有", + "no": "无", + "none": "无", + "install": "安装", + "update": "更新", + "install_enable": "安装并启用", + "back": "返回", + "confirm_install": "安装 {name}", + "confirm_update": "更新 {name}", + "warning_lead": "注意", + "warning": "插件在 AutoBangumi 的进程中运行,可以读写你的配置和下载器。权限只是作者的声明,AutoBangumi 不能强制。", + "ack_permissions": "我已了解新增的权限", + "install_success": "已安装并启用 {name}", + "update_success": "已将 {name} 更新到 {version}", + "install_failed": "安装 {name} 失败", + "update_failed": "更新 {name} 失败", + "load_failed": "无法获取插件目录", + "load_failed_hint": "检查网络或代理设置后重试。", + "retry": "重试", + "stale_lead": "目录不可用", + "stale": "下面是 {time} 的目录,安装和更新要等连接恢复。", + "no_match": "没有与 “{query}” 匹配的插件", + "clear_search": "清除搜索", + "no_filter_match": "没有符合筛选条件的插件", + "show_all": "显示全部插件", + "empty": "目录中还没有插件" } } diff --git a/webui/src/pages/index.test.ts b/webui/src/pages/index.test.ts index 118daf36..58d232b8 100644 --- a/webui/src/pages/index.test.ts +++ b/webui/src/pages/index.test.ts @@ -11,7 +11,7 @@ vi.stubGlobal('useRoute', () => ({ })); vi.mock('@/hooks/useMyI18n', async () => { const { ref } = await vi.importActual('vue'); - return { useMyI18n: () => ({ lang: ref('en') }) }; + return { useMyI18n: () => ({ t: (k: string) => k, lang: ref('en') }) }; }); const slots = ref([]); diff --git a/webui/src/pages/index.vue b/webui/src/pages/index.vue index 3521a650..d54b7c91 100644 --- a/webui/src/pages/index.vue +++ b/webui/src/pages/index.vue @@ -10,12 +10,13 @@ definePage({ const { editRule } = storeToRefs(useBangumiStore()); // 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin;清单已到达 -// 或加载失败仍无匹配页面时回退为插件 id);其它页面沿用路由名 +// 或加载失败仍无匹配页面时回退为插件 id);插件市场用翻译后的标题,其它页面沿用路由名 const route = useRoute(); -const { lang } = useMyI18n(); +const { t, lang } = useMyI18n(); const { slots, loaded, loadFailed } = usePluginUi(); const pageTitle = computed(() => { const name = String(route.name ?? ''); + if (name === 'Market') return t('market.title'); if (name !== 'Plugin') return name; const id = (route.params as Record).id; const ui = slots.value.find((s) => s.slot === 'page' && s.plugin_id === id); diff --git a/webui/src/pages/index/market/[[id]].vue b/webui/src/pages/index/market/[[id]].vue new file mode 100644 index 00000000..5ae838e8 --- /dev/null +++ b/webui/src/pages/index/market/[[id]].vue @@ -0,0 +1,855 @@ + + + + + diff --git a/webui/src/pages/index/market/market.test.ts b/webui/src/pages/index/market/market.test.ts new file mode 100644 index 00000000..1ef23558 --- /dev/null +++ b/webui/src/pages/index/market/market.test.ts @@ -0,0 +1,203 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { ref } from 'vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import MarketPage from './[[id]].vue'; +import type { CatalogEntry } from '#/plugins'; +import { apiPlugins } from '@/api/plugins'; +import AbAlert from '@/components/basic/ab-alert.vue'; +import AbButton from '@/components/basic/ab-button.vue'; +import AbEmpty from '@/components/basic/ab-empty.vue'; +import AbInput from '@/components/basic/ab-input.vue'; +import AbSegmented from '@/components/basic/ab-segmented.vue'; +import AbTag from '@/components/basic/ab-tag.vue'; +import { refreshPluginProviders } from '@/hooks/usePluginProviders'; +import { refreshPluginUi } from '@/hooks/usePluginUi'; + +vi.stubGlobal('definePage', vi.fn()); +vi.stubGlobal('useRoute', () => ({ params: {} })); +vi.stubGlobal('useRouter', () => ({ back: vi.fn(), replace: vi.fn() })); +vi.stubGlobal('useIntervalFn', vi.fn()); + +vi.mock('@/api/plugins', () => ({ + apiPlugins: { catalog: vi.fn(), list: vi.fn(), install: vi.fn() }, +})); +vi.mock('@/hooks/useMyI18n', async () => { + const { ref } = await vi.importActual('vue'); + return { useMyI18n: () => ({ t: (key: string) => key, lang: ref('en') }) }; +}); +vi.mock('@/hooks/useMessage', () => ({ + useMessage: () => ({ success: vi.fn(), error: vi.fn() }), +})); +vi.mock('@/hooks/useBreakpointQuery', () => ({ + useBreakpointQuery: () => ({ isMobile: ref(false) }), +})); +vi.mock('@/hooks/usePluginProviders', () => ({ + refreshPluginProviders: vi.fn(), +})); +vi.mock('@/hooks/usePluginUi', () => ({ refreshPluginUi: vi.fn() })); +const refreshGroup = vi.fn(); +vi.mock('@/store/config', () => ({ + useConfigStore: () => ({ refreshGroup }), +})); + +const api = vi.mocked(apiPlugins); +const getItem = vi.mocked(localStorage.getItem); +const setItem = vi.mocked(localStorage.setItem); + +function entry(overrides: Partial = {}): CatalogEntry { + return { + id: 'bili', + name: 'Bilibili', + version: '0.2.0', + kind: 'plugin', + extension_points: ['search_site'], + description: '', + sdk: '', + min_ab_version: '4.0.0', + authors: ['alice'], + permissions: ['network'], + has_web: false, + repo: 'alice/bili', + commit: '3f9c2e1aa', + path: '.', + readme: '', + installed_version: null, + ...overrides, + }; +} + +async function mountPage() { + const wrapper = mount(MarketPage, { + global: { + components: { AbAlert, AbButton, AbEmpty, AbInput, AbSegmented, AbTag }, + stubs: { + AbSelect: true, + AbSkeleton: true, + AbIconButton: true, + RouterLink: { + props: ['to'], + template: '', + }, + }, + }, + }); + await flushPromises(); + return wrapper; +} + +function buttons(w: Awaited>, text: string) { + return w.findAll('button').filter((b) => b.text() === text); +} + +describe('market page', () => { + beforeEach(() => { + vi.clearAllMocks(); + getItem.mockReturnValue(null); + api.list.mockResolvedValue({ allow_unsigned: false, plugins: [] }); + }); + + it('should show the cached catalog with install disabled when the catalog is unavailable', async () => { + getItem.mockReturnValue( + JSON.stringify({ entries: [entry()], fetchedAt: 0 }) + ); + api.catalog.mockRejectedValue({ + status: 502, + msg_en: '', + msg_zh: '', + detail: 'connect timeout', + }); + + const w = await mountPage(); + + const alert = w.find('[role="alert"]'); + expect(alert.text()).toContain('market.stale_lead'); + expect(alert.text()).toContain('connect timeout'); + expect(w.findAll('.row-link')).toHaveLength(1); + expect( + buttons(w, 'market.install')[0].attributes('disabled') + ).toBeDefined(); + }); + + it('should keep the loaded catalog and disable install when a refresh fails', async () => { + api.catalog.mockResolvedValueOnce([entry()]); + const w = await mountPage(); + expect(w.find('[role="alert"]').exists()).toBe(false); + + api.catalog.mockRejectedValueOnce({ status: 0, msg_en: '', msg_zh: '' }); + await buttons(w, 'market.refresh')[0].trigger('click'); + await flushPromises(); + + expect(w.find('[role="alert"]').text()).toContain('market.stale_lead'); + expect(getItem).not.toHaveBeenCalled(); + expect(w.findAll('.row-link')).toHaveLength(1); + expect( + buttons(w, 'market.install')[0].attributes('disabled') + ).toBeDefined(); + }); + + it('should show the error and recover on retry when there is no cache', async () => { + api.catalog.mockRejectedValueOnce(new Error('down')); + const w = await mountPage(); + expect(w.text()).toContain('market.load_failed'); + expect(w.find('.row-link').exists()).toBe(false); + + api.catalog.mockResolvedValue([entry()]); + await buttons(w, 'market.retry')[0].trigger('click'); + await flushPromises(); + + expect(w.text()).not.toContain('market.load_failed'); + expect(w.findAll('.row-link')).toHaveLength(1); + expect(setItem).toHaveBeenCalled(); + }); + + it('should offer to clear a search that matches nothing', async () => { + api.catalog.mockResolvedValue([ + entry(), + entry({ id: 'ntfy', name: 'ntfy' }), + ]); + const w = await mountPage(); + + await w.find('input').setValue('jellyfin'); + expect(w.findAll('.row-link')).toHaveLength(0); + expect(w.text()).toContain('market.no_match'); + + await buttons(w, 'market.clear_search')[0].trigger('click'); + expect(w.findAll('.row-link')).toHaveLength(2); + }); + + it('should install after the confirm step and refresh catalog, config and plugin UI', async () => { + api.catalog.mockResolvedValue([entry()]); + api.install.mockResolvedValue({ allow_unsigned: false, plugins: [] }); + const w = await mountPage(); + + await buttons(w, 'market.install')[0].trigger('click'); + expect(w.text()).toContain('market.warning'); + expect(api.install).not.toHaveBeenCalled(); + + await buttons(w, 'market.install_enable')[0].trigger('click'); + await flushPromises(); + + expect(api.install).toHaveBeenCalledWith('bili'); + expect(api.catalog).toHaveBeenCalledTimes(2); + expect(refreshGroup).toHaveBeenCalledWith( + 'plugins', + expect.arrayContaining(['enabled']) + ); + expect(refreshPluginProviders).toHaveBeenCalled(); + expect(refreshPluginUi).toHaveBeenCalled(); + expect(w.text()).not.toContain('market.warning'); + }); + + it('should stay on the confirm step when the install fails', async () => { + api.catalog.mockResolvedValue([entry()]); + api.install.mockRejectedValue(new Error('signature invalid')); + const w = await mountPage(); + + await buttons(w, 'market.install')[0].trigger('click'); + await buttons(w, 'market.install_enable')[0].trigger('click'); + await flushPromises(); + + expect(w.text()).toContain('market.warning'); + expect(refreshPluginUi).not.toHaveBeenCalled(); + }); +}); diff --git a/webui/src/utils/__tests__/markdown.test.ts b/webui/src/utils/__tests__/markdown.test.ts new file mode 100644 index 00000000..e91e8e63 --- /dev/null +++ b/webui/src/utils/__tests__/markdown.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { renderMarkdown } from '../markdown'; + +describe('renderMarkdown', () => { + it.each([ + ['# Title', '

Title

'], + ['### Deep ###', '
Deep
'], + ['one\ntwo\n\nthree', '

one two

three

'], + ['- a\n- b', '
  • a
  • b
'], + ['1. a\n2) b', '
  1. a
  2. b
'], + ['- a\n more\n\n1. b', '
  • a more
  1. b
'], + ['text\n- a', '

text

  • a
'], + ['**b** and *i*', '

b and i

'], + ['__b__ _i_ media_id', '

b i media_id

'], + ['use `a **b**`', '

use a **b**

'], + [ + '```toml\nid = 1 # *x*\n\n\n```', + '
id = 1 # *x*\n\n<b>
', + ], + ['```\nopen', '
open
'], + [ + '[docs](https://a.io/x_y_z)', + '

docs

', + ], + ])('should render %j', (source, html) => { + expect(renderMarkdown(source)).toBe(html); + }); + + it.each([ + [ + '', + '

<script>alert(1)</script>

', + ], + [ + '', + '

<img src=x onerror="alert(1)">

', + ], + ['[x](javascript:alert(1))', '

x)

'], + ['[x](JavaScript:alert`1`)', '

x

'], + ['[x](data:text/html,hi)', '

x

'], + ['![pic](https://a.io/p.png)', '

pic

'], + ['#