Architecture#
Overview#
main.ts (HarangImmichPlugin)
├─ settings.ts settings tab UI, reads/writes plugin settings
├─ immich-client.ts ImmichClient — all Immich HTTP calls
├─ gallery-modal.ts image picker modal (feature 1)
├─ reading-view.ts Markdown post-processor (feature 3)
├─ live-preview.ts CodeMirror 6 ViewPlugin (feature 2)
├─ link-syntax.ts shared ![immich:profile name/ID|alt] parser/builder
├─ asset-tracker.ts per-note asset ID bookkeeping
└─ confirm-modal.ts trash / permanent-delete confirmation dialog
HarangImmichPlugin (src/main.ts) wires all of these together on
onload(): it loads settings, prepares the local thumbnail cache directory,
registers the settings tab, the reading-view post-processor, the
live-preview editor extension, the “Insert Immich Image” command, and the
paste/drop upload handlers.
Multiple profiles and ImmichClient instances#
Harang Immich can connect to more than one Immich server through
profiles (ImmichProfile in src/settings.ts: id, name, server
URL, per-profile SecretStorage id, and album). HarangImmichPlugin
does not hold a single shared client; instead getClientForProfile(id)
lazily creates and caches one ImmichClient per profile id in a
Map<string, ImmichClient>. Each client’s constructor closures always
re-look-up the profile by id from this.settings.profiles, so editing a
profile’s name/URL/album never requires recreating its client.
A profile has two identities:
id— an internal, immutablecrypto.randomUUID()(or the fixed"default"for the one-time-migrated profile, see below). It’s never shown to the user or written into a note. Everything that needs a stable handle regardless of edits — the client cache, the active-profile choice, the settings-tab UI, the secret ID — is keyed by thisid, viagetProfileById(id?)(exact match only,undefinedif the id is missing or the profile was deleted).name— the user-editable label, and the value actually embedded in a note’s link syntax (see below).getProfileByName(name?)resolves it: with a name, exact match only (undefinedif no profile currently has that name — this is what produces the “profile not found” error state in the renderers below, whether the profile was deleted or renamed, rather than silently falling back to a different server). Without a name — the case for links written before profiles existed — it falls back togetProfileById("default")(the profile created by the one-time migration described below), then to the first configured profile.
Renaming a profile therefore orphans every link already inserted under its old name.
Which profile is “active” (used for browsing and uploads) is tracked
per-device via app.loadLocalStorage/saveLocalStorage rather than
plugin settings — settings are stored in data.json and sync with the
vault, while loadLocalStorage does not. This is what lets a desktop and
a mobile device use different active profiles (or set up the same server
independently) without one device’s choice clobbering the other’s every time
the vault syncs.
ImmichClient and mobile compatibility#
src/immich-client.ts centralizes every call to the Immich REST API
(search assets, fetch thumbnail/original, upload, delete, list/create
albums). It deliberately uses Obsidian’s requestUrl instead of the
browser fetch API, because fetch is subject to CORS restrictions on
Obsidian Mobile that would otherwise block requests to a self-hosted Immich
server; requestUrl goes through Obsidian’s own request layer and avoids
that restriction.
Thumbnails fetched via fetchThumbnailBlobUrl are cached as files under the
plugin’s .cache directory (inside the vault, alongside a .gitignore
that excludes the whole folder) so the same image isn’t re-downloaded on
every render. Clearing the cache from the settings tab simply removes those
cached files.
Rendering the custom link syntax#
src/link-syntax.ts defines the single regular expression used everywhere
a Harang Immich link needs to be found or built: ![immich:profile name/ASSET_ID]
or ![immich:profile name/ASSET_ID|alt] (the profile name/ part is
optional, for links written before profiles existed). The profile-name
segment accepts any character except the syntax’s own delimiters (/,
|, ]) — so Korean text, spaces, etc. all work directly without
escaping; buildImmichLink strips those three characters out of whatever
name it’s given so a profile name containing one can never corrupt the
syntax it’s embedded in. Because the whole thing omits the parenthesized
URL that standard Markdown images require, Obsidian’s built-in renderer
leaves the text alone, which is what allows two independent code paths to
take over rendering:
reading-view.tsregisters aMarkdownPostProcessorthat walks text nodes in the rendered Reading view DOM, finds link matches, and replaces them with<img>elements populated asynchronously from theImmichClientresolved for that match’s profile name.live-preview.tsregisters a CodeMirrorViewPluginthat scans the visible editor ranges for the same syntax and replaces matched ranges with aWidgetTypeimage, except where the cursor currently overlaps the match (so the raw syntax stays editable while you’re on it).
Both paths take a resolveClient: (profileName?) => ImmichClient |
undefined function rather than a single client — it’s main.ts’s
getClientForProfileByName — so each link renders from the server it
actually belongs to. If the match’s profile name doesn’t resolve (the
profile was deleted, or renamed since the link was inserted), the image is
shown as a “profile not found” error instead of silently falling back to
whichever profile happens to be active.
Both paths share the same findImmichLinks parser, so the two rendering
surfaces can never disagree about what counts as a valid link.
Tracking asset lifecycle#
src/asset-tracker.ts maintains an in-memory map of note path → set of
referenced { profileName, assetId } pairs, rebuilt for all vault notes on
startup (initTracker in main.ts). It is updated on three vault events
(modify, delete, rename) registered in
registerVaultDeletionEvents:
On
modify(debounced ~800ms so it doesn’t fire on every keystroke),scanFilediffs the new link set against the previous one and returns any entries that disappeared.On
delete,dropFilereturns every entry the note referenced and stops tracking the path.On
rename, the tracked entry simply moves to the new path.
Whenever entries are found to be “orphaned” this way, main.ts opens an
ImmichDeleteModal (src/confirm-modal.ts) asking whether to move them
to Immich’s trash, delete them permanently, or leave them alone. It then
resolves each entry’s profileName via getProfileByName, groups the
entries by the resulting profile’s id, and calls
ImmichClient.deleteAssets once per profile, so a note referencing images
from more than one server deletes each asset from the server it actually
came from.
Settings, profiles, and secrets#
src/settings.ts defines HarangImmichSettings as { profiles:
ImmichProfile[] } and the settings tab UI: an active-profile picker, one
section per profile (name, server URL, API key, album dropdown/creation),
add/delete-profile controls, and the cache status/clear controls. Because
minAppVersion is 1.13.4, the settings tab implements only the
declarative getSettingDefinitions() API (Obsidian 1.13.0+); there is no
older imperative display() fallback. The profile list is rendered
dynamically: getSettingDefinitions() recomputes its item array from the
current profiles every time it’s called, and add/delete handlers call
update() to force that recomputation.
Each profile’s API key is never stored as plain text: it’s kept in
Obsidian’s SecretStorage under a secret ID unique to that profile
(apiKeySecretId), so different profiles’ keys never collide. The first
time the plugin loads on a version that introduced profiles, main.ts’s
loadSettings() detects the old flat { serverUrl, apiKey, albumId }
shape (migrating any pre-existing plain-text API key into SecretStorage
exactly as before) and wraps it into a single profile with the fixed
internal id "default". getProfileByName(undefined) resolves
un-tagged, pre-migration links via getProfileById("default"), which
still works after that profile is renamed since the internal id doesn’t
change.
Internationalization#
src/i18n/ provides a small t(key, params) helper (src/i18n/index.ts)
backed by en.ts (source of truth for all string keys) and ko.ts
(Korean translations, allowed to be a partial/deep-partial override). Every
user-facing string in the plugin — settings labels, command names, modal
text, and error messages — goes through this helper rather than being
hardcoded, so the plugin’s own UI can be localized independently of these
Sphinx docs.