Proton Pass 导入器(Importer)开发指南:从数据读取到落库的完整实现路径

原创2026-10-11 23:58:091,841 阅读
文章标签:前端

Proton Pass 导入器(Importer)开发指南:从数据读取到落库的完整实现路径

本指南以 Proton Pass WebClients 仓库中 packages/pass/lib/import/guidelines.md 为骨架,结合 lib/import/ 下源码(types.ts、reader.ts、helpers/、providers/)与 UI/saga 消费端,系统讲解数据导入器的目录结构、核心类型模型、编码约定与新增 Provider 的完整流程。读完本文,你将掌握如何为 Proton Pass 新增一个密码管理器导入器(从枚举注册、图标配置、Reader 实现到测试用例),并理解导入数据从文件解析到批量落库的全链路工作原理。

一、导入器是什么:职责边界与整体架构

导入器(Importer)负责将不同密码管理器导出的原始文件(CSV、JSON、XML、1pux、ZIP、PGP 等)解析为 Proton Pass 可落库的统一数据模型。它的核心约定是:Reader 函数把原始文件内容映射为一个 ImportPayload 类型,后续的 vault 创建、文件夹规划、批量导入与文件附件上传全部交给 store 层与 saga 完成。

仓库中 lib/import 的目录结构(与文档一致,已按当前仓库实际内容核对):

packages/pass/lib/import/
├── helpers/
│   ├── csv.reader.ts / csv.reader.spec.ts   # 通用 CSV 解析(基于 PapaParse)
│   ├── zip.reader.ts / zip.reader.wasm.ts   # 通用 ZIP 解析(基于 @zip.js/zip.js + wasm)
│   ├── error.ts                             # ImportReaderError / ImportProviderError
│   ├── files.ts / files.spec.ts             # 文件附件挂载与导入文件名还原
│   ├── folders.ts / folders.spec.ts         # 文件夹树构建、限额规划、无文件夹模式拆分
│   ├── report.ts                            # ImportReport、计数与进度计算
│   └── transformers.ts                      # 各类条目的 ItemImportIntent 构造器
├── providers/
│   ├── 1password/    (1pif/1pux/zip 三种格式)
│   ├── bitwarden/    (json/zip)
│   ├── chromium/     (Brave/Chrome/Edge 共用)
│   ├── dashlane/     (csv/zip)
│   ├── enpass/       (json)
│   ├── firefox/      (csv)
│   ├── kaspersky/    (txt)
│   ├── keepass/      (xml)
│   ├── keeper/       (json)
│   ├── lastpass/     (csv)
│   ├── nordpass/     (csv)
│   ├── protonpass/   (csv/json/zip/pgp,含加解密工具)
│   ├── roboform/     (csv)
│   └── safari/       (csv)
├── guidelines.md     # 本文的骨架文档
├── reader.ts         # 主分发入口 importReader()
└── types.ts          # 核心类型与 Provider 注册表

每个 Provider 目录下都遵循同构的文件布局:{provider}.reader.ts、{provider}.reader.spec.ts、{provider}.types.ts、mocks/ 目录(存放真实格式的样例文件,如 lastpass.csv、keepass.xml、1password.1pux)。

二、核心类型模型:Reader 的输出契约

所有导入器的最终输出都收敛到 types.ts 中定义的三个关键类型:

2.1 ImportPayload:一次导入的顶层结果

export type ImportPayload = {
    vaults: ImportVault[];   // 本次导入要创建/写入的 vault 列表
    ignored: string[];       // 无法导入的条目,如 "[Login] My Bank"
    warnings: string[];      // 解析过程中的非致命告警
};

ImportReaderResult = ImportPayload & { fileReader?: ImportFileReader } 在 payload 基础上追加一个可选的文件读取句柄——当导入文件是 ZIP 且包含文件附件时(如 1Password、Enpass、Bitwarden),由 readZIP() 返回的 ImportFileReader 提供 files/dirs 集合与 getFile(filename) 按需解压能力,供 UI 层的文件导入阶段使用。

2.2 ImportVault 与 ImportFolder:层级结构

