MediaAttachment.ts raw
1 /**
2 * Media type for attachments
3 */
4 export type MediaType = 'image' | 'video' | 'audio' | 'file'
5
6 /**
7 * Upload status for media
8 */
9 export type UploadStatus = 'pending' | 'uploading' | 'completed' | 'failed'
10
11 /**
12 * Image metadata from imeta tag
13 */
14 export interface ImageMetadata {
15 url: string
16 mimeType?: string
17 width?: number
18 height?: number
19 size?: number
20 blurhash?: string
21 sha256?: string
22 alt?: string
23 }
24
25 /**
26 * MediaAttachment Value Object
27 *
28 * Represents a media file attached to a note.
29 * Handles URL validation, type detection, and imeta tag generation.
30 */
31 export class MediaAttachment {
32 private constructor(
33 private readonly _url: string,
34 private readonly _type: MediaType,
35 private readonly _mimeType: string | null,
36 private readonly _metadata: ImageMetadata | null,
37 private readonly _status: UploadStatus,
38 private readonly _alt: string | null
39 ) {}
40
41 /**
42 * Create from a URL (after upload)
43 */
44 static fromUrl(url: string, mimeType?: string): MediaAttachment {
45 const type = MediaAttachment.detectType(url, mimeType)
46 return new MediaAttachment(
47 url,
48 type,
49 mimeType ?? null,
50 null,
51 'completed',
52 null
53 )
54 }
55
56 /**
57 * Create with full metadata (from imeta)
58 */
59 static fromMetadata(metadata: ImageMetadata): MediaAttachment {
60 const type = MediaAttachment.detectType(metadata.url, metadata.mimeType)
61 return new MediaAttachment(
62 metadata.url,
63 type,
64 metadata.mimeType ?? null,
65 metadata,
66 'completed',
67 metadata.alt ?? null
68 )
69 }
70
71 /**
72 * Create a pending attachment (before upload)
73 */
74 static pending(_fileName: string, type: MediaType): MediaAttachment {
75 return new MediaAttachment(
76 '', // No URL yet
77 type,
78 null,
79 null,
80 'pending',
81 null
82 )
83 }
84
85 /**
86 * Detect media type from URL or mime type
87 */
88 private static detectType(url: string, mimeType?: string): MediaType {
89 // Check mime type first
90 if (mimeType) {
91 if (mimeType.startsWith('image/')) return 'image'
92 if (mimeType.startsWith('video/')) return 'video'
93 if (mimeType.startsWith('audio/')) return 'audio'
94 }
95
96 // Fall back to URL extension
97 const urlLower = url.toLowerCase()
98 if (/\.(jpg|jpeg|png|gif|webp|heic|avif|svg)(\?|$)/.test(urlLower)) {
99 return 'image'
100 }
101 if (/\.(mp4|webm|mov|avi|mkv)(\?|$)/.test(urlLower)) {
102 return 'video'
103 }
104 if (/\.(mp3|wav|ogg|flac|m4a)(\?|$)/.test(urlLower)) {
105 return 'audio'
106 }
107
108 return 'file'
109 }
110
111 // Getters
112 get url(): string {
113 return this._url
114 }
115
116 get type(): MediaType {
117 return this._type
118 }
119
120 get mimeType(): string | null {
121 return this._mimeType
122 }
123
124 get metadata(): ImageMetadata | null {
125 return this._metadata
126 }
127
128 get status(): UploadStatus {
129 return this._status
130 }
131
132 get alt(): string | null {
133 return this._alt
134 }
135
136 get isImage(): boolean {
137 return this._type === 'image'
138 }
139
140 get isVideo(): boolean {
141 return this._type === 'video'
142 }
143
144 get isAudio(): boolean {
145 return this._type === 'audio'
146 }
147
148 get isUploaded(): boolean {
149 return this._status === 'completed' && this._url !== ''
150 }
151
152 /**
153 * Generate imeta tag for this attachment
154 * Returns null if not an image or missing required data
155 */
156 toImetaTag(): string[] | null {
157 if (!this.isImage || !this._url) return null
158
159 const tag = ['imeta', `url ${this._url}`]
160
161 if (this._mimeType) {
162 tag.push(`m ${this._mimeType}`)
163 }
164
165 if (this._metadata) {
166 if (this._metadata.width && this._metadata.height) {
167 tag.push(`dim ${this._metadata.width}x${this._metadata.height}`)
168 }
169 if (this._metadata.size) {
170 tag.push(`size ${this._metadata.size}`)
171 }
172 if (this._metadata.blurhash) {
173 tag.push(`blurhash ${this._metadata.blurhash}`)
174 }
175 if (this._metadata.sha256) {
176 tag.push(`x ${this._metadata.sha256}`)
177 }
178 }
179
180 if (this._alt) {
181 tag.push(`alt ${this._alt}`)
182 }
183
184 return tag
185 }
186
187 /**
188 * Set alt text
189 */
190 withAlt(alt: string): MediaAttachment {
191 return new MediaAttachment(
192 this._url,
193 this._type,
194 this._mimeType,
195 this._metadata,
196 this._status,
197 alt
198 )
199 }
200
201 /**
202 * Update status
203 */
204 withStatus(status: UploadStatus): MediaAttachment {
205 return new MediaAttachment(
206 this._url,
207 this._type,
208 this._mimeType,
209 this._metadata,
210 status,
211 this._alt
212 )
213 }
214
215 /**
216 * Set URL after upload
217 */
218 withUrl(url: string, metadata?: ImageMetadata): MediaAttachment {
219 return new MediaAttachment(
220 url,
221 this._type,
222 metadata?.mimeType ?? this._mimeType,
223 metadata ?? this._metadata,
224 'completed',
225 this._alt
226 )
227 }
228
229 /**
230 * Check equality
231 */
232 equals(other: MediaAttachment): boolean {
233 return this._url === other._url
234 }
235 }
236