2026-09-07 16:48:36 +00:00
|
|
|
import {derived} from "svelte/store"
|
|
|
|
|
import type {Readable} from "svelte/store"
|
|
|
|
|
import {
|
|
|
|
|
Address,
|
|
|
|
|
COMMAND,
|
|
|
|
|
bindCommandArgs,
|
|
|
|
|
getActiveCommandArgIndex,
|
|
|
|
|
parseCommandInvocation,
|
|
|
|
|
} from "@welshman/util"
|
|
|
|
|
import type {
|
|
|
|
|
CommandArg,
|
|
|
|
|
CommandArgBinding,
|
|
|
|
|
CommandInvocation,
|
|
|
|
|
CommandScopeTarget,
|
|
|
|
|
} from "@welshman/util"
|
|
|
|
|
import {Command} from "@welshman/domain"
|
|
|
|
|
import type {CommandReader} from "@welshman/domain"
|
|
|
|
|
import {Domain, Network, RelayScopedDerivedPlugin, createSearch, projectFrom} from "@welshman/app"
|
|
|
|
|
import type {IApp, Projection} from "@welshman/app"
|
|
|
|
|
import {fromApp, usePlugin} from "@app/core"
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// `|` never appears in a url, and keeps these distinct from the `'`-separated room keys.
|
2026-09-07 16:48:36 +00:00
|
|
|
const makeCommandKey = (url: string, address: string) => `${url}|${address}`
|
|
|
|
|
|
|
|
|
|
const splitCommandKey = (key: string): [string, string] => {
|
|
|
|
|
const i = key.indexOf("|")
|
|
|
|
|
|
|
|
|
|
return [key.slice(0, i), key.slice(i + 1)]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class Commands extends RelayScopedDerivedPlugin<CommandReader> {
|
2026-09-24 18:23:21 +00:00
|
|
|
// The set lives on the plugin, so logging in and swapping the repository starts over.
|
2026-09-07 16:48:36 +00:00
|
|
|
private pulled = new Set<string>()
|
|
|
|
|
|
|
|
|
|
constructor(app: IApp) {
|
|
|
|
|
super(app, {
|
|
|
|
|
filters: [{kinds: [COMMAND]}],
|
|
|
|
|
eventToItem: app.use(Domain).reader(Command),
|
|
|
|
|
getKey: (command, url) => makeCommandKey(url, command.address()),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetch(key: string) {
|
|
|
|
|
const [url, address] = splitCommandKey(key)
|
|
|
|
|
const {pubkey, identifier} = Address.from(address)
|
|
|
|
|
|
2026-09-21 17:58:14 +00:00
|
|
|
return this.app.use(Network).loadComplete({
|
|
|
|
|
relays: [url],
|
|
|
|
|
filters: [{kinds: [COMMAND], authors: [pubkey], "#d": [identifier]}],
|
|
|
|
|
})
|
2026-09-07 16:48:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensureLoaded = (url: string) => {
|
|
|
|
|
if (!this.pulled.has(url)) {
|
|
|
|
|
this.pulled.add(url)
|
2026-09-21 17:58:14 +00:00
|
|
|
this.app.use(Network).loadLenient({relays: [url], filters: [{kinds: [COMMAND]}]})
|
2026-09-07 16:48:36 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
forUrl = (url: string): Projection<CommandReader[]> =>
|
|
|
|
|
projectFrom(this.index, byKey =>
|
|
|
|
|
Array.from(byKey.entries())
|
|
|
|
|
.filter(([key]) => key.startsWith(`${url}|`))
|
|
|
|
|
.map(([, command]) => command),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const commands = usePlugin(Commands)
|
|
|
|
|
|
|
|
|
|
// The definitions an executor is listening for, given the event that would be written.
|
|
|
|
|
export const getCommandsForTarget = (target: CommandScopeTarget) =>
|
|
|
|
|
commands
|
|
|
|
|
.get()
|
|
|
|
|
.forUrl(target.url ?? "")
|
|
|
|
|
.get()
|
|
|
|
|
.filter(command => command.matches(target))
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// One store per url, and subscribing to it is what pulls the definitions.
|
2026-09-09 01:09:24 +00:00
|
|
|
const commandsByUrl = new Map<string, Readable<CommandReader[]>>()
|
|
|
|
|
|
|
|
|
|
const deriveCommandsForUrl = (url: string): Readable<CommandReader[]> => {
|
|
|
|
|
const cached = commandsByUrl.get(url)
|
|
|
|
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
return cached
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const store = fromApp($app => {
|
|
|
|
|
const plugin = $app.use(Commands)
|
|
|
|
|
|
|
|
|
|
if (url) {
|
|
|
|
|
plugin.ensureLoaded(url)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return plugin.forUrl(url).$
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
commandsByUrl.set(url, store)
|
|
|
|
|
|
|
|
|
|
return store
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Content outside a space has no url, and so no commands.
|
2026-09-09 01:09:24 +00:00
|
|
|
export const deriveValidCommands = (target: CommandScopeTarget): Readable<CommandReader[]> =>
|
|
|
|
|
derived(deriveCommandsForUrl(target.url ?? ""), $commands =>
|
|
|
|
|
$commands.filter(command => command.matches(target)),
|
2026-09-07 16:48:36 +00:00
|
|
|
)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Without a qualifier an invocation targets every definition whose trigger matches.
|
2026-09-07 16:48:36 +00:00
|
|
|
export const getCommandsForInvocation = (
|
|
|
|
|
available: CommandReader[],
|
2026-09-09 01:09:24 +00:00
|
|
|
invocation: Pick<CommandInvocation, "command" | "pubkey">,
|
2026-09-07 16:48:36 +00:00
|
|
|
) =>
|
|
|
|
|
available.filter(
|
|
|
|
|
command =>
|
|
|
|
|
command.command() === invocation.command &&
|
|
|
|
|
(!invocation.pubkey || command.author() === invocation.pubkey),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const createCommandSearch = (target: CommandScopeTarget) =>
|
|
|
|
|
createSearch(
|
|
|
|
|
getCommandsForTarget(target).map(command => ({
|
|
|
|
|
address: command.address(),
|
|
|
|
|
command: command.command() ?? "",
|
|
|
|
|
title: command.title() ?? "",
|
|
|
|
|
description: command.description() ?? "",
|
|
|
|
|
})),
|
|
|
|
|
{
|
|
|
|
|
getValue: option => option.address,
|
|
|
|
|
fuseOptions: {keys: ["command", "title"], threshold: 0.3},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const getCommandByAddress = (url: string, address: string) =>
|
|
|
|
|
commands.get().get(makeCommandKey(url, address))
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// `matches` holds every definition the text targets, and `command` is the one shaping the input.
|
2026-09-07 16:48:36 +00:00
|
|
|
export type CommandDraft = {
|
|
|
|
|
invocation: CommandInvocation
|
|
|
|
|
command: CommandReader
|
|
|
|
|
matches: CommandReader[]
|
|
|
|
|
args: CommandArg[]
|
2026-09-24 18:23:21 +00:00
|
|
|
// One entry per argument the text supplies, in order.
|
2026-09-07 16:48:36 +00:00
|
|
|
bindings: CommandArgBinding[]
|
|
|
|
|
activeIndex: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const describeCommandDraft = (
|
|
|
|
|
available: CommandReader[],
|
|
|
|
|
content: string,
|
|
|
|
|
): CommandDraft | undefined => {
|
|
|
|
|
const invocation = parseCommandInvocation(content)
|
|
|
|
|
const matches = invocation ? getCommandsForInvocation(available, invocation) : []
|
|
|
|
|
const command = matches[0]
|
|
|
|
|
|
|
|
|
|
if (invocation && command) {
|
|
|
|
|
const args = command.args()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
invocation,
|
|
|
|
|
command,
|
|
|
|
|
matches,
|
|
|
|
|
args,
|
|
|
|
|
bindings: bindCommandArgs(args, invocation.rest),
|
|
|
|
|
activeIndex: getActiveCommandArgIndex(args, invocation.rest),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|