From 80bcd1b3a14a307e2ed2f0d65d1c9f06fd86aa79 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:28:43 -0700 Subject: [PATCH 01/25] Add richText field to TextItemsResponse schema --- lib/src/http/textItems.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 18e86f9..726d8f4 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -11,6 +11,7 @@ const TextItemsResponse = z.array( z.object({ id: z.string(), text: z.string(), + richText: z.string().optional(), status: z.string(), notes: z.string(), tags: z.array(z.string()), From 2006fe5d2fd0a9b9559b3f20777a49a6a1146074 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:30:00 -0700 Subject: [PATCH 02/25] Add richText configuration option to base output filters --- lib/src/outputs/shared.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index 2ba0b05..bc4744c 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -8,4 +8,5 @@ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), + richText: z.literal("html").optional(), }); From 3d2eb38daa86a4fb2beec5e31834036839d32e8f Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:31:57 -0700 Subject: [PATCH 03/25] Update Text Items API request to support richText parameter --- lib/src/http/textItems.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 726d8f4..5b5f39c 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,6 +5,7 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; + richText?: "html"; } const TextItemsResponse = z.array( @@ -25,10 +26,19 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { + const params: { filter: string; richText?: string } = { + filter: JSON.stringify({ + projects: filters?.projects, + variants: filters?.variants, + }), + }; + + if (filters?.richText) { + params.richText = filters.richText; + } + const response = await httpClient.get("/v2/textItems", { - params: { - filter: JSON.stringify(filters), - }, + params, }); return TextItemsResponse.parse(response.data); From 4200b0b674472dec623e6e17c750abc2f3b48fa3 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:32:56 -0700 Subject: [PATCH 04/25] Pass richText configuration to API filters in JSON formatter --- lib/src/formatters/json.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 912b341..0ca283a 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -85,6 +85,10 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } + if (this.output.richText) { + filters.richText = this.output.richText; + } + return filters; } } From 411de910e0dcd8431448e80b6bdf1c29de7204bf Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:34:55 -0700 Subject: [PATCH 05/25] Use richText field in JSON output when configured and available --- lib/src/formatters/json.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 0ca283a..3b4c72a 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -51,8 +51,12 @@ export default class JSONFormatter extends applyMixins( metadata: { variantId: textItem.variantId || "base" }, }); + // Use richText if available and configured, otherwise use text + const textValue = (this.output.richText && textItem.richText) + ? textItem.richText + : textItem.text; - outputJsonFiles[fileName].content[textItem.id] = textItem.text; + outputJsonFiles[fileName].content[textItem.id] = textValue; for (const variableId of textItem.variableIds) { const variable = data.variablesById[variableId]; variablesOutputFile.content[variableId] = variable.data; From 8bc78d701600d17767cae63a247c605cb078796f Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 8 Aug 2025 16:47:01 -0700 Subject: [PATCH 06/25] Add tests for richText field parsing in textItems --- lib/src/http/textItems.test.ts | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 lib/src/http/textItems.test.ts diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts new file mode 100644 index 0000000..aad3b53 --- /dev/null +++ b/lib/src/http/textItems.test.ts @@ -0,0 +1,89 @@ +import fetchText from "./textItems"; +import httpClient from "./client"; + +jest.mock("./client"); + +describe("fetchText", () => { + const mockHttpClient = httpClient as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("richText parameter", () => { + it("should parse response with richText field correctly", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + projectId: "project1", + variantId: "variant1", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchText({ + richText: "html", + }); + + expect(result).toEqual([ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + projectId: "project1", + variantId: "variant1", + }, + ]); + }); + + it("should handle response without richText field", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text only", + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project1", + variantId: null, + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchText({ + richText: "html", + }); + + expect(result).toEqual([ + { + id: "text1", + text: "Plain text only", + richText: undefined, + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project1", + variantId: null, + }, + ]); + }); + }); +}); \ No newline at end of file From fd44d1616071aea94da9a25bf00ead173644de39 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:47:22 -0700 Subject: [PATCH 07/25] Use both top level and out filter --- lib/src/formatters/json.ts | 7 ++++--- lib/src/http/textItems.ts | 2 +- lib/src/outputs/shared.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 3b4c72a..82e94a7 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -52,7 +52,8 @@ export default class JSONFormatter extends applyMixins( }); // Use richText if available and configured, otherwise use text - const textValue = (this.output.richText && textItem.richText) + const richTextConfigured = this.output.richText === "html" || this.projectConfig.richText === "html" && this.output.richText !== "false" + const textValue = richTextConfigured && textItem.richText ? textItem.richText : textItem.text; @@ -89,8 +90,8 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } - if (this.output.richText) { - filters.richText = this.output.richText; + if (this.projectConfig.richText === "html" || this.output.richText === "html") { + filters.richText = "html"; } return filters; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 5b5f39c..4d0ad4e 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,7 +5,7 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; - richText?: "html"; + richText?: "html" | "false"; } const TextItemsResponse = z.array( diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index bc4744c..e78caca 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -8,5 +8,5 @@ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), - richText: z.literal("html").optional(), + richText: z.union([z.literal("html"), z.literal("false")]).optional(), }); From 9e6ccfc199dc4f32f07d957fe70314a1ba895cc7 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:48:53 -0700 Subject: [PATCH 08/25] formatting --- lib/src/formatters/json.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 82e94a7..84383a4 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -52,7 +52,8 @@ export default class JSONFormatter extends applyMixins( }); // Use richText if available and configured, otherwise use text - const richTextConfigured = this.output.richText === "html" || this.projectConfig.richText === "html" && this.output.richText !== "false" + const richTextConfigured = this.output.richText === "html" || + (this.projectConfig.richText === "html" && this.output.richText !== "false") const textValue = richTextConfigured && textItem.richText ? textItem.richText : textItem.text; From 907b84a64489d87d9790928631e9f30465fd58cc Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:51:40 -0700 Subject: [PATCH 09/25] remove unnecessary change --- lib/src/http/textItems.ts | 13 +--- specs/DIT-11112/design.md | 65 +++++++++++++++++ specs/DIT-11112/implementation.md | 115 ++++++++++++++++++++++++++++++ specs/DIT-11112/requirements.md | 31 ++++++++ 4 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 specs/DIT-11112/design.md create mode 100644 specs/DIT-11112/implementation.md create mode 100644 specs/DIT-11112/requirements.md diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 4d0ad4e..ebec073 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -26,19 +26,8 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { - const params: { filter: string; richText?: string } = { - filter: JSON.stringify({ - projects: filters?.projects, - variants: filters?.variants, - }), - }; - - if (filters?.richText) { - params.richText = filters.richText; - } - const response = await httpClient.get("/v2/textItems", { - params, + params: JSON.stringify(filters) }); return TextItemsResponse.parse(response.data); diff --git a/specs/DIT-11112/design.md b/specs/DIT-11112/design.md new file mode 100644 index 0000000..e6448a2 --- /dev/null +++ b/specs/DIT-11112/design.md @@ -0,0 +1,65 @@ +# Design: Rich text for CLI + +## Architecture Changes +- Extend the text items API response schema to include an optional `richText` field +- Add a new configuration option `richText` to control rich text output behavior +- Modify the JSON formatter to conditionally use rich text values instead of plain text +- No changes to the overall architecture pattern - rich text is an additional data field + +## Components Modified + +### Core Components +- `lib/src/http/textItems.ts` - Update response schema to include richText field +- `lib/src/outputs/shared.ts` - Add richText configuration option +- `lib/src/outputs/json.ts` - Update output schema to support richText option +- `lib/src/formatters/json.ts` - Modify text value selection logic +- `lib/src/services/projectConfig.ts` - Update default config to include richText option + +### Framework Processors +- `lib/src/formatters/frameworks/json/i18next.ts` - Update to handle rich text values +- `lib/src/formatters/frameworks/json/vue-i18n.ts` - Update to handle rich text values + +### Legacy Support +- Legacy mode already has richText support implemented (found in `lib/legacy/types.ts` and `lib/legacy/pull.ts`) +- No changes needed for legacy mode + +## Database Changes +- None - this is a client-side change consuming existing API capabilities + +## API Changes +- No new endpoints required +- Existing `/v2/textItems` endpoint will be called with additional query parameter `richText=html` when rich text is enabled +- API response will include `richText` field alongside existing `text` field + +## UI Changes +- None - this is a CLI tool with JSON output +- Rich text data will be output as raw JSON values, not rendered + +## Implementation Notes + +### Configuration Approach +- Add `richText` as an optional string flag in the output configuration +- Valid values: `"html"` to enable rich text, or undefined/null to use plain text +- Default value should be undefined to maintain backward compatibility +- Can be set globally or per-output configuration + +### API Integration +- When `richText` is set to "html", pass query parameter to API: `richText=html` +- API response will include both `text` and `richText` fields +- Formatter will choose which field to use based on configuration + +### Output Format +- When rich text is enabled, the JSON value will be the `richText` content +- Rich text format is an HTML string +- Variables file remains unchanged as it doesn't contain rich text + +### Error Handling +- If rich text is requested but not available for a text item, fall back to plain text +- Log warnings for items missing rich text when it's expected +- Maintain robust parsing with Zod schema validation + +### Testing Considerations +- Test with both rich text enabled and disabled +- Verify backward compatibility +- Test framework processors with rich text content +- Ensure proper fallback behavior when rich text is unavailable \ No newline at end of file diff --git a/specs/DIT-11112/implementation.md b/specs/DIT-11112/implementation.md new file mode 100644 index 0000000..8532288 --- /dev/null +++ b/specs/DIT-11112/implementation.md @@ -0,0 +1,115 @@ +# Implementation: Rich text for CLI + +## Task Breakdown + +### 1. Update Text Items Response Schema +**Description**: Add richText field to the API response schema +- Files: `lib/src/http/textItems.ts` +- Dependencies: None +- Details: + - Add `richText: z.string().optional()` to TextItemsResponse schema + - Ensure backward compatibility with responses that don't include richText + +### 2. Add Rich Text Configuration to Base Output +**Description**: Add richText option to the base output configuration +- Files: `lib/src/outputs/shared.ts` +- Dependencies: None +- Details: + - Add `richText: z.literal("html").optional()` to ZBaseOutputFilters + - This allows all output formats to inherit the richText option + +### 3. Update Text Items API Request +**Description**: Pass richText parameter to API when configured +- Files: `lib/src/http/textItems.ts` +- Dependencies: Task 1, Task 2 +- Details: + - Modify fetchText function to accept richText parameter + - Add richText to query params when set: `params.richText = "html"` + - Update PullFilters interface to include richText option + +### 4. Update JSON Formatter Pull Filters +**Description**: Pass richText configuration to API fetch +- Files: `lib/src/formatters/json.ts` +- Dependencies: Task 3 +- Details: + - Update generatePullFilter() to include richText from output config + - Pass richText value to fetchText function + +### 5. Modify JSON Formatter Output Logic +**Description**: Use richText field when available and configured +- Files: `lib/src/formatters/json.ts` +- Dependencies: Task 1, Task 4 +- Details: + - In transformAPIData(), check if richText is configured + - Use `textItem.richText || textItem.text` when richText is enabled + - Use `textItem.text` when richText is not configured + +### 6. Update i18next Framework Processor +**Description**: Handle rich text in i18next output format +- Files: `lib/src/formatters/frameworks/json/i18next.ts` +- Dependencies: Task 5 +- Details: + - Ensure framework processor correctly handles HTML strings + - No special escaping needed as HTML should be preserved + +### 7. Update vue-i18n Framework Processor +**Description**: Handle rich text in vue-i18n output format +- Files: `lib/src/formatters/frameworks/json/vue-i18n.ts` +- Dependencies: Task 5 +- Details: + - Ensure framework processor correctly handles HTML strings + - No special escaping needed as HTML should be preserved + +### 8. Add Tests for Rich Text Feature +**Description**: Create tests to verify rich text functionality +- Files: New test files or updates to existing tests +- Dependencies: Tasks 1-7 +- Details: + - Test with richText="html" configuration + - Test without richText configuration (backward compatibility) + - Test fallback when richText field is missing + - Test framework processors with HTML content + +### 9. Update Documentation +**Description**: Document the new richText configuration option +- Files: `README.md`, configuration examples +- Dependencies: Tasks 1-7 +- Details: + - Add richText option to configuration documentation + - Provide example config with richText enabled + - Explain HTML output format + +## Implementation Order + +1. Update Text Items Response Schema (Task 1) +2. Add Rich Text Configuration to Base Output (Task 2) +3. Update Text Items API Request (Task 3) +4. Update JSON Formatter Pull Filters (Task 4) +5. Modify JSON Formatter Output Logic (Task 5) +6. Update i18next Framework Processor (Task 6) +7. Update vue-i18n Framework Processor (Task 7) +8. Add Tests for Rich Text Feature (Task 8) +9. Update Documentation (Task 9) + +## Example Configuration + +After implementation, users will be able to configure rich text like this: + +```yaml +projects: + - id: "project-id" +outputs: + - format: json + framework: i18next + richText: html # Enable rich text output +``` + +## Testing Checklist + +- [ ] Rich text parameter is correctly passed to API +- [ ] HTML content is properly returned in JSON output +- [ ] Plain text fallback works when richText field is missing +- [ ] Configuration validation accepts "html" value +- [ ] Framework processors handle HTML strings correctly +- [ ] Backward compatibility maintained when richText is not configured +- [ ] Legacy mode continues to work with its existing richText implementation \ No newline at end of file diff --git a/specs/DIT-11112/requirements.md b/specs/DIT-11112/requirements.md new file mode 100644 index 0000000..7204293 --- /dev/null +++ b/specs/DIT-11112/requirements.md @@ -0,0 +1,31 @@ +# Requirements: Rich text for CLI + +## Overview +The Ditto public API now supports rich text format, returning a `richText` field in addition to plain text values. The CLI needs to be extended to support outputting this rich text format when retrieving text items, providing users with access to formatted content including markup, links, and other rich text elements through the command-line interface. + +## User Stories +- As a developer using the Ditto CLI, I want to retrieve text items with rich text formatting so that I can access and work with formatted content in my development workflow +- As a team automating Ditto workflows, I want the CLI to optionally output rich text data so that we can preserve formatting information in our pipelines +- As a CLI user, I want to control whether I receive plain text or rich text output so that I can choose the appropriate format for my use case + +## Acceptance Criteria +- [ ] CLI can retrieve and output the `richText` field from the API's get text items endpoint +- [ ] JSON output includes the `richText` value as the value in key-value pairs when rich text mode is enabled +- [ ] Configuration file includes a sensible option to enable/disable rich text output +- [ ] Rich text mode can be toggled via CLI flag or configuration setting +- [ ] Existing plain text functionality remains unchanged when rich text is disabled +- [ ] Documentation is updated to explain the rich text feature and how to enable it +- [ ] Rich text output is properly formatted in JSON responses + +## Technical Requirements +- Integration with the existing API's `richText` field as documented in the Beta Developer Integrations guide +- Backward compatibility with existing CLI usage patterns +- Configuration management for rich text preference +- Proper JSON serialization of rich text content +- Support for all text item retrieval commands that currently exist in the CLI + +## Out of Scope +- Rich text rendering/display in the terminal (only raw rich text data output) +- Rich text editing capabilities through the CLI +- Conversion between rich text and plain text formats +- Support for rich text in other API endpoints beyond text items retrieval \ No newline at end of file From 35ebb5e0dce6effd244ed09a20d70851d6bb9900 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 15:53:01 -0700 Subject: [PATCH 10/25] remove spec files --- specs/DIT-11112/design.md | 65 ----------------- specs/DIT-11112/implementation.md | 115 ------------------------------ specs/DIT-11112/requirements.md | 31 -------- 3 files changed, 211 deletions(-) delete mode 100644 specs/DIT-11112/design.md delete mode 100644 specs/DIT-11112/implementation.md delete mode 100644 specs/DIT-11112/requirements.md diff --git a/specs/DIT-11112/design.md b/specs/DIT-11112/design.md deleted file mode 100644 index e6448a2..0000000 --- a/specs/DIT-11112/design.md +++ /dev/null @@ -1,65 +0,0 @@ -# Design: Rich text for CLI - -## Architecture Changes -- Extend the text items API response schema to include an optional `richText` field -- Add a new configuration option `richText` to control rich text output behavior -- Modify the JSON formatter to conditionally use rich text values instead of plain text -- No changes to the overall architecture pattern - rich text is an additional data field - -## Components Modified - -### Core Components -- `lib/src/http/textItems.ts` - Update response schema to include richText field -- `lib/src/outputs/shared.ts` - Add richText configuration option -- `lib/src/outputs/json.ts` - Update output schema to support richText option -- `lib/src/formatters/json.ts` - Modify text value selection logic -- `lib/src/services/projectConfig.ts` - Update default config to include richText option - -### Framework Processors -- `lib/src/formatters/frameworks/json/i18next.ts` - Update to handle rich text values -- `lib/src/formatters/frameworks/json/vue-i18n.ts` - Update to handle rich text values - -### Legacy Support -- Legacy mode already has richText support implemented (found in `lib/legacy/types.ts` and `lib/legacy/pull.ts`) -- No changes needed for legacy mode - -## Database Changes -- None - this is a client-side change consuming existing API capabilities - -## API Changes -- No new endpoints required -- Existing `/v2/textItems` endpoint will be called with additional query parameter `richText=html` when rich text is enabled -- API response will include `richText` field alongside existing `text` field - -## UI Changes -- None - this is a CLI tool with JSON output -- Rich text data will be output as raw JSON values, not rendered - -## Implementation Notes - -### Configuration Approach -- Add `richText` as an optional string flag in the output configuration -- Valid values: `"html"` to enable rich text, or undefined/null to use plain text -- Default value should be undefined to maintain backward compatibility -- Can be set globally or per-output configuration - -### API Integration -- When `richText` is set to "html", pass query parameter to API: `richText=html` -- API response will include both `text` and `richText` fields -- Formatter will choose which field to use based on configuration - -### Output Format -- When rich text is enabled, the JSON value will be the `richText` content -- Rich text format is an HTML string -- Variables file remains unchanged as it doesn't contain rich text - -### Error Handling -- If rich text is requested but not available for a text item, fall back to plain text -- Log warnings for items missing rich text when it's expected -- Maintain robust parsing with Zod schema validation - -### Testing Considerations -- Test with both rich text enabled and disabled -- Verify backward compatibility -- Test framework processors with rich text content -- Ensure proper fallback behavior when rich text is unavailable \ No newline at end of file diff --git a/specs/DIT-11112/implementation.md b/specs/DIT-11112/implementation.md deleted file mode 100644 index 8532288..0000000 --- a/specs/DIT-11112/implementation.md +++ /dev/null @@ -1,115 +0,0 @@ -# Implementation: Rich text for CLI - -## Task Breakdown - -### 1. Update Text Items Response Schema -**Description**: Add richText field to the API response schema -- Files: `lib/src/http/textItems.ts` -- Dependencies: None -- Details: - - Add `richText: z.string().optional()` to TextItemsResponse schema - - Ensure backward compatibility with responses that don't include richText - -### 2. Add Rich Text Configuration to Base Output -**Description**: Add richText option to the base output configuration -- Files: `lib/src/outputs/shared.ts` -- Dependencies: None -- Details: - - Add `richText: z.literal("html").optional()` to ZBaseOutputFilters - - This allows all output formats to inherit the richText option - -### 3. Update Text Items API Request -**Description**: Pass richText parameter to API when configured -- Files: `lib/src/http/textItems.ts` -- Dependencies: Task 1, Task 2 -- Details: - - Modify fetchText function to accept richText parameter - - Add richText to query params when set: `params.richText = "html"` - - Update PullFilters interface to include richText option - -### 4. Update JSON Formatter Pull Filters -**Description**: Pass richText configuration to API fetch -- Files: `lib/src/formatters/json.ts` -- Dependencies: Task 3 -- Details: - - Update generatePullFilter() to include richText from output config - - Pass richText value to fetchText function - -### 5. Modify JSON Formatter Output Logic -**Description**: Use richText field when available and configured -- Files: `lib/src/formatters/json.ts` -- Dependencies: Task 1, Task 4 -- Details: - - In transformAPIData(), check if richText is configured - - Use `textItem.richText || textItem.text` when richText is enabled - - Use `textItem.text` when richText is not configured - -### 6. Update i18next Framework Processor -**Description**: Handle rich text in i18next output format -- Files: `lib/src/formatters/frameworks/json/i18next.ts` -- Dependencies: Task 5 -- Details: - - Ensure framework processor correctly handles HTML strings - - No special escaping needed as HTML should be preserved - -### 7. Update vue-i18n Framework Processor -**Description**: Handle rich text in vue-i18n output format -- Files: `lib/src/formatters/frameworks/json/vue-i18n.ts` -- Dependencies: Task 5 -- Details: - - Ensure framework processor correctly handles HTML strings - - No special escaping needed as HTML should be preserved - -### 8. Add Tests for Rich Text Feature -**Description**: Create tests to verify rich text functionality -- Files: New test files or updates to existing tests -- Dependencies: Tasks 1-7 -- Details: - - Test with richText="html" configuration - - Test without richText configuration (backward compatibility) - - Test fallback when richText field is missing - - Test framework processors with HTML content - -### 9. Update Documentation -**Description**: Document the new richText configuration option -- Files: `README.md`, configuration examples -- Dependencies: Tasks 1-7 -- Details: - - Add richText option to configuration documentation - - Provide example config with richText enabled - - Explain HTML output format - -## Implementation Order - -1. Update Text Items Response Schema (Task 1) -2. Add Rich Text Configuration to Base Output (Task 2) -3. Update Text Items API Request (Task 3) -4. Update JSON Formatter Pull Filters (Task 4) -5. Modify JSON Formatter Output Logic (Task 5) -6. Update i18next Framework Processor (Task 6) -7. Update vue-i18n Framework Processor (Task 7) -8. Add Tests for Rich Text Feature (Task 8) -9. Update Documentation (Task 9) - -## Example Configuration - -After implementation, users will be able to configure rich text like this: - -```yaml -projects: - - id: "project-id" -outputs: - - format: json - framework: i18next - richText: html # Enable rich text output -``` - -## Testing Checklist - -- [ ] Rich text parameter is correctly passed to API -- [ ] HTML content is properly returned in JSON output -- [ ] Plain text fallback works when richText field is missing -- [ ] Configuration validation accepts "html" value -- [ ] Framework processors handle HTML strings correctly -- [ ] Backward compatibility maintained when richText is not configured -- [ ] Legacy mode continues to work with its existing richText implementation \ No newline at end of file diff --git a/specs/DIT-11112/requirements.md b/specs/DIT-11112/requirements.md deleted file mode 100644 index 7204293..0000000 --- a/specs/DIT-11112/requirements.md +++ /dev/null @@ -1,31 +0,0 @@ -# Requirements: Rich text for CLI - -## Overview -The Ditto public API now supports rich text format, returning a `richText` field in addition to plain text values. The CLI needs to be extended to support outputting this rich text format when retrieving text items, providing users with access to formatted content including markup, links, and other rich text elements through the command-line interface. - -## User Stories -- As a developer using the Ditto CLI, I want to retrieve text items with rich text formatting so that I can access and work with formatted content in my development workflow -- As a team automating Ditto workflows, I want the CLI to optionally output rich text data so that we can preserve formatting information in our pipelines -- As a CLI user, I want to control whether I receive plain text or rich text output so that I can choose the appropriate format for my use case - -## Acceptance Criteria -- [ ] CLI can retrieve and output the `richText` field from the API's get text items endpoint -- [ ] JSON output includes the `richText` value as the value in key-value pairs when rich text mode is enabled -- [ ] Configuration file includes a sensible option to enable/disable rich text output -- [ ] Rich text mode can be toggled via CLI flag or configuration setting -- [ ] Existing plain text functionality remains unchanged when rich text is disabled -- [ ] Documentation is updated to explain the rich text feature and how to enable it -- [ ] Rich text output is properly formatted in JSON responses - -## Technical Requirements -- Integration with the existing API's `richText` field as documented in the Beta Developer Integrations guide -- Backward compatibility with existing CLI usage patterns -- Configuration management for rich text preference -- Proper JSON serialization of rich text content -- Support for all text item retrieval commands that currently exist in the CLI - -## Out of Scope -- Rich text rendering/display in the terminal (only raw rich text data output) -- Rich text editing capabilities through the CLI -- Conversion between rich text and plain text formats -- Support for rich text in other API endpoints beyond text items retrieval \ No newline at end of file From 828f0e89f9558990b1d514798a5aefc37dc95476 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:04:42 -0700 Subject: [PATCH 11/25] clarify code --- lib/src/formatters/json.ts | 5 +++-- lib/src/http/textItems.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 84383a4..7a8401f 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -52,8 +52,9 @@ export default class JSONFormatter extends applyMixins( }); // Use richText if available and configured, otherwise use text - const richTextConfigured = this.output.richText === "html" || - (this.projectConfig.richText === "html" && this.output.richText !== "false") + const outputRichTextEnabled = this.output.richText === "html" + const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== "false" + const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden const textValue = richTextConfigured && textItem.richText ? textItem.richText : textItem.text; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index ebec073..e071c0d 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -27,7 +27,7 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { const response = await httpClient.get("/v2/textItems", { - params: JSON.stringify(filters) + params: filters }); return TextItemsResponse.parse(response.data); From 6a86fae9ae645b7fef15e425e0148938f8237bc3 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:06:51 -0700 Subject: [PATCH 12/25] simplify code --- lib/src/formatters/json.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 7a8401f..4b15849 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -83,6 +83,7 @@ export default class JSONFormatter extends applyMixins( let filters: PullFilters = { projects: this.projectConfig.projects, variants: this.projectConfig.variants, + richText: this.projectConfig.richText }; if (this.output.projects) { filters.projects = this.output.projects; @@ -92,7 +93,7 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } - if (this.projectConfig.richText === "html" || this.output.richText === "html") { + if (this.output.richText) { filters.richText = "html"; } From 45a4be03acb29ff5631667f9b368575cfc0991d7 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:10:07 -0700 Subject: [PATCH 13/25] remove incorrect richtext param --- lib/src/formatters/json.ts | 2 +- lib/src/http/textItems.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 4b15849..581b8c3 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -94,7 +94,7 @@ export default class JSONFormatter extends applyMixins( } if (this.output.richText) { - filters.richText = "html"; + filters.richText = this.output.richText; } return filters; diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index e071c0d..40446b3 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -26,8 +26,14 @@ export type TextItemsResponse = z.infer; export default async function fetchText(filters?: PullFilters) { try { + const params = filters; + + if (params?.richText === "false") { + delete params.richText + } + const response = await httpClient.get("/v2/textItems", { - params: filters + params }); return TextItemsResponse.parse(response.data); From c74c117d0595d37e7223ca9c5767fc65d3143a95 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:11:23 -0700 Subject: [PATCH 14/25] add clarifying comment --- lib/src/http/textItems.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 40446b3..bad2660 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -28,6 +28,7 @@ export default async function fetchText(filters?: PullFilters) { try { const params = filters; + // endpoint only takes "html" or undefined if (params?.richText === "false") { delete params.richText } From 5d98a9372313cab3aafdc5cfeda170bcf611f6a0 Mon Sep 17 00:00:00 2001 From: asnewman Date: Tue, 12 Aug 2025 16:16:07 -0700 Subject: [PATCH 15/25] use boolean false instead of string --- lib/src/formatters/json.ts | 2 +- lib/src/http/textItems.ts | 4 ++-- lib/src/outputs/shared.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 581b8c3..a6a79cd 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -53,7 +53,7 @@ export default class JSONFormatter extends applyMixins( // Use richText if available and configured, otherwise use text const outputRichTextEnabled = this.output.richText === "html" - const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== "false" + const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== false const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden const textValue = richTextConfigured && textItem.richText ? textItem.richText diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index bad2660..c0e4988 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,7 +5,7 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; - richText?: "html" | "false"; + richText?: "html" | false; } const TextItemsResponse = z.array( @@ -29,7 +29,7 @@ export default async function fetchText(filters?: PullFilters) { const params = filters; // endpoint only takes "html" or undefined - if (params?.richText === "false") { + if (params?.richText === false) { delete params.richText } diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index e78caca..c044d5a 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -8,5 +8,5 @@ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), - richText: z.union([z.literal("html"), z.literal("false")]).optional(), + richText: z.union([z.literal("html"), z.literal(false)]).optional(), }); From 26438dabf6cb93f36e9a328d418ef971f19b0e4a Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 14 Aug 2025 12:33:30 -0400 Subject: [PATCH 16/25] Update url for finding your api key --- lib/src/services/apiToken/collectToken.test.ts | 2 -- lib/src/services/apiToken/collectToken.ts | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/src/services/apiToken/collectToken.test.ts b/lib/src/services/apiToken/collectToken.test.ts index 4fa270c..54a5785 100644 --- a/lib/src/services/apiToken/collectToken.test.ts +++ b/lib/src/services/apiToken/collectToken.test.ts @@ -28,8 +28,6 @@ describe("collectToken", () => { const response = await collectToken(); expect(response).toBe(token); expect(logger.url).toHaveBeenCalled(); - expect(logger.bold).toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); expect(logger.writeLine).toHaveBeenCalled(); expect(promptForApiTokenSpy).toHaveBeenCalled(); }); diff --git a/lib/src/services/apiToken/collectToken.ts b/lib/src/services/apiToken/collectToken.ts index eb7b717..045951f 100644 --- a/lib/src/services/apiToken/collectToken.ts +++ b/lib/src/services/apiToken/collectToken.ts @@ -6,9 +6,8 @@ import promptForApiToken from "./promptForApiToken"; * @returns The collected token */ export default async function collectToken() { - const apiUrl = logger.url("https://app.dittowords.com/account/devtools"); - const breadcrumbs = logger.bold(logger.info("API Keys")); - const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} under "${breadcrumbs}".`; + const apiUrl = logger.url("https://app.dittowords.com/developers/api-keys"); + const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl}.`; logger.writeLine(tokenDescription); From 5cb330289b6519f09c8b82fe41a5943b36e4dc6f Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 14 Aug 2025 12:38:17 -0400 Subject: [PATCH 17/25] Fix fetch text query params in fetchText --- lib/src/http/textItems.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index c0e4988..250c5d5 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -24,18 +24,17 @@ const TextItemsResponse = z.array( export type TextItemsResponse = z.infer; -export default async function fetchText(filters?: PullFilters) { +export default async function fetchText(filters: PullFilters) { try { - const params = filters; + // richText applied as a separate parameter from the rest of the filters + const { richText, ...filter } = filters; - // endpoint only takes "html" or undefined - if (params?.richText === false) { - delete params.richText - } + const params = + richText === false + ? { filter: JSON.stringify(filter) } + : { filter: JSON.stringify(filter), richText }; - const response = await httpClient.get("/v2/textItems", { - params - }); + const response = await httpClient.get("/v2/textItems", { params }); return TextItemsResponse.parse(response.data); } catch (e: unknown) { From 3fc959811a0a0beba2805c25b3f380fb88cf503d Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Thu, 14 Aug 2025 12:50:14 -0400 Subject: [PATCH 18/25] Move the changes higher up --- lib/src/formatters/json.ts | 31 +++++++++++++++++++++++-------- lib/src/http/textItems.ts | 16 ++++++---------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index a6a79cd..e4145f8 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -1,9 +1,8 @@ -import fetchText, { PullFilters, TextItemsResponse } from "../http/textItems"; -import fetchVariables, { Variable, VariablesResponse } from "../http/variables"; +import fetchText, { TextItemsResponse, PullFilters, PullQueryParams } from "../http/textItems"; +import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; import OutputFile from "./shared/fileTypes/OutputFile"; import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; -import appContext from "../utils/appContext"; import { applyMixins } from "./shared"; import { getFrameworkProcessor } from "./frameworks/json"; @@ -16,8 +15,8 @@ export default class JSONFormatter extends applyMixins( BaseFormatter) { protected async fetchAPIData() { - const filters = this.generatePullFilter(); - const textItems = await fetchText(filters); + const queryParams = this.generateQueryParams(); + const textItems = await fetchText(queryParams); const variables = await fetchVariables(); const variablesById = variables.reduce((acc, variable) => { @@ -83,7 +82,6 @@ export default class JSONFormatter extends applyMixins( let filters: PullFilters = { projects: this.projectConfig.projects, variants: this.projectConfig.variants, - richText: this.projectConfig.richText }; if (this.output.projects) { filters.projects = this.output.projects; @@ -93,10 +91,27 @@ export default class JSONFormatter extends applyMixins( filters.variants = this.output.variants; } + return filters; + } + + /** + * Returns the query parameters for the fetchText API request + */ + private generateQueryParams() { + const filter = this.generatePullFilter(); + + let params: PullQueryParams = { + filter: JSON.stringify(filter), + }; + + if (this.projectConfig.richText) { + params.richText = this.projectConfig.richText; + } + if (this.output.richText) { - filters.richText = this.output.richText; + params.richText = this.output.richText; } - return filters; + return params; } } diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 250c5d5..f63cfbc 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -5,7 +5,11 @@ import { z } from "zod"; export interface PullFilters { projects?: { id: string }[]; variants?: { id: string }[]; - richText?: "html" | false; +} + +export interface PullQueryParams { + filter: string; // Stringified PullFilters + richText?: "html"; } const TextItemsResponse = z.array( @@ -24,16 +28,8 @@ const TextItemsResponse = z.array( export type TextItemsResponse = z.infer; -export default async function fetchText(filters: PullFilters) { +export default async function fetchText(params: PullQueryParams) { try { - // richText applied as a separate parameter from the rest of the filters - const { richText, ...filter } = filters; - - const params = - richText === false - ? { filter: JSON.stringify(filter) } - : { filter: JSON.stringify(filter), richText }; - const response = await httpClient.get("/v2/textItems", { params }); return TextItemsResponse.parse(response.data); From 26820d93a9bb7d5998cd7e105c69b6bc04ad871a Mon Sep 17 00:00:00 2001 From: asnewman Date: Thu, 14 Aug 2025 15:04:23 -0700 Subject: [PATCH 19/25] Add e2e tests --- lib/src/commands/pull.test.ts | 296 ++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 lib/src/commands/pull.test.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts new file mode 100644 index 0000000..2ab4b1b --- /dev/null +++ b/lib/src/commands/pull.test.ts @@ -0,0 +1,296 @@ +import { pull } from "./pull"; +import httpClient from "../http/client"; +import appContext from "../utils/appContext"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +jest.mock("../http/client"); + +const mockHttpClient = httpClient as jest.Mocked; + +// Test data factories +const createMockTextItem = (overrides: any = {}) => ({ + id: "text-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project-1", + variantId: null, + ...overrides, +}); + +const createMockVariable = (overrides: any = {}) => ({ + id: "var-1", + name: "Variable 1", + type: "string", + data: { + example: "variable value", + fallback: undefined + }, + ...overrides, +}); + +// Create a temporary directory for tests +let testDir: string; +let outputDir: string; + +// Helper functions +const setupFilesystem = (configContent: string) => { + // Create config file + const configPath = path.join(testDir, "config.yml"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, configContent, "utf-8"); + + // Ensure output directory exists + fs.mkdirSync(outputDir, { recursive: true }); +}; + +const setupMocks = (textItems: any[] = [], variables: any[] = []) => { + mockHttpClient.get.mockImplementation((url: string) => { + if (url.includes("/v2/textItems")) { + return Promise.resolve({ data: textItems }); + } + if (url.includes("/v2/variables")) { + return Promise.resolve({ data: variables }); + } + return Promise.resolve({ data: [] }); + }); +}; + +const parseJsonFile = (filepath: string) => { + const content = fs.readFileSync(filepath, "utf-8"); + return JSON.parse(content); +}; + +const assertFileContainsText = ( + filepath: string, + textId: string, + expectedText: string +) => { + const content = parseJsonFile(filepath); + expect(content[textId]).toBe(expectedText); +}; + +const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { + const actualFiles = fs.readdirSync(outputDir).sort(); + expect(actualFiles).toEqual(expectedFiles.sort()); +}; + +// Reset appContext before each test +beforeEach(() => { + jest.clearAllMocks(); + + // Create a fresh temp directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); + outputDir = path.join(testDir, 'output'); + + // Reset appContext to a clean state + appContext.setProjectConfig({ + projects: [], + outputs: [], + }); +}); + +// Clean up temp directory after each test +afterEach(() => { + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } +}); + +describe("pull command - end-to-end tests", () => { + describe("Rich Text Feature", () => { + it("should use rich text when configured at base level", async () => { + const config = ` +projects: + - id: project-1 +richText: html +outputs: + - format: json + outDir: ${outputDir} +`; + setupFilesystem(config); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + // Set up appContext + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + richText: "html", + outputs: [{ format: "json", outDir: outputDir }], + }); + + await pull(); + + // Verify rich text content was written + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "

