mirror of
https://github.com/immich-app/immich.git
synced 2026-05-20 14:52:34 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28eb1bc13c | |||
| 1e4779cf48 | |||
| 0647c22956 | |||
| b8087b4fa2 | |||
| d94cb9641b | |||
| 517c3e1d4c | |||
| 619de2a5e4 | |||
| 79d0e3e1ed | |||
| f5ff36a1f8 | |||
| b5efc9c16e | |||
| 1036076b0d |
@@ -20,6 +20,30 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
ANDROID_STORE_PASSWORD:
|
ANDROID_STORE_PASSWORD:
|
||||||
required: true
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID:
|
||||||
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID:
|
||||||
|
required: true
|
||||||
|
APP_STORE_CONNECT_API_KEY:
|
||||||
|
required: true
|
||||||
|
IOS_CERTIFICATE_P12:
|
||||||
|
required: true
|
||||||
|
IOS_CERTIFICATE_PASSWORD:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||||
|
required: true
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||||
|
required: true
|
||||||
|
FASTLANE_TEAM_ID:
|
||||||
|
required: true
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ jobs:
|
|||||||
ALIAS: ${{ secrets.ALIAS }}
|
ALIAS: ${{ secrets.ALIAS }}
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
# iOS secrets
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||||
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
|
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
|
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.bump_version.outputs.ref }}
|
ref: ${{ needs.bump_version.outputs.ref }}
|
||||||
environment: production
|
environment: production
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.99",
|
"version": "2.2.101",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
Vendored
+8
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v2.2.3",
|
||||||
|
"url": "https://docs.v2.2.3.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v2.2.2",
|
||||||
|
"url": "https://docs.v2.2.2.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v2.2.1",
|
"label": "v2.2.1",
|
||||||
"url": "https://docs.v2.2.1.archive.immich.app"
|
"url": "https://docs.v2.2.1.archive.immich.app"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "2.2.1",
|
"version": "2.2.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1140,6 +1140,16 @@ describe('/asset', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
input: 'metadata/gps-position/empty_gps.jpg',
|
||||||
|
expected: {
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
exifInfo: {
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => {
|
it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => {
|
||||||
|
|||||||
+1
-1
Submodule e2e/test-assets updated: 37f60ea537...68e8b5853c
@@ -1,8 +1,10 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from rapidocr.ch_ppocr_det import TextDetector as RapidTextDetector
|
from rapidocr.ch_ppocr_det.utils import DBPostProcess
|
||||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
from rapidocr.utils import DownloadFile, DownloadFileInput
|
||||||
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
||||||
@@ -10,11 +12,10 @@ from rapidocr.utils.typings import ModelType as RapidModelType
|
|||||||
|
|
||||||
from immich_ml.config import log
|
from immich_ml.config import log
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
from immich_ml.models.transforms import decode_cv2
|
|
||||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
|
|
||||||
from .schemas import OcrOptions, TextDetectionOutput
|
from .schemas import TextDetectionOutput
|
||||||
|
|
||||||
|
|
||||||
class TextDetector(InferenceModel):
|
class TextDetector(InferenceModel):
|
||||||
@@ -24,13 +25,20 @@ class TextDetector(InferenceModel):
|
|||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
||||||
self.max_resolution = 736
|
self.max_resolution = 736
|
||||||
self.min_score = 0.5
|
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
||||||
self.score_mode = "fast"
|
self.std_inv = np.float32(1.0) / (np.array([0.5, 0.5, 0.5], dtype=np.float32) * 255.0)
|
||||||
self._empty: TextDetectionOutput = {
|
self._empty: TextDetectionOutput = {
|
||||||
"image": np.empty(0, dtype=np.float32),
|
|
||||||
"boxes": np.empty(0, dtype=np.float32),
|
"boxes": np.empty(0, dtype=np.float32),
|
||||||
"scores": np.empty(0, dtype=np.float32),
|
"scores": np.empty(0, dtype=np.float32),
|
||||||
}
|
}
|
||||||
|
self.postprocess = DBPostProcess(
|
||||||
|
thresh=0.3,
|
||||||
|
box_thresh=model_kwargs.get("minScore", 0.5),
|
||||||
|
max_candidates=1000,
|
||||||
|
unclip_ratio=1.6,
|
||||||
|
use_dilation=True,
|
||||||
|
score_mode="fast",
|
||||||
|
)
|
||||||
|
|
||||||
def _download(self) -> None:
|
def _download(self) -> None:
|
||||||
model_info = InferSession.get_model_url(
|
model_info = InferSession.get_model_url(
|
||||||
@@ -52,35 +60,65 @@ class TextDetector(InferenceModel):
|
|||||||
|
|
||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
# TODO: support other runtime sessions
|
# TODO: support other runtime sessions
|
||||||
session = OrtSession(self.model_path)
|
return OrtSession(self.model_path)
|
||||||
self.model = RapidTextDetector(
|
|
||||||
OcrOptions(
|
|
||||||
session=session.session,
|
|
||||||
limit_side_len=self.max_resolution,
|
|
||||||
limit_type="min",
|
|
||||||
box_thresh=self.min_score,
|
|
||||||
score_mode=self.score_mode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _predict(self, inputs: bytes | Image.Image) -> TextDetectionOutput:
|
# partly adapted from RapidOCR
|
||||||
results = self.model(decode_cv2(inputs))
|
def _predict(self, inputs: Image.Image) -> TextDetectionOutput:
|
||||||
if results.boxes is None or results.scores is None or results.img is None:
|
w, h = inputs.size
|
||||||
|
if w < 32 or h < 32:
|
||||||
|
return self._empty
|
||||||
|
out = self.session.run(None, {"x": self._transform(inputs)})[0]
|
||||||
|
boxes, scores = self.postprocess(out, (h, w))
|
||||||
|
if len(boxes) == 0:
|
||||||
return self._empty
|
return self._empty
|
||||||
return {
|
return {
|
||||||
"image": results.img,
|
"boxes": self.sorted_boxes(boxes),
|
||||||
"boxes": np.array(results.boxes, dtype=np.float32),
|
"scores": np.array(scores, dtype=np.float32),
|
||||||
"scores": np.array(results.scores, dtype=np.float32),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# adapted from RapidOCR
|
||||||
|
def _transform(self, img: Image.Image) -> NDArray[np.float32]:
|
||||||
|
if img.height < img.width:
|
||||||
|
ratio = float(self.max_resolution) / img.height
|
||||||
|
else:
|
||||||
|
ratio = float(self.max_resolution) / img.width
|
||||||
|
|
||||||
|
resize_h = int(img.height * ratio)
|
||||||
|
resize_w = int(img.width * ratio)
|
||||||
|
|
||||||
|
resize_h = int(round(resize_h / 32) * 32)
|
||||||
|
resize_w = int(round(resize_w / 32) * 32)
|
||||||
|
resized_img = img.resize((int(resize_w), int(resize_h)), resample=Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
img_np: NDArray[np.float32] = cv2.cvtColor(np.array(resized_img, dtype=np.float32), cv2.COLOR_RGB2BGR) # type: ignore
|
||||||
|
img_np -= self.mean
|
||||||
|
img_np *= self.std_inv
|
||||||
|
img_np = np.transpose(img_np, (2, 0, 1))
|
||||||
|
return np.expand_dims(img_np, axis=0)
|
||||||
|
|
||||||
|
def sorted_boxes(self, dt_boxes: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||||
|
if len(dt_boxes) == 0:
|
||||||
|
return dt_boxes
|
||||||
|
|
||||||
|
# Sort by y, then identify lines, then sort by (line, x)
|
||||||
|
y_order = np.argsort(dt_boxes[:, 0, 1], kind="stable")
|
||||||
|
sorted_y = dt_boxes[y_order, 0, 1]
|
||||||
|
|
||||||
|
line_ids = np.empty(len(dt_boxes), dtype=np.int32)
|
||||||
|
line_ids[0] = 0
|
||||||
|
np.cumsum(np.abs(np.diff(sorted_y)) >= 10, out=line_ids[1:])
|
||||||
|
|
||||||
|
# Create composite sort key for final ordering
|
||||||
|
# Shift line_ids by large factor, add x for tie-breaking
|
||||||
|
sort_key = line_ids[y_order] * 1e6 + dt_boxes[y_order, 0, 0]
|
||||||
|
final_order = np.argsort(sort_key, kind="stable")
|
||||||
|
sorted_boxes: NDArray[np.float32] = dt_boxes[y_order[final_order]]
|
||||||
|
return sorted_boxes
|
||||||
|
|
||||||
def configure(self, **kwargs: Any) -> None:
|
def configure(self, **kwargs: Any) -> None:
|
||||||
if (max_resolution := kwargs.get("maxResolution")) is not None:
|
if (max_resolution := kwargs.get("maxResolution")) is not None:
|
||||||
self.max_resolution = max_resolution
|
self.max_resolution = max_resolution
|
||||||
self.model.limit_side_len = max_resolution
|
|
||||||
if (min_score := kwargs.get("minScore")) is not None:
|
if (min_score := kwargs.get("minScore")) is not None:
|
||||||
self.min_score = min_score
|
self.postprocess.box_thresh = min_score
|
||||||
self.model.postprocess_op.box_thresh = min_score
|
|
||||||
if (score_mode := kwargs.get("scoreMode")) is not None:
|
if (score_mode := kwargs.get("scoreMode")) is not None:
|
||||||
self.score_mode = score_mode
|
self.postprocess.score_mode = score_mode
|
||||||
self.model.postprocess_op.score_mode = score_mode
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from PIL.Image import Image
|
from PIL import Image
|
||||||
from rapidocr.ch_ppocr_rec import TextRecInput
|
from rapidocr.ch_ppocr_rec import TextRecInput
|
||||||
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
|
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
|
||||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||||
@@ -14,6 +13,7 @@ from rapidocr.utils.vis_res import VisRes
|
|||||||
|
|
||||||
from immich_ml.config import log, settings
|
from immich_ml.config import log, settings
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
|
from immich_ml.models.transforms import pil_to_cv2
|
||||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
|
|
||||||
@@ -65,17 +65,16 @@ class TextRecognizer(InferenceModel):
|
|||||||
)
|
)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def _predict(self, _: Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
|
def _predict(self, img: Image.Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
|
||||||
boxes, img, box_scores = texts["boxes"], texts["image"], texts["scores"]
|
boxes, box_scores = texts["boxes"], texts["scores"]
|
||||||
if boxes.shape[0] == 0:
|
if boxes.shape[0] == 0:
|
||||||
return self._empty
|
return self._empty
|
||||||
rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes)))
|
rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes)))
|
||||||
if rec.txts is None:
|
if rec.txts is None:
|
||||||
return self._empty
|
return self._empty
|
||||||
|
|
||||||
height, width = img.shape[0:2]
|
boxes[:, :, 0] /= img.width
|
||||||
boxes[:, :, 0] /= width
|
boxes[:, :, 1] /= img.height
|
||||||
boxes[:, :, 1] /= height
|
|
||||||
|
|
||||||
text_scores = np.array(rec.scores)
|
text_scores = np.array(rec.scores)
|
||||||
valid_text_score_idx = text_scores > self.min_score
|
valid_text_score_idx = text_scores > self.min_score
|
||||||
@@ -87,7 +86,7 @@ class TextRecognizer(InferenceModel):
|
|||||||
"textScore": text_scores[valid_text_score_idx],
|
"textScore": text_scores[valid_text_score_idx],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_crop_img_list(self, img: NDArray[np.float32], boxes: NDArray[np.float32]) -> list[NDArray[np.float32]]:
|
def get_crop_img_list(self, img: Image.Image, boxes: NDArray[np.float32]) -> list[NDArray[np.uint8]]:
|
||||||
img_crop_width = np.maximum(
|
img_crop_width = np.maximum(
|
||||||
np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1)
|
np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1)
|
||||||
).astype(np.int32)
|
).astype(np.int32)
|
||||||
@@ -98,22 +97,55 @@ class TextRecognizer(InferenceModel):
|
|||||||
pts_std[:, 1:3, 0] = img_crop_width[:, None]
|
pts_std[:, 1:3, 0] = img_crop_width[:, None]
|
||||||
pts_std[:, 2:4, 1] = img_crop_height[:, None]
|
pts_std[:, 2:4, 1] = img_crop_height[:, None]
|
||||||
|
|
||||||
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1).tolist()
|
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1)
|
||||||
imgs: list[NDArray[np.float32]] = []
|
all_coeffs = self._get_perspective_transform(pts_std, boxes)
|
||||||
for box, pts_std, dst_size in zip(list(boxes), list(pts_std), img_crop_sizes):
|
imgs: list[NDArray[np.uint8]] = []
|
||||||
M = cv2.getPerspectiveTransform(box, pts_std)
|
for coeffs, dst_size in zip(all_coeffs, img_crop_sizes):
|
||||||
dst_img: NDArray[np.float32] = cv2.warpPerspective(
|
dst_img = img.transform(
|
||||||
img,
|
size=tuple(dst_size),
|
||||||
M,
|
method=Image.Transform.PERSPECTIVE,
|
||||||
dst_size,
|
data=tuple(coeffs),
|
||||||
borderMode=cv2.BORDER_REPLICATE,
|
resample=Image.Resampling.BICUBIC,
|
||||||
flags=cv2.INTER_CUBIC,
|
)
|
||||||
) # type: ignore
|
|
||||||
dst_height, dst_width = dst_img.shape[0:2]
|
dst_width, dst_height = dst_img.size
|
||||||
if dst_height * 1.0 / dst_width >= 1.5:
|
if dst_height * 1.0 / dst_width >= 1.5:
|
||||||
dst_img = np.rot90(dst_img)
|
dst_img = dst_img.rotate(90, expand=True)
|
||||||
imgs.append(dst_img)
|
imgs.append(pil_to_cv2(dst_img))
|
||||||
|
|
||||||
return imgs
|
return imgs
|
||||||
|
|
||||||
|
def _get_perspective_transform(self, src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||||
|
N = src.shape[0]
|
||||||
|
x, y = src[:, :, 0], src[:, :, 1]
|
||||||
|
u, v = dst[:, :, 0], dst[:, :, 1]
|
||||||
|
A = np.zeros((N, 8, 9), dtype=np.float32)
|
||||||
|
|
||||||
|
# Fill even rows (0, 2, 4, 6): [x, y, 1, 0, 0, 0, -u*x, -u*y, -u]
|
||||||
|
A[:, ::2, 0] = x
|
||||||
|
A[:, ::2, 1] = y
|
||||||
|
A[:, ::2, 2] = 1
|
||||||
|
A[:, ::2, 6] = -u * x
|
||||||
|
A[:, ::2, 7] = -u * y
|
||||||
|
A[:, ::2, 8] = -u
|
||||||
|
|
||||||
|
# Fill odd rows (1, 3, 5, 7): [0, 0, 0, x, y, 1, -v*x, -v*y, -v]
|
||||||
|
A[:, 1::2, 3] = x
|
||||||
|
A[:, 1::2, 4] = y
|
||||||
|
A[:, 1::2, 5] = 1
|
||||||
|
A[:, 1::2, 6] = -v * x
|
||||||
|
A[:, 1::2, 7] = -v * y
|
||||||
|
A[:, 1::2, 8] = -v
|
||||||
|
|
||||||
|
# Solve using SVD for all matrices at once
|
||||||
|
_, _, Vt = np.linalg.svd(A)
|
||||||
|
H = Vt[:, -1, :].reshape(N, 3, 3)
|
||||||
|
H = H / H[:, 2:3, 2:3]
|
||||||
|
|
||||||
|
# Extract the 8 coefficients for each transformation
|
||||||
|
return np.column_stack(
|
||||||
|
[H[:, 0, 0], H[:, 0, 1], H[:, 0, 2], H[:, 1, 0], H[:, 1, 1], H[:, 1, 2], H[:, 2, 0], H[:, 2, 1]]
|
||||||
|
) # pyright: ignore[reportReturnType]
|
||||||
|
|
||||||
def configure(self, **kwargs: Any) -> None:
|
def configure(self, **kwargs: Any) -> None:
|
||||||
self.min_score = kwargs.get("minScore", self.min_score)
|
self.min_score = kwargs.get("minScore", self.min_score)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from typing_extensions import TypedDict
|
|||||||
|
|
||||||
|
|
||||||
class TextDetectionOutput(TypedDict):
|
class TextDetectionOutput(TypedDict):
|
||||||
image: npt.NDArray[np.float32]
|
|
||||||
boxes: npt.NDArray[np.float32]
|
boxes: npt.NDArray[np.float32]
|
||||||
scores: npt.NDArray[np.float32]
|
scores: npt.NDArray[np.float32]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "immich-ml"
|
name = "immich-ml"
|
||||||
version = "2.2.1"
|
version = "2.2.3"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||||
requires-python = ">=3.10,<4.0"
|
requires-python = ">=3.10,<4.0"
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
|
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
|
||||||
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
|
|
||||||
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
||||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 3024,
|
"android.injected.version.code" => 3026,
|
||||||
"android.injected.version.name" => "2.2.1",
|
"android.injected.version.name" => "2.2.3",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper method to get version from pubspec.yaml
|
||||||
|
def get_version_from_pubspec
|
||||||
|
require 'yaml'
|
||||||
|
|
||||||
|
pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml")
|
||||||
|
pubspec = YAML.load_file(pubspec_path)
|
||||||
|
|
||||||
|
version_string = pubspec['version']
|
||||||
|
version_string ? version_string.split('+').first : nil
|
||||||
|
end
|
||||||
|
|
||||||
# Helper method to configure code signing for all targets
|
# Helper method to configure code signing for all targets
|
||||||
def configure_code_signing(bundle_id_suffix: "")
|
def configure_code_signing(bundle_id_suffix: "")
|
||||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||||
@@ -158,7 +169,8 @@ platform :ios do
|
|||||||
# Build and upload with version number
|
# Build and upload with version number
|
||||||
build_and_upload(
|
build_and_upload(
|
||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
version_number: "2.1.0"
|
version_number: get_version_from_pubspec,
|
||||||
|
distribute_external: false,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,8 +180,9 @@ platform :ios do
|
|||||||
path: "./Runner.xcodeproj",
|
path: "./Runner.xcodeproj",
|
||||||
targets: ["Runner", "ShareExtension", "WidgetExtension"]
|
targets: ["Runner", "ShareExtension", "WidgetExtension"]
|
||||||
)
|
)
|
||||||
|
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "2.2.1"
|
version_number: get_version_from_pubspec
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ class SyncStreamService {
|
|||||||
return;
|
return;
|
||||||
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
|
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
|
||||||
case SyncEntityType.syncCompleteV1:
|
case SyncEntityType.syncCompleteV1:
|
||||||
return _syncStreamRepository.pruneAssets();
|
return;
|
||||||
|
// return _syncStreamRepository.pruneAssets();
|
||||||
// Request to reset the client state. Clear everything related to remote entities
|
// Request to reset the client state. Clear everything related to remote entities
|
||||||
case SyncEntityType.syncResetV1:
|
case SyncEntityType.syncResetV1:
|
||||||
return _syncStreamRepository.reset();
|
return _syncStreamRepository.reset();
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientVersion < serverVersion) {
|
if (clientVersion < serverVersion && clientVersion.differenceType(serverVersion) != SemVerType.patch) {
|
||||||
state = state.copyWith(versionStatus: VersionStatus.clientOutOfDate);
|
state = state.copyWith(versionStatus: VersionStatus.clientOutOfDate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,9 +89,16 @@ class AssetMediaRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// titleAsync gets the correct original filename for some assets on iOS
|
try {
|
||||||
// otherwise using the `entity.title` would return a random GUID
|
// titleAsync gets the correct original filename for some assets on iOS
|
||||||
return await entity.titleAsync;
|
// otherwise using the `entity.title` would return a random GUID
|
||||||
|
final originalFilename = await entity.titleAsync;
|
||||||
|
// treat empty filename as missing
|
||||||
|
return originalFilename.isNotEmpty ? originalFilename : null;
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning("Failed to get original filename for asset: $id. Error: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make this more efficient
|
// TODO: make this more efficient
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
enum SemVerType { major, minor, patch }
|
||||||
|
|
||||||
class SemVer {
|
class SemVer {
|
||||||
final int major;
|
final int major;
|
||||||
final int minor;
|
final int minor;
|
||||||
@@ -15,8 +17,20 @@ class SemVer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory SemVer.fromString(String version) {
|
factory SemVer.fromString(String version) {
|
||||||
|
if (version.toLowerCase().startsWith("v")) {
|
||||||
|
version = version.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
final parts = version.split("-")[0].split('.');
|
final parts = version.split("-")[0].split('.');
|
||||||
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
|
if (parts.length != 3) {
|
||||||
|
throw FormatException('Invalid semantic version string: $version');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
|
||||||
|
} catch (e) {
|
||||||
|
throw FormatException('Invalid semantic version string: $version');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool operator >(SemVer other) {
|
bool operator >(SemVer other) {
|
||||||
@@ -54,6 +68,20 @@ class SemVer {
|
|||||||
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
|
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SemVerType? differenceType(SemVer other) {
|
||||||
|
if (major != other.major) {
|
||||||
|
return SemVerType.major;
|
||||||
|
}
|
||||||
|
if (minor != other.minor) {
|
||||||
|
return SemVerType.minor;
|
||||||
|
}
|
||||||
|
if (patch != other.patch) {
|
||||||
|
return SemVerType.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
|
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 2.2.1
|
- API version: 2.2.3
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.2.1+3024
|
version: 2.2.3+3026
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ bash tool/build_android.sh x64
|
|||||||
bash tool/build_android.sh armv7
|
bash tool/build_android.sh armv7
|
||||||
bash tool/build_android.sh arm64
|
bash tool/build_android.sh arm64
|
||||||
mv libisar_android_arm64.so libisar.so
|
mv libisar_android_arm64.so libisar.so
|
||||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/
|
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/
|
||||||
mv libisar_android_armv7.so libisar.so
|
mv libisar_android_armv7.so libisar.so
|
||||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/
|
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/
|
||||||
mv libisar_android_x64.so libisar.so
|
mv libisar_android_x64.so libisar.so
|
||||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/
|
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86_64/
|
||||||
mv libisar_android_x86.so libisar.so
|
mv libisar_android_x86.so libisar.so
|
||||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/
|
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86/
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SemVer', () {
|
||||||
|
test('Parses valid semantic version strings correctly', () {
|
||||||
|
final version = SemVer.fromString('1.2.3');
|
||||||
|
expect(version.major, 1);
|
||||||
|
expect(version.minor, 2);
|
||||||
|
expect(version.patch, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Throws FormatException for invalid version strings', () {
|
||||||
|
expect(() => SemVer.fromString('1.2'), throwsFormatException);
|
||||||
|
expect(() => SemVer.fromString('a.b.c'), throwsFormatException);
|
||||||
|
expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Compares equal versons correctly', () {
|
||||||
|
final v1 = SemVer.fromString('1.2.3');
|
||||||
|
final v2 = SemVer.fromString('1.2.3');
|
||||||
|
expect(v1 == v2, isTrue);
|
||||||
|
expect(v1 > v2, isFalse);
|
||||||
|
expect(v1 < v2, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Compares major version correctly', () {
|
||||||
|
final v1 = SemVer.fromString('2.0.0');
|
||||||
|
final v2 = SemVer.fromString('1.9.9');
|
||||||
|
expect(v1 == v2, isFalse);
|
||||||
|
expect(v1 > v2, isTrue);
|
||||||
|
expect(v1 < v2, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Compares minor version correctly', () {
|
||||||
|
final v1 = SemVer.fromString('1.3.0');
|
||||||
|
final v2 = SemVer.fromString('1.2.9');
|
||||||
|
expect(v1 == v2, isFalse);
|
||||||
|
expect(v1 > v2, isTrue);
|
||||||
|
expect(v1 < v2, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Compares patch version correctly', () {
|
||||||
|
final v1 = SemVer.fromString('1.2.4');
|
||||||
|
final v2 = SemVer.fromString('1.2.3');
|
||||||
|
expect(v1 == v2, isFalse);
|
||||||
|
expect(v1 > v2, isTrue);
|
||||||
|
expect(v1 < v2, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gives correct major difference type', () {
|
||||||
|
final v1 = SemVer.fromString('2.0.0');
|
||||||
|
final v2 = SemVer.fromString('1.9.9');
|
||||||
|
expect(v1.differenceType(v2), SemVerType.major);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gives correct minor difference type', () {
|
||||||
|
final v1 = SemVer.fromString('1.3.0');
|
||||||
|
final v2 = SemVer.fromString('1.2.9');
|
||||||
|
expect(v1.differenceType(v2), SemVerType.minor);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gives correct patch difference type', () {
|
||||||
|
final v1 = SemVer.fromString('1.2.4');
|
||||||
|
final v2 = SemVer.fromString('1.2.3');
|
||||||
|
expect(v1.differenceType(v2), SemVerType.patch);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gives null difference type for equal versions', () {
|
||||||
|
final v1 = SemVer.fromString('1.2.3');
|
||||||
|
final v2 = SemVer.fromString('1.2.3');
|
||||||
|
expect(v1.differenceType(v2), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString returns correct format', () {
|
||||||
|
final version = SemVer.fromString('1.2.3');
|
||||||
|
expect(version.toString(), '1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Parses versions with leading v correctly', () {
|
||||||
|
final version1 = SemVer.fromString('v1.2.3');
|
||||||
|
expect(version1.major, 1);
|
||||||
|
expect(version1.minor, 2);
|
||||||
|
expect(version1.patch, 3);
|
||||||
|
|
||||||
|
final version2 = SemVer.fromString('V1.2.3');
|
||||||
|
expect(version2.major, 1);
|
||||||
|
expect(version2.minor, 2);
|
||||||
|
expect(version2.patch, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10006,7 +10006,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "2.2.1",
|
"version": "2.2.3",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "2.2.1",
|
"version": "2.2.3",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 2.2.1
|
* 2.2.3
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "2.2.1",
|
"version": "2.2.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ export class MetadataService extends BaseService {
|
|||||||
latitude: number | null = null,
|
latitude: number | null = null,
|
||||||
longitude: number | null = null;
|
longitude: number | null = null;
|
||||||
if (this.hasGeo(exifTags)) {
|
if (this.hasGeo(exifTags)) {
|
||||||
latitude = exifTags.GPSLatitude;
|
latitude = Number(exifTags.GPSLatitude);
|
||||||
longitude = exifTags.GPSLongitude;
|
longitude = Number(exifTags.GPSLongitude);
|
||||||
if (reverseGeocoding.enabled) {
|
if (reverseGeocoding.enabled) {
|
||||||
geo = await this.mapRepository.reverseGeocode({ latitude, longitude });
|
geo = await this.mapRepository.reverseGeocode({ latitude, longitude });
|
||||||
}
|
}
|
||||||
@@ -894,12 +894,10 @@ export class MetadataService extends BaseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } {
|
private hasGeo(tags: ImmichTags) {
|
||||||
return (
|
const lat = Number(tags.GPSLatitude);
|
||||||
tags.GPSLatitude !== undefined &&
|
const lng = Number(tags.GPSLongitude);
|
||||||
tags.GPSLongitude !== undefined &&
|
return !Number.isNaN(lat) && !Number.isNaN(lng) && (lat !== 0 || lng !== 0);
|
||||||
(tags.GPSLatitude !== 0 || tags.GPSLatitude !== 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAutoStackId(tags: ImmichTags | null): string | null {
|
private getAutoStackId(tags: ImmichTags | null): string | null {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "2.2.1",
|
"version": "2.2.3",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
let showSuggestions = $state(false);
|
let showSuggestions = $state(false);
|
||||||
let isSearchSuggestions = $state(false);
|
let isSearchSuggestions = $state(false);
|
||||||
let selectedId: string | undefined = $state();
|
let selectedId: string | undefined = $state();
|
||||||
let isFocus = $state(false);
|
|
||||||
let close: (() => Promise<void>) | undefined;
|
let close: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
const listboxId = generateId();
|
const listboxId = generateId();
|
||||||
|
const searchTypeId = generateId();
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
searchStore.isSearchEnabled = false;
|
searchStore.isSearchEnabled = false;
|
||||||
@@ -161,12 +161,10 @@
|
|||||||
|
|
||||||
const openDropdown = () => {
|
const openDropdown = () => {
|
||||||
showSuggestions = true;
|
showSuggestions = true;
|
||||||
isFocus = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDropdown = () => {
|
const closeDropdown = () => {
|
||||||
showSuggestions = false;
|
showSuggestions = false;
|
||||||
isFocus = false;
|
|
||||||
searchHistoryBox?.clearSelection();
|
searchHistoryBox?.clearSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -251,6 +249,7 @@
|
|||||||
aria-activedescendant={selectedId ?? ''}
|
aria-activedescendant={selectedId ?? ''}
|
||||||
aria-expanded={showSuggestions && isSearchSuggestions}
|
aria-expanded={showSuggestions && isSearchSuggestions}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
|
aria-describedby={searchTypeId}
|
||||||
use:shortcuts={[
|
use:shortcuts={[
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
|
||||||
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
|
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
|
||||||
@@ -287,12 +286,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isFocus}
|
{#if searchStore.isSearchEnabled}
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 flex items-center"
|
id={searchTypeId}
|
||||||
|
class="absolute inset-y-0 flex items-center end-16"
|
||||||
class:max-md:hidden={value}
|
class:max-md:hidden={value}
|
||||||
class:end-16={isFocus}
|
class:end-28={value.length > 0}
|
||||||
class:end-28={isFocus && value.length > 0}
|
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"
|
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs"
|
||||||
|
|||||||
Reference in New Issue
Block a user