diff --git a/docs/ResultValue.md b/docs/ResultValue.md index 38898e65..ccee9172 100644 --- a/docs/ResultValue.md +++ b/docs/ResultValue.md @@ -88,7 +88,7 @@ The above code will print the list field as a bullet list, but all the values wi The map method takes a function that takes the value and returns a new value. It can be used when none of the provided printing are enough for your use case, or when one of them is almost what you need but you need to transform the value a bit more. -### `trimmed`,`lower`,`upper`,`capitalized`,`slug`,`snake` shortcuts +### `trimmed`,`lower`,`upper`,`capitalized`,`slug`,`snake`,`unique` shortcuts The ResultValue class provides some shortcuts to common transformations of the value. They are: @@ -99,6 +99,7 @@ They are: - `capitalized`: Uppercases the first character and leaves the rest untouched. Chain after `lower` (e.g. `result.getValue('name').lower.capitalized`) if you also want the remaining characters lowercased. - `slug`: Converts the value to a URL/filename-friendly slug. Lowercases the value, turns whitespace and underscores into `-`, strips punctuation, collapses runs of dashes, and trims edge dashes. Unicode letters/numbers are preserved so `Café Noël` becomes `café-noël`. Handy for turning a form's title into a filename: `result.getValue('title').slug`. - `snake`: Converts the value to `snake_case`. Same shape as `slug`, but whitespace and dashes become underscores, runs of underscores collapse, and edge underscores are trimmed. Unicode letters/numbers are preserved so `Café Noël` becomes `café_noël`. Handy for deriving variable names, YAML keys, or database columns: `result.getValue('title').snake`. +- `unique`: Removes duplicate items from an array value, preserving the order of first occurrence. Non-array values are returned unchanged (a single value is trivially unique). Useful for cleaning up multiselect fields where the same option might appear more than once: `result.getValue('tags').unique.bullets`. All of these shortcuts return a new ResultValue object, so you can chain them with other methods. diff --git a/docs/templates.md b/docs/templates.md index 12d90db0..23989096 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -118,6 +118,17 @@ The following transformations can be applied to variables: - Input `Hello, World!` produces `hello_world`; input ` My Note (2024) ` produces `my_note_2024`. +8. **`unique`**: Removes duplicate items from an array value, preserving the order of first occurrence. + - Non-array values are returned unchanged (a single value is trivially unique), so the transformation is safe to use on any field. + - Useful for cleaning up multiselect or tag fields where the same option might have been picked more than once. + - Usage: + + ```plaintext + {{ tags | unique }} + ``` + + - Input `["foo", "bar", "foo", "baz"]` produces `foo,bar,baz`. + ### Example Templates Here are some examples of how to use the new template syntax: diff --git a/src/core/ResultValue.test.ts b/src/core/ResultValue.test.ts index 66726ca5..48a06bb6 100644 --- a/src/core/ResultValue.test.ts +++ b/src/core/ResultValue.test.ts @@ -378,6 +378,36 @@ describe("ResultValue", () => { expect(resultValue.trimmed.snake.toString()).toEqual("hello_world"); }); }); + describe("unique", () => { + it("should remove duplicate items from an array preserving first-seen order", () => { + const resultValue = ResultValue.from(["foo", "bar", "foo", "baz", "bar"], "Test"); + expect(resultValue.unique.toString()).toEqual("foo, bar, baz"); + }); + it("should render a deduped array as bullets", () => { + const resultValue = ResultValue.from(["foo", "bar", "foo"], "Test"); + expect(resultValue.unique.bullets).toEqual("- foo\n- bar"); + }); + it("should leave a string value unchanged", () => { + const resultValue = ResultValue.from("hello", "Test"); + expect(resultValue.unique.toString()).toEqual("hello"); + }); + it("should leave a number value unchanged", () => { + const resultValue = ResultValue.from(42, "Test"); + expect(resultValue.unique.toString()).toEqual("42"); + }); + it("should return an empty string for an empty array", () => { + const resultValue = ResultValue.from([], "Test"); + expect(resultValue.unique.toString()).toEqual(""); + }); + it("should preserve non-string primitives inside the array", () => { + const resultValue = ResultValue.from([1, 2, 1, 3, 2], "Test"); + expect(resultValue.unique.toString()).toEqual("1, 2, 3"); + }); + it("should be chainable with other shortcuts", () => { + const resultValue = ResultValue.from(["Foo", "BAR", "foo", "bar"], "Test"); + expect(resultValue.lower.unique.toString()).toEqual("foo, bar"); + }); + }); describe("chaining shortcuts", () => { it("should be possible to chain upper, lower and trim", () => { // Arrange diff --git a/src/core/ResultValue.ts b/src/core/ResultValue.ts index 7078b996..4cfe8086 100644 --- a/src/core/ResultValue.ts +++ b/src/core/ResultValue.ts @@ -1,7 +1,7 @@ import { E, O, ensureError, pipe } from "@std"; import { notifyError } from "src/utils/Log"; import { FileProxy } from "./files/FileProxy"; -import { toSlug, toSnake } from "./template/templateParser"; +import { toSlug, toSnake, uniqueValues } from "./template/templateParser"; function _toBulletList(value: Record | unknown[]) { if (Array.isArray(value)) { @@ -245,6 +245,18 @@ export class ResultValue { return this.map((v) => deepMap(v, (it) => (typeof it === "string" ? toSnake(it) : it))); } + /** + * getter that removes duplicate items from an array value, preserving + * the order of first occurrence. Non-array values (strings, numbers, + * booleans, `FileProxy`, records) are returned unchanged since a single + * value is trivially unique. Useful for cleaning up multiselect fields + * where the same option might appear more than once. + */ + get unique(): ResultValue { + if (!Array.isArray(this.value)) return this; + return new ResultValue(uniqueValues(this.value), this.name, this.notify); + } + /** * renders the value as a markdown link. * If the value is a string, it will be rendered as a markdown link. diff --git a/src/core/template/templateParser.test.ts b/src/core/template/templateParser.test.ts index 642ecf74..fe7571cd 100644 --- a/src/core/template/templateParser.test.ts +++ b/src/core/template/templateParser.test.ts @@ -411,6 +411,50 @@ describe("parseTemplate", () => { expect(result).toEqual(E.of("my_photopng")); }); + it("unique removes duplicate items from an array preserving first-seen order", () => { + const template = "{{tags|unique}}"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => + executeTemplate(parsedTemplate, { tags: ["foo", "bar", "foo", "baz", "bar"] }), + ), + ); + expect(result).toEqual(E.of("foo,bar,baz")); + }); + + it("unique leaves an array without duplicates unchanged", () => { + const template = "{{tags|unique}}"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => + executeTemplate(parsedTemplate, { tags: ["foo", "bar", "baz"] }), + ), + ); + expect(result).toEqual(E.of("foo,bar,baz")); + }); + + it("unique on a non-array value returns the value as a string", () => { + const template = "{{name|unique}}"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John" })), + ); + expect(result).toEqual(E.of("John")); + }); + + it("unique on an empty array produces an empty string", () => { + const template = "[{{tags|unique}}]"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { tags: [] })), + ); + expect(result).toEqual(E.of("[]")); + }); + it("should parse a frontmatter command", () => { const template = "{#frontmatter#}"; const result = parseTemplate(template); diff --git a/src/core/template/templateParser.ts b/src/core/template/templateParser.ts index 9620eccd..d6822a61 100644 --- a/src/core/template/templateParser.ts +++ b/src/core/template/templateParser.ts @@ -279,6 +279,13 @@ function applyPerString(fn: (s: string) => string): (v: Val) => string { }; } +// Removes duplicate items from an array, preserving the order of first +// occurrence. Non-array values are returned as-is (a single value is +// trivially unique) so the transformation is safe to use on any field. +export function uniqueValues(value: T[]): T[] { + return Array.from(new Set(value)); +} + export function executeTransformation( transformation: Transformations | undefined, ): (value: Val) => string { @@ -304,6 +311,8 @@ export function executeTransformation( return applyPerString(toSlug)(value); case "snake": return applyPerString(toSnake)(value); + case "unique": + return Array.isArray(value) ? uniqueValues(value).join(",") : String(value); default: return absurd(transformation); } diff --git a/src/core/template/templateSchema.ts b/src/core/template/templateSchema.ts index ef6c2993..c13f992e 100644 --- a/src/core/template/templateSchema.ts +++ b/src/core/template/templateSchema.ts @@ -25,6 +25,7 @@ export const transformations = union([ literal("capitalize"), literal("slug"), literal("snake"), + literal("unique"), ]); export type Transformations = Output;