From fba65d3087adf0ac20c5ef89ddcebe626eb86443 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sat, 16 May 2026 09:31:23 +0800 Subject: [PATCH 1/8] Add Health quantity action support --- ReadMe.md | 20 ++ actions/health.cherri | 297 ++++++++++++++++++++++++ actions/web.cherri | 2 +- actions_std.go | 159 ++++++++++++- cherri_test.go | 211 +++++++++++++++++ decompile.go | 197 +++++++++++++++- raw_actions.go | 45 +++- tests/decomp-expected.cherri | 1 + tests/health.cherri | 11 + tests/raw-action-health-quantity.cherri | 20 ++ 10 files changed, 954 insertions(+), 9 deletions(-) create mode 100644 actions/health.cherri create mode 100644 tests/health.cherri create mode 100644 tests/raw-action-health-quantity.cherri diff --git a/ReadMe.md b/ReadMe.md index 7ae9cb6b..c13017fc 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -101,6 +101,26 @@ cherri file.cherri Run `cherri` without any arguments to see all options and usage. For development, use the `--debug` (or `-d`) option to print stack traces, debug information, and output a `.plist` file. +### Health samples and workouts + +Use the Health standard actions to log quantity samples, find samples, open Health views, and log workouts. Quantity values are written with `qty(value, "unit")`: + +```ruby +#include 'actions/health' + +const high = prompt("Systolic", "Number") +const low = prompt("Diastolic", "Number") + +logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) +logHealthQuantity(qty(72, "count/min"), "Heart Rate") +const steps = logHealthQuantity(qty(20, "count"), "Steps", CurrentDate) +findHealthSamples(steps) +logHealthCategory("Acne", "Present", nil, CurrentDate, CurrentDate) +logWorkout("Basketball", qty(500, "Cal")) +``` + +Imported Shortcuts that contain supported Health actions are converted to standard Health calls when possible. + ## Why another Shortcuts language? Because it's fun :) diff --git a/actions/health.cherri b/actions/health.cherri new file mode 100644 index 00000000..327b5cf9 --- /dev/null +++ b/actions/health.cherri @@ -0,0 +1,297 @@ +/* +Health Actions +*/ + +enum healthQuantitySampleType { + 'Steps', + 'Step Count', + 'Walking + Running Distance', + 'Distance Walking Running', + 'Walking Step Length', + 'Walking Speed', + 'Time In Daylight', + 'Vertical Oscillation', + 'Running Vertical Oscillation', + 'Forced Expiratory Volume 1', + 'Forced Expiratory Volume1', + 'Systolic Blood Pressure', + 'Diastolic Blood Pressure', + 'Blood Pressure Systolic', + 'Blood Pressure Diastolic', + 'Dietary Energy', + 'Dietary Energy Consumed', + 'Water', + 'Dietary Water', + 'Protein', + 'Dietary Protein', + 'Saturated Fat', + 'Dietary Fat Saturated', + 'Monounsaturated Fat', + 'Dietary Fat Monounsaturated', + 'Apple Sleeping Wrist Temperature', + 'Body Fat Percentage', + 'Body Mass', + 'Body Mass Index', + 'Electrodermal Activity', + 'Height', + 'Lean Body Mass', + 'Waist Circumference', + 'Active Energy Burned', + 'Apple Exercise Time', + 'Apple Move Time', + 'Apple Stand Time', + 'Basal Energy Burned', + 'Cross Country Skiing Speed', + 'Cycling Cadence', + 'Cycling Functional Threshold Power', + 'Cycling Power', + 'Cycling Speed', + 'Distance Cross Country Skiing', + 'Distance Cycling', + 'Distance Downhill Snow Sports', + 'Distance Paddle Sports', + 'Distance Rowing', + 'Distance Skating Sports', + 'Distance Swimming', + 'Distance Wheelchair', + 'Estimated Workout Effort Score', + 'Flights Climbed', + 'Nike Fuel', + 'Paddle Sports Speed', + 'Physical Effort', + 'Push Count', + 'Rowing Speed', + 'Running Power', + 'Running Speed', + 'Swimming Stroke Count', + 'Underwater Depth', + 'Workout Effort Score', + 'Environmental Audio Exposure', + 'Environmental Sound Reduction', + 'Headphone Audio Exposure', + 'Atrial Fibrillation Burden', + 'Heart Rate', + 'Heart Rate Recovery One Minute', + 'Heart Rate Variability SDNN', + 'Peripheral Perfusion Index', + 'Resting Heart Rate', + 'VO2 Max', + 'Walking Heart Rate Average', + 'Apple Walking Steadiness', + 'Running Ground Contact Time', + 'Running Stride Length', + 'Six Minute Walk Test Distance', + 'Stair Ascent Speed', + 'Stair Descent Speed', + 'Walking Asymmetry Percentage', + 'Walking Double Support Percentage', + 'Dietary Biotin', + 'Dietary Caffeine', + 'Dietary Calcium', + 'Dietary Carbohydrates', + 'Dietary Chloride', + 'Dietary Cholesterol', + 'Dietary Chromium', + 'Dietary Copper', + 'Dietary Fat Polyunsaturated', + 'Dietary Fat Total', + 'Dietary Fiber', + 'Dietary Folate', + 'Dietary Iodine', + 'Dietary Iron', + 'Dietary Magnesium', + 'Dietary Manganese', + 'Dietary Molybdenum', + 'Dietary Niacin', + 'Dietary Pantothenic Acid', + 'Dietary Phosphorus', + 'Dietary Potassium', + 'Dietary Riboflavin', + 'Dietary Selenium', + 'Dietary Sodium', + 'Dietary Sugar', + 'Dietary Thiamin', + 'Dietary Vitamin A', + 'Dietary Vitamin B12', + 'Dietary Vitamin B6', + 'Dietary Vitamin C', + 'Dietary Vitamin D', + 'Dietary Vitamin E', + 'Dietary Vitamin K', + 'Dietary Zinc', + 'Blood Alcohol Content', + 'Insulin Delivery', + 'Number Of Alcoholic Beverages', + 'Number Of Times Fallen', + 'UV Exposure', + 'Water Temperature', + 'Basal Body Temperature', + 'Apple Sleeping Breathing Disturbances', + 'Forced Vital Capacity', + 'Inhaler Usage', + 'Oxygen Saturation', + 'Peak Expiratory Flow Rate', + 'Respiratory Rate', + 'Blood Glucose', + 'Body Temperature', +} + +enum healthQuantityUnit { + 'count', + 'count/min', + '%', + 'kg', + 'lb', + 'g', + 'mg', + 'mg/dL', + 'mg/dl', + 'mmol/L', + 'kcal', + 'Cal', + 'cal', + 'L', + 'mL', + 'm', + 'km', + 'cm', + 'mm', + 's', + 'ms', + 'min', + 'hr', + 'd', + 'degC', + 'degF', + 'S', + 'W', + 'Pa', + 'kPa', + 'atm', + 'dBASPL', + 'J', + 'kJ', + 'J/s', + 'm/s', + 'm/s^2', + 'L/min', + 'mL/(kg*min)', + 'ml/(kg*min)', + 'kcal/(kg*hr)', + 'IU', + 'appleEffortScore', + 'mmHg', +} + +enum bloodPressureUnit { + 'mmHg', +} + +enum healthCategorySampleType { + 'Apple Stand Hour', + 'Environmental Audio Exposure Event', + 'Headphone Audio Exposure Event', + 'High Heart Rate Event', + 'Hypertension Event', + 'Irregular Heart Rhythm Event', + 'Low Cardio Fitness Event', + 'Low Heart Rate Event', + 'Mindful Session', + 'Apple Walking Steadiness Event', + 'Handwashing Event', + 'Toothbrushing Event', + 'Bleeding After Pregnancy', + 'Bleeding During Pregnancy', + 'Cervical Mucus Quality', + 'Contraceptive', + 'Infrequent Menstrual Cycles', + 'Intermenstrual Bleeding', + 'Irregular Menstrual Cycles', + 'Lactation', + 'Menstrual Flow', + 'Ovulation Test Result', + 'Persistent Intermenstrual Bleeding', + 'Pregnancy', + 'Pregnancy Test Result', + 'Progesterone Test Result', + 'Prolonged Menstrual Periods', + 'Sexual Activity', + 'Sleep Apnea Event', + 'Sleep Analysis', + 'Abdominal Cramps', + 'Acne', + 'Appetite Changes', + 'Bladder Incontinence', + 'Bloating', + 'Breast Pain', + 'Chest Tightness Or Pain', + 'Chills', + 'Constipation', + 'Coughing', + 'Diarrhea', + 'Dizziness', + 'Dry Skin', + 'Fainting', + 'Fatigue', + 'Fever', + 'Generalized Body Ache', + 'Hair Loss', + 'Headache', + 'Heartburn', + 'Hot Flashes', + 'Loss Of Smell', + 'Loss Of Taste', + 'Lower Back Pain', + 'Memory Lapse', + 'Mood Changes', + 'Nausea', + 'Night Sweats', + 'Pelvic Pain', + 'Rapid Pounding Or Fluttering Heartbeat', + 'Runny Nose', + 'Shortness Of Breath', + 'Sinus Congestion', + 'Skipped Heartbeat', + 'Sleep Changes', + 'Sore Throat', + 'Vaginal Dryness', + 'Vomiting', + 'Wheezing', + 'Audio Exposure Event', +} + +// [Doc]: [Samples] Log Health Quantity: Log a quantity sample to Health. +action 'health.quantity.log' logHealthQuantity( + #healthQuantityUnit quantity: 'WFQuantitySampleQuantity', + healthQuantitySampleType sampleType: 'WFQuantitySampleType', + variable ?date: 'WFQuantitySampleDate' +) + +// [Doc]: [Samples] Log Blood Pressure: Log systolic and diastolic blood pressure to Health. +action 'health.quantity.log' logBloodPressure( + #bloodPressureUnit systolic: 'WFQuantitySampleQuantity', + #bloodPressureUnit diastolic: 'WFQuantitySampleAdditionalQuantity', + variable ?date: 'WFQuantitySampleDate' +) { + "WFQuantitySampleType": "Systolic Blood Pressure" +} + +// [Doc]: [Samples] Log Health Category: Log a category sample, such as symptoms or sleep analysis, to Health. +action 'health.quantity.log' logHealthCategory( + healthCategorySampleType sampleType: 'WFQuantitySampleType', + text value: 'WFCategorySampleEnumeration', + text ?additionalValue: 'WFCategorySampleAdditionalEnumerationKey', + variable ?startDate: 'WFQuantitySampleDate', + variable ?endDate: 'WFSampleEndDate' +) + +// [Doc]: [Samples] Find Health Samples: Find quantity samples from Health. +action 'filter.health.quantity' findHealthSamples( + variable input: 'WFContentItemInputParameter' +): array + +// [Doc]: [Workouts] Log Workout: Log a workout to Health. +action 'health.workout.log' logWorkout( + text workoutType: 'WFWorkoutReadableActivityType', + #healthQuantityUnit ?calories: 'WFWorkoutCaloriesQuantity' +) diff --git a/actions/web.cherri b/actions/web.cherri index a8fc9629..14140e0b 100644 --- a/actions/web.cherri +++ b/actions/web.cherri @@ -136,7 +136,7 @@ action 'downloadurl' jsonRequest( action 'downloadurl' fileRequest( text url: 'WFURL', HTTPMethod ?method: 'WFHTTPMethod', - dictionary! ?body: 'WFRequestVariable', + variable ?body: 'WFRequestVariable', dictionary! ?headers: 'WFHTTPHeaders', ) { "WFHTTPBodyType": "File" diff --git a/actions_std.go b/actions_std.go index 18f96dd9..b4adb4f3 100644 --- a/actions_std.go +++ b/actions_std.go @@ -50,6 +50,18 @@ var setMultitaskingModeIntent = appIntent{ appIntentIdentifier: "SetMultitaskingModeAction", } +var openHealthViewIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenViewIntent", +} + +var openHealthDataTypeIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenDataTypeIntent", +} + // actions is the data structure that determines every action the compiler knows about. // The key determines the identifier of the identifier that must be used in the syntax, it's value defines its behavior, etc. using an actionDefinition. var actions = map[string]*actionDefinition{ @@ -1236,6 +1248,64 @@ var actions = map[string]*actionDefinition{ }, }, }, + "openHealthView": { + doc: selfDoc{ + title: "Open Health View", + description: "Open a Health category view.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenViewIntent", + appIntent: openHealthViewIntent, + parameters: []parameterDefinition{ + { + name: "identifier", + validType: String, + }, + { + name: "title", + validType: String, + optional: true, + }, + { + name: "symbol", + validType: String, + optional: true, + }, + }, + makeParams: healthTargetParams, + decomp: decompHealthTarget, + }, + "openHealthData": { + doc: selfDoc{ + title: "Open Health Data", + description: "Open a Health data type.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenDataTypeIntent", + appIntent: openHealthDataTypeIntent, + parameters: []parameterDefinition{ + { + name: "identifier", + validType: String, + }, + { + name: "title", + validType: String, + optional: true, + }, + { + name: "symbol", + validType: String, + optional: true, + }, + }, + makeParams: healthTargetParams, + decomp: decompHealthTarget, + }, "getWindows": { doc: selfDoc{ title: "Get Windows", @@ -1815,6 +1885,7 @@ var actionIncludes = []string{ "device", "documents", "dropbox", + "health", "images", "location", "intelligence", @@ -1916,7 +1987,9 @@ func checkMissingStandardInclude(identifier *string, parsing bool) { lines = append([]string{fmt.Sprintf("#include 'actions/%s'\n", actionInclude)}, lines...) resetParse() handleIncludes() + currentCategory = actionInclude handleActionDefinitions() + currentCategory = "" if !parsing { mapSplitActions() @@ -2027,7 +2100,10 @@ func textParts(args []actionArgument) map[string]any { "Show-text": true, } - var separator = getArgValue(args[1]) + var separator any = "\n" + if len(args) > 1 { + separator = getArgValue(args[1]) + } switch { case separator == " ": data["WFTextSeparator"] = "Spaces" @@ -2055,20 +2131,99 @@ func decompTextParts(action *ShortcutAction) (arguments []string) { arguments = append(arguments, decompValue(action.WFWorkflowActionParameters["text"])) var glue string + var hasCustomSeparator bool if action.WFWorkflowActionParameters["WFTextSeparator"] != nil { glue = decompReferenceValue(action.WFWorkflowActionParameters["WFTextSeparator"]) } if action.WFWorkflowActionParameters["WFTextCustomSeparator"] != nil { glue = decompReferenceValue(action.WFWorkflowActionParameters["WFTextCustomSeparator"]) + hasCustomSeparator = true } - if glue != "" { + if glue != "" || hasCustomSeparator { arguments = append(arguments, fmt.Sprintf("\"%s\"", glueToChar(glue))) } return } +func healthTargetParams(args []actionArgument) map[string]any { + var identifier = healthTargetString(args, 0, "") + var title = healthTargetString(args, 1, identifier) + var symbol = healthTargetString(args, 2, "heart.fill") + + return map[string]any{ + "target": map[string]any{ + "identifier": identifier, + "subtitle": map[string]any{ + "key": title, + }, + "symbol": map[string]any{ + "systemName": symbol, + }, + "title": map[string]any{ + "key": title, + }, + }, + } +} + +func healthTargetString(args []actionArgument, idx int, fallback string) string { + if len(args) <= idx || args[idx].value == nil { + return fallback + } + + var value, ok = getArgValue(args[idx]).(string) + if !ok || value == "" { + return fallback + } + + return value +} + +func decompHealthTarget(action *ShortcutAction) (arguments []string) { + var target, ok = action.WFWorkflowActionParameters["target"].(map[string]interface{}) + if !ok { + return + } + + var identifier = healthTargetParam(target, "identifier") + if identifier == "" { + return + } + arguments = append(arguments, fmt.Sprintf("\"%s\"", escapeString(identifier))) + + var title = healthTargetNestedParam(target, "title", "key") + if title != "" { + arguments = append(arguments, fmt.Sprintf("\"%s\"", escapeString(title))) + } + + var symbol = healthTargetNestedParam(target, "symbol", "systemName") + if symbol != "" { + arguments = append(arguments, fmt.Sprintf("\"%s\"", escapeString(symbol))) + } + + return +} + +func healthTargetParam(target map[string]interface{}, key string) string { + var value, ok = target[key].(string) + if !ok { + return "" + } + + return value +} + +func healthTargetNestedParam(target map[string]interface{}, key string, nestedKey string) string { + var value, ok = target[key].(map[string]interface{}) + if !ok { + return "" + } + + return healthTargetParam(value, nestedKey) +} + var appIds map[string]string func makeAppIds() { diff --git a/cherri_test.go b/cherri_test.go index d39583f2..421dd349 100644 --- a/cherri_test.go +++ b/cherri_test.go @@ -203,6 +203,217 @@ func TestActionIdentifiers(t *testing.T) { resetParser() } +func TestRawActionQuantityFieldValue(t *testing.T) { + defer resetParser() + args.Args["no-ansi"] = "" + args.Args["skip-sign"] = "" + + currentTest = "tests/raw-action-health-quantity.cherri" + os.Args[1] = currentTest + compile() + + var action = shortcut.WFWorkflowActions[len(shortcut.WFWorkflowActions)-1] + if action.WFWorkflowActionIdentifier != "is.workflow.actions.health.quantity.log" { + t.Fatalf("expected health quantity log action, got %q", action.WFWorkflowActionIdentifier) + } + + var quantity, quantityOK = action.WFWorkflowActionParameters["WFQuantitySampleQuantity"].(WFQuantityFieldValue) + if !quantityOK { + t.Fatalf("expected quantity sample to be WFQuantityFieldValue, got %T", action.WFWorkflowActionParameters["WFQuantitySampleQuantity"]) + } + var magnitude, magnitudeOK = quantity.Value.Magnitude.(Value) + if !magnitudeOK { + t.Fatalf("expected quantity magnitude to be variable Value, got %T", quantity.Value.Magnitude) + } + if magnitude.OutputName != "high" || magnitude.Type != "ActionOutput" { + t.Fatalf("expected quantity magnitude to reference high, got %#v", magnitude) + } + + var additionalQuantity, additionalQuantityOK = action.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"].(WFQuantityFieldValue) + if !additionalQuantityOK { + t.Fatalf("expected additional quantity to be WFQuantityFieldValue, got %T", action.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"]) + } + var additionalMagnitude, additionalMagnitudeOK = additionalQuantity.Value.Magnitude.(Value) + if !additionalMagnitudeOK { + t.Fatalf("expected additional quantity magnitude to be variable Value, got %T", additionalQuantity.Value.Magnitude) + } + if additionalMagnitude.OutputName != "low" || additionalMagnitude.Type != "ActionOutput" { + t.Fatalf("expected additional quantity magnitude to reference low, got %#v", additionalMagnitude) + } +} + +func TestHealthQuantityActions(t *testing.T) { + defer resetParser() + args.Args["no-ansi"] = "" + args.Args["skip-sign"] = "" + loadStandardActions() + + currentTest = "tests/health.cherri" + os.Args[1] = currentTest + compile() + + if len(shortcut.WFWorkflowActions) < 7 { + t.Fatalf("expected health actions to compile, got %d actions", len(shortcut.WFWorkflowActions)) + } + + var bloodPressure = findCompiledAction("is.workflow.actions.health.quantity.log", "WFQuantitySampleAdditionalQuantity") + if bloodPressure == nil { + t.Fatal("expected blood pressure action") + } + if bloodPressure.WFWorkflowActionIdentifier != "is.workflow.actions.health.quantity.log" { + t.Fatalf("expected blood pressure action, got %q", bloodPressure.WFWorkflowActionIdentifier) + } + if sampleType := bloodPressure.WFWorkflowActionParameters["WFQuantitySampleType"]; sampleType != "Systolic Blood Pressure" { + t.Fatalf("expected blood pressure sample type, got %#v", sampleType) + } + if _, ok := bloodPressure.WFWorkflowActionParameters["WFQuantitySampleQuantity"].(WFQuantityFieldValue); !ok { + t.Fatalf("expected systolic quantity field, got %T", bloodPressure.WFWorkflowActionParameters["WFQuantitySampleQuantity"]) + } + if _, ok := bloodPressure.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"].(WFQuantityFieldValue); !ok { + t.Fatalf("expected diastolic quantity field, got %T", bloodPressure.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"]) + } + + var steps = findCompiledAction("is.workflow.actions.health.quantity.log", "WFQuantitySampleDate") + if steps == nil { + t.Fatal("expected dated steps action") + } + if sampleType := steps.WFWorkflowActionParameters["WFQuantitySampleType"]; sampleType != "Steps" { + t.Fatalf("expected steps sample type, got %#v", sampleType) + } + + var heartRate = findCompiledActionWithParamValue("is.workflow.actions.health.quantity.log", "WFQuantitySampleType", "Heart Rate") + if heartRate == nil { + t.Fatal("expected heart rate action") + } + if _, ok := heartRate.WFWorkflowActionParameters["WFQuantitySampleQuantity"].(WFQuantityFieldValue); !ok { + t.Fatalf("expected heart rate quantity field, got %T", heartRate.WFWorkflowActionParameters["WFQuantitySampleQuantity"]) + } + + if findCompiledAction("is.workflow.actions.filter.health.quantity", "WFContentItemInputParameter") == nil { + t.Fatal("expected find health samples action") + } + if findCompiledAction("com.apple.Health.OpenViewIntent", "target") == nil { + t.Fatal("expected open health view action") + } + if findCompiledAction("com.apple.Health.OpenDataTypeIntent", "target") == nil { + t.Fatal("expected open health data action") + } + if findCompiledAction("is.workflow.actions.health.workout.log", "WFWorkoutCaloriesQuantity") == nil { + t.Fatal("expected log workout action") + } + if findCompiledAction("is.workflow.actions.health.quantity.log", "WFCategorySampleEnumeration") == nil { + t.Fatal("expected log health category action") + } +} + +func findCompiledAction(identifier string, requiredParam string) *ShortcutAction { + for i := range shortcut.WFWorkflowActions { + var action = &shortcut.WFWorkflowActions[i] + if action.WFWorkflowActionIdentifier != identifier { + continue + } + if _, found := action.WFWorkflowActionParameters[requiredParam]; found { + return action + } + } + return nil +} + +func findCompiledActionWithParamValue(identifier string, param string, value any) *ShortcutAction { + for i := range shortcut.WFWorkflowActions { + var action = &shortcut.WFWorkflowActions[i] + if action.WFWorkflowActionIdentifier != identifier { + continue + } + if action.WFWorkflowActionParameters[param] == value { + return action + } + } + return nil +} + +func TestDecompileRawActionQuantityFieldValue(t *testing.T) { + defer resetParser() + uuids = map[string]string{ + "high-uuid": "high", + "low-uuid": "low", + } + var action = ShortcutAction{ + WFWorkflowActionIdentifier: "is.workflow.actions.health.quantity.log", + WFWorkflowActionParameters: map[string]any{ + "UUID": "action-uuid", + "WFQuantitySampleQuantity": map[string]any{ + "Value": map[string]any{ + "Magnitude": map[string]any{ + "OutputName": "high", + "OutputUUID": "high-uuid", + "Type": "ActionOutput", + }, + "Unit": "mmHg", + }, + "WFSerializationType": "WFQuantityFieldValue", + }, + "WFQuantitySampleAdditionalQuantity": map[string]any{ + "Value": map[string]any{ + "Magnitude": map[string]any{ + "OutputName": "low", + "OutputUUID": "low-uuid", + "Type": "ActionOutput", + }, + "Unit": "mmHg", + }, + "WFSerializationType": "WFQuantityFieldValue", + }, + "WFQuantitySampleType": "Systolic Blood Pressure", + }, + } + + var rawAction = makeRawAction(&action) + if !strings.Contains(rawAction, `"Magnitude": "${high}"`) { + t.Fatalf("expected decompiled raw action to reference high, got:\n%s", rawAction) + } + if !strings.Contains(rawAction, `"Magnitude": "${low}"`) { + t.Fatalf("expected decompiled raw action to reference low, got:\n%s", rawAction) + } + if !strings.Contains(rawAction, `"WFSerializationType": "WFQuantityFieldValue"`) { + t.Fatalf("expected decompiled raw action to keep quantity serialization, got:\n%s", rawAction) + } +} + +func TestScoreActionParamsSkipsInvalidEnumValue(t *testing.T) { + enumerations["testHTTPMethod"] = []string{"POST", "PUT", "PATCH", "DELETE"} + defer delete(enumerations, "testHTTPMethod") + + var parameters = []parameterDefinition{ + { + key: "WFHTTPMethod", + enum: "testHTTPMethod", + }, + } + var matchedParams, matchedValues = scoreActionParams(¶meters, map[string]any{ + "WFHTTPMethod": "GET", + }) + + if matchedParams != 0 || matchedValues != 0 { + t.Fatalf("expected invalid enum value not to match, got params=%d values=%d", matchedParams, matchedValues) + } +} + +func TestDecompileTextPartsKeepsEmptyCustomSeparator(t *testing.T) { + defer resetParser() + var arguments = decompTextParts(&ShortcutAction{ + WFWorkflowActionParameters: map[string]any{ + "text": "items", + "WFTextSeparator": "Custom", + "WFTextCustomSeparator": "", + }, + }) + + if len(arguments) != 2 || arguments[1] != `""` { + t.Fatalf("expected empty custom separator argument, got %#v", arguments) + } +} + func TestCapitalizeEmptyString(t *testing.T) { if got := capitalize(""); got != "" { t.Fatalf("expected empty string, got %q", got) diff --git a/decompile.go b/decompile.go index 84707ca9..b1169e56 100644 --- a/decompile.go +++ b/decompile.go @@ -265,6 +265,9 @@ func mapUUID(uuid string, varName string) { if _, found := uuids[uuid]; !found { outputName = varName sanitizeIdentifier(&outputName) + if outputName == "" { + outputName = "output" + } uuids[uuid] = checkDuplicateOutputName(outputName) } } @@ -939,6 +942,10 @@ func escapeString(value string) string { } func decompValueObject(value map[string]interface{}) string { + if isRawActionSerializedValue(value, rawActionQuantityFieldValueSerializationType) { + return decompQuantityFieldValue(value) + } + if v, found := value["Value"]; found { if reflect.TypeOf(v).Kind() == reflect.Map { value = v.(map[string]interface{}) @@ -986,6 +993,18 @@ func decompValueObject(value map[string]interface{}) string { return decompObjectValue(value) } +func decompQuantityFieldValue(value map[string]interface{}) string { + var quantityValue, ok = value["Value"].(map[string]interface{}) + if !ok { + return "qty(0, \"\")" + } + + var magnitude = decompValue(quantityValue["Magnitude"]) + var unit = decompValue(quantityValue["Unit"]) + + return fmt.Sprintf("qty(%s, %s)", strings.Trim(magnitude, "\""), unit) +} + func decompObjectValue(valueObj any) string { var valueType = reflect.TypeOf(valueObj).Kind() if valueType != reflect.Map { @@ -1142,6 +1161,7 @@ func makeActionCallCode(action *ShortcutAction) string { popLine(fmt.Sprintf("#define mac %v", macDefinition)) setMacDefinition = true } + ensureStandardActionInclude(action, &matchedAction) actionCallCode.WriteString(fmt.Sprintf("%s(", matchedIdentifier)) @@ -1159,6 +1179,64 @@ func makeActionCallCode(action *ShortcutAction) string { return actionCallCode.String() } +func ensureStandardActionInclude(action *ShortcutAction, definition *actionDefinition) { + var category = definition.doc.category + if category == "" { + category = standardActionCategory(action.WFWorkflowActionIdentifier) + } + if definition.builtin && category == "" { + return + } + if category == "" || category == "basic" { + return + } + + var includeStatement = fmt.Sprintf("#include 'actions/%s'", category) + if strings.Contains(code.String(), includeStatement) { + return + } + + popLine(includeStatement) +} + +var standardActionLineRegex = regexp.MustCompile(`(?m)^action\s+(?:default\s+)?(?:!?(?:mac|nonmac)\s+)?(?:v[0-9.]+\s+)?(?:toggleSet\s+)?(?:'([^']+)'\s+)?([A-Za-z_][A-Za-z0-9_]*)`) + +func standardActionCategory(identifier string) string { + for _, actionInclude := range actionIncludes { + var actionFileBytes, readErr = stdActions.ReadFile(fmt.Sprintf("actions/%s.cherri", actionInclude)) + if readErr != nil { + continue + } + + var actionLines = standardActionLineRegex.FindAllStringSubmatch(string(actionFileBytes), -1) + for _, actionLine := range actionLines { + if len(actionLine) < 3 { + continue + } + + var shortcutIdentifier = standardActionFullIdentifier(actionLine[1], actionLine[2]) + if identifier == shortcutIdentifier { + return actionInclude + } + } + } + + return "" +} + +func standardActionFullIdentifier(rawIdentifier string, actionName string) string { + if rawIdentifier == "" { + return fmt.Sprintf("is.workflow.actions.%s", strings.ToLower(actionName)) + } + + var parts = strings.Split(rawIdentifier, ".") + if len(parts) < 3 || (len(parts) == 3 && rawIdentifier == strings.ToLower(rawIdentifier)) { + return fmt.Sprintf("is.workflow.actions.%s", rawIdentifier) + } + + return rawIdentifier +} + // checkOutputType determines if action output is a constant or a variable. // If it is a constant we will write a constant statement on a new line to prepend the action. func checkOutputType(action *ShortcutAction) (isConstant bool, isVariableValue bool) { @@ -1292,18 +1370,86 @@ func processRawParameters(params map[string]any) map[string]any { for key, value := range params { if key == UUID || key == "CustomOutputName" { delete(params, key) + continue } - if reflect.TypeOf(value).Kind() == reflect.Map { - decompilingDictionary = true - params[key] = decompValueObject(value.(map[string]interface{})) - decompilingDictionary = false + if value != nil && reflect.TypeOf(value).Kind() == reflect.Map { + params[key] = decompRawParameterValue(value) } } return params } +func decompRawParameterValue(value any) any { + if value == nil || reflect.TypeOf(value).Kind() != reflect.Map { + return value + } + + var valueMap = value.(map[string]interface{}) + if isRawActionSerializedValue(valueMap, rawActionQuantityFieldValueSerializationType) { + return decompRawActionQuantityFieldValue(valueMap) + } + + decompilingDictionary = true + var decompiledValue = decompValueObject(valueMap) + decompilingDictionary = false + return decompiledValue +} + +func decompRawActionQuantityFieldValue(value map[string]interface{}) map[string]any { + var quantityValue = map[string]any{} + var valueMap, ok = value["Value"].(map[string]interface{}) + if ok { + if magnitude, found := valueMap["Magnitude"]; found { + quantityValue["Magnitude"] = decompRawActionQuantityComponent(magnitude) + } + if unit, found := valueMap["Unit"]; found { + quantityValue["Unit"] = decompRawActionQuantityComponent(unit) + } + } + + return map[string]any{ + "Value": quantityValue, + "WFSerializationType": rawActionQuantityFieldValueSerializationType, + } +} + +func decompRawActionQuantityComponent(value any) any { + if isRawActionVariableReference(value) { + var identifier = strings.Trim(decompValue(value), "\"") + return fmt.Sprintf("${%s}", identifier) + } + + return value +} + +func isRawActionVariableReference(value any) bool { + if value == nil || reflect.TypeOf(value).Kind() != reflect.Map { + return false + } + + var valueMap = value.(map[string]interface{}) + if wrappedValue, found := valueMap["Value"]; found && wrappedValue != nil && reflect.TypeOf(wrappedValue).Kind() == reflect.Map { + valueMap = wrappedValue.(map[string]interface{}) + } + + var valueType, ok = valueMap["Type"].(string) + if !ok { + return false + } + if valueType == "ActionOutput" || valueType == "Variable" { + return true + } + for _, global := range globals { + if valueType == global.variableType { + return true + } + } + + return false +} + func matchAction(action *ShortcutAction) (name string, definition actionDefinition) { for call, def := range actions { var identifier = strings.ToLower(call) @@ -1438,6 +1584,12 @@ func scoreActionMatch(splitAction actionValue, splitActionParams []parameterDefi matchedValues += valueMatches var splitActionAddParams []parameterDefinition + for key, value := range splitAction.definition.appendParams { + splitActionAddParams = append(splitActionAddParams, parameterDefinition{ + key: key, + defaultValue: value, + }) + } if splitAction.definition.appendParamsFunc != nil { for key, value := range splitAction.definition.appendParamsFunc([]actionArgument{}) { splitActionAddParams = append(splitActionAddParams, parameterDefinition{ @@ -1460,10 +1612,22 @@ func scoreActionParams(splitActionParams *[]parameterDefinition, parameters map[ continue } if value, found := parameters[param.key]; found { - matchedParams++ + if param.validType == Quantity { + matchedParams++ + if slices.Contains(getEnum(param.enum), quantityFieldUnit(value)) { + matchedValues++ + } + continue + } if len(param.enum) > 0 && slices.Contains(getEnum(param.enum), fmt.Sprintf("%s", value)) { + matchedParams++ matchedValues++ + continue + } + if len(param.enum) > 0 { + continue } + matchedParams++ if param.defaultValue != nil { var defaultValue = fmt.Sprintf("%v", param.defaultValue) var rawValue = strings.Trim(decompValue(value), "\"") @@ -1476,6 +1640,29 @@ func scoreActionParams(splitActionParams *[]parameterDefinition, parameters map[ return } +func quantityFieldUnit(value any) string { + if value == nil || reflect.TypeOf(value).Kind() != reflect.Map { + return "" + } + + var valueMap = value.(map[string]interface{}) + if !isRawActionSerializedValue(valueMap, rawActionQuantityFieldValueSerializationType) { + return "" + } + + var quantityValue, quantityOK = valueMap["Value"].(map[string]interface{}) + if !quantityOK { + return "" + } + + var unit, unitOK = quantityValue["Unit"].(string) + if !unitOK { + return "" + } + + return unit +} + func scoreActionAddParams(splitActionAddParams *[]parameterDefinition, parameters map[string]any) (matchedParams uint, matchedValues uint) { for _, param := range *splitActionAddParams { if param.key == "" { diff --git a/raw_actions.go b/raw_actions.go index d3f0b312..f7258f8f 100644 --- a/raw_actions.go +++ b/raw_actions.go @@ -6,6 +6,7 @@ import ( ) var rawActionVariableValueRegex = regexp.MustCompile(`^\$\{@?.*}$`) +var rawActionQuantityFieldValueSerializationType = "WFQuantityFieldValue" func defineRawAction() { actions["rawAction"] = &actionDefinition{ @@ -51,11 +52,14 @@ func normalizeRawActionParamValue(value any) any { } if rawActionVariableValueRegex.MatchString(v) { return variableValue(varValue{ - value: strings.Trim(v, "${@}"), + value: rawActionVariableIdentifier(v), }) } return attachmentValues(v) case map[string]any: + if isRawActionSerializedValue(v, rawActionQuantityFieldValueSerializationType) { + return makeRawActionQuantityFieldValue(v) + } var normalized any = normalizeRawActionDictionary(v) return makeDictionaryValue(&normalized) case []any: @@ -65,6 +69,16 @@ func normalizeRawActionParamValue(value any) any { } } +func rawActionVariableIdentifier(value string) string { + var identifier = strings.TrimPrefix(strings.TrimSuffix(value, "}"), "${") + return strings.TrimPrefix(identifier, "@") +} + +func isRawActionSerializedValue(value map[string]any, serializationType string) bool { + var valueSerializationType, ok = value["WFSerializationType"].(string) + return ok && valueSerializationType == serializationType +} + func normalizeRawActionDictionary(value map[string]any) map[string]any { var normalized = make(map[string]any, len(value)) for key, item := range value { @@ -73,6 +87,35 @@ func normalizeRawActionDictionary(value map[string]any) map[string]any { return normalized } +func makeRawActionQuantityFieldValue(value map[string]any) WFQuantityFieldValue { + var quantityValue = WFQuantityValue{} + var rawValue, ok = value["Value"].(map[string]any) + if ok { + if magnitude, found := rawValue["Magnitude"]; found { + quantityValue.Magnitude = normalizeRawActionQuantityValue(magnitude) + } + if unit, found := rawValue["Unit"]; found { + quantityValue.Unit = normalizeRawActionQuantityValue(unit) + } + } + + return WFQuantityFieldValue{ + Value: quantityValue, + WFSerializationType: rawActionQuantityFieldValueSerializationType, + } +} + +func normalizeRawActionQuantityValue(value any) any { + var stringValue, ok = value.(string) + if !ok || !rawActionVariableValueRegex.MatchString(stringValue) { + return value + } + + return variableValueWithSerialization(varValue{ + value: rawActionVariableIdentifier(stringValue), + }, "") +} + func makeRawActionArrayValue(value []any) WFArrayValue { var items = make([]WFDictionaryFieldValueItem, 0, len(value)) for _, item := range value { diff --git a/tests/decomp-expected.cherri b/tests/decomp-expected.cherri index 1a9e7563..616b27e8 100644 --- a/tests/decomp-expected.cherri +++ b/tests/decomp-expected.cherri @@ -1,3 +1,4 @@ +#include 'actions/web' #include 'actions/location' #define color red #define glyph apple diff --git a/tests/health.cherri b/tests/health.cherri new file mode 100644 index 00000000..acf5ba49 --- /dev/null +++ b/tests/health.cherri @@ -0,0 +1,11 @@ +const high = prompt("Systolic", "Number") +const low = prompt("Diastolic", "Number") + +const steps = logHealthQuantity(qty(20, "count"), "Steps", CurrentDate) +findHealthSamples(steps) +openHealthView("HKDisplayCategoryIdentifier_13", "Respiratory", "lungs.fill") +openHealthData("HKDisplayTypeIdentifierForcedVitalCapacity", "Forced Vital Capacity", "lungs.fill") +logWorkout("Basketball", qty(500, "Cal")) +logHealthCategory("Acne", "Present", nil, CurrentDate, CurrentDate) +logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) +logHealthQuantity(qty(72, "count/min"), "Heart Rate") diff --git a/tests/raw-action-health-quantity.cherri b/tests/raw-action-health-quantity.cherri new file mode 100644 index 00000000..569f3393 --- /dev/null +++ b/tests/raw-action-health-quantity.cherri @@ -0,0 +1,20 @@ +const high = prompt("Systolic", "Number") +const low = prompt("Diastolic", "Number") + +rawAction("is.workflow.actions.health.quantity.log", { + "WFQuantitySampleQuantity": { + "Value": { + "Magnitude": "${high}", + "Unit": "mmHg" + }, + "WFSerializationType": "WFQuantityFieldValue" + }, + "WFQuantitySampleAdditionalQuantity": { + "Value": { + "Magnitude": "${low}", + "Unit": "mmHg" + }, + "WFSerializationType": "WFQuantityFieldValue" + }, + "WFQuantitySampleType": "Systolic Blood Pressure" +}) From c0e396a175a57e476b39e721b839c6e86c4995ee Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sat, 16 May 2026 11:25:39 +0800 Subject: [PATCH 2/8] feat(health): add remaining shortcut actions --- actions/health.cherri | 22 ++++- actions_std.go | 182 ++++++++++++++++++++++++++++++++++++++++++ cherri_test.go | 27 +++++++ tests/health.cherri | 9 ++- 4 files changed, 238 insertions(+), 2 deletions(-) diff --git a/actions/health.cherri b/actions/health.cherri index 327b5cf9..a087fb00 100644 --- a/actions/health.cherri +++ b/actions/health.cherri @@ -260,6 +260,17 @@ enum healthCategorySampleType { 'Audio Exposure Event', } +enum physicalActivity { + 'Unknown', + 'Stationary', + 'Walking', + 'Running', + 'Cycling', + 'In a Stationary Vehicle', + 'In a Moving Vehicle', + 'Moving', +} + // [Doc]: [Samples] Log Health Quantity: Log a quantity sample to Health. action 'health.quantity.log' logHealthQuantity( #healthQuantityUnit quantity: 'WFQuantitySampleQuantity', @@ -290,8 +301,17 @@ action 'filter.health.quantity' findHealthSamples( variable input: 'WFContentItemInputParameter' ): array +// [Doc]: [Samples] Get Details of Health Sample: Get a detail from a Health sample. +action 'properties.health.quantity' getHealthSampleDetail( + variable sample: 'WFInput', + text detail: 'WFContentItemPropertyName' +) + // [Doc]: [Workouts] Log Workout: Log a workout to Health. action 'health.workout.log' logWorkout( text workoutType: 'WFWorkoutReadableActivityType', - #healthQuantityUnit ?calories: 'WFWorkoutCaloriesQuantity' + #healthQuantityUnit ?calories: 'WFWorkoutCaloriesQuantity', + variable ?date: 'WFWorkoutDate', + #healthQuantityUnit ?duration: 'WFWorkoutDuration', + #healthQuantityUnit ?distance: 'WFWorkoutDistanceQuantity' ) diff --git a/actions_std.go b/actions_std.go index b4adb4f3..4a2db88b 100644 --- a/actions_std.go +++ b/actions_std.go @@ -50,6 +50,12 @@ var setMultitaskingModeIntent = appIntent{ appIntentIdentifier: "SetMultitaskingModeAction", } +var getPhysicalActivityIntent = appIntent{ + name: "ShortcutsActions", + bundleIdentifier: "com.apple.ShortcutsActions", + appIntentIdentifier: "GetPhysicalActivity", +} + var openHealthViewIntent = appIntent{ name: "健康", bundleIdentifier: "com.apple.Health", @@ -62,6 +68,42 @@ var openHealthDataTypeIntent = appIntent{ appIntentIdentifier: "OpenDataTypeIntent", } +var openHealthCategoryIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenCategoryIntent", +} + +var openHealthRecordsIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenRecordsIntent", +} + +var openHealthSearchIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenSearchIntent", +} + +var openHealthSleepScheduleIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenSleepScheduleIntentV2", +} + +var openHealthSleepScheduleLegacyIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenSleepScheduleIntent", +} + +var openHealthTabIntent = appIntent{ + name: "健康", + bundleIdentifier: "com.apple.Health", + appIntentIdentifier: "OpenTabIntent", +} + // actions is the data structure that determines every action the compiler knows about. // The key determines the identifier of the identifier that must be used in the syntax, it's value defines its behavior, etc. using an actionDefinition. var actions = map[string]*actionDefinition{ @@ -1306,6 +1348,146 @@ var actions = map[string]*actionDefinition{ makeParams: healthTargetParams, decomp: decompHealthTarget, }, + "openHealthCategory": { + doc: selfDoc{ + title: "Open Health Category", + description: "Open a Health category.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenCategoryIntent", + appIntent: openHealthCategoryIntent, + parameters: []parameterDefinition{ + { + name: "identifier", + validType: String, + }, + { + name: "title", + validType: String, + optional: true, + }, + { + name: "symbol", + validType: String, + optional: true, + }, + }, + makeParams: healthTargetParams, + decomp: decompHealthTarget, + }, + "openHealthRecords": { + doc: selfDoc{ + title: "Open Health Records", + description: "Open a Health records section.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenRecordsIntent", + appIntent: openHealthRecordsIntent, + parameters: []parameterDefinition{ + { + name: "identifier", + validType: String, + }, + { + name: "title", + validType: String, + optional: true, + }, + { + name: "symbol", + validType: String, + optional: true, + }, + }, + makeParams: healthTargetParams, + decomp: decompHealthTarget, + }, + "openHealthSearch": { + doc: selfDoc{ + title: "Search in Health", + description: "Open Health and search for text.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenSearchIntent", + appIntent: openHealthSearchIntent, + parameters: []parameterDefinition{ + { + name: "query", + validType: String, + key: "searchPhrase", + }, + }, + }, + "openSleepSchedule": { + doc: selfDoc{ + title: "Open Sleep Schedule", + description: "Open the sleep schedule in Health.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenSleepScheduleIntentV2", + defaultAction: true, + appIntent: openHealthSleepScheduleIntent, + }, + "openSleepScheduleLegacy": { + doc: selfDoc{ + title: "Open Sleep Schedule", + description: "Open the sleep schedule in Health using the older Health intent.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenSleepScheduleIntent", + appIntent: openHealthSleepScheduleLegacyIntent, + }, + "openHealthTab": { + doc: selfDoc{ + title: "Open Health Tab", + description: "Open a tab in Health.", + category: "health", + subcategory: "Navigation", + }, + appIdentifier: "com.apple.Health", + identifier: "OpenTabIntent", + appIntent: openHealthTabIntent, + parameters: []parameterDefinition{ + { + name: "identifier", + validType: String, + }, + { + name: "title", + validType: String, + optional: true, + }, + { + name: "symbol", + validType: String, + optional: true, + }, + }, + makeParams: healthTargetParams, + decomp: decompHealthTarget, + }, + "getPhysicalActivity": { + doc: selfDoc{ + title: "Get Physical Activity", + description: "Get the current physical activity detected by the system.", + category: "health", + subcategory: "Activity", + }, + appIdentifier: "com.apple.ShortcutsActions", + identifier: "GetPhysicalActivity", + appIntent: getPhysicalActivityIntent, + outputType: String, + }, "getWindows": { doc: selfDoc{ title: "Get Windows", diff --git a/cherri_test.go b/cherri_test.go index 421dd349..718eea39 100644 --- a/cherri_test.go +++ b/cherri_test.go @@ -292,18 +292,45 @@ func TestHealthQuantityActions(t *testing.T) { if findCompiledAction("is.workflow.actions.filter.health.quantity", "WFContentItemInputParameter") == nil { t.Fatal("expected find health samples action") } + if findCompiledAction("is.workflow.actions.properties.health.quantity", "WFContentItemPropertyName") == nil { + t.Fatal("expected get health sample detail action") + } if findCompiledAction("com.apple.Health.OpenViewIntent", "target") == nil { t.Fatal("expected open health view action") } if findCompiledAction("com.apple.Health.OpenDataTypeIntent", "target") == nil { t.Fatal("expected open health data action") } + if findCompiledAction("com.apple.Health.OpenCategoryIntent", "target") == nil { + t.Fatal("expected open health category action") + } + if findCompiledAction("com.apple.Health.OpenRecordsIntent", "target") == nil { + t.Fatal("expected open health records action") + } + if findCompiledAction("com.apple.Health.OpenSearchIntent", "searchPhrase") == nil { + t.Fatal("expected open health search action") + } + if findCompiledAction("com.apple.Health.OpenSleepScheduleIntentV2", "AppIntentDescriptor") == nil { + t.Fatal("expected open sleep schedule action") + } + if findCompiledAction("com.apple.Health.OpenTabIntent", "target") == nil { + t.Fatal("expected open health tab action") + } if findCompiledAction("is.workflow.actions.health.workout.log", "WFWorkoutCaloriesQuantity") == nil { t.Fatal("expected log workout action") } + if findCompiledAction("is.workflow.actions.health.workout.log", "WFWorkoutDuration") == nil { + t.Fatal("expected log workout duration") + } + if findCompiledAction("is.workflow.actions.health.workout.log", "WFWorkoutDistanceQuantity") == nil { + t.Fatal("expected log workout distance") + } if findCompiledAction("is.workflow.actions.health.quantity.log", "WFCategorySampleEnumeration") == nil { t.Fatal("expected log health category action") } + if findCompiledAction("com.apple.ShortcutsActions.GetPhysicalActivity", "AppIntentDescriptor") == nil { + t.Fatal("expected get physical activity action") + } } func findCompiledAction(identifier string, requiredParam string) *ShortcutAction { diff --git a/tests/health.cherri b/tests/health.cherri index acf5ba49..beccdc66 100644 --- a/tests/health.cherri +++ b/tests/health.cherri @@ -3,9 +3,16 @@ const low = prompt("Diastolic", "Number") const steps = logHealthQuantity(qty(20, "count"), "Steps", CurrentDate) findHealthSamples(steps) +getHealthSampleDetail(steps, "Value") openHealthView("HKDisplayCategoryIdentifier_13", "Respiratory", "lungs.fill") openHealthData("HKDisplayTypeIdentifierForcedVitalCapacity", "Forced Vital Capacity", "lungs.fill") -logWorkout("Basketball", qty(500, "Cal")) +openHealthCategory("HKDisplayCategoryIdentifier_13", "Respiratory", "lungs.fill") +openHealthRecords("labs", "Lab Results", "cross.case.fill") +openHealthSearch("steps") +openSleepSchedule() +openHealthTab("summary", "Summary", "heart.text.square") +logWorkout("Basketball", qty(500, "Cal"), CurrentDate, qty(30, "min"), qty(1, "km")) logHealthCategory("Acne", "Present", nil, CurrentDate, CurrentDate) logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) logHealthQuantity(qty(72, "count/min"), "Heart Rate") +getPhysicalActivity() From aa96d79b33fb13234b064bd904467ae032b8b477 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Mon, 18 May 2026 13:52:42 +0800 Subject: [PATCH 3/8] Fix Health decompile round trip --- actions/health.cherri | 2 +- cherri_test.go | 43 +++++++++++++++++++++++++++++++++++ decompile.go | 52 +++++++++++++++++++++++++++++++------------ 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/actions/health.cherri b/actions/health.cherri index a087fb00..719a4777 100644 --- a/actions/health.cherri +++ b/actions/health.cherri @@ -305,7 +305,7 @@ action 'filter.health.quantity' findHealthSamples( action 'properties.health.quantity' getHealthSampleDetail( variable sample: 'WFInput', text detail: 'WFContentItemPropertyName' -) +): variable // [Doc]: [Workouts] Log Workout: Log a workout to Health. action 'health.workout.log' logWorkout( diff --git a/cherri_test.go b/cherri_test.go index 718eea39..e02076b4 100644 --- a/cherri_test.go +++ b/cherri_test.go @@ -7,6 +7,7 @@ package main import ( "fmt" "os" + "os/exec" "strings" "testing" "time" @@ -333,6 +334,48 @@ func TestHealthQuantityActions(t *testing.T) { } } +func TestHealthDecompileRoundTrip(t *testing.T) { + defer resetParser() + delete(args.Args, "import") + delete(args.Args, "output") + args.Args["no-ansi"] = "" + args.Args["skip-sign"] = "" + loadStandardActions() + + currentTest = "tests/health.cherri" + os.Args[1] = currentTest + compile() + + resetParser() + var decompiledPath = fmt.Sprintf("%s/health-decompiled.cherri", t.TempDir()) + args.Args["no-ansi"] = "" + args.Args["output"] = decompiledPath + decompile(importShortcut("tests/health_unsigned.shortcut")) + delete(args.Args, "output") + + var decompiled, readErr = os.ReadFile(decompiledPath) + if readErr != nil { + t.Fatalf("failed to read decompiled health file: %v", readErr) + } + var decompiledCode = string(decompiled) + var expectedLines = []string{ + `logHealthQuantity(qty(20, "count"), "Steps", CurrentDate)`, + `logWorkout("Basketball", qty(500, "Cal"), CurrentDate, qty(30, "min"), qty(1, "km"))`, + `logHealthCategory("Acne", "Present", nil, CurrentDate, CurrentDate)`, + } + for _, expectedLine := range expectedLines { + if !strings.Contains(decompiledCode, expectedLine) { + t.Fatalf("expected decompiled health code to contain %q, got:\n%s", expectedLine, decompiledCode) + } + } + + var cmd = exec.Command("go", "run", ".", decompiledPath, "--skip-sign", "--no-ansi") + var output, cmdErr = cmd.CombinedOutput() + if cmdErr != nil { + t.Fatalf("expected decompiled health code to compile, got %v:\n%s", cmdErr, output) + } +} + func findCompiledAction(identifier string, requiredParam string) *ShortcutAction { for i := range shortcut.WFWorkflowActions { var action = &shortcut.WFWorkflowActions[i] diff --git a/decompile.go b/decompile.go index b1169e56..959e345e 100644 --- a/decompile.go +++ b/decompile.go @@ -986,8 +986,11 @@ func decompValueObject(value map[string]interface{}) string { } return fmt.Sprintf("Ask: \"%s\"", value["Prompt"]) - case globals[ShortcutInput].variableType: - return ShortcutInput + } + for name, global := range globals { + if value["Type"] == global.variableType { + return name + } } return decompObjectValue(value) @@ -1286,33 +1289,54 @@ func actionIdentifierEnd(identifier string) string { } func decompActionArguments(actionCallCode *strings.Builder, matchedAction *actionDefinition, action *ShortcutAction) { - for i, param := range matchedAction.parameters { + type decompArgument struct { + value string + present bool + } + + var arguments []decompArgument + var lastPresent = -1 + for _, param := range matchedAction.parameters { if param.key == "" { continue } - var argValue string + var argument decompArgument if value, found := action.WFWorkflowActionParameters[param.key]; found { - argValue = decompValue(value) + argument.value = decompValue(value) + argument.present = true } else if !param.optional { - argValue = makeDefaultValue(param) + argument.value = makeDefaultValue(param) + argument.present = true } switch param.validType { case Integer: - if startsWith(Ask, argValue) { + if startsWith(Ask, argument.value) { break } - argValue = strings.Trim(argValue, "\"") + argument.value = strings.Trim(argument.value, "\"") } - if argValue != "" { - if i == 0 { - actionCallCode.WriteString(argValue) - } else { - actionCallCode.WriteString(fmt.Sprintf(", %s", argValue)) - } + arguments = append(arguments, argument) + if argument.present { + lastPresent = len(arguments) - 1 + } + } + + if lastPresent == -1 { + return + } + + for i, argument := range arguments[:lastPresent+1] { + if i > 0 { + actionCallCode.WriteString(", ") + } + if argument.value == "" { + actionCallCode.WriteString("nil") + continue } + actionCallCode.WriteString(argument.value) } } From 9466d612abc4690133bdf502301e35d0b43d8fd2 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Tue, 19 May 2026 18:44:12 +0800 Subject: [PATCH 4/8] Rename Health sample actions --- ReadMe.md | 6 +++--- actions/health.cherri | 8 ++++---- cherri_test.go | 4 ++-- tests/health.cherri | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index c13017fc..8b1b858d 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -112,10 +112,10 @@ const high = prompt("Systolic", "Number") const low = prompt("Diastolic", "Number") logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) -logHealthQuantity(qty(72, "count/min"), "Heart Rate") -const steps = logHealthQuantity(qty(20, "count"), "Steps", CurrentDate) +logHealthQuantitySample(qty(72, "count/min"), "Heart Rate") +const steps = logHealthQuantitySample(qty(20, "count"), "Steps", CurrentDate) findHealthSamples(steps) -logHealthCategory("Acne", "Present", nil, CurrentDate, CurrentDate) +logHealthCategorySample("Acne", "Present", nil, CurrentDate, CurrentDate) logWorkout("Basketball", qty(500, "Cal")) ``` diff --git a/actions/health.cherri b/actions/health.cherri index 719a4777..ecc67611 100644 --- a/actions/health.cherri +++ b/actions/health.cherri @@ -271,8 +271,8 @@ enum physicalActivity { 'Moving', } -// [Doc]: [Samples] Log Health Quantity: Log a quantity sample to Health. -action 'health.quantity.log' logHealthQuantity( +// [Doc]: [Samples] Log Health Quantity Sample: Log a quantity sample to Health. +action 'health.quantity.log' logHealthQuantitySample( #healthQuantityUnit quantity: 'WFQuantitySampleQuantity', healthQuantitySampleType sampleType: 'WFQuantitySampleType', variable ?date: 'WFQuantitySampleDate' @@ -287,8 +287,8 @@ action 'health.quantity.log' logBloodPressure( "WFQuantitySampleType": "Systolic Blood Pressure" } -// [Doc]: [Samples] Log Health Category: Log a category sample, such as symptoms or sleep analysis, to Health. -action 'health.quantity.log' logHealthCategory( +// [Doc]: [Samples] Log Health Category Sample: Log a category sample, such as symptoms or sleep analysis, to Health. +action 'health.quantity.log' logHealthCategorySample( healthCategorySampleType sampleType: 'WFQuantitySampleType', text value: 'WFCategorySampleEnumeration', text ?additionalValue: 'WFCategorySampleAdditionalEnumerationKey', diff --git a/cherri_test.go b/cherri_test.go index e02076b4..82bc2e9d 100644 --- a/cherri_test.go +++ b/cherri_test.go @@ -359,9 +359,9 @@ func TestHealthDecompileRoundTrip(t *testing.T) { } var decompiledCode = string(decompiled) var expectedLines = []string{ - `logHealthQuantity(qty(20, "count"), "Steps", CurrentDate)`, + `logHealthQuantitySample(qty(20, "count"), "Steps", CurrentDate)`, `logWorkout("Basketball", qty(500, "Cal"), CurrentDate, qty(30, "min"), qty(1, "km"))`, - `logHealthCategory("Acne", "Present", nil, CurrentDate, CurrentDate)`, + `logHealthCategorySample("Acne", "Present", nil, CurrentDate, CurrentDate)`, } for _, expectedLine := range expectedLines { if !strings.Contains(decompiledCode, expectedLine) { diff --git a/tests/health.cherri b/tests/health.cherri index beccdc66..707bf6fe 100644 --- a/tests/health.cherri +++ b/tests/health.cherri @@ -1,7 +1,7 @@ const high = prompt("Systolic", "Number") const low = prompt("Diastolic", "Number") -const steps = logHealthQuantity(qty(20, "count"), "Steps", CurrentDate) +const steps = logHealthQuantitySample(qty(20, "count"), "Steps", CurrentDate) findHealthSamples(steps) getHealthSampleDetail(steps, "Value") openHealthView("HKDisplayCategoryIdentifier_13", "Respiratory", "lungs.fill") @@ -12,7 +12,7 @@ openHealthSearch("steps") openSleepSchedule() openHealthTab("summary", "Summary", "heart.text.square") logWorkout("Basketball", qty(500, "Cal"), CurrentDate, qty(30, "min"), qty(1, "km")) -logHealthCategory("Acne", "Present", nil, CurrentDate, CurrentDate) +logHealthCategorySample("Acne", "Present", nil, CurrentDate, CurrentDate) logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) -logHealthQuantity(qty(72, "count/min"), "Heart Rate") +logHealthQuantitySample(qty(72, "count/min"), "Heart Rate") getPhysicalActivity() From 2fcf1c7e955d8295e41122cbe60d122d20ab7185 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sat, 23 May 2026 10:44:08 +0800 Subject: [PATCH 5/8] Address Health action review feedback --- ReadMe.md | 20 ------ actions_std.go | 20 +++--- cherri_test.go | 171 ------------------------------------------------- 3 files changed, 9 insertions(+), 202 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index 8b1b858d..7ae9cb6b 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -101,26 +101,6 @@ cherri file.cherri Run `cherri` without any arguments to see all options and usage. For development, use the `--debug` (or `-d`) option to print stack traces, debug information, and output a `.plist` file. -### Health samples and workouts - -Use the Health standard actions to log quantity samples, find samples, open Health views, and log workouts. Quantity values are written with `qty(value, "unit")`: - -```ruby -#include 'actions/health' - -const high = prompt("Systolic", "Number") -const low = prompt("Diastolic", "Number") - -logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) -logHealthQuantitySample(qty(72, "count/min"), "Heart Rate") -const steps = logHealthQuantitySample(qty(20, "count"), "Steps", CurrentDate) -findHealthSamples(steps) -logHealthCategorySample("Acne", "Present", nil, CurrentDate, CurrentDate) -logWorkout("Basketball", qty(500, "Cal")) -``` - -Imported Shortcuts that contain supported Health actions are converted to standard Health calls when possible. - ## Why another Shortcuts language? Because it's fun :) diff --git a/actions_std.go b/actions_std.go index 4a2db88b..beeef094 100644 --- a/actions_std.go +++ b/actions_std.go @@ -57,49 +57,49 @@ var getPhysicalActivityIntent = appIntent{ } var openHealthViewIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenViewIntent", } var openHealthDataTypeIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenDataTypeIntent", } var openHealthCategoryIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenCategoryIntent", } var openHealthRecordsIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenRecordsIntent", } var openHealthSearchIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenSearchIntent", } var openHealthSleepScheduleIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenSleepScheduleIntentV2", } var openHealthSleepScheduleLegacyIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenSleepScheduleIntent", } var openHealthTabIntent = appIntent{ - name: "健康", + name: "Health", bundleIdentifier: "com.apple.Health", appIntentIdentifier: "OpenTabIntent", } @@ -2313,16 +2313,14 @@ func decompTextParts(action *ShortcutAction) (arguments []string) { arguments = append(arguments, decompValue(action.WFWorkflowActionParameters["text"])) var glue string - var hasCustomSeparator bool if action.WFWorkflowActionParameters["WFTextSeparator"] != nil { glue = decompReferenceValue(action.WFWorkflowActionParameters["WFTextSeparator"]) } if action.WFWorkflowActionParameters["WFTextCustomSeparator"] != nil { glue = decompReferenceValue(action.WFWorkflowActionParameters["WFTextCustomSeparator"]) - hasCustomSeparator = true } - if glue != "" || hasCustomSeparator { + if glue != "" { arguments = append(arguments, fmt.Sprintf("\"%s\"", glueToChar(glue))) } diff --git a/cherri_test.go b/cherri_test.go index 82bc2e9d..c96d225d 100644 --- a/cherri_test.go +++ b/cherri_test.go @@ -204,136 +204,6 @@ func TestActionIdentifiers(t *testing.T) { resetParser() } -func TestRawActionQuantityFieldValue(t *testing.T) { - defer resetParser() - args.Args["no-ansi"] = "" - args.Args["skip-sign"] = "" - - currentTest = "tests/raw-action-health-quantity.cherri" - os.Args[1] = currentTest - compile() - - var action = shortcut.WFWorkflowActions[len(shortcut.WFWorkflowActions)-1] - if action.WFWorkflowActionIdentifier != "is.workflow.actions.health.quantity.log" { - t.Fatalf("expected health quantity log action, got %q", action.WFWorkflowActionIdentifier) - } - - var quantity, quantityOK = action.WFWorkflowActionParameters["WFQuantitySampleQuantity"].(WFQuantityFieldValue) - if !quantityOK { - t.Fatalf("expected quantity sample to be WFQuantityFieldValue, got %T", action.WFWorkflowActionParameters["WFQuantitySampleQuantity"]) - } - var magnitude, magnitudeOK = quantity.Value.Magnitude.(Value) - if !magnitudeOK { - t.Fatalf("expected quantity magnitude to be variable Value, got %T", quantity.Value.Magnitude) - } - if magnitude.OutputName != "high" || magnitude.Type != "ActionOutput" { - t.Fatalf("expected quantity magnitude to reference high, got %#v", magnitude) - } - - var additionalQuantity, additionalQuantityOK = action.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"].(WFQuantityFieldValue) - if !additionalQuantityOK { - t.Fatalf("expected additional quantity to be WFQuantityFieldValue, got %T", action.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"]) - } - var additionalMagnitude, additionalMagnitudeOK = additionalQuantity.Value.Magnitude.(Value) - if !additionalMagnitudeOK { - t.Fatalf("expected additional quantity magnitude to be variable Value, got %T", additionalQuantity.Value.Magnitude) - } - if additionalMagnitude.OutputName != "low" || additionalMagnitude.Type != "ActionOutput" { - t.Fatalf("expected additional quantity magnitude to reference low, got %#v", additionalMagnitude) - } -} - -func TestHealthQuantityActions(t *testing.T) { - defer resetParser() - args.Args["no-ansi"] = "" - args.Args["skip-sign"] = "" - loadStandardActions() - - currentTest = "tests/health.cherri" - os.Args[1] = currentTest - compile() - - if len(shortcut.WFWorkflowActions) < 7 { - t.Fatalf("expected health actions to compile, got %d actions", len(shortcut.WFWorkflowActions)) - } - - var bloodPressure = findCompiledAction("is.workflow.actions.health.quantity.log", "WFQuantitySampleAdditionalQuantity") - if bloodPressure == nil { - t.Fatal("expected blood pressure action") - } - if bloodPressure.WFWorkflowActionIdentifier != "is.workflow.actions.health.quantity.log" { - t.Fatalf("expected blood pressure action, got %q", bloodPressure.WFWorkflowActionIdentifier) - } - if sampleType := bloodPressure.WFWorkflowActionParameters["WFQuantitySampleType"]; sampleType != "Systolic Blood Pressure" { - t.Fatalf("expected blood pressure sample type, got %#v", sampleType) - } - if _, ok := bloodPressure.WFWorkflowActionParameters["WFQuantitySampleQuantity"].(WFQuantityFieldValue); !ok { - t.Fatalf("expected systolic quantity field, got %T", bloodPressure.WFWorkflowActionParameters["WFQuantitySampleQuantity"]) - } - if _, ok := bloodPressure.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"].(WFQuantityFieldValue); !ok { - t.Fatalf("expected diastolic quantity field, got %T", bloodPressure.WFWorkflowActionParameters["WFQuantitySampleAdditionalQuantity"]) - } - - var steps = findCompiledAction("is.workflow.actions.health.quantity.log", "WFQuantitySampleDate") - if steps == nil { - t.Fatal("expected dated steps action") - } - if sampleType := steps.WFWorkflowActionParameters["WFQuantitySampleType"]; sampleType != "Steps" { - t.Fatalf("expected steps sample type, got %#v", sampleType) - } - - var heartRate = findCompiledActionWithParamValue("is.workflow.actions.health.quantity.log", "WFQuantitySampleType", "Heart Rate") - if heartRate == nil { - t.Fatal("expected heart rate action") - } - if _, ok := heartRate.WFWorkflowActionParameters["WFQuantitySampleQuantity"].(WFQuantityFieldValue); !ok { - t.Fatalf("expected heart rate quantity field, got %T", heartRate.WFWorkflowActionParameters["WFQuantitySampleQuantity"]) - } - - if findCompiledAction("is.workflow.actions.filter.health.quantity", "WFContentItemInputParameter") == nil { - t.Fatal("expected find health samples action") - } - if findCompiledAction("is.workflow.actions.properties.health.quantity", "WFContentItemPropertyName") == nil { - t.Fatal("expected get health sample detail action") - } - if findCompiledAction("com.apple.Health.OpenViewIntent", "target") == nil { - t.Fatal("expected open health view action") - } - if findCompiledAction("com.apple.Health.OpenDataTypeIntent", "target") == nil { - t.Fatal("expected open health data action") - } - if findCompiledAction("com.apple.Health.OpenCategoryIntent", "target") == nil { - t.Fatal("expected open health category action") - } - if findCompiledAction("com.apple.Health.OpenRecordsIntent", "target") == nil { - t.Fatal("expected open health records action") - } - if findCompiledAction("com.apple.Health.OpenSearchIntent", "searchPhrase") == nil { - t.Fatal("expected open health search action") - } - if findCompiledAction("com.apple.Health.OpenSleepScheduleIntentV2", "AppIntentDescriptor") == nil { - t.Fatal("expected open sleep schedule action") - } - if findCompiledAction("com.apple.Health.OpenTabIntent", "target") == nil { - t.Fatal("expected open health tab action") - } - if findCompiledAction("is.workflow.actions.health.workout.log", "WFWorkoutCaloriesQuantity") == nil { - t.Fatal("expected log workout action") - } - if findCompiledAction("is.workflow.actions.health.workout.log", "WFWorkoutDuration") == nil { - t.Fatal("expected log workout duration") - } - if findCompiledAction("is.workflow.actions.health.workout.log", "WFWorkoutDistanceQuantity") == nil { - t.Fatal("expected log workout distance") - } - if findCompiledAction("is.workflow.actions.health.quantity.log", "WFCategorySampleEnumeration") == nil { - t.Fatal("expected log health category action") - } - if findCompiledAction("com.apple.ShortcutsActions.GetPhysicalActivity", "AppIntentDescriptor") == nil { - t.Fatal("expected get physical activity action") - } -} - func TestHealthDecompileRoundTrip(t *testing.T) { defer resetParser() delete(args.Args, "import") @@ -376,32 +246,6 @@ func TestHealthDecompileRoundTrip(t *testing.T) { } } -func findCompiledAction(identifier string, requiredParam string) *ShortcutAction { - for i := range shortcut.WFWorkflowActions { - var action = &shortcut.WFWorkflowActions[i] - if action.WFWorkflowActionIdentifier != identifier { - continue - } - if _, found := action.WFWorkflowActionParameters[requiredParam]; found { - return action - } - } - return nil -} - -func findCompiledActionWithParamValue(identifier string, param string, value any) *ShortcutAction { - for i := range shortcut.WFWorkflowActions { - var action = &shortcut.WFWorkflowActions[i] - if action.WFWorkflowActionIdentifier != identifier { - continue - } - if action.WFWorkflowActionParameters[param] == value { - return action - } - } - return nil -} - func TestDecompileRawActionQuantityFieldValue(t *testing.T) { defer resetParser() uuids = map[string]string{ @@ -469,21 +313,6 @@ func TestScoreActionParamsSkipsInvalidEnumValue(t *testing.T) { } } -func TestDecompileTextPartsKeepsEmptyCustomSeparator(t *testing.T) { - defer resetParser() - var arguments = decompTextParts(&ShortcutAction{ - WFWorkflowActionParameters: map[string]any{ - "text": "items", - "WFTextSeparator": "Custom", - "WFTextCustomSeparator": "", - }, - }) - - if len(arguments) != 2 || arguments[1] != `""` { - t.Fatalf("expected empty custom separator argument, got %#v", arguments) - } -} - func TestCapitalizeEmptyString(t *testing.T) { if got := capitalize(""); got != "" { t.Fatalf("expected empty string, got %q", got) From fef6fb1f4e8fc564902a73a46e44bce97bb807d4 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sat, 23 May 2026 10:55:30 +0800 Subject: [PATCH 6/8] Mark Health tests compile only --- tests/health.cherri | 3 +++ tests/raw-action-health-quantity.cherri | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/health.cherri b/tests/health.cherri index 707bf6fe..4972b570 100644 --- a/tests/health.cherri +++ b/tests/health.cherri @@ -1,3 +1,4 @@ +// compile-only: Health actions require device Health data and permissions. const high = prompt("Systolic", "Number") const low = prompt("Diastolic", "Number") @@ -16,3 +17,5 @@ logHealthCategorySample("Acne", "Present", nil, CurrentDate, CurrentDate) logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) logHealthQuantitySample(qty(72, "count/min"), "Heart Rate") getPhysicalActivity() + +show("✅ All tests passed") diff --git a/tests/raw-action-health-quantity.cherri b/tests/raw-action-health-quantity.cherri index 569f3393..153259c0 100644 --- a/tests/raw-action-health-quantity.cherri +++ b/tests/raw-action-health-quantity.cherri @@ -1,3 +1,4 @@ +// compile-only: Health quantity rawAction values require device Health permissions. const high = prompt("Systolic", "Number") const low = prompt("Diastolic", "Number") @@ -18,3 +19,5 @@ rawAction("is.workflow.actions.health.quantity.log", { }, "WFQuantitySampleType": "Systolic Blood Pressure" }) + +show("✅ All tests passed") From 2414105c9040a7926f8a9c125ae49582f22cc1d1 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sat, 23 May 2026 10:59:07 +0800 Subject: [PATCH 7/8] Expand Health Cherri compile coverage --- tests/health.cherri | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/health.cherri b/tests/health.cherri index 4972b570..180e5c74 100644 --- a/tests/health.cherri +++ b/tests/health.cherri @@ -6,15 +6,20 @@ const steps = logHealthQuantitySample(qty(20, "count"), "Steps", CurrentDate) findHealthSamples(steps) getHealthSampleDetail(steps, "Value") openHealthView("HKDisplayCategoryIdentifier_13", "Respiratory", "lungs.fill") +openHealthView("HKDisplayCategoryIdentifier_11") openHealthData("HKDisplayTypeIdentifierForcedVitalCapacity", "Forced Vital Capacity", "lungs.fill") openHealthCategory("HKDisplayCategoryIdentifier_13", "Respiratory", "lungs.fill") openHealthRecords("labs", "Lab Results", "cross.case.fill") openHealthSearch("steps") openSleepSchedule() +openSleepScheduleLegacy() openHealthTab("summary", "Summary", "heart.text.square") logWorkout("Basketball", qty(500, "Cal"), CurrentDate, qty(30, "min"), qty(1, "km")) +logWorkout("Running") logHealthCategorySample("Acne", "Present", nil, CurrentDate, CurrentDate) +logHealthCategorySample("Acne", "Not Present") logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg")) +logBloodPressure(qty(high, "mmHg"), qty(low, "mmHg"), CurrentDate) logHealthQuantitySample(qty(72, "count/min"), "Heart Rate") getPhysicalActivity() From d78b50be3356f151f4bb09516c504803d4d3b387 Mon Sep 17 00:00:00 2001 From: CorrectRoadH <29306285+CorrectRoadH@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:32:40 +0800 Subject: [PATCH 8/8] Revert unrelated fileRequest() change The fileRequest() body parameter change is unrelated to Health support; it has been moved to its own PR to keep this PR scoped to Health. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018Xvr9ByAkYxnM3SJjBw9T9 --- actions/web.cherri | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/web.cherri b/actions/web.cherri index 14140e0b..a8fc9629 100644 --- a/actions/web.cherri +++ b/actions/web.cherri @@ -136,7 +136,7 @@ action 'downloadurl' jsonRequest( action 'downloadurl' fileRequest( text url: 'WFURL', HTTPMethod ?method: 'WFHTTPMethod', - variable ?body: 'WFRequestVariable', + dictionary! ?body: 'WFRequestVariable', dictionary! ?headers: 'WFHTTPHeaders', ) { "WFHTTPBodyType": "File"