아키텍처#

개요#

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)은 onload()에서 이 모든 요소를 연결합니다: 설정을 불러오고, 로컬 썸네일 캐시 디렉터리를 준비하고, 설정 탭·읽기 보기 후처리기·라이브 프리뷰 에디터 확장·”Immich 이미지 삽입” 명령·붙여넣기/드래그 업로드 핸들러를 등록합니다.

여러 프로필과 ImmichClient 인스턴스#

Harang Immich는 프로필(src/settings.tsImmichProfile: id, name, 서버 URL, 프로필별 SecretStorage id, album)을 통해 여러 Immich 서버에 연결할 수 있습니다. HarangImmichPlugin은 공유되는 클라이언트 하나를 갖는 대신 getClientForProfile(id)가 프로필 id별로 ImmichClient를 lazy하게 생성해 Map<string, ImmichClient>에 캐싱합니다. 각 클라이언트의 생성자 클로저는 항상 this.settings.profiles에서 id로 프로필을 다시 조회하므로, 프로필의 이름/URL/앨범을 수정해도 클라이언트를 다시 만들 필요가 없습니다.

프로필은 두 가지 정체성을 가집니다:

  • id — 내부용의, 절대 바뀌지 않는 crypto.randomUUID()값입니다(단, 일회성 마이그레이션된 프로필은 고정값 "default"를 씁니다, 아래 참고). 사용자에게 보이거나 노트에 저장되는 일이 없습니다. 수정과 무관하게 안정적인 핸들이 필요한 모든 곳 — 클라이언트 캐시, 활성 프로필 선택, 설정 탭 UI, 비밀 ID — 은 getProfileById(id?)를 통해 이 id로 키를 삼습니다(정확히 일치하는 것만 찾고, id가 없거나 프로필이 삭제됐으면 undefined).

  • name — 사용자가 수정할 수 있는 라벨이자, 노트의 링크 문법에 실제로 담기는 값입니다(아래 참고). getProfileByName(name?)이 이를 해석합니다: 이름이 주어지면 정확히 일치하는 것만 찾습니다(현재 그 이름을 가진 프로필이 없으면 undefined— 프로필이 삭제됐든 이름이 바뀌었든 다른 서버로 조용히 대체하는 대신 아래 렌더러들이 “프로필을 찾을 수 없음” 오류 상태를 만들어내는 지점이 바로 여기입니다). 이름이 없으면 — 프로필이 생기기 전에 작성된 링크의 경우 — getProfileById("default")(아래에서 설명하는 일회성 마이그레이션으로 생성된 프로필)로, 그마저 없으면 첫 번째로 설정된 프로필로 대체됩니다.

따라서 프로필 이름을 바꾸면 그 옛 이름으로 이미 삽입된 모든 링크가 고아가 됩니다.

어느 프로필이 “활성”(조회와 업로드에 사용됨) 상태인지는 플러그인 설정이 아니라 기기별로 app.loadLocalStorage/saveLocalStorage를 통해 추적됩니다 — 설정은 data.json에 저장되어 볼트와 동기화되지만 loadLocalStorage는 그렇지 않습니다. 이 덕분에 데스크탑과 모바일 기기가 서로 다른 활성 프로필을 쓰거나(혹은 같은 서버를 각자 독립적으로 설정하더라도) 볼트가 동기화될 때마다 한쪽의 선택이 다른 쪽을 덮어쓰는 일 없이 동작할 수 있습니다.

ImmichClient와 모바일 호환성#

src/immich-client.ts는 Immich REST API 호출(에셋 검색, 썸네일/원본 조회, 업로드, 삭제, 앨범 조회/생성)을 한 곳에 모읍니다. 브라우저 fetch API 대신 의도적으로 Obsidian의 requestUrl을 사용하는데, fetch는 Obsidian Mobile에서 CORS 제약을 받아 자체 호스팅 Immich 서버로의 요청이 막힐 수 있기 때문입니다. requestUrl은 Obsidian 자체 요청 계층을 거치므로 이 제약을 피할 수 있습니다.

