科技 · 实用指南
分享 Anki 筛选牌组模板,不附带学习历史
把现有搜索条件和偏好单独导出为 JSON,再在另一个 Anki 集合中重建。本指南提供完整的本地辅助程序,在创建新筛选牌组前先检查整批配置。

如果你有一组 Anki 筛选牌组,希望让其他人复用,可以把牌组定义与卡片分开分享。下方的辅助程序会将名称、搜索条件、数量限制、排序方式和筛选牌组偏好导出为 JSON。接收者可以先查看文件,再使用自己集合(collection)中符合条件的卡片,批量重建这些牌组;JSON 本身不会传递你的卡片记录或复习日志。
这是一套面向 Anki 桌面版进阶用户的操作流程,已在临时集合中使用真实的 Anki 26.09.3 Python/Rust 后端验证。以 apply=True 导入设置时,程序会立即构建新的筛选牌组,把匹配的本地卡片移入其中。请先阅读预检报告,确认当前打开的是正确的用户档案(profile),并在应用更改前保留最新备份。下方提供完整程序,无需另外安装 Python。
1. 先确定实际需要迁移什么
如果对方希望在单独的用户档案中使用你的整个集合,包括其调度数据,那么现成的集合包 .colpkg 就是合适的选择。导入它会替换当前集合中的卡片。但如果你只想发送筛选定义,或不想在交付时附带自己积累的学习历史,它就不符合要求。
普通牌组包 .apkg 可以把卡片加入另一个集合,Anki 也提供排除调度信息的选项。这个包适合传递卡片,但不应把它当作可靠的筛选牌组定义传输方式。CrowdAnki 也明确不在其 JSON 导出中包含筛选牌组。根据标签创建筛选牌组,或重建现有筛选牌组的工具,都能解决流程中的一部分问题;这两种操作都不能迁移你选中的一整批现有完整定义。
当你要复用的是已经配置好的筛选牌组时,可以使用本指南提供的辅助程序。它会保留当前的七个配置字段,包括两组搜索条件、明确的 false 和 0 值、当前的预览延迟,以及保存下来的旧版延迟字段。复制旧字段并不会启用旧版调度器。普通牌组预设、FSRS 配置、插件设置、媒体和卡片内容都不在这个 JSON 格式的范围内。
文件仍包含你设置的牌组名称和原始查询文本,包括你写进搜索式的任何 ID。分享前请先阅读,必要时删除私密名称或词语。不含卡片和复习历史,并不意味着这些字符串已经匿名化。
2. 在 Anki 桌面版中保存并加载完整程序
将下面完整的一行 exec(...) 保存为 UTF-8 纯文本文件,命名为 anki_filtered_templates.py。这一行包含整个程序;其中转义后的换行和空格,可在从网页复制时保留 Python 缩进。请保持整行完整,不要把直引号替换成弯引号。配套源代码也可以保留为常规的缩进形式。
exec("\"\"\"Filtered-deck\x20configuration\x20only;\x20tested\x20with\x20Anki\x2026.9.3's\x20real\x20backend.\n\nLoad\x20this\x20file\x20in\x20Anki\x20Desktop's\x20debug\x20console,\x20then\x20use\x20mw.col:\n\x20\x20\x20\x20export_filtered_templates(mw.col,\x20'/absolute/path/templates.json')\n\x20\x20\x20\x20import_filtered_templates(mw.col,\x20'/absolute/path/templates.json')\n\x20\x20\x20\x20import_filtered_templates(mw.col,\x20'/absolute/path/templates.json',\x20apply=True)\nUse\x20names=['Exact\x20name',\x20...]\x20to\x20export\x20a\x20selection.\x20None\x20exports\x20all\x20filtered\ndecks\x20in\x20name\x20order.\x20prefix\x20changes\x20destination\x20deck\x20names,\x20never\x20searches.\napply=True\x20immediately\x20builds\x20using\x20destination-local\x20cards,\x20in\x20file\x20order.\nEarlier\x20builds\x20take\x20precedence\x20for\x20overlapping\x20cards.\x20This\x20is\x20not\x20one\x20atomic\nbatch\x20or\x20one\x20promised\x20Undo.\x20Existing\x20filtered\x20decks\x20are\x20never\x20emptied\x20here.\nNo\x20source\x20deck-ID\x20metadata,\x20card/note\x20records\x20or\x20review\x20logs\x20are\x20exported.\nSearch\x20text\x20is\x20preserved\x20and\x20may\x20contain\x20source-specific\x20IDs.\n\"\"\"\nimport\x20json\nimport\x20math\nimport\x20unicodedata\nfrom\x20pathlib\x20import\x20Path\nfrom\x20anki.collection\x20import\x20Collection\nfrom\x20anki.decks\x20import\x20DeckId,\x20FilteredDeckConfig\nfrom\x20google.protobuf.json_format\x20import\x20ParseDict\n\nSCHEMA\x20=\x20'anki-filtered-templates'\nVERSION\x20=\x201\nCONFIG_KEYS\x20=\x20{'reschedule',\x20'search_terms',\x20'delays',\x20'preview_delay',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'preview_again_secs',\x20'preview_hard_secs',\x20'preview_good_secs'}\nDELAY_KEYS\x20=\x20('preview_delay',\x20'preview_again_secs',\x20'preview_hard_secs',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'preview_good_secs')\nORDER\x20=\x20FilteredDeckConfig.SearchTerm.Order\n\n\nclass\x20PartialImportError(RuntimeError):\n\x20\x20\x20\x20\"\"\"Inspect\x20.partial_report;\x20successful\x20earlier\x20creations\x20remain\x20present.\"\"\"\n\x20\x20\x20\x20def\x20__init__(self,\x20message,\x20report):\n\x20\x20\x20\x20\x20\x20\x20\x20self.partial_report\x20=\x20report\n\x20\x20\x20\x20\x20\x20\x20\x20ids\x20=\x20[row['id']\x20for\x20row\x20in\x20report['created']]\n\x20\x20\x20\x20\x20\x20\x20\x20super().__init__(f'{message}.\x20New\x20filtered\x20IDs\x20so\x20far:\x20{ids}.\x20'\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'Inspect\x20exception.partial_report;\x20no\x20rollback\x20was\x20attempted.')\n\n\ndef\x20_object(value,\x20allowed,\x20required,\x20label):\n\x20\x20\x20\x20if\x20type(value)\x20is\x20not\x20dict:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{label}:\x20expected\x20an\x20object')\n\x20\x20\x20\x20unknown,\x20missing\x20=\x20set(value)\x20-\x20allowed,\x20required\x20-\x20set(value)\n\x20\x20\x20\x20if\x20unknown\x20or\x20missing:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{label}:\x20unknown\x20keys\x20{sorted(unknown)},\x20missing\x20keys\x20{sorted(missing)}')\n\n\ndef\x20_name(value):\n\x20\x20\x20\x20if\x20not\x20isinstance(value,\x20str)\x20or\x20not\x20value:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError('Deck\x20name\x20must\x20be\x20a\x20nonempty\x20string')\n\x20\x20\x20\x20if\x20(any(ord(c)\x20\u003c\x2032\x20or\x20ord(c)\x20==\x20127\x20for\x20c\x20in\x20value)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20or\x20any(not\x20p\x20or\x20p\x20!=\x20p.strip()\x20for\x20p\x20in\x20value.split('::'))\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20or\x20unicodedata.normalize('NFC',\x20value)\x20!=\x20value):\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Name\x20needs\x20nonempty,\x20unpadded,\x20NFC-normalized\x20components:\x20{value!r}')\n\x20\x20\x20\x20return\x20value\n\n\ndef\x20_uint(value,\x20label):\n\x20\x20\x20\x20if\x20type(value)\x20is\x20not\x20int\x20or\x20not\x200\x20\u003c=\x20value\x20\u003c=\x204294967295:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{label}:\x20expected\x20an\x20integer\x20from\x200\x20to\x204294967295')\n\n\ndef\x20_config_json(config):\n\x20\x20\x20\x20#\x20Explicit\x20defaults\x20keep\x20false\x20and\x20zero\x20visible;\x20no\x20IDs\x20or\x20scheduling\x20data.\n\x20\x20\x20\x20result\x20=\x20{'reschedule':\x20config.reschedule,\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'search_terms':\x20[{'search':\x20t.search,\x20'limit':\x20t.limit,\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'order':\x20ORDER.Name(t.order)}\x20for\x20t\x20in\x20config.search_terms],\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'delays':\x20list(config.delays)}\n\x20\x20\x20\x20result.update({key:\x20getattr(config,\x20key)\x20for\x20key\x20in\x20DELAY_KEYS})\n\x20\x20\x20\x20return\x20result\n\n\ndef\x20_config(col,\x20value,\x20label):\n\x20\x20\x20\x20_object(value,\x20CONFIG_KEYS,\x20{'search_terms'},\x20label)\n\x20\x20\x20\x20if\x20'reschedule'\x20in\x20value\x20and\x20type(value['reschedule'])\x20is\x20not\x20bool:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{label}.reschedule:\x20expected\x20true\x20or\x20false')\n\x20\x20\x20\x20for\x20key\x20in\x20DELAY_KEYS:\n\x20\x20\x20\x20\x20\x20\x20\x20if\x20key\x20in\x20value:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20_uint(value[key],\x20f'{label}.{key}')\n\x20\x20\x20\x20delays\x20=\x20value.get('delays',\x20[])\n\x20\x20\x20\x20if\x20type(delays)\x20is\x20not\x20list\x20or\x20any(type(v)\x20not\x20in\x20(int,\x20float)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20or\x20not\x20math.isfinite(v)\x20or\x20v\x20\u003c\x200\x20for\x20v\x20in\x20delays):\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{label}.delays:\x20expected\x20finite\x20nonnegative\x20numbers')\n\x20\x20\x20\x20terms\x20=\x20value['search_terms']\n\x20\x20\x20\x20if\x20type(terms)\x20is\x20not\x20list\x20or\x20not\x201\x20\u003c=\x20len(terms)\x20\u003c=\x202:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{label}:\x20requires\x20one\x20or\x20two\x20search\x20terms')\n\x20\x20\x20\x20normalized\x20=\x20[]\n\x20\x20\x20\x20for\x20i,\x20term\x20in\x20enumerate(terms):\n\x20\x20\x20\x20\x20\x20\x20\x20part\x20=\x20f'{label}.search_terms[{i}]'\n\x20\x20\x20\x20\x20\x20\x20\x20_object(term,\x20{'search',\x20'limit',\x20'order'},\x20{'search'},\x20part)\n\x20\x20\x20\x20\x20\x20\x20\x20search\x20=\x20term['search']\n\x20\x20\x20\x20\x20\x20\x20\x20if\x20not\x20isinstance(search,\x20str)\x20or\x20not\x20search.strip():\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{part}:\x20blank\x20search\x20rejected;\x20use\x20explicit\x20deck:*\x20for\x20all\x20decks')\n\x20\x20\x20\x20\x20\x20\x20\x20_uint(term.get('limit',\x200),\x20part\x20+\x20'.limit')\n\x20\x20\x20\x20\x20\x20\x20\x20order\x20=\x20term.get('order',\x200)\n\x20\x20\x20\x20\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if\x20type(order)\x20is\x20str:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20ORDER.Value(order)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20elif\x20type(order)\x20is\x20int:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20ORDER.Name(order)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20else:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError('not\x20an\x20enum\x20name\x20or\x20integer')\n\x20\x20\x20\x20\x20\x20\x20\x20except\x20(ValueError,\x20KeyError)\x20as\x20error:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{part}:\x20unknown\x20order\x20{order!r}')\x20from\x20error\n\x20\x20\x20\x20\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20normalized.append(col.build_search_string(search))\n\x20\x20\x20\x20\x20\x20\x20\x20except\x20Exception\x20as\x20error:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{part}:\x20invalid\x20search:\x20{error}')\x20from\x20error\n\x20\x20\x20\x20result\x20=\x20FilteredDeckConfig()\n\x20\x20\x20\x20result.Clear()\n\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20ParseDict(value,\x20result,\x20ignore_unknown_fields=False)\n\x20\x20\x20\x20except\x20Exception\x20as\x20error:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'{label}:\x20invalid\x20configuration:\x20{error}')\x20from\x20error\n\x20\x20\x20\x20return\x20result,\x20normalized\n\n\ndef\x20_manifest(col,\x20value):\n\x20\x20\x20\x20_object(value,\x20{'schema',\x20'version',\x20'templates'},\x20{'schema',\x20'version',\x20'templates'},\x20'manifest')\n\x20\x20\x20\x20if\x20value['schema']\x20!=\x20SCHEMA\x20or\x20type(value['version'])\x20is\x20not\x20int\x20or\x20value['version']\x20!=\x20VERSION:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Expected\x20schema\x20{SCHEMA!r},\x20version\x20{VERSION}')\n\x20\x20\x20\x20if\x20type(value['templates'])\x20is\x20not\x20list\x20or\x20not\x20value['templates']:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError('templates\x20must\x20be\x20a\x20nonempty\x20list')\n\x20\x20\x20\x20seen,\x20result\x20=\x20set(),\x20[]\n\x20\x20\x20\x20for\x20i,\x20item\x20in\x20enumerate(value['templates']):\n\x20\x20\x20\x20\x20\x20\x20\x20_object(item,\x20{'name',\x20'config'},\x20{'name',\x20'config'},\x20f'template\x20{i\x20+\x201}')\n\x20\x20\x20\x20\x20\x20\x20\x20name\x20=\x20_name(item['name'])\n\x20\x20\x20\x20\x20\x20\x20\x20if\x20name.casefold()\x20in\x20seen:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Duplicate\x20template\x20name:\x20{name}')\n\x20\x20\x20\x20\x20\x20\x20\x20seen.add(name.casefold())\n\x20\x20\x20\x20\x20\x20\x20\x20config,\x20normalized\x20=\x20_config(col,\x20item['config'],\x20name)\n\x20\x20\x20\x20\x20\x20\x20\x20result.append((name,\x20config,\x20normalized))\n\x20\x20\x20\x20return\x20result\n\n\ndef\x20_pairs(pairs):\n\x20\x20\x20\x20result\x20=\x20{}\n\x20\x20\x20\x20for\x20key,\x20value\x20in\x20pairs:\n\x20\x20\x20\x20\x20\x20\x20\x20if\x20key\x20in\x20result:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Duplicate\x20JSON\x20key:\x20{key}')\n\x20\x20\x20\x20\x20\x20\x20\x20result[key]\x20=\x20value\n\x20\x20\x20\x20return\x20result\n\n\ndef\x20_bad_constant(value):\n\x20\x20\x20\x20raise\x20ValueError(f'Nonfinite\x20JSON\x20constant:\x20{value}')\n\n\ndef\x20_counts(col):\n\x20\x20\x20\x20return\x20{'cards':\x20int(col.card_count()),\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'revlog':\x20int(col.db.scalar('select\x20count(*)\x20from\x20revlog'))}\n\n\ndef\x20export_filtered_templates(col,\x20path,\x20*,\x20names=None):\n\x20\x20\x20\x20\"\"\"Read\x20existing\x20filtered\x20configs,\x20validate\x20all,\x20then\x20exclusively\x20write\x20JSON.\n\n\x20\x20\x20\x20names=None\x20means\x20all\x20filtered\x20decks;\x20a\x20nonempty\x20list\x20gives\x20exact\x20selection\n\x20\x20\x20\x20and\x20order.\x20The\x20collection\x20is\x20not\x20written.\x20Existing\x20output\x20files\x20are\x20refused.\n\x20\x20\x20\x20Returns\x20the\x20manifest\x20that\x20was\x20written.\n\x20\x20\x20\x20\"\"\"\n\x20\x20\x20\x20available\x20=\x20{n.name:\x20n.id\x20for\x20n\x20in\x20col.decks.all_names_and_ids()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if\x20col.decks.get(n.id,\x20default=False)['dyn']}\n\x20\x20\x20\x20if\x20names\x20is\x20None:\n\x20\x20\x20\x20\x20\x20\x20\x20selected\x20=\x20list(available)\n\x20\x20\x20\x20elif\x20type(names)\x20is\x20list\x20and\x20names\x20and\x20all(isinstance(n,\x20str)\x20for\x20n\x20in\x20names):\n\x20\x20\x20\x20\x20\x20\x20\x20selected\x20=\x20names\n\x20\x20\x20\x20else:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError('names\x20must\x20be\x20None\x20or\x20a\x20nonempty\x20list\x20of\x20exact\x20deck\x20names')\n\x20\x20\x20\x20if\x20len(selected)\x20!=\x20len(set(selected)):\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError('Duplicate\x20names\x20in\x20selection')\n\x20\x20\x20\x20missing\x20=\x20[name\x20for\x20name\x20in\x20selected\x20if\x20name\x20not\x20in\x20available]\n\x20\x20\x20\x20if\x20missing:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Not\x20existing\x20filtered\x20deck\x20names:\x20{missing}')\n\x20\x20\x20\x20manifest\x20=\x20{'schema':\x20SCHEMA,\x20'version':\x20VERSION,\x20'templates':\x20[]}\n\x20\x20\x20\x20for\x20name\x20in\x20selected:\n\x20\x20\x20\x20\x20\x20\x20\x20saved\x20=\x20col.sched.get_or_create_filtered_deck(DeckId(available[name]))\n\x20\x20\x20\x20\x20\x20\x20\x20manifest['templates'].append({'name':\x20saved.name,\x20'config':\x20_config_json(saved.config)})\n\x20\x20\x20\x20_manifest(col,\x20manifest)\x20\x20#\x20No\x20incomplete\x20output\x20when\x20one\x20selection\x20is\x20invalid.\n\x20\x20\x20\x20text\x20=\x20json.dumps(manifest,\x20ensure_ascii=False,\x20allow_nan=False,\x20indent=2)\x20+\x20'\\n'\n\x20\x20\x20\x20with\x20Path(path).open('x',\x20encoding='utf-8')\x20as\x20stream:\n\x20\x20\x20\x20\x20\x20\x20\x20stream.write(text)\n\x20\x20\x20\x20return\x20manifest\n\n\ndef\x20import_filtered_templates(col,\x20path,\x20*,\x20prefix='',\x20apply=False):\n\x20\x20\x20\x20\"\"\"Validate\x20the\x20whole\x20file\x20first;\x20apply=False\x20never\x20creates/builds\x20decks.\n\n\x20\x20\x20\x20apply=True\x20creates\x20NEW\x20IDs\x20in\x20manifest\x20order\x20and\x20immediately\x20builds\x20from\n\x20\x20\x20\x20local\x20cards.\x20Prefix\x20alters\x20deck\x20names\x20only.\x20Backend-normalized\x20queries\x20are\n\x20\x20\x20\x20shown\x20in\x20the\x20plan\x20and\x20checked\x20on\x20readback;\x20original\x20query\x20text\x20is\x20submitted.\n\x20\x20\x20\x20On\x20execution\x20failure,\x20PartialImportError.partial_report\x20lists\x20created\x20IDs.\n\x20\x20\x20\x20Earlier\x20successful\x20operations\x20remain;\x20no\x20automatic\x20Empty/delete\x20is\x20done.\n\x20\x20\x20\x20\"\"\"\n\x20\x20\x20\x20if\x20type(apply)\x20is\x20not\x20bool\x20or\x20not\x20isinstance(prefix,\x20str):\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError('apply\x20must\x20be\x20a\x20bool\x20and\x20prefix\x20must\x20be\x20a\x20string')\n\x20\x20\x20\x20with\x20Path(path).open(encoding='utf-8-sig')\x20as\x20stream:\n\x20\x20\x20\x20\x20\x20\x20\x20data\x20=\x20json.load(stream,\x20object_pairs_hook=_pairs,\x20parse_constant=_bad_constant)\n\x20\x20\x20\x20parsed\x20=\x20_manifest(col,\x20data)\n\x20\x20\x20\x20existing\x20=\x20{n.name.casefold():\x20n.id\x20for\x20n\x20in\x20col.decks.all_names_and_ids()}\n\x20\x20\x20\x20original_ids\x20=\x20set(existing.values())\n\x20\x20\x20\x20planned\x20=\x20[_name(prefix\x20+\x20name)\x20for\x20name,\x20_,\x20_\x20in\x20parsed]\n\x20\x20\x20\x20folded\x20=\x20{name.casefold()\x20for\x20name\x20in\x20planned}\n\x20\x20\x20\x20if\x20len(folded)\x20!=\x20len(planned):\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError('Destination\x20names\x20collide\x20after\x20adding\x20prefix')\n\x20\x20\x20\x20for\x20name\x20in\x20planned:\n\x20\x20\x20\x20\x20\x20\x20\x20if\x20col.decks.id_for_name(name)\x20is\x20not\x20None:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Destination\x20name\x20already\x20exists:\x20{name}')\n\x20\x20\x20\x20\x20\x20\x20\x20components\x20=\x20name.split('::')\n\x20\x20\x20\x20\x20\x20\x20\x20for\x20length\x20in\x20range(1,\x20len(components)):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20parent\x20=\x20'::'.join(components[:length])\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20pid\x20=\x20col.decks.id_for_name(parent)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if\x20parent.casefold()\x20in\x20folded\x20or\x20(pid\x20is\x20not\x20None\x20and\x20col.decks.get(pid)['dyn']):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Filtered\x20decks\x20cannot\x20be\x20parents:\x20{parent}')\n\x20\x20\x20\x20report\x20=\x20{'applied':\x20False,\x20'templates':\x20[],\x20'created':\x20[],\x20'new_parent_decks':\x20[],\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'before':\x20_counts(col),\x20'warnings':\x20[\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'Apply\x20immediately\x20builds\x20from\x20destination-local\x20cards\x20in\x20manifest\x20order.',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'Earlier\x20filtered\x20builds\x20take\x20precedence\x20for\x20overlapping\x20cards.',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'The\x20batch\x20is\x20not\x20atomic;\x20no\x20automatic\x20rollback\x20or\x20promised\x20single\x20Undo.',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'Prefix\x20changes\x20filtered\x20names\x20only;\x20search\x20strings\x20keep\x20their\x20original\x20targets.'],\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'recovery':\x20[\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'Inspect\x20created\x20IDs\x20and\x20names\x20before\x20any\x20recovery\x20action.',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'To\x20return\x20cards,\x20use\x20Empty\x20on\x20ONLY\x20those\x20newly\x20created\x20filtered\x20decks.',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'Verify\x20each\x20is\x20empty,\x20then\x20delete\x20only\x20those\x20new\x20filtered\x20decks\x20if\x20unwanted.',\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'Do\x20not\x20delete\x20normal\x20parent\x20decks\x20that\x20contain\x20other\x20decks\x20or\x20cards.']}\n\x20\x20\x20\x20for\x20name,\x20(_,\x20config,\x20normalized)\x20in\x20zip(planned,\x20parsed):\n\x20\x20\x20\x20\x20\x20\x20\x20report['templates'].append({'name':\x20name,\x20'config':\x20_config_json(config),\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20'normalized_searches':\x20normalized})\n\x20\x20\x20\x20\x20\x20\x20\x20if\x20normalized\x20!=\x20[term.search\x20for\x20term\x20in\x20config.search_terms]:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20report['warnings'].append(f'{name}:\x20backend\x20will\x20normalize\x20search\x20spelling;\x20see\x20normalized_searches.')\n\x20\x20\x20\x20if\x20not\x20apply:\n\x20\x20\x20\x20\x20\x20\x20\x20report['after']\x20=\x20_counts(col)\n\x20\x20\x20\x20\x20\x20\x20\x20return\x20report\n\x20\x20\x20\x20report['applied']\x20=\x20True\n\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20for\x20item,\x20(_,\x20config,\x20normalized)\x20in\x20zip(report['templates'],\x20parsed):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if\x20col.decks.id_for_name(item['name'])\x20is\x20not\x20None:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20ValueError(f'Name\x20appeared\x20after\x20preflight:\x20{item[\"name\"]}')\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20new\x20=\x20col.sched.get_or_create_filtered_deck(DeckId(0))\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20new.name,\x20new.allow_empty\x20=\x20item['name'],\x20True\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20new.config.Clear()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20new.config.CopyFrom(config)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20did\x20=\x20int(col.sched.add_or_update_filtered_deck(new).id)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if\x20did\x20in\x20original_ids:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20RuntimeError(f'Backend\x20unexpectedly\x20reused\x20existing\x20ID\x20{did}')\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20row\x20=\x20{'id':\x20did,\x20'name':\x20item['name']}\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20report['created'].append(row)\x20\x20#\x20Retain\x20the\x20ID\x20even\x20if\x20verification\x20fails.\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20actual\x20=\x20col.sched.get_or_create_filtered_deck(DeckId(did))\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20row.update(name=actual.name,\x20config=_config_json(actual.config),\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20card_count=int(col.db.scalar('select\x20count(*)\x20from\x20cards\x20where\x20did=?',\x20did)))\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20expected\x20=\x20FilteredDeckConfig()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20expected.CopyFrom(config)\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for\x20term,\x20search\x20in\x20zip(expected.search_terms,\x20normalized):\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20term.search\x20=\x20search\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if\x20actual.name\x20!=\x20item['name']\x20or\x20actual.config\x20!=\x20expected:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20RuntimeError(f'Readback\x20mismatch\x20for\x20new\x20filtered\x20deck\x20{did};\x20inspect\x20partial_report')\n\x20\x20\x20\x20\x20\x20\x20\x20report['after']\x20=\x20_counts(col)\n\x20\x20\x20\x20\x20\x20\x20\x20if\x20report['before']\x20!=\x20report['after']:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20raise\x20RuntimeError('Card/revlog\x20totals\x20changed\x20during\x20import;\x20inspect\x20partial_report')\n\x20\x20\x20\x20except\x20Exception\x20as\x20error:\n\x20\x20\x20\x20\x20\x20\x20\x20raise\x20PartialImportError(str(error),\x20report)\x20from\x20error\n\x20\x20\x20\x20finally:\n\x20\x20\x20\x20\x20\x20\x20\x20try:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20created_ids\x20=\x20{row['id']\x20for\x20row\x20in\x20report['created']}\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20report['new_parent_decks']\x20=\x20[{'id':\x20n.id,\x20'name':\x20n.name}\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20for\x20n\x20in\x20col.decks.all_names_and_ids()\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20if\x20n.id\x20not\x20in\x20original_ids\x20and\x20n.id\x20not\x20in\x20created_ids]\n\x20\x20\x20\x20\x20\x20\x20\x20except\x20Exception\x20as\x20error:\n\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20report['inspection_error']\x20=\x20str(error)\n\x20\x20\x20\x20return\x20report\n")
在 Anki 桌面版中打开来源用户档案,并返回主窗口。Windows/Linux 使用 Ctrl+Shift+; 打开 Debug Console(调试控制台),macOS 使用 Command+Shift+;。部分键盘布局需要用 : 或 + 代替 ;。将命令粘贴到上方窗格后,按 Ctrl+Return 或 Command+Return 执行,不要按住 Shift。这些快捷键来自官方控制台说明;下文的验证没有实际操作图形界面的控制台。
使用下面的命令加载文件,并将示例替换为文件的完整路径。Windows 的引号内路径也可以使用正斜杠,例如 C:/Users/YOU/Downloads/anki_filtered_templates.py。mw.col 对象指向这个 Anki 用户档案当前打开的集合;不要用另行安装的 Python 进程去打开正在使用的集合文件。
from pathlib import Path; from aqt import mw; exec(Path("/FULL/PATH/anki_filtered_templates.py").read_text(encoding="utf-8"))
3. 准备卡片之前,先导出牌组定义
在已有文件夹中选择一个尚不存在的 JSON 文件名。下面的命令会按名称顺序导出当前所有筛选牌组,并打印导出数量。导出操作只读取集合,并会拒绝覆盖已有输出文件。请按自己的电脑修改路径。
manifest = export_filtered_templates(mw.col, "/FULL/PATH/templates.json"); print(len(manifest["templates"]))
如果只导出选定的一组牌组,请按希望构建的顺序提供准确名称。例如:manifest = export_filtered_templates(mw.col, "/FULL/PATH/selected-templates.json", names=["Morning", "Exam"])。普通牌组名称、不存在的名称、重复名称和空选择都会被拒绝。示例中的名称只是占位值,请替换为你实际的筛选牌组名称。
用文本编辑器打开 JSON。顶层字段为 schema、version 和 templates;每个模板都有 name 和 config。在 search_terms 中,每组搜索条件都包含查询、limit 和 order。请核对存在时的第二组条件、reschedule 和各项预览值。limit 为 0 时仍保留为 0,reschedule=false 也仍保留为 false。请保留生成的字段,不要因为某个值看起来像默认值就将其删除。
保留一份原始文件副本。调整模板顺序会改变构建顺序;修改查询会改变能收集到的目标卡片。如果来源模板的查询为空,导出会停止,并要求提供明确的查询。只有确实打算搜索所有牌组时才使用 deck:*,不要仅为了消除错误而填入它。
4. 按需另附不含调度信息的卡片包
如果接收者已有合适的卡片,可以跳过这一步。否则,在导出 JSON 后,对每个持有所需卡片的来源筛选牌组执行 Empty(清空),让卡片回到准备导出的普通归属牌组。Empty 会把卡片送回原处,不会删除卡片。这一步会有意改变卡片在来源集合中的当前所属牌组;辅助程序不会替你执行。
将普通归属牌组连同需要的子牌组,导出为 Anki Deck Package(.apkg)。关闭 Include Scheduling(包含调度信息),并根据卡片需要决定是否包含媒体。不要换成整库的 .colpkg。Anki 的普通牌组预设选项,与 JSON 中提供的筛选定义是两回事。
这个顺序很重要。在真实后端测试中,如果卡片仍被该归属牌组层级之外的筛选牌组借用,此时导出归属牌组,会漏掉三张预期卡片中的一张。一个仍有卡片的嵌套筛选牌组,在第一个包中变成了普通牌组。清空来源筛选牌组后,新包包含了普通归属牌组中的全部三张卡片,也不再包含已经为空的筛选牌组定义。检查两个包内部的集合数据库后,确认它们的复习日志行数都为零。
接收者先导入 APKG,再导入 JSON,并核对预期卡片、标签和归属牌组名称。合并不保证现有笔记保持不变:在已测试的 GUID 匹配情形中,较新的来源笔记字段和标签会根据导入设置更新,而已有的目标卡片记录和复习日志行保持不变。如果接收者已经有相关卡片,请检查这些设置和导入结果。仅含设置的 JSON,其影响范围比另行导入的卡片包更窄。
5. 在接收者的用户档案中预检整个文件
切换到预期的目标用户档案,重新打开控制台,并用第 2 步的命令加载程序。如果前缀有助于区分新建的筛选视图,可以设置一个。Shared:: 会生成 Shared::Morning 这样的名称;Shared 必须是普通父牌组,实际导入时可能会新建这个普通父牌组。预检本身不会创建任何牌组。
plan = import_filtered_templates(mw.col, "/FULL/PATH/templates.json", prefix="Shared::"); pp(plan)
逐一阅读计划中的 name 和 config,以及 normalized_searches 和 warnings。程序会先验证全部条目,再创建任何牌组:无效 JSON、重复键、未知字段或排序值、格式错误的搜索式、空搜索式、无效名称和目标名称冲突都会被拒绝。每个模板必须有一组或两组非空搜索条件。名称已存在时会直接拒绝,不会静默覆盖,也不会自动加上 + 后缀继续创建。
前缀不会改写查询中的 deck:Course、tag:chapter1 或其他内容。cid: 卡片 ID 搜索和 nid: 笔记 ID 搜索都会保留来源 ID,但该 ID 在另一个集合中未必指向你想要的记录。请确认这些查询目标对应正确的目标记录;也可以主动编辑一份 JSON 工作副本,再重新预检。Anki 可能规范化有效搜索式的写法。计划中会显示规范化结果,导入器也会在创建后据此核对实际保存的查询。
预检检查配置和搜索语法,不检查最终能收集到哪些卡片,也不涵盖每种调度器特有的构建条件。有效查询也可能匹配不到符合资格的卡片。接收者的调度器设置还可能与发送者不同;下文说明了可提取性排序在测试中暴露的边界。使用 apply=True 之前,请先解决名称或语法错误。
6. 应用一次,再验证新建牌组
确认报告正确后,用相同的 JSON 路径和前缀执行下面的命令。这段封装命令会打印完成结果或部分失败报告,并请求刷新 Anki 界面。应用操作会有意修改目标集合,请不要误在来源用户档案中执行。
exec("try:\n\x20\x20\x20\x20result\x20=\x20import_filtered_templates(mw.col,\x20'/FULL/PATH/templates.json',\x20prefix='Shared::',\x20apply=True)\nexcept\x20PartialImportError\x20as\x20error:\n\x20\x20\x20\x20pp(error.partial_report)\n\x20\x20\x20\x20mw.reset()\n\x20\x20\x20\x20raise\nelse:\n\x20\x20\x20\x20pp(result)\n\x20\x20\x20\x20mw.reset()\n")
逐项检查 created 中的 id、name、card_count 和 config。程序始终请求新的筛选牌组 ID,并回读结果。它会核对实际保存的名称和配置,同时接受计划中已显示的查询规范化结果。before 与 after 中的卡片和复习日志总数应一致:这项操作会重新分配符合条件的本地卡片,不会导入来源卡片或复习历史。
打开新筛选牌组,将搜索条件、数量限制、选取顺序、是否重新调度及预览延迟与 JSON 对照。检查一张已知应当匹配的卡片及其原归属牌组。全局总数相等,并不能单独证明查询选中了你想要的卡片。辅助程序允许结果为空,因此筛选视图没有卡片也可能是正常情况。
将 JSON 和打印出的结果保存在一起。以后如果需要再建一组,请使用新名称或新前缀,并重新预检。在目标名称未改变的情况下重复同一次导入会被拒绝;这个辅助程序不会就地更新已有牌组。
7. 理解重叠卡片、Empty 和部分失败
先构建的牌组优先获得重叠卡片。如果 Morning 收集了卡片 1、2、3,那么后构建的 Exam 就不能在它们仍留在 Morning 时再次收集卡片 2、3。图中 Exam 只收到卡片 4。这些是合成示例中的卡片标签,不是可直接运行的查询,也不是真实的 Anki 卡片 ID。现有筛选牌组同样可能持有原本符合新筛选条件的卡片。
常见的排除对象包括已暂停的卡片、已搁置的卡片,以及已经位于其他筛选牌组中的卡片;也可能还有其他资格规则。程序不会解除卡片暂停状态,也不会清空现有筛选牌组。遇到数量偏少或结果为零时,先检查实际查询、数量限制、卡片资格和构建顺序,再判断是否真的缺少数据。
reschedule 设置决定了学习这些卡片会产生什么影响。启用重新调度时,你的作答可能改变卡片的正常调度;预览模式则遵循其预览行为。迁移模板与之后实际学习卡片是不同的操作。开始复习前,请确认这个选项。
如果此前的模板已经成功,而后续 apply 失败,先前新建的牌组会保留下来。请阅读打印出的部分报告中的 created 和 new_parent_decks,并检查目标集合。如果决定放弃这些新视图,只对报告中列出的新筛选牌组执行 Empty。确认卡片已回到原归属牌组、这些视图也已为空后,再按需删除这些新筛选牌组。不要删除仍包含其他卡片或牌组的普通父牌组。
程序不会自动回滚整批操作,也不承诺一次 Undo(撤销)就能全部恢复。清空新筛选牌组会让卡片返回原处,但不会删除新建的牌组记录、抹去后续学习、撤回此前 APKG 导入对笔记的更新,或恢复集合中的所有设置。请先纠正根本问题,再预检剩余工作,不要盲目用相同名称重新执行。
8. 实际验证了什么,哪些仍取决于版本
常规形式的辅助程序,在临时集合中通过了使用真实 Anki 26.9.3 Python/Rust 后端运行的 21 项测试;该后端报告的应用版本为 26.09.3。测试涵盖 20 个模板的导出、导入和再次导出,两组搜索条件,明确的 false/0 值,准确的新 ID 请求,名称与语法错误,含学习历史的非空来源集合,以及用于检查只读导出和预检的完整 SQLite 逻辑快照。测试通过后端实际作答,生成了真实格式的复习日志数据。
重叠卡片和 Empty 案例使用的是真实后端操作。部分失败测试在第一次真实创建成功后,刻意让第二次创建抛出错误;它验证的是报告和此前工作如何保留,不代表自然故障的发生频率。另一位独立审稿人又运行了四个后端案例:第二项使用可提取性排序 8 时自然失败,后项与筛选父牌组冲突,后项使用未知枚举值,以及查询中包含合成来源集合里的实际卡片 ID。自然失败保留了第一个已创建牌组,并正确报告;两个预检失败案例都未改变任何 SQLite 表。卡片 ID 案例保留了原始 cid: 查询,在空目标中成功导入,却没有收集到卡片,说明语法有效不等于能选中预期目标卡片。另行进行的真实 APKG 测试核对了普通卡片分享、GUID 匹配时的合并,以及修正后的先 Empty 再导出顺序,随后将该卡片包与本篇使用的这一版辅助程序配对验证。
初始测试准备中出现了一个具体的兼容性限制:在未启用 FSRS 的新集合中构建来源测试牌组时,RETRIEVABILITY_ASCENDING(排序值 8)在 Anki 内部失败,当时还没有运行辅助程序。最终的 20 模板往返测试使用了能够正常工作的排序值 0 至 7。全部 11 种已定义排序的名称和数值都通过了只读预检;这不等于全部 11 种都能在每一种调度器配置下成功构建。如果你的排序失败,请检查错误及此前已创建的牌组,再选择兼容的排序,或有意调整目标设置以与所需环境一致,然后重试。
程序使用 Anki 的调度器和配置 API,这些 API 可能随版本变化。它复制的是已测试版本定义的七个字段,不承诺支持未来未知的选项。本次没有测试 Anki 图形界面、私人用户档案、移动应用、跨平台组合或大型实际使用集合。请保留源代码、JSON 和结果,并在更换 Anki 版本时重新进行有意义的检查。