Rich HTML content

" + ); + }); + + it("should use plain text when richText is disabled at output level", async () => { + const config = ` +projects: + - id: project-1 +richText: html +outputs: + - format: json + outDir: ${outputDir} + richText: false +`; + setupFilesystem(config); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + richText: "html", + outputs: [{ format: "json", outDir: outputDir, richText: false }], + }); + + await pull(); + + // Verify plain text content was written despite base config + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "Plain text content" + ); + }); + + it("should use rich text when enabled only at output level", async () => { + const config = ` +projects: + - id: project-1 +outputs: + - format: json + outDir: ${outputDir} + richText: html +`; + setupFilesystem(config); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + outputs: [{ format: "json", outDir: outputDir, richText: "html" }], + }); + + await pull(); + + // Verify rich text content was written + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "

Rich HTML content

" + ); + }); + }); + + describe("Filter Feature", () => { + it("should filter projects at output level", async () => { + const config = ` +projects: + - id: project-1 + - id: project-2 +outputs: + - format: json + outDir: ${outputDir} + projects: + - id: project-1 +`; + setupFilesystem(config); + + const textItem1 = createMockTextItem({ projectId: "project-1" }); + const textItem2 = createMockTextItem({ + id: "text-2", + projectId: "project-2" + }); + + // Mock should only return project-1 items when filtered + // The API only returns items for the requested projects + setupMocks([textItem1], []); + + appContext.setProjectConfig({ + projects: [ + { id: "project-1" }, + { id: "project-2" }, + ], + outputs: [{ + format: "json", + outDir: outputDir, + projects: [{ id: "project-1" }] + }], + }); + + await pull(); + + // Verify only project-1 files were created + assertFilesCreated(outputDir, [ + "project-1___base.json", + "variables.json", + ]); + }); + + it("should filter variants at output level", async () => { + const config = ` +projects: + - id: project-1 +variants: + - id: variant-a + - id: variant-b +outputs: + - format: json + outDir: ${outputDir} + variants: + - id: variant-a +`; + setupFilesystem(config); + + const textItemBase = createMockTextItem({ variantId: null }); + const textItemA = createMockTextItem({ + id: "text-2", + variantId: "variant-a" + }); + const textItemB = createMockTextItem({ + id: "text-3", + variantId: "variant-b" + }); + + // Mock should only return base and variant-a items when filtered + // The API only returns items for the requested variants (plus base) + setupMocks([textItemBase, textItemA], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + variants: [ + { id: "variant-a" }, + { id: "variant-b" }, + ], + outputs: [{ + format: "json", + outDir: outputDir, + variants: [{ id: "variant-a" }] + }], + }); + + await pull(); + + // Verify only base and variant-a files were created + assertFilesCreated(outputDir, [ + "project-1___base.json", + "project-1___variant-a.json", + "variables.json", + ]); + }); + }); +}); From ee8a8150365d406cea0b3da0f3bc58e36fb21cd8 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 14:35:12 -0700 Subject: [PATCH 20/25] Replace any types with proper TypeScript types in tests Updates createMockTextItem function to use Partial instead of any, providing better type safety for test mock overrides. --- lib/src/commands/pull.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 2ab4b1b..cc60b37 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -1,5 +1,6 @@ import { pull } from "./pull"; import httpClient from "../http/client"; +import { TextItemsResponse } from "../http/textItems"; import appContext from "../utils/appContext"; import * as path from "path"; import * as fs from "fs"; @@ -10,7 +11,7 @@ jest.mock("../http/client"); const mockHttpClient = httpClient as jest.Mocked; // Test data factories -const createMockTextItem = (overrides: any = {}) => ({ +const createMockTextItem = (overrides: Partial = {}) => ({ id: "text-1", text: "Plain text content", richText: "

