Property value loading (#80)

* docs: add more features

* refactor: move to services

* feat: update loadPropertyValue to handle edge cases

* test: add typescript support for jest

* refactor: remove version script

* test: add test for loadPropertyValue

* test: remove paths
This commit is contained in:
DecafDev 2024-06-13 15:32:34 -06:00 committed by GitHub
parent 831773b459
commit 8e863ada96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 386 additions and 92 deletions

View file

@ -36,3 +36,11 @@ Features are what the software should do.
| Set a favorite property | #settings |
| Set text properties | #settings |
| Set the log level | #settings |
| When is a file deleted, refresh the view | |
| When is the metadata of a file changes, refresh the view | |
| When a file is renamed, refresh the view | |
| When a file is created, refresh the view | |
| When a file is modified, refresh the view | |
| Refresh time variables every minute | |
| When a setting changes, update the view | |
| Move focus with left and right arrows | |

7
__mocks__/js-logger.ts Normal file
View file

@ -0,0 +1,7 @@
export default {
log: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};

8
__mocks__/obsidian.ts Normal file
View file

@ -0,0 +1,8 @@
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
const DATE_TIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
export const moment = jest.fn((date, formats, strict) => {
return {
isValid: jest.fn(() => DATE_REGEX.test(date) || DATE_TIME_REGEX.test(date)),
};
});

BIN
bun.lockb

Binary file not shown.

8
jest.config.js Normal file
View file

@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
moduleNameMapper: {
"^src/(.*)$": "<rootDir>/src/$1",
},
};

View file

@ -6,7 +6,7 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"test": "jest --config jest.config.js"
},
"keywords": [],
"author": "DecafDev",
@ -14,6 +14,7 @@
"devDependencies": {
"@tsconfig/svelte": "^5.0.4",
"@types/bun": "latest",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
@ -21,8 +22,10 @@
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"esbuild-svelte": "^0.8.0",
"jest": "^29.7.0",
"obsidian": "latest",
"svelte-preprocess": "^5.1.4",
"ts-jest": "^29.1.4",
"tslib": "2.4.0",
"typescript": "4.7.4"
},

View file

@ -32,7 +32,7 @@
getStartOfLastWeekMillis,
getStartOfTodayMillis,
getStartOfThisWeekMillis,
} from "./services/time-utils";
} from "../shared/services/time-utils";
let plugin: VaultExplorerPlugin;

View file

@ -1,8 +1,9 @@
import { FrontMatterCache } from "obsidian";
import { CheckboxFilterCondition, DateFilterCondition, ListFilterCondition, NumberFilterCondition, PropertyFilter, PropertyFilterGroup } from "src/types";
import { FilterCondition, TextFilterCondition } from "src/types";
import Logger from "js-logger";
import { getEndOfDayMillis, getStartOfDayMillis, getTimeMillis, isDateSupported } from "../time-utils";
import { getEndOfDayMillis, getStartOfDayMillis, getTimeMillis } from "../../../shared/services/time-utils";
import { loadPropertyValue } from "src/svelte/shared/services/property-utils";
//Tests
//Group is enabled/disabled
@ -48,56 +49,29 @@ const filterByProperty = (frontmatter: FrontMatterCache | undefined, filter: Pro
const { propertyName, condition, value, type, matchWhenPropertyDNE } = filter;
if (propertyName === "") return true;
let propertyValue: (string | string[] | boolean | number | null) = frontmatter?.[propertyName] ?? null;
if (type === "text") {
//If the value is not a string, skip the filter
if (propertyValue !== null && typeof propertyValue !== "string") {
Logger.warn(`Property value is not a string: ${propertyValue}`);
return true;
}
const propertyValue = loadPropertyValue<string>(frontmatter, propertyName, type);
const doesMatch = doesTextMatchFilter(propertyValue, value, condition, matchWhenPropertyDNE);
return doesMatch;
} else if (type === "list") {
if (propertyValue !== null && !Array.isArray(propertyValue)) {
Logger.warn(`Property value is not an array: ${propertyValue}`);
return true;
}
const compare = value.split(",").map((v) => v.trim()).filter((v) => v !== "");
const doesMatch = doesListMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE);
return doesMatch;
} else if (type === "number") {
if (propertyValue !== null && typeof propertyValue !== "number") {
Logger.warn(`Property value is not a number: ${propertyValue}`);
return true;
}
const propertyValue = loadPropertyValue<number>(frontmatter, propertyName, type);
const compare = parseFloat(value);
const doesMatch = doesNumberMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE);
return doesMatch;
} else if (type === "checkbox") {
if (propertyValue !== null && typeof propertyValue !== "boolean") {
Logger.warn(`Property value is not a boolean: ${propertyValue}`);
return true;
}
const propertyValue = loadPropertyValue<boolean>(frontmatter, propertyName, type);
const compare = value === "true";
const doesMatch = doesCheckboxMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE);
return doesMatch;
} else if (type === "date" || type === "datetime") {
if (propertyValue !== null && typeof propertyValue !== "string") {
Logger.warn(`Property value is not a string: ${propertyValue}`);
return true;
//In older versions of Obsidian, the date could be stored in the frontmatter
//in an unsupported date format
} else if (propertyValue !== null && !isDateSupported(propertyValue)) {
Logger.warn(`Property value has unsupported date format: ${propertyValue}`);
return true;
}
const propertyValue = loadPropertyValue<string>(frontmatter, propertyName, type);
const doesMatch = doesDateMatchFilter(condition, propertyValue, value, matchWhenPropertyDNE);
return doesMatch;
} else if (type === "list") {
const propertyValue = loadPropertyValue<string[]>(frontmatter, propertyName, type);
const compare = value.split(",").map((v) => v.trim()).filter((v) => v !== "");
const doesMatch = doesListMatchFilter(propertyValue, compare, condition, matchWhenPropertyDNE);
return doesMatch;
} else {
throw new Error(`Property filter type not supported: ${type}`);
}

