Compare commits

...

15 Commits

Author SHA1 Message Date
renovate[bot]
c989e53451 chore(deps): update dependency @types/node to ^24.12.0 2026-03-17 00:45:43 +00:00
Mees Frensel
9dafc8e8e9 fix(web): make link fit album card (#26958) 2026-03-16 19:17:55 +01:00
Michel Heusschen
4e44fb9cf7 fix(web): prevent search page error on missing album filter (#26948) 2026-03-16 19:15:20 +01:00
Yaros
82db581cc5 feat(mobile): open in browser (#26369)
* feat(mobile): open in browser

* chore: open in browser instead of webview

* chore: allow archived asset

* fix: moved openinbrowser above unstack

* feat: deeplink into favorites, trash & archived

* fix: use remoteId (for tests to succeed)

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-16 18:06:51 +00:00
Mert
b66c97b785 fix(mobile): use shared auth for background_downloader (#26911)
shared client for background_downloader on ios
2026-03-13 22:23:07 -05:00
Mert
ff936f901d fix(mobile): duplicate server urls returned (#26864)
remove server url

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-13 22:09:42 -05:00
Min Idzelis
48fe111daa feat(web): improve OCR overlay text fitting, reactivity, and accessibility (#26678)
- Precise font sizing using canvas measureText instead of character-count heuristic
- Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads
- Fix animation delay on resize by using transition-colors instead of transition-all
- Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort
- Show text on focus (same styling as hover) with proper ARIA attributes
2026-03-13 22:04:55 -05:00
bo0tzz
0581b49750 fix: ignore optional headers in pr template check (#26910) 2026-03-13 22:55:00 +00:00
rthrth-svg
2c6d4f3fe1 fix(web): copy yearMonth in MonthGroup to avoid shared object reference with asset (#26890)
Co-authored-by: Min Idzelis <min123@gmail.com>
2026-03-13 22:27:08 +01:00
Belnadifia
55513cd59f feat(server): support IDPs that only send the userinfo in the ID token (#26717)
Co-authored-by: irouply <irouply@secom.fr>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-13 22:14:45 +01:00
bo0tzz
10fa928abe feat: require pull requests to follow template (#26902)
* feat: require pull requests to follow template

* fix: persist-credentials: false
2026-03-13 09:43:00 -05:00
Nathaniel Hourt
e322d44f95 fix: SMTP over TLS (#26893)
Final step on #22833

PReq #22833 is about adding support for SMTP-over-TLS rather than just STARTTLS when sending emails. That PReq adds almost everything; it just forgot to actually pass the flag to Nodemailer at the end.

This adds that last line of code and makes it work correctly (for me, anyways!).

Co-authored-by: Nathaniel <I@nathaniel.land>
2026-03-13 09:41:50 -05:00
Michel Heusschen
c2a279e49e fix(web): keep header fixed on individual shared links (#26892) 2026-03-13 09:40:04 -05:00
Mert
226b9390db fix(mobile): video auth (#26887)
* fix video auth

* update commit
2026-03-13 09:38:21 -05:00
Michel Heusschen
754f072ef9 fix(web): disable drag and drop for internal items (#26897) 2026-03-13 09:37:51 -05:00
31 changed files with 586 additions and 103 deletions

80
.github/workflows/check-pr-template.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Check PR Template
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]
permissions: {}
jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -10,6 +10,7 @@ export enum OAuthClient {
export enum OAuthUser {
NO_EMAIL = 'no-email',
NO_NAME = 'no-name',
ID_TOKEN_CLAIMS = 'id-token-claims',
WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role',
@@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({
email_verified: true,
});
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
const getClaims = (sub: string, use?: string) => {
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
return {
sub,
email: `oauth-${sub}@immich.app`,
email_verified: true,
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
};
}
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
};
const setup = async () => {
const { privateKey, publicKey } = await generateKeyPair('RS256');
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
const redirectUris = [
'http://127.0.0.1:2285/auth/login',
'https://photos.immich.app/oauth/mobile-redirect',
];
const port = 2286;
const host = '0.0.0.0';
const oidc = new Provider(`http://${host}:${port}`, {
@@ -66,7 +80,10 @@ const setup = async () => {
console.error(error);
ctx.body = 'Internal Server Error';
},
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
findAccount: (ctx, sub) => ({
accountId: sub,
claims: (use) => getClaims(sub, use),
}),
scopes: ['openid', 'email', 'profile'],
claims: {
openid: ['sub'],
@@ -94,6 +111,7 @@ const setup = async () => {
state: 'oidc.state',
},
},
conformIdTokenClaims: false,
pkce: {
required: () => false,
},
@@ -125,7 +143,10 @@ const setup = async () => {
],
});
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
const onStart = () =>
console.log(
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
);
const app = oidc.listen(port, host, onStart);
return () => app.close();
};

View File

@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",

View File

@@ -380,4 +380,23 @@ describe(`/oauth`, () => {
});
});
});
describe('idTokenClaims', () => {
it('should use claims from the ID token if IDP includes them', async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
});
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
name: 'ID Token User',
userEmail: 'oauth-id-token-claims@immich.app',
userId: expect.any(String),
});
});
});
});

View File

@@ -1651,6 +1651,7 @@
"only_favorites": "Only favorites",
"open": "Open",
"open_calendar": "Open calendar",
"open_in_browser": "Open in browser",
"open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters",

View File

@@ -113,6 +113,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"

View File

@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger

View File

@@ -3,7 +3,13 @@ package app.alextran.immich.core
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.annotation.OptIn
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache
@@ -16,15 +22,22 @@ import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.net.Authenticator
import java.net.CookieHandler
import java.net.PasswordAuthentication
import java.net.Socket
import java.net.URI
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
@@ -56,6 +69,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
*/
object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
@@ -67,6 +81,11 @@ object HttpClientManager {
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
var cronetEngine: CronetEngine? = null
private set
private lateinit var cronetStorageDir: File
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var keyChainAlias: String? = null
@@ -89,6 +108,25 @@ object HttpClientManager {
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
cookieJar.init(prefs)
System.setProperty("http.agent", USER_AGENT)
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
val url = requestingURL ?: return null
if (url.userInfo.isNullOrEmpty()) return null
val parts = url.userInfo.split(":", limit = 2)
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
}
})
CookieHandler.setDefault(object : CookieHandler() {
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
val cookies = cookieJar.loadForRequest(httpUrl)
if (cookies.isEmpty()) return emptyMap()
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
}
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
})
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) {
@@ -107,6 +145,10 @@ object HttpClientManager {
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()
initialized = true
}
}
@@ -223,6 +265,53 @@ object HttpClientManager {
?.joinToString("; ") { "${it.name}=${it.value}" }
}
fun getAuthHeaders(url: String): Map<String, String> {
val result = mutableMapOf<String, String>()
headers.forEach { (key, value) -> result[key] = value }
loadCookieHeader(url)?.let { result["Cookie"] = it }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
}
}
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
}
val cronetStoragePath: File get() = cronetStorageDir
@OptIn(UnstableApi::class)
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
return if (isMtls) {
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
} else {
ResolvingDataSource.Factory(
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
) { dataSpec ->
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
newHeaders["Cache-Control"] = "no-store"
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
}
}
}
private fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(cronetStorageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
.build()
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,

View File

@@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
@@ -15,9 +14,6 @@ import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
@@ -31,10 +27,6 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
@@ -110,7 +101,6 @@ private object ImageFetcherManager {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate)
@@ -143,7 +133,7 @@ private object ImageFetcherManager {
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
CronetImageFetcher(appContext, cacheDir)
CronetImageFetcher()
}
}
}
@@ -161,19 +151,11 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private class CronetImageFetcher : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
init {
engine = build(context)
}
override fun fetch(
url: String,
@@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
val requestBuilder = HttpClientManager.cronetEngine!!
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
}
private fun build(ctx: Context): CronetEngine {
return CronetEngine.Builder(ctx)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(storageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
.build()
}
private fun onComplete() {
val didDrain = synchronized(stateLock) {
activeCount--
@@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared == null) {
executor.shutdown()
} else {
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
engine = build(ctx)
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
@@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor(
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.build()
return OkHttpImageFetcher(client)

View File

@@ -1,5 +1,6 @@
import BackgroundTasks
import Flutter
import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
@@ -18,6 +19,8 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
URLSessionManager.patchBackgroundDownloader()
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller)

View File

@@ -51,7 +51,7 @@ class URLSessionManager: NSObject {
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
)
private static let userAgent: String = {
static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
@@ -158,6 +158,49 @@ class URLSessionManager: NSObject {
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
/// Patches background_downloader's URLSession to use shared auth configuration.
/// Must be called before background_downloader creates its session (i.e. early in app startup).
static func patchBackgroundDownloader() {
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
method_exchangeImplementations(original, swizzled)
}
// Add auth challenge handling to background_downloader's UrlSessionDelegate
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, task, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
}
}
private extension URLSessionConfiguration {
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
// After swizzle, this calls the original implementation
let config = immich_background(withIdentifier: id)
config.httpCookieStorage = URLSessionManager.cookieStorage
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
return config
}
}
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
@@ -168,7 +211,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) {
handleChallenge(session, challenge, completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
@@ -177,7 +220,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) {
handleChallenge(session, challenge, completionHandler, task: task)
}
func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge,
@@ -190,7 +233,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
@@ -200,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let identity = item {
@@ -214,7 +257,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
}
completion(.performDefaultHandling, nil)
}
private func handleBasicAuth(
_ session: URLSession,
task: URLSessionTask?,

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserActionButton extends ConsumerWidget {
final String remoteId;
final TimelineOrigin origin;
final bool iconOnly;
final bool menuItem;
final Color? iconColor;
const OpenInBrowserActionButton({
super.key,
required this.remoteId,
required this.origin,
this.iconOnly = false,
this.menuItem = false,
this.iconColor,
});
void _onTap() async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
String originPath = '';
switch (origin) {
case TimelineOrigin.favorite:
originPath = '/favorites';
break;
case TimelineOrigin.trash:
originPath = '/trash';
break;
case TimelineOrigin.archive:
originPath = '/archive';
break;
default:
break;
}
final url = '$serverEndpoint$originPath/photos/$remoteId';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
label: 'open_in_browser'.t(context: context),
iconData: Icons.open_in_browser,
iconColor: iconColor,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: _onTap,
);
}
}

