Merge remote-tracking branch 'origin/prerelease' into feat/resolution-condition

This commit is contained in:
Varun Raj
2026-07-10 14:03:40 +00:00
6 changed files with 172 additions and 4 deletions
@@ -30,6 +30,11 @@ const conditionTypeLabels: Record<ConditionType, string> = {
resolution: "Resolution",
rating: "Rating",
is_favorited: "Favorited",
file_size: "File Size",
filename: "File Name / Path",
file_extension: "File Extension",
face_count: "Face Count",
time_of_day: "Time of Day",
not_in_album: "Not in Any Album",
not_in_specific_album: "Not in Specific Album",
};
@@ -222,6 +227,71 @@ function ConditionFields({ condition, onChange }: { condition: ICondition; onCha
</SelectContent>
</Select>
);
case "file_size":
return (
<div className="flex items-center gap-2">
<Input className="h-7 text-xs" type="number" min={0} step="any" placeholder="Min MB" value={condition.min ?? ""} onChange={(e) => onChange({ ...condition, min: e.target.value === "" ? undefined : parseFloat(e.target.value) })} />
<Input className="h-7 text-xs" type="number" min={0} step="any" placeholder="Max MB" value={condition.max ?? ""} onChange={(e) => onChange({ ...condition, max: e.target.value === "" ? undefined : parseFloat(e.target.value) })} />
</div>
);
case "filename":
return (
<div className="space-y-2">
<div className="flex gap-2">
<Select value={condition.field || "name"} onValueChange={(v) => onChange({ ...condition, field: v })}>
<SelectTrigger className="h-7 text-xs w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="name">File Name</SelectItem>
<SelectItem value="path">File Path</SelectItem>
</SelectContent>
</Select>
<Select value={condition.match || "contains"} onValueChange={(v) => onChange({ ...condition, match: v })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="not_contains">Does not contain</SelectItem>
<SelectItem value="starts_with">Starts with</SelectItem>
<SelectItem value="ends_with">Ends with</SelectItem>
</SelectContent>
</Select>
</div>
<Input className="h-7 text-xs" placeholder="e.g. Screenshot" value={condition.text || ""} onChange={(e) => onChange({ ...condition, text: e.target.value })} />
</div>
);
case "file_extension":
return (
<div className="space-y-2">
<Select value={condition.match || "in"} onValueChange={(v) => onChange({ ...condition, match: v })}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="in">Is any of</SelectItem>
<SelectItem value="not_in">Is none of</SelectItem>
</SelectContent>
</Select>
<Input className="h-7 text-xs" placeholder="e.g. png, gif, heic" value={condition.extensions || ""} onChange={(e) => onChange({ ...condition, extensions: e.target.value })} />
</div>
);
case "face_count":
return (
<div className="flex items-center gap-2">
<Input className="h-7 text-xs w-20" type="number" min={0} placeholder="Min" value={condition.min ?? ""} onChange={(e) => onChange({ ...condition, min: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<Input className="h-7 text-xs w-20" type="number" min={0} placeholder="Max" value={condition.max ?? ""} onChange={(e) => onChange({ ...condition, max: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<span className="text-xs text-muted-foreground">faces</span>
</div>
);
case "time_of_day":
return (
<div className="space-y-1">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">From</span>
<Input className="h-7 text-xs w-16" type="number" min={0} max={23} placeholder="0" value={condition.fromHour ?? ""} onChange={(e) => onChange({ ...condition, fromHour: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<span className="text-xs text-muted-foreground">to</span>
<Input className="h-7 text-xs w-16" type="number" min={0} max={23} placeholder="23" value={condition.toHour ?? ""} onChange={(e) => onChange({ ...condition, toHour: e.target.value === "" ? undefined : parseInt(e.target.value) })} />
<span className="text-xs text-muted-foreground">h</span>
</div>
<p className="text-[10px] text-muted-foreground">Hours 0–23; From &gt; To wraps past midnight (e.g. 22 to 5).</p>
</div>
);
case "person":
return (
<div className="space-y-2">
@@ -19,6 +19,11 @@ const conditionTypeLabels: Record<ConditionType, string> = {
resolution: "Resolution",
rating: "Rating",
is_favorited: "Favorited",
file_size: "File Size",
filename: "File Name / Path",
file_extension: "Extension",
face_count: "Faces",
time_of_day: "Time of Day",
not_in_album: "Not in Any Album",
not_in_specific_album: "Not in Specific Album",
};
@@ -108,6 +113,32 @@ export function formatConditionSummary(c: ICondition): string {
}
case "is_favorited":
return c.value === false ? "Not Favorited" : "Favorited";
case "file_size": {
if (c.min != null && c.max != null) return `${label}: ${c.min}–${c.max}MB`;
if (c.min != null) return `${label}: ≥${c.min}MB`;
if (c.max != null) return `${label}: ≤${c.max}MB`;
return label;
}
case "filename": {
if (!c.text) return label;
const field = c.field === "path" ? "Path" : "Name";
const matchNames: Record<string, string> = { contains: "contains", not_contains: "doesn't contain", starts_with: "starts with", ends_with: "ends with" };
return `${field} ${matchNames[c.match] || "contains"}: ${c.text}`;
}
case "file_extension": {
if (!c.extensions) return label;
return `${label} ${c.match === "not_in" ? "none of" : "any of"}: ${c.extensions}`;
}
case "face_count": {
if (c.min != null && c.max != null) return c.min === c.max ? `${label}: ${c.min}` : `${label}: ${c.min}–${c.max}`;
if (c.min != null) return `${label}: ≥${c.min}`;
if (c.max != null) return `${label}: ≤${c.max}`;
return label;
}
case "time_of_day": {
if (c.fromHour == null || c.toHour == null) return label;
return `${label}: ${c.fromHour}:00–${c.toHour}:59`;
}
case "not_in_album":
return label;
case "not_in_specific_album":
+7 -3
View File
@@ -8,7 +8,8 @@ export const IMPORT_PERMISSIONS: Permission[] = [
{ name: "asset.upload", description: "Upload new assets" },
{ name: "album.read", description: "Read album data" },
{ name: "album.create", description: "Create new albums" },
{ name: "album.update", description: "Add assets to albums" },
{ name: "album.update", description: "Update album metadata" },
{ name: "albumAsset.create", description: "Add assets to albums" },
{ name: "tag.create", description: "Tag imported assets" },
{ name: "tag.asset", description: "Assign tags to assets" },
];
@@ -18,8 +19,11 @@ export const WORKFLOW_PERMISSIONS: Permission[] = [
{ name: "asset.update", description: "Favorite, archive, update metadata" },
{ name: "album.read", description: "Read album data" },
{ name: "album.create", description: "Create new albums" },
{ name: "album.update", description: "Add/remove assets from albums" },
{ name: "tag.create", description: "Create and assign tags" },
{ name: "album.update", description: "Update album metadata" },
{ name: "albumAsset.create", description: "Add assets to albums" },
{ name: "albumAsset.delete", description: "Remove assets from albums" },
{ name: "tag.create", description: "Create tags" },
{ name: "tag.asset", description: "Assign tags to assets" },
];
export const getPermissionNames = (permissions: Permission[]): string[] =>
+57
View File
@@ -107,6 +107,63 @@ function buildSingleCondition(c: ICondition): SQL | undefined {
return parts.length > 0 ? and(...parts)! : undefined;
}
case "file_size": {
// min/max in megabytes (fractional allowed)
const parts: SQL[] = [];
if (c.min !== undefined && c.min !== null) parts.push(sql`${exif.fileSizeInByte} >= ${Math.round(c.min * 1048576)}`);
if (c.max !== undefined && c.max !== null) parts.push(sql`${exif.fileSizeInByte} <= ${Math.round(c.max * 1048576)}`);
return parts.length > 0 ? and(...parts)! : undefined;
}
case "filename": {
if (!c.text) return undefined;
const column = c.field === "path" ? assets.originalPath : assets.originalFileName;
// Escape LIKE wildcards so user text is matched literally
const escaped = c.text.replace(/([\\%_])/g, "\\$1");
const match = c.match || "contains";
const pattern =
match === "starts_with" ? `${escaped}%`
: match === "ends_with" ? `%${escaped}`
: `%${escaped}%`;
return match === "not_contains"
? sql`${column} NOT ILIKE ${pattern}`
: sql`${column} ILIKE ${pattern}`;
}
case "file_extension": {
const raw: string = c.extensions || "";
const exts = raw
.split(",")
.map((e: string) => e.trim().replace(/^\./, "").toLowerCase())
.filter(Boolean);
if (exts.length === 0) return undefined;
// Extension = text after the last dot; files without one never match either mode
const extExpr = sql`lower(substring(${assets.originalFileName} from '\\.([^.]+)$'))`;
const list = exts.map((e: string) => `'${e.replace(/'/g, "''")}'`).join(",");
return c.match === "not_in"
? sql`${extExpr} NOT IN (${sql.raw(list)})`
: sql`${extExpr} IN (${sql.raw(list)})`;
}
case "face_count": {
const faceCount = sql`(SELECT count(*) FROM "asset_face" af WHERE af."assetId" = ${assets.id} AND af."deletedAt" IS NULL AND af."isVisible" = true)`;
const parts: SQL[] = [];
if (c.min !== undefined && c.min !== null) parts.push(sql`${faceCount} >= ${c.min}`);
if (c.max !== undefined && c.max !== null) parts.push(sql`${faceCount} <= ${c.max}`);
return parts.length > 0 ? and(...parts)! : undefined;
}
case "time_of_day": {
if (c.fromHour == null || c.toHour == null) return undefined;
// localDateTime holds the capture wall-clock time, so no timezone math needed
const hour = sql`EXTRACT(HOUR FROM ${assets.localDateTime})`;
if (c.fromHour <= c.toHour) {
return sql`${hour} BETWEEN ${c.fromHour} AND ${c.toHour}`;
}
// From > To wraps past midnight, e.g. 22–5 = evening through early morning
return sql`(${hour} >= ${c.fromHour} OR ${hour} <= ${c.toHour})`;
}
case "person": {
const ids: string[] = c.personIds || (c.personId ? [c.personId] : []);
if (ids.length === 0) return undefined;
+5 -1
View File
@@ -30,7 +30,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const keyData = await keyRes.json();
const granted: string[] = keyData.permissions ?? [];
const missing = REQUIRED_PERMISSIONS.filter((p) => !granted.includes(p));
// A key created with the "All" checkbox reports its permissions as ["all"],
// which grants every permission without listing them individually.
const missing = granted.includes("all")
? []
: REQUIRED_PERMISSIONS.filter((p) => !granted.includes(p));
return res.status(200).json({
valid: missing.length === 0,
+2
View File
@@ -55,6 +55,8 @@ export type ConditionType =
| "camera_make" | "camera_model" | "lens"
| "asset_type" | "iso_range" | "focal_length"
| "rating" | "is_favorited" | "resolution"
| "file_size" | "filename" | "file_extension"
| "face_count" | "time_of_day"
| "not_in_album" | "not_in_specific_album";
export interface ICondition {