아키텍처#
개요#
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.ts의 ImmichProfile: 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/link-syntax.ts는 Harang Immich 링크를 찾거나 만들 때 어디서든 사용되는 단 하나의 정규식을 정의합니다: ![immich:프로필 이름/ASSET_ID] 또는 ![immich:프로필 이름/ASSET_ID|alt] (프로필 이름/ 부분은 선택적이며, 프로필이 생기기 전에 작성된 링크를 위한 것입니다). 프로필 이름 구간은 문법 자체의 구분자(/, |, ])만 제외하면 어떤 문자든 허용하므로 한글 텍스트, 공백 등도 이스케이프 없이 그대로 동작합니다; buildImmichLink는 주어진 이름에서 이 세 문자를 제거하므로, 이런 문자를 포함한 프로필 이름이 있어도 그 이름이 삽입되는 문법 자체를 망가뜨릴 수 없습니다. 표준 마크다운 이미지에 필요한 괄호로 감싼 URL이 전체적으로 없기 때문에 Obsidian 기본 렌더러가 이 텍스트를 건드리지 않으며, 덕분에 서로 독립적인 두 코드 경로가 렌더링을 대신 처리할 수 있습니다:
reading-view.ts는 렌더링된 읽기 보기 DOM의 텍스트 노드를 순회하며 링크를 찾아<img>요소로 교체하는MarkdownPostProcessor를 등록합니다. 이 요소는 해당 매치의 프로필 이름에 대해 해석된ImmichClient로부터 비동기로 채워집니다.live-preview.ts는 보이는 에디터 범위에서 동일한 문법을 찾아 일치하는 범위를WidgetType이미지로 교체하는 CodeMirrorViewPlugin을 등록합니다. 다만 커서가 해당 범위와 겹칠 때는 예외로 두어(그 위에 있는 동안은 원본 문법을 편집할 수 있도록) 교체하지 않습니다.
두 경로 모두 클라이언트 하나 대신 resolveClient: (profileName?) => ImmichClient | undefined 함수를 받습니다 — 이는 main.ts의 getClientForProfileByName이며, 덕분에 각 링크는 실제로 속한 서버로부터 렌더링됩니다. 매치의 프로필 이름이 해석되지 않으면(프로필이 삭제됐거나, 링크가 삽입된 이후 이름이 바뀐 경우), 현재 어느 프로필이 활성 상태이든 그것으로 조용히 대체하는 대신 “프로필을 찾을 수 없음” 오류로 이미지가 표시됩니다.
두 경로 모두 동일한 findImmichLinks 파서를 공유하므로, 두 렌더링 방식이 유효한 링크의 기준을 두고 서로 어긋날 일이 없습니다.
에셋 수명 주기 추적#
src/asset-tracker.ts는 노트 경로 → 참조된 { profileName, assetId } 쌍의 집합을 메모리에 유지하며, 시작 시 볼트의 모든 노트에 대해 다시 구성됩니다(main.ts의 initTracker). 이는 registerVaultDeletionEvents에 등록된 세 가지 볼트 이벤트(modify, delete, rename)에서 갱신됩니다:
modify``(매 키 입력마다 실행되지 않도록 약 800ms 디바운스)에서는 ``scanFile이 새 링크 집합과 이전 집합을 비교해 사라진 항목을 반환합니다.delete에서는dropFile이 해당 노트가 참조하던 모든 항목을 반환하고 그 경로에 대한 추적을 중단합니다.rename에서는 추적 중이던 항목을 새 경로로 옮기기만 합니다.
이런 식으로 “고아가 된” 항목이 발견될 때마다 main.ts는 ImmichDeleteModal``(``src/confirm-modal.ts)을 열어 휴지통으로 이동할지, 완전히 삭제할지, 그대로 둘지 묻습니다. 그런 다음 각 항목의 profileName을 getProfileByName으로 해석하고, 그렇게 얻은 프로필의 id로 항목들을 묶어 프로필마다 한 번씩 ImmichClient.deleteAssets를 호출하므로, 여러 서버의 이미지를 참조하는 노트라도 각 에셋이 실제로 속한 서버에서 삭제됩니다.
설정, 프로필, 비밀정보#
src/settings.ts는 HarangImmichSettings를 { profiles: ImmichProfile[] }로 정의하고, 설정 탭 UI로 활성 프로필 선택기, 프로필별 섹션(이름, 서버 URL, API 키, 앨범 드롭다운/생성), 프로필 추가/삭제 컨트롤, 캐시 상태 조회/비우기 컨트롤을 정의합니다. minAppVersion이 1.13.4이므로 설정 탭은 선언형 getSettingDefinitions() API(Obsidian 1.13.0+)만 구현하며, 이전의 명령형 display() 폴백은 존재하지 않습니다. 프로필 목록은 동적으로 렌더링됩니다: getSettingDefinitions()는 호출될 때마다 현재 프로필 목록으로부터 항목 배열을 다시 계산하고, 추가/삭제 핸들러는 그 재계산을 강제하기 위해 update()를 호출합니다.
각 프로필의 API 키는 결코 평문으로 저장되지 않습니다: 그 프로필 고유의 비밀 ID(apiKeySecretId) 아래 Obsidian의 SecretStorage에 보관되므로, 서로 다른 프로필의 키가 절대 충돌하지 않습니다. 프로필 개념이 도입된 버전으로 플러그인이 처음 로드될 때, main.ts의 loadSettings()는 예전의 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 문서와 별개로 지역화될 수 있습니다.