Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
.venv/bin/pip install ruff pytest

- name: Lint Python
run: .venv/bin/ruff check searchy/*.py
run: .venv/bin/ruff check searchy/backend/*.py

- name: Test Python
run: .venv/bin/pytest searchy/tests/ -x -q
run: .venv/bin/pytest searchy/backend/tests/ -x -q
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,19 @@ release: dmg sha ## Full release: archive → export → DMG → SHA
setup: ## Set up Python venv with dependencies
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r searchy/requirements.txt
.venv/bin/pip install -r searchy/backend/requirements.txt
@echo "✓ Python env ready. Activate with: source .venv/bin/activate"

# ─── Lint ─────────────────────────────────────────────────
lint: ## Run linters (ruff for Python)
@command -v ruff >/dev/null 2>&1 || (echo "Installing ruff..." && pip install ruff)
ruff check searchy/*.py
ruff check searchy/backend/*.py
@echo "✓ Lint passed"

# ─── Test ─────────────────────────────────────────────────
test: ## Run Python unit tests
@test -f .venv/bin/pytest || (python3 -m venv .venv && .venv/bin/pip install -q pytest)
.venv/bin/pytest searchy/tests/ -x -q
.venv/bin/pytest searchy/backend/tests/ -x -q

# ─── Check (all pre-push checks) ─────────────────────────
check: lint test ## Run lint + tests
Expand Down
6 changes: 3 additions & 3 deletions Scripts/pre-push-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ echo "═══ Pre-push checks ═══"

# 1. Syntax check
echo -n "• py_compile... "
for f in searchy/*.py; do
for f in searchy/backend/*.py; do
$PYTHON -m py_compile "$f" || FAILED=1
done
[ $FAILED -eq 0 ] && echo "ok" || echo "FAIL"

# 2. Ruff lint
echo -n "• ruff... "
"$VENV/bin/ruff" check searchy/*.py --quiet || FAILED=1
"$VENV/bin/ruff" check searchy/backend/*.py --quiet || FAILED=1
[ $FAILED -eq 0 ] && echo "ok" || echo "FAIL"

# 3. Tests
echo -n "• pytest... "
$PYTHON -m pytest searchy/tests/ -x -q || FAILED=1
$PYTHON -m pytest searchy/backend/tests/ -x -q || FAILED=1

# 4. Swift build (skip with SKIP_SWIFT=1)
if [ "${SKIP_SWIFT:-0}" != "1" ]; then
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ line-length = 120
select = ["F", "E9", "W6"]

[tool.pytest.ini_options]
testpaths = ["searchy/tests"]
testpaths = ["searchy/backend/tests"]
6 changes: 1 addition & 5 deletions searchy/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1401,11 +1401,7 @@ class DirectoryManager: ObservableObject {
let directories = try? JSONDecoder().decode([WatchedDirectory].self, from: data) {
self.watchedDirectories = directories
} else {
// Default directories
self.watchedDirectories = [
WatchedDirectory(path: NSString(string: "~/Downloads").expandingTildeInPath),
WatchedDirectory(path: NSString(string: "~/Desktop").expandingTildeInPath, filter: "Screenshot", filterType: .startsWith)
]
self.watchedDirectories = []
}
}

Expand Down
File renamed without changes.
File renamed without changes.
46 changes: 46 additions & 0 deletions searchy/backend/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Centralized constants for the Searchy project."""

import os

# ─── Paths ────────────────────────────────────────────────
DEFAULT_DATA_DIR = os.path.join(
os.path.expanduser("~/Library/Application Support"), "searchy"
)

# ─── Image Formats ────────────────────────────────────────
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.heic'}

# ─── Directories to Skip During Indexing ──────────────────
SKIP_DIRS = {
'site-packages', 'node_modules', 'vendor', '__pycache__',
'env', 'venv', '.venv', 'virtualenv',
'Library', 'Caches', 'cache', '.cache',
'build', 'dist', 'target', '.git', '.svn',
'DerivedData', 'xcuserdata', 'Pods',
'__MACOSX', '.Trash', '.Spotlight-V100', '.fseventsd',
}

# ─── Indexing Defaults ────────────────────────────────────
DEFAULT_BATCH_SIZE = 64
DEFAULT_MAX_DIMENSION = 384
LARGE_BATCH_SIZE = 500 # For bulk operations (duplicates, face clustering)

# ─── Search Defaults ──────────────────────────────────────
DEFAULT_TOP_K = 20
DEFAULT_OCR_WEIGHT = 0.3
DEFAULT_SIMILARITY_THRESHOLD = 0.95 # For duplicate detection

# ─── Model ────────────────────────────────────────────────
DEFAULT_EMBEDDING_DIM = 512
DEFAULT_MODEL_NAME = "openai/clip-vit-base-patch32"

# ─── TTL ──────────────────────────────────────────────────
TTL_CHECK_INTERVAL = 30 # Seconds between TTL checks

# ─── OCR ──────────────────────────────────────────────────
OCR_TEXT_PREVIEW_LENGTH = 300

# ─── Image Watcher ────────────────────────────────────────
WATCHER_DEBOUNCE_DELAY = 0.5 # Seconds
WATCHER_NOTIFY_TIMEOUT = 5 # Seconds
WATCHER_INDEX_TIMEOUT = 30 # Seconds
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
import sys
import json
import argparse
import re
import time
from PIL import Image

from atomic_write import atomic_pickle_dump

# Import centralized model manager
from clip_model import model_manager
from constants import SKIP_DIRS, IMAGE_EXTENSIONS
from utils import matches_filter, is_user_image

# OCR support using macOS Vision framework
try:
Expand Down Expand Up @@ -96,45 +97,6 @@ def resize_image_for_fast_indexing(image, max_dimension=384):
return image.resize((new_width, new_height), Image.LANCZOS)


def matches_filter(filename, filter_type, filter_value):
"""Check if filename matches the filter criteria"""
if not filter_value or filter_type == "all":
return True

filename_lower = filename.lower()
filter_lower = filter_value.lower()

if filter_type == "starts-with":
return filename_lower.startswith(filter_lower)
elif filter_type == "ends-with":
return filename_lower.endswith(filter_lower)
elif filter_type == "contains":
return filter_lower in filename_lower
elif filter_type == "regex":
try:
return bool(re.search(filter_value, filename, re.IGNORECASE))
except re.error:
return False
return True


# Directories to skip (system, packages, caches, etc.)
SKIP_DIRS = {
'site-packages', 'node_modules', 'vendor', '__pycache__',
'env', 'venv', '.venv', 'virtualenv',
'Library', 'Caches', 'cache', '.cache',
'build', 'dist', 'target', '.git', '.svn',
'DerivedData', 'xcuserdata', 'Pods',
'__MACOSX', '.Trash', '.Spotlight-V100', '.fseventsd'
}


def is_user_image(path):
"""Check if path is a user image (not system/package file)."""
if os.path.basename(path).startswith('.'):
return False
parts = path.split(os.sep)
return not any(part in SKIP_DIRS for part in parts)


def index_images_with_clip(output_dir, incremental=False, new_files=None,
Expand Down Expand Up @@ -313,7 +275,7 @@ def process_images(image_dirs, output_dir, fast_indexing=True, max_dimension=384
if file.startswith('.'):
continue

if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
if os.path.splitext(file.lower())[1] in IMAGE_EXTENSIONS:
# Apply filter if specified
if filter_type and filter_value:
if not matches_filter(file, filter_type, filter_value):
Expand Down
49 changes: 16 additions & 33 deletions searchy/image_watcher.py → searchy/backend/image_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,23 @@
import sys
import time
import json
import re
import argparse
import urllib.request
import urllib.error
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

# Supported image extensions
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.heic'}
from constants import (
IMAGE_EXTENSIONS, DEFAULT_BATCH_SIZE, DEFAULT_MAX_DIMENSION,
WATCHER_DEBOUNCE_DELAY, WATCHER_NOTIFY_TIMEOUT, WATCHER_INDEX_TIMEOUT,
)
from utils import matches_filter

# Default server URL (can be overridden via --server-url)
DEFAULT_SERVER_URL = "http://127.0.0.1:7860"


def matches_filter(filename, filter_type, filter_value):
"""Check if filename matches the filter criteria"""
if not filter_value or filter_type == "all":
return True

filename_lower = filename.lower()
filter_lower = filter_value.lower()

if filter_type == "starts-with":
return filename_lower.startswith(filter_lower)
elif filter_type == "ends-with":
return filename_lower.endswith(filter_lower)
elif filter_type == "contains":
return filter_lower in filename_lower
elif filter_type == "regex":
try:
return bool(re.search(filter_value, filename, re.IGNORECASE))
except re.error:
return False
return True


def call_server_notify(server_url, file_path):
"""Notify the server that a new image was detected (for immediate display)."""
url = f"{server_url}/notify-new-image"
Expand All @@ -65,7 +45,7 @@ def call_server_notify(server_url, file_path):
method='POST'
)

with urllib.request.urlopen(req, timeout=5) as response:
with urllib.request.urlopen(req, timeout=WATCHER_NOTIFY_TIMEOUT) as response:
result = json.loads(response.read().decode('utf-8'))
return result

Expand All @@ -76,7 +56,8 @@ def call_server_notify(server_url, file_path):


def call_server_index(server_url, data_dir, files, fast_indexing=True,
max_dimension=384, batch_size=64):
max_dimension=DEFAULT_MAX_DIMENSION,
batch_size=DEFAULT_BATCH_SIZE):
"""Call the server's /index endpoint to index files."""
url = f"{server_url}/index"

Expand All @@ -97,7 +78,7 @@ def call_server_index(server_url, data_dir, files, fast_indexing=True,
method='POST'
)

with urllib.request.urlopen(req, timeout=30) as response:
with urllib.request.urlopen(req, timeout=WATCHER_INDEX_TIMEOUT) as response:
result = json.loads(response.read().decode('utf-8'))
return result

Expand All @@ -112,7 +93,8 @@ def call_server_index(server_url, data_dir, files, fast_indexing=True,
class ImageEventHandler(FileSystemEventHandler):
def __init__(self, data_dir, server_url=DEFAULT_SERVER_URL,
filter_type=None, filter_value=None,
fast_indexing=True, max_dimension=384, batch_size=64):
fast_indexing=True, max_dimension=DEFAULT_MAX_DIMENSION,
batch_size=DEFAULT_BATCH_SIZE):
self.data_dir = data_dir
self.server_url = server_url
self.filter_type = filter_type
Expand All @@ -122,7 +104,7 @@ def __init__(self, data_dir, server_url=DEFAULT_SERVER_URL,
self.batch_size = batch_size
self.pending_files = set()
self.last_index_time = time.time()
self.debounce_delay = 0.5 # Wait 0.5 seconds before indexing (reduced for faster response)
self.debounce_delay = WATCHER_DEBOUNCE_DELAY

def on_created(self, event):
"""Called when a file or directory is created"""
Expand Down Expand Up @@ -231,7 +213,8 @@ def check_and_index(self):

def watch_directory(watch_path, data_dir, server_url=DEFAULT_SERVER_URL,
filter_type=None, filter_value=None,
fast_indexing=True, max_dimension=384, batch_size=64):
fast_indexing=True, max_dimension=DEFAULT_MAX_DIMENSION,
batch_size=DEFAULT_BATCH_SIZE):
"""Watch directory for new images and auto-index them via server."""

print(f"Watching for new images in: {watch_path}", file=sys.stderr)
Expand Down Expand Up @@ -280,9 +263,9 @@ def watch_directory(watch_path, data_dir, server_url=DEFAULT_SERVER_URL,
help="Enable fast indexing (resize images)")
parser.add_argument("--no-fast", action="store_false", dest="fast",
help="Disable fast indexing")
parser.add_argument("--max-dimension", type=int, default=384,
parser.add_argument("--max-dimension", type=int, default=DEFAULT_MAX_DIMENSION,
help="Maximum image dimension for fast indexing")
parser.add_argument("--batch-size", type=int, default=64,
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE,
help="Batch size for processing")

args = parser.parse_args()
Expand Down
File renamed without changes.
Loading
Loading