View File

@@ -176,10 +176,6 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null && serverUrl.isNotEmpty) {
urls.add(serverUrl);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
@@ -75,6 +76,7 @@ enum ActionButtonType {
viewInTimeline,
download,
upload,
openInBrowser,
unstack,
archive,
unarchive,
@@ -149,6 +151,7 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -236,6 +239,13 @@ enum ActionButtonType {
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
iconOnly: iconOnly,
menuItem: menuItem,
iconColor: context.originalTheme?.iconTheme.color,
),
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly,

View File

@@ -1194,10 +1194,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1218,8 +1218,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:

View File

@@ -56,7 +56,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.3
octo_image: ^2.1.0
openapi:

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"typescript": "^5.3.3"
},
"repository": {

8
pnpm-lock.yaml generated
View File

@@ -63,7 +63,7 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^24.11.0
specifier: ^24.12.0
version: 24.12.0
'@vitest/coverage-v8':
specifier: ^4.0.0
@@ -220,7 +220,7 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^24.11.0
specifier: ^24.12.0
version: 24.12.0
'@types/pg':
specifier: ^8.15.1
@@ -323,7 +323,7 @@ importers:
version: 1.2.0
devDependencies:
'@types/node':
specifier: ^24.11.0
specifier: ^24.12.0
version: 24.12.0
typescript:
specifier: ^5.3.3
@@ -645,7 +645,7 @@ importers:
specifier: ^2.0.0
version: 2.1.0
'@types/node':
specifier: ^24.11.0
specifier: ^24.12.0
version: 24.12.0
'@types/nodemailer':
specifier: ^7.0.0

View File

@@ -136,7 +136,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -162,6 +162,7 @@ export class EmailRepository {
host: options.host,
port: options.port,
tls: { rejectUnauthorized: !options.ignoreCert },
secure: options.secure,
auth:
options.username || options.password
? {

View File

@@ -70,7 +70,16 @@ export class OAuthRepository {
try {
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
let profile: OAuthProfile;
const tokenClaims = tokens.claims();
if (tokenClaims && 'email' in tokenClaims) {
this.logger.debug('Using ID token claims instead of userinfo endpoint');
profile = tokenClaims as OAuthProfile;
} else {
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
}
if (!profile.sub) {
throw new Error('Unexpected profile response, no `sub`');
}

View File

@@ -65,6 +65,7 @@
{#each albums as album, index (album.id)}
<a
href={Route.viewAlbum(album)}
class="h-fit"
animate:flip={{ duration: 400 }}
oncontextmenu={(event) => oncontextmenu(event, album)}
>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
type Props = {
ocrBox: OcrBox;
@@ -11,16 +11,35 @@
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
// Fits almost all strings within the box, depends on font family
const fontSize = $derived(
`max(var(--text-sm), min(var(--text-6xl), ${(1.4 * dimensions.width) / ocrBox.text.length}px))`,
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
);
const verticalStyle = $derived.by(() => {
switch (ocrBox.verticalMode) {
case 'cjk': {
return ' writing-mode: vertical-rl;';
}
case 'rotated': {
return ' writing-mode: vertical-rl; text-orientation: sideways;';
}
default: {
return '';
}
}
});
</script>
<div class="absolute left-0 top-0">
<div
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-all hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 pointer-events-auto cursor-text select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none {ocrBox.verticalMode ===
'none'
? 'px-2 py-1 whitespace-nowrap'
: 'px-1 py-2'}"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;{verticalStyle}"
tabindex="0"
role="button"
aria-label={ocrBox.text}
>
{ocrBox.text}
</div>

View File

@@ -73,7 +73,8 @@
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
return {
contentWidth: scaled.width,
contentHeight: scaled.height,

View File

@@ -74,8 +74,12 @@
};
</script>
<section>
{#if sharedLink?.allowUpload || assets.length > 1}
{#if sharedLink?.allowUpload || assets.length > 1}
<main class="mt-24 mb-40 mx-4 isolate" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {viewport} allowDeletion={false} />
</main>
<header class="fixed top-0 inset-s-0 w-full">
{#if assetInteraction.selectionActive}
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
@@ -129,14 +133,11 @@
{/snippet}
</ControlAppBar>
{/if}
<section class="my-40 mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {viewport} allowDeletion={false} />
</section>
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
{/await}
</header>
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
{/await}
{/if}
</section>
{/await}
{/if}

View File

@@ -13,6 +13,7 @@
let isInLockedFolder = $derived(isLockedFolderRoute(page.route.id));
let dragStartTarget: EventTarget | null = $state(null);
let isInternalDrag = false;
const onDragEnter = (e: DragEvent) => {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
@@ -133,7 +134,19 @@
}
};
const ondragstart = () => {
isInternalDrag = true;
};
const ondragend = () => {
isInternalDrag = false;
};
const ondragenter = (e: DragEvent) => {
if (isInternalDrag) {
return;
}
e.preventDefault();
e.stopPropagation();
onDragEnter(e);
@@ -146,6 +159,10 @@
};
const ondrop = async (e: DragEvent) => {
if (isInternalDrag) {
return;
}
e.preventDefault();
e.stopPropagation();
await onDrop(e);
@@ -159,7 +176,7 @@
<svelte:window onpaste={onPaste} />
<svelte:body {ondragenter} {ondragleave} {ondrop} />
<svelte:body {ondragstart} {ondragend} {ondragenter} {ondragleave} {ondrop} />
{#if dragStartTarget}
<!-- svelte-ignore a11y_no_static_element_interactions -->

View File

@@ -60,7 +60,7 @@ export class MonthGroup {
this.#initialCount = initialCount;
this.#sortOrder = order;
this.yearMonth = yearMonth;
this.yearMonth = { year: yearMonth.year, month: yearMonth.month };
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
this.loader = new CancellableTask(

View File

@@ -355,6 +355,29 @@ describe('TimelineManager', () => {
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
});
it('yearMonth is not a shared reference with asset.localDateTime (reference bug)', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([asset]);
const januaryMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
const monthYearMonth = januaryMonth.yearMonth;
const originalMonth = monthYearMonth.month;
expect(originalMonth).toEqual(1);
// Simulating updateObject
asset.localDateTime.month = 3;
asset.localDateTime.day = 20;
expect(monthYearMonth.month).toEqual(originalMonth);
expect(monthYearMonth.month).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
await timelineManager.updateOptions({
visibility: AssetVisibility.Archive,

View File

@@ -1,18 +1,38 @@
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
import type { ContentMetrics } from '$lib/utils/container-utils';
import { clamp } from 'lodash-es';
export type Point = {
x: number;
y: number;
};
const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
export type VerticalMode = 'none' | 'cjk' | 'rotated';
export interface OcrBox {
id: string;
points: Point[];
text: string;
confidence: number;
verticalMode: VerticalMode;
}
const CJK_PATTERN =
/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/;
const VERTICAL_ASPECT_RATIO = 1.5;
const containsCjk = (text: string): boolean => CJK_PATTERN.test(text);
const getVerticalMode = (width: number, height: number, text: string): VerticalMode => {
if (height / width < VERTICAL_ASPECT_RATIO) {
return 'none';
}
return containsCjk(text) ? 'cjk' : 'rotated';
};
/**
* Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d.
* @param points - Array of 4 corner points of the bounding box
@@ -21,8 +41,6 @@ export interface OcrBox {
export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => {
const [topLeft, topRight, bottomRight, bottomLeft] = points;
// Approximate width and height to prevent text distortion as much as possible
const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight));
const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight));
@@ -55,6 +73,96 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[];
return { matrix, width, height };
};
const BORDER_SIZE = 4;
const HORIZONTAL_PADDING = 16 + BORDER_SIZE;
const VERTICAL_PADDING = 8 + BORDER_SIZE;
const REFERENCE_FONT_SIZE = 100;
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 96;
const FALLBACK_FONT = `${REFERENCE_FONT_SIZE}px sans-serif`;
let sharedCanvasContext: CanvasRenderingContext2D | null = null;
let resolvedFont: string | undefined;
const getCanvasContext = (): CanvasRenderingContext2D | null => {
if (sharedCanvasContext !== null) {
return sharedCanvasContext;
}
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
return null;
}
sharedCanvasContext = context;
return sharedCanvasContext;
};
const getReferenceFont = (): string => {
if (resolvedFont !== undefined) {
return resolvedFont;
}
const fontFamily = globalThis.getComputedStyle?.(document.documentElement).getPropertyValue('--font-sans').trim();
resolvedFont = fontFamily ? `${REFERENCE_FONT_SIZE}px ${fontFamily}` : FALLBACK_FONT;
return resolvedFont;
};
export const calculateFittedFontSize = (
text: string,
boxWidth: number,
boxHeight: number,
verticalMode: VerticalMode,
): number => {
const isVertical = verticalMode === 'cjk' || verticalMode === 'rotated';
const availableWidth = boxWidth - (isVertical ? VERTICAL_PADDING : HORIZONTAL_PADDING);
const availableHeight = boxHeight - (isVertical ? HORIZONTAL_PADDING : VERTICAL_PADDING);
const context = getCanvasContext();
if (verticalMode === 'cjk') {
if (!context) {
const fontSize = Math.min(availableWidth, availableHeight / text.length);
return clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE);
}
// eslint-disable-next-line tscompat/tscompat
context.font = getReferenceFont();
let maxCharWidth = 0;
let totalCharHeight = 0;
for (const character of text) {
const metrics = context.measureText(character);
const charWidth = metrics.width;
const charHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
maxCharWidth = Math.max(maxCharWidth, charWidth);
totalCharHeight += Math.max(charWidth, charHeight);
}
const scaleFromWidth = (availableWidth / maxCharWidth) * REFERENCE_FONT_SIZE;
const scaleFromHeight = (availableHeight / totalCharHeight) * REFERENCE_FONT_SIZE;
return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE);
}
const fitWidth = verticalMode === 'rotated' ? availableHeight : availableWidth;
const fitHeight = verticalMode === 'rotated' ? availableWidth : availableHeight;
if (!context) {
return clamp((1.4 * fitWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE);
}
// Unsupported in Safari iOS <16.6; falls back to default canvas font, giving less accurate but functional sizing
// eslint-disable-next-line tscompat/tscompat
context.font = getReferenceFont();
const metrics = context.measureText(text);
const measuredWidth = metrics.width;
const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
const scaleFromWidth = (fitWidth / measuredWidth) * REFERENCE_FONT_SIZE;
const scaleFromHeight = (fitHeight / measuredHeight) * REFERENCE_FONT_SIZE;
return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE);
};
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => {
const boxes: OcrBox[] = [];
for (const ocr of ocrData) {
@@ -68,13 +176,26 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM
y: point.y * metrics.contentHeight + metrics.offsetY,
}));
const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2]));
const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2]));
boxes.push({
id: ocr.id,
points,
text: ocr.text,
confidence: ocr.textScore,
verticalMode: getVerticalMode(boxWidth, boxHeight, ocr.text),
});
}
const rowThreshold = metrics.contentHeight * 0.02;
boxes.sort((a, b) => {
const yDifference = a.points[0].y - b.points[0].y;
if (Math.abs(yDifference) < rowThreshold) {
return a.points[0].x - b.points[0].x;
}
return yDifference;
});
return boxes;
};

View File

@@ -67,7 +67,7 @@
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
let terms = $derived<SearchTerms>(searchQuery ? JSON.parse(searchQuery) : {});
$effect(() => {
// we want this to *only* be reactive on `terms`
@@ -137,15 +137,13 @@
const searchDto: SearchTerms = {
page: nextPage,
withExif: true,
isVisible: true,
language: $lang,
...terms,
};
try {
const { albums, assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto })
? await searchSmart({ smartSearchDto: { ...searchDto, language: $lang } })
: await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
@@ -230,7 +228,7 @@
const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => {
cancelMultiselect(assetInteraction);
if (terms.isNotInAlbum.toString() == 'true') {
if (terms.isNotInAlbum) {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
}