Merge remote-tracking branch 'origin/prerelease' into feat/resolution-condition
This commit is contained in:
@@ -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 > 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":
|
||||
|
||||
@@ -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[] =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user