Rich HTML content

", From 53479018447515a9f267ccdcd47dc8a74379bc94 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 14:41:58 -0700 Subject: [PATCH 21/25] Move test variables and hooks inside describe block Improves test organization by moving testDir, outputDir variables and beforeEach/afterEach hooks inside the main describe block for better scoping and isolation. --- lib/src/commands/pull.test.ts | 70 +++++++++++++++++------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index cc60b37..311e215 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -35,21 +35,7 @@ const createMockVariable = (overrides: any = {}) => ({ ...overrides, }); -// Create a temporary directory for tests -let testDir: string; -let outputDir: string; - // Helper functions -const setupFilesystem = (configContent: string) => { - // Create config file - const configPath = path.join(testDir, "config.yml"); - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, configContent, "utf-8"); - - // Ensure output directory exists - fs.mkdirSync(outputDir, { recursive: true }); -}; - const setupMocks = (textItems: any[] = [], variables: any[] = []) => { mockHttpClient.get.mockImplementation((url: string) => { if (url.includes("/v2/textItems")) { @@ -81,29 +67,43 @@ const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { expect(actualFiles).toEqual(expectedFiles.sort()); }; -// Reset appContext before each test -beforeEach(() => { - jest.clearAllMocks(); - - // Create a fresh temp directory for each test - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); - outputDir = path.join(testDir, 'output'); - - // Reset appContext to a clean state - appContext.setProjectConfig({ - projects: [], - outputs: [], - }); -}); +describe("pull command - end-to-end tests", () => { + // Create a temporary directory for tests + let testDir: string; + let outputDir: string; -// Clean up temp directory after each test -afterEach(() => { - if (testDir && fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } -}); + // Helper function for this test suite + const setupFilesystem = (configContent: string) => { + // Create config file + const configPath = path.join(testDir, "config.yml"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, configContent, "utf-8"); + + // Ensure output directory exists + fs.mkdirSync(outputDir, { recursive: true }); + }; -describe("pull command - end-to-end tests", () => { + // Reset appContext before each test + beforeEach(() => { + jest.clearAllMocks(); + + // Create a fresh temp directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); + outputDir = path.join(testDir, 'output'); + + // Reset appContext to a clean state + appContext.setProjectConfig({ + projects: [], + outputs: [], + }); + }); + + // Clean up temp directory after each test + afterEach(() => { + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); describe("Rich Text Feature", () => { it("should use rich text when configured at base level", async () => { const config = ` From 0a13b282e65ab7c78a981684286f57d15573b51f Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 14:44:30 -0700 Subject: [PATCH 22/25] Use toSorted() instead of sort() in tests Replaces mutating sort() calls with non-mutating toSorted() to avoid potential side effects in test assertions. --- lib/src/commands/pull.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 311e215..a7016ba 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -63,8 +63,8 @@ const assertFileContainsText = ( }; const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { - const actualFiles = fs.readdirSync(outputDir).sort(); - expect(actualFiles).toEqual(expectedFiles.sort()); + const actualFiles = fs.readdirSync(outputDir).toSorted(); + expect(actualFiles).toEqual(expectedFiles.toSorted()); }; describe("pull command - end-to-end tests", () => { From 17085068fda635b3c5d6ce75f344db99562b7cd2 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 15:12:48 -0700 Subject: [PATCH 23/25] Simplify test setup by removing unused YAML config files These tests mock HTTP calls and set appContext directly, so the YAML config files were never actually read. Removed the duplication between YAML strings and setProjectConfig() calls - now tests only create what they need and use a single source of truth for configuration. This eliminates the risk of YAML/object mismatch bugs and makes the tests clearer about what they're actually testing. --- lib/src/commands/pull.test.ts | 70 ++++------------------------------- 1 file changed, 7 insertions(+), 63 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index a7016ba..af95cab 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -72,16 +72,6 @@ describe("pull command - end-to-end tests", () => { let testDir: string; let outputDir: string; - // Helper function for this test suite - const setupFilesystem = (configContent: string) => { - // Create config file - const configPath = path.join(testDir, "config.yml"); - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, configContent, "utf-8"); - - // Ensure output directory exists - fs.mkdirSync(outputDir, { recursive: true }); - }; // Reset appContext before each test beforeEach(() => { @@ -106,20 +96,13 @@ describe("pull command - end-to-end tests", () => { }); describe("Rich Text Feature", () => { it("should use rich text when configured at base level", async () => { - const config = ` -projects: - - id: project-1 -richText: html -outputs: - - format: json - outDir: ${outputDir} -`; - setupFilesystem(config); + // Only create output directory since we're mocking HTTP and setting appContext directly + fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - // Set up appContext + // Set up appContext - this is what actually drives the test appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", @@ -137,16 +120,7 @@ outputs: }); it("should use plain text when richText is disabled at output level", async () => { - const config = ` -projects: - - id: project-1 -richText: html -outputs: - - format: json - outDir: ${outputDir} - richText: false -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); @@ -168,15 +142,7 @@ outputs: }); it("should use rich text when enabled only at output level", async () => { - const config = ` -projects: - - id: project-1 -outputs: - - format: json - outDir: ${outputDir} - richText: html -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); @@ -199,17 +165,7 @@ outputs: describe("Filter Feature", () => { it("should filter projects at output level", async () => { - const config = ` -projects: - - id: project-1 - - id: project-2 -outputs: - - format: json - outDir: ${outputDir} - projects: - - id: project-1 -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const textItem1 = createMockTextItem({ projectId: "project-1" }); const textItem2 = createMockTextItem({ @@ -243,19 +199,7 @@ outputs: }); it("should filter variants at output level", async () => { - const config = ` -projects: - - id: project-1 -variants: - - id: variant-a - - id: variant-b -outputs: - - format: json - outDir: ${outputDir} - variants: - - id: variant-a -`; - setupFilesystem(config); + fs.mkdirSync(outputDir, { recursive: true }); const textItemBase = createMockTextItem({ variantId: null }); const textItemA = createMockTextItem({ From e47c9e259e7ab3fa8d64058cdb2ef5aee8b69b14 Mon Sep 17 00:00:00 2001 From: asnewman Date: Fri, 15 Aug 2025 15:22:26 -0700 Subject: [PATCH 24/25] Add proper filter tests with API parameter verification Adds toHaveBeenCalledWith assertions to filter tests to verify that config filters are correctly converted to API query parameters. Tests now catch bugs in the config->API params conversion logic instead of just testing the mocked response->file output flow. This addresses the concern that filter tests weren't actually testing the filtering functionality due to complete API mocking. --- lib/src/commands/pull.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index af95cab..4f1ecfb 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -168,13 +168,6 @@ describe("pull command - end-to-end tests", () => { fs.mkdirSync(outputDir, { recursive: true }); const textItem1 = createMockTextItem({ projectId: "project-1" }); - const textItem2 = createMockTextItem({ - id: "text-2", - projectId: "project-2" - }); - - // Mock should only return project-1 items when filtered - // The API only returns items for the requested projects setupMocks([textItem1], []); appContext.setProjectConfig({ @@ -191,6 +184,13 @@ describe("pull command - end-to-end tests", () => { await pull(); + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { + params: { + filter: '{"projects":[{"id":"project-1"}]}' + } + }); + // Verify only project-1 files were created assertFilesCreated(outputDir, [ "project-1___base.json", @@ -206,13 +206,6 @@ describe("pull command - end-to-end tests", () => { id: "text-2", variantId: "variant-a" }); - const textItemB = createMockTextItem({ - id: "text-3", - variantId: "variant-b" - }); - - // Mock should only return base and variant-a items when filtered - // The API only returns items for the requested variants (plus base) setupMocks([textItemBase, textItemA], []); appContext.setProjectConfig({ @@ -230,6 +223,13 @@ describe("pull command - end-to-end tests", () => { await pull(); + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { + params: { + filter: '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"}]}' + } + }); + // Verify only base and variant-a files were created assertFilesCreated(outputDir, [ "project-1___base.json", From bb48ae21fed9b46ae4f85ac1ad6c14a8fb729ac4 Mon Sep 17 00:00:00 2001 From: Marla Hoggard Date: Mon, 18 Aug 2025 09:28:56 -0400 Subject: [PATCH 25/25] Split up filter/output tests, add more filter cases --- lib/src/commands/pull.test.ts | 261 +++++++++++++++++++++++++--------- lib/src/http/textItems.ts | 31 ++-- 2 files changed, 209 insertions(+), 83 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 4f1ecfb..99703d6 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -1,6 +1,6 @@ import { pull } from "./pull"; import httpClient from "../http/client"; -import { TextItemsResponse } from "../http/textItems"; +import { TextItem } from "../http/textItems"; import appContext from "../utils/appContext"; import * as path from "path"; import * as fs from "fs"; @@ -11,7 +11,7 @@ jest.mock("../http/client"); const mockHttpClient = httpClient as jest.Mocked; // Test data factories -const createMockTextItem = (overrides: Partial = {}) => ({ +const createMockTextItem = (overrides: Partial = {}) => ({ id: "text-1", text: "Plain text content", richText: "

Rich HTML content

", @@ -30,13 +30,13 @@ const createMockVariable = (overrides: any = {}) => ({ type: "string", data: { example: "variable value", - fallback: undefined + fallback: undefined, }, ...overrides, }); // Helper functions -const setupMocks = (textItems: any[] = [], variables: any[] = []) => { +const setupMocks = (textItems: TextItem[] = [], variables: any[] = []) => { mockHttpClient.get.mockImplementation((url: string) => { if (url.includes("/v2/textItems")) { return Promise.resolve({ data: textItems }); @@ -72,19 +72,23 @@ describe("pull command - end-to-end tests", () => { let testDir: string; let outputDir: string; - // Reset appContext before each test beforeEach(() => { jest.clearAllMocks(); - + // Create a fresh temp directory for each test - testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ditto-test-')); - outputDir = path.join(testDir, 'output'); - + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "ditto-test-")); + outputDir = path.join(testDir, "output"); + // Reset appContext to a clean state appContext.setProjectConfig({ projects: [], - outputs: [], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], }); }); @@ -94,23 +98,24 @@ describe("pull command - end-to-end tests", () => { fs.rmSync(testDir, { recursive: true, force: true }); } }); + describe("Rich Text Feature", () => { it("should use rich text when configured at base level", async () => { // Only create output directory since we're mocking HTTP and setting appContext directly fs.mkdirSync(outputDir, { recursive: true }); - + const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - + // Set up appContext - this is what actually drives the test appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", outputs: [{ format: "json", outDir: outputDir }], }); - + await pull(); - + // Verify rich text content was written assertFileContainsText( path.join(outputDir, "project-1___base.json"), @@ -121,18 +126,18 @@ describe("pull command - end-to-end tests", () => { it("should use plain text when richText is disabled at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - + const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - + appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", outputs: [{ format: "json", outDir: outputDir, richText: false }], }); - + await pull(); - + // Verify plain text content was written despite base config assertFileContainsText( path.join(outputDir, "project-1___base.json"), @@ -143,17 +148,17 @@ describe("pull command - end-to-end tests", () => { it("should use rich text when enabled only at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - + const mockTextItem = createMockTextItem(); setupMocks([mockTextItem], []); - + appContext.setProjectConfig({ projects: [{ id: "project-1" }], outputs: [{ format: "json", outDir: outputDir, richText: "html" }], }); - + await pull(); - + // Verify rich text content was written assertFileContainsText( path.join(outputDir, "project-1___base.json"), @@ -164,76 +169,192 @@ describe("pull command - end-to-end tests", () => { }); describe("Filter Feature", () => { + it("should filter projects when configured at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[{"id":"project-1"},{"id":"project-2"}]}', + }, + }); + }); + + it("should filter variants at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: + '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', + }, + }); + }); + it("should filter projects at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - - const textItem1 = createMockTextItem({ projectId: "project-1" }); - setupMocks([textItem1], []); - + appContext.setProjectConfig({ - projects: [ - { id: "project-1" }, - { id: "project-2" }, + projects: [{ id: "project-1" }, { id: "project-2" }], + outputs: [ + { + format: "json", + outDir: outputDir, + projects: [{ id: "project-1" }], + }, ], - outputs: [{ - format: "json", - outDir: outputDir, - projects: [{ id: "project-1" }] - }], }); - + await pull(); - + // Verify correct API call with filtered params - expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { - params: { - filter: '{"projects":[{"id":"project-1"}]}' - } + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[{"id":"project-1"}]}', + }, }); - - // Verify only project-1 files were created - assertFilesCreated(outputDir, [ - "project-1___base.json", - "variables.json", - ]); }); it("should filter variants at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); - - const textItemBase = createMockTextItem({ variantId: null }); - const textItemA = createMockTextItem({ - id: "text-2", - variantId: "variant-a" - }); - setupMocks([textItemBase, textItemA], []); - + appContext.setProjectConfig({ projects: [{ id: "project-1" }], - variants: [ - { id: "variant-a" }, - { id: "variant-b" }, + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + variants: [{ id: "variant-a" }], + }, ], - outputs: [{ - format: "json", - outDir: outputDir, - variants: [{ id: "variant-a" }] - }], }); - + await pull(); - + // Verify correct API call with filtered params - expect(mockHttpClient.get).toHaveBeenCalledWith('/v2/textItems', { - params: { - filter: '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"}]}' - } + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: + '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"}]}', + }, }); - - // Verify only base and variant-a files were created + }); + + it("supports the default filter behavior", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: "{}", + }, + }); + }); + }); + + describe("Output files", () => { + it("should create output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + // project-1 and project-2 each have at least one base text item + const baseTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-1", + }), + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-2", + }), + createMockTextItem({ + projectId: "project-2", + variantId: null, + id: "text-3", + }), + ]; + + // project-1 and project-2 each have a variant-a text item + const variantATextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-a", + id: "text-4", + }), + createMockTextItem({ + projectId: "project-2", + variantId: "variant-a", + id: "text-5", + }), + ]; + + // Only project-1 has variant-b, so only project-1 should get a variant-b file + const variantBTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-6", + }), + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-7", + }), + ]; + + setupMocks( + [...baseTextItems, ...variantATextItems, ...variantBTextItems], + [] + ); + + await pull(); + + // Verify a file was created for each project and variant present in the (mocked) API response assertFilesCreated(outputDir, [ "project-1___base.json", "project-1___variant-a.json", + "project-1___variant-b.json", + "project-2___base.json", + "project-2___variant-a.json", "variables.json", ]); }); diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index f63cfbc..173fc5b 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -12,19 +12,24 @@ export interface PullQueryParams { richText?: "html"; } -const TextItemsResponse = z.array( - z.object({ - id: z.string(), - text: z.string(), - richText: z.string().optional(), - status: z.string(), - notes: z.string(), - tags: z.array(z.string()), - variableIds: z.array(z.string()), - projectId: z.string(), - variantId: z.string().nullable(), - }) -); +const ZTextItem = z.object({ + id: z.string(), + text: z.string(), + richText: z.string().optional(), + status: z.string(), + notes: z.string(), + tags: z.array(z.string()), + variableIds: z.array(z.string()), + projectId: z.string(), + variantId: z.string().nullable(), +}); + +/** + * Represents a single text item, as returned from the /v2/textItems endpoint + */ +export type TextItem = z.infer; + +const TextItemsResponse = z.array(ZTextItem); export type TextItemsResponse = z.infer;