From 461d95a6429e7d64e9224d6b40a2916dbf40de6d Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Tue, 4 Feb 2025 21:30:27 -0600 Subject: [PATCH 1/5] test: fix Jest tests - Add explicit dev dependency on `ts-node`, which fixes an configuration issue with TypeScript/`ts-jest` that would prevent tests from running. - Update the DateTime JSON conversion tests: - Update the deserializer logic so that it applies the zone offset to the resulting DateTime object if there is one in the ISO String - Use the ISO strings for equality comparisons, since the `.equals()` method on the DateTime object does a field-wise deep comparison, and zones don't serialize/deserialize symmetrically. --- package.json | 1 + src/index/types/json/common.ts | 2 +- src/test/index/json-conversion.test.ts | 13 ++- yarn.lock | 129 +++++++++++++++++++++++-- 4 files changed, 134 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 5e7c6eaf..8d058f0e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "obsidian": "^1.6.6", "prettier": "2.3.2", "ts-jest": "^27.0.5", + "ts-node": "^10.9.2", "tslib": "^2.3.1", "typescript": "^5.4.2" }, diff --git a/src/index/types/json/common.ts b/src/index/types/json/common.ts index 0367f0e5..55938745 100644 --- a/src/index/types/json/common.ts +++ b/src/index/types/json/common.ts @@ -61,7 +61,7 @@ export namespace JsonConversion { const type = json["$_type"]; switch (type) { case "date": - return normalizer(DateTime.fromISO(json.value)); + return normalizer(DateTime.fromISO(json.value, { setZone: true })); case "duration": return normalizer(Duration.fromISO(json.value)); case "link": diff --git a/src/test/index/json-conversion.test.ts b/src/test/index/json-conversion.test.ts index 0c55e221..2ced7274 100644 --- a/src/test/index/json-conversion.test.ts +++ b/src/test/index/json-conversion.test.ts @@ -13,15 +13,24 @@ describe("Literals", () => { test("Date", () => expect(roundTrip(DateTime.fromObject({ year: 1982, month: 5, day: 25 })).day).toEqual(25)); test("Date Timezone", () => { const date = DateTime.fromObject({ year: 1941, month: 6, day: 5 }, { zone: "PST" }); - expect(roundTrip(date)).toEqual(date); + expect( + roundTrip(date).toISO({ extendedZone: true, includeOffset: true }) === + date.toISO({ extendedZone: true, includeOffset: true }) + ).toBeTruthy(); }); test("Duration", () => expect(roundTrip(Duration.fromMillis(10000)).toMillis()).toEqual(10000)); test("Link", () => expect(roundTrip(Link.file("hello"))).toEqual(Link.file("hello"))); +// Checking the ISO string for equality here instead of the objects themselves. +// The .equals() method does a deep equality check on every object key/value, and +// the serialized object ends up with a different Zone than the deserialized one. test("Full Date", () => { let date = DateTime.fromObject({ year: 1982, month: 5, day: 19 }, { zone: "UTC+8" }); - expect(roundTrip(date).equals(date)).toBeTruthy(); + expect( + roundTrip(date).toISO({ extendedZone: true, includeOffset: true }) === + date.toISO({ extendedZone: true, includeOffset: true }) + ).toBeTruthy(); }); /** Run a value through the transferable converter and back again. */ diff --git a/yarn.lock b/yarn.lock index 2dee91c5..556e62d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -323,6 +323,13 @@ style-mod "^4.1.0" w3c-keyname "^2.2.4" +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@datastructures-js/queue@^4.2.3": version "4.2.3" resolved "https://registry.yarnpkg.com/@datastructures-js/queue/-/queue-4.2.3.tgz#a63b155e2c1634c9b9d8fcd83969c7b3e143dff7" @@ -778,7 +785,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/resolve-uri@^3.1.0": +"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== @@ -793,6 +800,14 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -844,6 +859,26 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@tsconfig/node10@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" + integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1017,11 +1052,23 @@ acorn-walk@^7.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.1.1: + version "8.3.4" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.11.0, acorn@^8.4.1: + version "8.14.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" + integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== + acorn@^8.2.4: version "8.11.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" @@ -1088,6 +1135,11 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1374,6 +1426,11 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.0, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1451,6 +1508,11 @@ diff-sequences@^27.5.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -2496,7 +2558,7 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" -make-error@1.x: +make-error@1.x, make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== @@ -2982,8 +3044,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3001,8 +3071,14 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -3182,6 +3258,25 @@ ts-jest@^27.0.5: semver "7.x" yargs-parser "20.x" +ts-node@^10.9.2: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + tslib@^2.3.1: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" @@ -3240,6 +3335,11 @@ use-isomorphic-layout-effect@^1.1.2: resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + v8-to-istanbul@^8.1.0: version "8.1.1" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed" @@ -3313,8 +3413,16 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -3399,3 +3507,8 @@ yargs@^16.2.0: string-width "^4.2.0" y18n "^5.0.5" yargs-parser "^20.2.2" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== From dd826cfe2a7c232d492fff58f76d00d75d17f6f8 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Tue, 4 Feb 2025 21:36:29 -0600 Subject: [PATCH 2/5] feat: Support `Set` as a Setting type Provides an overloaded implementation of `Plugin.saveData()` that serializes a `Set` in to an array `T[]`. `loadData()` is not overloaded for conversion back to a `Set`, since there's ambiguity around whether something is intended to be an array or not. --- src/main.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main.ts b/src/main.ts index 936aabb2..5db9317a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -88,6 +88,20 @@ export default class DatacorePlugin extends Plugin { }; } + public async saveData(data: any) { + const serialized: Record = {}; + Object.entries(data).forEach(([key, val]) => { + var serializedVal; + if (val instanceof Set) { + serializedVal = Array.from(val); + } else { + serializedVal = val; + } + serialized[key] = serializedVal; + }); + super.saveData(serialized); + } + /** Update the given settings to new values. */ async updateSettings(settings: Partial) { Object.assign(this.settings, settings); From 63f96c0ed4b34d3511458358ea0e70b5b58e72e7 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Tue, 4 Feb 2025 21:42:28 -0600 Subject: [PATCH 3/5] feat: Add new setting, `scriptRoots`. Allows users to provide any amount of folders in the vault that can be used for shortening the paths that need to be provided when `require`'ing other script files or code block links. --- src/main.ts | 104 +++++++++++++++++++++- src/settings.css | 29 ++++++ src/settings.ts | 3 + src/typings/obsidian-ex.d.ts | 4 + src/utils/settings/fuzzy-folder-finder.ts | 44 +++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/settings.css create mode 100644 src/utils/settings/fuzzy-folder-finder.ts diff --git a/src/main.ts b/src/main.ts index 5db9317a..f367350c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,13 @@ import { DatacoreApi } from "api/api"; import { Datacore } from "index/datacore"; import { DateTime } from "luxon"; -import { App, Plugin, PluginSettingTab, Setting } from "obsidian"; +import { App, Plugin, PluginSettingTab, SearchComponent, setIcon, Setting } from "obsidian"; import { createElement, render } from "preact"; import { DEFAULT_SETTINGS, Settings } from "settings"; import { IndexStatusBar } from "ui/index-status"; +import { FuzzyFolderSearchSuggest } from "utils/settings/fuzzy-folder-finder"; + +import "./settings.css"; /** Reactive data engine for your Obsidian.md vault. */ export default class DatacorePlugin extends Plugin { @@ -18,6 +21,7 @@ export default class DatacorePlugin extends Plugin { async onload() { this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) ?? {}); + this.settings.scriptRoots = new Set([...this.settings.scriptRoots]); this.addSettingTab(new GeneralSettingsTab(this.app, this)); // Initialize the core API for usage in all views and downstream apps. @@ -108,6 +112,16 @@ export default class DatacorePlugin extends Plugin { await this.saveData(this.settings); } + async addScriptRootsToSettings(newRoots: string[]) { + newRoots.forEach((newRoot) => this.settings.scriptRoots.add(newRoot)); + await this.saveData(this.settings); + } + + async removeScriptRoots(rootsToRemove: string[]) { + rootsToRemove.forEach((root) => this.settings.scriptRoots.delete(root)); + await this.saveData(this.settings); + } + /** Render datacore indexing status using the index. */ private mountIndexState(root: HTMLElement, core: Datacore): void { render(createElement(IndexStatusBar, { datacore: core }), root); @@ -122,9 +136,97 @@ class GeneralSettingsTab extends PluginSettingTab { super(app, plugin); } + private async handleNewScriptRoot(component?: SearchComponent) { + if (!component) { + return; + } + + const searchValue = component.getValue(); + if (this.plugin.settings.scriptRoots.has(searchValue)) { + return; + } + + const dirStat = await this.app.vault.adapter.stat(searchValue); + if (!(dirStat?.type === "folder")) { + return; + } + + await this.plugin.addScriptRootsToSettings([searchValue]); + this.display(); + } + public display(): void { this.containerEl.empty(); + this.containerEl.createEl("h2", { text: "Scripting" }); + + const importRootsDesc = new DocumentFragment(); + importRootsDesc.createDiv().innerHTML = ` +

+Provide folders in the vault to be used when resolving module and/or script file names, +in addition to the vault root. These values are used with with +require(...)/await dc.require(...)/import ... when the path +does not start with some kind of indicator for resolving the root +(such as ./ or /). +

+`; + var searchBar: SearchComponent | undefined = undefined; + new Setting(this.containerEl).setName("Additional Script/Module Roots").setDesc(importRootsDesc); + new Setting(this.containerEl) + .addSearch((searchComponent) => { + searchBar = searchComponent; + const searcher = new FuzzyFolderSearchSuggest(this.app, searchComponent.inputEl); + searcher.limit = 10; + searcher.onSelect(async (val, evt) => { + evt.preventDefault(); + evt.stopImmediatePropagation(); + evt.stopPropagation(); + searcher.setValue(val); + searcher.close(); + }); + + searchComponent.setPlaceholder("New Script Root Folder..."); + searchComponent.onChange((val) => { + if (val.length > 0) { + searcher.open(); + } + }); + + searchComponent.inputEl.addEventListener("keydown", (evt) => { + if (evt.key === "Enter") { + this.handleNewScriptRoot(searchComponent); + } + }); + searchComponent.inputEl.addClass("datacore-settings-full-width-search-input"); + }) + .addButton((buttonComponent) => { + buttonComponent + .setIcon("plus") + .setTooltip("Add Folder To Script Roots") + .onClick(async () => { + await this.handleNewScriptRoot(searchBar); + }); + }); + + this.plugin.settings.scriptRoots.forEach((root) => { + const folderItem = new Setting(this.containerEl); + const folderItemFragment = new DocumentFragment(); + const folderItemDiv = folderItemFragment.createDiv(); + folderItemDiv.addClasses(["datacore-settings-script-root", "setting-item-info"]); + setIcon(folderItemDiv, "folder"); + folderItemDiv.createEl("h2", { text: root }); + folderItem.infoEl.replaceWith(folderItemFragment); + folderItem.addButton((buttonComponent) => { + buttonComponent + .setIcon("cross") + .setTooltip("Remove Folder from Script Roots") + .onClick(async () => { + await this.plugin.removeScriptRoots([root]); + this.display(); + }); + }); + }); + this.containerEl.createEl("h2", { text: "Views" }); new Setting(this.containerEl) diff --git a/src/settings.css b/src/settings.css new file mode 100644 index 00000000..7fd9d960 --- /dev/null +++ b/src/settings.css @@ -0,0 +1,29 @@ +.setting-item-control:has(.datacore-settings-full-width-search-input), +.search-input-container:has(.datacore-settings-full-width-search-input), +.datacore-settings-full-width-search-input { + width: 100%; +} + +.setting-item:has(.datacore-settings-full-width-search-input) { + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 15px; +} + +.setting-item:has(.datacore-settings-full-width-search-input) .setting-item-info { + margin-inline-end: 0; + display: none; +} + +.setting-item:has(.datacore-settings-script-root) { + border: none; + padding: 0; + margin-bottom: 10px; +} + +.datacore-settings-script-root { + display: inline-flex !important; + align-items: center; +} +.datacore-settings-script-root h2 { + margin: 8px; +} diff --git a/src/settings.ts b/src/settings.ts index 082d4170..82cb193d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,7 @@ /** All datacore settings. */ export interface Settings { + /** Vault folders to be used when resolving required/imported scripts in addition to the vault root. */ + scriptRoots: Set; /** The number of threads the importer will use for importing. */ importerNumThreads: number; /** The CPU utilization (between 0.1 and 1.0) that importer threads should use. */ @@ -40,6 +42,7 @@ export interface Settings { /** Default settings for the plugin. */ export const DEFAULT_SETTINGS: Readonly = Object.freeze({ + scriptRoots: new Set(), importerNumThreads: 2, importerUtilization: 0.75, diff --git a/src/typings/obsidian-ex.d.ts b/src/typings/obsidian-ex.d.ts index 80ce8d32..111380e6 100644 --- a/src/typings/obsidian-ex.d.ts +++ b/src/typings/obsidian-ex.d.ts @@ -4,6 +4,10 @@ import "obsidian"; /** @hidden */ declare module "obsidian" { + interface MetadataCache { + isUserIgnored(path: string): boolean; + } + interface FileManager { linkUpdaters: { canvas: { diff --git a/src/utils/settings/fuzzy-folder-finder.ts b/src/utils/settings/fuzzy-folder-finder.ts new file mode 100644 index 00000000..9302f410 --- /dev/null +++ b/src/utils/settings/fuzzy-folder-finder.ts @@ -0,0 +1,44 @@ +import { AbstractInputSuggest, App, prepareFuzzySearch, SearchResult } from "obsidian"; + +export class FuzzyFolderSearchSuggest extends AbstractInputSuggest { + constructor( + app: App, + inputEl: HTMLInputElement, + private threshold: number = -5, + private respectUserIgnored: boolean = false + ) { + super(app, inputEl); + } + + protected getSuggestions(query: string): string[] | Promise { + const searchFn = prepareFuzzySearch(query); + const accumulator: Record = {}; + const matchedFolders = this.app.vault.getAllFolders(false).reduce((accum, tfile) => { + const isIgnoredFile = this.respectUserIgnored && this.app.metadataCache.isUserIgnored(tfile.path); + if (isIgnoredFile) { + return accum; + } + + const searchResult = searchFn(tfile.path); + const noResults = !searchResult?.score; + const belowThreshold = searchResult?.score && searchResult.score < this.threshold; + + if (noResults || belowThreshold) { + return accum; + } + + if (!accum.hasOwnProperty(tfile.path) || accum[tfile.path].score < searchResult.score) { + accum[tfile.path] = searchResult; + } + return accum; + }, accumulator); + + return Object.entries(matchedFolders) + .sort((a, b) => b[1].score - a[1].score) + .map((result) => result[0]); + } + + renderSuggestion(value: string, el: HTMLElement): void { + el.setText(value); + } +} From 23be4b0e029151a064b02e0276c3d3e230b8c033 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Tue, 4 Feb 2025 22:12:12 -0600 Subject: [PATCH 4/5] feat: Improved ergonomics for import/require This commit improves the ergonomics aroud importing/requiring scripts from other files or blocks, while remaining backwards compatible with the existing functionality in Datacore. The specifics of the changes are: - Paths and wikilink-like strings passed to `dc.require()` will be checked against all elements in the `scriptRoots` Setting if the path fails to resolve against the vault root - The `imports` transform is now enabled on the Sucrase `transform` operations. This causes `import { foo } from "bar"` syntax to be transformed in to `const _foo = require("bar")` during transpiling. - The script transpilation pass will convert any `foo = require("bar")` statements in to the form `foo = await dc.require("bar")` before evaluation. This covers both explicitly authored CommonJS require syntax, as well as the output of the Sucrase transformation for ES Module imports. - The script transpilation pass will resolve relative paths (e.g. `./foo`) against the script that called `dc.require()`, allowing for relative imports to now be utilized. This happens as part of the same new transform sa the one above that converts all `require` statements in to `dc.require` statements. - The `imports` transform also allows for the use of the `export` keyword in scripts, which gets transformed in to keys on an `exports` object. - The `exports` object is injected via the context used to construct the `Function` evaluator. - Scripts can now choose to either `return { ... }`, as is the current pattern, *or* they can use `export`. - Returned values take precedence over the exports object. - Datacore code blocks that return a View to be rendered can now use `export default` by leveraging the same effects introduced by the `imports` transform. - Similarly to scripts loaded with `dc.require()`, the `return function ...` form is still supported. - Also similarly to the above, the `return function ...` form is given precedence over the `export default` form. --- package.json | 120 ++++++++++++++++++------------------ src/api/local-api.tsx | 2 +- src/api/script-cache.ts | 79 ++++++++++++++++-------- src/index/datastore.ts | 43 +++++++++++-- src/ui/javascript.tsx | 19 +++--- src/ui/loading-boundary.tsx | 11 +++- src/utils/javascript.ts | 79 ++++++++++++++++++++---- yarn.lock | 10 +++ 8 files changed, 251 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 8d058f0e..13cbe1e7 100644 --- a/package.json +++ b/package.json @@ -1,61 +1,63 @@ { - "name": "datacore", - "version": "0.1.19", - "description": "Reactive data engine for Obsidian.md.", - "main": "lib/index.js", - "types": "lib/index.d.ts", - "files": [ - "lib/**/*" - ], - "scripts": { - "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "test": "yarn run jest", - "test-watch": "yarn run jest -i --watch --no-cache", - "check-format": "yarn run prettier --check src", - "format": "yarn run prettier --write src" - }, - "keywords": [ - "obsidian", - "datacore", - "dataview", - "pkm" - ], - "author": "Michael Brenan", - "license": "MIT", - "devDependencies": { - "@codemirror/language": "https://github.com/lishid/cm-language", - "@codemirror/state": "^6.0.1", - "@codemirror/view": "^6.0.1", - "@types/jest": "^27.0.1", - "@types/luxon": "^2.3.2", - "@types/node": "^16.7.13", - "@types/parsimmon": "^1.10.6", - "builtin-modules": "3.3.0", - "esbuild": "^0.16.11", - "esbuild-plugin-inline-worker": "https://github.com/mitschabaude/esbuild-plugin-inline-worker", - "jest": "^27.1.0", - "obsidian": "^1.6.6", - "prettier": "2.3.2", - "ts-jest": "^27.0.5", - "ts-node": "^10.9.2", - "tslib": "^2.3.1", - "typescript": "^5.4.2" - }, - "dependencies": { - "@datastructures-js/queue": "^4.2.3", - "@fortawesome/fontawesome-svg-core": "^6.4.0", - "@fortawesome/free-solid-svg-icons": "^6.4.0", - "@fortawesome/react-fontawesome": "^0.2.0", - "emoji-regex": "^10.2.1", - "flatqueue": "^2.0.3", - "localforage": "1.10.0", - "luxon": "^2.4.0", - "parsimmon": "^1.18.0", - "preact": "^10.17.1", - "react-select": "^5.8.0", - "sorted-btree": "^1.8.1", - "sucrase": "3.35.0", - "yaml": "^2.3.3" - } + "name": "datacore", + "version": "0.1.19", + "description": "Reactive data engine for Obsidian.md.", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib/**/*" + ], + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "yarn run jest", + "test-watch": "yarn run jest -i --watch --no-cache", + "check-format": "yarn run prettier --check src", + "format": "yarn run prettier --write src" + }, + "keywords": [ + "obsidian", + "datacore", + "dataview", + "pkm" + ], + "author": "Michael Brenan", + "license": "MIT", + "devDependencies": { + "@codemirror/language": "https://github.com/lishid/cm-language", + "@codemirror/state": "^6.0.1", + "@codemirror/view": "^6.0.1", + "@types/jest": "^27.0.1", + "@types/luxon": "^2.3.2", + "@types/node": "^16.7.13", + "@types/parsimmon": "^1.10.6", + "@types/path-browserify": "^1.0.3", + "builtin-modules": "3.3.0", + "esbuild": "^0.16.11", + "esbuild-plugin-inline-worker": "https://github.com/mitschabaude/esbuild-plugin-inline-worker", + "jest": "^27.1.0", + "obsidian": "^1.6.6", + "prettier": "2.3.2", + "ts-jest": "^27.0.5", + "ts-node": "^10.9.2", + "tslib": "^2.3.1", + "typescript": "^5.4.2" + }, + "dependencies": { + "@datastructures-js/queue": "^4.2.3", + "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-solid-svg-icons": "^6.4.0", + "@fortawesome/react-fontawesome": "^0.2.0", + "emoji-regex": "^10.2.1", + "flatqueue": "^2.0.3", + "localforage": "1.10.0", + "luxon": "^2.4.0", + "parsimmon": "^1.18.0", + "path-browserify": "^1.0.1", + "preact": "^10.17.1", + "react-select": "^5.8.0", + "sorted-btree": "^1.8.1", + "sucrase": "3.35.0", + "yaml": "^2.3.3" + } } diff --git a/src/api/local-api.tsx b/src/api/local-api.tsx index bf9c92e0..332c968d 100644 --- a/src/api/local-api.tsx +++ b/src/api/local-api.tsx @@ -90,7 +90,7 @@ export class DatacoreLocalApi { * ``` */ public async require(path: string | Link): Promise { - const result = await this.scriptCache.load(path, { dc: this }); + const result = await this.scriptCache.load(path, this); return result.orElseThrow(); } diff --git a/src/api/script-cache.ts b/src/api/script-cache.ts index 535d8914..8c3d14ba 100644 --- a/src/api/script-cache.ts +++ b/src/api/script-cache.ts @@ -3,10 +3,16 @@ import { Datastore } from "index/datastore"; import { Result } from "./result"; import { MarkdownCodeblock, MarkdownSection } from "index/types/markdown"; import { Deferred, deferred } from "utils/deferred"; -import { ScriptLanguage, asyncEvalInContext, transpile } from "utils/javascript"; +import { + ScriptDefinition, + ScriptLanguage, + asyncEvalInContext, + defaultScriptLoadingContext, + transpile, +} from "utils/javascript"; import { lineRange } from "utils/normalizers"; -import { TFile } from "obsidian"; -import { Fragment, h } from "preact"; +import { normalizePath, TFile } from "obsidian"; +import { DatacoreLocalApi } from "./local-api"; /** A script that is currently being loaded. */ export interface LoadingScript { @@ -56,20 +62,34 @@ export class ScriptCache { public constructor(private store: Datastore) {} /** Load the given script at the given path, recursively loading any subscripts as well. */ - public async load(path: string | Link, context: Record): Promise> { + public async load(path: string | Link, api: DatacoreLocalApi): Promise> { + // First, attempt to resolve the script against the script roots so we cache a canonical script path as the key. + var linkToLoad = undefined; + const roots = ["", ...api.core.settings.scriptRoots]; + for (var i = 0; i < roots.length; i++) { + linkToLoad = this.store.tryNormalizeLink(path, normalizePath(roots[i])); + if (linkToLoad) { + break; + } + } + + const resolvedPath = linkToLoad ?? path; + // Always check the cache first. - const key = this.pathkey(path); + const key = this.pathkey(resolvedPath); const currentScript = this.scripts.get(key); if (currentScript) { - if (currentScript.type === "loaded") return Result.success(currentScript.object); + if (currentScript.type === "loaded") { + return Result.success(currentScript.object); + } // TODO: If we try to load an already-loading script, we are almost certainly doing something // weird. Either the caller is not `await`-ing the load and loading multiple times, OR // we are in a `require()` loop. Either way, we'll error out for now since we can't handle // either case currently. return Result.failure( - `Failed to import script "${path.toString()}", as it is in the middle of being loaded. Do you have - a circular dependency in your require() calls? The currently loaded or loading scripts are: + `Failed to import script "${resolvedPath.toString()}", as it is in the middle of being loaded. Do you have + a circular dependency in your require() calls? The currently loaded or loading scripts are: ${Array.from(this.scripts.values()) .map((sc) => "\t" + sc.path) .join("\n")}` @@ -80,7 +100,7 @@ export class ScriptCache { const deferral = deferred>(); this.scripts.set(key, { type: "loading", promise: deferral, path: key }); - const result = await this.loadUncached(path, context); + const result = await this.loadUncached(resolvedPath, api); deferral.resolve(result); if (result.successful) { @@ -93,25 +113,30 @@ export class ScriptCache { } /** Load a script, directly bypassing the cache. */ - private async loadUncached(path: string | Link, context: Record): Promise> { - const maybeSource = await this.resolveSource(path); + private async loadUncached(scriptPath: string | Link, api: DatacoreLocalApi): Promise> { + var maybeSource = await this.resolveSource(scriptPath); if (!maybeSource.successful) return maybeSource; // Transpile to vanilla javascript first... - const { code, language } = maybeSource.value; + const scriptDefinition = maybeSource.value; let basic; try { - basic = transpile(code, language); + basic = transpile(scriptDefinition); } catch (error) { - return Result.failure(`Failed to import ${path.toString()} while transpiling from ${language}: ${error}`); + return Result.failure( + `Failed to import ${scriptPath.toString()} while transpiling from ${ + scriptDefinition.scriptLanguage + }: ${error}` + ); } // Then finally execute the script to 'load' it. - const finalContext = Object.assign({ h: h, Fragment: Fragment }, context); + const scriptContext = defaultScriptLoadingContext(api); try { - return Result.success(await asyncEvalInContext(basic, finalContext)); + const loadRet = (await asyncEvalInContext(basic, scriptContext)) ?? scriptContext.exports; + return Result.success(loadRet); } catch (error) { - return Result.failure(`Failed to execute script '${path.toString()}': ${error}`); + return Result.failure(`Failed to execute script '${scriptPath.toString()}': ${error}`); } } @@ -122,10 +147,8 @@ export class ScriptCache { } /** Attempts to resolve the source to load given a path or link to a markdown section. */ - private async resolveSource( - path: string | Link - ): Promise> { - const object = this.store.resolveLink(path); + private async resolveSource(path: string | Link, sourcePath?: string): Promise> { + const object = this.store.resolveLink(path, sourcePath); if (!object) return Result.failure("Could not find a script at the given path: " + path.toString()); const tfile = this.store.vault.getFileByPath(object.$file!); @@ -137,7 +160,7 @@ export class ScriptCache { try { const code = await this.store.vault.cachedRead(tfile); - return Result.success({ code, language }); + return Result.success({ scriptFile: tfile, scriptLanguage: language, scriptSource: code }); } catch (error) { return Result.failure("Failed to load javascript/typescript source file: " + error); } @@ -158,7 +181,11 @@ export class ScriptCache { ScriptCache.SCRIPT_LANGUAGES[ maybeBlock.$languages.find((lang) => lang.toLocaleLowerCase() in ScriptCache.SCRIPT_LANGUAGES)! ]; - return (await this.readCodeblock(tfile, maybeBlock)).map((code) => ({ code, language })); + return (await this.readCodeblock(tfile, maybeBlock)).map((code) => ({ + scriptFile: tfile, + scriptSource: code, + scriptLanguage: language, + })); } else if (object instanceof MarkdownCodeblock) { const maybeLanguage = object.$languages.find( (lang) => lang.toLocaleLowerCase() in ScriptCache.SCRIPT_LANGUAGES @@ -167,7 +194,11 @@ export class ScriptCache { return Result.failure(`The codeblock referenced by '${path}' is not a JS/TS codeblock.`); const language = ScriptCache.SCRIPT_LANGUAGES[maybeLanguage]; - return (await this.readCodeblock(tfile, object)).map((code) => ({ code, language })); + return (await this.readCodeblock(tfile, object)).map((code) => ({ + scriptFile: tfile, + scriptSource: code, + scriptLanguage: language, + })); } return Result.failure(`Cannot import '${path.toString()}: not a JS/TS file or codeblock reference.`); diff --git a/src/index/datastore.ts b/src/index/datastore.ts index 0e817133..9608ffca 100644 --- a/src/index/datastore.ts +++ b/src/index/datastore.ts @@ -4,7 +4,7 @@ import { FolderIndex } from "index/storage/folder"; import { InvertedIndex } from "index/storage/inverted"; import { IndexPrimitive, IndexQuery, IndexSource } from "index/types/index-query"; import { Indexable, LINKABLE_TYPE, LINKBEARING_TYPE, TAGGABLE_TYPE } from "index/types/indexable"; -import { MetadataCache, Vault } from "obsidian"; +import { MetadataCache, normalizePath, Vault } from "obsidian"; import { MarkdownPage } from "./types/markdown"; import { extractSubtags, normalizeHeaderForLink } from "utils/normalizers"; import FlatQueue from "flatqueue"; @@ -14,6 +14,7 @@ import { IndexResolver, execute, optimizeQuery } from "index/storage/query-execu import { Result } from "api/result"; import { Evaluator } from "expression/evaluator"; import { Settings } from "settings"; +import path from "path-browserify"; /** Central, index storage for datacore values. */ export class Datastore { @@ -266,15 +267,44 @@ export class Datastore { this.revision++; } - /** Find the corresponding object for a given link. */ - public resolveLink(rawLink: string | Link, sourcePath?: string): Indexable | undefined { + public tryNormalizeLink(rawLink: string | Link, sourcePath?: string): Link | undefined { let link = typeof rawLink === "string" ? Link.parseInner(rawLink) : rawLink; - if (sourcePath) { const linkdest = this.metadataCache.getFirstLinkpathDest(link.path, sourcePath); if (linkdest) link = link.withPath(linkdest.path); } + if (this.objects.has(link.path)) { + return link; + } + + const normalizedSourcePath = normalizePath(sourcePath ?? "/"); + const normalizedModuleParentDir = normalizePath(path.join(normalizedSourcePath, path.dirname(link.path))); + const resolvedModuleFile = this.folder + .getExact(normalizedModuleParentDir, (childPath) => { + const moduleBasename = path.basename(link.path); + const childFileBasename = path.basename(childPath); + return childFileBasename === moduleBasename || childFileBasename.startsWith(`${moduleBasename}.`); + }) + .values() + .next()?.value; + if (resolvedModuleFile) { + return link.withPath(resolvedModuleFile); + } + + return undefined; + } + + public normalizeLink(rawLink: string | Link, sourcePath?: string): Link { + return ( + this.tryNormalizeLink(rawLink, sourcePath) ?? + (typeof rawLink === "string" ? Link.parseInner(rawLink) : rawLink) + ); + } + + /** Find the corresponding object for a given link. */ + public resolveLink(rawLink: string | Link, sourcePath?: string): Indexable | undefined { + const link = this.normalizeLink(rawLink, sourcePath); const file = this.objects.get(link.path); if (!file) return undefined; @@ -288,8 +318,9 @@ export class Datastore { (sec) => normalizeHeaderForLink(sec.$title) == link.subpath || sec.$title == link.subpath ); - if (section) return section; - else return undefined; + if (section) { + return section; + } else return undefined; } else if (link.type === "block") { for (const section of file.$sections) { const block = section.$blocks.find((bl) => bl.$blockId === link.subpath); diff --git a/src/ui/javascript.tsx b/src/ui/javascript.tsx index 2202273e..774ecc61 100644 --- a/src/ui/javascript.tsx +++ b/src/ui/javascript.tsx @@ -1,9 +1,9 @@ import { ErrorMessage, SimpleErrorBoundary, CURRENT_FILE_CONTEXT, DatacoreContextProvider } from "ui/markdown"; import { App, MarkdownRenderChild } from "obsidian"; import { DatacoreLocalApi } from "api/local-api"; -import { h, render, Fragment, VNode } from "preact"; +import { render, VNode } from "preact"; import { unmountComponentAtNode } from "preact/compat"; -import { ScriptLanguage, asyncEvalInContext, transpile } from "utils/javascript"; +import { ScriptLanguage, asyncEvalInContext, defaultScriptLoadingContext, transpile } from "utils/javascript"; import { LoadingBoundary, ScriptContainer } from "./loading-boundary"; import { Datacore } from "index/datacore"; @@ -29,13 +29,16 @@ export class DatacoreJSRenderer extends MarkdownRenderChild { // Attempt to parse and evaluate the script to produce either a renderable JSX object or a function. try { - const primitiveScript = transpile(this.script, this.language); + const primitiveScript = transpile({ + scriptSource: this.script, + scriptLanguage: this.language, + scriptFile: this.api.app.vault.getFileByPath(this.path)!, + }); + const scriptContext = defaultScriptLoadingContext(this.api); const renderer = async () => { - return await asyncEvalInContext(primitiveScript, { - dc: this.api, - h: h, - Fragment: Fragment, - }); + const loadedScript = + (await asyncEvalInContext(primitiveScript, scriptContext)) ?? scriptContext.exports; + return loadedScript; }; render( diff --git a/src/ui/loading-boundary.tsx b/src/ui/loading-boundary.tsx index 5a25d67d..8ed0acc1 100644 --- a/src/ui/loading-boundary.tsx +++ b/src/ui/loading-boundary.tsx @@ -7,6 +7,8 @@ import { ErrorMessage, Lit } from "./markdown"; import "./errors.css"; +type Renderable = Literal | VNode | Function; + function LoadingProgress({ datacore }: { datacore: Datacore }) { useIndexUpdates(datacore, { debounce: 250 }); @@ -53,7 +55,7 @@ export function ScriptContainer({ executor, sourcePath, }: { - executor: () => Promise; + executor: () => Promise Renderable }>; sourcePath: string; }) { const [element, setElement] = useState(undefined); @@ -64,7 +66,12 @@ export function ScriptContainer({ setError(undefined); executor() - .then((result) => setElement(makeRenderableElement(result, sourcePath))) + .then((result) => { + if (result && result.hasOwnProperty("default")) { + return setElement(makeRenderableElement((result as any).default, sourcePath)); + } + return setElement(makeRenderableElement(result, sourcePath)); + }) .catch((error) => setError(error)); }, [executor]); diff --git a/src/utils/javascript.ts b/src/utils/javascript.ts index e94f137a..cd8966dc 100644 --- a/src/utils/javascript.ts +++ b/src/utils/javascript.ts @@ -1,25 +1,59 @@ //! Utilities for running javascript. +import { DatacoreLocalApi } from "api/local-api"; +import { normalizePath, TFile } from "obsidian"; import { transform } from "sucrase"; +import path from "path-browserify"; + export type ScriptLanguage = "js" | "ts" | "jsx" | "tsx"; +export interface ScriptDefinition { + /** The file that the script source has been loaded from */ + scriptFile: TFile; + /** The script source code */ + scriptSource: string; + /** The script language */ + scriptLanguage: ScriptLanguage; +} + +export function defaultScriptLoadingContext(api: DatacoreLocalApi): Record { + return { + dc: api, + h: api.preact.h, + Fragment: api.preact.Fragment, + exports: {}, + }; +} + +export function newScriptLoadingContextWith(api: DatacoreLocalApi, other: Record): Record { + return { ...defaultScriptLoadingContext(api), ...other }; +} + /** Converts a raw script in the given language to plain javascript. */ -export function transpile(script: string, language: ScriptLanguage): string { - switch (language) { +export function transpile(script: ScriptDefinition): ScriptDefinition { + const transpiled = { ...script }; + + switch (script.scriptLanguage) { case "js": - return script; + transpiled.scriptSource = transform(script.scriptSource, { transforms: ["imports"] }).code; case "jsx": - return transform(script, { transforms: ["jsx"], jsxPragma: "h", jsxFragmentPragma: "Fragment" }).code; + transpiled.scriptSource = transform(script.scriptSource, { + transforms: ["jsx", "imports"], + jsxPragma: "h", + jsxFragmentPragma: "Fragment", + }).code; case "ts": - return transform(script, { transforms: ["typescript"] }).code; + transpiled.scriptSource = transform(script.scriptSource, { transforms: ["typescript", "imports"] }).code; case "tsx": - return transform(script, { - transforms: ["typescript", "jsx"], + transpiled.scriptSource = transform(script.scriptSource, { + transforms: ["typescript", "jsx", "imports"], jsxPragma: "h", jsxFragmentPragma: "Fragment", }).code; } + + return transpiled; } /** @@ -29,17 +63,38 @@ export function evalInContext(script: string, variables: Record): a const pairs = Object.entries(variables); const keys = pairs.map(([key, _]) => key); const values = pairs.map(([_, value]) => value); - return new Function(...keys, script)(...values); } +function resolveRelativeRequires(script: string, scriptFile: TFile): string { + const requireMatches = script.matchAll(/=\s*require\(([^\)]+)\)/gm); + for (const match of requireMatches) { + const requireTargetRaw = match[1]; + const requireTarget = requireTargetRaw.replace(/'/gm, "").replace(/"/gm, "").replace(/^\//, ""); + const regexEscapedRequire = requireTargetRaw.replace(/[.*+?^=!:${}()|\[\]\/\\]/g, "\\$&"); + const regExp = new RegExp(`=\\s*require\\(${regexEscapedRequire}\\)`, "gm"); + + var resolvedRequireTarget = requireTarget; + if (/^[\.]*\//gm.test(requireTarget)) { + resolvedRequireTarget = normalizePath(path.join(scriptFile.parent!.path, requireTarget)); + } + script = script.replace(regExp, `= await dc.require("${resolvedRequireTarget}")`); + } + return script; +} + /** * Evaluate a script possibly asynchronously, if the script contains `async/await` blocks. */ -export async function asyncEvalInContext(script: string, variables: Record): Promise { - if (script.includes("await")) { - return evalInContext("return (async () => { " + script + " })()", variables) as Promise; +export async function asyncEvalInContext(script: ScriptDefinition, variables: Record): Promise { + var { scriptSource, scriptFile } = script; + if (/=\s*require\(/gm.test(scriptSource)) { + scriptSource = resolveRelativeRequires(scriptSource, scriptFile); + } + + if (scriptSource.includes("await")) { + return evalInContext("return (async () => { " + scriptSource + " })()", variables) as Promise; } else { - return Promise.resolve(evalInContext(script, variables)); + return Promise.resolve(evalInContext(scriptSource, variables)); } } diff --git a/yarn.lock b/yarn.lock index 556e62d6..86d27f70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -985,6 +985,11 @@ resolved "https://registry.yarnpkg.com/@types/parsimmon/-/parsimmon-1.10.9.tgz#14e60db223c1d213fea0e15985d480b5cfe1789a" integrity sha512-O2M2x1w+m7gWLen8i5DOy6tWRnbRcsW6Pke3j3HAsJUrPb4g0MgjksIUm2aqUtCYxy7Qjr3CzjjwQBzhiGn46A== +"@types/path-browserify@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/path-browserify/-/path-browserify-1.0.3.tgz#25de712d4def94b3901f033c30d3d3bd16eba8d3" + integrity sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw== + "@types/prettier@^2.1.5": version "2.7.3" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" @@ -2748,6 +2753,11 @@ parsimmon@^1.18.0: resolved "https://registry.yarnpkg.com/parsimmon/-/parsimmon-1.18.1.tgz#d8dd9c28745647d02fc6566f217690897eed7709" integrity sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw== +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" From 94b2a136c2b1fb41b288dece4225a39ef39911e4 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Fri, 7 Feb 2025 12:49:47 -0600 Subject: [PATCH 5/5] fixup! feat: Add new setting, `scriptRoots`. --- src/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.ts b/src/main.ts index f367350c..92b51252 100644 --- a/src/main.ts +++ b/src/main.ts @@ -142,6 +142,10 @@ class GeneralSettingsTab extends PluginSettingTab { } const searchValue = component.getValue(); + if (!searchValue || searchValue.length === 0) { + return; + } + if (this.plugin.settings.scriptRoots.has(searchValue)) { return; }