Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: readability changes #57

Merged
merged 5 commits into from
Mar 9, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ exports[`PeriodicNotes > pre-conditions > should throw when everything is invali
[AggregateError: invalid config
AssertionError: folder must be non-empty
AssertionError: date format must parse the formatted strings it produces
AssertionError: interval offset is invalid: invalid time: unspecified
AssertionError: interval duration is invalid: invalid time: infinity]
AssertionError: interval duration is invalid: invalid time: infinity
AssertionError: interval offset is invalid: invalid time: unspecified]
`;

exports[`PeriodicNotes > pre-conditions > should throw when folder is empty 1`] = `
Expand Down
10 changes: 5 additions & 5 deletions src/model/collection/__tests__/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { describe, expect, it } from "vitest";

import { sanitizeFolder } from "../util";
import { stripTrailingSlash } from "../util";

describe(`${sanitizeFolder.name}`, () => {
describe(`${stripTrailingSlash.name}`, () => {
it("should strip trailing slashes", () => {
expect(sanitizeFolder("foo/")).toEqual("foo");
expect(stripTrailingSlash("foo/")).toEqual("foo");
});

it("should do nothing if there are no trailing slashes", () => {
expect(sanitizeFolder("foo")).toEqual("foo");
expect(stripTrailingSlash("foo")).toEqual("foo");
});

it("should do nothing when folder is the vault root", () => {
expect(sanitizeFolder("/")).toEqual("/");
expect(stripTrailingSlash("/")).toEqual("/");
});
});
97 changes: 54 additions & 43 deletions src/model/collection/periodic-notes.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,13 @@
import assert from "assert";
import { attempt, isError } from "lodash";
import { notStrictEqual as assertNotStrictEqual } from "assert";
import { attempt, clone, isError } from "lodash";
import { DateTime, DateTimeOptions, Duration, DurationLike, Interval, IntervalMaybeValid } from "luxon";
import { parse } from "path";
import { DeepReadonly } from "utility-types";

import { assertLuxonFormat, assertValid } from "@/util/luxon-utils";

import { DateBasedCollection } from "./schema";
import { sanitizeFolder } from "./util";

/**
* Configuration options for {@link PeriodicNotes}.
* @param IsValidConfiguration - whether the configuration is valid.
*/
export type PeriodicNotesConfig<IsValidConfiguration extends boolean> =
IsValidConfiguration extends true ?
{
/** {@inheritDoc PeriodicNotes.folder} */
folder: string;
/** {@inheritDoc PeriodicNotes.dateFormat} */
dateFormat: string;
/** {@inheritDoc PeriodicNotes.dateOptions} */
dateOptions: DateTimeOptions;
/** {@inheritDoc PeriodicNotes.intervalDuration} */
intervalDuration: Duration<true>;
/** {@inheritDoc PeriodicNotes.intervalOffset} */
intervalOffset: Duration<true>;
}
: {
/** {@inheritDoc PeriodicNotes.folder} */
folder: string;
/** {@inheritDoc PeriodicNotes.dateFormat} */
dateFormat: string;
/** {@inheritDoc PeriodicNotes.dateOptions} */
dateOptions?: DateTimeOptions;
/** {@inheritDoc PeriodicNotes.intervalDuration} */
intervalDuration: DurationLike;
/** {@inheritDoc PeriodicNotes.intervalOffset} */
intervalOffset?: DurationLike;
};
import { stripTrailingSlash } from "./util";

/**
* Intended to handle the popular "Periodic Notes" community plugin.
Expand All @@ -55,7 +25,7 @@ export class PeriodicNotes extends DateBasedCollection implements PeriodicNotesC
public readonly dateFormat: string;

/** Luxon options used when parsing {@link DateTime}s from file names. */
public readonly dateOptions: DateTimeOptions;
public readonly dateOptions: DeepReadonly<DateTimeOptions>;

/**
* The {@link Duration} of each file's corresponding {@link Interval}.
Expand Down Expand Up @@ -101,23 +71,64 @@ export class PeriodicNotes extends DateBasedCollection implements PeriodicNotesC
}

/**
* @param config - the config to validate.
* @throws if any of the config's properties are invalid.
* @returns a valid config.
* Configuration options for {@link PeriodicNotes}.
* @typeParam IsValidConfiguration - whether the configuration is valid.
*/
export type PeriodicNotesConfig<IsValidConfiguration extends boolean> =
IsValidConfiguration extends true ?
{
/** {@inheritDoc PeriodicNotes.folder} */
folder: string;
/** {@inheritDoc PeriodicNotes.dateFormat} */
dateFormat: string;
/** {@inheritDoc PeriodicNotes.dateOptions} */
dateOptions: DateTimeOptions;
/** {@inheritDoc PeriodicNotes.intervalDuration} */
intervalDuration: Duration<true>;
/** {@inheritDoc PeriodicNotes.intervalOffset} */
intervalOffset: Duration<true>;
}
: {
/** {@inheritDoc PeriodicNotes.folder} */
folder: string;
/** {@inheritDoc PeriodicNotes.dateFormat} */
dateFormat: string;
/** {@inheritDoc PeriodicNotes.dateOptions} */
dateOptions?: DateTimeOptions;
/** {@inheritDoc PeriodicNotes.intervalDuration} */
intervalDuration: DurationLike;
/** {@inheritDoc PeriodicNotes.intervalOffset} */
intervalOffset?: DurationLike;
};

/**
* Validates and normalizes a PeriodicNotes configuration object.
*
* This function checks that:
* - the folder path (after stripping any trailing slash) is non-empty,
* - the date format is valid with the given date options,
* - the interval duration is a valid, non-zero duration, and
* - the interval offset is a valid duration.
*
* If any of these validations fail, an {@link AggregateError} is thrown containing details
* about each issue. On success, a new configuration object with strictly defined properties is returned.
* @param config - The configuration object to validate.
* @returns A validated configuration object with normalized properties.
* @throws If one or more configuration properties are invalid.
*/
function validated(config: PeriodicNotesConfig<false>): PeriodicNotesConfig<true> {
const folder = sanitizeFolder(config.folder);
const folder = stripTrailingSlash(config.folder);
const dateFormat = config.dateFormat;
const dateOptions = config.dateOptions ?? {};
const dateOptions = clone(config.dateOptions ?? {});
const intervalDuration = Duration.fromDurationLike(config.intervalDuration);
const intervalOffset = Duration.fromDurationLike(config.intervalOffset ?? 0);

const errors = [
attempt(() => assert(folder.length > 0, "folder must be non-empty")),
attempt(() => assertNotStrictEqual(folder.length, 0, "folder must be non-empty")),
attempt(() => assertLuxonFormat(dateFormat, dateOptions)),
attempt(() => assertValid(intervalOffset, "interval offset is invalid")),
attempt(() => assertValid(intervalDuration, "interval duration is invalid")),
attempt(() => assert(intervalDuration.valueOf() !== 0, "interval duration must not be zero")),
attempt(() => assertNotStrictEqual(intervalDuration.valueOf(), 0, "interval duration must not be zero")),
attempt(() => assertValid(intervalOffset, "interval offset is invalid")),
].filter(isError);

if (errors.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion src/model/collection/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export abstract class Collection {
public abstract includes(filePath: string): boolean;
}

/** A {@link Collection} where each note corresponds to a unique {@link luxon.Interval} of time. */
/** Interface for {@link Collection} where each note corresponds to a unique {@link luxon.Interval} of time. */
export abstract class DateBasedCollection extends Collection {
/**
* @param filePath - the path to check.
Expand Down
9 changes: 6 additions & 3 deletions src/model/collection/util.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/**
* @param folder - the folder to sanitize.
* @returns sanitized value with trailing slashes removed, if applicable.
* Returns a sanitized folder path by removing a trailing slash unless the path is the root ("/").
*
* The function trims whitespace from the input folder string. If the trimmed string equals "/", it is returned unchanged. Otherwise, any trailing slash is removed.
* @param folder - The folder path to sanitize.
* @returns The folder path without a trailing slash, except for the root path.
*/
export function sanitizeFolder(folder: string): string {
export function stripTrailingSlash(folder: string): string {
folder = folder.trim();
return folder === "/" ? folder : folder.replace(/\/$/, "");
}
2 changes: 1 addition & 1 deletion src/model/index/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DeepReadonly } from "utility-types";

import { Collection } from "../collection/schema";

/** Provides an API for efficiently accessing {@link Collection}s in a vault. */
/** API for efficiently accessing the notes of a {@link Collection}. */
export interface VaultIndex {
/**
* Register a new {@link Collection} into the index.
Expand Down
19 changes: 8 additions & 11 deletions src/model/task/schema.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,26 @@ import { DateTime } from "luxon";

import { Task, TaskSource, TaskStatus } from "./schema";

/** The default date used by {@link Task}s. Valid dates will always take precedence over invalid dates. */
export const DEFAULT_DATETIME_VALUE: DateTime = DateTime.invalid("unspecified");

/** The default priority used by {@link Task}s. Different values will always take precedence over this value. */
export const DEFAULT_PRIORITY_VALUE: Task["priority"] = 3;
export const DEFAULT_PRIORITY_VALUE = 3 as const satisfies Task["priority"];

/**
* The default type used by {@link TaskSource} and {@link TaskStatus}.
* Different values will always take precedence over this value.
*/
export const DEFAULT_TYPE_VALUE: TaskSource["type"] & TaskStatus["type"] = "UNKNOWN";
export const DEFAULT_TYPE_VALUE = "UNKNOWN" as const satisfies TaskSource["type"] & TaskStatus["type"];

/** A strongly-typed {@link Task} with all-default values. */
export const TASK_WITH_DEFAULT_VALUES: Task = {
status: { type: DEFAULT_TYPE_VALUE },
source: { type: DEFAULT_TYPE_VALUE },
dates: {
cancelled: DEFAULT_DATETIME_VALUE,
created: DEFAULT_DATETIME_VALUE,
done: DEFAULT_DATETIME_VALUE,
due: DEFAULT_DATETIME_VALUE,
scheduled: DEFAULT_DATETIME_VALUE,
start: DEFAULT_DATETIME_VALUE,
cancelled: DateTime.invalid("unspecified"),
created: DateTime.invalid("unspecified"),
done: DateTime.invalid("unspecified"),
due: DateTime.invalid("unspecified"),
scheduled: DateTime.invalid("unspecified"),
start: DateTime.invalid("unspecified"),
},
description: "",
priority: DEFAULT_PRIORITY_VALUE,
Expand Down
32 changes: 6 additions & 26 deletions src/model/task/schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { DateTime } from "luxon";

export {
DEFAULT_DATETIME_VALUE,
DEFAULT_PRIORITY_VALUE,
DEFAULT_TYPE_VALUE,
TASK_WITH_DEFAULT_VALUES,
} from "./schema.const";
export { DEFAULT_PRIORITY_VALUE, DEFAULT_TYPE_VALUE, TASK_WITH_DEFAULT_VALUES } from "./schema.const";

/** Objo-related metadata for the tasks in a user's vault. */
export interface Task {
Expand Down Expand Up @@ -43,39 +38,24 @@ export interface Task {
/** Metadata about the actionable status of a task. */
export type TaskStatus =
| {
/**
* The status of the task. Mirrors the status provided by the "obsidian-tasks" plugin.
* `"UNKNOWN"` is reserved for tasks that are invalid or unparsable.
*/
/** `"UNKNOWN"` is reserved for tasks that are invalid or unparsable. */
type: "UNKNOWN";
}
| {
/**
* The status of the task. Mirrors the status provided by the "obsidian-tasks" plugin.
* `"UNKNOWN"` is reserved for tasks that are invalid or unparsable.
*/
/** The status of the task. Mirrors the status provided by the "obsidian-tasks" plugin. */
type: "OPEN" | "DONE" | "CANCELLED" | "NON_TASK";
/**
* The character inside the `[ ]` brackets on the line with the task.
* Generally a space (" ") for incomplete tasks and an ("x") for completed tasks.
*/
/** The character inside the `[ ]` brackets on the line with the task. */
symbol: string;
};

/** Metadata about where a task was extracted/generated from. */
export type TaskSource =
| {
/**
* Identifies where this task was extracted/generated from.
* `"UNKNOWN"` is reserved for tasks that are invalid or unparsable.
*/
/** `"UNKNOWN"` is reserved for tasks that are invalid or unparsable. */
type: "UNKNOWN";
}
| {
/**
* Identifies where this task was extracted/generated from.
* `"UNKNOWN"` is reserved for tasks that are invalid or unparsable.
*/
/** Identifies where this task was extracted/generated from. */
type: "PAGE";
/** The full path of the file this task was taken from. */
path: string;
Expand Down
6 changes: 4 additions & 2 deletions src/model/task/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { DeepPartial } from "utility-types";
import { DEFAULT_PRIORITY_VALUE, DEFAULT_TYPE_VALUE, Task, TASK_WITH_DEFAULT_VALUES } from "@/model/task/schema";

/**
* @param parts - the task parts to merge.
* @returns a new {@link Task} with the front-most non-default values taken from the parts.
* Merges multiple partial Task objects into a complete Task using a custom merge strategy.
* The function begins with default Task values and sequentially merges each provided partial Task. For each property, the front-most non-default value is retained. Special handling is applied for properties such as task type, priority, strings, DateTime objects, and Set collections.
* @param parts - The partial Task objects to merge.
* @returns new {@link Task} with the front-most non-default values taken from the parts.
*/
export function mergeTaskParts(...parts: DeepPartial<Task>[]): Task {
const defaults = { ...TASK_WITH_DEFAULT_VALUES };
Expand Down
2 changes: 1 addition & 1 deletion src/render/preact/lib/obsidian/obsidian-markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import { MARKDOWN_RENDER_DEBOUNCE_TIME } from "./obsidian-markdown.const";
*/
export const ObsidianMarkdown: FunctionalComponent<ObsidianMarkdownProps> = (props) => {
const { app, component, markdown, sourcePath, tagName = "span", delay = MARKDOWN_RENDER_DEBOUNCE_TIME } = props;
const elRef = useRef<HTMLElement>();
const delayMs = useMemo(() => Duration.fromDurationLike(delay).toMillis(), [delay]);
const renderObsidianMarkdown = useDebounceCallback(MarkdownRenderer["render"], delayMs);
const elRef = useRef<HTMLElement>();

useEffect(() => {
const el = elRef.current;
Expand Down
23 changes: 13 additions & 10 deletions src/util/luxon-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AssertionError, ok as assert } from "assert";
import assert from "assert";
import { DateTime, DateTimeOptions, Duration, Interval } from "luxon";
import { Brand } from "utility-types";

Expand All @@ -9,20 +9,23 @@ export type LuxonValue<IsValid extends boolean> = DateTime<IsValid> | Duration<I
export type LuxonFormat = Brand<string, "LuxonFormat">;

/**
* @param value - the {@link LuxonValue} to check.
* @param message - the message to use in the error.
* @throws error if value is invalid.
* Asserts that the provided Luxon value is valid.
*
* This function checks whether a Luxon date, duration, or interval is valid. If the value is invalid,
* it throws an error with a message combining a custom header (or default constructor name) with the
* value's invalid reason and, if available, its invalid explanation.
* @param value - The Luxon object to validate.
* @param message - Optional custom header for the error message.
* @throws If the provided value is invalid.
*/
export function assertValid(
value: LuxonValue<true> | LuxonValue<false>,
message?: string,
): asserts value is LuxonValue<true> {
if (!value.isValid) {
const { invalidReason, invalidExplanation } = value;
const header = message ?? `Invalid ${value.constructor.name}`;
const reason = invalidExplanation ? `${invalidReason}: ${invalidExplanation}` : invalidReason;
throw new AssertionError({ message: `${header}: ${reason}`, actual: false, expected: true, operator: "==" });
}
const { invalidReason, invalidExplanation } = value;
const header = message ?? `Invalid ${value.constructor.name}`;
const reason = invalidExplanation ? `${invalidReason}: ${invalidExplanation}` : invalidReason;
assert(value.isValid, `${header}: ${reason}`);
}

/**
Expand Down