Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/ResultValue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions src/core/ResultValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/core/ResultValue.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | unknown[]) {
if (Array.isArray(value)) {
Expand Down Expand Up @@ -245,6 +245,18 @@ export class ResultValue<T = unknown> {
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<unknown> {
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.
Expand Down
44 changes: 44 additions & 0 deletions src/core/template/templateParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/core/template/templateParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(value: T[]): T[] {
return Array.from(new Set(value));
}

export function executeTransformation(
transformation: Transformations | undefined,
): (value: Val) => string {
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/core/template/templateSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const transformations = union([
literal("capitalize"),
literal("slug"),
literal("snake"),
literal("unique"),
]);

export type Transformations = Output<typeof transformations>;
Expand Down
Loading