export type ImportVault = {
    shareId: MaybeNull<string>;   // shareId: null => 需要新建 vault
    name: string;
    folders: ImportFolder[];      // 父优先排序,保证按顺序创建
    items: ItemImportIntent[];
};

export type ImportFolder = {
    id: string;                   // 导入载荷内的局部 id,由 saga 解析为真实 FolderID
    name: string;
    parentId: MaybeNull<string>;  // 引用另一个 ImportFolder.id,null 为 vault 根目录
};

注意 shareId 的语义:Reader 阶段并不知道用户最终会把数据导入哪个 vault,因此默认置 null。saga 在执行时会为 shareId === null 的 vault 调用 createVaultForImport 新建 vault(描述固定为 "Imported on {date}"),并在导入完成后把局部 ImportFolder.id 解析为服务器端 FolderID。

2.3 ImportReaderPayload:Reader 的输入

export type ImportReaderPayload = {
    file: File;
    provider: ImportProvider;
    userId?: string;
    options?: { currentAliases?: string[] };
    onPassphrase: () => Promise<string>;   // PGP 加密导出解密时向用户索要口令
};

onPassphrase 是回调式设计:PGP 解密是异步交互过程(弹出输入框让用户输入导出时设置的 passphrase),Reader 在需要时主动调用并 await 结果。

2.4 ItemImportIntent:条目的落库意图

各 ItemImportIntent 构造器集中在 helpers/transformers.ts,覆盖 Proton Pass 的全部条目类型:importLoginItem(登录项,含 autofillUrls、TOTP、passkeys 占位)、importNoteItem(笔记)、importCreditCardItem(信用卡)、importIdentityItem(身份)、importCustomItem(自定义)、importSshKeyItem(SSH 密钥)、importWifiItem(Wi-Fi)。构造器内部会:

  • 通过 obfuscateItem / obfuscateExtraFields 对敏感字段(密码、TOTP、SSH 私钥、笔记)做异或混淆,避免明文驻留内存;
  • 用 uniqueId() 生成 itemUuid;
  • 对 URL 调用 sanitizeAutofillUrls 做去重与净化(拒绝非法 scheme、超大 hostname,正则模式经 safe-regex2 校验);
  • 用 getEmailOrUsername 借助 PassUI.is_email_valid 区分邮箱与用户名;
  • 名称缺省时回退为 Unnamed item / Unnamed note 等兜底文案。

三、Provider 注册表:ImportProvider 枚举与 PROVIDER_INFO_MAP

types.ts 中定义了全部 18 个导入来源的枚举,以及与之配套的展示信息映射:

export enum ImportProvider {
    APPLEPASSWORDS = 'applepasswords',
    BITWARDEN = 'bitwarden',
    BRAVE = 'brave',
    CHROME = 'chrome',
    EDGE = 'edge',
    FIREFOX = 'firefox',
    KEEPASS = 'keepass',
    LASTPASS = 'lastpass',
    ONEPASSWORD = '1password',
    PROTONPASS = 'protonpass',
    DASHLANE = 'dashlane',
    SAFARI = 'safari',
    KEEPER = 'keeper',
    ROBOFORM = 'roboform',
    NORDPASS = 'nordpass',
    ENPASS = 'enpass',
    KASPERSKY = 'kaspersky',
    CSV = 'csv',
}

export const PROVIDER_INFO_MAP: Record<ImportProvider, { title: string; fileExtension: string; tutorialUrl: string }> = {
    [ImportProvider.BITWARDEN]: { title: 'Bitwarden', fileExtension: 'json, zip', tutorialUrl: 'https://proton.me/support/pass-import-bitwarden' },
    [ImportProvider.ONEPASSWORD]: { title: '1Password', fileExtension: '1pux, 1pif, zip', tutorialUrl: 'https://proton.me/support/pass-import-1password' },
    [ImportProvider.KEEPASS]: { title: 'KeePass', fileExtension: 'xml', tutorialUrl: 'https://proton.me/support/pass-import-keepass' },
    [ImportProvider.PROTONPASS]: { title: PASS_APP_NAME, fileExtension: 'zip, pgp, csv', tutorialUrl: '' },
    [ImportProvider.CSV]: { title: c('Label').t`Generic CSV`, fileExtension: 'csv', tutorialUrl: '' },
    // ... 其余 Provider 见源码
};

