diff --git a/AGENTS.md b/AGENTS.md index 3268ff0..6bcd8e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ pnpm run dev ### Production build +Use this when verifying changes. + ```bash pnpm run build ``` diff --git a/manifest.json b/manifest.json index 66397ac..95c5560 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "come-down", "name": "Come Down", - "version": "1.1.1-rc.1", + "version": "1.1.1", "minAppVersion": "1.8.0", "description": "Maintains a cache of your notes’ embedded external images.", "author": "mntno", diff --git a/src/Env.ts b/src/Env.ts index 7000624..5ccd3a8 100644 --- a/src/Env.ts +++ b/src/Env.ts @@ -108,6 +108,7 @@ export const Env = { SPACE: " ", is: (value: unknown): value is string => typeof value === "string", nonEmpty: (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined, + isNonEmpty: (value: unknown): value is string => typeof value === "string" && value !== "", } as const, bool: { diff --git a/src/cache/CacheManager.ts b/src/cache/CacheManager.ts index 637bd19..ae61580 100644 --- a/src/cache/CacheManager.ts +++ b/src/cache/CacheManager.ts @@ -891,12 +891,15 @@ export class CacheManager { const response = await requestUrl({ url: sourceUrl, method: 'GET', throw: false }); const headers = Url.normalizeHeaders(response.headers); - const contentType = headers[Url.RESPONSE_HEADER_LOWERCASE.contentType] ?? undefined; - const cacheControl = headers[Url.RESPONSE_HEADER_LOWERCASE.cacheControl] ?? undefined; + const contentType = headers[Url.RESPONSE_HEADER_LOWERCASE.contentType]; + const cacheControl = headers[Url.RESPONSE_HEADER_LOWERCASE.cacheControl]; + const etag = Url.parseETag(headers[Url.RESPONSE_HEADER_LOWERCASE.etag]); + const lastModifiedHeader = headers[Url.RESPONSE_HEADER_LOWERCASE.lastModified]; + const lastModified = lastModifiedHeader !== undefined ? new Date(lastModifiedHeader).toISOString() : undefined; //Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:download: Got response:\n\tcacheID: ${cacheKey}\n\t${response.status}\n\tcontentType: ${contentType}`); - if (cacheControl == Url.CACHE_CONTROL_LOWERCASE.noStore) + if (cacheControl === Url.CACHE_CONTROL_LOWERCASE.noStore) throw new CacheError(`Caching not allowed on ${sourceUrl}.`, cacheKey); if (response.status >= 400) @@ -926,6 +929,8 @@ export class CacheManager { d: nowDateString, l: nowDateString, cc: cacheControl, + et: etag !== null ? etag : undefined, + m: lastModified, }, f: { s: sourceUrl, diff --git a/src/cache/CacheMetadata.ts b/src/cache/CacheMetadata.ts index e5332d2..d567be7 100644 --- a/src/cache/CacheMetadata.ts +++ b/src/cache/CacheMetadata.ts @@ -75,9 +75,32 @@ export interface CacheMetadataTime { /** Download time */ d: string; - /** Last accessed */ + /** + * "Last time checked". + * + * Currently set to download time and never updated. + * @todo May be used to keep track of when the server was last checked for modified. + */ l: string; /** Value of the `Cache-Control` HTTP response header. */ cc?: string; + + /** + * Value of the `ETag` HTTP response header. + * + * In the HTTP spec (RFC 7232), an ETag must be wrapped in double quotes. But here they are not. Add them back when using the ETag in requests. + * + * @since 1.1.1 + */ + et?: string; + + /** + * Normalized value of the `Last-Modified` HTTP response header (ISO 8601). + * + * Converted from HTTP-date format to ISO for consistency. Convert back to UTC string when using in 'If-Modified-Since' headers. + * + * @since 1.1.1 + */ + m?: string; } diff --git a/src/utils/Url.ts b/src/utils/Url.ts index d0ded46..bcc7cf2 100644 --- a/src/utils/Url.ts +++ b/src/utils/Url.ts @@ -10,6 +10,8 @@ export class Url { contentLength: "Content-Length".toLowerCase(), cacheControl: "Cache-Control".toLowerCase(), expires: "Expires".toLowerCase(), + etag: "ETag".toLowerCase(), + lastModified: "Last-Modified".toLowerCase(), } as const; public static readonly CACHE_CONTROL_LOWERCASE = { @@ -81,6 +83,40 @@ export class Url { return url.startsWith("app://") || url.startsWith("capacitor://") || url.startsWith("file://"); } + /** + * Parses an ETag header value into a storage-friendly format. + * - `W/"abc"` -> `W/abc` + * - `"abc"` -> `abc` + * @param value The raw ETag header string. + * @returns The tag value stripped of quotes, with "W/" prefix preserved if present. + * @since 1.1.1 + */ + public static parseETag(value: string | undefined): string | null { + if (!Env.str.isNonEmpty(value)) + return null; + + const match = value.match(/^(W\/)?"(.*)"$/); + if (match === null) + return null; + + const prefix = match[1] !== undefined ? match[1] : Env.str.EMPTY; + const tag = match[2]; + return `${prefix}${tag}`; + } + + /** + * Converts a stored ETag back to a format suitable for HTTP headers. + * - `W/abc` -> `W/"abc"` + * - `abc` -> `"abc"` + * @param tag The stored ETag value. + * @since 1.1.1 + */ + public static stringifyETag(tag: string): string | null { + if (Env.str.nonEmpty(tag) === undefined) + return null; + return tag.replace(/^(W\/)?(.*)$/, '$1"$2"'); + } + public static trimBackslash(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; }