View file

@ -1,11 +1,12 @@
import { FrontMatterCache, TFile } from "obsidian";
import { VaultExplorerPluginSettings } from "src/types";
import { PropertyType, VaultExplorerPluginSettings } from "src/types";
import { MarkdownFileRenderData } from "../types";
import { getTimeMillis, isDateSupported } from "./time-utils";
import { getTimeMillis, isDateSupported } from "../../shared/services/time-utils";
import Logger from "js-logger";
import { loadPropertyValue } from "src/svelte/shared/services/property-utils";
export const formatFileDataForRender = (settings: VaultExplorerPluginSettings, file: TFile, frontmatter: FrontMatterCache | undefined,): MarkdownFileRenderData => {
const tags: string[] | null = loadPropertyValue(frontmatter, "tags", true);
const tags: string[] | null = loadPropertyValue<string[]>(frontmatter, "tags", PropertyType.LIST);
const {
createdDate: createdDateProp,
@ -17,14 +18,14 @@ export const formatFileDataForRender = (settings: VaultExplorerPluginSettings, f
custom3: custom3Prop,
} = settings.properties;
const url: string | null = loadPropertyValue(frontmatter, urlProp);
const favorite: string | null = loadPropertyValue(frontmatter, favoriteProp);
const creationDate: string | null = loadPropertyValue(frontmatter, createdDateProp);
const modifiedDate: string | null = loadPropertyValue(frontmatter, modifiedDateProp);
const url: string | null = loadPropertyValue<string>(frontmatter, urlProp, PropertyType.TEXT);
const favorite: boolean | null = loadPropertyValue<boolean>(frontmatter, favoriteProp, PropertyType.CHECKBOX);
const creationDate: string | null = loadPropertyValue<string>(frontmatter, createdDateProp, PropertyType.DATE || PropertyType.DATETIME);
const modifiedDate: string | null = loadPropertyValue<string>(frontmatter, modifiedDateProp, PropertyType.DATE || PropertyType.DATETIME);
const custom1: string | null = loadPropertyValue(frontmatter, custom1Prop);
const custom2: string | null = loadPropertyValue(frontmatter, custom2Prop);
const custom3: string | null = loadPropertyValue(frontmatter, custom3Prop);
const custom1: string | null = loadPropertyValue<string>(frontmatter, custom1Prop, PropertyType.TEXT);
const custom2: string | null = loadPropertyValue<string>(frontmatter, custom2Prop, PropertyType.TEXT);
const custom3: string | null = loadPropertyValue<string>(frontmatter, custom3Prop, PropertyType.TEXT);
let createdMillis = file.stat.ctime;
if (creationDate != null) {
@ -62,29 +63,3 @@ export const formatFileDataForRender = (settings: VaultExplorerPluginSettings, f
};
}
const loadPropertyValue = (frontmatter: FrontMatterCache | undefined, propertyName: string, isArray = false) => {
if (propertyName === "") {
return null;
}
if (!frontmatter) {
return null;
}
const propertyValue = frontmatter?.[propertyName];
if (isArray) {
if (propertyValue === undefined || propertyValue === null) {
return null;
} else if (Array.isArray(propertyValue)) {
return propertyValue;
} else {
//If the property is not an array, return it as an array
//This is a bug in Obsidian?
return [propertyValue];
}
}
return propertyValue ?? null;
}

View file

@ -2,7 +2,7 @@ export interface MarkdownFileRenderData {
name: string;
path: string;
tags: string[] | null;
favorite: string | null;
favorite: boolean | null;
url: string | null;
createdMillis: number;
modifiedMillis: number;

View file

@ -0,0 +1,235 @@
import { FrontMatterCache } from "obsidian";
import { PropertyType } from "src/types";
import { loadPropertyValue } from "./property-utils";
describe("loadPropertyValue", () => {
it("returns null if frontmatter is undefined", () => {
//Arrange
const frontmatter: FrontMatterCache | undefined = undefined;
//Act
const result = loadPropertyValue<string>(frontmatter, "test", PropertyType.TEXT);
//Assert
expect(result).toBeNull();
});
it("returns null if propertyName is empty", () => {
//Arrange
const frontmatter: FrontMatterCache = {};
const propertyName = "";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.TEXT);
//Assert
expect(result).toBeNull();
});
it("returns null if propertyValue is undefined", () => {
//Arrange
const frontmatter: FrontMatterCache = {};
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.TEXT);
//Assert
expect(result).toBeNull();
});
it("returns null if propertyValue is null", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: null };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.TEXT);
//Assert
expect(result).toBeNull();
});
it("returns null if expectedType is PropertyType.TEXT and value is not a string", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: 1 };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.TEXT);
//Assert
expect(result).toBeNull();
});
it("returns propertyValue if expectedType is PropertyType.TEXT and value is a string", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: "test" };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.TEXT);
//Assert
expect(result).toBe("test");
});
it("returns null if expectedType is PropertyType.NUMBER and value is not a number", () => {
//Arrange
const frontmatter: FrontMatterCache = { name: "test" };
const propertyName = "test";
//Act
const result = loadPropertyValue<number>(frontmatter, propertyName, PropertyType.NUMBER);
//Assert
expect(result).toBeNull();
});
it("returns propertyValue if expectedType is PropertyType.NUMBER and value is a number", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: 1 };
const propertyName = "test";
//Act
const result = loadPropertyValue<number>(frontmatter, propertyName, PropertyType.NUMBER);
//Assert
expect(result).toBe(1);
});
it("returns null if expectedType is PropertyType.CHECKBOX and value is not a boolean", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: "test" };
const propertyName = "test";
//Act
const result = loadPropertyValue<boolean>(frontmatter, propertyName, PropertyType.CHECKBOX);
//Assert
expect(result).toBeNull();
});
it("returns propertyValue if expectedType is PropertyType.TEXT and value is a boolean", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: true };
const propertyName = "test";
//Act
const result = loadPropertyValue<boolean>(frontmatter, propertyName, PropertyType.CHECKBOX);
//Assert
expect(result).toBe(true);
});
it("returns propertyValue as array if expectedType is PropertyType.LIST and value is an string", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: "test" };
const propertyName = "test";
//Act
const result = loadPropertyValue<string[]>(frontmatter, propertyName, PropertyType.LIST);
//Assert
expect(result).toEqual(["test"]);
});
it("returns propertyValue if expectedType is PropertyType.LIST and value is an array", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: ["test"] };
const propertyName = "test";
//Act
const result = loadPropertyValue<string[]>(frontmatter, propertyName, PropertyType.LIST);
//Assert
expect(result).toEqual(["test"]);
});
it("returns propertyValue without null values if expectedType is PropertyType.LIST and value is an array", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: ["test", null] };
const propertyName = "test";
//Act
const result = loadPropertyValue<string[]>(frontmatter, propertyName, PropertyType.LIST);
//Assert
expect(result).toEqual(["test"]);
});
it("returns null if expectedType is PropertyType.DATE and value is not a string", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: 1 };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.DATE);
//Assert
expect(result).toBeNull();
});
it("returns null if expectedType is PropertyType.DATETIME and value is not a supported format", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: '<% tp.file.creation_date() %>' };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.DATETIME);
//Assert
expect(result).toBeNull();
});
it("returns propertyValue if expectedType is PropertyType.DATE and value is a string", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: "2021-01-01" };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.DATE);
//Assert
expect(result).toBe("2021-01-01");
});
it("returns null if expectedType is PropertyType.DATETIME and value is not a string", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: 1 };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.DATETIME);
//Assert
expect(result).toBeNull();
});
it("returns null if expectedType is PropertyType.DATETIME and value is not a supported format", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: '<% tp.file.last_modified_date("dddd Do MMMM YYYY HH: mm: ss") %>' };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.DATETIME);
//Assert
expect(result).toBeNull();
});
it("returns propertyValue if expectedType is PropertyType.LIST and value is a string", () => {
//Arrange
const frontmatter: FrontMatterCache = { test: "2021-01-01T00:00:00" };
const propertyName = "test";
//Act
const result = loadPropertyValue<string>(frontmatter, propertyName, PropertyType.DATETIME);
//Assert
expect(result).toBe("2021-01-01T00:00:00");
});
});

