From 4cf9bdec857405500a915995992294a534def36b Mon Sep 17 00:00:00 2001 From: Joey Holiga Date: Fri, 27 Mar 2026 14:43:26 -0400 Subject: [PATCH] support filtering by tags --- lib/src/formatters/json.test.ts | 2 + lib/src/formatters/shared/base.test.ts | 148 ++++++++++++++++++++++++ lib/src/formatters/shared/base.ts | 10 ++ lib/src/formatters/shared/baseExport.ts | 10 +- lib/src/http/types.ts | 8 ++ lib/src/outputs/shared.ts | 3 +- package.json | 2 +- 7 files changed, 177 insertions(+), 6 deletions(-) diff --git a/lib/src/formatters/json.test.ts b/lib/src/formatters/json.test.ts index 0d45514..974f2ac 100644 --- a/lib/src/formatters/json.test.ts +++ b/lib/src/formatters/json.test.ts @@ -508,6 +508,7 @@ describe("JSONFormatter", () => { projects: projectConfig.projects, variants: projectConfig.variants, statuses: projectConfig.statuses, + tags: projectConfig.tags, }; expect(mockFetchTextItems).toHaveBeenCalledWith( { @@ -562,6 +563,7 @@ describe("JSONFormatter", () => { folders: projectConfig.components?.folders, variants: projectConfig.variants, statuses: projectConfig.statuses, + tags: projectConfig.tags, }; expect(mockFetchComponents).toHaveBeenCalledWith( { diff --git a/lib/src/formatters/shared/base.test.ts b/lib/src/formatters/shared/base.test.ts index 7765f06..61529d1 100644 --- a/lib/src/formatters/shared/base.test.ts +++ b/lib/src/formatters/shared/base.test.ts @@ -250,6 +250,62 @@ describe("BaseFormatter", () => { integrated: false, }); }); + + it("should use projectConfig tags when output does not override", () => { + const projectConfig = createMockProjectConfig({ + tags: { values: ["tag-1", "tag-2"], operator: "AND" }, + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters.tags).toEqual({ + values: ["tag-1", "tag-2"], + operator: "AND", + }); + }); + + it("should override tags with output.tags when provided", () => { + const projectConfig = createMockProjectConfig({ + tags: { values: ["tag-1"] }, + }); + const output = createMockOutput({ + tags: { values: ["tag-2", "tag-3"], operator: "OR" }, + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters.tags).toEqual({ + values: ["tag-2", "tag-3"], + operator: "OR", + }); + }); + + it("should use tags with only values and no operator", () => { + const projectConfig = createMockProjectConfig({ + tags: { values: ["tag-1"] }, + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters.tags).toEqual({ values: ["tag-1"] }); + }); }); /*********************************************************** @@ -461,6 +517,50 @@ describe("BaseFormatter", () => { integrated: false, }); }); + + it("should use projectConfig tags when output does not override", () => { + const filters = getComponentPullFilters({ + components: { + folders: [{ id: "folder1" }], + }, + tags: { values: ["tag-1", "tag-2"], operator: "AND" }, + }); + + expect(filters.tags).toEqual({ + values: ["tag-1", "tag-2"], + operator: "AND", + }); + }); + + it("should override tags with output.tags when provided", () => { + const filters = getComponentPullFilters( + { + components: { + folders: [{ id: "folder1" }], + }, + tags: { values: ["tag-1"] }, + }, + { + tags: { values: ["tag-2", "tag-3"], operator: "OR" }, + } + ); + + expect(filters.tags).toEqual({ + values: ["tag-2", "tag-3"], + operator: "OR", + }); + }); + + it("should use tags with only values and no operator", () => { + const filters = getComponentPullFilters({ + components: { + folders: [{ id: "folder1" }], + }, + tags: { values: ["tag-1"] }, + }); + + expect(filters.tags).toEqual({ values: ["tag-1"] }); + }); }); /*********************************************************** @@ -494,6 +594,54 @@ describe("BaseFormatter", () => { expect(params.richText).toBeUndefined(); }); + it("should generate query params with tags in text item filters", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + tags: { values: ["tag-1"], operator: "AND" }, + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams( + formatter.generateTextItemPullFilter() + ); + + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter.tags).toEqual({ + values: ["tag-1"], + operator: "AND", + }); + }); + + it("should generate query params with tags in component filters", () => { + const projectConfig = createMockProjectConfig({ + components: { + folders: [{ id: "folder1" }], + }, + tags: { values: ["tag-1", "tag-2"], operator: "OR" }, + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams( + formatter.generateComponentPullFilter() + ); + + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter.tags).toEqual({ + values: ["tag-1", "tag-2"], + operator: "OR", + }); + }); + it("should generate query params for provided optional text item filters", () => { const projectConfig = createMockProjectConfig({ projects: [{ id: "project1" }], diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 2340c70..5ad483a 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -41,6 +41,7 @@ export default class BaseFormatter { variants: this.projectConfig.variants, statuses: this.projectConfig.statuses, integrated: this.projectConfig.integrated, + tags: this.projectConfig.tags, }; if (this.output.projects) { @@ -59,6 +60,10 @@ export default class BaseFormatter { filters.integrated = this.output.integrated; } + if (this.output.tags) { + filters.tags = this.output.tags; + } + return filters; } @@ -70,6 +75,7 @@ export default class BaseFormatter { variants: this.projectConfig.variants, statuses: this.projectConfig.statuses, integrated: this.projectConfig.integrated, + tags: this.projectConfig.tags, }; if (this.output.components) { @@ -88,6 +94,10 @@ export default class BaseFormatter { filters.integrated = this.output.integrated; } + if (this.output.tags) { + filters.tags = this.output.tags; + } + return filters; } diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 075c2a5..c4e8434 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -141,11 +141,13 @@ export default abstract class BaseExportFormatter< // map "base" to undefined, as by default export endpoint returns base variant const variantId = variant.id === BASE_VARIANT_ID ? undefined : variant.id; + const { statuses, integrated, tags } = super.generateTextItemPullFilter(); const params: PullQueryParams = { ...super.generateQueryParams({ projects: [{ id: project.id }], - statuses: super.generateTextItemPullFilter().statuses, - integrated: super.generateTextItemPullFilter().integrated, + statuses, + integrated, + tags, }), variantId, format: this.exportFormat, @@ -180,10 +182,10 @@ export default abstract class BaseExportFormatter< for (const variant of this.variants) { // map "base" to undefined, as by default export endpoint returns base variant const variantId = variant.id === BASE_VARIANT_ID ? undefined : variant.id; - const { folders, statuses } = super.generateComponentPullFilter(); + const { folders, statuses, tags } = super.generateComponentPullFilter(); const params: PullQueryParams = { // gets folders from base component pull filters, overwrites variants with just this iteration's variant - ...super.generateQueryParams({ folders, statuses }), + ...super.generateQueryParams({ folders, statuses, tags }), variantId, format: this.exportFormat, }; diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 263619c..96f6deb 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -9,6 +9,7 @@ export interface PullFilters { variants?: { id: string }[]; statuses?: ITextStatus[]; integrated?: boolean; + tags?: ITagsFilter; } export interface PullQueryParams { filter: string; // Stringified PullFilters @@ -24,6 +25,12 @@ export interface PullQueryParams { export const ZTextStatus = z.enum(["NONE", "WIP", "REVIEW", "FINAL"]); export type ITextStatus = z.infer; +export const ZTagsFilter = z.object({ + values: z.array(z.string()), + operator: z.enum(["AND", "OR"]).optional(), +}); +export type ITagsFilter = z.infer; + export const ZTextPluralType = z.enum([ "zero", "one", @@ -166,6 +173,7 @@ export const ZExportSwiftFileRequest = z.object({ .optional(), statuses: z.array(ZTextStatus).optional(), integrated: z.boolean().optional(), + tags: ZTagsFilter.optional(), }); export type IExportSwiftFileRequest = z.infer; diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index b498d7f..9cec7b8 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ZTextStatus } from "../http/types"; +import { ZTagsFilter, ZTextStatus } from "../http/types"; /** * These filters that are common to all outputs, used to filter the text items and components that are fetched from the API. @@ -21,6 +21,7 @@ export const ZBaseOutputFilters = z.object({ .optional(), statuses: z.array(ZTextStatus).optional(), integrated: z.boolean().optional(), + tags: ZTagsFilter.optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), richText: z.union([z.literal("html"), z.literal(false)]).optional(), diff --git a/package.json b/package.json index 0b5a46c..6dae159 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.4.0", + "version": "5.5.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js",