三个字段的用途分别是:

  • title:UI 上展示的厂商名(ProtonPass 使用 PASS_APP_NAME 常量,Generic CSV 走 ttag 翻译);
  • fileExtension:逗号分隔的受支持扩展名,直接驱动 UI 层 ImportForm.tsx 的 Dropzone 文件类型过滤与 <input accept> 属性;
  • tutorialUrl:指向对应厂商导出教程的链接,空串表示不展示 "How do I export my data from ...?" 引导链接(如 Proton Pass 自身的 pgp/zip 导出流程不在此列)。

此外 ImportProviderValues 是 UI 网格的渲染顺序:按名称字母序排序,但 Generic CSV 恒排最后(源码中 sort 回调对 'csv' 特判返回 ±1)。

四、编码约定(Conventions)逐条解读

原文档给出 6 条约定,结合源码逐一展开:

  1. 所有导入器放在 packages/pass/lib/import/providers,每个 Provider 一个独立文件夹。当前仓库已落地 14 个 Provider 目录(覆盖 18 个枚举值,因为 Brave/Chrome/Edge 共用一个 chromium 实现,Safari 与 Apple Passwords 共用 safari 实现),参见 providers/。

  2. 每个 Provider 目录必须包含 mock 文件。mocks/ 存放从各厂商导出的真实格式样例,例如 1password.1pux、bitwarden.json、lastpass.csv、keepass.xml、dashlane.zip、protonpass.pgp。这些文件同时被 .spec.ts 测试直接 fs.readFileSync 读取,保证测试覆盖真实数据而非合成数据。

  3. 导出工具函数必须带 Provider 名前缀。例如 lastpass.utils.ts 中的 extractLastPassExtraFields、extractLastPassIdentity、extractLastPassFieldValue,而不是泛化的 extractExtraFields。这样在 reader.ts 的 import 区域大量同名工具并存时不会产生命名冲突,也让函数归属一目了然。

  4. 文件结构定义在 {provider}.types.ts,类型应紧密贴合真实数据。文档特别注明"目前尚未实现数据校验"(no data-validation is implemented yet),因此类型定义必须反映各厂商导出的真实字段形态。以 lastpass.types.ts 为例:

export type LastPassItem = {
    url: Maybe<string>;
    username: Maybe<string>;
    password: Maybe<string>;
    totp: Maybe<string>;
    extra: Maybe<string>;     // 附加字段以 "Key:Value\n" 多行文本内嵌
    name: Maybe<string>;
    grouping: Maybe<string>;  // 反斜杠分隔的文件夹路径,用于划分 vault
    fav: Maybe<string>;
};

grouping 在 lastpass.reader.ts 中被 lastItem(grouping.split('\\')) 取末段作为 vault 名,实现"每个文件夹导出一个 vault"。而 KeePass 的 XML 结构则在 keepass.types.ts 中以 KeePassFile/KeePassGroup/KeePassEntry/KeePassEntryValue(支持字符串或 __text 对象两种形态)建模。

  1. Reader 函数把原始文件内容映射为 ImportPayload 类型。这是所有 Reader 的统一签名:async (file: File): Promise<ImportReaderResult>。内部职责链条为:读取文件文本 →(可选)通用解析器解析 → 遍历条目 → 用 transformers 构造 ItemImportIntent → 按来源结构分组为 ImportVault[] → 返回 { vaults, ignored, warnings }。

  2. (补充)统一错误处理:helpers/error.ts 定义了 ImportReaderError(解析层错误)与 ImportProviderError(Provider 级包装),后者会把内部错误翻译为 "{provider} file could not be parsed." 的用户可读文案。每个 Reader 的 catch 块都要 throw new ImportProviderError(providerName, e) 并 logger.warn 记录上下文,例如 keepass.reader.ts。