View file

@ -0,0 +1,90 @@
import Logger from "js-logger";
import { FrontMatterCache } from "obsidian";
import { PropertyType } from "src/types";
import { isDateSupported } from "./time-utils";
/**
* Loads a property value from the frontmatter object
* @param frontmatter - The frontmatter object to load the property value from
* @param propertyName - The name of the property to load
* @param expectedType - The expected type of the property
* @returns - The property value or null if the property isn't valid
*/
export const loadPropertyValue = <T>(frontmatter: FrontMatterCache | undefined, propertyName: string, expectedType: PropertyType): T | null => {
//If the file has no frontmatter, return null
if (!frontmatter) {
return null;
}
//If the property name is empty, return null
//This can happen if the user has not set a property name
if (propertyName === "") {
return null;
}
const propertyValue = frontmatter[propertyName];
if (propertyValue === undefined || propertyValue === null) {
return null;
}
//Validate the property value for the expected type
if (expectedType === PropertyType.TEXT) {
if (typeof propertyValue !== "string") {
Logger.warn(`Property value of type 'text' is not a string: ${propertyValue}`);
return null;
}
} else if (expectedType === PropertyType.NUMBER) {
if (typeof propertyValue !== "number") {
Logger.warn(`Property value of type 'number' is not a number: ${propertyValue}`);
return null;
}
} else if (expectedType === PropertyType.DATE) {
if (typeof propertyValue !== "string") {
Logger.warn(`Property value of type 'date' is not a string: ${propertyValue}`);
return null;
}
} else if (expectedType === PropertyType.DATETIME) {
if (typeof propertyValue !== "string") {
Logger.warn(`Property value of type 'datetime' is not a string: ${propertyValue}`);
return null;
}
} else if (expectedType === PropertyType.CHECKBOX) {
if (typeof propertyValue !== "boolean") {
Logger.warn(`Property value of type 'checkbox' is not a boolean: ${propertyValue}`);
return null;
}
} else if (expectedType === PropertyType.LIST) {
if (!Array.isArray(propertyValue)) {
Logger.warn(`Property value of type 'list' is not an array: ${propertyValue}`);
//Don't return null here, because the property value can be converted to an array
}
}
//In older versions of Obsidian, the date can be stored in the frontmatter
//in a format other than YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
if (expectedType === PropertyType.DATE) {
if (!isDateSupported(propertyValue)) {
Logger.warn(`Property value of type 'date' has unsupported date format: ${propertyValue}`);
return null;
}
} else if (expectedType === PropertyType.DATETIME) {
if (!isDateSupported(propertyValue)) {
Logger.warn(`Property value of type 'datetime' has unsupported date format: ${propertyValue}`);
return null;
}
}
if (expectedType === PropertyType.LIST) {
//If the property is not an array, return it as an array
//This is a bug in Obsidian?
if (!Array.isArray(propertyValue)) {
return [propertyValue] as unknown as T;
}
//Filter out null and undefined values
return propertyValue.filter((v) => v !== null && v !== undefined) as unknown as T;
}
return propertyValue as T;
}

View file

@ -1,7 +1,7 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"types": ["svelte", "node"],
"types": ["svelte", "node", "jest"],
"baseUrl": ".",
"inlineSources": false,
"module": "ESNext",

View file

@ -1,14 +0,0 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));