LANrurugi Plugin SDK
A plugin is a single .ts file dropped under plugins/<category>/ (or uploaded through
POST /plugins/upload, which lands in plugins/custom/<category>/ instead — see
lanrurugi-api::plugins::upload_plugin). It runs inside its own Deno subprocess
(crates/lanrurugi-plugin/src/pool.rs), started with exactly the --allow-net/--allow-read
grant its own PluginInfoResult.declared_permissions asked for — nothing broader
(constitution Principle IV). This module documents that contract as real, type-checked TS
(generate a browsable copy with mise run plugin-sdk-docs) rather than free-hand prose, so it
can't drift from what the host (lanrurugi-api::plugins) and the dispatcher
(dispatcher.ts, doc'd alongside this file) actually do.
Every plugin file must export exactly two things:
pluginInfo(): [PluginInfoResult](../../../.././crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts/~/PluginInfoResult.html)— synchronous, no side effects. Called by itself in a disposable zero-permission subprocess just to read a plugin's own metadata (lanrurugi_plugin::pool::PluginPool::plugin_info) — must not assumefetch/file access are available even ifdeclared_permissionsasks for them.- One
kind-dependent entry point, matchingPluginInfoResult.type:"metadata"→execMetadata(hostArgs): Promise<[MetadataResult](../../../.././crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts/~/MetadataResult.html)>"login"→execLogin(hostArgs): Promise<[LoginResult](../../../.././crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts/~/LoginResult.html)>"download"→execDownload(hostArgs): Promise<[DownloadResult](../../../.././crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts/~/DownloadResult.html)>"script"→runScript(hostArgs): Promise<unknown>— legacy's own three Scripts plugins (FolderToCat/SourceFinder/nHentaiSourceConverter) are reimplemented as native Rust endpoints instead (lanrurugi-api::scripts) rather than shipped as real.tsfiles underplugins/script/, so this entry point currently has no worked example in this repo — a third-party script plugin uploaded viaPOST /plugins/uploadwould still be dispatched through this same generic path, though.
A "download"-type plugin MAY additionally export pluginOptions(): [PluginOptionsResult](../../../.././crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts/~/PluginOptionsResult.html) — synchronous, no side effects, same zero-permission-subprocess call
pattern as pluginInfo(). Omit it entirely if the plugin has no configurable
concurrency/rate-limit/bundling defaults to declare.
lanrurugi-plugin-converter generates both from a legacy .pm file's plugin_info/
get_tags/do_login/provide_url subs automatically — see
crates/lanrurugi-plugin-converter/src/render.rs's own docs, or just run
mise run convert-plugins -- <path-to-legacy-LANraragi-checkout> (see scripts/convert-plugins.sh).
Everything below is what that generated code targets; write it by hand only for a genuinely new
(non-legacy-derived) plugin.
The host-provided globals a plugin body can call (legacyCompat.*, LegacyUserAgent, ...) are
declared in a sibling file, plugins/legacy-globals.d.ts — deliberately not here, and
deliberately not an exported symbol from this module (issue #86). plugins/legacy-globals.d.ts
is a pure global script (no top-level import/export in it at all), which is what lets
plugins/tsconfig.json (covering every .ts file under plugins/) make it ambiently visible
to every plugin file with zero boilerplate of its own — no /// <reference> line, no import,
nothing (this replaced 31 files' worth of copy-pasted declare global boilerplate that used to
live inline in each one, then briefly a one-line reference directive per file, before landing on
this fully zero-touch shape). dispatcher.ts (the real runtime home of
globalThis.legacyCompat = {...}) sits outside plugins/ and so isn't covered by that
project-wide config — it references plugins/legacy-globals.d.ts via its own explicit
/// <reference types="..." /> line instead, same as before.
The same zero-boilerplate constraint is also why MetadataHostArgs,
LoginHostArgs, ScriptHostArgs, and DownloadHostArgs —
despite being genuinely part of this SDK's protocol contract, not host-implementation
plumbing — live in plugins/legacy-globals.d.ts too, not here (issue #86's later type-
unification pass). lanrurugi-plugin-converter's generated code narrows each entry point's own
local info-hash interface with extends Pick<AuthoritativeInterface, "key1" | "key2" | ...>
against these exact four (render.rs's authoritative_host_args_interface) — a real symbol
reference a generated plugin file needs to resolve with zero import/reference of its own, the
same constraint that keeps Legacy*/legacyCompat out of this export-bearing module.
DownloadResult and the types it contains are mirrored there for the same reason
(as DownloadResultShape/DownloadRequestResult/VersionHistoryEntryResult/
PluginErrorResult): a download plugin annotates its own execDownload return type with them,
which needs a name resolvable without an import. That annotation is what makes a mistyped key a
compile error instead of a field the host's deserializer silently drops — version_history
(issue #107) is exactly the kind of optional field that failure mode would hide. The two copies
are kept in sync by hand; this module stays the authoritative source the Rust side is verified
against.
Every other protocol type below (PluginInfoResult, permissions, MetadataResult, ...) has no
such constraint — nothing needs to reference them by name inside a plugin file's own source, only
this module's own callers (dispatcher.ts, deno doc, a future Rust-side protocol.rs
comparison) do, so they stay right here as ordinary exports.
deno doc this file together with dispatcher.ts (exactly what mise run plugin-sdk-docs
does) to get the protocol-type half in one place — plugins/legacy-globals.d.ts's own host
globals aren't currently included in that generated doc output (a plain deno doc invocation
against a global script produces nothing useful; folding it in would need its own follow-up).
deno doc --json (same two input files) is also confirmed to work, producing structured
per-symbol output (JSDoc text, resolved tsType, source location) that's a more natural fit
for a future documentation site's own Markdown generation (issue #52) than the fixed
pre-rendered HTML bundle --html produces — the one thing to know before wiring that up:
--json's nodes keys and every symbol's location.filename are absolute file:// paths on
whatever machine generated them, so a conversion step needs to strip/remap those before the
output is portable to another machine or CI.
Throw this (instead of a plain throw new Error(...)) to report a PluginError
through an exception rather than a return {error} — used for the exact same "expected,
semantic failure" cases MetadataResult.error documents, just from a code path
that's more naturally a throw (deep inside a helper, a caught-and-rethrown parse failure,
etc.) than a return. The dispatcher (dispatcher.ts's handleRequest) special-cases this
class specifically — an unexpected throw of anything else (a genuine bug, a network library's
own exception type) still degrades to today's generic unstructured-fault handling, since an
arbitrary caught value has no stable translatable shape to extract error_code/data from.
What a plugin is allowed to touch — enforced by starting its Deno subprocess with exactly the
matching --allow-net/--allow-read/--allow-write flags and nothing wider
(lanrurugi_plugin::pool::Worker::spawn), constitution Principle IV. Declaring less than a
plugin actually needs fails loudly at the call site (a denied fetch/file op throws); there is
no way to request "read access to one specific file" here beyond PluginInfoResult.sidecar_files, which the host resolves itself rather than granting real
filesystem access for.
-
net: string[]
Exact hostnames this plugin's
fetch()calls are allowed to reach — verify every one against a real URL literal in the plugin's own source before shipping it; an empty array means no network access at all. - read: boolean
- write: boolean
One work a listing offered.
-
category: string
The source's own classification (
"Doujinshi","Manga"), for the excluded-category rule. -
pages: number
Length, so the host can show and filter on download cost before anything is spent.
-
posted_at: string | number | Date
When the source published it, as the listing states it.
-
rating: number
0–5. Absent means not yet rated, which is not the same as rated zero — a work published minutes ago usually has no rating at all, and the host treats the two differently rather than judging it at the moment the answer is least knowable.
-
source: string
This work's source URL, already normalised through this plugin's own
canonicalizeSourceso the host's duplicate check is a plain comparison. An un-normalised value silently fails to match an identical work already held, with nothing reporting the mismatch. -
tags: string[]
Namespaced as the source writes them (
artist:foo), not stripped — the host's tag rules compare against these verbatim. -
title: string | Record<string, string>
The work's title, by language. Keys are language tags (
"ja","en", …) plus"origin"for the title as the source itself writes it. -
uploader: string
Who posted it — what a creator-scoped subscription matches on.
What a subscription is looking for, handed to discover (issue #55).
-
creator: string
A creator/uploader to follow.
-
listing_url: string
A feed or index page the user supplied, for sources whose extension cannot search unaided.
-
max_pages: number
How many listing pages to read at most, supplied by the host.
-
tags: string[]
Tags that scope the subscription.
discover's return shape.
- candidates: DiscoveredCandidate[]
-
degraded: boolean
Load-bearing.
truewhen this listing was read without the sign-in it needed. -
error: PluginError
Why the result is degraded, or why nothing could be read at all.
One per-domain concurrency/rate-limit rule, as declared by a plugin's own PluginOptionsResult.domain_rules default, or as a user's persisted override
(GET/PUT /api/plugins/{namespace}/options) — specs/005-download-plugin-progress/ data-model.md's Domain Rule.
-
description: string
Human-readable explanation shown in the settings UI.
-
max_bytes_per_sec: number
Maximum transfer speed (bytes/sec) permitted against a domain matching
pattern. Absent = no rate limit from this rule. -
max_concurrent: number
Maximum simultaneous downloads permitted against a domain matching
pattern. Absent = no concurrency limit from this rule. -
pattern: string
An exact hostname (
"example.com") or a wildcard covering subdomains ("*.example.com"). Case-insensitive; no port/scheme component. Omit entirely (or use"*") for a general, non-domain-specific fallback rule. An exact-hostname match always takes precedence over a wildcard match, which always takes precedence over the general fallback.
One resource to fetch, as part of DownloadResult.downloads — see that field's own
docs. specs/005-download-plugin-progress/contracts/plugin-download-protocol.md is the wire
contract this mirrors field-for-field.
-
filename_hint: string
A plugin-suggested filename, used only as a fallback when the real HTTP response has no (or an unparseable)
Content-Dispositionheader. -
headers: Record<string, string>
Extra request headers for this specific resource (e.g. Pixiv's anti-hotlink
Refererheader) — absent means no extra headers beyond whatever the host's own downloader sends by default. -
method: string
Defaults to
"GET"when absent — every real corpus plugin (and legacy LANraragi's ownModel::Upload.pm::download_url) only ever uses GET, but other methods remain representable for a future plugin that needs one. -
url: string
The real, directly-fetchable resource URL.
execDownload's return shape — verified against every real legacy Download/*.pm's own
provide_url sub. The host performs the real byte-level HTTP transfer itself now (Rust-side
streaming download — see specs/005-download-plugin-progress/plan.md's Summary for why: this
is what makes real progress reporting, per-domain concurrency limiting, and rate limiting
possible at all), rather than treating a plugin's result as an opaque already-downloaded blob.
-
downloads: DownloadRequest[]
One element = a single-file download (e.g. Chaika/EHentai-style plugins, which resolve one real download URL); more than one element = a multi-resource download (e.g. Pixiv's per-page images), assembled per the plugin's
PluginOptionsResult.bundle_as_archivesetting. - error: PluginError
-
file_path: string
Pre-existing fallback escape hatch: a plugin that already downloaded/wrote a file itself and hands back a local path. Mutually exclusive with
downloads; does not receive progress/concurrency/rate-limit treatment since the transfer already happened entirely inside the plugin process by the time this returns. -
version_history: VersionHistoryEntry[]
The full known version-history of this download's series, as this plugin's site exposes it — every source URL the plugin can enumerate, each with the timestamp the site reports for it.
execLogin's return shape. Only cookies/headers are ever read by the host
(lanrurugi-api::plugins::with_login_cookies), which folds them into the next
metadata/download call's MetadataHostArgs.user_agent_cookies/
user_agent_headers — every other property a real legacyCompat.userAgent() instance exposes
is a function, which JSON.stringify silently drops crossing the dispatcher's
newline-delimited-JSON boundary (see dispatcher.ts's own LegacyUserAgent.cookies docs), so
returning the whole ua object works, but only these two plain-data fields survive.
- cookies: LegacyCookie[]
- error: PluginError
-
headers: Record<string, string>
Any header/token-based credential this login plugin wants a downstream metadata/download call to send (e.g.
{ "Authorization": "Key <api_key>" }, a bearer token, a custom signed-request header — whatever the target site's own auth scheme needs). Independent ofcookiesand ofLegacyUserAgententirely: a plugin usinglegacyCompat.userAgent()can hand back its ownua.headerssnapshot here (see that property's own docs), but nothing requires going throughuserAgent()at all — a plugin that authenticates with a plainfetch()call and holds the resulting token in a local variable can justreturn { headers: { Authorization:Bearer ${token}} }directly. For a site authenticating via a header/token rather than a cookie, this is the only way that credential actually reaches a downstream call;cookiesalone is empty for such a login plugin (issue #78/#93:nhapiauth'sAuthorizationheader used to have no way to cross this boundary at all before this field existed).
execMetadata's return shape. This is the real, verified-against-every-converted-plugin
contract: every one of this repo's 21 converted Metadata/*.pm plugins (and every legacy .pm
source they came from) returns exactly (tags => ..., title => ...) from its own get_tags
sub, and lanrurugi-api::plugins::use_plugin_sync/use_plugin_async currently forward a
plugin's JSON response to the API's own data field completely unchanged — no field renaming
happens on the host side today. (Legacy's own ~/LANraragi/lib/LANraragi/Model/Plugins.pm does
rename a plugin's tags to new_tags before its real HTTP API sees it, matching
~/LANraragi/tools/openapi.yaml's documented response shape — this repo deliberately doesn't
mirror that rename, since nothing on the host or frontend side depends on the new_tags name;
crates/lanrurugi-plugin/samples/sample-metadata-plugin.ts and every real call site use tags
to match.)
-
error: PluginError
Present instead of
tags/titleon failure (e.g. a filename plugin whose regex didn't match) — a structuredPluginError, not thrown, so the host can report it without the wholeuse_plugincall failing. - tags: string
- title: string
A plugin-authored error — error_code doubles as an i18n lookup key (the frontend's
apps/frontend/src/i18n/locales/*.json use literal English sentences as keys, not symbolic
codes; a missing translation falls back to rendering error_code itself verbatim, i18next's
own default behavior). Write error_code as a natural, stable English phrase that does NOT
embed any dynamic value (a file path, a URL, an HTTP status code, ...) — those go in
data instead, so the same error_code can be looked up and translated regardless
of which specific file/URL/status triggered it. E.g. for "Could not open foo.json!", use
error_code: "Could not open file" + data: {filepath: "foo.json"}, not the interpolated
string itself as the code.
-
data: Record<string, string | number>
Interpolation params for the translated string — only strings/numbers (filenames, URLs, other messages, page counts, HTTP status codes: everything this SDK's real plugin corpus actually needs to interpolate), never a richer/nested value.
- error_code: string
pluginInfo()'s return shape — matches lanrurugi_plugin::protocol::PluginInfo field for
field (that's the Rust-side deserialization target every one of these fields is verified
against). Fields below login_from are display-only, answering GET /plugins/{type} with the
shape existing UI/tooling expects; they're not part of contracts/plugin-protocol.md's minimal
wire contract.
- author: string
- declared_permissions: DeclaredPermissions
- description: string
-
domain_match: string[]
Bare domains (no scheme, no path, e.g.
["e-hentai.org", "exhentai.org"]) this plugin considers itself the owner of — only for domain-ownership lookups (Upload page's "fetch metadata" button, the AI wizard's coverage check), never for real dispatch (stillPluginInfoResult.url_pattern's job — keep declaring that too, it should stay a precise trigger condition). Omit only whenurl_patternis already a bare domain-level pattern; declare this explicitly wheneverurl_patternis narrower than the domain itself (e.g. requires a specific path segment). -
generated_by_wizard: boolean
trueonly for a plugin whose source was produced by the AI plugin creation wizard (specs/006-ai-plugin-wizard, FR-026) — a wizard-generated draft always sets this literally in its ownpluginInfo()return value (the wizard's generation prompt instructs the model to include it), so it survives independently of any wizard session once the file is saved to disk. Omit entirely for a hand-written/manually-uploaded plugin; the host never infers this from anything other than the plugin's own declared value. - icon: string
-
login_from: string
Namespace of another
"login"-type plugin to run fresh before every single call to this one (never a cached session) — mirrors legacy'sexec_login_plugin(~/LANraragi/lib/LANraragi/Model/Plugins.pm:107-135). Omit entirely for a plugin that needs no login, or that is itself a"login"-type plugin (which never chases another login). A failed login attempt only logs a warning; this plugin's own call still goes ahead, just withouthostArgs.user_agent_cookiespopulated. - name: string
-
namespace: string
Stable machine identifier — also this plugin's filename without the
.tsextension once installed (metadata/ehentai.ts→ namespacemetadata/ehentai), and the Redis key suffix its one configurableargvalue is stored under. -
oneshot_arg: string
Prompt text for the single free-text value a user types into this plugin's "run once" dialog (as opposed to a saved settings-page
PluginParameter) — e.g. a URL or gallery ID for a one-shot "copy tags from this other archive" action. Omit if this plugin has no such one-shot action. - parameters: PluginParameter[]
-
sidecar_files: string[]
Sidecar metadata filenames this plugin wants read out of the archive it's currently processing (basename suffixes, e.g.
"ComicInfo.xml","info.json") —lanrurugi- plugin-converterpopulates this automatically from everyis_file_in_archive(...)call it finds in a converted plugin's source. The host resolves each name against the current archive before callingexecMetadataand hands back whatever it found asMetadataHostArgs.sidecar_files: file content, never a path — this plugin itself never gets real filesystem access for this (constitution Principle IV). - type: PluginKind
-
url_pattern: string
A regex (JS
RegExpsource, no delimiters, e.g."pixiv\\.net") matched case-insensitively against a full candidate URL to decide whether this plugin should handle it — the precise trigger condition for a real download/metadata fetch (the Upload page's own URL-queue grouping, the host's ownfind_matching_plugin). NOT what a domain-ownership lookup should match against — seePluginInfoResult.domain_matchfor that. Omit if this plugin has no meaningful URL-based routing (e.g. a script/login plugin, or a metadata/download plugin with no single well-known source domain). - version: string
pluginOptions()'s return shape — a plain additional export function, parallel to the
mandatory pluginInfo() (see this module's own top-level docs). Omitting pluginOptions()
entirely is valid, and is the expected shape for every non-download plugin, and for a download
plugin with nothing to configure — the host then shows no settings UI for that plugin at all
(spec FR-015). specs/005-download-plugin-progress/contracts/plugin-download-protocol.md is the
wire contract this mirrors field-for-field.
-
bundle_as_archive: { default: boolean; description: string; }
Only meaningful for a plugin whose
execDownloadcan return more than oneDownloadResult.downloadselement. Absent for a single-resource-only plugin (no such setting shown at all). -
check_interval: { suggested_secs: number; minimum_secs: number; description: string; }
How often a subscription against this source may check (issue #55). Only meaningful for a plugin that also exports
Discover. -
domain_rules: DomainRule[]
The plugin's own declared default
DomainRules for the domain(s) it targets. Absent/empty means the plugin declares no concurrency/rate-limit defaults of its own — that domain is unmanaged (unlimited) unless a user override exists (spec FR-017: the system itself imposes no default). -
overwrite_on_duplicate: { default: boolean; description: string; }
Whether starting a download for a URL this plugin handles should overwrite an existing archive that collides by content hash or destination filename, rather than the default safe behavior (reject the new file, keep the existing archive untouched). Omit if this plugin has no opinion — the effective value then falls back to the global
replacedupesetting. -
relative_newer_policy: { default: RelativeNewerPolicy; description: string; }
How to handle a download the host determines (via
DownloadResult.version_history) is a NEWER revision of an already-catalogued archive. -
relative_older_policy: { default: RelativeOlderPolicy; description: string; }
How to handle a download the host determines is an OLDER revision of an already-catalogued archive. Optional on exactly the same terms as
PluginOptionsResult.relative_newer_policy; defaults toDEFAULT_RELATIVE_OLDER_POLICY.
One entry in PluginInfoResult.parameters — a user-configurable setting shown in
the plugin's settings UI. The host persists one value per declared parameter (GET/
PUT /plugins/settings?namespace=..., lanrurugi-api::plugins::plugin_settings_key, Redis
field customargs) and passes the whole array back on every call, positionally matching this
array (parameters[0]'s saved value is hostArgs.customargs[0], etc. — see
MetadataHostArgs.customargs), each element already in its own declared type — a bool parameter's value arrives as a real boolean, never a string a plugin has to
parse back out itself. Distinct from PluginInfoResult.oneshot_arg, which is a single
value typed fresh into a "run once" dialog each time, not persisted here.
- description: string
- name: string
- required: boolean
-
type: "string" | "bool" | "int"
Matches legacy's own
param.type(~/LANraragi/templates/plugins.html.tt2'sSWITCH param.type—'string'→ a text input,'bool'→ a checkbox/switch,'int'→ a number input). Missing entirely before this — every converted plugin's parameters rendered as a plain, unstyled text input regardless of their real semantics, including boolean toggles likeplugins/metadata/mems.ts's own 3 params (real legacyMEMS.pmdeclares all 3 astype => 'bool', rendered as ON/OFF switches — a plain blank text box here was a real, visible mismatch). Legacy's own unmatched-SWITCHfallback renders atype="color"picker (its source literally comments this default "ayy lmao" — an unintentional quirk, not a real 4th parameter kind any real plugin in this corpus uses); this SDK deliberately defaults absenttypeto'string'instead, since every one of this corpus's 31 plugins with declared parameters had none of this field before now and were already correctly rendered/used as plain text inputs — matching legacy's silly color-picker default would have turned every one of those already-working parameters into a broken control instead of leaving them unaffected.
runScript's return shape — deliberately a loose bag of fields (mirrors legacy's own
run_script subs, each of which returns a different ad-hoc hash with no shared shape across the
three real Scripts plugins) rather than one rigid interface every script must conform to.
total/id/error are SourceFinder.pm's own fields; modified/updates are
nHentaiSourceConverter.pm's.
-
categories_to_create: { name: string; archive_ids: string[]; }[]
One static Category to create per discovered subfolder — the host creates each one after
runScriptreturns, same read-compute-return-write-to-host split asupdates(foldertocat). -
delete_old_categories: boolean
Whether to delete every existing static (non-dynamic) category before creating the new ones — echoes back this call's own
customargs[0], since the host applies the deletion itself (foldertocat). -
elapsed_ms: number
Wall-clock milliseconds
runScriptitself spent walking the directory tree and computing groupings — reported back for a direct comparison against the equivalent native Rust endpoint (POST /database/scripts/subfolders-to-categories's ownelapsed_ms), which does the identical job. Not a generic field every script plugin is expected to fill in (foldertocatonly). - error: PluginError
-
id: string
The matching archive's ID, when
totalis1(urlfinder). -
modified: number
Count of archives this call changed (
nhsrcconv). -
total: number
1if a match was found,0otherwise (urlfinder). -
updates: ScriptArchiveTags[]
This call's intended tag rewrites — the host applies each one (same shape as
ScriptHostArgs.archives) afterrunScriptreturns; a plugin never writes storage directly (nhsrcconv).
One node of a DownloadResult.version_history chain — a single known revision of the
series, as the plugin's site reports it.
-
posted_at: string
ISO 8601 timestamp the site reports for this revision. The plugin converts from whatever the site's native format is (e-hentai's
gdatareportspostedas a unix-seconds string). Sole determiner of revision order: there is deliberately no separate "this one is current" flag, since a second source of truth could contradict the sort and leave the host no way to know which to believe. -
source: string
This revision's source URL, already normalized through this plugin's own
canonicalizeSource(seeDownloadResult.version_history's docs — an un-normalized value silently fails to match a storedsource:tag).
Optional export, parallel to pluginOptions. Given a URL this plugin's site might
expose (any domain alias, with or without query string/trailing slash/scheme), returns the
canonical form the host should use for matching against stored source: tags — e.g. e-hentai
folding exhentai.org/g.e-hentai.org/forums.e-hentai.org all down to a single
e-hentai.org/g/{gid}/{token} form. The host calls this before every source: tag comparison
(both for DownloadResult.version_history matching and for
plugins/script/sourcefinder.ts's existing lookup) instead of maintaining its own per-site
domain-alias table, so this plugin's own domain-alias knowledge lives in the one place that
actually has it.
Optional export, parallel to pluginOptions. Given a subscription's criteria (or a
listing URL), returns the candidate works this source currently offers.
The four plugin categories the host recognizes (lanrurugi-api::plugins::PLUGIN_CATEGORIES) —
also the fixed set of subdirectories under plugins/ (metadata/, login/, download/,
script/), and the value upload_plugin trusts only from a freshly-uploaded plugin's own
pluginInfo() response (never a client-supplied category) to decide where to file it.
What to do when the incoming download is a newer revision of something already in the library.
| "warn_then_conflict_menu"
| "silent_then_conflict_menu"
What to do when the incoming download is an older revision than something already in library.
Applied when neither the user nor the plugin states a preference. A download the site itself marks as a later revision of something already in the library is normally meant to replace it; keeping both would leave a superseded duplicate for the user to find and delete by hand. The old archive is only removed after the new revision is safely catalogued, so this default never risks ending up with neither.
Applied when neither the user nor the plugin states a preference. Asking first is the safe default for the genuinely surprising case — the user asked for a URL that turns out to be older than what they already have.
Usage
import * as mod from "crates/lanrurugi-plugin/dispatcher/plugin-sdk.ts";