diff --git a/README.md b/README.md index ba056ad..52840df 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ TFS 2013 工作项批量导入工具集,用于把 Excel/CSV 里的工时台账 | --- | --- | --- | | 通信协议 | TFS Client OM(VS/Team Explorer 同款内部 SOAP) | REST `/_apis/wit/*` | | 适用 | **本环境可用**。该 TFS 2013 实例只有 `/_apis/projects` 通,`wit/fields`、`wit/classificationnodes`、`wit/wiql` 全部 404 | 服务器开放了 wit REST 路由时使用 | -| 数据源 | `.xlsx`(内置模板) | `data/work_items.csv`(GBK/UTF-8 均可,需自备) | +| 数据源 | `.xlsx`(默认模板随程序发布) | `data/work_items.csv`(GBK/UTF-8 均可,需自备) | | 界面 | 图形界面,预览后导入 | 命令行,`--dry-run` 先校验 | Python 版在本环境会因 REST 路由 404 而失败,保留用于其它 TFS 实例或接口可用时的批量脚本化导入。 @@ -33,14 +33,12 @@ TFSImport/ ├── data/ │ └── work_items.example.csv # 列结构与示例行(UTF-8 BOM);真实台账不入库 ├── templates/ -│ ├── TFS导入模板.xlsx # Excel 模板成品 -│ ├── build_template.ps1 # 由 _parts 里的 XML 打包生成 xlsx -│ └── _parts/ # xlsx 的 OOXML 源文件 +│ └── TFS导入模板.xlsx # 默认模板:csproj 随构建拷到 exe 目录,「导出Excel模板」原样复制它 └── TfsExcelImporter/ # C# WinForms 桌面版(主用) ├── TfsExcelImporter.csproj # net48 + ClosedXML + TFS ExtendedClient 14.* ├── MainForm.cs # 界面与导入/修 Area 流程 ├── TfsClient.cs # TFS Client OM 封装 - ├── ExcelService.cs # 读 Excel、生成模板、时间/数值归一 + ├── ExcelService.cs # 读 Excel、导出默认模板、时间/数值归一 ├── Config.cs # exe 同目录 config.json 读写 └── config.json # 桌面版配置 ``` @@ -55,11 +53,13 @@ dotnet build TfsExcelImporter.sln -c Release TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe ``` +「导出Excel模板」复制的是 exe 同目录的 `TFS导入模板.xlsx`(构建时自动拷过去);单独分发 exe 时要带上这个文件,否则该按钮会报「未找到默认模板文件」。 + 操作流程: 1. 填「TFS 连接」区:URL、项目、工作项类型、认证方式与域账号;内网保持「使用系统代理」**不勾选**。 2. 点「加载迭代列表」,选择目标迭代。 -3. 点「生成Excel模板」拿到表头,或「浏览...」选择已有 `.xlsx`;点「预览数据」核对。 +3. 点「导出Excel模板」拿到默认模板,或「浏览...」选择已有 `.xlsx`;点「预览数据」核对。 4. 需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项)。 5. 点「开始导入 TFS」,日志区逐行输出 `OK/SKIP/FAIL` 与工作项 ID。 6. 历史数据 Area 不对时用「按迭代修Area」批量纠正。 @@ -89,14 +89,28 @@ python tfs_import.py --input other.csv --limit 5 | `auth.mode` | `ntlm`(域账号,典型)/ `basic` / `default`(当前 Windows 凭据,仅桌面版) | | `field_refs` | 语义名 → 字段引用名;自定义字段留空时按 `resolve_fields_by_display_name` 的显示名在服务器字段表里查(支持前缀模糊匹配,如 `Completed` → `Completed Work`) | | `parent_title` / `parent_id` | 父工作项,二选一;按标题匹配不到会直接报错并提示改用 `parent_id` | -| `bypass_rules` | 是否尝试写入 `CreatedDate`/`ClosedDate` 等系统日期 | +| `bypass_rules` | 是否请求绕过字段校验规则;桌面版走 Client OM(14.x 无 `SaveFlags.BypassRules`),实际由服务器规则决定 | | `skip_existing_titles` | 同项目/同类型/同迭代下按标题去重 | > 配置里含明文密码,请勿提交到共享仓库;`config.json` 保持空值,凭据本地填写。 ## Excel / CSV 列 -`Title`、`State`、`Assigned To`、`FinancialProject`、`Completed`、`LogDate`、`Created Date`、`Closed Date`、`PBI Id`、`Notes` +桌面版 Excel 模板 = 仓库里的 `templates\TFS导入模板.xlsx`,界面「导出Excel模板」直接复制它: + +`Title`、`State`、`Assigned To`、`FinancialProject`、`Completed`、`LogDate`、`PBI Id`、`Description` + +写入 TFS 的对应关系: + +| 模板列 | 目标字段 | +| --- | --- | +| `Description` | `System.Description` | +| `Completed` | 显示名 `Completed`(模糊匹配到 `Completed Work`)解析出的字段 | +| —(无列) | `Original Estimate` 取该行 `Completed` 的值;引用名留空则按显示名 `Original Estimate` 解析,可写在 `OriginalEstimateRef` 里固定 | + +系统日期列(`Created Date` / `Closed Date`)已从模板移除:`System.CreatedDate` 由服务器按导入时间填写,`Microsoft.VSTS.Common.ClosedDate` 不再写入、保持为空。 + +Python 版仍读 `data/work_items.example.csv` 的旧列(含 `CreatedDate`、`ClosedDate`、`Notes`),未随模板调整。 真实台账含人员与项目信息,不入库;本地准备数据文件: @@ -104,7 +118,7 @@ python tfs_import.py --input other.csv --limit 5 copy data\work_items.example.csv data\work_items.csv # Python 版默认读这个路径,已在 .gitignore 里 ``` -- 表头按名匹配,顺序不限,`AssignedTo` / `Assigned To` 等价;缺列自动按模板顺序兜底。 +- 表头按名匹配,顺序不限,`AssignedTo` / `Assigned To` 等价;缺列按模板顺序兜底,但该列已被别的表头占用时不套用位置(旧版模板文件不会串列)。 - 空 `Title` 的行直接跳过;`State` 为空时用 `default_state`;未识别的列(如 `Group`)忽略。 - 日期支持 `yyyy-MM-dd[ / ][ HH:mm[:ss]]` 等格式,Excel 日期序列号(如 `46300`)会被还原成日期。 - CSV 从 Excel 另存时注意编码,脚本按 `utf-8-sig` → `gbk` 依次尝试。 @@ -116,7 +130,7 @@ copy data\work_items.example.csv data\work_items.csv # Python 版默认读这 - **wit REST 路由全 404**:TFS 2013 RTM/低 Update 不带 `/_apis/wit/*`,`Classification.asmx?WSDL` 也被禁 → 桌面版整体走 Client OM(NuGet `Microsoft.TeamFoundationServer.ExtendedClient` 钉 `14.*`,对应 VS2015 客户端代际;15.x 不兼容 TFS 2013)。 - **502 Bad Gateway**:TFS OM 内部用 `HttpWebRequest`,默认跟随 IE 系统代理,代理会拦内网 TFS。桌面版默认把 `WebRequest.DefaultWebProxy` 置空直连;确实要走代理时勾「使用系统代理」并带上 Windows 凭据过代理认证。 - **TF400276 / 关闭状态只读**:改已关闭工作项的 Area 会被拒;`FixAreaAsync` 走「重新打开 → 改 Area → 还原状态」的状态循环,中途失败回滚状态。 -- **`System.CreatedDate` 写不进去**:OM 14.x 没有 `SaveFlags.BypassRules`(15.x 才加),该字段服务器托管;`Microsoft.VSTS.Common.ClosedDate` 在 `State=Done` 时尝试写入,校验或保存失败自动降级为普通保存,保证行不丢。 +- **`System.CreatedDate` 写不进去**:OM 14.x 没有 `SaveFlags.BypassRules`(15.x 才加),该字段服务器托管;`Microsoft.VSTS.Common.ClosedDate` 在 `State=Done` 时按普通字段可尝试写入。模板已去掉日期列,所以导入不再走这条路——若某模板要求 `Done` 必填 Closed Date,需另加列或改回写日期。 - **孤立 Task 在积压工作页不可见**:TFS 2013 的 backlog/任务板按「父项 → 子任务」组织,没挂父链接的 Task 只能在查询里看到,所以导入必须带 `parent_title`/`parent_id`。 - **DNS 解析失败 / 超时**:多为 VPN 或网络断开,不是代码问题;桌面版会对这类异常附加排查提示。 diff --git a/TfsExcelImporter/Config.cs b/TfsExcelImporter/Config.cs index 89f9eb9..e11c842 100644 --- a/TfsExcelImporter/Config.cs +++ b/TfsExcelImporter/Config.cs @@ -36,6 +36,8 @@ namespace TfsExcelImporter public string FinancialProjectRef { get; set; } = ""; public string CompletedRef { get; set; } = ""; public string LogDateRef { get; set; } = ""; + // Original Estimate 导入时取 Completed 的值;引用名留空则按显示名解析 + public string OriginalEstimateRef { get; set; } = ""; public string LastExcelPath { get; set; } = ""; diff --git a/TfsExcelImporter/ExcelService.cs b/TfsExcelImporter/ExcelService.cs index ce46e81..2faecb0 100644 --- a/TfsExcelImporter/ExcelService.cs +++ b/TfsExcelImporter/ExcelService.cs @@ -16,22 +16,23 @@ namespace TfsExcelImporter public string FinancialProject { get; set; } public string Completed { get; set; } public string LogDate { get; set; } - public string CreatedDate { get; set; } - public string ClosedDate { get; set; } public string PbiId { get; set; } - public string Notes { get; set; } + public string Description { get; set; } } /// - /// Excel 读取与模板生成。 - /// 模板列: Title | State | Assigned To | FinancialProject | Completed | LogDate | Created Date | Closed Date | PBI Id | Notes + /// Excel 读取与默认模板。模板内容(列宽、示例行、样式)只在 + /// templates\TFS导入模板.xlsx 里维护,随构建复制到 exe 目录后原样导出。 + /// 列: Title | State | Assigned To | FinancialProject | Completed | LogDate | PBI Id | Description /// public static class ExcelService { + public const string TemplateFileName = "TFS导入模板.xlsx"; + public static readonly string[] Headers = { "Title", "State", "Assigned To", "FinancialProject", - "Completed", "LogDate", "Created Date", "Closed Date", "PBI Id", "Notes" + "Completed", "LogDate", "PBI Id", "Description" }; private static readonly string[] DateFormats = @@ -41,68 +42,15 @@ namespace TfsExcelImporter "yyyy-MM-dd", "yyyy/MM/dd" }; - // --------------------------------------------------------- 生成模板 + // --------------------------------------------------------- 默认模板 - public static void GenerateTemplate(string path) + /// 把随程序发布的默认模板原样复制到目标路径。 + public static void CopyTemplate(string path) { - using (var wb = new XLWorkbook()) - { - var ws = wb.Worksheets.Add("工作项"); - - for (int i = 0; i < Headers.Length; i++) - { - var cell = ws.Cell(1, i + 1); - cell.Value = Headers[i]; - cell.Style.Font.Bold = true; - cell.Style.Fill.BackgroundColor = XLColor.LightGray; - } - - // 示例行(To Do / Done 各一,帮助理解格式) - var sample1 = new WorkItemRow - { - Title = "20261012 - 示例:货载报告解析并上传凭证受托报告相关内容", - State = "To Do", - AssignedTo = "李晓柯", - FinancialProject = "信托 - 上海信托", - Completed = "5", - LogDate = "2026-10-12", - CreatedDate = "2026-10-30 11:14:41", - ClosedDate = "", - Notes = "示例行,导入前请删除" - }; - var sample2 = new WorkItemRow - { - Title = "20261009 示例:受托报告bug修复", - State = "Done", - AssignedTo = "陈卫宇", - FinancialProject = "信托 - 上海信托", - Completed = "8", - LogDate = "2026-10-09", - CreatedDate = "2026-10-10 18:20:00", - ClosedDate = "2026-10-30 18:00:00", - Notes = "示例行,导入前请删除" - }; - WriteRow(ws, 2, sample1); - WriteRow(ws, 3, sample2); - - ws.Columns().AdjustToContents(); - ws.SheetView.FreezeRows(1); - wb.SaveAs(path); - } - } - - private static void WriteRow(IXLWorksheet ws, int r, WorkItemRow row) - { - ws.Cell(r, 1).Value = row.Title ?? ""; - ws.Cell(r, 2).Value = row.State ?? ""; - ws.Cell(r, 3).Value = row.AssignedTo ?? ""; - ws.Cell(r, 4).Value = row.FinancialProject ?? ""; - ws.Cell(r, 5).Value = row.Completed ?? ""; - ws.Cell(r, 6).Value = row.LogDate ?? ""; - ws.Cell(r, 7).Value = row.CreatedDate ?? ""; - ws.Cell(r, 8).Value = row.ClosedDate ?? ""; - ws.Cell(r, 9).Value = row.PbiId ?? ""; - ws.Cell(r, 10).Value = row.Notes ?? ""; + var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); + if (!File.Exists(src)) + throw new FileNotFoundException("未找到默认模板文件: " + src, src); + File.Copy(src, path, true); } // --------------------------------------------------------- 读取数据 @@ -132,10 +80,8 @@ namespace TfsExcelImporter FinancialProject = CellText(ws, r, headerMap, "FinancialProject"), Completed = CellText(ws, r, headerMap, "Completed"), LogDate = CellText(ws, r, headerMap, "LogDate"), - CreatedDate = CellText(ws, r, headerMap, "Created Date"), - ClosedDate = CellText(ws, r, headerMap, "Closed Date"), PbiId = CellText(ws, r, headerMap, "PBI Id"), - Notes = CellText(ws, r, headerMap, "Notes") + Description = CellText(ws, r, headerMap, "Description") }); } } @@ -151,7 +97,7 @@ namespace TfsExcelImporter { var text = ws.Cell(firstRow, c).GetString().Trim(); if (text.Length == 0) continue; - // 归一化:去掉空格/下划线,兼容 "Assigned To" / "AssignedTo" / "Created Date" 等 + // 归一化:去掉空格/下划线,兼容 "Assigned To" / "AssignedTo" / "PBI Id" 等 var key = text.Replace(" ", "").Replace("_", ""); if (!map.ContainsKey(key)) map[key] = c; } @@ -159,11 +105,12 @@ namespace TfsExcelImporter foreach (var h in Headers) { var key = h.Replace(" ", ""); - if (!map.ContainsKey(key)) - { - int idx = Array.IndexOf(Headers, h) + 1; - if (idx <= lastCol) map[key] = idx; - } + if (map.ContainsKey(key)) continue; + int idx = Array.IndexOf(Headers, h) + 1; + if (idx > lastCol) continue; + // 该列已有别的表头占用(如旧版模板的 Created Date 列)时不套用位置,避免读错列 + bool taken = map.Values.Any(c => c == idx); + if (!taken) map[key] = idx; } return map; } diff --git a/TfsExcelImporter/MainForm.cs b/TfsExcelImporter/MainForm.cs index 0d8c87d..4767823 100644 --- a/TfsExcelImporter/MainForm.cs +++ b/TfsExcelImporter/MainForm.cs @@ -144,10 +144,10 @@ namespace TfsExcelImporter _btnBrowse.Click += (s, e) => BrowseExcel(); grpImport.Controls.Add(_btnBrowse); - _btnTemplate.Text = "生成Excel模板"; + _btnTemplate.Text = "导出Excel模板"; _btnTemplate.Location = new Point(760, 58); _btnTemplate.Size = new Size(110, 29); _btnTemplate.Anchor = AnchorStyles.Top | AnchorStyles.Right; - _btnTemplate.Click += (s, e) => GenerateTemplate(); + _btnTemplate.Click += (s, e) => ExportTemplate(); grpImport.Controls.Add(_btnTemplate); _chkSkip.Text = "跳过已存在标题"; @@ -155,7 +155,7 @@ namespace TfsExcelImporter _chkSkip.Anchor = AnchorStyles.Top | AnchorStyles.Right; grpImport.Controls.Add(_chkSkip); - _chkBypass.Text = "bypassRules(可写系统日期)"; + _chkBypass.Text = "bypassRules(绕过字段规则)"; _chkBypass.Location = new Point(886, 60); _chkBypass.Size = new Size(150, 24); _chkBypass.Anchor = AnchorStyles.Top | AnchorStyles.Right; grpImport.Controls.Add(_chkBypass); @@ -357,25 +357,25 @@ namespace TfsExcelImporter } } - private void GenerateTemplate() + private void ExportTemplate() { using (var dlg = new SaveFileDialog()) { dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx"; - dlg.FileName = "TFS导入模板.xlsx"; - dlg.Title = "保存 Excel 模板"; + dlg.FileName = ExcelService.TemplateFileName; + dlg.Title = "导出默认模板"; if (dlg.ShowDialog(this) != DialogResult.OK) return; try { - ExcelService.GenerateTemplate(dlg.FileName); - Log("模板已生成: " + dlg.FileName); - MessageBox.Show(this, "模板已生成:\n" + dlg.FileName, "完成", + ExcelService.CopyTemplate(dlg.FileName); + Log("模板已导出: " + dlg.FileName); + MessageBox.Show(this, "模板已导出:\n" + dlg.FileName, "完成", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { - Log("生成模板失败: " + ex.Message); - MessageBox.Show(this, "生成模板失败:\n" + ex.Message, "错误", + Log("导出模板失败: " + ex.Message); + MessageBox.Show(this, "导出模板失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } } @@ -402,9 +402,8 @@ namespace TfsExcelImporter 财务项目 = r.FinancialProject, r.Completed, LogDate = r.LogDate, - Created = r.CreatedDate, - Closed = r.ClosedDate, - r.Notes + r.PbiId, + r.Description }).ToList(); _lblStatus.Text = $"已读取 {_rows.Count} 行"; Log($"读取 {path} 共 {_rows.Count} 行"); @@ -563,13 +562,12 @@ namespace TfsExcelImporter var assignedRef = "System.AssignedTo"; var iterationRef = "System.IterationPath"; var areaRef = "System.AreaPath"; - var createdRef = "System.CreatedDate"; - var closedRef = "Microsoft.VSTS.Common.ClosedDate"; var finRef = TfsClient.ResolveRef(fieldMap, "FinancialProject", _cfg.FinancialProjectRef); var compRef = TfsClient.ResolveRef(fieldMap, "Completed", _cfg.CompletedRef); var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef); - Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"}"); + var origEstRef = TfsClient.ResolveRef(fieldMap, "Original Estimate", _cfg.OriginalEstimateRef); + Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"} OriginalEstimate->{origEstRef ?? "(未找到)"}"); Log($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖"); HashSet existing = null; @@ -612,25 +610,17 @@ namespace TfsExcelImporter if (completed != null && compRef != null) fields.Add(new KeyValuePair(compRef, completed)); - // Notes -> Description(该 Task 模板必填) - if (!string.IsNullOrWhiteSpace(row.Notes)) - fields.Add(new KeyValuePair("System.Description", row.Notes.Trim())); + // Original Estimate 默认取 Completed 的值 + if (completed != null && origEstRef != null) + fields.Add(new KeyValuePair(origEstRef, completed)); + + if (!string.IsNullOrWhiteSpace(row.Description)) + fields.Add(new KeyValuePair("System.Description", row.Description.Trim())); var logDate = ExcelService.ToIsoDateTime(row.LogDate); if (logDate != null && logRef != null) fields.Add(new KeyValuePair(logRef, logDate)); - // 系统日期字段:bypassRules 时尝试写入 - if (_chkBypass.Checked) - { - var created = ExcelService.ToIsoDateTime(row.CreatedDate); - if (created != null) - fields.Add(new KeyValuePair(createdRef, created)); - var closed = ExcelService.ToIsoDateTime(row.ClosedDate); - if (closed != null) - fields.Add(new KeyValuePair(closedRef, closed)); - } - // 父 PBI:行级 PBI Id 列优先,其次界面默认值 var pbiText = !string.IsNullOrWhiteSpace(row.PbiId) ? row.PbiId.Trim() : _cfg.ParentPbiId; int? parentPbi = int.TryParse(pbiText, out var pbiNum) ? pbiNum : (int?)null; diff --git a/TfsExcelImporter/TfsExcelImporter.csproj b/TfsExcelImporter/TfsExcelImporter.csproj index ce48ffc..35b37b4 100644 --- a/TfsExcelImporter/TfsExcelImporter.csproj +++ b/TfsExcelImporter/TfsExcelImporter.csproj @@ -26,6 +26,10 @@ PreserveNewest + + + PreserveNewest + diff --git a/TfsExcelImporter/config.json b/TfsExcelImporter/config.json index 3aaa39b..0f4b896 100644 --- a/TfsExcelImporter/config.json +++ b/TfsExcelImporter/config.json @@ -15,6 +15,7 @@ "FinancialProjectRef": "", "CompletedRef": "", "LogDateRef": "", + "OriginalEstimateRef": "", "LastExcelPath": "", "ParentPbiId": "" } diff --git a/templates/TFS导入模板.xlsx b/templates/TFS导入模板.xlsx index c5610ad..0a65aa9 100644 Binary files a/templates/TFS导入模板.xlsx and b/templates/TFS导入模板.xlsx differ diff --git a/templates/_parts/[Content_Types].xml b/templates/_parts/[Content_Types].xml deleted file mode 100644 index 0cf64ec..0000000 --- a/templates/_parts/[Content_Types].xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/templates/_parts/_rels/.rels b/templates/_parts/_rels/.rels deleted file mode 100644 index ad3ced5..0000000 --- a/templates/_parts/_rels/.rels +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/templates/_parts/xl/_rels/workbook.xml.rels b/templates/_parts/xl/_rels/workbook.xml.rels deleted file mode 100644 index 45a0a46..0000000 --- a/templates/_parts/xl/_rels/workbook.xml.rels +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/templates/_parts/xl/styles.xml b/templates/_parts/xl/styles.xml deleted file mode 100644 index 75ce262..0000000 --- a/templates/_parts/xl/styles.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/templates/_parts/xl/workbook.xml b/templates/_parts/xl/workbook.xml deleted file mode 100644 index c26af18..0000000 --- a/templates/_parts/xl/workbook.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/templates/_parts/xl/worksheets/sheet1.xml b/templates/_parts/xl/worksheets/sheet1.xml deleted file mode 100644 index 25ce4c8..0000000 --- a/templates/_parts/xl/worksheets/sheet1.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - -Title -State -Assigned To -FinancialProject -Completed -LogDate -Created Date -Closed Date -PBI Id -Notes - - -20261012 - 示例:货载报告解析并上传凭证受托报告相关内容 -To Do -李晓柯 -信托 - 上海信托 -5 -2026-10-12 -2026-10-30 11:14:41 - -219975 -示例行,导入前请删除 - - -20261009 示例:受托报告bug修复 -Done -陈卫宇 -信托 - 上海信托 -8 -2026-10-09 -2026-10-10 18:20:00 -2026-10-30 18:00:00 -219975 -示例行,导入前请删除 - - - diff --git a/templates/build_template.ps1 b/templates/build_template.ps1 deleted file mode 100644 index 614e135..0000000 --- a/templates/build_template.ps1 +++ /dev/null @@ -1,104 +0,0 @@ -# Build TFS import xlsx from XML parts as a stored (uncompressed) ZIP. -# Pure in-process .NET byte APIs: no external processes, no assembly loading. -# Output file name uses ASCII+char trick to stay encoding-safe. -$ErrorActionPreference = 'Stop' - -$parts = Join-Path $PSScriptRoot '_parts' -$outName = 'TFS' + [char]0x5BFC + [char]0x5165 + [char]0x6A21 + [char]0x677F + '.xlsx' -$out = Join-Path $PSScriptRoot $outName - -# ---------- CRC32 (IEEE 802.3) ---------- -$tbl = New-Object 'System.Int64[]' 256 -for ($i = 0; $i -lt 256; $i++) { - $c = [int64]$i - for ($k = 0; $k -lt 8; $k++) { - if (($c -band 1) -eq 1) { $c = (3988292384 -bxor ($c -shr 1)) } else { $c = ($c -shr 1) } - $c = $c -band 4294967295 - } - $tbl[$i] = $c -} - -function Get-Crc32([byte[]]$data) { - $crc = [int64]4294967295 - foreach ($b in $data) { - $idx = [int](($crc -bxor [int64]$b) -band 255) - $crc = ($tbl[$idx] -bxor ($crc -shr 8)) -band 4294967295 - } - return ($crc -bxor 4294967295) -} - -# ---------- entry list (source relative path, zip entry name) ---------- -$entries = @( - @{ src = '[Content_Types].xml'; zip = '[Content_Types].xml' }, - @{ src = '_rels\.rels'; zip = '_rels/.rels' }, - @{ src = 'xl\workbook.xml'; zip = 'xl/workbook.xml' }, - @{ src = 'xl\_rels\workbook.xml.rels'; zip = 'xl/_rels/workbook.xml.rels' }, - @{ src = 'xl\styles.xml'; zip = 'xl/styles.xml' }, - @{ src = 'xl\worksheets\sheet1.xml'; zip = 'xl/worksheets/sheet1.xml' } -) - -$ms = New-Object System.IO.MemoryStream -$central = New-Object System.Collections.ArrayList - -foreach ($e in $entries) { - $srcPath = Join-Path $parts $e['src'] - $bytes = [System.IO.File]::ReadAllBytes($srcPath) - $nameBytes = [System.Text.Encoding]::ASCII.GetBytes([string]$e['zip']) - $crc = Get-Crc32 $bytes - $offset = $ms.Position - - $ms.Write([BitConverter]::GetBytes([uint32]0x04034b50), 0, 4) # local header signature - $ms.Write([BitConverter]::GetBytes([uint16]20), 0, 2) # version needed - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # flags - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # method: stored - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # mod time - $ms.Write([BitConverter]::GetBytes([uint16]33), 0, 2) # mod date 1980-01-01 - $ms.Write([BitConverter]::GetBytes([uint32]$crc), 0, 4) - $ms.Write([BitConverter]::GetBytes([uint32]$bytes.Length), 0, 4) - $ms.Write([BitConverter]::GetBytes([uint32]$bytes.Length), 0, 4) - $ms.Write([BitConverter]::GetBytes([uint16]$nameBytes.Length), 0, 2) - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # extra len - $ms.Write($nameBytes, 0, $nameBytes.Length) - $ms.Write($bytes, 0, $bytes.Length) - - [void]$central.Add(@{ name = $nameBytes; crc = $crc; size = $bytes.Length; offset = $offset }) -} - -# ---------- central directory ---------- -$cdStart = $ms.Position -foreach ($c in $central) { - $ms.Write([BitConverter]::GetBytes([uint32]0x02014b50), 0, 4) # central header signature - $ms.Write([BitConverter]::GetBytes([uint16]20), 0, 2) # made by - $ms.Write([BitConverter]::GetBytes([uint16]20), 0, 2) # version needed - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # flags - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # method - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # time - $ms.Write([BitConverter]::GetBytes([uint16]33), 0, 2) # date - $ms.Write([BitConverter]::GetBytes([uint32]$c['crc']), 0, 4) - $ms.Write([BitConverter]::GetBytes([uint32]$c['size']), 0, 4) - $ms.Write([BitConverter]::GetBytes([uint32]$c['size']), 0, 4) - $ms.Write([BitConverter]::GetBytes([uint16]$c['name'].Length), 0, 2) - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # extra len - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # comment len - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # disk start - $ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # internal attrs - $ms.Write([BitConverter]::GetBytes([uint32]0), 0, 4) # external attrs - $ms.Write([BitConverter]::GetBytes([uint32]$c['offset']), 0, 4) - $ms.Write($c['name'], 0, $c['name'].Length) -} -$cdSize = $ms.Position - $cdStart - -# ---------- end of central directory ---------- -$ms.Write([BitConverter]::GetBytes([uint32]0x06054b50), 0, 4) -$ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # disk number -$ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # cd disk -$ms.Write([BitConverter]::GetBytes([uint16]$central.Count), 0, 2) -$ms.Write([BitConverter]::GetBytes([uint16]$central.Count), 0, 2) -$ms.Write([BitConverter]::GetBytes([uint32]$cdSize), 0, 4) -$ms.Write([BitConverter]::GetBytes([uint32]$cdStart), 0, 4) -$ms.Write([BitConverter]::GetBytes([uint16]0), 0, 2) # comment len - -[System.IO.File]::WriteAllBytes($out, $ms.ToArray()) -$ms.Dispose() - -Write-Output ('CREATED ' + $out + ' size=' + (New-Object System.IO.FileInfo $out).Length)