五、通用 Helper 详解:CSV、ZIP、文件夹与报告

5.1 readCSV:基于 PapaParse 的通用 CSV 解析

helpers/csv.reader.ts 是所有 CSV 类 Provider(LastPass、NordPass、Firefox、Safari、Roboform、Chrome 系)的公共底座,签名与行为如下:

export const readCSV = async <T extends Record<string, any>>(options: {
    data: string;            // 文件文本内容
    headers?: (keyof T)[];   // 期望的表头(用于校验与忽略判定)
    hasHeader?: boolean;
    onError?: (error: string) => void;   // 解析到损坏行时回调,写入 warnings
    throwOnEmpty?: boolean;  // 空文件是否直接抛错,默认 true
}): Promise<CSVReaderResult<T>>

解析细节(源码可验证):

  • 内部通过 import('papaparse') 动态加载(webpackChunkName csv.reader),配置 header: true、skipEmptyLines: true、delimiter: ','、newline: '\n',并对 \r 统一剔除;
  • 解析错误(Papa 的 errors 数组)会汇总为 "Detected N corrupted csv row(s)" 文案通过 onError 上报;
  • 行级校验逻辑:把每一行与 headers 比对,缺失期望表头的行被归入 ignored;如果所有行都缺表头,直接抛出 "CSV file is missing expected headers: ..." 错误;
  • 空文件在 throwOnEmpty: true 时抛出 "Empty CSV file"。

以 LastPass 为例,其期望表头常量位于 lastpass.utils.ts:['url', 'username', 'password', 'extra', 'name', 'grouping', 'fav'],并在 onError 中附加提示:"LastPass will export a corrupted CSV file if any of your item fields contain unexpected commas, quotes or multiple lines"。

5.2 readZIP:基于 @zip.js/zip.js 的 ZIP 读取

helpers/zip.reader.ts 返回 ImportFileReader 接口:

export interface ImportFileReader {
    files: Set<string>;            // 非目录条目清单
    dirs: Set<string>;             // 目录条目清单
    getFile(filename: string): Promise<MaybeNull<Blob>>;   // 按名取文件,失败返回 null
    close: () => void;             // 释放 reader 与 worker
}

实现要点:zip.configure({ useWebWorkers: false, useCompressionStream: false, wasmURI }) 关闭 worker/压缩流以适配浏览器扩展环境,用 wasm 解码;close() 同时调用 reader.close() 与 zip.terminateWorkers()。Dashlane 的 dashlane.zip.reader.ts、Proton Pass 的 protonpass.zip.reader.ts 均基于它构建,后者还会遍历 ZIP 内的 CSV/JSON 再走对应解析。

5.3 folders.ts:文件夹树与限额规划

helpers/folders.ts 提供了三层能力:

  • createImportFolderTree:把扁平路径(如 A\B\C)转成父优先的 ImportFolder[],内部用嵌套 Map 建立 (parentId, name) -> id 索引防止同层同名碰撞;
  • planImportFolders:结合目标 share 已有文件夹与 FolderLimits(数量上限、子文件夹上限、嵌套深度上限)规划 create(需新建)/reuse(合并到同名已有)/redirect(超限时条目回落到最近祖先)三张表,并生成对应的中英文告警文案(见 getLimitWarning);
  • splitFoldersIntoVaults:当用户无文件夹权限(feature flag 关闭)时,把"一个文件夹一个 vault"退化为"每个有内容的文件夹拆成独立 vault",保持旧行为。

5.4 report.ts:导入报告与进度计算

helpers/report.ts 定义 ImportReport(含 total、ignored、ignoredFiles、warnings、error、importedAt、provider),其中 total + ignored.length 可反推导入总量。getImportCounts 统计 totalItems/totalFiles;computeProgress 按"每文件一个工作量单位 + 条目按 MAX_BATCH_PER_IMPORT_REQUEST 分批"的模型计算 0~100 进度(importItemsBatch 批量上限常量见 packages/pass/lib/constants)。

5.5 files.ts:附件文件名还原

