科技 · 實用指南
分享 Anki 篩選牌組範本,不附帶學習歷史
將現有的搜尋條件與偏好設定另存為 JSON,再於另一個 Anki 集合中重建。完整的本機輔助程式會在建立新篩選牌組前,先檢查整批設定。

如果你有一組 Anki 篩選牌組,希望讓別人重複使用,請將它們的定義與卡片分開分享。下方輔助程式會將牌組名稱、搜尋條件、數量上限、選取順序與篩選牌組偏好匯出為 JSON。接收方可以先檢查檔案,再使用自己集合中符合資格的卡片,重建整批牌組;這份 JSON 不會把你的卡片紀錄或複習紀錄傳給對方。
這是一套進階的 Anki Desktop 操作流程,已在可拋棄的測試集合中,使用真正的 Anki 26.09.3 Python/Rust 後端驗證。以 apply=True 匯入設定,會立即建立新的篩選牌組,並將符合條件的本機卡片移入其中。套用變更前,請先閱讀事前檢查報告,確認使用正確的使用者設定檔,並保留最新備份。下方提供完整輔助程式,不需要另行安裝 Python。
1. 選擇真正需要的轉移方式
如果對方想在另一個使用者設定檔中取得你的整個集合,包含排程資料,現有的集合套件 .colpkg 就是合適選項。匯入它會取代目前集合中的卡片。它不符合只傳送篩選定義,或交付時不包含你累積學習歷史的需求。
一般牌組套件 .apkg 可以將卡片加入另一個集合,Anki 也提供不包含排程的選項。這種套件適合傳送卡片,但不應視為可靠的篩選牌組定義傳輸方式。CrowdAnki 也明確將篩選牌組排除在 JSON 匯出之外。有些工具能根據標籤建立篩選牌組,或重建現有篩選牌組,這些功能各有用處;但兩者都不會轉移你選定的一批既有完整定義。
如果要重複使用的是已設定好的篩選牌組,請使用本指南提供的輔助程式。它會保留目前的七個設定欄位,包括兩組搜尋條件、明確設為 false 與 0 的值、目前的預覽延遲,以及較舊的已儲存延遲欄位。複製舊欄位不會讓舊排程器啟用。一般牌組的預設設定、FSRS 設定、附加元件設定、媒體與卡片內容,都不在這份 JSON 格式的範圍內。
檔案仍包含你選定的牌組名稱及查詢原文,也包含你放進搜尋條件的任何 ID。分享前請先閱讀,必要時移除私密名稱或字詞。不含卡片與複習歷史,並不代表這些字串已經匿名化。
2. 在 Anki Desktop 儲存並載入完整輔助程式
將下方完整的 exec(...) 單行內容,儲存成名為 anki_filtered_templates.py 的 UTF-8 純文字檔案。它包含整個程式;經跳脫處理的換行與空格,能在從本頁複製時保留 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 Desktop 開啟來源使用者設定檔,回到主視窗。在 Windows/Linux 按 Ctrl+Shift+;,或在 macOS 按 Command+Shift+;,開啟 Debug Console。某些鍵盤配置使用 : 或 +,而不是 ;。將指令貼到上方區域,再按 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。請保留產生的欄位,不要因為某個值看起來像預設值,就將它刪除。
保留原始檔案的副本。重新排列 templates 會改變建立順序;修改查詢會改變能收集哪些目標卡片。如果來源範本的查詢空白,匯出會停止,並要求提供明確的查詢。只有確實打算涵蓋所有牌組時,才使用 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、重複鍵、未知欄位或 order 值、格式錯誤的搜尋、空白搜尋、無效名稱,以及目的地名稱衝突。每個範本必須有一或兩組非空白搜尋。遇到已存在的名稱時會拒絕,不會默默覆寫,也不會自動加上 + 後綴後接受。
前綴不會改寫 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,並重新讀取結果。它會核對已儲存的名稱與設定,同時允許計畫中顯示的查詢正規化。前後的 card/revlog 總數應該一致:這個操作重新安排的是符合資格的本機卡片,不會匯入來源卡片或複習歷史。
開啟新的篩選牌組,將搜尋條件、數量上限、選取順序、是否重新排程,以及預覽延遲,逐一與 JSON 比對。檢查一張已知符合條件的卡片及其原屬牌組。只有全域數量一致,不能證明查詢選中了你想要的卡片。篩選檢視可以合理地為空,因為輔助程式允許空結果。
將 JSON 與印出的結果放在一起保留。如果之後想建立另一組,請選擇新名稱或新前綴,再次進行事前檢查。若目的地名稱未變,重複匯入相同內容會被拒絕;輔助程式不會就地更新既有牌組。
7. 了解卡片重疊、Empty 與部分失敗
遇到重疊卡片時,先建立內容的牌組有優先權。如果 Morning 收集了卡片 1、2、3,之後建立的 Exam 就不能在 2、3 仍留在 Morning 時,也收集它們。示意圖中,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 案例使用實際後端操作。部分失敗測試是在第一次真實建立成功後,刻意於第二次建立注入錯誤;它驗證的是報告及先前成果的保留,不是自然失敗發生的頻率。另一位審查者另外執行四個後端案例:第二項使用可回憶率 order 8 時自然失敗、後續項目發生篩選父牌組衝突、後續項目含有未知列舉值,以及查詢包含合成來源集合中的實際卡片 ID。自然失敗保留了第一個已建立牌組,並正確列入報告;兩個事前檢查失敗案例則讓所有 SQLite 資料表保持不變。卡片 ID 案例保留了原文的 cid: 查詢,並成功匯入空的目標集合,收集到的卡片為零,說明語法有效並不保證能取得預期的目標卡片。另有實際 APKG 測試,檢查一般卡片分享、GUID 相符時的合併,以及修正後先 Empty 再匯出的順序,接著將該套件與這份完全相同的輔助程式搭配驗證。
最初測試環境出現了一項明確的相容性限制:在未啟用 FSRS 的新集合中建立來源測試資料時,RETRIEVABILITY_ASCENDING,也就是 order 8,在輔助程式執行前就於 Anki 內部失敗。最後的 20 範本往返測試,使用的是可運作的 order 0 到 7。全部 11 個已定義的 order 名稱與數值,都通過唯讀事前檢查;這不代表全部 11 個在每種排程器設定中都能成功建立。如果你的 order 失敗,請檢查錯誤與先前已建立的任何牌組,再選擇相容的 order,或刻意調整目的地設定使其一致,然後重試。
程式使用 Anki 的排程器與設定 API,這些介面可能隨版本改變。它複製的是已測試版本所定義的七個欄位,不宣稱支援未來的未知選項。未測試 Anki 圖形介面、私人使用者設定檔、行動應用程式、跨平台組合,或大型實際使用中的集合。請保留原始碼、JSON 與結果,並在更換 Anki 版本時,重新進行有意義的檢查。