fetchThumbnailBlobUrl로 가져온 썸네일은 플러그인의 .cache 디렉터리(볼트 안, 폴더 전체를 제외하는 .gitignore와 함께) 아래에 파일로 캐시되어 매 렌더링마다 다시 내려받지 않습니다. 설정 탭에서 캐시를 비우면 이렇게 캐시된 파일들이 삭제됩니다.

에셋 수명 주기 추적#

src/asset-tracker.ts는 노트 경로 → 참조된 { profileName, assetId } 쌍의 집합을 메모리에 유지하며, 시작 시 볼트의 모든 노트에 대해 다시 구성됩니다(main.tsinitTracker). 이는 registerVaultDeletionEvents에 등록된 세 가지 볼트 이벤트(modify, delete, rename)에서 갱신됩니다:

  • modify``(매 입력마다 실행되지 않도록 800ms 디바운스)에서는 ``scanFile이 새 링크 집합과 이전 집합을 비교해 사라진 항목을 반환합니다.

  • delete에서는 dropFile이 해당 노트가 참조하던 모든 항목을 반환하고 그 경로에 대한 추적을 중단합니다.

  • rename에서는 추적 중이던 항목을 새 경로로 옮기기만 합니다.

이런 식으로 “고아가 된” 항목이 발견될 때마다 main.tsImmichDeleteModal``(``src/confirm-modal.ts)을 열어 휴지통으로 이동할지, 완전히 삭제할지, 그대로 둘지 묻습니다. 그런 다음 각 항목의 profileNamegetProfileByName으로 해석하고, 그렇게 얻은 프로필의 id로 항목들을 묶어 프로필마다 한 번씩 ImmichClient.deleteAssets를 호출하므로, 여러 서버의 이미지를 참조하는 노트라도 각 에셋이 실제로 속한 서버에서 삭제됩니다.

설정, 프로필, 비밀정보#

src/settings.tsHarangImmichSettings{ profiles: ImmichProfile[] }로 정의하고, 설정 탭 UI로 활성 프로필 선택기, 프로필별 섹션(이름, 서버 URL, API 키, 앨범 드롭다운/생성), 프로필 추가/삭제 컨트롤, 캐시 상태 조회/비우기 컨트롤을 정의합니다. minAppVersion이 1.13.4이므로 설정 탭은 선언형 getSettingDefinitions() API(Obsidian 1.13.0+)만 구현하며, 이전의 명령형 display() 폴백은 존재하지 않습니다. 프로필 목록은 동적으로 렌더링됩니다: getSettingDefinitions()는 호출될 때마다 현재 프로필 목록으로부터 항목 배열을 다시 계산하고, 추가/삭제 핸들러는 그 재계산을 강제하기 위해 update()를 호출합니다.

각 프로필의 API 키는 결코 평문으로 저장되지 않습니다: 그 프로필 고유의 비밀 ID(apiKeySecretId) 아래 Obsidian의 SecretStorage에 보관되므로, 서로 다른 프로필의 키가 절대 충돌하지 않습니다. 프로필 개념이 도입된 버전으로 플러그인이 처음 로드될 때, main.tsloadSettings()는 예전의 flat한 { serverUrl, apiKey, albumId } 형태를 감지하여(기존에 평문으로 저장된 API 키가 있다면 이전과 동일하게 SecretStorage로 이전하면서) 고정된 내부 id "default"를 가진 프로필 하나로 감쌉니다. getProfileByName(undefined)는 프로필 정보 없는 마이그레이션 이전 링크를 getProfileById("default")로 해석하며, 그 프로필의 이름을 바꿔도 내부 id는 바뀌지 않으므로 이 대체 로직은 계속 동작합니다.

국제화(i18n)#

src/i18n/은 모든 문자열 키의 기준(source of truth)이 되는 en.ts와, 부분/깊은 부분 재정의가 허용되는 한국어 번역 ko.ts를 기반으로 하는 작은 t(key, params) 헬퍼(src/i18n/index.ts)를 제공합니다. 플러그인의 모든 사용자 노출 문자열 — 설정 레이블, 명령 이름, 모달 텍스트, 오류 메시지 — 은 하드코딩되지 않고 이 헬퍼를 거치므로, 플러그인 자체 UI는 이 Sphinx 문서와 별개로 지역화될 수 있습니다.