helpers/files.ts 的 attachFilesToItem(item, files) 把文件列表挂到条目上;getImportFilename(path, provider) 负责把 ZIP 内厂商特有的命名还原为原始文件名——Proton Pass 导出的 {base}.{uuid}.{ext} 剥离 uuid(扩展名长度 ≥16 视为导出时追加的无扩展名处理),1Password 的 {documentId}__{base}.{ext} 剥离 documentId 前缀。

六、Reader 实现剖析:以 KeePass XML 与 LastPass CSV 为例

6.1 KeePass:XML 树递归转 vault

keepass.reader.ts 展示了非 CSV 格式的典型实现:

  1. file.text() 读入后,动态加载 x2js 以 { stripWhitespaces: false } 解析 XML(保留 Group C 这类条目中刻意存在的空格,spec 中有对应断言 password === 'a bbbb c ');
  2. entryToItem 用 reduce 遍历 Entry/String 键值对,按 Title/Notes/UserName/Password/URL/otp/TOTP Seed/TOTP Settings 分流,其余键全部落入 customFields,并依据 _ProtectInMemory 标记决定字段类型是 hidden 还是 text;
  3. TOTP 兼容处理:现代条目直接用 otpauth URI;遗留格式 TOTP Seed + TOTP Settings(形如 42;8)会拼装成标准 otpauth://totp/...?secret=...&period=...&digits=...,格式非法时仅 logger.warn 不中断导入;
  4. extractVaults 递归遍历 <Group>,每个含条目的 Group 递归地生成一个 ImportVault——这正好实现了文档中"组 → vault"的映射策略。

对应测试 keepass.reader.spec.ts 用 fs.readFileSync 加载 mocks/keepass.xml,断言 5 个 vault 的名称、条目字段(邮箱/用户名/密码/autofillUrls/TOTP URI/extraFields)与去混淆后的内容,并覆盖损坏文件抛错、空格保真、TOTP 新旧两种定义等边界场景。

6.2 LastPass:CSV 多类型分发

lastpass.reader.ts 演示了"一个厂商多种条目类型"的处理:

  • 先用 readCSV 读取并按 grouping 末段分组为 vault;
  • 对每条记录按 extra 字段中的 NoteType 分发到不同处理器:url !== 'http://sn' 的走 processLoginItem(登录);Credit Card → processCreditCardItem;Address → processIdentityItem(经 lastpass.utils.ts 的 LAST_PASS_IDENTITY_FIELD_MAP 把 "First Name"、"Zip / Postal Code" 等厂商字段名映射到 Pass 的 IdentityFieldName);SSH Key → processSshKeyItem;Wi-Fi Password → processWifiItem;其余 → processCustomItem;
  • 单条处理失败不会中断整体:try/catch 后把条目推入 ignored(格式化输出 [Type] Name,见 formatIgnoredItem)并 logger.warn;
  • 信用卡过期日 "January, 2025" 会被 formatLastPassCCExpirationDate 规范化为 MMYYYY(Firefox 要求日期含日,内部补 15 再取 UTC 月年)。

七、主分发入口:importReader 与文件扩展名路由

