Skip to content

Commit

Permalink
Draft new settings framework
Browse files Browse the repository at this point in the history
  • Loading branch information
pjeby committed Jan 19, 2025
1 parent 516c2e5 commit 0c9d58b
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 17 deletions.
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
"license": "ISC",
"type": "module",
"exports": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"./dist/*"
Expand Down Expand Up @@ -39,10 +41,10 @@
"tsx": "^4.10.0",
"typedoc": "^0.25.13",
"typescript": "5.2.2",
"uneventful": "^0.0.9"
"uneventful": "^0.0.10"
},
"peerDependencies": {
"uneventful": "^0.0.9"
"uneventful": "^0.0.10"
},
"scripts": {
"docs": "tsx typedoc/run.mts",
Expand Down
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions src/JSON.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Any JSONifiable value
*
* @category Types and Interfaces
*/
export type JSON = JSONPrimitive | JSONArray | JSONObject;

/**
* An object representable as JSON
*
* @category Types and Interfaces
*/

export type JSONObject = { [attr: string]: JSON; } & NotAFunction;

/**
* A primitive JSONifiable value
*
* @category Types and Interfaces
*/
export type JSONPrimitive = string | number | boolean | Date | null;

/**
* An array of JSONifiable values
*
* @category Types and Interfaces
*/
export type JSONArray = JSON[] & NotAFunction;

/**
* A non-function value
*
* @category Types and Interfaces
*/
export type NotAFunction = { bind?: void; } | { apply?: void; } | { call?: void; };
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ export * from "./deferred.ts";
export * from "./dom.ts";
export * from "./eventful.ts";
export * from "./indexing.ts";
export * from "./JSON.ts"
export * from "./layout/index.ts";
export * from "./localStorage.ts";
export * from "./plugin-settings.ts";
export * from "./settings.ts";
export * from "./resources.ts";
export * from "./services.ts";
export * from "./signify.ts";
Expand Down
7 changes: 7 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { obsidian as o } from "./obsidian.ts";
export var app: o.App, plugin: o.Plugin

export function setPlugin(p: o.Plugin) {
plugin = p
app = p.app
}
11 changes: 4 additions & 7 deletions src/services.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { obsidian as o } from "./obsidian.ts";
import { Context, Useful, Key, Provides, use as _use } from "to-use";
import { defer } from "./defer.ts";
import { setPlugin } from "./plugin.ts"
import { Component } from "obsidian";
import { Job, SyncStart, Yielding, noop, root } from "uneventful";
import { GeneratorBase, isFunction } from "uneventful/utils";
export type * from "to-use";
export var app: o.App;
export { app } from "./plugin.ts"
import { isGeneratorFunction } from "uneventful/utils";

/** @category Components and Services */
export const use = /* @__PURE__ */ (use => {
Expand All @@ -15,7 +16,7 @@ export const use = /* @__PURE__ */ (use => {
}
use.plugin = function plugin(plugin: o.Plugin) {
if (!rootCtx) {
app = plugin.app;
setPlugin(plugin);
rootCtx = use.fork();
// Register the plugin under its generic and concrete types
rootCtx.set(o.Plugin, plugin);
Expand Down Expand Up @@ -167,7 +168,3 @@ type OnloadMethod<T extends Component> = SyncStart<never, T> | OnloadGenerator<T
type OnloadGenerator<T extends Component> = (this: T, job: Job<never>) => Yielding<void>
type OnloadDecoratorContext = {kind: "method", name: "onload"}
type OnloadDescriptor<T extends Component> = {value?: OnloadMethod<T>}

function isGeneratorFunction<G extends Generator<any,any,any>=Generator>(fn: any): fn is (this: any, ...args: any[]) => G {
return isFunction(fn) && fn.prototype instanceof GeneratorBase
}
155 changes: 155 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { default as setDefaults } from "defaults";
import { Writable, cached, rule, until, value } from "uneventful/signals";
import { service } from "uneventful/shared";
import { setMap } from "./add-ons.ts";
import { taskQueue } from "./defer.ts";
import { cloneValue } from "./clone-value.ts";
import { around } from "monkey-around";
import { UntilMethod, Yielding, must } from "uneventful";
import type { JSONObject, JSON } from "./JSON.ts";
import { plugin } from "./plugin.ts";

/**
* @category Settings Management
*/
export type SettingsMigration<T extends JSONObject=JSONObject> = (old: T) => T

export const settings = /* @__PURE__ */ (() => {
/**
* Define setting defaults + migrations, and get setting helpers.
*
* Calling settings() with an object of defaults (and an optional migration
* function) gets you an object you can extract {@link Writable} helpers
* from, for accessing individual settings. e.g.:
*
* ```ts
* interface MySettings { a: number; b: string; }
* const {a, b} = settings<MySettings>({})
* a.set(42); b.set("foo");
* ```
*
* The helpers are reactive values that also work as streams, i.e. you can
* use them in rules or pipe them through stream operators. They will throw
* an error if used before any settings are loaded, however, so you will
* need to only use them in either a `rule.if(settings, () => {...})` block
* or after a `yield *until(settings)` in an async job.
*
* Note that you can call `settings({...})` more than once with different
* types, defaults, and migration functions, extracting different helpers in
* different modules. Generally speaking, you should do this at the top
* level of your module, then set up handlers (e.g. rules, streams, etc.)
* within your service to monitor or update the settings.
*
* @param defaults An object containing setting defaults. This argument
* *must* be present in order to get helpers, even if you aren't defining
* any defaults. (If the defaults are omitted or falsy, settings() returns
* the loaded settings, or null if they aren't currently loaded - see the
* second overload for details.)
*
* @param migrate Optional - A function that will be called with an on-disk
* version of the settings, and should returning an updated value (for
* handling versioning of your settings schema).
*
* @category Settings Management
*/
function settings<T extends JSONObject>(defaults: T, migrate?: (old: T) => T): {[K in keyof T]: Writable<T[K]>}

/**
* Get the current settings as a reactive value, or null if not loaded.
*
* This is a reactive value that refreshes when settings load or change, so
* you can e.g. use `rule.if(settings, () => {})` to start a rule once
* settings are loaded. (You can also use `yield *until(settings)` to
* wait for settings to be loaded in an async task.)
*/
function settings<T extends JSONObject>(): T

function settings(defaults?: JSONObject, migrate?: (old: JSONObject) => JSONObject) {
useIO()
if (defaults) {
defaultSettings.set(JSON.stringify(setDefaults(
JSON.parse(defaultSettings()) as JSONObject, cloneValue(defaults)
)))
if (migrate) migrations.push(migrate)
return settingsProxy
} else {
return settingsLoaded() ? cookedSettings() : null
}
}

const defaultSettings = value("{}")
const rawSettings = value("null"), onDisk = value("null")
const settingsLoaded = value(false)
const settingsJSON = cached(() => JSON.stringify(setDefaults(
JSON.parse(rawSettings()) || {}, JSON.parse(defaultSettings())
)))
const cookedSettings = cached(() => JSON.parse(settingsJSON()))
const migrations: SettingsMigration[] = []
const helpers = new Map<string, Writable<JSON>>()
const settingsProxy = new Proxy({}, { get(_, prop) {
if (typeof prop === "string") {
return helpers.get(prop) || setMap(helpers, prop, cached<JSON>(
() => {
if (settingsLoaded()) return cookedSettings()[prop]
throw new Error("Settings not loaded yet")
}
).withSet(v => rawSettings.set(JSON.stringify(
{...JSON.parse(rawSettings()), [prop]: v}
))))
}
}})

const io = taskQueue(), ioRule = rule.factory(io), useIO = service(ioHandler)
return withUntil(settings, function*(){ return yield *until(settingsLoaded); })

function *ioHandler() {
// Track external settings changes and trigger loads (This must be done
// before first loadData as Obsidian expects the onExternalSettingsChange method
// to be present at load time to enable tracking.)
must(around(plugin, { onExternalSettingsChange(old) {
const nextFn = plugin.onExternalSettingsChange
return function() {
io(loadData)
if (nextFn) old.call(this)
}
}}))

// Once the plugin is initialized, queue a data load
io(loadData)

// And once it's done, begin monitoring setting changes
// to write them to disk
ioRule(() => {
if (rawSettings() != onDisk()) io(saveData)
})

// And reset the loaded flag to false when the plugin unloads
must(() => settingsLoaded.set(false))

async function saveData() {
const toWrite = rawSettings()
if (toWrite != onDisk()) {
await plugin.saveData(JSON.parse(toWrite))
onDisk.set(toWrite)
// XXX sleep here to enforce a max save rate
}
}

async function loadData() {
const data = await plugin.loadData()
const j = JSON.stringify(migrations.reduce((p, c) => c(p), data || {}))
onDisk.set(j)
rawSettings.set(j)
settingsLoaded.set(true)
}
}
function withUntil<T extends object, R>(ob: T, until: () => Yielding<R>): T & UntilMethod<R> {
return Object.assign(ob, {"uneventful.until": until})
}
})()

declare module "obsidian" {
interface Plugin {
onExternalSettingsChange?(): any
}
}
2 changes: 1 addition & 1 deletion src/ui/settings-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Component, PluginSettingTab, Setting } from "obsidian";
import { obsidian as o } from "../obsidian.ts";
import { SettingsService } from "../plugin-settings.ts";
import { Useful, getContext, onLoad, use, app } from "../services.ts";
import { getJob, must, root } from "uneventful";
import { must, root } from "uneventful";
import { peek, rule, value } from "uneventful/signals"

/** @category Settings UI */
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@
// Keep VSCode, tsc, typedoc, tsup, etc. from treating any output .ts files
// as part of their input:
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["src/drafts/**"]
"exclude": ["src/drafts/**", "dist/*"],
}

0 comments on commit 0c9d58b

Please sign in to comment.