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, immutable crypto.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 this id, via getProfileById(id?) (exact match only, undefined if 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 (undefined if 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 to getProfileById("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.

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), scanFile diffs the new link set against the previous one and returns any entries that disappeared.

  • On delete, dropFile returns 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.