reader.ts 的 importReader(payload) 是 UI 层唯一调用的入口(useImportForm 中 await importReader({...})),按 payload.provider 分发,并在需要时进一步按 extractFileExtension(file.name) 细分:

  • Bitwarden:json → readBitwardenData,zip → readBitwardenArchiveData,其余抛 "Unsupported Bitwarden file format";
  • Brave / Chrome / Edge:统一走 readChromiumData(三者 CSV 格式同源);
  • 1Password:1pif → read1Password1PifData,1pux → read1Password1PuxData,zip(含 1pif 归档)→ read1Password1PifArchiveData;
  • Proton Pass:csv → readProtonPassCSV;pgp → 先 onPassphrase() 取口令、decryptProtonPassImport 解密(utils.ts 中按文件头 { 判断解密结果是 JSON 还是整个 ZIP 加密的遗留格式),再分别走 JSON 或 ZIP 读取;zip → readProtonPassZIP(会读取包内 CSV/JSON 并复用当前 aliases 去重,currentAliases 由 UI 从 store 中所有别名邮箱传入);
  • Dashlane:csv 与 zip 两路;
  • Safari / Apple Passwords:共用 readSafariData;
  • Generic CSV:readProtonPassCSV(file, true)——第二个参数标记宽松模式,走 ImportForm.tsx 中的模板下载流程;
  • 其余 Provider 直接对应各自的 reader;默认分支抛 "Invalid password manager"。

所有分支的兜底错误文案均经 ttag 的 c('Error').t 做 i18n 翻译。

八、UI 消费侧:Provider 网格、图标与表单流程

导入表单的实现横跨三个文件:

  • ImportIcon.tsx:维护 IMPORT_ICON_MAP: Record<ImportProvider, string>,把每个 Provider 映射到 packages/pass/assets/import/ 下的 48px 图标(如 bitwarden-icon-48.png),渲染时以 PROVIDER_INFO_MAP[provider].title 作为 alt;
  • ImportProviderItem.tsx:单个可点击的网格项,展示图标 + 标题 + 支持的扩展名;
  • ImportForm.tsx:按 ImportProviderValues 渲染选择网格;选择后把 PROVIDER_INFO_MAP[provider].fileExtension.split(', ') 传给 Dropzone 做文件类型限制,并针对三个特例放宽 accept 属性——iOS(不支持扩展名 accept)、1Password(导出目录以 .1pif 结尾需允许选文件夹)、KeePass(KeePassXC 导出对话框可能不带 .xml 后缀,源码中还会 extensions.push('') 允许无扩展名文件)。

useImportForm.ts 完整展示了读取→确认→导入的编排(onSubmit 内的 4 步):

  1. 调用 importReader 得到 ImportReaderResult(内部触发 PGP 口令输入回调);
  2. 空载荷(!isNonEmptyImportPayload)抛 "The file you are trying to import is empty";无文件夹权限时用 splitFoldersIntoVaults 降级;随后 onWillSubmit 弹窗让用户选择目标 vault(可能改动 payload);
  3. setProgress({ ...getImportCounts(data), step: 'items' }) 后 doImportItems({ data, provider, tabId }) 发起 saga 导入;
  4. 成功后 step: 'files',若 fileReader 且报告中有文件,调用 useFileImporter 的 start(fileReader, files, report, signal) 上传附件;进度经 computeProgress 计算并做 250ms 防抖避免 UI 闪烁;支持 AbortController + requestCancel 取消,finally 中 reader?.close() 释放 ZIP 句柄。

九、数据落库:Import Saga 的完整流水线

packages/pass/store/sagas/import/import.saga.ts 与 actions/creators/import.ts 构成了读取之后的落库流水线:

  1. importItems 请求 action(requestActionsFactory 定义 import::items)携带 { data: ImportPayload, provider } 进入 saga;
  2. saga 先做特性门控净化:若 PassAutofillUrlAdvancedModes flag 未开,把非 URL 模式的 autofillUrls 全部降级为 AutofillMode.Default;
  3. 按 shareId 分组(groupByKey(..., 'shareId', { splitEmpty: true })):shareId 为空的 vault 通过 createVaultForImport 直接调用 vault 创建 worker(绕过 action 分发以获得同步 shareId);
  4. 文件夹权限开启时执行 importFoldersForVault(内部走 planImportFolders),逐个调用 createFolderWorker 按父先子后创建(子文件夹 key 需用父文件夹 key 加密),失败不中断——子树回落到最近成功创建的祖先,并在 folderWarnings 中汇总告警;同时用 assertNotAborted 支持取消;
  5. 条目按 chunk(vaultItems, MAX_BATCH_PER_IMPORT_REQUEST) 分批调用 importItemsBatch,成功条目的 itemUuid 从 pendingItems 移除,文件附件按 shareId/itemId 索引存入 filesForImport,并 put(importItemsProgress) 上报进度;
  6. 结束前推送 ImportCompletion 遥测事件(item_count、vaults、source: provider);
  7. 成功或失败(含取消)都会生成 ImportReport(失败时把未处理条目并入 ignored、未处理文件并入 ignoredFiles),取消遵循"当前批次完成后终止"的优雅策略(见 watcher 中 fork 的 matchCancel 监听)。

十、添加一个新 Import Provider 的完整步骤

原文档给出 6 个步骤,下面是结合源码的逐条落地说明:

步骤 1:在 ImportProvider 枚举中定义新 Provider

在 types.ts 的枚举中追加一行,如 MYMANAGER = 'mymanager'。枚举值是稳定标识,会进入遥测与导入报告,一旦发布不建议随意改名。

步骤 2:向 PROVIDER_INFO_MAP 添加 Provider 配置

在 types.ts 中补充:

[ImportProvider.MYMANAGER]: {
    title: 'MyManager',
    fileExtension: 'csv',            // 多个格式用 ", " 分隔
    tutorialUrl: '',                 // 或指向官方导出教程
},

该配置同时驱动 UI 网格、Dropzone 文件类型过滤、<input accept> 与 "How do I export..." 教程链接。若新厂商文件格式特殊(如 KeePass 的无扩展名问题),需同步在 ImportForm.tsx 的 onSelectProvider/FileInput 中加入特判。

步骤 3:在 ImportIcon.tsx 添加图标

在 ImportIcon.tsx 中新增 import 语句并把 48px 图标(放置于 packages/pass/assets/import/)注册进 IMPORT_ICON_MAP。注意 TypeScript 的 ImportIconMap 是 Record<ImportProvider, string>,漏注册会直接编译报错——这是类型系统强制"枚举与图标一一对应"的手段。

步骤 4:实现 Provider 专属的 reader 函数

在 providers/{provider-name}/ 下创建三个文件:

  • {provider}.types.ts:依据厂商导出的真实结构定义类型(现阶段不做运行时校验,类型只需贴合实际数据);
  • {provider}.reader.ts:导出 read{ProviderName}Data = async (file: File): Promise<ImportReaderResult>。CSV 类优先复用 readCSV + 设置期望表头;ZIP 类复用 readZIP;解析失败一律 throw new ImportProviderError('{ProviderName}', e),条目级失败写入 ignored;
  • 工具函数统一以 extract{ProviderName}... 前缀命名(如 extractLastPassExtraFields),条目构造复用 transformers.ts 中的 import*Item 系列。

步骤 5:添加带自定义导入 mock 的测试用例

在 {provider}.reader.spec.ts 中仿照 keepass.reader.spec.ts 与 lastpass.reader.spec.ts:把真实样例放入 mocks/,用 fs.readFileSync 构造 File 喂给 reader,再对 payload.vaults、items 的字段(经 deobfuscateItem 去混淆后)逐一断言;同时覆盖损坏文件抛错、空文件、缺表头等异常路径。

步骤 6:在主 fileReader 函数中注册分发

在 reader.ts 的 switch (payload.provider) 中新增 case ImportProvider.MYMANAGER: return readMyManagerData(file);。若新 Provider 支持多种格式,参照 Bitwarden/1Password/Proton Pass 的嵌套 switch (fileExtension) 写法,未知扩展名抛 Unsupported ... file format 错误。

完成上述 6 步后,新 Provider 会自动出现在导入 UI 网格、通过 Dropzone 文件过滤、被 saga 流水线消费,并进入 ImportReport 与 ImportCompletion 遥测——这就是本仓库"一次注册、全链路生效"的扩展模式。

十一、结语

Proton Pass 的导入器是一个高度约定化、可插拔的子系统:types.ts 是注册中心,reader.ts 是唯一入口,helpers/ 提供 CSV/ZIP/文件夹/报告等公共能力,providers/ 各目录按同一模板承载厂商差异,saga 与 UI 则完成从 ImportPayload 到服务器数据的最后一公里。理解本文的目录结构、核心类型与 6 步扩展流程后,为项目新增一个导入器的工作可以被压缩为一次纯粹的数据映射任务——而这正是 guidelines.md 希望传达的设计意图。

登录后查看全文
WebClients